From db7d9a94a797b1af159de89287375cf8a06cbf92 Mon Sep 17 00:00:00 2001 From: Frank L <295432405+frank-1802@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:55:14 -0700 Subject: [PATCH 1/2] feat(telemetry): Add CSV export functionality for telemetry data - Introduces a dedicated `TelemetryExportService` for handling the export logic. - Enables users to export collected telemetry data as a CSV file. - Updates the System Resources Dialog to include an "Export CSV" button. - Implements localization for all new telemetry export messages. - Enhances numerical formatting across the desktop application using `LocalizationManager`. Refs: # --- .../askimo/ui/service/MessageExportService.kt | 2 +- .../ui/service/TelemetryExportService.kt | 157 ++++++++++++++++++ .../askimo/ui/shell/SystemResourcesDialog.kt | 20 ++- .../main/resources/i18n/messages.properties | 6 + .../resources/i18n/messages_de.properties | 6 + .../resources/i18n/messages_es.properties | 6 + .../resources/i18n/messages_fr.properties | 6 + .../resources/i18n/messages_ja_JP.properties | 6 + .../resources/i18n/messages_ko_KR.properties | 6 + .../resources/i18n/messages_pt_BR.properties | 6 + .../resources/i18n/messages_vi_VN.properties | 6 + .../resources/i18n/messages_zh_CN.properties | 6 + .../resources/i18n/messages_zh_TW.properties | 6 + .../src/main/kotlin/io/askimo/desktop/Main.kt | 25 +++ .../io/askimo/desktop/shell/TelemetryPanel.kt | 23 +-- .../kotlin/io/askimo/core/config/AppConfig.kt | 4 +- .../askimo/core/i18n/LocalizationManager.kt | 10 ++ 17 files changed, 283 insertions(+), 18 deletions(-) create mode 100644 desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/service/MessageExportService.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/service/MessageExportService.kt index 9b69357a..fd54672a 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/service/MessageExportService.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/service/MessageExportService.kt @@ -20,7 +20,7 @@ object MessageExportService { enum class ExportFormat { PDF, WORD } - private val COPYRIGHT = "Generated by Askimo · Copyright © ${Year.now().value} · https://askimo.io" + private val COPYRIGHT = "Generated by Askimo · Copyright © ${Year.now().value} · https://askimo.chat" /** * Exports [content] (markdown) to [targetFile] in the given [format]. diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt new file mode 100644 index 00000000..ae419b56 --- /dev/null +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt @@ -0,0 +1,157 @@ +/* SPDX-License-Identifier: AGPLv3 + * + * Copyright (c) 2026 Askimo + */ +package io.askimo.ui.service + +import io.askimo.core.i18n.LocalizationManager +import io.askimo.core.logging.logger +import io.askimo.core.telemetry.TelemetryMetrics +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.StringWriter +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** + * Exports a [TelemetryMetrics] snapshot to a ZIP file containing two CSVs: + * + * - `session-metrics.csv` — aggregated RAG and LLM summary metrics. + * - `model-token-usage.csv` — per-provider/model LLM call breakdown. + * + * CSV conventions: + * - UTF-8 encoding, comma delimiter, RFC 4180 quoting (all fields quoted). + * - English-only stable column headers for downstream tooling compatibility. + * - Numeric values formatted via [LocalizationManager] using the current user locale. + * - Decimal values use [LocalizationManager.formatDouble] with 2 fraction digits. + * - Empty rows are written (header only) when a section has no data. + * - Timestamps are ISO-8601 UTC. + */ +object TelemetryExportService { + + private val log = logger() + private val timestampFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'") + .withZone(ZoneOffset.UTC) + + /** + * Exports [metrics] to [targetZipFile] as a ZIP with two CSV entries. + * + * @param metrics Current telemetry snapshot (may have all-zero values). + * @param targetZipFile Destination file; parent directories are created if needed. + * @return [Result.success] on completion, [Result.failure] on any error. + */ + suspend fun export(metrics: TelemetryMetrics, targetZipFile: File): Result = withContext(Dispatchers.IO) { + runCatching { + targetZipFile.parentFile?.mkdirs() + val capturedAt = timestampFormatter.format(Instant.now()) + + ZipOutputStream(targetZipFile.outputStream().buffered()).use { zos -> + // ── session-metrics.csv ────────────────────────────────── + zos.putNextEntry(ZipEntry("session-metrics.csv")) + zos.write(buildSessionMetricsCsv(metrics, capturedAt).toByteArray(Charsets.UTF_8)) + zos.closeEntry() + + // ── model-token-usage.csv ──────────────────────────────── + zos.putNextEntry(ZipEntry("model-token-usage.csv")) + zos.write(buildModelTokenUsageCsv(metrics, capturedAt).toByteArray(Charsets.UTF_8)) + zos.closeEntry() + } + + log.info("Telemetry exported to {}", targetZipFile.absolutePath) + }.onFailure { e -> + log.error("Failed to export telemetry", e) + } + } + + // ── CSV builders ───────────────────────────────────────────────────────── + + private fun buildSessionMetricsCsv(metrics: TelemetryMetrics, capturedAt: String): String { + val sw = StringWriter() + sw.appendCsvLine( + "captured_at", + "metric_key", + "metric_label", + "unit", + "value", + ) + + // RAG metrics — only if any RAG data was collected + if (metrics.ragClassificationTotal > 0) { + sw.appendCsvLine(capturedAt, "rag_classification_total", "RAG Classification Total", "count", LocalizationManager.formatNumber(metrics.ragClassificationTotal)) + sw.appendCsvLine(capturedAt, "rag_triggered", "RAG Triggered", "count", LocalizationManager.formatNumber(metrics.ragTriggered)) + sw.appendCsvLine(capturedAt, "rag_skipped", "RAG Skipped", "count", LocalizationManager.formatNumber(metrics.ragSkipped)) + sw.appendCsvLine(capturedAt, "rag_triggered_percent", "RAG Triggered Percent", "%", LocalizationManager.formatDouble(metrics.ragTriggeredPercent, 2)) + sw.appendCsvLine(capturedAt, "rag_avg_classification_time_ms", "RAG Avg Classification Time", "ms", LocalizationManager.formatNumber(metrics.ragAvgClassificationTimeMs)) + } + + if (metrics.ragRetrievalTotal > 0) { + sw.appendCsvLine(capturedAt, "rag_retrieval_total", "RAG Retrieval Total", "count", LocalizationManager.formatNumber(metrics.ragRetrievalTotal)) + sw.appendCsvLine(capturedAt, "rag_avg_retrieval_time_ms", "RAG Avg Retrieval Time", "ms", LocalizationManager.formatNumber(metrics.ragAvgRetrievalTimeMs)) + sw.appendCsvLine(capturedAt, "rag_avg_chunks_retrieved", "RAG Avg Chunks Retrieved", "count", LocalizationManager.formatDouble(metrics.ragAvgChunksRetrieved, 2)) + } + + // LLM summary metrics + if (metrics.llmCallsByProvider.isNotEmpty()) { + val totalCalls = metrics.llmCallsByProvider.values.sum() + val totalTokens = metrics.llmTokensByProvider.values.sum() + val totalErrors = metrics.llmErrorsByProvider.values.sum() + sw.appendCsvLine(capturedAt, "llm_total_calls", "LLM Total Calls", "count", LocalizationManager.formatNumber(totalCalls)) + sw.appendCsvLine(capturedAt, "llm_total_tokens", "LLM Total Tokens", "tokens", LocalizationManager.formatNumber(totalTokens)) + sw.appendCsvLine(capturedAt, "llm_total_errors", "LLM Total Errors", "count", LocalizationManager.formatNumber(totalErrors)) + } + + return sw.toString() + } + + private fun buildModelTokenUsageCsv(metrics: TelemetryMetrics, capturedAt: String): String { + val sw = StringWriter() + sw.appendCsvLine( + "captured_at", + "provider", + "model", + "calls", + "tokens", + "avg_duration_ms", + "errors", + ) + + metrics.llmCallsByProvider.forEach { (providerModel, calls) -> + val parts = providerModel.split(":", limit = 2) + val provider = parts.getOrElse(0) { providerModel } + val model = parts.getOrElse(1) { "" } + val tokens = metrics.llmTokensByProvider[providerModel] ?: 0L + val avgDurationMs = metrics.llmAvgDurationMsByProvider[providerModel] ?: 0L + val errors = metrics.llmErrorsByProvider[providerModel] ?: 0 + + sw.appendCsvLine( + capturedAt, + provider, + model, + LocalizationManager.formatNumber(calls), + LocalizationManager.formatNumber(tokens), + LocalizationManager.formatNumber(avgDurationMs), + LocalizationManager.formatNumber(errors), + ) + } + + return sw.toString() + } + + // ── CSV helpers ─────────────────────────────────────────────────────────── + + /** + * Appends a RFC 4180 CSV row: all fields are quoted, internal quotes are doubled, + * newlines within values are escaped as \n. + */ + private fun StringWriter.appendCsvLine(vararg fields: Any) { + append(fields.joinToString(",") { field -> + "\"${field.toString().replace("\"", "\"\"").replace("\n", "\\n").replace("\r", "")}\"" + }) + append("\n") + } +} + diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SystemResourcesDialog.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SystemResourcesDialog.kt index f2ff78cd..025baea2 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SystemResourcesDialog.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/shell/SystemResourcesDialog.kt @@ -45,10 +45,15 @@ import org.koin.java.KoinJavaComponent.get * The [telemetryContent] slot is intentionally left open so that each module * can pass its own `telemetryPanel` composable (community vs. Pro flavours * differ in implementation but share this outer shell). + * + * @param onExportTelemetry Called when the user clicks "Export CSV". The caller + * is responsible for picking a save path and invoking the telemetry export service. + * Pass `null` to hide the export button (e.g. when no telemetry data exists). */ @Composable fun systemResourcesDialog( onDismiss: () -> Unit, + onExportTelemetry: (() -> Unit)?, telemetryContent: @Composable () -> Unit, ) { val monitor = remember { get(SystemResourceMonitor::class.java) } @@ -140,12 +145,19 @@ fun systemResourcesDialog( Spacer(Modifier.height(Spacing.large)) - // ── Close button ───────────────────────────────────────── - secondaryButton( - onClick = onDismiss, + // ── Action buttons ──────────────────────────────────────── + Row( modifier = Modifier.align(Alignment.End), + horizontalArrangement = Arrangement.spacedBy(Spacing.small), ) { - Text(stringResource("action.close")) + if (onExportTelemetry != null) { + secondaryButton(onClick = onExportTelemetry) { + Text(stringResource("telemetry.export.csv")) + } + } + secondaryButton(onClick = onDismiss) { + Text(stringResource("action.close")) + } } } } diff --git a/desktop-shared/src/main/resources/i18n/messages.properties b/desktop-shared/src/main/resources/i18n/messages.properties index a40e53c9..616bcfeb 100644 --- a/desktop-shared/src/main/resources/i18n/messages.properties +++ b/desktop-shared/src/main/resources/i18n/messages.properties @@ -915,6 +915,12 @@ system.diagnostics.jvm.memory=JVM Memory (used / total) system.diagnostics.cpu.usage=CPU Usage system.diagnostics.telemetry=Telemetry +# Telemetry export +telemetry.export.csv=Export CSV +telemetry.export.dialog.title=Export Telemetry as ZIP +telemetry.export.error.title=Export Failed +telemetry.export.error.message=Failed to export telemetry data: {0} + # Telemetry telemetry.reset=Reset telemetry data telemetry.title=📊 Session Metrics diff --git a/desktop-shared/src/main/resources/i18n/messages_de.properties b/desktop-shared/src/main/resources/i18n/messages_de.properties index 332b52fe..3fcfb029 100644 --- a/desktop-shared/src/main/resources/i18n/messages_de.properties +++ b/desktop-shared/src/main/resources/i18n/messages_de.properties @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM-Speicher (verwendet / gesamt) system.diagnostics.cpu.usage=CPU-Auslastung system.diagnostics.telemetry=Telemetrie +# Telemetry export +telemetry.export.csv=CSV exportieren +telemetry.export.dialog.title=Telemetrie als ZIP exportieren +telemetry.export.error.title=Export fehlgeschlagen +telemetry.export.error.message=Telemetriedaten konnten nicht exportiert werden: {0} + # Telemetry telemetry.reset=Telemetriedaten zurücksetzen telemetry.title=📊 Sitzungsmetriken diff --git a/desktop-shared/src/main/resources/i18n/messages_es.properties b/desktop-shared/src/main/resources/i18n/messages_es.properties index 65176533..2061ad86 100644 --- a/desktop-shared/src/main/resources/i18n/messages_es.properties +++ b/desktop-shared/src/main/resources/i18n/messages_es.properties @@ -891,6 +891,12 @@ system.diagnostics.jvm.memory=Memoria JVM (usada / total) system.diagnostics.cpu.usage=Uso de CPU system.diagnostics.telemetry=Telemetría +# Telemetry export +telemetry.export.csv=Exportar CSV +telemetry.export.dialog.title=Exportar Telemetría como ZIP +telemetry.export.error.title=Error de Exportación +telemetry.export.error.message=Error al exportar datos de telemetría: {0} + # Telemetry telemetry.reset=Restablecer datos de telemetría telemetry.title=📊 Métricas de sesión diff --git a/desktop-shared/src/main/resources/i18n/messages_fr.properties b/desktop-shared/src/main/resources/i18n/messages_fr.properties index 4b287c24..be90d0f0 100644 --- a/desktop-shared/src/main/resources/i18n/messages_fr.properties +++ b/desktop-shared/src/main/resources/i18n/messages_fr.properties @@ -893,6 +893,12 @@ system.diagnostics.jvm.memory=Mémoire JVM (utilisée / totale) system.diagnostics.cpu.usage=Utilisation du CPU system.diagnostics.telemetry=Télémétrie +# Telemetry export +telemetry.export.csv=Exporter CSV +telemetry.export.dialog.title=Exporter la télémétrie en ZIP +telemetry.export.error.title=Échec de l'exportation +telemetry.export.error.message=Échec de l'exportation des données de télémétrie : {0} + # Telemetry telemetry.reset=Réinitialiser les données de télémétrie telemetry.title=📊 Métriques de session diff --git a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties index 477e8b47..323dfe9a 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ja_JP.properties @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM メモリ (使用済み / 合計) system.diagnostics.cpu.usage=CPU 使用率 system.diagnostics.telemetry=テレメトリ +# Telemetry export +telemetry.export.csv=CSV エクスポート +telemetry.export.dialog.title=テレメトリを ZIP としてエクスポート +telemetry.export.error.title=エクスポート失敗 +telemetry.export.error.message=テレメトリデータのエクスポートに失敗しました: {0} + # Telemetry telemetry.reset=テレメトリデータをリセット telemetry.title=📊 セッション指標 diff --git a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties index d06c6a0e..858dc025 100644 --- a/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_ko_KR.properties @@ -891,6 +891,12 @@ system.diagnostics.jvm.memory=JVM 메모리 (사용됨 / 전체) system.diagnostics.cpu.usage=CPU 사용량 system.diagnostics.telemetry=텔레메트리 +# Telemetry export +telemetry.export.csv=CSV 내보내기 +telemetry.export.dialog.title=텔레메트리를 ZIP으로 내보내기 +telemetry.export.error.title=내보내기 실패 +telemetry.export.error.message=텔레메트리 데이터 내보내기 실패: {0} + # Telemetry telemetry.reset=텔레메트리 데이터 재설정 telemetry.title=📊 세션 지표 diff --git a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties index 842f088c..725a9b35 100644 --- a/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties +++ b/desktop-shared/src/main/resources/i18n/messages_pt_BR.properties @@ -893,6 +893,12 @@ system.diagnostics.jvm.memory=Memória JVM (usada / total) system.diagnostics.cpu.usage=Uso da CPU system.diagnostics.telemetry=Telemetria +# Telemetry export +telemetry.export.csv=Exportar CSV +telemetry.export.dialog.title=Exportar Telemetria como ZIP +telemetry.export.error.title=Falha na Exportação +telemetry.export.error.message=Falha ao exportar dados de telemetria: {0} + # Telemetry telemetry.reset=Redefinir dados de telemetria telemetry.title=📊 Métricas da sessão diff --git a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties index 23b07da5..c9868a30 100644 --- a/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_vi_VN.properties @@ -891,6 +891,12 @@ system.diagnostics.jvm.memory=Bộ nhớ JVM (đã dùng / tổng) system.diagnostics.cpu.usage=Mức sử dụng CPU system.diagnostics.telemetry=Đo từ xa +# Telemetry export +telemetry.export.csv=Xuất CSV +telemetry.export.dialog.title=Xuất Dữ Liệu Đo Lường thành ZIP +telemetry.export.error.title=Xuất Thất Bại +telemetry.export.error.message=Không thể xuất dữ liệu đo lường: {0} + # Telemetry telemetry.reset=Đặt lại dữ liệu telemetry telemetry.title=📊 Chỉ số phiên làm việc diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties index 453e0091..a18551d3 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_CN.properties @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM 内存 (已用 / 总计) system.diagnostics.cpu.usage=CPU 使用率 system.diagnostics.telemetry=遥测 +# Telemetry export +telemetry.export.csv=导出 CSV +telemetry.export.dialog.title=将遥测数据导出为 ZIP +telemetry.export.error.title=导出失败 +telemetry.export.error.message=导出遥测数据失败:{0} + # Telemetry telemetry.reset=重置遥测数据 telemetry.title=📊 会话指标 diff --git a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties index b541a668..dcdc4f82 100644 --- a/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties +++ b/desktop-shared/src/main/resources/i18n/messages_zh_TW.properties @@ -892,6 +892,12 @@ system.diagnostics.jvm.memory=JVM 記憶體 (已使用 / 總計) system.diagnostics.cpu.usage=CPU 使用率 system.diagnostics.telemetry=遙測 +# Telemetry export +telemetry.export.csv=匯出 CSV +telemetry.export.dialog.title=將遙測資料匯出為 ZIP +telemetry.export.error.title=匯出失敗 +telemetry.export.error.message=無法匯出遙測資料:{0} + # Telemetry telemetry.reset=重設遙測資料 telemetry.title=📊 工作階段指標 diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index a05d08f6..3d01bfda 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -101,6 +101,7 @@ import io.askimo.desktop.shell.telemetryPanel import io.askimo.desktop.user.userProfileDialog import io.askimo.ui.chat.ChatViewModel import io.askimo.ui.chat.chatView +import io.askimo.ui.service.TelemetryExportService import io.askimo.ui.common.components.dangerButton import io.askimo.ui.common.components.primaryButton import io.askimo.ui.common.components.secondaryButton @@ -1404,6 +1405,30 @@ fun app(frameWindowScope: FrameWindowScope? = null, windowState: WindowState? = val metrics by appContext.telemetry.metricsFlow.collectAsState() systemResourcesDialog( onDismiss = { showSystemDiagnosticsDialog = false }, + onExportTelemetry = { + scope.launch { + val timestamp = withContext(Dispatchers.IO) { + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")) + } + val targetFile = FileDialogUtils.pickSavePath( + suggestedName = "telemetry_$timestamp", + extension = "zip", + title = LocalizationManager.getString("telemetry.export.dialog.title"), + ) ?: return@launch + + val result = TelemetryExportService.export(metrics, targetFile) + if (result.isFailure) { + errorDialogState = ErrorDialogState( + show = true, + title = LocalizationManager.getString("telemetry.export.error.title"), + message = LocalizationManager.getString( + "telemetry.export.error.message", + result.exceptionOrNull()?.message ?: "Unknown error", + ), + ) + } + } + }, telemetryContent = { telemetryPanel(metrics = metrics, maxHeight = 480.dp) }, ) } diff --git a/desktop/src/main/kotlin/io/askimo/desktop/shell/TelemetryPanel.kt b/desktop/src/main/kotlin/io/askimo/desktop/shell/TelemetryPanel.kt index f5cce6f0..30338951 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/shell/TelemetryPanel.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/shell/TelemetryPanel.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import io.askimo.core.context.AppContext +import io.askimo.core.i18n.LocalizationManager import io.askimo.core.telemetry.TelemetryMetrics import io.askimo.ui.common.i18n.stringResource import io.askimo.ui.common.theme.AppComponents @@ -138,17 +139,17 @@ internal fun telemetryPanel(metrics: TelemetryMetrics, maxHeight: Dp) { ) { telemetryStat( label = stringResource("telemetry.rag.total.queries"), - value = metrics.ragClassificationTotal.toString(), + value = LocalizationManager.formatNumber(metrics.ragClassificationTotal), modifier = Modifier.weight(1f), ) telemetryStat( label = stringResource("telemetry.rag.triggered.label"), - value = "${metrics.ragTriggered} (${String.format("%.0f", metrics.ragTriggeredPercent)}%)", + value = "${LocalizationManager.formatNumber(metrics.ragTriggered)} (${LocalizationManager.formatDouble(metrics.ragTriggeredPercent, 0)}%)", modifier = Modifier.weight(1f), ) telemetryStat( label = stringResource("telemetry.rag.skipped.label"), - value = metrics.ragSkipped.toString(), + value = LocalizationManager.formatNumber(metrics.ragSkipped), modifier = Modifier.weight(1f), ) } @@ -160,7 +161,7 @@ internal fun telemetryPanel(metrics: TelemetryMetrics, maxHeight: Dp) { ) { telemetryMetricCard( label = stringResource("telemetry.rag.efficiency"), - value = "${String.format("%.0f", metrics.ragTriggeredPercent)}%", + value = "${LocalizationManager.formatDouble(metrics.ragTriggeredPercent, 0)}%", subtitle = stringResource("telemetry.rag.triggered", metrics.ragTriggered, metrics.ragClassificationTotal), modifier = Modifier.weight(1f), ) @@ -176,7 +177,7 @@ internal fun telemetryPanel(metrics: TelemetryMetrics, maxHeight: Dp) { label = stringResource("telemetry.retrieval"), value = formatDuration(metrics.ragAvgRetrievalTimeMs), valueTooltip = formatDurationDetailed(metrics.ragAvgRetrievalTimeMs), - subtitle = stringResource("telemetry.retrieval.chunks", String.format("%.1f", metrics.ragAvgChunksRetrieved)), + subtitle = stringResource("telemetry.retrieval.chunks", LocalizationManager.formatDouble(metrics.ragAvgChunksRetrieved, 1)), modifier = Modifier.weight(1f), ) } @@ -255,10 +256,10 @@ internal fun telemetryPanel(metrics: TelemetryMetrics, maxHeight: Dp) { llmTableRow( provider = stringResource("telemetry.llm.col.total"), model = "", - calls = totalCalls.toString(), - tokens = totalTokens.toString(), + calls = LocalizationManager.formatNumber(totalCalls), + tokens = LocalizationManager.formatNumber(totalTokens), avgDuration = "", - errors = if (totalErrors > 0) totalErrors.toString() else "—", + errors = if (totalErrors > 0) LocalizationManager.formatNumber(totalErrors) else "—", isHeader = true, errorsIsError = totalErrors > 0, ) @@ -361,15 +362,15 @@ private fun llmTableDataRow(row: LlmRow) { ) { Text(text = row.provider, style = style, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(COL_PROVIDER), maxLines = 1, overflow = TextOverflow.Ellipsis) Text(text = row.model, style = style, color = secondary, modifier = Modifier.weight(COL_MODEL), maxLines = 1, overflow = TextOverflow.Ellipsis) - Text(text = row.calls.toString(), style = style, color = secondary, modifier = Modifier.weight(COL_CALLS), maxLines = 1) - Text(text = row.tokens.toString(), style = style, color = secondary, modifier = Modifier.weight(COL_TOKENS), maxLines = 1) + Text(text = LocalizationManager.formatNumber(row.calls), style = style, color = secondary, modifier = Modifier.weight(COL_CALLS), maxLines = 1) + Text(text = LocalizationManager.formatNumber(row.tokens), style = style, color = secondary, modifier = Modifier.weight(COL_TOKENS), maxLines = 1) Box(modifier = Modifier.weight(COL_DURATION)) { themedTooltip(text = formatDurationDetailed(row.avgDurationMs)) { Text(text = formatDuration(row.avgDurationMs), style = style, color = secondary, maxLines = 1) } } Text( - text = if (row.errors > 0) row.errors.toString() else "—", + text = if (row.errors > 0) LocalizationManager.formatNumber(row.errors) else "—", style = style, color = if (row.errors > 0) MaterialTheme.colorScheme.error else secondary, modifier = Modifier.weight(COL_ERRORS), diff --git a/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt b/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt index 27b7e56c..3be98a5a 100644 --- a/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt +++ b/shared/src/main/kotlin/io/askimo/core/config/AppConfig.kt @@ -1050,8 +1050,8 @@ object AppConfig { val models = ModelsConfig( timeouts = ModelTimeoutsConfig( - utilityModelTimeoutSeconds = envLong("ASKIMO_UTILITY_MODEL_TIMEOUT", 45L), - defaultModelTimeoutSeconds = envLong("ASKIMO_DEFAULT_MODEL_TIMEOUT", 300L), + utilityModelTimeoutSeconds = envLong("ASKIMO_UTILITY_MODEL_TIMEOUT", 600L), + defaultModelTimeoutSeconds = envLong("ASKIMO_DEFAULT_MODEL_TIMEOUT", 600L), ), anthropic = ProviderModelConfig( utilityModel = env("ASKIMO_ANTHROPIC_UTILITY_MODEL", ""), diff --git a/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt b/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt index 21d8ae8d..04feec60 100644 --- a/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt +++ b/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt @@ -188,6 +188,16 @@ object LocalizationManager { fun formatNumber(value: Int): String = NumberFormat.getNumberInstance(currentLocale).format(value) + /** + * Formats a decimal number using locale-aware grouping and fraction separators. + * e.g. 3.14159 with fractionDigits=2 → "3.14" (en-US), "3,14" (de) + */ + fun formatDouble(value: Double, fractionDigits: Int = 2): String = + NumberFormat.getNumberInstance(currentLocale).apply { + minimumFractionDigits = fractionDigits + maximumFractionDigits = fractionDigits + }.format(value) + /** * Load properties file for the current locale with UTF-8 encoding. * Supports both language_country (ja_JP) and language-only (ja) formats. From b0f982cca48bdcb6a47f6ac0a79a8c80f5f6dd86 Mon Sep 17 00:00:00 2001 From: Frank L <295432405+frank-1802@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:06:39 -0700 Subject: [PATCH 2/2] Format code --- .../io/askimo/ui/service/TelemetryExportService.kt | 9 +++++---- desktop/src/main/kotlin/io/askimo/desktop/Main.kt | 2 +- .../kotlin/io/askimo/core/i18n/LocalizationManager.kt | 9 ++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt b/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt index ae419b56..c242b26f 100644 --- a/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt +++ b/desktop-shared/src/main/kotlin/io/askimo/ui/service/TelemetryExportService.kt @@ -148,10 +148,11 @@ object TelemetryExportService { * newlines within values are escaped as \n. */ private fun StringWriter.appendCsvLine(vararg fields: Any) { - append(fields.joinToString(",") { field -> - "\"${field.toString().replace("\"", "\"\"").replace("\n", "\\n").replace("\r", "")}\"" - }) + append( + fields.joinToString(",") { field -> + "\"${field.toString().replace("\"", "\"\"").replace("\n", "\\n").replace("\r", "")}\"" + }, + ) append("\n") } } - diff --git a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt index 3d01bfda..1ffd3ed5 100644 --- a/desktop/src/main/kotlin/io/askimo/desktop/Main.kt +++ b/desktop/src/main/kotlin/io/askimo/desktop/Main.kt @@ -101,7 +101,6 @@ import io.askimo.desktop.shell.telemetryPanel import io.askimo.desktop.user.userProfileDialog import io.askimo.ui.chat.ChatViewModel import io.askimo.ui.chat.chatView -import io.askimo.ui.service.TelemetryExportService import io.askimo.ui.common.components.dangerButton import io.askimo.ui.common.components.primaryButton import io.askimo.ui.common.components.secondaryButton @@ -138,6 +137,7 @@ import io.askimo.ui.onboarding.onboardingWizardDialog import io.askimo.ui.plan.PlansViewModel import io.askimo.ui.plan.planDetailView import io.askimo.ui.plan.plansGalleryView +import io.askimo.ui.service.TelemetryExportService import io.askimo.ui.session.SessionManager import io.askimo.ui.session.SessionsViewModel import io.askimo.ui.session.command.DeleteSessionFromProjectCommand diff --git a/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt b/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt index 04feec60..4404af09 100644 --- a/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt +++ b/shared/src/main/kotlin/io/askimo/core/i18n/LocalizationManager.kt @@ -192,11 +192,10 @@ object LocalizationManager { * Formats a decimal number using locale-aware grouping and fraction separators. * e.g. 3.14159 with fractionDigits=2 → "3.14" (en-US), "3,14" (de) */ - fun formatDouble(value: Double, fractionDigits: Int = 2): String = - NumberFormat.getNumberInstance(currentLocale).apply { - minimumFractionDigits = fractionDigits - maximumFractionDigits = fractionDigits - }.format(value) + fun formatDouble(value: Double, fractionDigits: Int = 2): String = NumberFormat.getNumberInstance(currentLocale).apply { + minimumFractionDigits = fractionDigits + maximumFractionDigits = fractionDigits + }.format(value) /** * Load properties file for the current locale with UTF-8 encoding.