From dac8a6b1b2b05d9956a9fda5ea8fbc0ce3694efb Mon Sep 17 00:00:00 2001 From: olddonkey Date: Mon, 17 Aug 2026 10:08:49 -0700 Subject: [PATCH 1/2] fix(gui): repair log tooltip encoding --- gui/src/pages/Logs.tsx | 51 +--------------------- gui/src/pages/logs-model-title.ts | 20 +++++++++ gui/src/pages/logs-token-title.ts | 56 ++++++++++++++++++++++++ gui/tests/logs-model-title.test.ts | 25 +++++++++++ gui/tests/logs-token-title.test.ts | 35 +++++++++++++++ gui/tests/text-encoding-hygiene.test.ts | 57 +++++++++++++++++++++++++ 6 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 gui/src/pages/logs-model-title.ts create mode 100644 gui/src/pages/logs-token-title.ts create mode 100644 gui/tests/logs-model-title.test.ts create mode 100644 gui/tests/logs-token-title.test.ts create mode 100644 gui/tests/text-encoding-hygiene.test.ts diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 1ec4d0a972..353131a304 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -15,7 +15,9 @@ import Debug from "./Debug"; import type { LogsTab } from "./logs-tab-keydown"; import { logsTabKeyDown, readTabFromHash, selectLogsTab } from "./logs-tab-keydown"; +import { modelTitle } from "./logs-model-title"; import { speedLabel } from "./logs-speed-label"; +import { cacheSplit, isCursorUsageProvider, tokensTitle } from "./logs-token-title"; import type { LogSurface, LogSurfaceFilter } from "./logs-surface-filter"; import { logMatchesSurface } from "./logs-surface-filter"; import { @@ -177,30 +179,6 @@ function validCachedLogs(cached: LogEntry[] | null): LogEntry[] | null { return cached; } -function isCursorUsageProvider(provider: string): boolean { - return provider === "cursor" || provider.startsWith("cursor-"); -} - -function tokensTitle(log: LogEntry, t: TFn): string | undefined { - if (!log.usage) return undefined; - const split = cacheSplit(log); - const parts = [ - `${t("logs.tokens.input")}=${log.usage.inputTokens}`, - `${t("logs.tokens.output")}=${log.usage.outputTokens}`, - ]; - if (split.read !== undefined) parts.push(`${t("logs.tokens.cacheRead")}=${split.read}`); - if (split.write !== undefined) parts.push(`${t("logs.tokens.cacheWrite")}=${split.write}`); - if (typeof log.usage.contextTotalTokens === "number") { - parts.push(`${t("logs.tokens.contextTotal")}=${log.usage.contextTotalTokens}`); - } - if (typeof log.usage.reasoningOutputTokens === "number") parts.push(`${t("logs.tokens.reasoning")}=${log.usage.reasoningOutputTokens}`); - if (log.usageStatus === "estimated") parts.push(t("logs.tokens.estimatedNote")); - if (log.usageStatus === "estimated" && split.read === undefined && split.write === undefined) { - parts.push(t(isCursorUsageProvider(log.provider) ? "logs.tokens.noCacheCursorNote" : "logs.tokens.noCacheNote")); - } - return parts.join(" \xC2\xB7 "); -} - function displayTokenTotal(log: LogEntry): number | undefined { if (!log.usage) return typeof log.totalTokens === "number" ? log.totalTokens : undefined; // inputTokens is inclusive of cache read/write (canonical convention, devlog 070); @@ -227,19 +205,6 @@ function displayContextTokenTotal(log: LogEntry): number | undefined { return Math.max(base ?? 0, contextTotal) || undefined; } -/** Cache read/write split; recovers reads from legacy rows that stored read+write combined. */ -function cacheSplit(log: LogEntry): { read?: number; write?: number } { - const u = log.usage; - if (!u) return {}; - const write = typeof u.cacheCreationInputTokens === "number" ? u.cacheCreationInputTokens : undefined; - const read = typeof u.cacheReadInputTokens === "number" - ? u.cacheReadInputTokens - : typeof u.cachedInputTokens === "number" && write !== undefined - ? Math.max(0, u.cachedInputTokens - write) - : u.cachedInputTokens; - return { read, write }; -} - interface ReasoningLogFields { requestedEffort?: string; effectiveEffort?: string; @@ -376,18 +341,6 @@ function formatLogDateTime(ts: number, localeTag?: string, timeZone?: string): s return `${date} ${time}`; } -function modelTitle(log: LogEntry): string { - const details = [ - `model=${log.model}`, - log.resolvedModel ? `resolved=${log.resolvedModel}` : undefined, - log.requestedServiceTier ? `requestedTier=${log.requestedServiceTier}` : undefined, - log.configuredServiceTier ? `configuredTier=${log.configuredServiceTier}` : undefined, - log.responseServiceTier ? `responseTier=${log.responseServiceTier}` : undefined, - log.modelSupportsServiceTier !== undefined ? `supportsTier=${log.modelSupportsServiceTier}` : undefined, - ].filter(Boolean); - return details.join(" \xC2\xB7 "); -} - function summarizeFilteredLogs(entries: LogEntry[]): { requests: number; totalTokens: number; diff --git a/gui/src/pages/logs-model-title.ts b/gui/src/pages/logs-model-title.ts new file mode 100644 index 0000000000..cccb80c5f3 --- /dev/null +++ b/gui/src/pages/logs-model-title.ts @@ -0,0 +1,20 @@ +export interface ModelTitleEntry { + model: string; + resolvedModel?: string; + requestedServiceTier?: string; + configuredServiceTier?: string; + responseServiceTier?: string; + modelSupportsServiceTier?: boolean; +} + +export function modelTitle(log: ModelTitleEntry): string { + const details = [ + `model=${log.model}`, + log.resolvedModel ? `resolved=${log.resolvedModel}` : undefined, + log.requestedServiceTier ? `requestedTier=${log.requestedServiceTier}` : undefined, + log.configuredServiceTier ? `configuredTier=${log.configuredServiceTier}` : undefined, + log.responseServiceTier ? `responseTier=${log.responseServiceTier}` : undefined, + log.modelSupportsServiceTier !== undefined ? `supportsTier=${log.modelSupportsServiceTier}` : undefined, + ].filter(Boolean); + return details.join(" \u00B7 "); +} diff --git a/gui/src/pages/logs-token-title.ts b/gui/src/pages/logs-token-title.ts new file mode 100644 index 0000000000..4d39c83637 --- /dev/null +++ b/gui/src/pages/logs-token-title.ts @@ -0,0 +1,56 @@ +import type { TFn } from "../i18n/shared"; + +interface TokenUsageBreakdown { + inputTokens: number; + outputTokens: number; + contextTotalTokens?: number; + cachedInputTokens?: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + reasoningOutputTokens?: number; +} + +export interface TokenTitleEntry { + provider: string; + usageStatus?: string; + usage?: TokenUsageBreakdown; +} + +export function isCursorUsageProvider(provider: string): boolean { + return provider === "cursor" || provider.startsWith("cursor-"); +} + +/** Cache read/write split; recovers reads from legacy rows that stored read+write combined. */ +export function cacheSplit(log: TokenTitleEntry): { read?: number; write?: number } { + const u = log.usage; + if (!u) return {}; + const write = typeof u.cacheCreationInputTokens === "number" ? u.cacheCreationInputTokens : undefined; + const read = typeof u.cacheReadInputTokens === "number" + ? u.cacheReadInputTokens + : typeof u.cachedInputTokens === "number" && write !== undefined + ? Math.max(0, u.cachedInputTokens - write) + : u.cachedInputTokens; + return { read, write }; +} + +export function tokensTitle(log: TokenTitleEntry, t: TFn): string | undefined { + if (!log.usage) return undefined; + const split = cacheSplit(log); + const parts = [ + `${t("logs.tokens.input")}=${log.usage.inputTokens}`, + `${t("logs.tokens.output")}=${log.usage.outputTokens}`, + ]; + if (split.read !== undefined) parts.push(`${t("logs.tokens.cacheRead")}=${split.read}`); + if (split.write !== undefined) parts.push(`${t("logs.tokens.cacheWrite")}=${split.write}`); + if (typeof log.usage.contextTotalTokens === "number") { + parts.push(`${t("logs.tokens.contextTotal")}=${log.usage.contextTotalTokens}`); + } + if (typeof log.usage.reasoningOutputTokens === "number") { + parts.push(`${t("logs.tokens.reasoning")}=${log.usage.reasoningOutputTokens}`); + } + if (log.usageStatus === "estimated") parts.push(t("logs.tokens.estimatedNote")); + if (log.usageStatus === "estimated" && split.read === undefined && split.write === undefined) { + parts.push(t(isCursorUsageProvider(log.provider) ? "logs.tokens.noCacheCursorNote" : "logs.tokens.noCacheNote")); + } + return parts.join(" \u00B7 "); +} diff --git a/gui/tests/logs-model-title.test.ts b/gui/tests/logs-model-title.test.ts new file mode 100644 index 0000000000..fc37c34d43 --- /dev/null +++ b/gui/tests/logs-model-title.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { modelTitle, type ModelTitleEntry } from "../src/pages/logs-model-title"; + +function entry(fields: Partial = {}): ModelTitleEntry { + return { + model: "gpt-5.6-sol", + ...fields, + }; +} + +test("model diagnostics use one Unicode middle dot between fields", () => { + expect(modelTitle(entry({ + resolvedModel: "gpt-5.6-sol", + requestedServiceTier: "priority", + configuredServiceTier: "fast", + responseServiceTier: "default", + modelSupportsServiceTier: true, + }))).toBe( + "model=gpt-5.6-sol · resolved=gpt-5.6-sol · requestedTier=priority · configuredTier=fast · responseTier=default · supportsTier=true", + ); +}); + +test("model diagnostics do not include an extra Latin capital A with circumflex", () => { + expect(modelTitle(entry({ resolvedModel: "gpt-5.6-sol" }))).not.toContain("\u00C2"); +}); diff --git a/gui/tests/logs-token-title.test.ts b/gui/tests/logs-token-title.test.ts new file mode 100644 index 0000000000..8db75428f4 --- /dev/null +++ b/gui/tests/logs-token-title.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; +import type { TFn } from "../src/i18n/shared"; +import { tokensTitle } from "../src/pages/logs-token-title"; + +const labels: Record = { + "logs.tokens.input": "输入", + "logs.tokens.output": "输出", + "logs.tokens.cacheRead": "缓存命中 (c)", + "logs.tokens.cacheWrite": "缓存写入 (w)", + "logs.tokens.reasoning": "推理", +}; + +const t = ((key: string) => labels[key] ?? key) as TFn; + +test("token diagnostics use one Unicode middle dot between localized fields", () => { + expect(tokensTitle({ + provider: "openai", + usage: { + inputTokens: 59_375, + outputTokens: 553, + cacheReadInputTokens: 54_272, + cacheCreationInputTokens: 0, + reasoningOutputTokens: 237, + }, + }, t)).toBe( + "输入=59375 · 输出=553 · 缓存命中 (c)=54272 · 缓存写入 (w)=0 · 推理=237", + ); +}); + +test("token diagnostics do not include an extra Latin capital A with circumflex", () => { + expect(tokensTitle({ + provider: "openai", + usage: { inputTokens: 1, outputTokens: 2 }, + }, t)).not.toContain("\u00C2"); +}); diff --git a/gui/tests/text-encoding-hygiene.test.ts b/gui/tests/text-encoding-hygiene.test.ts new file mode 100644 index 0000000000..7c4448bf54 --- /dev/null +++ b/gui/tests/text-encoding-hygiene.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "bun:test"; +import { readdir, readFile } from "node:fs/promises"; +import { extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const GUI_DIR = fileURLToPath(new URL("..", import.meta.url)); +const SOURCE_DIR = join(GUI_DIR, "src"); +const TEXT_EXTENSIONS = new Set([".css", ".html", ".js", ".jsx", ".ts", ".tsx"]); +const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true }); + +const MOJIBAKE_PATTERNS: Array<{ name: string; pattern: RegExp }> = [ + { name: "replacement character", pattern: /\uFFFD/u }, + { name: "C1 control character", pattern: /[\u0080-\u009F]/u }, + { name: "Latin-1 decoded UTF-8", pattern: /(?:Â[\u0080-\u00BF]|Ã[\u0080-\u00BF])/u }, + { name: "Windows-1252 punctuation decoded UTF-8", pattern: /â(?:€.|[\u0080-\u00BF]{2})/u }, + { name: "emoji decoded UTF-8", pattern: /ðŸ/u }, + { name: "replacement or BOM decoded UTF-8", pattern: /ï(?:¿½|»¿)/u }, + { + name: "UTF-8 bytes expressed as adjacent JavaScript hex escapes", + pattern: /\\x(?:c[2-9a-f]|d[0-9a-f]|e[0-9a-f]|f[0-4])(?:\\x[89ab][0-9a-f])+/iu, + }, +]; + +async function sourceFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return sourceFiles(path); + return TEXT_EXTENSIONS.has(extname(entry.name)) ? [path] : []; + })); + return nested.flat(); +} + +test("GUI source is valid UTF-8 and contains no common mojibake", async () => { + const files = [...await sourceFiles(SOURCE_DIR), join(GUI_DIR, "index.html")]; + const failures: string[] = []; + + for (const file of files) { + let source: string; + try { + source = UTF8_DECODER.decode(await readFile(file)); + } catch { + failures.push(`${file}: invalid UTF-8`); + continue; + } + for (const { name, pattern } of MOJIBAKE_PATTERNS) { + if (pattern.test(source)) failures.push(`${file}: ${name}`); + } + } + + expect(failures).toEqual([]); +}); + +test("GUI document declares UTF-8", async () => { + const html = UTF8_DECODER.decode(await readFile(join(GUI_DIR, "index.html"))); + expect(html).toMatch(//i); +}); From 8c8f8cf186079a5ad65488ec579b0a6912e477c7 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 18 Aug 2026 02:42:30 -0700 Subject: [PATCH 2/2] fix(gui): localize model tooltip diagnostics --- gui/src/i18n/de.ts | 6 ++++++ gui/src/i18n/en.ts | 6 ++++++ gui/src/i18n/fr.ts | 6 ++++++ gui/src/i18n/ja.ts | 6 ++++++ gui/src/i18n/ko.ts | 6 ++++++ gui/src/i18n/ru.ts | 6 ++++++ gui/src/i18n/tr.ts | 6 ++++++ gui/src/i18n/zh-TW.ts | 6 ++++++ gui/src/i18n/zh.ts | 6 ++++++ gui/src/pages/Logs.tsx | 2 +- gui/src/pages/logs-model-title.ts | 18 +++++++++++------- gui/tests/logs-model-title.test.ts | 20 ++++++++++++++++---- 12 files changed, 82 insertions(+), 12 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index f06b217022..907935a811 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -714,6 +714,12 @@ export const de: Record = { "logs.col.error": "Fehler", "logs.col.upstreamReason": "Upstream-Grund", "logs.col.duration": "Dauer", + "logs.modelTooltip.model": "Modell", + "logs.modelTooltip.resolvedModel": "aufgelöstes Modell", + "logs.modelTooltip.requestedTier": "angeforderte Stufe", + "logs.modelTooltip.configuredTier": "konfigurierte Stufe", + "logs.modelTooltip.responseTier": "Antwortstufe", + "logs.modelTooltip.supportsTier": "Stufenunterstützung", "logs.tokens.reported": "gemeldet", "logs.tokens.unreported": "nicht gemeldet", "logs.tokens.unsupported": "nicht unterstützt", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d1e5389165..985e953993 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -747,6 +747,12 @@ export const en = { "logs.col.error": "Error", "logs.col.upstreamReason": "Upstream reason", "logs.col.duration": "Duration", + "logs.modelTooltip.model": "model", + "logs.modelTooltip.resolvedModel": "resolved model", + "logs.modelTooltip.requestedTier": "requested tier", + "logs.modelTooltip.configuredTier": "configured tier", + "logs.modelTooltip.responseTier": "response tier", + "logs.modelTooltip.supportsTier": "tier support", "logs.tokens.reported": "reported", "logs.tokens.unreported": "unreported", "logs.tokens.unsupported": "unsupported", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index a6a34e5954..5d25cbad34 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -728,6 +728,12 @@ export const fr: Record = { "logs.col.error": "Erreur", "logs.col.upstreamReason": "Motif en amont", "logs.col.duration": "Durée", + "logs.modelTooltip.model": "modèle", + "logs.modelTooltip.resolvedModel": "modèle résolu", + "logs.modelTooltip.requestedTier": "niveau demandé", + "logs.modelTooltip.configuredTier": "niveau configuré", + "logs.modelTooltip.responseTier": "niveau de réponse", + "logs.modelTooltip.supportsTier": "prise en charge du niveau", "logs.tokens.reported": "communiqués", "logs.tokens.unreported": "non communiqués", "logs.tokens.unsupported": "non pris en charge", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 23889a0742..9c44f5bf04 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -690,6 +690,12 @@ export const ja: Record = { "logs.col.error": "エラー", "logs.col.upstreamReason": "上流の理由", "logs.col.duration": "所要時間", + "logs.modelTooltip.model": "モデル", + "logs.modelTooltip.resolvedModel": "解決後モデル", + "logs.modelTooltip.requestedTier": "要求ティア", + "logs.modelTooltip.configuredTier": "設定ティア", + "logs.modelTooltip.responseTier": "応答ティア", + "logs.modelTooltip.supportsTier": "ティア対応", "logs.tokens.reported": "報告済み", "logs.tokens.unreported": "未報告", "logs.tokens.unsupported": "非対応", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index dc7fb87462..8b13c93cfa 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -733,6 +733,12 @@ export const ko: Record = { "logs.col.error": "오류", "logs.col.upstreamReason": "업스트림 원인", "logs.col.duration": "소요 시간", + "logs.modelTooltip.model": "모델", + "logs.modelTooltip.resolvedModel": "해석된 모델", + "logs.modelTooltip.requestedTier": "요청 티어", + "logs.modelTooltip.configuredTier": "설정 티어", + "logs.modelTooltip.responseTier": "응답 티어", + "logs.modelTooltip.supportsTier": "티어 지원", "logs.tokens.reported": "측정됨", "logs.tokens.unreported": "미보고", "logs.tokens.unsupported": "미지원", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2368ee0257..225e4f0576 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -731,6 +731,12 @@ export const ru: Record = { "logs.col.error": "Ошибка", "logs.col.upstreamReason": "Причина от провайдера", "logs.col.duration": "Длительность", + "logs.modelTooltip.model": "модель", + "logs.modelTooltip.resolvedModel": "разрешённая модель", + "logs.modelTooltip.requestedTier": "запрошенный уровень", + "logs.modelTooltip.configuredTier": "настроенный уровень", + "logs.modelTooltip.responseTier": "уровень ответа", + "logs.modelTooltip.supportsTier": "поддержка уровня", "logs.tokens.reported": "сообщено", "logs.tokens.unreported": "не сообщено", "logs.tokens.unsupported": "не поддерживается", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 7644e50c89..bda0011fcf 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -738,6 +738,12 @@ export const tr: Record = { "logs.col.error": "Hata", "logs.col.upstreamReason": "Yukarı akış nedeni", "logs.col.duration": "Süre", + "logs.modelTooltip.model": "model", + "logs.modelTooltip.resolvedModel": "çözümlenen model", + "logs.modelTooltip.requestedTier": "istenen katman", + "logs.modelTooltip.configuredTier": "yapılandırılan katman", + "logs.modelTooltip.responseTier": "yanıt katmanı", + "logs.modelTooltip.supportsTier": "katman desteği", "logs.tokens.reported": "bildirilen", "logs.tokens.unreported": "bildirilmeyen", "logs.tokens.unsupported": "desteklenmeyen", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4f5cb8331d..a53fdd14e6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -567,6 +567,12 @@ export const zhTW: Record = { "logs.col.error": "錯誤", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗時", + "logs.modelTooltip.model": "模型", + "logs.modelTooltip.resolvedModel": "解析後模型", + "logs.modelTooltip.requestedTier": "請求層級", + "logs.modelTooltip.configuredTier": "設定層級", + "logs.modelTooltip.responseTier": "回應層級", + "logs.modelTooltip.supportsTier": "支援層級", "logs.tokens.reported": "已上報", "logs.tokens.unreported": "未上報", "logs.tokens.unsupported": "不支援", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3589644f4a..df95ba752d 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -726,6 +726,12 @@ export const zh: Record = { "logs.col.error": "错误", "logs.col.upstreamReason": "上游原因", "logs.col.duration": "耗时", + "logs.modelTooltip.model": "模型", + "logs.modelTooltip.resolvedModel": "解析后模型", + "logs.modelTooltip.requestedTier": "请求层级", + "logs.modelTooltip.configuredTier": "配置层级", + "logs.modelTooltip.responseTier": "响应层级", + "logs.modelTooltip.supportsTier": "支持层级", "logs.tokens.reported": "已上报", "logs.tokens.unreported": "未上报", "logs.tokens.unsupported": "不支持", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 353131a304..b7acbb4d02 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -733,7 +733,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { {formatEstimatedUsd(log.displayMetrics?.cost, localeTag)} - + {modelLabel(log.resolvedModel ?? log.model)} {(log.surface === "claude" || log.surface === "claude-desktop") && ( diff --git a/gui/src/pages/logs-model-title.ts b/gui/src/pages/logs-model-title.ts index cccb80c5f3..77b1fe0577 100644 --- a/gui/src/pages/logs-model-title.ts +++ b/gui/src/pages/logs-model-title.ts @@ -1,3 +1,5 @@ +import type { TFn } from "../i18n/shared"; + export interface ModelTitleEntry { model: string; resolvedModel?: string; @@ -7,14 +9,16 @@ export interface ModelTitleEntry { modelSupportsServiceTier?: boolean; } -export function modelTitle(log: ModelTitleEntry): string { +export function modelTitle(log: ModelTitleEntry, t: TFn): string { const details = [ - `model=${log.model}`, - log.resolvedModel ? `resolved=${log.resolvedModel}` : undefined, - log.requestedServiceTier ? `requestedTier=${log.requestedServiceTier}` : undefined, - log.configuredServiceTier ? `configuredTier=${log.configuredServiceTier}` : undefined, - log.responseServiceTier ? `responseTier=${log.responseServiceTier}` : undefined, - log.modelSupportsServiceTier !== undefined ? `supportsTier=${log.modelSupportsServiceTier}` : undefined, + `${t("logs.modelTooltip.model")}=${log.model}`, + log.resolvedModel ? `${t("logs.modelTooltip.resolvedModel")}=${log.resolvedModel}` : undefined, + log.requestedServiceTier ? `${t("logs.modelTooltip.requestedTier")}=${log.requestedServiceTier}` : undefined, + log.configuredServiceTier ? `${t("logs.modelTooltip.configuredTier")}=${log.configuredServiceTier}` : undefined, + log.responseServiceTier ? `${t("logs.modelTooltip.responseTier")}=${log.responseServiceTier}` : undefined, + log.modelSupportsServiceTier !== undefined + ? `${t("logs.modelTooltip.supportsTier")}=${log.modelSupportsServiceTier}` + : undefined, ].filter(Boolean); return details.join(" \u00B7 "); } diff --git a/gui/tests/logs-model-title.test.ts b/gui/tests/logs-model-title.test.ts index fc37c34d43..009433d334 100644 --- a/gui/tests/logs-model-title.test.ts +++ b/gui/tests/logs-model-title.test.ts @@ -1,6 +1,18 @@ import { expect, test } from "bun:test"; +import type { TFn, TKey } from "../src/i18n/shared"; import { modelTitle, type ModelTitleEntry } from "../src/pages/logs-model-title"; +const labels: Partial> = { + "logs.modelTooltip.model": "模型", + "logs.modelTooltip.resolvedModel": "解析后模型", + "logs.modelTooltip.requestedTier": "请求层级", + "logs.modelTooltip.configuredTier": "配置层级", + "logs.modelTooltip.responseTier": "响应层级", + "logs.modelTooltip.supportsTier": "支持层级", +}; + +const t: TFn = key => labels[key] ?? key; + function entry(fields: Partial = {}): ModelTitleEntry { return { model: "gpt-5.6-sol", @@ -8,18 +20,18 @@ function entry(fields: Partial = {}): ModelTitleEntry { }; } -test("model diagnostics use one Unicode middle dot between fields", () => { +test("model diagnostics localize every label and use one Unicode middle dot between fields", () => { expect(modelTitle(entry({ resolvedModel: "gpt-5.6-sol", requestedServiceTier: "priority", configuredServiceTier: "fast", responseServiceTier: "default", modelSupportsServiceTier: true, - }))).toBe( - "model=gpt-5.6-sol · resolved=gpt-5.6-sol · requestedTier=priority · configuredTier=fast · responseTier=default · supportsTier=true", + }), t)).toBe( + "模型=gpt-5.6-sol · 解析后模型=gpt-5.6-sol · 请求层级=priority · 配置层级=fast · 响应层级=default · 支持层级=true", ); }); test("model diagnostics do not include an extra Latin capital A with circumflex", () => { - expect(modelTitle(entry({ resolvedModel: "gpt-5.6-sol" }))).not.toContain("\u00C2"); + expect(modelTitle(entry({ resolvedModel: "gpt-5.6-sol" }), t)).not.toContain("\u00C2"); });