fix(webui): surface current context in turn usage

This commit is contained in:
chengyongru
2026-08-23 22:22:49 +08:00
parent 1581544b7f
commit 4fd21097e1
13 changed files with 164 additions and 67 deletions
+45 -23
View File
@@ -192,46 +192,69 @@ function compactDuration(milliseconds: number): string {
function TurnUsageMeta({ function TurnUsageMeta({
usage, usage,
latencyMs, latencyMs,
contextWindowTokens,
}: { }: {
usage: TurnUsage; usage: TurnUsage;
latencyMs?: number; latencyMs?: number;
contextWindowTokens?: number;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const context = usage.context_tokens;
const prompt = usage.prompt_tokens; const prompt = usage.prompt_tokens;
const completion = usage.completion_tokens; const completion = usage.completion_tokens;
const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : ""; const approximate = (usage.estimated_tokens ?? 0) > 0 ? "~" : "";
const formatTokens = (tokens: number) => `${approximate}${formatCompactTokenCount(tokens)}`;
const parts: string[] = []; const parts: string[] = [];
if (typeof prompt === "number") parts.push(`${approximate}${formatCompactTokenCount(prompt)} in`); const details: string[] = [];
if (typeof completion === "number") parts.push(`${approximate}${formatCompactTokenCount(completion)} out`);
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 ( if (
typeof usage.cached_tokens === "number" typeof usage.cached_tokens === "number"
&& typeof prompt === "number" && typeof prompt === "number"
&& prompt > 0 && prompt > 0
) { ) {
parts.push(`${Math.round(Math.min(1, usage.cached_tokens / prompt) * 100)}% cached`); details.push(t("message.usage.cached", {
} percent: Math.round(Math.min(1, usage.cached_tokens / prompt) * 100),
if (typeof usage.request_count === "number" && usage.request_count > 1) { defaultValue: "{{percent}}% cached",
parts.push(t("message.usage.requests", {
count: usage.request_count,
defaultValue: "{{count}} calls this turn",
})); }));
} }
if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs)); if (typeof latencyMs === "number" && latencyMs >= 0) parts.push(compactDuration(latencyMs));
if (parts.length === 0) return null; 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) { if (approximate) {
details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" })); details.push(t("message.usage.estimated", { defaultValue: "Includes estimated usage" }));
} }
@@ -639,7 +662,6 @@ export function MessageBubble({
<TurnUsageMeta <TurnUsageMeta
usage={message.usage!} usage={message.usage!}
latencyMs={message.latencyMs} latencyMs={message.latencyMs}
contextWindowTokens={message.contextWindowTokens}
/> />
) : null} ) : null}
{showAssistantTimestamp ? ( {showAssistantTimestamp ? (
+6 -2
View File
@@ -1431,8 +1431,12 @@
"fileEditOpenFile": "Open file", "fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change.", "fileEditDiffTruncated": "Diff truncated. Open the file for the full change.",
"usage": { "usage": {
"context": "Context now: {{tokens}}{{capacity}}", "context": "{{tokens}} context",
"requests": "{{count}} calls this turn", "turnInput": "{{tokens}} turn input",
"request": "{{count}} request",
"requests": "{{count}} requests",
"output": "{{tokens}} out",
"cached": "{{percent}}% cached",
"estimated": "Includes estimated usage" "estimated": "Includes estimated usage"
} }
}, },
+6 -2
View File
@@ -1401,8 +1401,12 @@
"fileEditOpenFile": "Abrir archivo", "fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.", "fileEditDiffTruncated": "Diferencias truncadas. Abre el archivo para ver el cambio completo.",
"usage": { "usage": {
"context": "Contexto más reciente: {{tokens}}{{capacity}} tokens", "context": "{{tokens}} de contexto",
"requests": "{{count}} solicitudes al modelo", "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" "estimated": "Incluye uso estimado"
}, },
"activityThinkingFor": "Pensando durante {{duration}}", "activityThinkingFor": "Pensando durante {{duration}}",
+6 -2
View File
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "Ouvrir le fichier", "fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.", "fileEditDiffTruncated": "Différences tronquées. Ouvrez le fichier pour voir la modification complète.",
"usage": { "usage": {
"context": "Dernier contexte : {{tokens}}{{capacity}} tokens", "context": "{{tokens}} de contexte",
"requests": "{{count}} requêtes au modèle", "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 lutilisation" "estimated": "Inclut une estimation de lutilisation"
}, },
"activityThinkingFor": "Réflexion pendant {{duration}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
+6 -2
View File
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "Buka file", "fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.", "fileEditDiffTruncated": "Perbedaan dipotong. Buka file untuk melihat perubahan lengkap.",
"usage": { "usage": {
"context": "Konteks terbaru: {{tokens}}{{capacity}} token", "context": "Konteks {{tokens}}",
"requests": "{{count}} permintaan model", "turnInput": "Input giliran {{tokens}}",
"request": "{{count}} permintaan",
"requests": "{{count}} permintaan",
"output": "Output {{tokens}}",
"cached": "{{percent}}% di-cache",
"estimated": "Termasuk penggunaan perkiraan" "estimated": "Termasuk penggunaan perkiraan"
}, },
"activityThinkingFor": "Berpikir selama {{duration}}", "activityThinkingFor": "Berpikir selama {{duration}}",
+6 -2
View File
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "ファイルを開く", "fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。", "fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"usage": { "usage": {
"context": "最新のコンテキスト: {{tokens}}{{capacity}} トークン", "context": "コンテキスト {{tokens}}",
"requests": "モデルリクエスト {{count}} 回", "turnInput": "ターン入力 {{tokens}}",
"request": "リクエスト {{count}} 回",
"requests": "リクエスト {{count}} 回",
"output": "出力 {{tokens}}",
"cached": "キャッシュ {{percent}}%",
"estimated": "推定使用量を含みます" "estimated": "推定使用量を含みます"
}, },
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
+6 -2
View File
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "파일 열기", "fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.", "fileEditDiffTruncated": "변경 사항이 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"usage": { "usage": {
"context": "최근 컨텍스트: {{tokens}}{{capacity}} 토큰", "context": "컨텍스트 {{tokens}}",
"requests": "모델 요청 {{count}}", "turnInput": "턴 입력 {{tokens}}",
"request": "요청 {{count}}회",
"requests": "요청 {{count}}회",
"output": "출력 {{tokens}}",
"cached": "캐시됨 {{percent}}%",
"estimated": "예상 사용량 포함" "estimated": "예상 사용량 포함"
}, },
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
+6 -2
View File
@@ -1431,8 +1431,12 @@
"fileEditOpenFile": "Abrir arquivo", "fileEditOpenFile": "Abrir arquivo",
"fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.", "fileEditDiffTruncated": "Diferenças truncadas. Abra o arquivo para ver a alteração completa.",
"usage": { "usage": {
"context": "Contexto atual: {{tokens}}{{capacity}}", "context": "{{tokens}} de contexto",
"requests": "{{count}} chamadas nesta rodada", "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" "estimated": "Inclui uso estimado"
} }
}, },
+6 -2
View File
@@ -1400,8 +1400,12 @@
"fileEditOpenFile": "Mở tệp", "fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.", "fileEditDiffTruncated": "Khác biệt đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"usage": { "usage": {
"context": "Ngữ cảnh gần nhất: {{tokens}}{{capacity}} token", "context": "Ngữ cảnh {{tokens}}",
"requests": "{{count}} yêu cầu mô hình", "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" "estimated": "Bao gồm mức sử dụng ước tính"
}, },
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
+6 -2
View File
@@ -1431,8 +1431,12 @@
"fileEditOpenFile": "打开文件", "fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。", "fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。",
"usage": { "usage": {
"context": "当前上下文{{tokens}}{{capacity}}", "context": "上下文 {{tokens}}",
"requests": "本轮 {{count}} 次模型调用", "turnInput": "本轮输入 {{tokens}}",
"request": "{{count}} 次模型调用",
"requests": "{{count}} 次模型调用",
"output": "输出 {{tokens}}",
"cached": "缓存 {{percent}}%",
"estimated": "包含估算用量" "estimated": "包含估算用量"
} }
}, },
+5 -1
View File
@@ -1399,8 +1399,12 @@
"fileEditOpenFile": "開啟檔案", "fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。", "fileEditDiffTruncated": "差異內容已截斷。請開啟檔案以檢視完整變更。",
"usage": { "usage": {
"context": "最近一次上下文{{tokens}}{{capacity}} tokens", "context": "上下文 {{tokens}}",
"turnInput": "本輪輸入 {{tokens}}",
"request": "{{count}} 次模型請求",
"requests": "{{count}} 次模型請求", "requests": "{{count}} 次模型請求",
"output": "輸出 {{tokens}}",
"cached": "快取 {{percent}}%",
"estimated": "包含估算用量" "estimated": "包含估算用量"
}, },
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
+7
View File
@@ -291,6 +291,13 @@ const LOCALIZED_NEW_SURFACE_KEYS = [
"message.openLink", "message.openLink",
"message.openAttachment", "message.openAttachment",
"message.skill", "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.connectionChecks",
"settings.channels.open", "settings.channels.open",
]; ];
+50 -22
View File
@@ -1013,35 +1013,59 @@ describe("MessageBubble", () => {
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument(); expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
}); });
it("distinguishes aggregate turn usage from the latest context", () => { it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
const message: UIMessage = { const turns: UIMessage[] = [
id: "a-usage", {
id: "turn-a",
role: "assistant", role: "assistant",
content: "done", content: "first reply",
createdAt: Date.now(), createdAt: Date.now(),
latencyMs: 18_200,
contextWindowTokens: 128_000,
usage: { usage: {
prompt_tokens: 12_400, prompt_tokens: 111_000,
completion_tokens: 823, completion_tokens: 333,
cached_tokens: 9_672, cached_tokens: 55_500,
context_tokens: 8_100, context_tokens: 56_000,
request_count: 3, 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(<MessageBubble message={message} />); render(<>{turns.map((message) => <MessageBubble key={message.id} message={message} />)}</>);
const usage = screen.getByText("12.4K in · 823 out · 78% cached · 3 calls this turn · 18s"); const turnAUsage = screen.getByText("56K context");
expect(usage).toHaveAttribute("data-turn-usage"); const turnBUsage = screen.getByText("57K context");
expect(usage).toHaveAttribute("tabindex", "0"); 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 = { const message: UIMessage = {
id: "a-estimated-usage", id: "a-estimated-usage",
role: "assistant", role: "assistant",
@@ -1050,15 +1074,19 @@ describe("MessageBubble", () => {
usage: { usage: {
prompt_tokens: 1_250, prompt_tokens: 1_250,
completion_tokens: 90, completion_tokens: 90,
request_count: 1,
estimated_tokens: 1_340, estimated_tokens: 1_340,
}, },
}; };
render(<MessageBubble message={message} />); render(<MessageBubble message={message} />);
const usage = screen.getByText("~1.3K in · ~90 out"); const usage = screen.getByText("~1.3K turn input");
expect(usage).not.toHaveTextContent("cached"); expect(usage).not.toHaveTextContent("context");
fireEvent.focus(usage); 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");
}); });
}); });