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({
|
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 ? (
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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 l’utilisation"
|
"estimated": "Inclut une estimation de l’utilisation"
|
||||||
},
|
},
|
||||||
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
"activityThinkingFor": "Réflexion pendant {{duration}}",
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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}}考えています",
|
||||||
|
|||||||
@@ -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}} 동안 생각 중",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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": "包含估算用量"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}}",
|
||||||
|
|||||||
@@ -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",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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",
|
{
|
||||||
role: "assistant",
|
id: "turn-a",
|
||||||
content: "done",
|
role: "assistant",
|
||||||
createdAt: Date.now(),
|
content: "first reply",
|
||||||
latencyMs: 18_200,
|
createdAt: Date.now(),
|
||||||
contextWindowTokens: 128_000,
|
usage: {
|
||||||
usage: {
|
prompt_tokens: 111_000,
|
||||||
prompt_tokens: 12_400,
|
completion_tokens: 333,
|
||||||
completion_tokens: 823,
|
cached_tokens: 55_500,
|
||||||
cached_tokens: 9_672,
|
context_tokens: 56_000,
|
||||||
context_tokens: 8_100,
|
request_count: 2,
|
||||||
request_count: 3,
|
},
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user