mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): improve token usage hierarchy (#14648)
* feat(web-shell): improve token usage hierarchy * fix(web-shell): exclude token usage copy from transcripts * fix(web-shell): refine token usage tooltips and coverage * test(web-shell): use artifact-safe token screenshot names --------- Co-authored-by: 钉萁 <[email protected]>
This commit is contained in:
1 parent
9345c4ed1d
commit
9c59535db6
11 files changed
+2036
-392
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Token usage hierarchy
|
||||
|
||||
[English](token-usage-hierarchy.md) | [简体中文](token-usage-hierarchy.zh-CN.md)
|
||||
|
||||
## Problem and scope
|
||||
|
||||
The session Token Usage panel repeats metrics in tall cards, making comparison difficult. Change presentation and disclosure while preserving numeric values, token normalization, daemon routes, polling and SDK contracts. Use the requested shorter labels “Cache” and “Reasoning” in place of “Cached input” and “Reasoning (included in total output)”.
|
||||
|
||||
## Design
|
||||
|
||||
Place the session total above a rounded two-column overview, with “Total tokens” inline after the number and refresh/time on the right, including when the header wraps. Use foreground text for the total label with at least 4.5:1 text contrast. The primary columns are Input and Total output. Use fixed 28px text for the session total and 24px for primary overview values. Input retains the complete normalized count without subtracting cached tokens; its caption shows cache count and cached/input percentage. Output retains a reasoning-count caption. Preserve zero values. Use normal 12px foreground sans-serif captions and omit inclusion notices.
|
||||
|
||||
Sort models by normalized total consumption in descending order. Use fixed 15px bold names, 14px shares and gradient bars with the palette from the user's reference image. Omit rank tiles, per-model token-total lines, click disclosures and row-hover backgrounds. One shared Tooltip provider groups the panel so moving promptly between rows skips another 300ms delay. An Info icon after the name exposes request count and average latency on hover, keyboard focus or touch/click. Preserve upstream streamed-generation metrics in the same tooltip: average first-token latency and, when generation duration is positive, average generation time and output speed. Keep their original arithmetic and availability conditions; show each metric on its own line. Include the model name in each Info button's accessible name. Use local foreground colors for the Info icon and authored focus indicators (including Refresh) so both contrast at least 3:1 against their adjacent card in light and dark themes; keep the global theme tokens unchanged.
|
||||
|
||||
Above each bar, show “Input xxx (cache rate: xxx) · Total output xxx”. Hide the separate cache count and reasoning from the inline row. Keep it on one line with ellipsis on overflow. Hover, keyboard focus and touch always expose input, cache count with ratio, output and reasoning, one complete metric per tooltip line, whether the row fits or is clipped. Use ordinary arrow cursors for the Info icon and metric row. Put the consumption share at the far right of the track; hovering, focusing or tapping the track shows “Token share: percentage”. Tapped share tooltips remain open until dismissal; their noninteractive content does not intercept taps on adjacent metric rows. Keep actual shares, including an over-reported source above 100%, without silently clamping the displayed number. Keep the bar row's top margin, model-list gap and model-heading bottom margin at zero. All fonts remain fixed during sidebar resizing. By model, By agent and Tools headings share the same size, weight and color.
|
||||
|
||||
Keep “By agent” collapsed initially. Always place “Main Agent” first, then individual subagents in descending normalized-total order without type groups or aggregate headers. Derive Main Agent from session model totals minus reported subagent usage: normalize input and total per source before subtracting them, and subtract cache and reasoning separately. Clamp the remaining total to zero, input to remaining total, cache to input and reasoning to output; derive output as remaining total minus input. Main Agent is the remainder outside attributed subagents and is not added to the overview. An empty sources array shows Main Agent; an omitted field hides the section because older daemons cannot distinguish attribution.
|
||||
|
||||
Use the same card for every agent: original name at the left and a differing localized type at the far right of the header, truncated with its full HTML title when needed. Compare name/type text without case sensitivity and show identical text once in the entire card; preserve a different localized type. Keep fixed 12px text, foreground type labels with at least 4.5:1 contrast, pale rounded borders and horizontal padding aligned with the section heading. Each share uses the session model aggregate as denominator. Keep actual invocation IDs in subagent cards' HTML title attributes and React keys; Main Agent has a separate frontend marker without a fabricated daemon source ID. Omit ordinals, nested disclosures and inclusion notices.
|
||||
|
||||
## Affected files and constraints
|
||||
|
||||
Update TokenUsagePanel.tsx, its CSS Module and focused tests, client/i18n.tsx and the browser E2E spec. Reuse the shared portal-aware Tooltip primitives, TokenSummary for the overview, and TokenDetails for model/agent rows. Add no dependencies or backend changes. Preserve unavailable/loading/error/retry states, session mismatch protection and optional legacy source/tool sections. Retain the two-child agent header's right alignment.
|
||||
|
||||
Keep punctuation and model-specific accessible-name composition in the language catalog, including full-width Chinese punctuation. Keep Token Usage translations in a dedicated interactive-only dictionary, replaced with an empty stub in the read-only transcript build. Guard that the transcript contains no Token Usage keys or lookups, while the interactive entry retains English and Chinese copy.
|
||||
|
||||
## Validation and acceptance
|
||||
|
||||
Run build, typecheck, bundle, focused tests, lint/format and Chromium browser tests: 1200px English light, Chinese light and English dark, plus 390px English touch light and Chinese touch in light and dark themes. This matrix does not claim WebKit coverage. Cover normalized model sorting with fixtures whose raw and normalized orders disagree, cached-only input, legacy totals, zero values, Main Agent remainder bounds (assert cache and reasoning caps through the complete tooltip, including a zero remainder), entire-card duplicate-name checks (including localized type capitalization) and source-ID HTML titles. Pin cached-only Main Agent subtraction and negative input/cache/reasoning floors, complete overview captions, and tooltip label/value pairs in both locales. Verify streamed timing values and their absence when unavailable in both locales, with timing details kept out of the compact row. Verify distinct model-specific Info names, visible-content constraints, small-text/icon/focus contrast, matching heading styles, colored-text contrast, long-name truncation, far-right type alignment on wide cards, grouped tooltip timing, share-tap persistence and adjacent-row access, tooltip hover/focus/touch behavior, and fixed fonts while actually dragging the sidebar narrower and wider, and right-aligned refresh metadata after wrapping at 320px with a fixed clock for repeatable layout checks. Pause automatic polling before page load in the manual refresh test. Verify that the initial response is visible and exactly one read has occurred, then require the click to produce exactly one new request and updated visible values. Confirm that suppressing the manual handler makes this assertion fail.
|
||||
|
||||
Capture a real saved session through the local bundled daemon without new model prompts or mocked stats, and compare overview/agent values with the response. Inspect desktop and touch screenshots and preserve the user's running preview. Screenshots do not verify provider billing; touch emulation does not replace physical-device or cross-browser testing.
|
||||
|
||||
## Open questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Token 消耗信息层级
|
||||
|
||||
[English](token-usage-hierarchy.md) | [简体中文](token-usage-hierarchy.zh-CN.md)
|
||||
|
||||
## 问题与范围
|
||||
|
||||
会话 Token 消耗面板在高卡片中重复指标,难以比较。本次调整呈现及提示交互,保留数值、Token 归一化、daemon 路由、轮询和 SDK 协议。按用户要求,将“缓存输入”“思考(包含在总输出中)”缩短为“缓存”“思考”。
|
||||
|
||||
## 设计
|
||||
|
||||
会话总量放在圆角两列摘要卡片上方,“总 Token”文案跟随数值同行显示并使用正文颜色达到至少 4.5:1 的文字对比度,右侧保留刷新及更新时间,换行后仍右对齐。摘要使用输入、总输出两列;会话总量固定 28px,摘要主数值固定 24px。输入保留归一化后的完整输入量,不减去缓存,下方显示缓存数量及缓存量/输入量比例;总输出下方显示思考数量。保留零值,小字使用 12px 普通无衬线字体及正文颜色,不显示包含关系提示。
|
||||
|
||||
模型按归一化总消耗降序排列。名称固定 15px 加粗,占比固定 14px,比例条沿用用户参考图配色。不显示序号色块、每个模型的总量行、点击展开或整行 hover 背景。面板统一使用一个 Tooltip provider,首次提示延迟 300ms,随后跨行查看即时打开。模型名后的 Info 图标在悬停、键盘聚焦或触屏点击时展示请求次数及平均延迟,其可访问名称包含对应模型名。保留上游新增的流式生成指标,并放在同一提示中逐行显示:平均首 token 延迟,以及生成时长为正时的平均生成耗时和输出速度;保持原计算方式和可用条件。Info 图标和自定义焦点环(包括刷新按钮)使用局部正文颜色,在浅色与深色主题下均与相邻卡片达到至少 3:1 的对比度,不修改全局主题 token。
|
||||
|
||||
比例条上方显示“输入 xxx(缓存率:xxx) · 总输出 xxx”,行内不显示单独的缓存数量和思考。指标保持单行,溢出时显示省略号。无论是否截断,悬停、聚焦或触屏点击始终显示完整的输入、缓存数量及缓存率、总输出、思考,每项各占一行。Info 图标和指标行使用普通箭头光标。消耗占比位于线条最右侧,悬停、聚焦或触屏点击进度条显示“Token 占比:百分比”。触屏提示在主动关闭前保持显示,提示内容不拦截对相邻指标行的点击。保留真实占比,即使异常来源超过 100% 也不静默截断显示数值。比例条行的顶部 margin、模型列表 gap 和模型标题底部 margin 均为零。拖动侧栏宽度时字号固定;“按模型”“按agent”“工具”标题使用一致的字号、字重及颜色。
|
||||
|
||||
“按agent”默认收起,首行固定显示“主Agent”,后面按归一化总消耗降序逐条显示子 agent,不分类或汇总。主 Agent 用量按会话模型总量减去已归属的子 agent 用量计算:先逐个归一化来源输入和总量再扣除,并单独扣除缓存和思考。剩余总量取非负值,输入不超过剩余总量,缓存不超过输入,输出为剩余总量减输入,思考不超过输出。主 Agent 是已归属子 agent 之外的剩余用量,不再次计入顶部总量。sources 为空时显示主 Agent;字段缺失时仍隐藏此区域,因为旧服务不支持区分来源。
|
||||
|
||||
所有 agent 使用相同卡片:原名称位于标题左侧,与名称不同的本地化类型位于最右侧,过长时省略,并在 HTML title 属性中保留完整内容。名称与类型相同(忽略大小写)时,在整张卡片中只显示一次;保留实际不同的本地化类型。文字固定 12px,类型文字使用正文颜色达到至少 4.5:1 的对比度,保留浅色圆角边框及与分区标题对齐的左右内边距。占比以顶部会话模型总量为分母。实际子 agent 的唯一执行 ID 保留在卡片 HTML title 属性及 React key 中;主 Agent 使用独立前端标记,不伪造服务端来源 ID。不显示序号、内层折叠或包含关系说明。
|
||||
|
||||
## 影响文件与约束
|
||||
|
||||
修改 TokenUsagePanel.tsx、对应 CSS Module 和定向测试、client/i18n.tsx 及浏览器 E2E 测试。复用接入 portal 的共享 Tooltip、总览 TokenSummary 和模型/agent 指标 TokenDetails。不新增依赖或修改后端;保留不可用、加载、错误、重试、会话不匹配保护以及旧协议可选的来源和工具区域。保留含名称和类型两个元素的 agent 标题右对齐布局。
|
||||
|
||||
标点及模型专属可访问名称在语言字典中组合,中文使用全角标点。Token 消耗文案独立存放,仅在交互页面使用;只读 transcript 构建时替换为空字典。产物测试验证 transcript 不含 Token 消耗键名或查询,交互入口仍保留中英文文案。
|
||||
|
||||
## 验证与验收
|
||||
|
||||
运行构建、类型检查、打包、定向测试、lint/格式检查和 Chromium 浏览器测试:1200px 英文浅色、中文浅色、英文深色,以及 390px 英文触屏浅色、中文触屏浅色和深色。不把此矩阵视为 WebKit 覆盖。用原始总量与归一化总量排序相反的模型验证排序,覆盖仅有缓存的输入、旧版总量、零值、主 Agent 剩余量边界(通过完整提示断言缓存和思考的上限,包括零余量)及整卡名称不重复,并验证来源 ID 的 HTML title。覆盖本地化类型大小写去重、仅缓存来源的主 Agent 扣除、输入/缓存/思考的非负下限、完整总览小字,以及两种语言的提示标签与数值配对。验证两种语言的流式耗时数值及不可用时不展示,且耗时详情不出现在紧凑行中。验证模型专属 Info 可访问名、可见内容约束、小字、图标与焦点对比度、标题样式一致、彩色文字对比度、长名称截断、宽卡片下类型最右对齐、跨行提示延迟分组、占比提示触屏保持及相邻行可点击,提示的悬停/聚焦/触屏操作及实际拖动侧栏变窄、变宽时字号不变,固定时间下 320px 侧栏换行后刷新行仍右对齐,以保证布局检查可重复。手动刷新测试在页面加载前暂停自动轮询,先验证初始响应已显示且只有一次读取,再要求点击产生恰好一个新请求和可见数值更新;确认拦截手动处理逻辑后断言会失败。
|
||||
|
||||
通过本地打包版 daemon 读取真实历史会话,不发送新模型请求或模拟统计,对照响应与总览及 agent 数值,目视检查桌面和触屏截图,并保留用户正在验证的服务。截图不验证供应商计费,触屏模拟不替代实体设备或跨浏览器测试。
|
||||
|
||||
## 待决问题
|
||||
|
||||
无。
|
||||
@@ -539,6 +539,14 @@ describe('build artifact — transcript entry (#11031)', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Token Usage copy and lookups in the interactive entry only', () => {
|
||||
expect(readTranscriptBundle()).not.toMatch(/["'`]tokenUsage\./);
|
||||
const interactive = readPackageJavascript();
|
||||
expect(interactive).toContain('tokenUsage.inputWithCacheRate');
|
||||
expect(interactive).toContain('Failed to load token usage.');
|
||||
expect(interactive).toContain('Token 消耗加载失败。');
|
||||
});
|
||||
|
||||
it('carries no Live Voice strings and looks none up', () => {
|
||||
// vite.lib.config.ts swaps client/live/messages.ts for an empty stub in
|
||||
// this build, which keeps ~200 entries out of every exported document. It
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
.heroHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
@@ -31,8 +32,13 @@
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.heroLabel {
|
||||
font-size: 12px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.heroMeta {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
@@ -52,52 +58,72 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refreshButton:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.refreshButton:hover,
|
||||
.refreshButton:focus-visible {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
.overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 20px 0 12px;
|
||||
}
|
||||
|
||||
.legendEntry {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.summaryMetric {
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.summaryMetric:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
.summaryMetric:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
.summaryMetric + .summaryMetric {
|
||||
border-left: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||
}
|
||||
.summaryMetric dt {
|
||||
line-height: 1.3;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legendSwatch {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
.summaryMetric dd {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.legendValue {
|
||||
color: var(--muted-foreground);
|
||||
.summaryValue {
|
||||
display: block;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 24px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.legendPercent {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--success-color) 14%, transparent);
|
||||
color: var(--success-color);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
.inputLabel {
|
||||
color: color-mix(in srgb, #1686ef 70%, var(--foreground));
|
||||
}
|
||||
.outputLabel {
|
||||
color: color-mix(in srgb, #00a8e8 70%, var(--foreground));
|
||||
}
|
||||
|
||||
.modelList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.overview,
|
||||
.modelSection,
|
||||
.detailSection {
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
background: var(--card);
|
||||
box-shadow: 0 4px 24px color-mix(in srgb, #4c7ee8 8%, transparent);
|
||||
}
|
||||
|
||||
.section {
|
||||
@@ -105,12 +131,12 @@
|
||||
}
|
||||
|
||||
details.section {
|
||||
margin-top: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0 0 8px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
color: color-mix(in srgb, #6f86ac 80%, var(--foreground));
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -135,10 +161,6 @@ details.section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsibleSummary .sectionTitle {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section[open] .collapsibleSummary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -148,54 +170,52 @@ details.section {
|
||||
transition: transform 150ms ease;
|
||||
}
|
||||
|
||||
.section[open] .collapseIcon {
|
||||
details[open] > summary > .collapseIcon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.modelCard {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--card);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.modelCard[data-color='0'] {
|
||||
--model-color: #4b67ff;
|
||||
}
|
||||
.modelCard[data-color='1'] {
|
||||
--model-color: #4f94e7;
|
||||
}
|
||||
.modelCard[data-color='2'] {
|
||||
--model-color: #42b17e;
|
||||
}
|
||||
.modelCard[data-color='3'] {
|
||||
--model-color: #ed8b21;
|
||||
}
|
||||
.modelCard[data-color='4'] {
|
||||
--model-color: #9468ed;
|
||||
}
|
||||
|
||||
.modelInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.modelName {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelMeta {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.modelTiming {
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.modelBreakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.toolList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -206,7 +226,6 @@ details.section {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.toolHeader {
|
||||
@@ -243,46 +262,38 @@ details.section {
|
||||
.subagentList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.subagentCard {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--card);
|
||||
--model-color: #4b67ff;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.subagentHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.subagentName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subagentType {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted-foreground);
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.subagentBreakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -318,3 +329,133 @@ details.section {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.collapsibleSummary:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.modelShare {
|
||||
flex-shrink: 0;
|
||||
color: color-mix(in srgb, var(--model-color) 65%, var(--foreground));
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.modelInfoButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
cursor: default;
|
||||
}
|
||||
.modelInfoButton:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.modelApiMeta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.shareRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.shareTrack {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, #4f94e7 12%, var(--card));
|
||||
}
|
||||
|
||||
.shareTrack:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.shareFill {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #4f94e7, var(--model-color));
|
||||
}
|
||||
|
||||
.summaryCaption {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
color: var(--foreground);
|
||||
font-family: inherit;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.subagentCard .modelShare {
|
||||
font-size: 12px;
|
||||
}
|
||||
.tokenDetails {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin: 12px 0 0;
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 1.6;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tokenDetails > div {
|
||||
display: inline;
|
||||
}
|
||||
.tokenDetails dt,
|
||||
.tokenDetails dd {
|
||||
display: inline;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tokenDetails > div + div::before {
|
||||
content: '·';
|
||||
margin: 0 8px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.tokenDetails dt {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tokenDetails[tabindex] {
|
||||
cursor: default;
|
||||
}
|
||||
.tokenDetails:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.tokenDetailsTooltip {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.tokenDetailsTooltip > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tokenDetailsTooltip dt,
|
||||
.tokenDetailsTooltip dd {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -132,26 +132,501 @@ function renderPanel(
|
||||
}
|
||||
|
||||
describe('TokenUsagePanel', () => {
|
||||
it('renders hero totals, model cards, and tool details', async () => {
|
||||
const getStats = vi.fn().mockResolvedValue(statsFixture());
|
||||
const container = renderPanel(getStats);
|
||||
it.each(['en', 'zh-CN'] as const)(
|
||||
'renders complete hero metrics in %s',
|
||||
async (language) => {
|
||||
const getStats = vi.fn().mockResolvedValue(statsFixture());
|
||||
const container = renderPanel(getStats, language);
|
||||
await act(async () => {});
|
||||
const zh = language === 'zh-CN';
|
||||
expect(getStats).toHaveBeenCalledTimes(1);
|
||||
// Hero grand total uses the provider-reported total across models.
|
||||
expect(container.textContent).toContain(
|
||||
zh ? '1.35M总 Token' : '1.35MTotal tokens',
|
||||
);
|
||||
expect(
|
||||
Array.from(container.querySelector('dl')!.querySelectorAll('dt')).map(
|
||||
(node) => node.textContent,
|
||||
),
|
||||
).toEqual(zh ? ['输入', '总输出'] : ['Input', 'Total output']);
|
||||
// Model cards sorted by total tokens desc.
|
||||
expect(container.textContent).toContain('qwen-plus::hybrid');
|
||||
expect(container.textContent).toContain('qwen-max::auto');
|
||||
expect(container.querySelector('dl')?.textContent).toBe(
|
||||
zh
|
||||
? '输入1M缓存:300K(30.0%)总输出350K思考:40K'
|
||||
: 'Input1MCache: 300K (30.0%)Total output350KReasoning: 40K',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('shows model shares, metadata and token details without disclosure', async () => {
|
||||
const stats = statsFixture();
|
||||
stats.models = Object.fromEntries(Object.entries(stats.models).reverse());
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
expect(getStats).toHaveBeenCalledTimes(1);
|
||||
// Hero grand total uses the provider-reported total across models.
|
||||
expect(container.textContent).toContain('1.35M');
|
||||
expect(container.textContent).not.toContain('3 prompts');
|
||||
// Model cards sorted by total tokens desc.
|
||||
expect(container.textContent).toContain('qwen-plus::hybrid');
|
||||
expect(container.textContent).toContain('qwen-max::auto');
|
||||
// Legend rows each on their own line: total input with a green dot,
|
||||
// cached input with a deeper-green dot and the cache-hit rate
|
||||
// (cached / prompt = 300,000 / 1,000,000).
|
||||
expect(container.textContent).toContain('Input');
|
||||
expect(container.textContent).toContain('Input1M');
|
||||
expect(container.textContent).toContain('Cached input');
|
||||
expect(container.textContent).toContain('300K');
|
||||
expect(container.textContent).toContain('30.0%');
|
||||
expect(container.textContent).toContain('Total output350K');
|
||||
const models = container.querySelectorAll('[data-color]');
|
||||
expect(models).toHaveLength(2);
|
||||
expect(models[0]?.getAttribute('data-color')).toBe('0');
|
||||
expect(models[1]?.getAttribute('data-color')).toBe('1');
|
||||
expect(models[0]?.textContent).toMatch(/^qwen-plus::hybrid/);
|
||||
expect(models[1]?.textContent).toMatch(/^qwen-max::auto/);
|
||||
expect(models[0]?.querySelector('details, summary')).toBeNull();
|
||||
expect(
|
||||
Array.from(models).map((model) =>
|
||||
model.querySelector('button')?.getAttribute('aria-label'),
|
||||
),
|
||||
).toEqual([
|
||||
'qwen-plus::hybrid: Requests · Avg latency',
|
||||
'qwen-max::auto: Requests · Avg latency',
|
||||
]);
|
||||
expect(models[0]?.textContent).toBe(
|
||||
'qwen-plus::hybridInput900K (cache rate: 33.3%)Total output300K88.9%',
|
||||
);
|
||||
expect(models[1]?.textContent).toBe(
|
||||
'qwen-max::autoInput100K (cache rate: 0.0%)Total output50K11.1%',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ prompt: 200, cached: 100 },
|
||||
{ prompt: 0, cached: 200 },
|
||||
])(
|
||||
'sorts models by normalized total with legacy input $prompt/cache $cached',
|
||||
async ({ prompt, cached }) => {
|
||||
const stats = statsFixture();
|
||||
stats.models = {
|
||||
reported: {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 20,
|
||||
total: 120,
|
||||
cached: 0,
|
||||
thoughts: 5,
|
||||
},
|
||||
},
|
||||
legacy: {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: { prompt, candidates: 30, total: 0, cached, thoughts: 10 },
|
||||
},
|
||||
};
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
const models = container.querySelectorAll('[data-color]');
|
||||
expect(
|
||||
Array.from(models).map((model) =>
|
||||
model.querySelector('[title]')?.getAttribute('title'),
|
||||
),
|
||||
).toEqual(['legacy', 'reported']);
|
||||
expect(models[0]?.lastElementChild?.textContent).toBe('66.7%');
|
||||
expect(container.querySelector('dl')?.textContent).toBe(
|
||||
`Input300Cache: ${cached} (${((cached / 300) * 100).toFixed(1)}%)Total output60Reasoning: 15`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps individual sources sorted by normalized totals without grouping', async () => {
|
||||
const stats = statsFixture();
|
||||
stats.sources = [
|
||||
{
|
||||
...stats.sources![0]!,
|
||||
id: 'invocation-one',
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
cached: 80,
|
||||
candidates: 30,
|
||||
thoughts: 10,
|
||||
total: 130,
|
||||
},
|
||||
},
|
||||
{
|
||||
...stats.sources![0]!,
|
||||
id: 'invocation-two',
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
cached: 80,
|
||||
candidates: 30,
|
||||
thoughts: 10,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
const cards = container.querySelectorAll('[data-source-id]');
|
||||
expect(cards).toHaveLength(2);
|
||||
expect(cards[0]?.getAttribute('data-source-id')).toBe('invocation-two');
|
||||
expect(cards[1]?.getAttribute('data-source-id')).toBe('invocation-one');
|
||||
expect(Array.from(cards).map((card) => card.getAttribute('title'))).toEqual(
|
||||
['invocation-two', 'invocation-one'],
|
||||
);
|
||||
expect(cards[0]?.firstElementChild?.children).toHaveLength(2);
|
||||
expect(cards[0]?.firstElementChild?.children[0]?.textContent).toBe(
|
||||
'echoer',
|
||||
);
|
||||
expect(cards[0]?.firstElementChild?.children[1]?.textContent).toBe(
|
||||
'General-purpose',
|
||||
);
|
||||
expect(cards[0]?.textContent).toContain('Input100');
|
||||
expect(cards[0]?.textContent).toContain('Total output40');
|
||||
expect(cards[0]?.textContent).toContain('Input100 (cache rate: 80.0%)');
|
||||
});
|
||||
|
||||
it('puts Main Agent first with the remaining session usage', async () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(statsFixture()));
|
||||
await act(async () => {});
|
||||
const main = container.querySelector('[data-main-agent]');
|
||||
expect(main?.parentElement?.firstElementChild).toBe(main);
|
||||
expect(main?.getAttribute('data-source-id')).toBeNull();
|
||||
expect(main?.textContent).toContain('Main Agent');
|
||||
expect(main?.textContent).toContain('Input700K (cache rate: 28.6%)');
|
||||
expect(main?.textContent).toContain('Total output200K');
|
||||
expect(main?.lastElementChild?.lastElementChild?.textContent).toBe('66.7%');
|
||||
});
|
||||
|
||||
it('shows only Main Agent when there are no subagent calls', async () => {
|
||||
const stats = statsFixture();
|
||||
stats.sources = [];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats), 'zh-CN');
|
||||
await act(async () => {});
|
||||
const main = container.querySelector('[data-main-agent]');
|
||||
expect(container.textContent).toContain('按agent');
|
||||
expect(main?.textContent).toContain('主Agent');
|
||||
expect(main?.textContent).toContain('输入1M(缓存率:30.0%)');
|
||||
expect(main?.textContent).toContain('总输出350K');
|
||||
expect(main?.lastElementChild?.lastElementChild?.textContent).toBe(
|
||||
'100.0%',
|
||||
);
|
||||
expect(container.querySelectorAll('[data-source-id]')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('normalizes each legacy source before deriving Main Agent', async () => {
|
||||
const stats = statsFixture();
|
||||
stats.models = {
|
||||
model: {
|
||||
api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 300 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 50,
|
||||
total: 160,
|
||||
cached: 40,
|
||||
thoughts: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
stats.sources = [
|
||||
{
|
||||
...stats.sources![0]!,
|
||||
id: 'legacy',
|
||||
tokens: { prompt: 10, candidates: 5, total: 0, cached: 4, thoughts: 2 },
|
||||
},
|
||||
{
|
||||
...stats.sources![1]!,
|
||||
id: 'reported',
|
||||
tokens: {
|
||||
prompt: 10,
|
||||
candidates: 5,
|
||||
total: 15,
|
||||
cached: 2,
|
||||
thoughts: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
const main = container.querySelector('[data-main-agent]');
|
||||
expect(main?.textContent).toContain('Input80 (cache rate: 42.5%)');
|
||||
expect(main?.textContent).toContain('Total output48');
|
||||
expect(main?.lastElementChild?.lastElementChild?.textContent).toBe('80.0%');
|
||||
});
|
||||
|
||||
it.each(['en', 'zh-CN'] as const)(
|
||||
'bounds and labels Main Agent details in %s',
|
||||
async (language) => {
|
||||
const stats = statsFixture();
|
||||
stats.models = {
|
||||
model: {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 20,
|
||||
total: 120,
|
||||
cached: 80,
|
||||
thoughts: 15,
|
||||
},
|
||||
},
|
||||
};
|
||||
stats.sources = [
|
||||
{
|
||||
...stats.sources![0]!,
|
||||
tokens: {
|
||||
prompt: 80,
|
||||
candidates: 10,
|
||||
total: 90,
|
||||
cached: 10,
|
||||
thoughts: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats), language);
|
||||
await act(async () => {});
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-color] button')
|
||||
?.getAttribute('aria-label'),
|
||||
).toBe(
|
||||
language === 'zh-CN'
|
||||
? 'model:请求 · 平均延迟'
|
||||
: 'model: Requests · Avg latency',
|
||||
);
|
||||
const main = container.querySelector('[data-main-agent]')!;
|
||||
await act(async () => main.querySelector<HTMLElement>('dl')!.focus());
|
||||
expect(
|
||||
Array.from(
|
||||
document
|
||||
.querySelector('[data-slot="tooltip-content"]')!
|
||||
.querySelectorAll(':scope > dl > div'),
|
||||
).map((row) => [
|
||||
row.querySelector('dt')?.textContent,
|
||||
row.querySelector('dd')?.textContent,
|
||||
]),
|
||||
).toEqual(
|
||||
language === 'zh-CN'
|
||||
? [
|
||||
['输入', '20'],
|
||||
['缓存', '20(100.0%)'],
|
||||
['总输出', '10'],
|
||||
['思考', '10'],
|
||||
]
|
||||
: [
|
||||
['Input', '20'],
|
||||
['Cache', '20 (100.0%)'],
|
||||
['Total output', '10'],
|
||||
['Reasoning', '10'],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'cached-only source',
|
||||
model: {
|
||||
prompt: 100,
|
||||
candidates: 50,
|
||||
total: 160,
|
||||
cached: 40,
|
||||
thoughts: 10,
|
||||
},
|
||||
source: { prompt: 0, candidates: 5, total: 20, cached: 12, thoughts: 1 },
|
||||
values: ['88', '28 (31.8%)', '52', '9'],
|
||||
},
|
||||
{
|
||||
name: 'over-reported input, cache and reasoning',
|
||||
model: {
|
||||
prompt: 100,
|
||||
candidates: 100,
|
||||
total: 200,
|
||||
cached: 20,
|
||||
thoughts: 10,
|
||||
},
|
||||
source: {
|
||||
prompt: 150,
|
||||
candidates: 5,
|
||||
total: 160,
|
||||
cached: 60,
|
||||
thoughts: 15,
|
||||
},
|
||||
values: ['0', '0 (0.0%)', '40', '0'],
|
||||
},
|
||||
])(
|
||||
'normalizes and floors Main Agent for $name',
|
||||
async ({ model, source, values }) => {
|
||||
const stats = statsFixture();
|
||||
stats.models = {
|
||||
model: { ...stats.models['qwen-plus::hybrid']!, tokens: model },
|
||||
};
|
||||
stats.sources = [{ ...stats.sources![0]!, tokens: source }];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
const main = container.querySelector('[data-main-agent]')!;
|
||||
await act(async () => main.querySelector<HTMLElement>('dl')!.focus());
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll(
|
||||
'[data-slot="tooltip-content"] > dl > div > dd',
|
||||
),
|
||||
).map((node) => node.textContent),
|
||||
).toEqual(values);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])(
|
||||
'keeps Main Agent at zero when sources account for %s times the session',
|
||||
async (factor) => {
|
||||
const stats = statsFixture();
|
||||
stats.models = {
|
||||
model: {
|
||||
api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 },
|
||||
tokens: {
|
||||
prompt: 100,
|
||||
candidates: 20,
|
||||
total: 120,
|
||||
cached: 50,
|
||||
thoughts: 10,
|
||||
},
|
||||
},
|
||||
};
|
||||
stats.sources = [
|
||||
{
|
||||
...stats.sources![0]!,
|
||||
tokens: {
|
||||
prompt: factor === 1 ? 100 : 50,
|
||||
candidates: 20 * factor,
|
||||
total: 120 * factor,
|
||||
cached: factor === 1 ? 50 : 0,
|
||||
thoughts: factor === 1 ? 10 : 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
const main = container.querySelector('[data-main-agent]');
|
||||
expect(main?.parentElement?.firstElementChild).toBe(main);
|
||||
expect(main?.textContent).toContain('Input0 (cache rate: 0.0%)');
|
||||
expect(main?.textContent).toContain('Total output0');
|
||||
expect(main?.lastElementChild?.lastElementChild?.textContent).toBe(
|
||||
'0.0%',
|
||||
);
|
||||
expect(main?.textContent).not.toMatch(/NaN|Infinity|-/);
|
||||
if (factor === 2) {
|
||||
expect(
|
||||
container
|
||||
.querySelector('[data-source-id] [role="img"]')
|
||||
?.getAttribute('aria-label'),
|
||||
).toBe('Token share: 200.0%');
|
||||
await act(async () => main!.querySelector<HTMLElement>('dl')!.focus());
|
||||
expect(
|
||||
Array.from(
|
||||
document
|
||||
.querySelector('[data-slot="tooltip-content"]')!
|
||||
.querySelectorAll(':scope > dl > div > dd'),
|
||||
).map((node) => node.textContent),
|
||||
).toEqual(['0', '0 (0.0%)', '0', '0']);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the full session total for subagent shares', async () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(statsFixture()));
|
||||
await act(async () => {});
|
||||
const first = container.querySelector('[data-source-id="agent-1"]');
|
||||
const second = container.querySelector('[data-source-id="agent-2"]');
|
||||
expect(first?.lastElementChild?.lastElementChild?.textContent).toBe(
|
||||
'22.2%',
|
||||
);
|
||||
expect(second?.lastElementChild?.lastElementChild?.textContent).toBe(
|
||||
'11.1%',
|
||||
);
|
||||
expect(first?.textContent).not.toContain(' Token');
|
||||
});
|
||||
|
||||
it('shows matching subagent name and type once with compact metrics', async () => {
|
||||
const stats = statsFixture();
|
||||
stats.sources = [
|
||||
'managed-auto-memory-extractor',
|
||||
'managed-auto-memory-dreamer',
|
||||
].map((type, index) => ({
|
||||
...stats.sources![0]!,
|
||||
type,
|
||||
name: type,
|
||||
id: `memory-${index}`,
|
||||
}));
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats), 'zh-CN');
|
||||
await act(async () => {});
|
||||
for (const [index, type] of [
|
||||
'managed-auto-memory-extractor',
|
||||
'managed-auto-memory-dreamer',
|
||||
].entries()) {
|
||||
const card = container.querySelector(
|
||||
`[data-source-id="memory-${index}"]`,
|
||||
);
|
||||
const header = card?.firstElementChild;
|
||||
expect(header?.children).toHaveLength(1);
|
||||
expect(header?.children[0]?.textContent).toBe(type);
|
||||
expect(card?.textContent).toBe(
|
||||
`${type}输入200K(缓存率:40.0%)总输出100K22.2%`,
|
||||
);
|
||||
expect(card?.querySelectorAll('dt')).toHaveLength(2);
|
||||
expect(card?.textContent).not.toContain('思考');
|
||||
expect(card?.textContent).toContain('输入200K');
|
||||
expect(card?.textContent).toContain('总输出100K');
|
||||
expect(card?.textContent).toContain('输入200K(缓存率:40.0%)');
|
||||
expect(card?.querySelectorAll('dt')[0]?.textContent).toBe('输入');
|
||||
expect(card?.querySelectorAll('dt')[1]?.textContent).toBe('总输出');
|
||||
expect(card?.querySelectorAll('dd')[0]?.textContent).toBe(
|
||||
'200K(缓存率:40.0%)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['en', 'zh-CN'] as const)(
|
||||
'deduplicates localized agent names in %s',
|
||||
async (language) => {
|
||||
const stats = statsFixture();
|
||||
stats.sources = [
|
||||
{
|
||||
...stats.sources![0]!,
|
||||
name: 'general-purpose',
|
||||
type: 'general-purpose',
|
||||
},
|
||||
];
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats), language);
|
||||
await act(async () => {});
|
||||
const header =
|
||||
container.querySelector('[data-source-id]')!.firstElementChild!;
|
||||
expect(
|
||||
Array.from(header.children).map((node) => node.textContent),
|
||||
).toEqual(
|
||||
language === 'zh-CN'
|
||||
? ['general-purpose', '通用']
|
||||
: ['general-purpose'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('uses zero shares and cache rates when API requests have no tokens', async () => {
|
||||
const stats = statsFixture();
|
||||
for (const model of Object.values(stats.models)) {
|
||||
model.tokens = {
|
||||
prompt: 0,
|
||||
cached: 0,
|
||||
candidates: 0,
|
||||
thoughts: 0,
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
stats.sources = stats.sources?.map((source) => ({
|
||||
...source,
|
||||
tokens: { prompt: 0, cached: 0, candidates: 0, thoughts: 0, total: 0 },
|
||||
}));
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
const summary = container
|
||||
.querySelector('[title="qwen-plus::hybrid"]')
|
||||
?.closest('[data-color]');
|
||||
expect(summary?.textContent).toContain('0.0%');
|
||||
expect(summary?.textContent).not.toContain(' Token');
|
||||
expect(summary?.textContent).toContain('Input0 (cache rate: 0.0%)');
|
||||
expect(container.innerHTML).not.toMatch(/NaN|Infinity/);
|
||||
const source = container.querySelector('[data-source-id]');
|
||||
expect(source?.querySelectorAll('dt')).toHaveLength(2);
|
||||
expect(source?.textContent).not.toContain('Reasoning');
|
||||
expect(source?.textContent).toContain('Input0 (cache rate: 0.0%)');
|
||||
expect(source?.lastElementChild?.lastElementChild?.textContent).toBe(
|
||||
'0.0%',
|
||||
);
|
||||
});
|
||||
|
||||
it('splits timed model latency into first token and generation', async () => {
|
||||
@@ -171,12 +646,30 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'First-token latency 1.2s · Generation 500ms · Output speed 85.0 tok/s',
|
||||
expect(container.textContent).not.toContain('First-token latency');
|
||||
const models = container.querySelectorAll('[data-color]');
|
||||
await act(async () =>
|
||||
models[0]!.querySelector<HTMLButtonElement>('button')!.focus(),
|
||||
);
|
||||
expect(container.textContent).toContain('First-token latency 900msInput');
|
||||
expect(container.textContent?.match(/Generation /g)).toHaveLength(1);
|
||||
expect(container.textContent?.match(/Output speed/g)).toHaveLength(1);
|
||||
const popup = () =>
|
||||
document.querySelector('[data-slot="tooltip-content"]')!;
|
||||
expect(
|
||||
Array.from(popup().querySelectorAll(':scope > div > span')).map(
|
||||
(row) => row.textContent,
|
||||
),
|
||||
).toEqual([
|
||||
'Requests 4',
|
||||
'Avg latency 800ms',
|
||||
'First-token latency 1.2s',
|
||||
'Generation 500ms',
|
||||
'Output speed 85.0 tok/s',
|
||||
]);
|
||||
await act(async () =>
|
||||
models[1]!.querySelector<HTMLButtonElement>('button')!.focus(),
|
||||
);
|
||||
expect(popup().textContent).toContain('First-token latency 900ms');
|
||||
expect(popup().textContent).not.toContain('Generation ');
|
||||
expect(popup().textContent).not.toContain('Output speed');
|
||||
});
|
||||
|
||||
it('localizes the model timing line', async () => {
|
||||
@@ -190,9 +683,24 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats), 'zh-CN');
|
||||
await act(async () => {});
|
||||
|
||||
expect(container.textContent).toContain(
|
||||
'首 token 延迟 500ms · 生成 2.0s · 输出速度 75.0 tok/s',
|
||||
expect(container.textContent).not.toContain('首 token 延迟');
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-color] button')!
|
||||
.focus(),
|
||||
);
|
||||
const popup = document.querySelector('[data-slot="tooltip-content"]')!;
|
||||
expect(
|
||||
Array.from(popup.querySelectorAll(':scope > div > span')).map(
|
||||
(row) => row.textContent,
|
||||
),
|
||||
).toEqual([
|
||||
'请求 4',
|
||||
'平均延迟 800ms',
|
||||
'首 token 延迟 500ms',
|
||||
'生成 2.0s',
|
||||
'输出速度 75.0 tok/s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits the timing line for models without streamed timing', async () => {
|
||||
@@ -200,7 +708,14 @@ describe('TokenUsagePanel', () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(container.textContent).toContain('qwen-plus::hybrid');
|
||||
expect(container.textContent).not.toContain('First-token latency');
|
||||
await act(async () =>
|
||||
container
|
||||
.querySelector<HTMLButtonElement>('[data-color] button')!
|
||||
.focus(),
|
||||
);
|
||||
const popup = document.querySelector('[data-slot="tooltip-content"]')!;
|
||||
expect(popup.textContent).toContain('Requests 4');
|
||||
expect(popup.textContent).not.toContain('First-token latency');
|
||||
});
|
||||
|
||||
it('promotes compact values that round into the next unit', async () => {
|
||||
@@ -280,30 +795,22 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(statsFixture()));
|
||||
await act(async () => {});
|
||||
|
||||
const sections = Array.from(container.querySelectorAll('details'));
|
||||
const sections = Array.from(container.querySelectorAll('details')).filter(
|
||||
(section) =>
|
||||
section.querySelector(':scope > summary')?.textContent === 'By agent' ||
|
||||
section.querySelector(':scope > summary')?.textContent === 'Tools',
|
||||
);
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections.every((section) => !section.open)).toBe(true);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll('details')).every(
|
||||
(section) => !section.open,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
act(() => sections[0]!.querySelector('summary')!.click());
|
||||
expect(sections[0]!.open).toBe(true);
|
||||
});
|
||||
|
||||
it('renders a legend dot for every token category', async () => {
|
||||
const getStats = vi.fn().mockResolvedValue(statsFixture());
|
||||
const container = renderPanel(getStats);
|
||||
await act(async () => {});
|
||||
const html = container.innerHTML;
|
||||
// Inline dot colors (CSS Modules class names are lowercased in some
|
||||
// environments). All four categories get a dot; cache-hit carries the
|
||||
// green tag.
|
||||
expect(html).toContain('style="background: var(--success-color);"');
|
||||
expect(html).toContain(
|
||||
'color-mix(in srgb, var(--success-color) 70%, var(--foreground))',
|
||||
);
|
||||
expect(html).toContain('var(--primary)');
|
||||
expect(html).toContain('var(--warning-color)');
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ total: 130, expectedOutput: '30' },
|
||||
{ total: 140, expectedOutput: '40' },
|
||||
@@ -326,15 +833,13 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
|
||||
const modelCard =
|
||||
container.querySelector('[title="model"]')?.parentElement
|
||||
?.parentElement;
|
||||
const modelCard = container
|
||||
.querySelector('[title="model"]')
|
||||
?.closest('[data-color]');
|
||||
expect(modelCard?.textContent).toContain('Input100');
|
||||
expect(modelCard?.textContent).toContain('Cached input8080.0%');
|
||||
expect(modelCard?.textContent).toContain('Input100 (cache rate: 80.0%)');
|
||||
expect(modelCard?.textContent).toContain(`Total output${expectedOutput}`);
|
||||
expect(modelCard?.textContent).toContain(
|
||||
'Reasoning (included in total output)10',
|
||||
);
|
||||
expect(modelCard?.textContent).not.toContain('Reasoning');
|
||||
},
|
||||
);
|
||||
|
||||
@@ -364,15 +869,17 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
|
||||
const modelCard =
|
||||
container.querySelector('[title="model"]')?.parentElement?.parentElement;
|
||||
const modelCard = container
|
||||
.querySelector('[title="model"]')
|
||||
?.closest('[data-color]');
|
||||
const subagentCard = container.querySelector('[title="cached-only-agent"]')
|
||||
?.parentElement?.parentElement;
|
||||
for (const card of [modelCard, subagentCard]) {
|
||||
expect(card?.textContent).toContain('Input40');
|
||||
expect(card?.textContent).toContain('Cached input40100.0%');
|
||||
expect(card?.textContent).toContain('Total output12');
|
||||
}
|
||||
expect(modelCard?.textContent).toContain('Input40');
|
||||
expect(modelCard?.textContent).toContain('Input40 (cache rate: 100.0%)');
|
||||
expect(modelCard?.textContent).toContain('Total output12');
|
||||
expect(subagentCard?.textContent).toContain('Input40');
|
||||
expect(subagentCard?.textContent).toContain('Input40 (cache rate: 100.0%)');
|
||||
expect(subagentCard?.textContent).toContain('Total output12');
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -397,9 +904,9 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
|
||||
const modelCard =
|
||||
container.querySelector('[title="model"]')?.parentElement
|
||||
?.parentElement;
|
||||
const modelCard = container
|
||||
.querySelector('[title="model"]')
|
||||
?.closest('[data-color]');
|
||||
const hero = container.firstElementChild?.firstElementChild;
|
||||
expect(hero?.textContent?.startsWith(expected)).toBe(true);
|
||||
expect(modelCard?.textContent).toContain(`Total output${expectedOutput}`);
|
||||
@@ -423,8 +930,9 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
|
||||
const modelCard =
|
||||
container.querySelector('[title="model"]')?.parentElement?.parentElement;
|
||||
const modelCard = container
|
||||
.querySelector('[title="model"]')
|
||||
?.closest('[data-color]');
|
||||
const hero = container.firstElementChild?.firstElementChild;
|
||||
expect(hero?.textContent?.startsWith('110')).toBe(true);
|
||||
expect(modelCard?.textContent).toContain('Total output10');
|
||||
@@ -719,10 +1227,8 @@ describe('TokenUsagePanel', () => {
|
||||
const container = renderPanel(vi.fn().mockResolvedValue(stats));
|
||||
await act(async () => {});
|
||||
|
||||
expect(container.textContent).not.toContain('Subagents');
|
||||
expect(container.textContent).not.toContain(
|
||||
'No subagent calls in this session yet.',
|
||||
);
|
||||
expect(container.textContent).not.toContain('By agent');
|
||||
expect(container.querySelector('[data-main-agent]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows an empty state for a session without tool calls', async () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ChevronRightIcon, RefreshCwIcon } from 'lucide-react';
|
||||
import { ChevronRightIcon, InfoIcon, RefreshCwIcon } from 'lucide-react';
|
||||
import type {
|
||||
DaemonSessionActions,
|
||||
DaemonSessionStatsModelMetrics,
|
||||
DaemonSessionStatsSource,
|
||||
DaemonSessionStatsStatus,
|
||||
} from '@qwen-code/web-shell/daemon-react-sdk';
|
||||
import { useI18n } from '../../i18n';
|
||||
@@ -13,6 +12,12 @@ import {
|
||||
localizeAgentTypeName,
|
||||
localizeToolDisplayName,
|
||||
} from '../messages/toolFormatting';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../ui/tooltip';
|
||||
import styles from './TokenUsagePanel.module.css';
|
||||
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
@@ -22,13 +27,6 @@ interface TokenUsagePanelProps {
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
type SegmentKey = 'input' | 'cached' | 'output' | 'thoughts';
|
||||
|
||||
interface Segment {
|
||||
key: SegmentKey;
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface ModelEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -52,17 +50,6 @@ function outputTokens(
|
||||
return Math.max(0, totalTokens(tokens) - inputTokens(tokens));
|
||||
}
|
||||
|
||||
// Inline (not CSS Modules) so legend dots survive environments where the
|
||||
// compiled local class names are lowercased and `styles['segmentInput']`
|
||||
// resolves to undefined. Input/cache stay in one green family; output and
|
||||
// thoughts get their own soft hues.
|
||||
const SEGMENT_COLORS: Record<SegmentKey, string> = {
|
||||
input: 'var(--success-color)',
|
||||
cached: 'color-mix(in srgb, var(--success-color) 70%, var(--foreground))',
|
||||
output: 'var(--primary)',
|
||||
thoughts: 'var(--warning-color)',
|
||||
};
|
||||
|
||||
function flattenModels(
|
||||
models: Record<string, DaemonSessionStatsModelMetrics>,
|
||||
): ModelEntry[] {
|
||||
@@ -74,19 +61,6 @@ function flattenModels(
|
||||
);
|
||||
}
|
||||
|
||||
function segmentsOf(m: {
|
||||
tokens: DaemonSessionStatsModelMetrics['tokens'];
|
||||
}): Segment[] {
|
||||
const { thoughts, cached } = m.tokens;
|
||||
const raw: Segment[] = [
|
||||
{ key: 'input', value: inputTokens(m.tokens) },
|
||||
{ key: 'cached', value: cached },
|
||||
{ key: 'output', value: outputTokens(m.tokens) },
|
||||
{ key: 'thoughts', value: thoughts },
|
||||
];
|
||||
return raw.filter((s) => s.value > 0);
|
||||
}
|
||||
|
||||
function formatCount(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
@@ -183,7 +157,7 @@ export function TokenUsagePanel({
|
||||
const hasModels = status !== null && flattenModels(status.models).length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.panel} data-testid="token-usage-panel">
|
||||
{error ? (
|
||||
<div className={styles.error} role="alert">
|
||||
<span>{error}</span>
|
||||
@@ -239,12 +213,39 @@ function TokenUsageBody({
|
||||
const subagentEntries = [...(status.sources ?? [])].sort(
|
||||
(a, b) => totalTokens(b.tokens) - totalTokens(a.tokens),
|
||||
);
|
||||
const mainRemainder = subagentEntries.reduce(
|
||||
(remaining, { tokens }) => ({
|
||||
prompt: Math.max(0, remaining.prompt - inputTokens(tokens)),
|
||||
total: Math.max(0, remaining.total - totalTokens(tokens)),
|
||||
cached: Math.max(0, remaining.cached - tokens.cached),
|
||||
thoughts: Math.max(0, remaining.thoughts - tokens.thoughts),
|
||||
}),
|
||||
{
|
||||
prompt: totals.prompt,
|
||||
total: grandTotal,
|
||||
cached: totals.cached,
|
||||
thoughts: totals.thoughts,
|
||||
},
|
||||
);
|
||||
const mainInput = Math.min(mainRemainder.prompt, mainRemainder.total);
|
||||
const mainOutput = mainRemainder.total - mainInput;
|
||||
const mainTokens = {
|
||||
prompt: mainInput,
|
||||
// Normalized output keeps the legacy fallback from adding reasoning again.
|
||||
candidates: mainOutput,
|
||||
total: mainRemainder.total,
|
||||
cached: Math.min(mainRemainder.cached, mainInput),
|
||||
thoughts: Math.min(mainRemainder.thoughts, mainOutput),
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider delayDuration={300} disableHoverableContent>
|
||||
<div className={styles.hero}>
|
||||
<div className={styles.heroHeader}>
|
||||
<div className={styles.heroValue}>{formatCompact(grandTotal)}</div>
|
||||
<div className={styles.heroValue}>
|
||||
{formatCompact(grandTotal)}
|
||||
<span className={styles.heroLabel}>{t('tokenUsage.total')}</span>
|
||||
</div>
|
||||
<span className={styles.heroMeta}>
|
||||
<button
|
||||
type="button"
|
||||
@@ -264,68 +265,60 @@ function TokenUsageBody({
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.legend}>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.input')}
|
||||
value={totals.prompt}
|
||||
color={SEGMENT_COLORS.input}
|
||||
/>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.cached')}
|
||||
value={totals.cached}
|
||||
color={SEGMENT_COLORS.cached}
|
||||
percent={
|
||||
totals.cached > 0 && totals.prompt > 0
|
||||
? `${((totals.cached / totals.prompt) * 100).toFixed(1)}%`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.output')}
|
||||
value={totals.output}
|
||||
color={SEGMENT_COLORS.output}
|
||||
/>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.thoughts')}
|
||||
value={totals.thoughts}
|
||||
color={SEGMENT_COLORS.thoughts}
|
||||
/>
|
||||
</div>
|
||||
<TokenSummary
|
||||
input={totals.prompt}
|
||||
output={totals.output}
|
||||
cached={totals.cached}
|
||||
thoughts={totals.thoughts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className={styles.section}>
|
||||
<section className={`${styles.section} ${styles.modelSection}`}>
|
||||
<h3 className={styles.sectionTitle}>{t('tokenUsage.models')}</h3>
|
||||
<div className={styles.modelList}>
|
||||
{entries.map((entry) => (
|
||||
<ModelCard key={entry.key} entry={entry} />
|
||||
{entries.map((entry, index) => (
|
||||
<ModelCard
|
||||
key={entry.key}
|
||||
entry={entry}
|
||||
grandTotal={grandTotal}
|
||||
rank={index + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{status.sources !== undefined && (
|
||||
<details className={styles.section}>
|
||||
<details className={`${styles.section} ${styles.detailSection}`}>
|
||||
<summary className={styles.collapsibleSummary}>
|
||||
<h3 className={styles.sectionTitle}>{t('tokenUsage.subagents')}</h3>
|
||||
<h3 className={styles.sectionTitle}>{t('tokenUsage.agents')}</h3>
|
||||
<ChevronRightIcon
|
||||
className={styles.collapseIcon}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</summary>
|
||||
{subagentEntries.length > 0 ? (
|
||||
<div className={styles.subagentList}>
|
||||
{subagentEntries.map((source) => (
|
||||
<SubagentCard key={source.id} source={source} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>{t('tokenUsage.noSubagents')}</div>
|
||||
)}
|
||||
<div className={styles.subagentList}>
|
||||
<AgentCard
|
||||
name={t('tokenUsage.mainAgent')}
|
||||
tokens={mainTokens}
|
||||
grandTotal={grandTotal}
|
||||
/>
|
||||
{subagentEntries.map((source) => (
|
||||
<AgentCard
|
||||
key={source.id}
|
||||
sourceId={source.id}
|
||||
name={source.name}
|
||||
type={source.type}
|
||||
tokens={source.tokens}
|
||||
grandTotal={grandTotal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{status.tools.byName !== undefined && (
|
||||
<details className={styles.section}>
|
||||
<details className={`${styles.section} ${styles.detailSection}`}>
|
||||
<summary className={styles.collapsibleSummary}>
|
||||
<h3 className={styles.sectionTitle}>{t('tokenUsage.tools')}</h3>
|
||||
<ChevronRightIcon
|
||||
@@ -337,42 +330,24 @@ function TokenUsageBody({
|
||||
<ToolList status={status} />
|
||||
</details>
|
||||
)}
|
||||
</>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendEntry({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
percent,
|
||||
function ModelCard({
|
||||
entry,
|
||||
grandTotal,
|
||||
rank,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
percent?: string;
|
||||
entry: ModelEntry;
|
||||
grandTotal: number;
|
||||
rank: number;
|
||||
}) {
|
||||
if (value <= 0) return null;
|
||||
return (
|
||||
<span className={styles.legendEntry}>
|
||||
{color && (
|
||||
<span
|
||||
className={styles.legendSwatch}
|
||||
style={{ background: color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
<span>{label}</span>
|
||||
<span className={styles.legendValue}>{formatCompact(value)}</span>
|
||||
{percent && <span className={styles.legendPercent}>{percent}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelCard({ entry }: { entry: ModelEntry }) {
|
||||
const { t } = useI18n();
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const m = entry.metrics;
|
||||
const input = inputTokens(m.tokens);
|
||||
const total = totalTokens(m.tokens);
|
||||
const share = grandTotal > 0 ? (total / grandTotal) * 100 : 0;
|
||||
const avgLatency =
|
||||
m.api.totalRequests > 0 ? m.api.totalLatencyMs / m.api.totalRequests : 0;
|
||||
const { generation } = m;
|
||||
@@ -393,100 +368,248 @@ function ModelCard({ entry }: { entry: ModelEntry }) {
|
||||
}
|
||||
: undefined;
|
||||
return (
|
||||
<div className={styles.modelCard}>
|
||||
<div className={styles.modelCard} data-color={(rank - 1) % 5}>
|
||||
<div className={styles.modelHeader}>
|
||||
<span className={styles.modelName} title={entry.key}>
|
||||
{entry.label}
|
||||
</span>
|
||||
<span className={styles.modelMeta}>
|
||||
{t('tokenUsage.requests')} {formatCount(m.api.totalRequests)}
|
||||
{m.api.totalRequests > 0 && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('tokenUsage.avgLatency')} {formatDuration(avgLatency)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{timing && (
|
||||
<div className={styles.modelTiming}>
|
||||
{t('tokenUsage.firstTokenLatency', {
|
||||
duration: formatDuration(timing.ttft),
|
||||
})}
|
||||
{timing.speed !== undefined && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('tokenUsage.generationTime', {
|
||||
duration: formatDuration(timing.generation),
|
||||
})}
|
||||
{' · '}
|
||||
{t('tokenUsage.outputSpeed', { rate: timing.speed.toFixed(1) })}
|
||||
</>
|
||||
)}
|
||||
<div className={styles.modelInfo}>
|
||||
<div className={styles.modelName} title={entry.key}>
|
||||
{entry.label}
|
||||
</div>
|
||||
<Tooltip open={infoOpen} onOpenChange={setInfoOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.modelInfoButton}
|
||||
onClick={(event) => {
|
||||
// Touch focus may open first; prevent Radix's click-close.
|
||||
event.preventDefault();
|
||||
setInfoOpen(true);
|
||||
}}
|
||||
aria-label={t('tokenUsage.modelApiMeta', {
|
||||
model: entry.label,
|
||||
})}
|
||||
>
|
||||
<InfoIcon size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
<div className={styles.modelApiMeta}>
|
||||
<span>
|
||||
{t('tokenUsage.requests')} {formatCount(m.api.totalRequests)}
|
||||
</span>
|
||||
<span>
|
||||
{t('tokenUsage.avgLatency')} {formatDuration(avgLatency)}
|
||||
</span>
|
||||
{timing && (
|
||||
<>
|
||||
<span>
|
||||
{t('tokenUsage.firstTokenLatency', {
|
||||
duration: formatDuration(timing.ttft),
|
||||
})}
|
||||
</span>
|
||||
{timing.speed !== undefined && (
|
||||
<>
|
||||
<span>
|
||||
{t('tokenUsage.generationTime', {
|
||||
duration: formatDuration(timing.generation),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('tokenUsage.outputSpeed', {
|
||||
rate: timing.speed.toFixed(1),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.modelBreakdown}>
|
||||
{segmentsOf(m).map((s) => (
|
||||
<LegendEntry
|
||||
key={s.key}
|
||||
label={t(`tokenUsage.${s.key}`)}
|
||||
value={s.value}
|
||||
color={SEGMENT_COLORS[s.key]}
|
||||
percent={
|
||||
s.key === 'cached' && input > 0
|
||||
? `${((m.tokens.cached / input) * 100).toFixed(1)}%`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<TokenDetails tokens={m.tokens} />
|
||||
<ShareBar share={share} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentCard({ source }: { source: DaemonSessionStatsSource }) {
|
||||
function ShareBar({ share }: { share: number }) {
|
||||
const { t } = useI18n();
|
||||
const { tokens } = source;
|
||||
const input = inputTokens(tokens);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
const label = t('tokenUsage.shareTooltip', { percent: share.toFixed(1) });
|
||||
return (
|
||||
<div className={styles.subagentCard}>
|
||||
<div className={styles.shareRow}>
|
||||
<Tooltip open={shareOpen} onOpenChange={setShareOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={styles.shareTrack}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setShareOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className={styles.shareFill} style={{ width: `${share}%` }} />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className={styles.modelShare}>{share.toFixed(1)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenSummary({
|
||||
input,
|
||||
output,
|
||||
cached,
|
||||
thoughts,
|
||||
}: {
|
||||
input: number;
|
||||
output: number;
|
||||
cached: number;
|
||||
thoughts: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<dl className={styles.overview}>
|
||||
<div className={styles.summaryMetric}>
|
||||
<dt className={styles.inputLabel}>{t('tokenUsage.input')}</dt>
|
||||
<dd>
|
||||
<strong className={styles.summaryValue}>
|
||||
{formatCompact(input)}
|
||||
</strong>
|
||||
<span className={styles.summaryCaption}>
|
||||
{t('tokenUsage.cachedDetail', {
|
||||
count: formatCompact(cached),
|
||||
percent: input > 0 ? ((cached / input) * 100).toFixed(1) : '0.0',
|
||||
})}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className={styles.summaryMetric}>
|
||||
<dt className={styles.outputLabel}>{t('tokenUsage.output')}</dt>
|
||||
<dd>
|
||||
<strong className={styles.summaryValue}>
|
||||
{formatCompact(output)}
|
||||
</strong>
|
||||
<span className={styles.summaryCaption}>
|
||||
{t('tokenUsage.thoughtsDetail', { count: formatCompact(thoughts) })}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenDetails({
|
||||
tokens,
|
||||
}: {
|
||||
tokens: DaemonSessionStatsModelMetrics['tokens'];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const input = inputTokens(tokens);
|
||||
const output = outputTokens(tokens);
|
||||
const { cached, thoughts } = tokens;
|
||||
const cacheRate = input > 0 ? ((cached / input) * 100).toFixed(1) : '0.0';
|
||||
const metrics = [
|
||||
{ label: t('tokenUsage.input'), value: formatCompact(input) },
|
||||
{
|
||||
label: t('tokenUsage.cached'),
|
||||
value: t('tokenUsage.valueWithRate', {
|
||||
count: formatCompact(cached),
|
||||
percent: cacheRate,
|
||||
}),
|
||||
},
|
||||
{ label: t('tokenUsage.output'), value: formatCompact(output) },
|
||||
{ label: t('tokenUsage.thoughts'), value: formatCompact(thoughts) },
|
||||
];
|
||||
|
||||
return (
|
||||
<Tooltip open={detailsOpen} onOpenChange={setDetailsOpen}>
|
||||
<TooltipTrigger asChild>
|
||||
<dl
|
||||
className={styles.tokenDetails}
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setDetailsOpen(true);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.input')}</dt>
|
||||
<dd>
|
||||
{t('tokenUsage.inputWithCacheRate', {
|
||||
count: formatCompact(input),
|
||||
percent: cacheRate,
|
||||
})}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.output')}</dt>
|
||||
<dd>{formatCompact(output)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6}>
|
||||
<dl className={styles.tokenDetailsTooltip}>
|
||||
{metrics.map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentCard({
|
||||
sourceId,
|
||||
name,
|
||||
type,
|
||||
tokens,
|
||||
grandTotal,
|
||||
}: {
|
||||
sourceId?: string;
|
||||
name: string;
|
||||
type?: string;
|
||||
tokens: DaemonSessionStatsModelMetrics['tokens'];
|
||||
grandTotal: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const typeName = type ? localizeAgentTypeName(type, t) : '';
|
||||
const total = totalTokens(tokens);
|
||||
const share = grandTotal > 0 ? (total / grandTotal) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
className={styles.subagentCard}
|
||||
data-source-id={sourceId}
|
||||
data-main-agent={sourceId === undefined ? true : undefined}
|
||||
title={sourceId}
|
||||
>
|
||||
<div className={styles.subagentHeader}>
|
||||
<span className={styles.subagentName} title={source.name}>
|
||||
{source.name}
|
||||
<span className={styles.subagentName} title={name}>
|
||||
{name}
|
||||
</span>
|
||||
{source.type && (
|
||||
<span className={styles.subagentType}>
|
||||
{localizeAgentTypeName(source.type, t)}
|
||||
{typeName && typeName.toLowerCase() !== name.toLowerCase() && (
|
||||
<span className={styles.subagentType} title={typeName}>
|
||||
{typeName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.subagentBreakdown}>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.input')}
|
||||
value={input}
|
||||
color={SEGMENT_COLORS.input}
|
||||
/>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.cached')}
|
||||
value={tokens.cached}
|
||||
color={SEGMENT_COLORS.cached}
|
||||
percent={
|
||||
input > 0
|
||||
? `${((tokens.cached / input) * 100).toFixed(1)}%`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.output')}
|
||||
value={outputTokens(tokens)}
|
||||
color={SEGMENT_COLORS.output}
|
||||
/>
|
||||
<LegendEntry
|
||||
label={t('tokenUsage.thoughts')}
|
||||
value={tokens.thoughts}
|
||||
color={SEGMENT_COLORS.thoughts}
|
||||
/>
|
||||
</div>
|
||||
<TokenDetails tokens={tokens} />
|
||||
<ShareBar share={share} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const TOKEN_USAGE_MESSAGES_EN = {};
|
||||
export const TOKEN_USAGE_MESSAGES_ZH = {};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
type TokenUsageMessage =
|
||||
| string
|
||||
| ((vars?: Record<string, string | number>) => string);
|
||||
|
||||
export const TOKEN_USAGE_MESSAGES_EN: Record<string, TokenUsageMessage> = {
|
||||
'tokenUsage.total': 'Total tokens',
|
||||
'tokenUsage.shareTooltip': (v) => `Token share: ${v?.percent ?? '0.0'}%`,
|
||||
'tokenUsage.avgLatency': 'Avg latency',
|
||||
'tokenUsage.firstTokenLatency': (v) =>
|
||||
`First-token latency ${v?.duration ?? ''}`,
|
||||
'tokenUsage.generationTime': (v) => `Generation ${v?.duration ?? ''}`,
|
||||
'tokenUsage.outputSpeed': (v) => `Output speed ${v?.rate ?? ''} tok/s`,
|
||||
'tokenUsage.modelApiMeta': (v) => `${v?.model ?? ''}: Requests · Avg latency`,
|
||||
'tokenUsage.valueWithRate': (v) =>
|
||||
`${v?.count ?? 0} (${v?.percent ?? '0.0'}%)`,
|
||||
'tokenUsage.cached': 'Cache',
|
||||
'tokenUsage.cachedDetail': (v) =>
|
||||
`Cache: ${v?.count ?? 0} (${v?.percent ?? '0.0'}%)`,
|
||||
'tokenUsage.thoughtsDetail': (v) => `Reasoning: ${v?.count ?? 0}`,
|
||||
'tokenUsage.input': 'Input',
|
||||
'tokenUsage.inputWithCacheRate': (v) =>
|
||||
`${v?.count ?? 0} (cache rate: ${v?.percent ?? '0.0'}%)`,
|
||||
'tokenUsage.loadError': 'Failed to load token usage.',
|
||||
'tokenUsage.models': 'By model',
|
||||
'tokenUsage.noData': 'No API calls in this session yet.',
|
||||
'tokenUsage.noTools': 'No tool calls in this session yet.',
|
||||
'tokenUsage.open': 'Session token usage',
|
||||
'tokenUsage.output': 'Total output',
|
||||
'tokenUsage.refresh': 'Refresh',
|
||||
'tokenUsage.requests': 'Requests',
|
||||
'tokenUsage.retry': 'Retry',
|
||||
'tokenUsage.agents': 'By agent',
|
||||
'tokenUsage.mainAgent': 'Main Agent',
|
||||
'tokenUsage.thoughts': 'Reasoning',
|
||||
'tokenUsage.title': 'Token Usage',
|
||||
'tokenUsage.toolRow': (v) =>
|
||||
`${v?.count ?? 0} calls · ${v?.rate ?? 0}% ok · ${v?.duration ?? ''}`,
|
||||
'tokenUsage.tools': 'Tools',
|
||||
'tokenUsage.updatedAt': (v) => `Updated ${v?.time ?? ''}`,
|
||||
'tokenUsage.unavailable': 'Token usage is unavailable for this session.',
|
||||
};
|
||||
|
||||
export const TOKEN_USAGE_MESSAGES_ZH: Record<string, TokenUsageMessage> = {
|
||||
'tokenUsage.total': '总 Token',
|
||||
'tokenUsage.shareTooltip': (v) => `Token 占比:${v?.percent ?? '0.0'}%`,
|
||||
'tokenUsage.avgLatency': '平均延迟',
|
||||
'tokenUsage.firstTokenLatency': (v) => `首 token 延迟 ${v?.duration ?? ''}`,
|
||||
'tokenUsage.generationTime': (v) => `生成 ${v?.duration ?? ''}`,
|
||||
'tokenUsage.outputSpeed': (v) => `输出速度 ${v?.rate ?? ''} tok/s`,
|
||||
'tokenUsage.modelApiMeta': (v) => `${v?.model ?? ''}:请求 · 平均延迟`,
|
||||
'tokenUsage.valueWithRate': (v) =>
|
||||
`${v?.count ?? 0}(${v?.percent ?? '0.0'}%)`,
|
||||
'tokenUsage.cached': '缓存',
|
||||
'tokenUsage.cachedDetail': (v) =>
|
||||
`缓存:${v?.count ?? 0}(${v?.percent ?? '0.0'}%)`,
|
||||
'tokenUsage.thoughtsDetail': (v) => `思考:${v?.count ?? 0}`,
|
||||
'tokenUsage.input': '输入',
|
||||
'tokenUsage.inputWithCacheRate': (v) =>
|
||||
`${v?.count ?? 0}(缓存率:${v?.percent ?? '0.0'}%)`,
|
||||
'tokenUsage.loadError': 'Token 消耗加载失败。',
|
||||
'tokenUsage.models': '按模型',
|
||||
'tokenUsage.noData': '本会话还没有 API 调用。',
|
||||
'tokenUsage.noTools': '本会话还没有工具调用。',
|
||||
'tokenUsage.open': '会话 Token 消耗',
|
||||
'tokenUsage.output': '总输出',
|
||||
'tokenUsage.refresh': '刷新',
|
||||
'tokenUsage.requests': '请求',
|
||||
'tokenUsage.retry': '重试',
|
||||
'tokenUsage.agents': '按agent',
|
||||
'tokenUsage.mainAgent': '主Agent',
|
||||
'tokenUsage.thoughts': '思考',
|
||||
'tokenUsage.title': 'Token 消耗',
|
||||
'tokenUsage.toolRow': (v) =>
|
||||
`${v?.count ?? 0} 次 · 成功率 ${v?.rate ?? 0}% · ${v?.duration ?? ''}`,
|
||||
'tokenUsage.tools': '工具',
|
||||
'tokenUsage.updatedAt': (v) => `更新于 ${v?.time ?? ''}`,
|
||||
'tokenUsage.unavailable': '当前会话无法读取 Token 消耗。',
|
||||
};
|
||||
@@ -0,0 +1,743 @@
|
||||
import { expect, test, type Locator } from '@playwright/test';
|
||||
import type { DaemonSessionStatsStatus } from '@qwen-code/sdk/daemon';
|
||||
import {
|
||||
createWebShellDaemonScenario,
|
||||
installMockDaemon,
|
||||
replayCompleteEvent,
|
||||
} from './utils/mockDaemon';
|
||||
|
||||
for (const { width, language, theme } of [
|
||||
{ width: 1200, language: 'en', theme: 'light' },
|
||||
{ width: 1200, language: 'zh-CN', theme: 'light' },
|
||||
{ width: 1200, language: 'en', theme: 'dark' },
|
||||
{ width: 390, language: 'en', theme: 'light' },
|
||||
{ width: 390, language: 'zh-CN', theme: 'light' },
|
||||
{ width: 390, language: 'zh-CN', theme: 'dark' },
|
||||
]) {
|
||||
test.describe(`token usage ${width}px ${language} ${theme}`, () => {
|
||||
test.use({ hasTouch: width === 390, timezoneId: 'UTC' });
|
||||
test(`token usage disclosure ${width}px ${language} ${theme} @smoke`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width, height: 960 });
|
||||
await page.clock.setFixedTime(new Date('2026-10-20T11:49:29Z'));
|
||||
await page.addInitScript(
|
||||
({ language, theme }) => {
|
||||
localStorage.setItem('qwen-code-web-shell-language', language);
|
||||
localStorage.setItem('qwen-code-web-shell-theme', theme);
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
get: () => 'hidden',
|
||||
});
|
||||
},
|
||||
{ language, theme },
|
||||
);
|
||||
const scenario = createWebShellDaemonScenario();
|
||||
const daemon = await installMockDaemon(page, scenario, {
|
||||
baseURL: String(testInfo.project.use.baseURL),
|
||||
});
|
||||
const metrics = (prompt: number, candidates: number, cached: number) => ({
|
||||
api: { totalRequests: 105, totalErrors: 0, totalLatencyMs: 2404500 },
|
||||
tokens: {
|
||||
prompt,
|
||||
candidates,
|
||||
total: prompt + candidates,
|
||||
cached,
|
||||
thoughts: Math.floor(candidates / 2),
|
||||
},
|
||||
});
|
||||
const stats: DaemonSessionStatsStatus = {
|
||||
v: 1,
|
||||
sessionId: scenario.sessionId,
|
||||
workspaceCwd: scenario.workspaceCwd,
|
||||
sessionStartTimeMs: 1000,
|
||||
durationMs: 42000,
|
||||
promptCount: 2,
|
||||
models: {
|
||||
'qwen3.8-flash': {
|
||||
...metrics(14000000, 250000, 12320000),
|
||||
generation: {
|
||||
timedRequests: 4,
|
||||
totalTtftMs: 4800,
|
||||
totalGenerationDurationMs: 2000,
|
||||
totalThroughputOutputTokens: 170,
|
||||
},
|
||||
},
|
||||
'qwen3.8-max-0902': metrics(3000000, 750000, 2700000),
|
||||
'gpt-6.1-sol': metrics(500000, 100000, 490000),
|
||||
'gpt-5.6-sol': metrics(300000, 100000, 294000),
|
||||
'bailian/kimi-k3-with-a-very-long-model-name-for-layout': metrics(
|
||||
900000,
|
||||
90000,
|
||||
720000,
|
||||
),
|
||||
},
|
||||
tools: {
|
||||
totalCalls: 0,
|
||||
totalSuccess: 0,
|
||||
totalFail: 0,
|
||||
totalDurationMs: 0,
|
||||
byName: {},
|
||||
},
|
||||
files: { totalLinesAdded: 0, totalLinesRemoved: 0 },
|
||||
sources: [
|
||||
{
|
||||
id: 'invocation-extractor-one',
|
||||
type: 'general-purpose',
|
||||
name: 'Review tests e2e and docs',
|
||||
tokens: metrics(500000, 5000, 350000).tokens,
|
||||
},
|
||||
{
|
||||
id: 'invocation-extractor-two',
|
||||
type: 'managed-auto-memory-extractor',
|
||||
name: 'managed-auto-memory-extractor',
|
||||
tokens: metrics(250000, 2500, 200000).tokens,
|
||||
},
|
||||
{
|
||||
id: 'invocation-dreamer-one',
|
||||
type: 'managed-auto-memory-dreamer',
|
||||
name: 'managed-auto-memory-dreamer',
|
||||
tokens: metrics(100000, 1000, 80000).tokens,
|
||||
},
|
||||
],
|
||||
};
|
||||
let reads = 0;
|
||||
await page.route('**/session/*/stats', (route) => {
|
||||
reads++;
|
||||
return route.fulfill({ json: stats });
|
||||
});
|
||||
await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`);
|
||||
const connection = await daemon.sse.waitForConnection(scenario.sessionId);
|
||||
await daemon.sendEvent(
|
||||
replayCompleteEvent({
|
||||
sessionId: connection.sessionId,
|
||||
replayedCount: 0,
|
||||
}),
|
||||
);
|
||||
const zh = language === 'zh-CN';
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: zh ? '会话 Token 消耗' : 'Session token usage',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
const hero = page.getByText(zh ? '总 Token' : 'Total tokens', {
|
||||
exact: true,
|
||||
});
|
||||
await expect(hero).toBeVisible();
|
||||
const panel = page.getByTestId('token-usage-panel');
|
||||
await expect(panel.locator('dl').first().locator('dt')).toHaveCount(2);
|
||||
const model = panel.locator('[data-color]').filter({
|
||||
has: page.locator('[title="qwen3.8-flash"]'),
|
||||
});
|
||||
await expect(model).toContainText('71.3%');
|
||||
const modelText = (input: string, rate: string, share: string) =>
|
||||
`qwen3.8-flash${zh ? '输入' : 'Input'}${zh ? `${input}(缓存率:${rate}%)` : `${input} (cache rate: ${rate}%)`}${zh ? '总输出' : 'Total output'}250K${share}%`;
|
||||
await expect(model).toHaveText(modelText('14M', '88.0', '71.3'));
|
||||
await expect(model.getByText('71.3%', { exact: true })).toBeVisible();
|
||||
await expect(model.locator('details, summary')).toHaveCount(0);
|
||||
const info = model.getByRole('button', {
|
||||
name: zh
|
||||
? 'qwen3.8-flash:请求 · 平均延迟'
|
||||
: 'qwen3.8-flash: Requests · Avg latency',
|
||||
exact: true,
|
||||
});
|
||||
await expect(model).not.toContainText(zh ? '请求 105' : 'Requests 105');
|
||||
await expect(info).toHaveCSS('cursor', 'default');
|
||||
const infoNames = await panel
|
||||
.locator('[data-color] button')
|
||||
.evaluateAll((buttons) =>
|
||||
buttons.map((button) => button.getAttribute('aria-label')),
|
||||
);
|
||||
expect(new Set(infoNames).size).toBe(5);
|
||||
for (const [index, card] of (
|
||||
await panel.locator('[data-color]').all()
|
||||
).entries()) {
|
||||
const name = await card
|
||||
.locator('[title]')
|
||||
.first()
|
||||
.getAttribute('title');
|
||||
expect(infoNames[index]).toBe(
|
||||
zh ? `${name}:请求 · 平均延迟` : `${name}: Requests · Avg latency`,
|
||||
);
|
||||
}
|
||||
await info.hover();
|
||||
await expect(page.getByRole('tooltip')).toContainText(
|
||||
zh ? '请求 105' : 'Requests 105',
|
||||
);
|
||||
await expect(page.getByRole('tooltip')).toContainText(
|
||||
zh ? '平均延迟 22.9s' : 'Avg latency 22.9s',
|
||||
);
|
||||
for (const text of zh
|
||||
? ['首 token 延迟 1.2s', '生成 500ms', '输出速度 85.0 tok/s']
|
||||
: [
|
||||
'First-token latency 1.2s',
|
||||
'Generation 500ms',
|
||||
'Output speed 85.0 tok/s',
|
||||
]) {
|
||||
await expect(page.getByRole('tooltip')).toContainText(text);
|
||||
await expect(model).not.toContainText(text);
|
||||
}
|
||||
const timingPath = testInfo.outputPath('token-model-timing.png');
|
||||
await page.screenshot({ path: timingPath, animations: 'disabled' });
|
||||
await testInfo.attach('token-model-timing', {
|
||||
path: timingPath,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
await panel.locator('[data-color] button').nth(1).hover();
|
||||
await expect(
|
||||
page.locator('[data-slot="tooltip-content"][data-state$="open"]'),
|
||||
).toHaveAttribute('data-state', 'instant-open');
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await info.focus();
|
||||
await expect(page.getByRole('tooltip')).toContainText(
|
||||
zh ? '请求 105' : 'Requests 105',
|
||||
);
|
||||
await info.press('Escape');
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await info.blur();
|
||||
if (width === 390) {
|
||||
await info.tap();
|
||||
const touchTooltip = page.locator('[data-slot="tooltip-content"]');
|
||||
await expect(touchTooltip).toHaveAttribute('data-state', /open$/);
|
||||
await expect(touchTooltip).toHaveCSS('opacity', '1');
|
||||
await expect(touchTooltip).toContainText(
|
||||
zh ? '请求 105' : 'Requests 105',
|
||||
);
|
||||
const touchPath = testInfo.outputPath('token-info-touch.png');
|
||||
await page.screenshot({ path: touchPath, animations: 'disabled' });
|
||||
await expect(touchTooltip).toHaveAttribute('data-state', /open$/);
|
||||
await testInfo.attach('Visible Info metadata after touch', {
|
||||
path: touchPath,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
await hero.tap();
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
const checkContrast = async (
|
||||
target: Locator,
|
||||
property: 'color' | 'outlineColor',
|
||||
minimum = 3,
|
||||
) => {
|
||||
const contrast = await target.evaluate((element, property) => {
|
||||
const context = document.createElement('canvas').getContext('2d')!;
|
||||
const rgba = (color: string) => {
|
||||
context.clearRect(0, 0, 1, 1);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
return Array.from(context.getImageData(0, 0, 1, 1).data);
|
||||
};
|
||||
let surface = element.parentElement;
|
||||
while (
|
||||
surface &&
|
||||
rgba(getComputedStyle(surface).backgroundColor)[3] === 0
|
||||
) {
|
||||
surface = surface.parentElement;
|
||||
}
|
||||
if (!surface) throw new Error('Missing adjacent background');
|
||||
const luminance = (color: number[]) =>
|
||||
color.slice(0, 3).reduce((sum, channel, index) => {
|
||||
const value = channel / 255;
|
||||
const linear =
|
||||
value <= 0.04045
|
||||
? value / 12.92
|
||||
: ((value + 0.055) / 1.055) ** 2.4;
|
||||
return sum + linear * [0.2126, 0.7152, 0.0722][index];
|
||||
}, 0);
|
||||
const foreground = luminance(
|
||||
rgba(getComputedStyle(element)[property]),
|
||||
);
|
||||
const background = luminance(
|
||||
rgba(getComputedStyle(surface).backgroundColor),
|
||||
);
|
||||
return (
|
||||
(Math.max(foreground, background) + 0.05) /
|
||||
(Math.min(foreground, background) + 0.05)
|
||||
);
|
||||
}, property);
|
||||
expect(contrast).toBeGreaterThanOrEqual(minimum);
|
||||
};
|
||||
await checkContrast(info, 'color');
|
||||
await checkContrast(hero, 'color', 4.5);
|
||||
for (const card of await panel.locator('[data-color]').all()) {
|
||||
await checkContrast(card.getByText(/^\d+\.\d+%$/), 'color', 4.5);
|
||||
}
|
||||
for (const label of await panel
|
||||
.locator('dl')
|
||||
.first()
|
||||
.locator('dt')
|
||||
.all()) {
|
||||
await checkContrast(label, 'color', 4.5);
|
||||
}
|
||||
const longName = panel.locator(
|
||||
'[title="bailian/kimi-k3-with-a-very-long-model-name-for-layout"]',
|
||||
);
|
||||
await expect(longName).toHaveCSS('white-space', 'nowrap');
|
||||
await expect(longName).toHaveCSS('text-overflow', 'ellipsis');
|
||||
expect(
|
||||
await longName.evaluate(
|
||||
(element) =>
|
||||
element.clientHeight <=
|
||||
parseFloat(getComputedStyle(element).lineHeight) + 1,
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
await model
|
||||
.locator('dl > div')
|
||||
.nth(1)
|
||||
.evaluate((node) => getComputedStyle(node, '::before').content),
|
||||
).toContain('·');
|
||||
await expect(model.locator('dl')).toHaveCSS('column-gap', 'normal');
|
||||
await expect(model.locator('dl')).toHaveCSS('row-gap', 'normal');
|
||||
await expect(
|
||||
model.getByText(zh ? '输入' : 'Input', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
model.getByText(zh ? '总输出' : 'Total output', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(model.locator('dt')).toHaveCount(2);
|
||||
await expect(model.locator('dd').first()).toHaveText(
|
||||
zh ? '14M(缓存率:88.0%)' : '14M (cache rate: 88.0%)',
|
||||
);
|
||||
await expect(model).not.toContainText(zh ? '思考' : 'Reasoning');
|
||||
await expect(model).not.toContainText('12.32M');
|
||||
const checkMetricTooltip = async (row: Locator, values: string[]) => {
|
||||
await expect(row).toHaveCSS('cursor', 'default');
|
||||
await expect(row).toHaveCSS('white-space', 'nowrap');
|
||||
await expect(row).toHaveCSS('text-overflow', 'ellipsis');
|
||||
await expect(row).toHaveAttribute('tabindex', '0');
|
||||
expect(
|
||||
await row.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return element.clientHeight <= parseFloat(style.lineHeight) + 1;
|
||||
}),
|
||||
).toBe(true);
|
||||
await row.hover();
|
||||
const popup = page.locator('[data-slot="tooltip-content"]');
|
||||
await expect(popup).toHaveAttribute('data-state', /open$/);
|
||||
await expect(popup).toHaveCSS('opacity', '1');
|
||||
const items = popup.locator(':scope > dl > div');
|
||||
await expect(items).toHaveCount(4);
|
||||
expect(await items.locator('dt').allTextContents()).toEqual(
|
||||
zh
|
||||
? ['输入', '缓存', '总输出', '思考']
|
||||
: ['Input', 'Cache', 'Total output', 'Reasoning'],
|
||||
);
|
||||
expect(await items.locator('dd').allTextContents()).toEqual(values);
|
||||
const positions = await items.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getBoundingClientRect().top),
|
||||
);
|
||||
expect(
|
||||
positions.every(
|
||||
(y, index) => index === 0 || y > positions[index - 1],
|
||||
),
|
||||
).toBe(true);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await row.focus();
|
||||
await expect(popup).toHaveAttribute('data-state', /open$/);
|
||||
await row.press('Escape');
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await row.blur();
|
||||
if (width === 390) {
|
||||
await row.tap();
|
||||
await expect(popup).toHaveAttribute('data-state', /open$/);
|
||||
await expect(popup).toHaveCSS('opacity', '1');
|
||||
const metricPath = testInfo.outputPath(
|
||||
`token-metrics-touch-${values[0]}.png`,
|
||||
);
|
||||
await page.screenshot({ path: metricPath, animations: 'disabled' });
|
||||
await expect(popup).toHaveAttribute('data-state', /open$/);
|
||||
await testInfo.attach('Complete metric rows after touch', {
|
||||
path: metricPath,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
await hero.tap();
|
||||
await expect(
|
||||
page.locator('[data-slot="tooltip-content"]'),
|
||||
).toHaveCount(0);
|
||||
}
|
||||
};
|
||||
await checkMetricTooltip(model.locator('dl'), [
|
||||
'14M',
|
||||
zh ? '12.32M(88.0%)' : '12.32M (88.0%)',
|
||||
'250K',
|
||||
'125K',
|
||||
]);
|
||||
const modelShareLabel = zh ? 'Token 占比:71.3%' : 'Token share: 71.3%';
|
||||
const modelTrack = model.getByRole('img', { name: modelShareLabel });
|
||||
await modelTrack.hover();
|
||||
await expect(page.getByRole('tooltip')).toHaveText(modelShareLabel);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await modelTrack.focus();
|
||||
await expect(page.getByRole('tooltip')).toHaveText(modelShareLabel);
|
||||
await modelTrack.press('Escape');
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await modelTrack.blur();
|
||||
if (width === 390) {
|
||||
await modelTrack.tap();
|
||||
const popup = page.locator('[data-slot="tooltip-content"]');
|
||||
await expect(popup).toHaveCSS('opacity', '1');
|
||||
await expect(page.getByRole('tooltip')).toHaveText(modelShareLabel);
|
||||
await expect(popup).toHaveCSS('pointer-events', 'none');
|
||||
await model.locator('dl').tap();
|
||||
await expect(popup.locator(':scope > dl > div > dt')).toHaveText(
|
||||
zh
|
||||
? ['输入', '缓存', '总输出', '思考']
|
||||
: ['Input', 'Cache', 'Total output', 'Reasoning'],
|
||||
);
|
||||
await hero.tap();
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
const subagents = panel
|
||||
.locator('details')
|
||||
.filter({
|
||||
has: page.locator('summary h3', {
|
||||
hasText: zh ? '按agent' : 'By agent',
|
||||
}),
|
||||
})
|
||||
.first();
|
||||
await subagents.locator(':scope > summary').click();
|
||||
await expect(subagents.locator('[data-source-id]')).toHaveCount(3);
|
||||
await expect(subagents.locator('details')).toHaveCount(0);
|
||||
const mainAgent = subagents.locator('[data-main-agent]');
|
||||
await expect(
|
||||
mainAgent.getByText(zh ? '主Agent' : 'Main Agent', { exact: true }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
await mainAgent.evaluate(
|
||||
(element) => element.parentElement?.firstElementChild === element,
|
||||
),
|
||||
).toBe(true);
|
||||
await expect(mainAgent).toContainText('95.7%');
|
||||
await expect(mainAgent.locator('dd').first()).toHaveText(
|
||||
zh ? '17.85M(缓存率:89.0%)' : '17.85M (cache rate: 89.0%)',
|
||||
);
|
||||
await expect(mainAgent.locator('dd').last()).toHaveText('1.28M');
|
||||
await checkMetricTooltip(mainAgent.locator('dl'), [
|
||||
'17.85M',
|
||||
zh ? '15.89M(89.0%)' : '15.89M (89.0%)',
|
||||
'1.28M',
|
||||
'640.8K',
|
||||
]);
|
||||
await expect(panel.locator('[data-color="4"]')).toHaveCount(1);
|
||||
await expect(
|
||||
panel.getByText(zh ? '输入' : 'Input', { exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
panel
|
||||
.locator('dd span')
|
||||
.filter({ hasText: zh ? '缓存' : 'Cache' })
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
const path = testInfo.outputPath(
|
||||
`token-usage-${width}-${language}-${theme}.png`,
|
||||
);
|
||||
await page.screenshot({ path, fullPage: true, animations: 'disabled' });
|
||||
await testInfo.attach('Token usage overview (simulated daemon)', {
|
||||
path,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
const readFonts = () =>
|
||||
Promise.all([
|
||||
model
|
||||
.getByText('qwen3.8-flash', { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
model
|
||||
.getByText('71.3%', { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
model
|
||||
.locator('dl')
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
hero
|
||||
.locator('..')
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
panel
|
||||
.locator('dl')
|
||||
.first()
|
||||
.locator('strong')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
mainAgent
|
||||
.getByText('95.7%', { exact: true })
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
panel
|
||||
.locator('dl')
|
||||
.first()
|
||||
.locator('dd span')
|
||||
.first()
|
||||
.evaluate((element) => getComputedStyle(element).fontSize),
|
||||
]);
|
||||
const dockedFonts = await readFonts();
|
||||
expect(dockedFonts).toEqual([
|
||||
'15px',
|
||||
'14px',
|
||||
'12px',
|
||||
'28px',
|
||||
'24px',
|
||||
'12px',
|
||||
'12px',
|
||||
]);
|
||||
if (width === 1200) {
|
||||
const resize = page.locator(
|
||||
'[role="separator"][aria-orientation="vertical"][aria-valuenow]',
|
||||
);
|
||||
const initialWidth = Number(await resize.getAttribute('aria-valuenow'));
|
||||
const minWidth = Number(await resize.getAttribute('aria-valuemin'));
|
||||
const maxWidth = Number(await resize.getAttribute('aria-valuemax'));
|
||||
for (const targetWidth of [
|
||||
minWidth,
|
||||
Math.min(maxWidth, initialWidth + 120),
|
||||
initialWidth,
|
||||
]) {
|
||||
const currentWidth = Number(
|
||||
await resize.getAttribute('aria-valuenow'),
|
||||
);
|
||||
const box = (await resize.boundingBox())!;
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
box.x + box.width / 2 + currentWidth - targetWidth,
|
||||
box.y + box.height / 2,
|
||||
{ steps: 5 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect(resize).toHaveAttribute(
|
||||
'aria-valuenow',
|
||||
String(targetWidth),
|
||||
);
|
||||
expect(await readFonts()).toEqual(dockedFonts);
|
||||
if (targetWidth === minWidth) {
|
||||
const alignment = await panel
|
||||
.getByRole('button', {
|
||||
name: zh ? '刷新' : 'Refresh',
|
||||
exact: true,
|
||||
})
|
||||
.evaluate((button) => {
|
||||
const meta = button.parentElement!.getBoundingClientRect();
|
||||
const header = button.parentElement!.parentElement!;
|
||||
const value = header.firstElementChild!.getBoundingClientRect();
|
||||
return {
|
||||
right: Math.abs(
|
||||
meta.right - header.getBoundingClientRect().right,
|
||||
),
|
||||
wrapped: meta.top >= value.bottom,
|
||||
fits: header.scrollWidth <= header.clientWidth + 1,
|
||||
};
|
||||
});
|
||||
expect(alignment.right).toBeLessThanOrEqual(1);
|
||||
expect(alignment.wrapped).toBe(true);
|
||||
expect(alignment.fits).toBe(true);
|
||||
}
|
||||
}
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: zh ? '全屏' : 'Fullscreen',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
expect(await readFonts()).toEqual(dockedFonts);
|
||||
// Wide cards leave enough space to detect lost right alignment.
|
||||
const wideType = subagents
|
||||
.locator('[data-source-id]')
|
||||
.first()
|
||||
.getByText(zh ? '通用' : 'General-purpose', { exact: true });
|
||||
await expect
|
||||
.poll(() =>
|
||||
wideType.evaluate((type) => type.parentElement!.clientWidth),
|
||||
)
|
||||
.toBeGreaterThan(width / 2);
|
||||
expect(
|
||||
await wideType.evaluate((type) =>
|
||||
Math.abs(
|
||||
type.getBoundingClientRect().right -
|
||||
type.parentElement!.getBoundingClientRect().right,
|
||||
),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
const wideMetrics = model.locator('dl');
|
||||
await expect(wideMetrics).toHaveAttribute('tabindex', '0');
|
||||
expect(
|
||||
await wideMetrics.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth,
|
||||
),
|
||||
).toBe(true);
|
||||
await checkMetricTooltip(wideMetrics, [
|
||||
'14M',
|
||||
zh ? '12.32M(88.0%)' : '12.32M (88.0%)',
|
||||
'250K',
|
||||
'125K',
|
||||
]);
|
||||
const referencePath = testInfo.outputPath(
|
||||
`token-usage-reference-${language}-${theme}.png`,
|
||||
);
|
||||
await panel
|
||||
.locator('section')
|
||||
.screenshot({ path: referencePath, animations: 'disabled' });
|
||||
await testInfo.attach(
|
||||
'Reference-style model ranking (simulated daemon)',
|
||||
{ path: referencePath, contentType: 'image/png' },
|
||||
);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: zh ? '退出全屏' : 'Exit fullscreen',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
}
|
||||
const cards = subagents.locator('[data-source-id]');
|
||||
await expect(cards).toHaveCount(3);
|
||||
await expect(cards.first()).toBeVisible();
|
||||
await expect(cards.first()).toContainText('500K');
|
||||
await expect(
|
||||
cards.first().getByText('2.5%', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(cards.first()).not.toContainText('350K');
|
||||
await expect(cards.first().locator('dt')).toHaveCount(2);
|
||||
const sourceHeader = cards.first().locator(':scope > div').first();
|
||||
const sourceName = sourceHeader.getByText('Review tests e2e and docs', {
|
||||
exact: true,
|
||||
});
|
||||
const sourceType = sourceHeader.getByText(
|
||||
zh ? '通用' : 'General-purpose',
|
||||
{ exact: true },
|
||||
);
|
||||
await checkContrast(sourceType, 'color', 4.5);
|
||||
await expect(sourceType).toHaveAttribute(
|
||||
'title',
|
||||
zh ? '通用' : 'General-purpose',
|
||||
);
|
||||
const nameBox = await sourceName.boundingBox();
|
||||
const typeBox = await sourceType.boundingBox();
|
||||
expect(typeBox!.x).toBeGreaterThanOrEqual(nameBox!.x + nameBox!.width);
|
||||
expect(Math.abs(typeBox!.y - nameBox!.y)).toBeLessThan(2);
|
||||
const headerBox = (await sourceHeader.boundingBox())!;
|
||||
expect(
|
||||
Math.abs(typeBox!.x + typeBox!.width - headerBox.x - headerBox.width),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await expect(cards.first().locator('dd').first()).toHaveText(
|
||||
zh ? '500K(缓存率:70.0%)' : '500K (cache rate: 70.0%)',
|
||||
);
|
||||
await checkMetricTooltip(cards.first().locator('dl'), [
|
||||
'500K',
|
||||
zh ? '350K(70.0%)' : '350K (70.0%)',
|
||||
'5K',
|
||||
'2.5K',
|
||||
]);
|
||||
const sourceShareLabel = zh ? 'Token 占比:2.5%' : 'Token share: 2.5%';
|
||||
await cards.first().getByRole('img', { name: sourceShareLabel }).hover();
|
||||
await expect(page.getByRole('tooltip')).toHaveText(sourceShareLabel);
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.locator('[data-slot="tooltip-content"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(cards.first()).not.toContainText(zh ? '思考' : 'Reasoning');
|
||||
await expect(
|
||||
cards.first().getByText('Review tests e2e and docs', { exact: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(cards.first()).toContainText(zh ? '输入' : 'Input');
|
||||
await expect(cards.first()).toContainText(zh ? '缓存率' : 'cache rate');
|
||||
const matchingSource = cards.nth(1);
|
||||
await expect(
|
||||
matchingSource.getByText('managed-auto-memory-extractor', {
|
||||
exact: true,
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
await expect(matchingSource).toHaveText(
|
||||
`managed-auto-memory-extractor${zh ? '输入250K(缓存率:80.0%)总输出' : 'Input250K (cache rate: 80.0%)Total output'}2.5K1.3%`,
|
||||
);
|
||||
const headings = await panel
|
||||
.getByRole('heading', { level: 3 })
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return [style.fontSize, style.fontWeight, style.color];
|
||||
}),
|
||||
);
|
||||
for (const heading of await panel
|
||||
.getByRole('heading', { level: 3 })
|
||||
.all()) {
|
||||
await checkContrast(heading, 'color', 4.5);
|
||||
}
|
||||
expect(headings).toHaveLength(3);
|
||||
expect(headings[1]).toEqual(headings[0]);
|
||||
expect(headings[2]).toEqual(headings[0]);
|
||||
const refresh = panel.getByRole('button', {
|
||||
name: zh ? '刷新' : 'Refresh',
|
||||
exact: true,
|
||||
});
|
||||
await page.keyboard.press('Tab');
|
||||
for (const [index, target] of [
|
||||
subagents.locator(':scope > summary'),
|
||||
refresh,
|
||||
info,
|
||||
modelTrack,
|
||||
model.locator('dl'),
|
||||
].entries()) {
|
||||
await target.focus();
|
||||
expect(
|
||||
await target.evaluate(
|
||||
(element) =>
|
||||
element.matches(':focus-visible') &&
|
||||
getComputedStyle(element).outlineStyle !== 'none',
|
||||
),
|
||||
).toBe(true);
|
||||
await checkContrast(target, 'outlineColor');
|
||||
if (index > 1) await target.press('Escape');
|
||||
await target.blur();
|
||||
}
|
||||
expect(await page.evaluate(() => document.visibilityState)).toBe(
|
||||
'hidden',
|
||||
);
|
||||
expect(reads).toBe(1);
|
||||
const oldTokens = stats.models['qwen3.8-flash']!.tokens;
|
||||
stats.models['qwen3.8-flash']!.tokens = {
|
||||
...oldTokens,
|
||||
prompt: 15000000,
|
||||
total: 15250000,
|
||||
};
|
||||
const before = reads;
|
||||
await refresh.click();
|
||||
await expect.poll(() => reads).toBe(before + 1);
|
||||
await expect(model).toHaveText(modelText('15M', '82.1', '72.7'));
|
||||
await expect(hero.locator('..')).toContainText('20.99M');
|
||||
const expandedPath = testInfo.outputPath(
|
||||
`token-usage-expanded-${width}-${language}-${theme}.png`,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: expandedPath,
|
||||
fullPage: true,
|
||||
animations: 'disabled',
|
||||
});
|
||||
await testInfo.attach('Expanded token details (simulated daemon)', {
|
||||
path: expandedPath,
|
||||
contentType: 'image/png',
|
||||
});
|
||||
expect(
|
||||
await panel.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth + 1,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
FILES_MESSAGES_EN,
|
||||
FILES_MESSAGES_ZH,
|
||||
} from './components/artifacts/files-messages.js';
|
||||
import {
|
||||
TOKEN_USAGE_MESSAGES_EN,
|
||||
TOKEN_USAGE_MESSAGES_ZH,
|
||||
} from './components/artifacts/token-usage-messages.js';
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
@@ -3402,31 +3406,7 @@ const EN: Messages = {
|
||||
'contextUsage.unavailable': 'Context usage is unavailable for this session.',
|
||||
'contextUsage.unknown':
|
||||
'Context usage has not been observed for this session yet.',
|
||||
'tokenUsage.avgLatency': 'Avg latency',
|
||||
'tokenUsage.cached': 'Cached input',
|
||||
'tokenUsage.firstTokenLatency': (v) =>
|
||||
`First-token latency ${v?.duration ?? ''}`,
|
||||
'tokenUsage.generationTime': (v) => `Generation ${v?.duration ?? ''}`,
|
||||
'tokenUsage.input': 'Input',
|
||||
'tokenUsage.loadError': 'Failed to load token usage.',
|
||||
'tokenUsage.models': 'By model',
|
||||
'tokenUsage.noData': 'No API calls in this session yet.',
|
||||
'tokenUsage.noSubagents': 'No subagent calls in this session yet.',
|
||||
'tokenUsage.noTools': 'No tool calls in this session yet.',
|
||||
'tokenUsage.open': 'Session token usage',
|
||||
'tokenUsage.output': 'Total output',
|
||||
'tokenUsage.outputSpeed': (v) => `Output speed ${v?.rate ?? ''} tok/s`,
|
||||
'tokenUsage.refresh': 'Refresh',
|
||||
'tokenUsage.requests': 'Requests',
|
||||
'tokenUsage.retry': 'Retry',
|
||||
'tokenUsage.subagents': 'Subagents',
|
||||
'tokenUsage.thoughts': 'Reasoning (included in total output)',
|
||||
'tokenUsage.title': 'Token Usage',
|
||||
'tokenUsage.toolRow': (v) =>
|
||||
`${v?.count ?? 0} calls · ${v?.rate ?? 0}% ok · ${v?.duration ?? ''}`,
|
||||
'tokenUsage.tools': 'Tools',
|
||||
'tokenUsage.updatedAt': (v) => `Updated ${v?.time ?? ''}`,
|
||||
'tokenUsage.unavailable': 'Token usage is unavailable for this session.',
|
||||
...TOKEN_USAGE_MESSAGES_EN,
|
||||
'trajectory.title': 'Trajectory',
|
||||
'trajectory.description': 'View time and tokens',
|
||||
'trajectory.empty': 'No records in this session yet.',
|
||||
@@ -7644,30 +7624,7 @@ const ZH: Messages = {
|
||||
'contextUsage.previousReading': '刷新失败,当前显示先前读数。',
|
||||
'contextUsage.unavailable': '当前会话无法读取上下文使用情况。',
|
||||
'contextUsage.unknown': '当前会话尚无已观测的上下文占用数据。',
|
||||
'tokenUsage.avgLatency': '平均延迟',
|
||||
'tokenUsage.cached': '缓存输入',
|
||||
'tokenUsage.firstTokenLatency': (v) => `首 token 延迟 ${v?.duration ?? ''}`,
|
||||
'tokenUsage.generationTime': (v) => `生成 ${v?.duration ?? ''}`,
|
||||
'tokenUsage.input': '输入',
|
||||
'tokenUsage.loadError': 'Token 消耗加载失败。',
|
||||
'tokenUsage.models': '按模型',
|
||||
'tokenUsage.noData': '本会话还没有 API 调用。',
|
||||
'tokenUsage.noSubagents': '本会话还没有子 agent 调用。',
|
||||
'tokenUsage.noTools': '本会话还没有工具调用。',
|
||||
'tokenUsage.open': '会话 Token 消耗',
|
||||
'tokenUsage.output': '总输出',
|
||||
'tokenUsage.outputSpeed': (v) => `输出速度 ${v?.rate ?? ''} tok/s`,
|
||||
'tokenUsage.refresh': '刷新',
|
||||
'tokenUsage.requests': '请求',
|
||||
'tokenUsage.retry': '重试',
|
||||
'tokenUsage.subagents': '子agent',
|
||||
'tokenUsage.thoughts': '思考(包含在总输出中)',
|
||||
'tokenUsage.title': 'Token 消耗',
|
||||
'tokenUsage.toolRow': (v) =>
|
||||
`${v?.count ?? 0} 次 · 成功率 ${v?.rate ?? 0}% · ${v?.duration ?? ''}`,
|
||||
'tokenUsage.tools': '工具',
|
||||
'tokenUsage.updatedAt': (v) => `更新于 ${v?.time ?? ''}`,
|
||||
'tokenUsage.unavailable': '当前会话无法读取 Token 消耗。',
|
||||
...TOKEN_USAGE_MESSAGES_ZH,
|
||||
'trajectory.title': '轨迹',
|
||||
'trajectory.description': '查看时间和 token',
|
||||
'trajectory.empty': '这个会话还没有记录。',
|
||||
|
||||
@@ -180,6 +180,10 @@ const TRANSCRIPT_STUBS = new Map(
|
||||
'./client/components/artifacts/files-messages.ts',
|
||||
'./client/components/artifacts/files-messages.transcript-stub.ts',
|
||||
],
|
||||
[
|
||||
'./client/components/artifacts/token-usage-messages.ts',
|
||||
'./client/components/artifacts/token-usage-messages.transcript-stub.ts',
|
||||
],
|
||||
[
|
||||
'./client/voice/messages.ts',
|
||||
'./client/voice/messages.transcript-stub.ts',
|
||||
|
||||
Reference in new issue
Block a user