From 4fd21097e16d18c3635f3734a0af8095c276a640 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Sun, 23 Aug 2026 22:22:49 +0800 Subject: [PATCH] fix(webui): surface current context in turn usage --- webui/src/components/MessageBubble.tsx | 68 ++++++++++++++------- webui/src/i18n/locales/en/common.json | 8 ++- webui/src/i18n/locales/es/common.json | 8 ++- webui/src/i18n/locales/fr/common.json | 8 ++- webui/src/i18n/locales/id/common.json | 8 ++- webui/src/i18n/locales/ja/common.json | 8 ++- webui/src/i18n/locales/ko/common.json | 8 ++- webui/src/i18n/locales/pt-BR/common.json | 8 ++- webui/src/i18n/locales/vi/common.json | 8 ++- webui/src/i18n/locales/zh-CN/common.json | 8 ++- webui/src/i18n/locales/zh-TW/common.json | 6 +- webui/src/tests/i18n.test.tsx | 7 +++ webui/src/tests/message-bubble.test.tsx | 78 ++++++++++++++++-------- 13 files changed, 164 insertions(+), 67 deletions(-) diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index d527749e9..2eb4f930b 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -192,46 +192,69 @@ function compactDuration(milliseconds: number): string { function TurnUsageMeta({ usage, latencyMs, - contextWindowTokens, }: { usage: TurnUsage; latencyMs?: number; - contextWindowTokens?: number; }) { const { t } = useTranslation(); + const context = usage.context_tokens; const prompt = usage.prompt_tokens; const completion = usage.completion_tokens; const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : ""; + const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`; const parts: string[] = []; - if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`); - if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`); + const details: string[] = []; + + if (typeof context === "number" && context >= 0) { + parts.push(t("message.usage.context", { + tokens: formatTokens(context), + defaultValue: "{{tokens}} context", + })); + if (typeof prompt === "number") { + details.push(t("message.usage.turnInput", { + tokens: formatTokens(prompt), + defaultValue: "{{tokens}} turn input", + })); + } + } else if (typeof prompt === "number") { + parts.push(t("message.usage.turnInput", { + tokens: formatTokens(prompt), + defaultValue: "{{tokens}} turn input", + })); + } + + if (typeof usage.request_count === "number" && usage.request_count > 0) { + const requestKey = usage.request_count === 1 + ? "message.usage.request" + : "message.usage.requests"; + details.push(t(requestKey, { + count: usage.request_count, + defaultValue: usage.request_count === 1 ? "{{count}} request" : "{{count}} requests", + })); + } + + if (typeof completion === "number") { + const output = t("message.usage.output", { + tokens: formatTokens(completion), + defaultValue: "{{tokens}} out", + }); + if (parts.length > 0) details.push(output); + else parts.push(output); + } + if ( typeof usage.cached_tokens === "number" && typeof prompt === "number" && prompt > 0 ) { - parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`); - } - if (typeof usage.request_count === "number" && usage.request_count > 1) { - parts.push(t("message.usage.requests", { - count: usage.request_count, - defaultValue: "{{count}} calls this turn", + details.push(t("message.usage.cached", { + percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100), + defaultValue: "{{percent}}% cached", })); } + if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs)); if (parts.length === 0) return null; - - const details: string[] = []; - if (typeof usage.context_tokens === "number" && usage.context_tokens >= 0) { - const capacity = typeof contextWindowTokens === "number" && contextWindowTokens > 0 - ? ` / ${formatCompactTokenCount(contextWindowTokens)}` - : ""; - details.push(t("message.usage.context", { - tokens: `${approximate}${formatCompactTokenCount(usage.context_tokens)}`, - capacity, - defaultValue: "Context now: {{tokens}}{{capacity}}", - })); - } if (approximate) { details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" })); } @@ -639,7 +662,6 @@ export function MessageBubble({ ) : null} {showAssistantTimestamp ? ( diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index a31c8866c..81abcf15d 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1431,8 +1431,12 @@ "fileEditOpenFile": "Open file", "fileEditDiffTruncated": "Diff truncated. Open the file for the full change.", "usage": { - "context": "Context now: {{tokens}}{{capacity}}", - "requests": "{{count}} calls this turn", + "context": "{{tokens}} context", + "turnInput": "{{tokens}} turn input", + "request": "{{count}} request", + "requests": "{{count}} requests", + "output": "{{tokens}} out", + "cached": "{{percent}}% cached", "estimated": "Includes estimated usage" } }, diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 5dc215773..b960fb670 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1401,8 +1401,12 @@ "fileEditOpenFile": "Abrir archivo", "fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", "usage": { - "context": "Contexto más reciente: {{tokens}}{{capacity}} tokens", - "requests": "{{count}} solicitudes al modelo", + "context": "{{tokens}} de contexto", + "turnInput": "{{tokens}} de entrada en el turno", + "request": "{{count}} solicitud", + "requests": "{{count}} solicitudes", + "output": "{{tokens}} de salida", + "cached": "{{percent}}% en caché", "estimated": "Incluye uso estimado" }, "activityThinkingFor": "Pensando durante {{duration}}", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 360ebed75..c7686a501 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1400,8 +1400,12 @@ "fileEditOpenFile": "Ouvrir le fichier", "fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", "usage": { - "context": "Dernier contexte : {{tokens}}{{capacity}} tokens", - "requests": "{{count}} requêtes au modèle", + "context": "{{tokens}} de contexte", + "turnInput": "{{tokens}} en entrée sur ce tour", + "request": "{{count}} requête", + "requests": "{{count}} requêtes", + "output": "{{tokens}} en sortie", + "cached": "{{percent}} % en cache", "estimated": "Inclut une estimation de l’utilisation" }, "activityThinkingFor": "Réflexion pendant {{duration}}", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index fa4708f95..c50da5313 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1400,8 +1400,12 @@ "fileEditOpenFile": "Buka file", "fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", "usage": { - "context": "Konteks terbaru: {{tokens}}{{capacity}} token", - "requests": "{{count}} permintaan model", + "context": "Konteks {{tokens}}", + "turnInput": "Input giliran {{tokens}}", + "request": "{{count}} permintaan", + "requests": "{{count}} permintaan", + "output": "Output {{tokens}}", + "cached": "{{percent}}% di-cache", "estimated": "Termasuk penggunaan perkiraan" }, "activityThinkingFor": "Berpikir selama {{duration}}", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 4622ca056..dc0fc843e 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1400,8 +1400,12 @@ "fileEditOpenFile": "ファイルを開く", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "usage": { - "context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン", - "requests": "モデルリクエスト {{count}} 回", + "context": "コンテキスト {{tokens}}", + "turnInput": "ターン入力 {{tokens}}", + "request": "リクエスト {{count}} 回", + "requests": "リクエスト {{count}} 回", + "output": "出力 {{tokens}}", + "cached": "キャッシュ {{percent}}%", "estimated": "推定使用量を含みます" }, "activityThinkingFor": "{{duration}}考えています", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 7cff407ec..2b8206a08 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1400,8 +1400,12 @@ "fileEditOpenFile": "파일 열기", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "usage": { - "context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰", - "requests": "모델 요청 {{count}}회", + "context": "컨텍스트 {{tokens}}", + "turnInput": "턴 입력 {{tokens}}", + "request": "요청 {{count}}회", + "requests": "요청 {{count}}회", + "output": "출력 {{tokens}}", + "cached": "캐시됨 {{percent}}%", "estimated": "예상 사용량 포함" }, "activityThinkingFor": "{{duration}} 동안 생각 중", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index f6e0b0329..e17d43ecd 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1431,8 +1431,12 @@ "fileEditOpenFile": "Abrir arquivo", "fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.", "usage": { - "context": "Contexto atual: {{tokens}}{{capacity}}", - "requests": "{{count}} chamadas nesta rodada", + "context": "{{tokens}} de contexto", + "turnInput": "{{tokens}} de entrada na rodada", + "request": "{{count}} chamada", + "requests": "{{count}} chamadas", + "output": "{{tokens}} de saída", + "cached": "{{percent}}% em cache", "estimated": "Inclui uso estimado" } }, diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index b17017947..03869794a 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1400,8 +1400,12 @@ "fileEditOpenFile": "Mở tệp", "fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "usage": { - "context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token", - "requests": "{{count}} yêu cầu mô hình", + "context": "Ngữ cảnh {{tokens}}", + "turnInput": "Đầu vào lượt {{tokens}}", + "request": "{{count}} yêu cầu", + "requests": "{{count}} yêu cầu", + "output": "Đầu ra {{tokens}}", + "cached": "{{percent}}% đã lưu đệm", "estimated": "Bao gồm mức sử dụng ước tính" }, "activityThinkingFor": "Đang suy nghĩ trong {{duration}}", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 93cc48ec6..361b9fd29 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1431,8 +1431,12 @@ "fileEditOpenFile": "打开文件", "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。", "usage": { - "context": "当前上下文:{{tokens}}{{capacity}}", - "requests": "本轮 {{count}} 次模型调用", + "context": "上下文 {{tokens}}", + "turnInput": "本轮输入 {{tokens}}", + "request": "{{count}} 次模型调用", + "requests": "{{count}} 次模型调用", + "output": "输出 {{tokens}}", + "cached": "缓存 {{percent}}%", "estimated": "包含估算用量" } }, diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 99e05d33d..127058c71 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1399,8 +1399,12 @@ "fileEditOpenFile": "開啟檔案", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", "usage": { - "context": "最近一次上下文:{{tokens}}{{capacity}} tokens", + "context": "上下文 {{tokens}}", + "turnInput": "本輪輸入 {{tokens}}", + "request": "{{count}} 次模型請求", "requests": "{{count}} 次模型請求", + "output": "輸出 {{tokens}}", + "cached": "快取 {{percent}}%", "estimated": "包含估算用量" }, "activityThinkingFor": "思考中,已 {{duration}}", diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 2891cbd97..29644f81f 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -291,6 +291,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [ "message.openLink", "message.openAttachment", "message.skill", + "message.usage.context", + "message.usage.turnInput", + "message.usage.request", + "message.usage.requests", + "message.usage.output", + "message.usage.cached", + "message.usage.estimated", "settings.channels.connectionChecks", "settings.channels.open", ]; diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 601624176..cba5040da 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -1013,35 +1013,59 @@ describe("MessageBubble", () => { expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument(); }); - it("distinguishes aggregate turn usage from the latest context", () => { - const message: UIMessage = { - id: "a-usage", - role: "assistant", - content: "done", - createdAt: Date.now(), - latencyMs: 18_200, - contextWindowTokens: 128_000, - usage: { - prompt_tokens: 12_400, - completion_tokens: 823, - cached_tokens: 9_672, - context_tokens: 8_100, - request_count: 3, + it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => { + const turns: UIMessage[] = [ + { + id: "turn-a", + role: "assistant", + content: "first reply", + createdAt: Date.now(), + usage: { + prompt_tokens: 111_000, + completion_tokens: 333, + cached_tokens: 55_500, + context_tokens: 56_000, + request_count: 2, + }, }, - }; + { + id: "turn-b", + role: "assistant", + content: "second reply", + createdAt: Date.now(), + usage: { + prompt_tokens: 57_000, + completion_tokens: 192, + cached_tokens: 55_290, + context_tokens: 57_000, + request_count: 1, + }, + }, + ]; - render(); + render(<>{turns.map((message) => )}); - const usage = screen.getByText("12.4K in · 823 out · 78% cached · 3 calls this turn · 18s"); - expect(usage).toHaveAttribute("data-turn-usage"); - expect(usage).toHaveAttribute("tabindex", "0"); + const turnAUsage = screen.getByText("56K context"); + const turnBUsage = screen.getByText("57K context"); + expect(turnAUsage).toHaveAttribute("data-turn-usage"); + expect(turnBUsage).toHaveAttribute("data-turn-usage"); + expect(screen.queryByText(/111K turn input/)).not.toBeInTheDocument(); - fireEvent.focus(usage); + fireEvent.focus(turnAUsage); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "111K turn input · 2 requests · 333 out · 50% cached", + ); - expect(screen.getByRole("tooltip")).toHaveTextContent("Context now: 8.1K / 128K"); + fireEvent.blur(turnAUsage); + await waitFor(() => expect(screen.queryByRole("tooltip")).not.toBeInTheDocument()); + + fireEvent.focus(turnBUsage); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "57K turn input · 1 request · 192 out · 97% cached", + ); }); - it("marks estimated usage and omits cache when the provider did not report it", () => { + it("falls back to clearly labeled estimated turn input when context is unavailable", () => { const message: UIMessage = { id: "a-estimated-usage", role: "assistant", @@ -1050,15 +1074,19 @@ describe("MessageBubble", () => { usage: { prompt_tokens: 1_250, completion_tokens: 90, + request_count: 1, estimated_tokens: 1_340, }, }; render(); - const usage = screen.getByText("~1.3K in · ~90 out"); - expect(usage).not.toHaveTextContent("cached"); + const usage = screen.getByText("~1.3K turn input"); + expect(usage).not.toHaveTextContent("context"); fireEvent.focus(usage); - expect(screen.getByRole("tooltip")).toHaveTextContent("Includes estimated usage"); + expect(screen.getByRole("tooltip")).toHaveTextContent( + "1 request · ~90 out · Includes estimated usage", + ); + expect(screen.getByRole("tooltip")).not.toHaveTextContent("cached"); }); });