mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(webui): surface current context in turn usage
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}考えています",
|
||||
|
||||
@@ -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}} 동안 생각 중",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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": "包含估算用量"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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(<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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user