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({
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({
<TurnUsageMeta
usage={message.usage!}
latencyMs={message.latencyMs}
contextWindowTokens={message.contextWindowTokens}
/>
) : null}
{showAssistantTimestamp ? (
+6 -2
View File
@@ -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"
}
},
+6 -2
View File
@@ -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}}",
+6 -2
View File
@@ -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 lutilisation"
},
"activityThinkingFor": "Réflexion pendant {{duration}}",
+6 -2
View File
@@ -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}}",
+6 -2
View File
@@ -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}}考えています",
+6 -2
View File
@@ -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}} 동안 생각 중",
+6 -2
View File
@@ -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"
}
},
+6 -2
View File
@@ -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}}",
+6 -2
View File
@@ -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": "包含估算用量"
}
},
+5 -1
View File
@@ -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}}",
+7
View File
@@ -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",
];
+50 -22
View File
@@ -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",
it("shows consecutive contexts while keeping each turn's aggregate cost in details", async () => {
const turns: UIMessage[] = [
{
id: "turn-a",
role: "assistant",
content: "done",
content: "first reply",
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,
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(<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");
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(<MessageBubble message={message} />);
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");
});
});