@mzzsfy/dsh-usage-dash 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @mzzsfy/dsh-usage-dash
2
2
 
3
- 用量统计面板(dsh 插件)。天/小时/分钟三粒度 token 与请求统计,设置页自绘面板:汇总卡、活动热力图、缓存命中率曲线、模型 donut 与列表、回扫状态行、会话底栏接管与回合费用芯片,支持 en/zh 双语与可选费用估算。复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel),感谢原作者。
3
+ 用量统计面板(dsh 插件)。天/小时/分钟三粒度 token 与请求统计,设置页自绘面板:汇总卡、活动热力图、缓存命中率曲线、模型 donut 与列表、回扫状态行、会话底栏追加与回合费用芯片,支持 en/zh 双语与可选费用估算。复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel),感谢原作者。
4
4
 
5
5
  ## 功能(全阶段已交付)
6
6
 
@@ -13,7 +13,7 @@
13
13
  - 模型 donut 与列表:按 token 排序的模型占比环形图(中心为总量,模型不超过 5 个时全展示,超过时折叠为前 4 +「其他」,「其他」可展开),每行名称两行展示(厂商/模型名),右侧三排:第一排 token 数·占比,第二排输入输出占比构成(缓存/输入/输出 三段各占该模型 token 总量,缓存=读+写,三段合计 100%,无四桶数据不显示),第三排估算费用·TTFT·生成速度(费用需配置定价规则,TTFT/速度无配对数据不显示),悬停明细含输入/缓存读/缓存写/输出四桶 token 拆分并联动
14
14
  - 三粒度堆叠柱状趋势图(数据量大时裁最旧并提示);节头「金额」开关把柱状图从 token 消耗切到金额消耗——柱按模型堆叠估算费用,标题与左轴同步切金额读数(悬停明细保持 token 口径与估算费用行不变,命中率/速度/首 token 延迟曲线不受影响),三粒度通用;开关仅在存在正计价数据时显示(未配置规则或全量未计价时不渲染,避免全零金额柱误导);图例可点击切换显隐:点击单选(仅显示该项,左轴刻度按可见项归一)/再点恢复全部,Ctrl+点击多选,隐藏最后一项无效;悬停明细按当前时段数据展示,零用量模型不显示,全零时段不显示悬浮框
15
15
  - 回扫状态行默认隐藏:首次启用自动回扫历史会话,运行中显示进度(常显);右上角为折叠箭头与刷新图标,折叠层内展开扫描异常日志块(逐条:时间/类型/明细,计数即明细条数,上限 200 条超限丢最旧,重建时清空)与「重建」入口(二次确认 3 秒后清库重扫),采集错误常显;折叠且有待读信息(异常日志或写入失败)时箭头带红点提醒,展开后消失;工具栏分主区与右侧操作区,窄宽度时挡位组在主区内换行
16
- - 会话底栏接管:替换官方信息行为增强版,偏好卡三个开关默认全开——精确缓存命中率(两位小数)、会话 Token 明细(总/命中缓存/未命中缓存/输出)、费用显示(底栏费用项、趋势 tooltip 费用行与回合费用芯片);三开关全关时与官方逐字节一致
16
+ - 会话底栏追加(append 模式):本插件在官方 activity/usage 胶囊之后仅显示估算费用,不另建明细按钮或浮层。精确缓存命中率开关(`cachePrecision`)原位修改官方 usage 胶囊的命中率文字、按钮 `aria-label` 与官方弹窗原命中率 `dd`,统一显示两位小数;关闭开关或卸载增强时还原,保留官方节点与交互。「会话 Token 明细」开关(`tokenDetail`)只在已打开的官方 `[data-session-stats-usage]` 用量弹窗 `dl` 末尾追加「输入合计(含缓存)」(未缓存输入+缓存读+缓存写),仅当 `cacheWriteTokens > 0` 再追加「未命中合计(含缓存写入)」(未缓存输入+缓存写入)。合计使用完整千分位计数,官方已有的总量、缓存读写、输出与命中率不重复追加;无输入用量时不追加零值合计。偏好卡三个开关仍默认全开,已有偏好继续生效;「费用显示」控制底栏费用、趋势 tooltip 费用行与回合费用芯片。官方弹窗关闭时不挂载补充行;空用量无插件可见条目,精度或明细单独开启均不产生底栏入口。官方未公开用量弹窗扩展 slot,上述增强依赖宿主 DOM 语义标记,标记改变或无法唯一确认弹窗归属时停用相应增强,底栏费用仍独立显示。
17
17
  - 回合费用芯片:经官方 `conversation.chat.assistant-actions` 槽注入动作行(复制与分支图标之间,官方赞/踩与上下文跳转同排),每轮对话结束后显示该轮估算费用(悬停 title 带 token 摘要与估算口径;显隐节奏随官方动作行——最新一轮常显,历史轮悬停显现;受「费用显示」开关;官方用量芯片弹窗已有 token 明细,芯片只承载费用)。旧宿主无该插槽时告警禁用
18
18
  - 定价规则编辑器:设置面板内按**模型分组**编辑,组头 = 模型键(整组一次改名,改后即时重新聚合)+ 删除整组;组内 = **默认价**槽(价格四桶,不设条件——附加规则全不命中时兜底,禁排序/删除)+ **附加计费规则**列表(四桶单价 + 条件组合,「+添加条件」追加条件行,行内下拉切换类型,规则带 ↑/↓ 调序与删除),「添加额外计费规则」追加一条(默认带全天时段条件);货币为全局切换(「定价规则」标题右侧,仅 ¥/$ 两档,整表统一),显式「保存」整表写入
19
19
  - 双语:跟随宿主语言设置(设置 → 通用 → 语言)即时切换 en/zh
@@ -43,7 +43,7 @@
43
43
 
44
44
  本插件复刻自 [HaoyueQin/dsh-usage-statistics-panel](https://github.com/HaoyueQin/dsh-usage-statistics-panel)(npm 包 [dsh-usage-statistics-panel](https://www.npmjs.com/package/dsh-usage-statistics-panel)),感谢原作者 HaoyueQin 的开源实现。在其基础上补足缺失的小时/分钟粒度与热力图/曲线/donut,移除其远程访问限制。路由(`/api/usage-dash/*`)与存储域(`usage_stats`)均不冲突,共存只是重复采集。
45
45
 
46
- 同装时两插件争抢会话底栏 'stats' 槽位:槽注册表对同 id 同 priority 直接抛错,本插件以更低 priority 注册遮蔽原插件(lowest renders),同装时本插件胜出、卸载本插件后原插件恢复。仍建议卸载原插件以避免重复采集。
46
+ 同装时两插件争抢会话底栏 'stats' 槽位(append 后位增强条目):槽注册表对同 id 同 priority 直接抛错,本插件以更低 priority 注册遮蔽原插件(lowest renders),同装时本插件胜出、卸载本插件后原插件恢复。仍建议卸载原插件以避免重复采集。
47
47
 
48
48
  ## 存档兼容
49
49
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-usage-dash",
3
- "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图、缓存命中率/生成速度/首 token 延迟曲线、模型占比与四桶明细,会话底栏信息接管与官方动作行回合费用芯片,多条件分时段定价规则与费用估算,en/zh 双语;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
- "version": "0.10.1",
3
+ "description": "用量统计面板:天/小时/分钟三粒度 token 与请求统计,26 周活跃热力图、缓存命中率/生成速度/首 token 延迟曲线、模型占比与四桶明细,会话底栏官方数据后追加增强项与官方动作行回合费用芯片,多条件分时段定价规则与费用估算,en/zh 双语;复刻自 github.com/HaoyueQin/dsh-usage-statistics-panel,感谢原作者 HaoyueQin",
4
+ "version": "0.10.2",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/client.js CHANGED
@@ -246,19 +246,14 @@ const MESSAGES_ZH = {
246
246
  anomalyLog: '扫描异常日志',
247
247
  logKindSkipped: '跳过会话',
248
248
  logKindRecord: '写入失败',
249
- 'stats.counts': '{turns} 轮 · {steps} 步',
250
- 'stats.llm': 'LLM {duration}',
251
- 'stats.toolCall': '工具调用 {duration}',
252
- 'stats.ttftAverage': '首 token 平均 {duration}',
253
- 'stats.tokensPerSecond': '{throughput} tok/s',
254
- 'stats.cacheHit': '缓存命中 {percent}%',
249
+ 'stats.inputTotal': '输入合计(含缓存)',
250
+ 'stats.missTotal': '未命中合计(含缓存写入)',
255
251
  'stats.tokens': '输入 {input} tok · 输出 {output} tok',
256
252
  'turnCostChip': '{cost}',
257
- 'stats.tokensDetail': '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok',
258
253
  cachePrecision: '精确缓存命中率',
259
- cachePrecisionDesc: '在会话底部信息栏以两位小数显示缓存命中率。',
254
+ cachePrecisionDesc: '将官方用量胶囊及弹窗中的命中率显示为两位小数。',
260
255
  tokenDetail: '会话 Token 明细',
261
- tokenDetailDesc: '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。',
256
+ tokenDetailDesc: '在官方 Token 用量弹窗末尾追加含缓存输入合计;存在缓存写入时追加未命中合计。',
262
257
  costDisplay: '费用显示',
263
258
  costDisplayDesc: '在信息栏、趋势悬浮与回合费用芯片中显示按当前费率估算的费用。',
264
259
  costTitle: '按当前费率对历史用量估算,精度为小时级',
@@ -394,19 +389,14 @@ const MESSAGES_EN = {
394
389
  anomalyLog: 'Scan anomaly log',
395
390
  logKindSkipped: 'skipped',
396
391
  logKindRecord: 'write failed',
397
- 'stats.counts': '{turns} turns · {steps} steps',
398
- 'stats.llm': 'LLM {duration}',
399
- 'stats.toolCall': 'Tool call {duration}',
400
- 'stats.ttftAverage': 'TTFT avg {duration}',
401
- 'stats.tokensPerSecond': '{throughput} tok/s',
402
- 'stats.cacheHit': 'Cache hit {percent}%',
392
+ 'stats.inputTotal': 'Total input (including cache)',
393
+ 'stats.missTotal': 'Total cache miss (including writes)',
403
394
  'stats.tokens': 'Input {input} tok · Output {output} tok',
404
395
  'turnCostChip': '{cost}',
405
- 'stats.tokensDetail': 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok',
406
396
  cachePrecision: 'Precise cache-hit rate',
407
- cachePrecisionDesc: 'Show the cache-hit rate with two decimals in the session stats line.',
397
+ cachePrecisionDesc: 'Show two decimals in the official usage pill and Token usage dialog.',
408
398
  tokenDetail: 'Session token detail',
409
- tokenDetailDesc: 'Show total, cache hit/miss and output tokens in the session stats line.',
399
+ tokenDetailDesc: 'Append total input including cache to the official Token usage dialog; add total cache misses when cache writes exist.',
410
400
  costDisplay: 'Cost display',
411
401
  costDisplayDesc: 'Show costs estimated at current rates in the stats line, trend tooltips and the turn cost chip.',
412
402
  costTitle: 'Estimated at current rates over historical usage, hourly precision',
@@ -1029,19 +1019,13 @@ function tipPlace(anchor, tip, bounds, gap = TIP_GAP_PX, margin = TIP_MARGIN_PX)
1029
1019
  return { left, top }
1030
1020
  }
1031
1021
 
1032
- // ===== 底部信息栏:官方 StatsLine 口径(dsh-client-ui-chat/lib/client.js 同构)+ usp 双开关 =====
1022
+ // ===== 底部信息栏(append 模式):官方 pill 数据后追加增强项 + usp 双开关 =====
1033
1023
  const STATS_LINE_STORAGE_KEY = 'dsh-usage-dash:stats-line'
1034
1024
  const LOCALE_NS = 'usage-dash'
1035
- const STATS_ITEM_SEPARATOR = ' · '
1036
1025
  const MS_PER_SECOND = 1000
1037
1026
  const SECONDS_PER_MINUTE = 60
1038
1027
  const NUMBER_ONE_DECIMAL = 10
1039
1028
  const NUMBER_COMPACT_INT_THRESHOLD = 100
1040
- const PERCENT_TENTH_SCALE = 10
1041
- const PERCENT_GAP_DOUBLE_SCALE = 2 * PERCENT_SCALE
1042
- const PERCENT_LOSS_BASE = 10
1043
- const PERCENT_LOSS_CAP = 4
1044
- const PERCENT_LOSS_DEFAULT = 5
1045
1029
  const PERCENT_PRECISE_CAP = 99.99
1046
1030
  const DURATION_MINUTE_SECONDS = 60
1047
1031
  const TPS_INTEGER_THRESHOLD = 10
@@ -1051,65 +1035,6 @@ function billedInputTokens(usage) {
1051
1035
  return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
1052
1036
  }
1053
1037
 
1054
- // 官方二分取整:求最大 units 使 hit ≥ (2u-1)q + ceil((2u-1)r/2s),Math.round(hit·scale/D) 的保守复刻
1055
- function roundedPercentUnits(cacheReadTokens, denominator, decimalPlaces) {
1056
- const scale = (decimalPlaces === 0 ? 1 : PERCENT_TENTH_SCALE) * PERCENT_SCALE
1057
- const doubledScale = scale * 2
1058
- const quotient = Math.floor(denominator / doubledScale)
1059
- const remainder = denominator % doubledScale
1060
- const holds = (candidate) => {
1061
- const doubled = candidate * 2 - 1
1062
- return cacheReadTokens >= doubled * quotient + Math.ceil((doubled * remainder) / doubledScale)
1063
- }
1064
- let low = 0
1065
- let high = scale
1066
- while (low < high) {
1067
- const mid = Math.floor((low + high + 1) / 2)
1068
- if (holds(mid)) low = mid
1069
- else high = mid - 1
1070
- }
1071
- return low
1072
- }
1073
-
1074
- function displayPercentUnits(units, decimalPlaces) {
1075
- if (decimalPlaces === 0) return String(units)
1076
- const whole = Math.floor(units / PERCENT_TENTH_SCALE)
1077
- const tenths = units % PERCENT_TENTH_SCALE
1078
- return tenths === 0 ? String(whole) : `${whole}.${tenths}`
1079
- }
1080
-
1081
- // 官方整数百分比:0.5 进位;舍入溢出 100 而仍有 miss 时以 99.x 闭式诚实呈现
1082
- function formatCacheHitPercent(cacheReadTokens, promptTokens, decimalPlaces = 0) {
1083
- if (promptTokens === 0) return null
1084
- const missed = promptTokens - cacheReadTokens
1085
- if (missed === 0) return '100'
1086
- const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces)
1087
- const unitsCeiling = decimalPlaces === 0 ? PERCENT_SCALE : PERCENT_SCALE * PERCENT_TENTH_SCALE
1088
- if (roundedUnits < unitsCeiling) return displayPercentUnits(roundedUnits, decimalPlaces)
1089
- let distinguishingPlaces = 1
1090
- let scaledDoubleGap = missed * PERCENT_GAP_DOUBLE_SCALE
1091
- const denominatorTens = Math.floor(promptTokens / PERCENT_TENTH_SCALE)
1092
- while (scaledDoubleGap <= denominatorTens) {
1093
- scaledDoubleGap *= PERCENT_TENTH_SCALE
1094
- distinguishingPlaces += 1
1095
- }
1096
- const denominatorOnes = promptTokens % PERCENT_TENTH_SCALE
1097
- let roundedLoss = PERCENT_LOSS_DEFAULT
1098
- for (let loss = 1; loss <= PERCENT_LOSS_CAP; loss++) {
1099
- const factor = loss * 2 + 1
1100
- const threshold = factor * denominatorTens + Math.floor((factor * denominatorOnes) / PERCENT_TENTH_SCALE)
1101
- if (scaledDoubleGap <= threshold) {
1102
- roundedLoss = loss
1103
- break
1104
- }
1105
- }
1106
- return `99.${'9'.repeat(distinguishingPlaces - 1)}${PERCENT_LOSS_BASE - roundedLoss}`
1107
- }
1108
-
1109
- function cacheHitPercent(usage) {
1110
- return formatCacheHitPercent(usage.cacheReadTokens, billedInputTokens(usage), 0)
1111
- }
1112
-
1113
1038
  // usp 增强:恒两位小数,有 miss 即 99.99 封顶(镜像官方整数路径的诚实性)
1114
1039
  function cacheHitPercentPrecise(usage) {
1115
1040
  const billed = billedInputTokens(usage)
@@ -1148,90 +1073,68 @@ function formatTokensPerSecond(tps) {
1148
1073
  return String(Math.round(clamped * NUMBER_ONE_DECIMAL) / NUMBER_ONE_DECIMAL)
1149
1074
  }
1150
1075
 
1151
- // 官方节点读数:TTFT/decode 仅在对应时间戳齐全时产出,usage 须为非负有限数
1152
- function assistantStepReading(node) {
1153
- const timing = node.timing
1154
- const ttftMs = timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
1155
- ? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
1156
- : null
1157
- const decodeMs = timing !== undefined && timing.firstTokenTime !== null
1158
- ? Math.max(0, timing.completedTime - timing.firstTokenTime)
1159
- : null
1160
- const outputTokens = typeof node.usage === 'number' && Number.isFinite(node.usage) && node.usage >= 0
1161
- ? node.usage
1162
- : null
1163
- return { ttftMs, decodeMs, outputTokens }
1164
- }
1165
-
1166
- // 官方窗口折叠:tool-result 累计工具时长,assistant 计轮次/步数/LLM 时长并聚合读数
1167
- function deriveStats(nodes) {
1168
- const turns = new Set()
1169
- const stats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
1170
- for (const node of nodes) {
1171
- if (node.kind === 'tool-result') {
1172
- if (node.callTime !== null) stats.toolMs += Math.max(0, node.time - node.callTime)
1173
- continue
1174
- }
1175
- if (node.kind !== 'assistant') continue
1176
- turns.add(node.turn)
1177
- stats.steps += 1
1178
- const timing = node.timing
1179
- if (timing !== undefined && timing.stepStartTime !== null) {
1180
- stats.llmMs += Math.max(0, timing.completedTime - timing.stepStartTime)
1181
- }
1182
- const reading = assistantStepReading(node)
1183
- if (reading.ttftMs !== null) {
1184
- stats.ttftMs += reading.ttftMs
1185
- stats.ttftSteps += 1
1076
+ function buildUsageDialogRows(usage, prefs, t) {
1077
+ if (!usage || billedInputTokens(usage) === 0) return []
1078
+ const rows = []
1079
+ if (prefs.tokenDetail) {
1080
+ rows.push({ key: 'input', label: t('stats.inputTotal'), value: `${formatTokens(billedInputTokens(usage))} tok` })
1081
+ if (usage.cacheWriteTokens > 0) rows.push({ key: 'miss', label: t('stats.missTotal'), value: `${formatTokens(usage.uncachedInputTokens + usage.cacheWriteTokens)} tok` })
1082
+ }
1083
+ return rows
1084
+ }
1085
+
1086
+ // 仅改写百分比文本,保留官方节点与事件;卸载时只还原仍由本次增强持有的值。
1087
+ function observeUsageDialog(anchor, document, Observer, onTarget, percent) {
1088
+ let current
1089
+ const changes = new Map()
1090
+ const patch = (node, key, read, write) => {
1091
+ const before = read()
1092
+ if (typeof before !== 'string') return
1093
+ const after = before.replace(/\d+(?:\.\d+)?%/g, `${percent}%`)
1094
+ if (after === before) return
1095
+ let fields = changes.get(node)
1096
+ if (!fields) changes.set(node, fields = new Map())
1097
+ fields.set(key, { before, after, read, write })
1098
+ write(after)
1099
+ }
1100
+ const patchText = (element) => {
1101
+ if (!element || percent === null) return
1102
+ const walker = document.createTreeWalker(element, 4)
1103
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
1104
+ const textNode = node
1105
+ patch(textNode, 'text', () => textNode.nodeValue, (value) => { textNode.nodeValue = value })
1186
1106
  }
1187
- if (reading.decodeMs !== null && reading.outputTokens !== null) {
1188
- stats.decodeMs += reading.decodeMs
1189
- stats.decodeTokens += reading.outputTokens
1107
+ }
1108
+ const releaseChanged = (records) => {
1109
+ for (const record of records) {
1110
+ const fields = changes.get(record.target)
1111
+ if (record.type === 'characterData') fields?.delete('text')
1112
+ if (record.type === 'attributes' && record.attributeName === 'aria-label') fields?.delete('aria')
1190
1113
  }
1114
+ for (const [node, fields] of changes) if (fields.size === 0 || !document.contains(node)) changes.delete(node)
1191
1115
  }
1192
- stats.turns = turns.size
1193
- return stats
1194
- }
1195
-
1196
- // 分组装配:官方 StatsLine 分组序 + usp 双开关(精确命中率/Token 明细)+ 费用开关;
1197
- // 费用组在 Token 组后追加:开关关/无用量/价格未加载不渲染,规则已载无命中价显示占位符
1198
- function buildStatsGroups(stats, usage, prefs, t, pricingRules = null) {
1199
- const groups = []
1200
- if (stats.steps > 0) {
1201
- groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
1202
- const durations = []
1203
- if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) }))
1204
- if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) }))
1205
- if (durations.length > 0) groups.push(durations.join(STATS_ITEM_SEPARATOR))
1206
- const speeds = []
1207
- if (stats.ttftSteps > 0) speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) }))
1208
- if (stats.decodeMs > 0) speeds.push(t('stats.tokensPerSecond', {
1209
- throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / MS_PER_SECOND)),
1210
- }))
1211
- if (speeds.length > 0) groups.push(speeds.join(STATS_ITEM_SEPARATOR))
1116
+ const update = (records = []) => {
1117
+ releaseChanged(records)
1118
+ const pill = anchor?.closest('[data-slot="conversation.composer.dock"]')?.querySelector('[data-composer-stat="usage"]')
1119
+ const button = pill?.querySelector('button')
1120
+ patchText(pill)
1121
+ if (button && percent !== null) patch(button, 'aria', () => button.getAttribute('aria-label'), (value) => button.setAttribute('aria-label', value))
1122
+ const dialogs = document.querySelectorAll('[data-session-stats-usage]')
1123
+ const next = button?.getAttribute('aria-expanded') === 'true' && dialogs.length === 1 ? dialogs[0] : null
1124
+ patchText(next?.querySelector('dd'))
1125
+ observer.takeRecords()
1126
+ if (next !== current) { current = next; onTarget(next) }
1212
1127
  }
1213
- if (usage !== undefined && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
1214
- const percent = prefs.cachePrecision ? cacheHitPercentPrecise(usage) : cacheHitPercent(usage)
1215
- if (percent !== null) groups.push(t('stats.cacheHit', { percent }))
1216
- if (prefs.tokenDetail) {
1217
- const billed = billedInputTokens(usage)
1218
- groups.push(t('stats.tokensDetail', {
1219
- total: formatTokensCompact(billed + usage.outputTokens, t),
1220
- input: formatTokensCompact(billed, t),
1221
- hit: formatTokensCompact(usage.cacheReadTokens, t),
1222
- miss: formatTokensCompact(usage.uncachedInputTokens + usage.cacheWriteTokens, t),
1223
- output: formatTokensCompact(usage.outputTokens, t),
1224
- }))
1225
- } else {
1226
- groups.push(t('stats.tokens', {
1227
- input: formatTokensCompact(billedInputTokens(usage), t),
1228
- output: formatTokensCompact(usage.outputTokens, t),
1229
- }))
1128
+ const observer = new Observer(update)
1129
+ observer.observe(document.body, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['aria-expanded', 'aria-label'] })
1130
+ update()
1131
+ return () => {
1132
+ releaseChanged(observer.takeRecords())
1133
+ observer.disconnect()
1134
+ for (const fields of changes.values()) for (const change of fields.values()) {
1135
+ if (change.read() === change.after) change.write(change.before)
1230
1136
  }
1231
- const costItem = buildCostItem(usage, pricingRules, prefs, t)
1232
- if (costItem !== null) groups.push(costItem)
1233
1137
  }
1234
- return groups
1235
1138
  }
1236
1139
 
1237
1140
  // usp stats-line 偏好状态工厂:storage 注入便于 Node 测试,读写全防御,内存值始终生效
@@ -1949,7 +1852,6 @@ if (typeof window !== 'undefined' && window.__ModuleLoader__) {
1949
1852
  // 遮蔽语义:同 id 同 priority 属注册冲突(注册表抛错),需取更低值压过
1950
1853
  // 同格竞争者;usp 同款接管注册用 -1,本插件取次低值,官方无显式 priority(默认 0)
1951
1854
  const STATS_SLOT_PRIORITY = -2
1952
- const STATS_LINE_TITLE_SEPARATOR = ' | '
1953
1855
  const VIEW_TABS = [
1954
1856
  { id: 'day', labelKey: 'viewDay' },
1955
1857
  { id: 'hour', labelKey: 'viewHour' },
@@ -2192,8 +2094,8 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2192
2094
  .ud-pricing-actions{display:flex;align-items:center;gap:8px}
2193
2095
  .ud-rule-add{border:1px dashed var(--dsw-alias-border-l2);border-radius:8px;background:transparent;color:var(--dsw-alias-label-tertiary);padding:8px;font-size:12px;cursor:pointer}
2194
2096
  .ud-rule-add:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
2195
- .ud-statsline-root{text-align:center;max-width:var(--dsh-chat-content-width);box-sizing:border-box;width:100%;padding:4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;margin:0 auto;display:block;overflow:hidden}
2196
- .ud-statsline-sep{color:var(--dsw-alias-separator-primary);margin:0 10px}
2097
+ /* dock 内并列条目:官方 pill 满宽不动,空间不足时高收缩因子让自身先收缩(min-width:0 配合 ellipsis),挤压力不转嫁官方 */
2098
+ .ud-statsline-root{text-align:center;max-width:var(--dsh-chat-content-width);box-sizing:border-box;flex:0 999 auto;min-width:0;padding:0;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(20px + var(--dsh-content-font-delta-secondary,0px));color:var(--dsw-alias-label-tertiary);white-space:nowrap;text-overflow:ellipsis;margin:0;margin-left:calc(0px - var(--ud-dock-gap,0px));text-indent:var(--ud-dock-gap,0px);display:block;overflow:hidden}
2197
2099
  /* 芯片 portal 至动作行末尾,历史轮悬停/焦点显隐随父级 data-actions-reveal,无需自绘规则 */
2198
2100
  .ud-turn-cost{font-size:var(--dsh-content-font-size-secondary,13px);color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;white-space:nowrap;margin-left:${TURN_COST_GAP_PX}px}
2199
2101
  `
@@ -2691,17 +2593,18 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2691
2593
  h('span', { className: 'ud-switch__thumb' })))
2692
2594
  }
2693
2595
 
2694
- // 底部信息栏接管:与官方 StatsLine 同 id 'stats' 的槽条目,双开关控制增强项
2695
- // 语言切换经槽的 locale 座以新 t 引用驱动 memo 重渲染
2696
- const StatsLineEnhanced = React.memo(function StatsLineEnhanced({ useChat, useProjection, t = defaultT }) {
2697
- if (typeof useProjection !== 'function' || typeof useChat !== 'function') return null
2596
+ function useUsageDialogTarget(anchorRef, percent) {
2597
+ const [target, setTarget] = useState(null)
2598
+ useEffect(() => observeUsageDialog(anchorRef.current, document, MutationObserver, setTarget, percent), [percent])
2599
+ return target
2600
+ }
2601
+
2602
+ // 官方明细末尾仅追加补充项,底栏保留费用。
2603
+ const StatsLineAppended = React.memo(function StatsLineAppended({ useProjection, t = defaultT }) {
2604
+ if (typeof useProjection !== 'function') return null
2698
2605
  const usage = useProjection('tokenUsage')
2699
- const projected = useProjection('sessionStats')
2700
- const settledNodes = useChat((state) => state.legacy.nodes)
2701
- const stats = useMemo(() => projected ?? deriveStats(settledNodes ?? []), [projected, settledNodes])
2702
2606
  const [prefs, setPrefs] = useState(() => statsLineState.get())
2703
2607
  useEffect(() => statsLineState.subscribe(() => setPrefs(statsLineState.get())), [])
2704
- // 价格异步首帧可能未回:回包经状态刷新补渲染,未回期间费用组不渲染
2705
2608
  const [pricingRules, setPricingRules] = useState(null)
2706
2609
  useEffect(() => {
2707
2610
  let alive = true
@@ -2710,31 +2613,28 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
2710
2613
  })
2711
2614
  return () => { alive = false }
2712
2615
  }, [])
2713
- const groups = buildStatsGroups(stats, usage, prefs, t, pricingRules)
2714
- const costItem = buildCostItem(usage, pricingRules, prefs, t)
2715
- const entries = groups.map((text) => ({ text }))
2716
- if (costItem !== null && groups[groups.length - 1] === costItem) {
2717
- entries[entries.length - 1] = { text: costItem, title: t('statsCostTitle') }
2718
- }
2719
- const rootRef = useRef(null)
2720
- const [truncated, setTruncated] = useState(false)
2616
+ const anchorRef = useRef(null)
2617
+ const costRef = useRef(null)
2721
2618
  useLayoutEffect(() => {
2722
- const element = rootRef.current
2723
- if (!element) return undefined
2724
- const measure = () => setTruncated(element.scrollWidth > element.clientWidth)
2725
- measure()
2726
- const observer = new ResizeObserver(measure)
2727
- observer.observe(element)
2728
- return () => observer.disconnect()
2729
- }, [groups])
2730
- if (entries.length === 0) return null
2731
- return h('div', {
2732
- className: 'ud-statsline-root',
2733
- ref: rootRef,
2734
- title: truncated ? entries.map((entry) => entry.text).join(STATS_LINE_TITLE_SEPARATOR) : undefined,
2735
- }, entries.map((entry, index) => h(React.Fragment, { key: index },
2736
- index > 0 && h('span', { className: 'ud-statsline-sep', 'aria-hidden': true }, '|'),
2737
- entry.title ? h('span', { title: entry.title }, entry.text) : entry.text)))
2619
+ const element = costRef.current
2620
+ const dock = anchorRef.current?.closest('[data-slot="conversation.composer.dock"]')?.parentElement
2621
+ if (element && dock) element.style.setProperty('--ud-dock-gap', getComputedStyle(dock).columnGap)
2622
+ })
2623
+ const percent = prefs.cachePrecision && usage ? cacheHitPercentPrecise(usage) : null
2624
+ const target = useUsageDialogTarget(anchorRef, percent)
2625
+ const rows = buildUsageDialogRows(usage, prefs, t)
2626
+ const costItem = usage && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
2627
+ ? buildCostItem(usage, pricingRules, prefs, t)
2628
+ : null
2629
+ const details = target && createPortal && rows.length > 0
2630
+ ? createPortal(rows.map((row) => h(React.Fragment, { key: row.key },
2631
+ h('dt', { 'data-ud-usage-detail': row.key }, row.label),
2632
+ h('dd', { 'data-ud-usage-detail': row.key }, row.value))), target)
2633
+ : null
2634
+ return h(React.Fragment, null,
2635
+ h('span', { ref: anchorRef, hidden: true }),
2636
+ costItem !== null && h('div', { ref: costRef, className: 'ud-statsline-root', title: t('statsCostTitle') }, costItem),
2637
+ details)
2738
2638
  })
2739
2639
 
2740
2640
  // 注入点B 组件:回合费用芯片,官方动作行内渲染(复制与分支图标之间,赞踩/上下文跳转同排);
@@ -3550,12 +3450,13 @@ body[data-ds-dark-theme] .ud-panel{--ud-chart-1:color-mix(in srgb,#0576ff 65%,wh
3550
3450
  { name: 'settings.section', id: 'usage-dash', order: 45, label: () => ctx.locale.bind(LOCALE_NS)('nav'), locale: LOCALE_NS },
3551
3451
  UsageDashPanel,
3552
3452
  ))
3553
- // 两段式接管官方 stats 格:宿主缺该插槽时注册抛错即禁用本功能
3453
+ // 两段式注册官方 dock 槽增强条目:宿主缺该插槽时注册抛错即禁用本功能;
3454
+ // order 2 排官方 activity(0)/usage(1) 之后,append 在官方数据后面
3554
3455
  try {
3555
3456
  ctx.slots.inject('conversation.composer.dock', () =>
3556
3457
  ctx.slots.register(
3557
- { name: 'conversation.composer.dock', id: 'stats', order: 0, priority: STATS_SLOT_PRIORITY, locale: LOCALE_NS },
3558
- StatsLineEnhanced,
3458
+ { name: 'conversation.composer.dock', id: 'stats', order: 2, priority: STATS_SLOT_PRIORITY, locale: LOCALE_NS },
3459
+ StatsLineAppended,
3559
3460
  ))
3560
3461
  } catch (error) {
3561
3462
  console.warn('[usage-dash] 底部信息栏未注册(宿主无 conversation.composer.dock 插槽)', error)
@@ -0,0 +1,75 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
4
+
5
+ const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
6
+ const usage = { uncachedInputTokens: 1000, cacheReadTokens: 4000, cacheWriteTokens: 0, outputTokens: 500 }
7
+ const on = { cachePrecision: true, tokenDetail: true, costDisplay: true }
8
+ const rules = [{ model: '*/*', currency: '¥', price: { input: 2, cacheRead: 0, cacheWrite: 0, output: 0 }, conditions: [] }]
9
+
10
+ function render(options = {}) {
11
+ const { prefs = on, lang = 'zh', pricing = rules, target = {} } = options
12
+ const value = Object.hasOwn(options, 'value') ? options.value : usage
13
+ const source = CLIENT_BODY.match(/const StatsLineAppended = React\.memo\(([\s\S]*?)\n \}\)/)?.[1] + '\n}'
14
+ let stateIndex = 0
15
+ const React = { Fragment: 'fragment', useId: () => ':test:' }
16
+ const h = (type, props, ...children) => ({ type, props: props ?? {}, children: children.flat(Infinity).filter((x) => x != null && x !== false) })
17
+ const useState = (init) => [stateIndex++ === 1 ? pricing : typeof init === 'function' ? init() : init, () => {}]
18
+ const noop = () => {}
19
+ const names = Object.keys(core)
20
+ const component = new Function(...names, 'React', 'h', 'useState', 'useEffect', 'useLayoutEffect', 'useRef', 'statsLineState', 'defaultT', 'fetchPricing', 'useUsageDialogTarget', 'createPortal', `return (${source})`)(...Object.values(core), React, h, useState, noop, noop, () => ({ current: null }), { get: () => prefs }, core.createTranslator(core.MESSAGES_ZH), noop, () => target, (content, container) => ({ type: 'portal', props: { container }, children: [content].flat(Infinity) }))
21
+ return component({ useProjection: () => value, t: core.createTranslator(lang === 'en' ? core.MESSAGES_EN : core.MESSAGES_ZH) })
22
+ }
23
+ const nodes = (tree) => tree && typeof tree === 'object' ? [tree, ...tree.children.flatMap(nodes)] : []
24
+ const text = (tree) => typeof tree === 'string' ? tree : tree?.children.map(text).join('') ?? ''
25
+ const inlineText = (tree) => tree?.type === 'portal' ? '' : typeof tree === 'string' ? tree : tree?.children.map(inlineText).join('') ?? ''
26
+
27
+ test('Given 旧三开偏好 When 渲染 Then 底栏只有费用,补充项追加到官方弹窗', () => {
28
+ const target = {}
29
+ const tree = render({ target })
30
+ assert.equal(inlineText(tree), '费用 ≈ ¥0.0020')
31
+ assert.equal(nodes(tree).filter((n) => n.type === 'button' || n.props.popover).length, 0)
32
+ const portal = nodes(tree).find((n) => n.type === 'portal')
33
+ assert.equal(portal.props.container, target)
34
+ assert.doesNotMatch(text(portal), /命中率/)
35
+ assert.match(text(portal), /输入合计(含缓存)5,000 tok/)
36
+ assert.doesNotMatch(text(portal), /总 |输出|未命中缓存|命中缓存/)
37
+ })
38
+
39
+ test('Given 英文与费用关闭 When 渲染 Then 仅向官方弹窗追加英文补充项', () => {
40
+ const tree = render({ prefs: { ...on, costDisplay: false }, lang: 'en' })
41
+ assert.equal(inlineText(tree), '')
42
+ assert.doesNotMatch(text(tree), /cache-hit/)
43
+ assert.match(text(tree), /Total input \(including cache\)5,000 tok/)
44
+ })
45
+
46
+ test('Given 官方弹窗未打开 When 渲染 Then 没有额外入口或补充节点', () => {
47
+ const tree = render({ target: null })
48
+ assert.equal(text(tree), '费用 ≈ ¥0.0020')
49
+ assert.equal(nodes(tree).filter((n) => n.type === 'portal').length, 0)
50
+ })
51
+
52
+ test('Given 明细开关全关 When 渲染 Then 只显示费用', () => {
53
+ const tree = render({ prefs: { cachePrecision: false, tokenDetail: false, costDisplay: true } })
54
+ assert.equal(text(tree), '费用 ≈ ¥0.0020')
55
+ assert.equal(nodes(tree).filter((n) => n.type === 'portal').length, 0)
56
+ })
57
+
58
+ test('Given 空用量或全关 When 渲染 Then 不留下空条目', () => {
59
+ for (const value of [undefined, { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }]) assert.equal(text(render({ value })), '')
60
+ assert.equal(text(render({ prefs: { cachePrecision: false, tokenDetail: false, costDisplay: false } })), '')
61
+ })
62
+
63
+ test('Given 只有输出且费用未加载 When 渲染 Then 官方已有输出,不追加零值明细', () => {
64
+ assert.equal(text(render({ value: { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }, pricing: null })), '')
65
+ })
66
+
67
+ test('Given 有缓存写入 When 渲染 Then 追加合计未命中量且两开关独立', () => {
68
+ const value = { ...usage, cacheWriteTokens: 2000 }
69
+ const detailOnly = render({ value, prefs: { cachePrecision: false, tokenDetail: true, costDisplay: false } })
70
+ assert.match(text(detailOnly), /输入合计(含缓存)7,000 tok/)
71
+ assert.match(text(detailOnly), /未命中合计(含缓存写入)3,000 tok/)
72
+ assert.doesNotMatch(text(detailOnly), /命中率/)
73
+ const precisionOnly = render({ value, prefs: { cachePrecision: true, tokenDetail: false, costDisplay: false } })
74
+ assert.equal(text(precisionOnly), '')
75
+ })
@@ -1,4 +1,4 @@
1
- // 底部信息栏增强纯函数层测试:官方 StatsLine 口径(dsh-client-ui-chat 同构)+ usp 双开关状态
1
+ // 底部信息栏 append 条目纯函数层测试:官方 pill 数据后追加增强项 + usp 双开关状态
2
2
  // Given/When/Then 场景内嵌于用例描述
3
3
  // client.js 为经典 script bundle(禁 import/export),整文件 IIFE 书挡;经 client-eval 剥壳后整源求值,按顶层声明名收集
4
4
  import { test } from 'node:test'
@@ -12,24 +12,16 @@ const {
12
12
  MESSAGES_EN,
13
13
  MESSAGES_ZH,
14
14
  billedInputTokens,
15
- roundedPercentUnits,
16
- displayPercentUnits,
17
- formatCacheHitPercent,
18
- cacheHitPercent,
19
15
  cacheHitPercentPrecise,
20
16
  formatTokensCompact,
21
17
  formatDuration,
22
18
  formatTokensPerSecond,
23
- assistantStepReading,
24
- deriveStats,
25
- buildStatsGroups,
26
19
  buildCostItem,
27
20
  createStatsLineState,
28
21
  createTranslator,
29
22
  } = core
30
23
 
31
24
  const USAGE = { uncachedInputTokens: 1000, cacheReadTokens: 4000, cacheWriteTokens: 0, outputTokens: 500 }
32
- const STATS = { turns: 2, steps: 3, llmMs: 1500, toolMs: 2000, ttftMs: 200, ttftSteps: 1, decodeMs: 800, decodeTokens: 50 }
33
25
  const PREFS_OFF = { cachePrecision: false, tokenDetail: false, costDisplay: false }
34
26
  const PREFS_COST_ON = { cachePrecision: false, tokenDetail: false, costDisplay: true }
35
27
  const zhT = createTranslator(MESSAGES_ZH)
@@ -54,20 +46,13 @@ function fakeStorage(initial = {}) {
54
46
  }
55
47
  }
56
48
 
57
- test('stats 词典 zh 关键值逐字节对齐官方,en 族逐字节取官方 chat', () => {
49
+ test('stats 词典关键值逐字节,en 族逐字节', () => {
58
50
  assert.equal(STATS_LINE_STORAGE_KEY, 'dsh-usage-dash:stats-line')
59
- assert.equal(MESSAGES_ZH['stats.counts'], '{turns} 轮 · {steps} 步')
60
- assert.equal(MESSAGES_ZH['stats.llm'], 'LLM {duration}')
61
- assert.equal(MESSAGES_ZH['stats.toolCall'], '工具调用 {duration}')
62
- assert.equal(MESSAGES_ZH['stats.ttftAverage'], '首 token 平均 {duration}')
63
- assert.equal(MESSAGES_ZH['stats.tokensPerSecond'], '{throughput} tok/s')
64
- assert.equal(MESSAGES_ZH['stats.cacheHit'], '缓存命中 {percent}%')
65
51
  assert.equal(MESSAGES_ZH['stats.tokens'], '输入 {input} tok · 输出 {output} tok')
66
- assert.equal(MESSAGES_ZH['stats.tokensDetail'], '总 {total} tok · 输入 {input} tok · 命中缓存 {hit} tok · 未命中缓存 {miss} tok · 输出 {output} tok')
67
52
  assert.equal(MESSAGES_ZH.cachePrecision, '精确缓存命中率')
68
- assert.equal(MESSAGES_ZH.cachePrecisionDesc, '在会话底部信息栏以两位小数显示缓存命中率。')
53
+ assert.equal(MESSAGES_ZH.cachePrecisionDesc, '将官方用量胶囊及弹窗中的命中率显示为两位小数。')
69
54
  assert.equal(MESSAGES_ZH.tokenDetail, '会话 Token 明细')
70
- assert.equal(MESSAGES_ZH.tokenDetailDesc, '在会话底部信息栏显示总 Token、命中/未命中缓存与输出明细。')
55
+ assert.equal(MESSAGES_ZH.tokenDetailDesc, '在官方 Token 用量弹窗末尾追加含缓存输入合计;存在缓存写入时追加未命中合计。')
71
56
  assert.equal(MESSAGES_ZH['stats.cost'], '费用 ≈ {cost}')
72
57
  assert.equal(MESSAGES_ZH.costDisplay, '费用显示')
73
58
  assert.equal(MESSAGES_EN['stats.cost'], 'Cost ≈ {cost}')
@@ -76,14 +61,6 @@ test('stats 词典 zh 关键值逐字节对齐官方,en 族逐字节取官方 ch
76
61
  assert.equal(MESSAGES_ZH['duration.compactMinutes'], '{minutes}分{seconds}秒')
77
62
  assert.equal(MESSAGES_ZH['number.thousand'], '{value}K')
78
63
  assert.equal(MESSAGES_ZH['number.million'], '{value}M')
79
- assert.equal(MESSAGES_EN['stats.counts'], '{turns} turns · {steps} steps')
80
- assert.equal(MESSAGES_EN['stats.llm'], 'LLM {duration}')
81
- assert.equal(MESSAGES_EN['stats.toolCall'], 'Tool call {duration}')
82
- assert.equal(MESSAGES_EN['stats.ttftAverage'], 'TTFT avg {duration}')
83
- assert.equal(MESSAGES_EN['stats.tokensPerSecond'], '{throughput} tok/s')
84
- assert.equal(MESSAGES_EN['stats.cacheHit'], 'Cache hit {percent}%')
85
- assert.equal(MESSAGES_EN['stats.tokens'], 'Input {input} tok · Output {output} tok')
86
- assert.equal(MESSAGES_EN['stats.tokensDetail'], 'Total {total} tok · Input {input} tok · Cache hit {hit} tok · Cache miss {miss} tok · Output {output} tok')
87
64
  assert.equal(MESSAGES_EN['duration.compactSeconds'], '{seconds}s')
88
65
  assert.equal(MESSAGES_EN['duration.compactMinutes'], '{minutes}m{seconds}s')
89
66
  assert.equal(MESSAGES_EN['number.thousand'], '{value}K')
@@ -95,46 +72,6 @@ test('billedInputTokens 为三个互斥 prompt 桶之和', () => {
95
72
  assert.equal(billedInputTokens({ uncachedInputTokens: 100, cacheReadTokens: 200, cacheWriteTokens: 300, outputTokens: 0 }), 600)
96
73
  })
97
74
 
98
- test('roundedPercentUnits 整数位 0.5 进位', () => {
99
- // 125*100/200 = 62.5 → 63(0.5 进位,非银行家舍入)
100
- assert.equal(roundedPercentUnits(125, 200, 0), 63)
101
- assert.equal(roundedPercentUnits(75, 100, 0), 75)
102
- assert.equal(roundedPercentUnits(999946, 1000000, 0), 100)
103
- })
104
-
105
- test('roundedPercentUnits 十分位单位 0.5 进位', () => {
106
- // 755*1000/1000 = 755 个十分位 → 755('75.5')
107
- assert.equal(roundedPercentUnits(755, 1000, 1), 755)
108
- assert.equal(roundedPercentUnits(125, 200, 1), 625)
109
- })
110
-
111
- test('displayPercentUnits 按小数位渲染,十分位为 0 时省略小数点', () => {
112
- assert.equal(displayPercentUnits(75, 0), '75')
113
- assert.equal(displayPercentUnits(805, 1), '80.5')
114
- assert.equal(displayPercentUnits(800, 1), '80')
115
- })
116
-
117
- test('formatCacheHitPercent 无分母为 null,无 miss 为 100', () => {
118
- assert.equal(formatCacheHitPercent(100, 0, 0), null)
119
- assert.equal(formatCacheHitPercent(100, 100, 0), '100')
120
- })
121
-
122
- test('formatCacheHitPercent 整数位四舍五入', () => {
123
- assert.equal(formatCacheHitPercent(75, 100), '75')
124
- assert.equal(formatCacheHitPercent(125, 200), '63')
125
- assert.equal(formatCacheHitPercent(52, 100), '52')
126
- })
127
-
128
- test('formatCacheHitPercent 溢出 100 时闭式构造 99.x', () => {
129
- assert.equal(formatCacheHitPercent(999946, 1000000), '99.99')
130
- assert.equal(formatCacheHitPercent(99995, 100000), '99.995')
131
- })
132
-
133
- test('cacheHitPercent 官方整数口径与空数据 null', () => {
134
- assert.equal(cacheHitPercent(USAGE), '80')
135
- assert.equal(cacheHitPercent({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }), null)
136
- })
137
-
138
75
  test('cacheHitPercentPrecise 恒两位小数且 99.99 封顶', () => {
139
76
  assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }), null)
140
77
  assert.equal(cacheHitPercentPrecise({ uncachedInputTokens: 0, cacheReadTokens: 8000, cacheWriteTokens: 0, outputTokens: 10 }), '100.00')
@@ -163,123 +100,6 @@ test('formatTokensPerSecond 钳负值且阈值上下分别取整', () => {
163
100
  assert.equal(formatTokensPerSecond(3.24), '3.2')
164
101
  })
165
102
 
166
- test('assistantStepReading 时间戳齐全时产出 TTFT/decode/输出 token', () => {
167
- assert.deepEqual(
168
- assistantStepReading({ timing: { stepStartTime: 1000, firstTokenTime: 1200, completedTime: 2000 }, usage: 50 }),
169
- { ttftMs: 200, decodeMs: 800, outputTokens: 50 },
170
- )
171
- })
172
-
173
- test('assistantStepReading 负时长钳 0 且非法 usage 为 null', () => {
174
- // completedTime 早于 firstTokenTime → decode 钳 0
175
- assert.deepEqual(
176
- assistantStepReading({ timing: { stepStartTime: 1000, firstTokenTime: 2000, completedTime: 1500 }, usage: 30 }),
177
- { ttftMs: 1000, decodeMs: 0, outputTokens: 30 },
178
- )
179
- assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: 'oops' }).outputTokens, null)
180
- assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: -1 }).outputTokens, null)
181
- assert.equal(assistantStepReading({ timing: { stepStartTime: 0, firstTokenTime: 100, completedTime: 200 }, usage: Number.NaN }).outputTokens, null)
182
- })
183
-
184
- test('assistantStepReading 无 timing 时读数双 null', () => {
185
- assert.deepEqual(assistantStepReading({ usage: 50 }), { ttftMs: null, decodeMs: null, outputTokens: 50 })
186
- })
187
-
188
- test('deriveStats 折叠窗口节点:轮次去重/负值钳制/读数按有值步聚合', () => {
189
- // When:混合 tool-result 与 assistant(含 turn 重复、stepStart null、firstToken null、非法 usage)
190
- const nodes = [
191
- { kind: 'user' },
192
- { kind: 'tool-result', time: 5000, callTime: 3000 },
193
- { kind: 'tool-result', time: 1000, callTime: 3000 },
194
- { kind: 'tool-result', time: 9000, callTime: null },
195
- { kind: 'assistant', turn: 1, timing: { stepStartTime: 1000, firstTokenTime: 1200, completedTime: 2000 }, usage: 50 },
196
- { kind: 'assistant', turn: 1, timing: { stepStartTime: null, firstTokenTime: 1300, completedTime: 2100 }, usage: 10 },
197
- { kind: 'assistant', turn: 2, timing: { stepStartTime: 3000, firstTokenTime: null, completedTime: 3500 }, usage: 30 },
198
- { kind: 'assistant', turn: 3, timing: { stepStartTime: 4000, firstTokenTime: 4100, completedTime: 4600 }, usage: 'oops' },
199
- ]
200
- // Then:llm 1000+0+500+600;ttft 分母=有值 2 步;decode 只算时间戳与 usage 双全的步
201
- assert.deepEqual(deriveStats(nodes), {
202
- turns: 3,
203
- steps: 4,
204
- llmMs: 2100,
205
- toolMs: 2000,
206
- ttftMs: 300,
207
- ttftSteps: 2,
208
- decodeMs: 1600,
209
- decodeTokens: 60,
210
- })
211
- })
212
-
213
- test('deriveStats 空节点为零值统计', () => {
214
- assert.deepEqual(deriveStats([]), { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 })
215
- })
216
-
217
- test('buildStatsGroups 双开关全关与官方行逐字节一致', () => {
218
- assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, zhT), [
219
- '2 轮 · 3 步',
220
- 'LLM 1.5秒 · 工具调用 2秒',
221
- '首 token 平均 0.2秒 · 63 tok/s',
222
- '缓存命中 80%',
223
- '输入 5K tok · 输出 500 tok',
224
- ])
225
- })
226
-
227
- test('buildStatsGroups 传 en 翻译器输出官方 en 口径逐字节', () => {
228
- assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, enT), [
229
- '2 turns · 3 steps',
230
- 'LLM 1.5s · Tool call 2s',
231
- 'TTFT avg 0.2s · 63 tok/s',
232
- 'Cache hit 80%',
233
- 'Input 5K tok · Output 500 tok',
234
- ])
235
- })
236
-
237
- test('buildStatsGroups en 零命中率呈现 Cache hit 0%', () => {
238
- const zeroHitStats = { turns: 2, steps: 3, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
239
- const zeroHitUsage = { uncachedInputTokens: 4000, cacheReadTokens: 0, cacheWriteTokens: 1000, outputTokens: 0 }
240
- assert.deepEqual(buildStatsGroups(zeroHitStats, zeroHitUsage, PREFS_OFF, enT), [
241
- '2 turns · 3 steps',
242
- 'Cache hit 0%',
243
- 'Input 5K tok · Output 0 tok',
244
- ])
245
- })
246
-
247
- test('buildStatsGroups tokenDetail 开关切换五项明细组', () => {
248
- const groups = buildStatsGroups(STATS, USAGE, { cachePrecision: false, tokenDetail: true }, zhT)
249
- assert.deepEqual(groups.slice(0, 4), [
250
- '2 轮 · 3 步',
251
- 'LLM 1.5秒 · 工具调用 2秒',
252
- '首 token 平均 0.2秒 · 63 tok/s',
253
- '缓存命中 80%',
254
- ])
255
- assert.equal(groups[4], '总 5.5K tok · 输入 5K tok · 命中缓存 4K tok · 未命中缓存 1K tok · 输出 500 tok')
256
- })
257
-
258
- test('buildStatsGroups cachePrecision 开关切精确命中率', () => {
259
- const groups = buildStatsGroups(STATS, USAGE, { cachePrecision: true, tokenDetail: false }, zhT)
260
- assert.equal(groups[3], '缓存命中 80.00%')
261
- })
262
-
263
- test('buildStatsGroups usage 缺席时命中与 Token 组整体缺席', () => {
264
- assert.deepEqual(buildStatsGroups(STATS, undefined, PREFS_OFF, zhT), [
265
- '2 轮 · 3 步',
266
- 'LLM 1.5秒 · 工具调用 2秒',
267
- '首 token 平均 0.2秒 · 63 tok/s',
268
- ])
269
- })
270
-
271
- test('buildStatsGroups 无步进且用量空为空数组', () => {
272
- const zeroStats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
273
- const zeroUsage = { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
274
- assert.deepEqual(buildStatsGroups(zeroStats, zeroUsage, PREFS_OFF, zhT), [])
275
- })
276
-
277
- test('buildStatsGroups 仅输出时命中率组因 null 丢弃', () => {
278
- const zeroStats = { turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0 }
279
- const usage = { uncachedInputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 500 }
280
- assert.deepEqual(buildStatsGroups(zeroStats, usage, PREFS_OFF, zhT), ['输入 0 tok · 输出 500 tok'])
281
- })
282
-
283
103
  test('createStatsLineState 默认三开,非法 JSON 回落三开,显式 false 才视为关', () => {
284
104
  assert.deepEqual(createStatsLineState(fakeStorage()).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
285
105
  assert.deepEqual(createStatsLineState(null).get(), { cachePrecision: true, tokenDetail: true, costDisplay: true })
@@ -334,15 +154,8 @@ test('createStatsLineState 写入失败仅丢持久化,内存仍生效', () => {
334
154
 
335
155
  // —— S14 费用组(buildCostItem) ——
336
156
 
337
- test('buildCostItem 开关关恒为 null,组数组不受 rules 影响', () => {
157
+ test('buildCostItem 开关关恒为 null', () => {
338
158
  assert.equal(buildCostItem(COST_USAGE, COST_RULES, PREFS_OFF, zhT, COST_NOW), null)
339
- assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_OFF, zhT, COST_RULES), [
340
- '2 轮 · 3 步',
341
- 'LLM 1.5秒 · 工具调用 2秒',
342
- '首 token 平均 0.2秒 · 63 tok/s',
343
- '缓存命中 80%',
344
- '输入 5K tok · 输出 500 tok',
345
- ])
346
159
  })
347
160
 
348
161
  test('buildCostItem routes 缺席按全通配规则匹配,符号取全局口径(首个非空货币)', () => {
@@ -376,24 +189,22 @@ test('buildCostItem usage 缺席为 null', () => {
376
189
  assert.equal(buildCostItem(undefined, COST_RULES, PREFS_COST_ON, zhT, COST_NOW), null)
377
190
  })
378
191
 
379
- test('buildCostItem buildStatsGroups 费用组符号同全局口径', () => {
380
- // USAGE 无 routes → 通配价 input 1:1000 × 1 / 每百万 = 0.001 → 四位小数微观格式;符号取首个非空 ¥
381
- assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_COST_ON, zhT, COST_RULES), [
382
- '2 轮 · 3 步',
383
- 'LLM 1.5秒 · 工具调用 2秒',
384
- '首 token 平均 0.2秒 · 63 tok/s',
385
- '缓存命中 80%',
386
- '输入 5K tok · 输出 500 tok',
387
- '费用 ≈ ¥0.0010',
388
- ])
389
- })
390
-
391
- test('buildStatsGroups 开+无价不追加费用组', () => {
392
- assert.deepEqual(buildStatsGroups(STATS, USAGE, PREFS_COST_ON, zhT, null), [
393
- '2 轮 · 3 步',
394
- 'LLM 1.5秒 · 工具调用 2秒',
395
- '首 token 平均 0.2秒 · 63 tok/s',
396
- '缓存命中 80%',
397
- '输入 5K tok · 输出 500 tok',
398
- ])
192
+ test('dock 注册 append 契约:order 排官方 pill(activity 0/usage 1)之后,条目只消费 useProjection', () => {
193
+ assert.ok(CLIENT_BODY.includes("id: 'stats', order: 2, priority: STATS_SLOT_PRIORITY"))
194
+ assert.ok(CLIENT_BODY.includes('StatsLineAppended({ useProjection, t = defaultT })'))
195
+ assert.ok(!CLIENT_BODY.includes("useProjection('sessionStats')"))
196
+ assert.ok(!CLIENT_BODY.includes('deriveStats'))
197
+ })
198
+
199
+ test('dock append 条目布局契约:官方 pill 满宽不受挤压,空间不足时增强条目先让位', () => {
200
+ // 失效方式:回退为整行 width:100% 或去掉让位收缩/截断,官方胶囊被压缩或被溢出文本遮挡(独立环境实测 147→96px)
201
+ const rule = CLIENT_BODY.match(/\.ud-statsline-root\{[^}]*\}/)?.[0]
202
+ assert.ok(rule, '存在 .ud-statsline-root 规则')
203
+ assert.ok(!/(^|[^-a-z])width:100%/.test(rule), '不再整行占满,dock 内为并列条目')
204
+ assert.ok(!rule.includes('--dsh-composer-side-clearance'), '不再沿用整行侧边距')
205
+ assert.ok(rule.includes('flex:0 999 auto'), '高收缩因子锁定,空间不足时先收缩自身,挤压亏损不由官方 pill 承担')
206
+ assert.ok(rule.includes('padding:0;'), '费用收缩到零时不留下固定内边距挤压官方胶囊')
207
+ assert.ok(rule.includes('margin-left:calc(0px - var(--ud-dock-gap,0px))') && rule.includes('text-indent:var(--ud-dock-gap,0px)'), '抵消新增 flex gap,文字缩进提供可收缩间距')
208
+ assert.ok(rule.includes('min-width:0'), '允许收缩到零')
209
+ assert.ok(rule.includes('overflow:hidden') && rule.includes('white-space:nowrap') && rule.includes('text-overflow:ellipsis'), '收缩残态截断三件套在位,溢出不遮挡官方 pill')
399
210
  })
@@ -0,0 +1,106 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { CLIENT_BODY, DECLARATION_NAMES } from './client-eval.mjs'
4
+ const core = new Function(`${CLIENT_BODY}\nreturn { ${DECLARATION_NAMES.join(', ')} }`)()
5
+
6
+ function fixture() {
7
+ const label = { nodeValue: '缓存命中 80%' }
8
+ const other = { nodeValue: '5.5K tok' }
9
+ const value = { nodeValue: '80%' }
10
+ const button = { aria: '5.5K tok · 缓存命中 80%', expanded: 'false', getAttribute(name) { return name === 'aria-expanded' ? this.expanded : this.aria }, setAttribute(name, v) { this.aria = v } }
11
+ const pill = { querySelector: () => button, texts: [other, label] }
12
+ const dock = { querySelector: () => pill }
13
+ const anchor = { closest: () => dock }
14
+ const dd = { texts: [value] }
15
+ const dialog = { querySelector: () => dd }
16
+ let dialogs = []
17
+ let callback
18
+ let disconnected = false
19
+ let pending = []
20
+ const document = { body: {}, contains: (node) => !node.detached, querySelectorAll: () => dialogs, createTreeWalker(node) { let i = 0; return { nextNode: () => node.texts[i++] ?? null } } }
21
+ class Observer { constructor(cb) { callback = cb } observe() {} takeRecords() { const records = pending; pending = []; return records } disconnect() { disconnected = true } }
22
+ return { label, other, value, button, anchor, dialog, document, Observer, notify: (records = []) => callback(records), setDialogs: (v) => { dialogs = v }, setPending: (v) => { pending = v }, disconnected: () => disconnected }
23
+ }
24
+
25
+ test('Given 官方命中率 When 精度增强 Then 原位替换文本及 aria,关闭时还原,不增加精度行', () => {
26
+ const f = fixture(), seen = []
27
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), '80.00')
28
+ assert.equal(f.label.nodeValue, '缓存命中 80.00%')
29
+ assert.equal(f.button.aria, '5.5K tok · 缓存命中 80.00%')
30
+ assert.equal(f.other.nodeValue, '5.5K tok')
31
+ f.button.expanded = 'true'; f.setDialogs([f.dialog]); f.notify([{ type: 'attributes', attributeName: 'aria-expanded', target: f.button }])
32
+ assert.equal(f.value.nodeValue, '80.00%')
33
+ f.notify()
34
+ assert.deepEqual(seen, [null, f.dialog])
35
+ stop()
36
+ assert.equal(f.disconnected(), true)
37
+ assert.equal(f.label.nodeValue, '缓存命中 80%')
38
+ assert.equal(f.button.aria, '5.5K tok · 缓存命中 80%')
39
+ assert.equal(f.value.nodeValue, '80%')
40
+ })
41
+
42
+ test('Given 官方重渲染和重建弹窗 When 观察变更 Then 更新读数且清理不覆盖官方新值', () => {
43
+ const f = fixture(), seen = []
44
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), '80.00')
45
+ f.label.nodeValue = 'Cache hit 81%'; f.button.aria = '5.5K tok · Cache hit 81%'; f.notify()
46
+ assert.equal(f.label.nodeValue, 'Cache hit 80.00%')
47
+ f.label.nodeValue = 'Cache hit 82%'
48
+ f.button.expanded = 'true'; f.setDialogs([f.dialog]); f.notify()
49
+ f.setDialogs([]); f.notify()
50
+ f.setDialogs([f.dialog]); f.notify()
51
+ assert.deepEqual(seen, [null, f.dialog, null, f.dialog])
52
+ f.label.nodeValue = 'Cache hit 83%'
53
+ stop()
54
+ assert.equal(f.label.nodeValue, 'Cache hit 83%')
55
+ assert.equal(f.button.aria, '5.5K tok · Cache hit 81%')
56
+ })
57
+
58
+ test('Given 另一窗格用量弹窗已打开 When 本胶囊未展开 Then 不注入也不修改别窗格读数', () => {
59
+ const f = fixture(), seen = []
60
+ f.setDialogs([f.dialog])
61
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), '80.00')
62
+ assert.deepEqual(seen, [null])
63
+ assert.equal(f.value.nodeValue, '80%')
64
+ f.button.expanded = 'true'; f.setDialogs([f.dialog, {}]); f.notify()
65
+ assert.deepEqual(seen, [null])
66
+ stop()
67
+ })
68
+
69
+ test('Given 官方更新恰好等于增强值 When 关闭增强 Then 保留官方新值', () => {
70
+ const f = fixture()
71
+ f.label.nodeValue = 'Cache hit 99.999%'
72
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, () => {}, '99.99')
73
+ f.label.nodeValue = 'Cache hit 99.99%'
74
+ f.notify([{ type: 'characterData', target: f.label }])
75
+ stop()
76
+ assert.equal(f.label.nodeValue, 'Cache hit 99.99%')
77
+ })
78
+
79
+ test('Given 弹窗节点已脱离文档 When 后续更新 Then 清理不再持有或还原旧节点', () => {
80
+ const f = fixture()
81
+ f.button.expanded = 'true'; f.setDialogs([f.dialog])
82
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, () => {}, '80.00')
83
+ f.value.detached = true; f.setDialogs([]); f.notify()
84
+ stop()
85
+ assert.equal(f.value.nodeValue, '80.00%')
86
+ })
87
+
88
+ test('Given 官方更新记录尚未投递 When 立即卸载 Then 不恢复过期值', () => {
89
+ const f = fixture()
90
+ f.label.nodeValue = 'Cache hit 99.999%'
91
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, () => {}, '99.99')
92
+ f.label.nodeValue = 'Cache hit 99.99%'
93
+ f.setPending([{ type: 'characterData', target: f.label }])
94
+ stop()
95
+ assert.equal(f.label.nodeValue, 'Cache hit 99.99%')
96
+ })
97
+
98
+ test('Given 精度关闭 When 弹窗出现 Then 原始读数不变但仍可定位追加合计项', () => {
99
+ const f = fixture(), seen = []
100
+ f.button.expanded = 'true'; f.setDialogs([f.dialog])
101
+ const stop = core.observeUsageDialog(f.anchor, f.document, f.Observer, (v) => seen.push(v), null)
102
+ assert.equal(f.label.nodeValue, '缓存命中 80%')
103
+ assert.equal(f.value.nodeValue, '80%')
104
+ assert.deepEqual(seen, [f.dialog])
105
+ stop()
106
+ })