# 设计说明 DSH Usage Statistics Panel 复刻 reasonix 的用量统计功能(PR #7238 / #7503),按 DeepSeek Harness 的插件规范实现:Host 半(Node)负责采集与聚合,Client 半(浏览器)负责渲染(插件页内的独立页面,以及一个同名主面板),两者通过插件自有的 fenced HTTP 路由通信。 ## 数据流 ``` session 事件流 (session/event) │ assistant/message.usage, assistant/chunk(chunk.type=usage) │ request/context (provider/model 归因) ▼ UsageCollector ──(turn,step 去重折叠)──▶ UsageStore (storage-domain) ▲ │ │ 首次启用回扫 rangeRows(from,to) │ sessionPersistence.list() + ▼ └── inspect(id) 逐会话回放 ────── /usage/api/range (fenced) │ ▼ UsageStatsPanel (插件页 + 独立主面板, 手绘 SVG) ``` ## Host 半 ### collector.ts — 采集与去重 - **实时**:`ctx.on('session/event')` 订阅。`assistant/message` 的 `data.usage`(TokenUsage)与 `assistant/chunk` 的 `chunk.type === 'usage'` 都是 usage 样本;`request/context` 提供 provider/model 归因。fold 桶与归因路由都按回调携带的 sessionId 分桶,并发会话互不干扰;`session/disposed` 时释放对应桶。 - **去重**:同一会话内同一 `(turn, step)` 只向 store 发射一次(fold 内部保留最新值用于判重)。已发布的两个适配器在流式 chunk 与最终 message 上报告**完全相同**的 TokenUsage(llm-deepseek 在 DONE 时用同一个 `pendingUsage` 对象发一次;llm-pi-ai 仅在 done/error 终态发射),因此"首样本生效"与"取后者"观测等价。若未来适配器对同一次调用报告不同数值,需升级为 store 侧按 `(session, turn, step)` 的差值修正。跨会话的相同 `(turn, step)` 各自成立。 - **游标**:实时监听把每个触达过的会话 id 合批写入持久化游标(`markSeenSessions`,microtask 合批)。这是重启安全的关键:harness 的 `SessionStore.list()` 只返回内存中活跃的会话,会话释放后其样本只存在于 store 行和持久化日志里——没有游标,下次启动的回扫会把日志重放在已记录的行上,全部翻倍。 - **回扫**:首次启用时 `sessionPersistence.list()` 枚举全部会话(当前活跃会话除外),`inspect(id)` 读取完整事件日志(zstd 由后端内部处理),并发 4 逐会话回放——每个会话使用全新的独立 fold。单个会话读取失败不中断整体回扫(status.error 记录);游标按批(32 个/次)串行写回,避免并发读改写丢 id。每个目标重放前复查一次存活状态:快照之后恢复活跃的会话归实时监听所有,跳过重放。 - **旧库重建**:storage-domain 打开时(store 构造链内)检测"有行但游标为空"的 ≤0.1.1 旧库,一次性清空行让回扫重建——判定发生在任何 record/mark 之前,无顺序竞态。 ### store.ts — 持久化 使用 storage-domain 的 `usage_history` 域,单表 `days`: - key:`YYYY-MM-DD|provider|model` - value:`{ day, provider, model, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, requests, turns, lastSeen }` - 写入走 `KvTable.update()`(按 key 原子读改写队列),并发 turn 不交错 - 后端(web-app bundle 的 storage-json)落地 `$DSH_HOME/storages/usage_history.json` Token 桶语义:`inputTokens` 是 uncached input(即缓存 miss 侧),`cacheReadTokens` 是缓存命中侧——命中率恒为 `Σhit / Σ(hit+miss)`,与 reasonix 一致,两个分母不混。 ### query.ts — 范围聚合 翻译自 reasonix `internal/stats/query.go`: - 按日 emit 全范围(含零值日),趋势图显示完整时间轴 - token 总计、请求数、turn 数、命中率派生、活跃天数、Top 模型/Provider - 模型 ref `provider/model`,裸模型名归 `default` provider ### routes.ts + trust-fence.ts — HTTP 服务 `webServer.register({ kind: 'prefix', path: '/usage/api', handler })`: - `POST /usage/api/range`:`{ range, from?, to? }` → `UsageStatsRange`;custom 范围做语义日期校验(拒绝 `2026-13-45` 这类正则可过但日历不存在的形状)并限制跨度 ≤366 天(400) - `POST /usage/api/status`:`BackfillStatus` - 信任围栏与 `/api` 网关同源同语义(dsh-client-connection `isTrustedApiRequest`):① Host 头须为 loopback 或 webRuntime.trustedHosts 授权(无端口条目匹配任意端口,带端口条目精确匹配 host:port);② `sec-fetch-site: cross-site` 一律拒绝;③ 携带 Origin 时必须与 Host 同源("null" 视为不透明 origin 拒绝)。任一不满足即 403。 ## Client 半 ### index.tsx — 面板与侧栏入口注册 本插件注册三个面板相关槽位:`plugins.bundle.config`(keyed,键为本 bundle 的 npm 包名)把面板渲染进插件页里该组合包的详情页;`main`(keyed,键 `usage-stats`)把它注册成一个全局主面板;`sidebar.panellist`(list,id `usage-stats`,order 30)在左侧栏「新会话」下方加一行,点击即切到该主面板。面板因此由**同一组件渲染在两处**,`UsageStatsPanelPage` 用与插件页相同的 960px 内容列包住它(模块 css 的 `.page` 逐条镜像插件页自己的 `.page`,含 padding 与前景色 token),两处外观一致。locale 座绑定 `usageStats` 命名空间(en/zh/zh-TW 三份字典);面板数值格式化跟随当前语言——中文显示 亿/万(简)或 億/萬(繁),英文用 k/M/B 图表惯例。组件经 `/usage/api` fetch 数据,不直接触 ctx。 > 侧栏入口刻意**不**走 `sidebar.footer.action`:那个座位是宿主里一条与其它插件共享的 flex 行,注册在那里会与邻居争宽度——两个插件时尚可等分,三个以上就会把彼此的标签挤成省略号(2026-09 实测:256px 行里三个条目各约 85px,而「上下文洞察」一类的标签需要约 126px)。 ### UsageStatsPanel.tsx — 图表 移植自 reasonix 面板(853 行)+ PR #7503 的改动: - **热力图**:数据窗口固定为一年(52 周);宽度求解优先把空余宽度用在**更多周数**上,放不下整个窗口时改裁最早的列,两种情形都精确撑满容器;5 级色阶由 brand accent 经 color-mix 派生 - **趋势图**:堆叠柱状图按全范围用量排名着色(模型颜色逐日稳定),叠加 Catmull-Rom 命中率曲线;最窄时裁剪最早天数,超过 180 天显示提示 - **模型图 / 供应商图**:两者同一解剖——左侧环形占比图(`Donut`)+ 右侧明细列表。模型取前 10 名分色、供应商取前 5 名分色,其余折叠为灰色 "Other"。圆环直径由所在行的实测宽度求解(`resolveDonutSize`,钳在 200–280px),既随容器变化,又保证右侧明细列表不低于它的 flex 基准宽度;圆环与其列表在同一行内**垂直居中**(`align-items: center`),列表高度从不反过来决定图表尺寸。每段可聚焦(tabIndex + role="img" + aria-label + focus/hover 出 tooltip)——分段数量有界(≤11),而热力图 ~180 个格子不适合逐格进 tab 序,故热力图与趋势图保持鼠标悬停(SVG 整体带 role="img" 标注) - **展开层级**:模型区只有 "Other" 行可展开(列出被折叠的模型);供应商区两级——排名行展开该供应商的模型,"Other" 展开被折叠的供应商(每行带该供应商的模型数),这些行再展开各自的模型。展开容器是行的**兄弟节点**且不带行类名;圆环的直径只依赖行的**宽度**,故展开任一行都不会改变图表尺寸 - **色板**:`--dsw-chart-1..10` + `--dsw-chart-other`(模型)、`--dsw-provider-1..5` + `--dsw-provider-other`(供应商独立色板,供应商不穿模型色),light/dark 两套(CSS `@media (prefers-color-scheme)`),色值经 color-mix 向底色柔化 ### StatsLineEnhanced.tsx — 底部信息栏接管 以 id `stats`、`priority: -1` 注册进 `conversation.composer.dock`(list 槽,最低优先级条目渲染),遮蔽官方 StatsPills 并叠加两个读数开关。该行跨宿主代际有两套容器契约:`0.1.6-alpha.1` 及以前,槽位直接渲染进 InputBar 自己的列,行自持内容宽度(`--dsh-chat-content-width`)、左右 `clearance + 16px` 侧边距与 4px 顶距;`0.1.6-alpha.2` 起,槽位被放进与常驻 ContextMeter 并排的 flex 行 `.dock`,居中、12px 间距、顶距与侧边距由该容器负责,行只需在其中收缩。组件在 DOM 挂载时读父容器的计算样式并打 `data-dock-row` 标记,样式表按标记在两套声明间切换——一份构建同时满足两条宿主线。 ### 样式 全部视觉值走 DSH 语义 token(`--dsw-alias-*` 颜色、`--dsw-font-*` 排版),无静态色值、无主题选择器,浅色/深色由主题包负责。 ## 双通道打包 `tsdown.config.ts` 复刻官方未发布的 `tsdown.client.ts` 预设: - host ESM → `lib/index.js` - client bundle ×2:`lib/client.js`(官方 profile 通道,id=包名)与 `lib/client-registry.js`(注册表通道,id=manifest id),lazy-CJS factory(`window.__ModuleLoader__.load`),external 走模块表(react/cordis/dsh-client-* 白名单),其余内联,purity gate 拒非白名单 `@deepseek-ai` 值导入 - CSS Modules(lightningcss)哈希类名 + `