Skip to content

dsh-usage-stats

Verified

@lifei-alt/dsh-usage-stats · v0.1.0 · MIT · Web UI

DSH web plugin: CC-Switch-style usage statistics for DeepSeek Harness — by model source, by model, by date, realtime, with cache-hit accounting.

Install

dsh plugin add @lifei-alt/dsh-usage-stats

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Published to npm without a public repository. Inspect the package contents before installing.

Tags

Creators

Readme

dsh-usage-stats

给 DeepSeek Harness 的 Web GUI 加一个 用量统计 面板:按 模型来源(provider 路由)、模型、日期 分组,带 实时 视图和 缓存命中(cache read / write / miss 与命中率), 口径参照 CC Switch v3.19.2 的「使用统计」。

数据全部来自 DSH 自己的会话日志(assistant/message 事件里上报的用量), 不读 CC Switch 的数据库、不写任何会话、不碰凭据。

浏览器(左侧栏「用量统计」行 → 主栏页面)
        │  POST /usage-stats/api/summary      (每 N 秒轮询,可关)
        │  POST /usage-stats/api/calls        (请求日志分页)
        ▼
dsh-usage-stats 宿主半(cordis 插件)
        │  ctx.sessionQuery / ctx.sessionPersistence  (只读)
        ▼
~/.dsh/sessions/<项目>/<会话>/session.v3.jsonl.zstd
        └─ 折叠 assistant/message.data.usage + data.message.source
           (step/start / assistant/attempt 锚点 → 用时/首字)

面板里有什么

布局(与 CC Switch 一致):Hero → 筛选栏 → 用量趋势(常驻)→ 页签行 → 页签内容。 Hero = 真实消耗 Tokens 大数 + 总请求数 / 总成本 + 一排 tiles(↓ 新增输入 / ↑ Output / ▤ 创建 / ✧ 命中 / 缓存命中率 / ⚡ 实时输出 / ◎ 平均输出),与图一样常驻。 折线图是常驻区域,不属于任何页签——页签行在图的下方,只切换图下面的内容(请求日志、统计表 …);所以切页签不会把图切走,图也不再"被页签框住"。

标签 内容
总览 图下方:模型来源与提供商路由 Top 列表 + 今日 / 近 1 小时 KPI
用量趋势(常驻区,非页签) CC Switch 式 Hero 之上、页签之下的固定区块:平滑曲线趋势图(输入、输出、缓存命中、缓存创建、成本五条线;实线带渐变阴影与柔光,虚线是缓存与成本;左轴 tokens、右轴成本)+ 悬停十字线:竖直参考线吸附到最近的时间桶、每条线上落一个白边点位、浮卡顶部显示 2026/09/25 09:00 时间戳并一行一个系列(颜色点 + 标签 + 千分位数值,成本带 6 位小数)
按来源 来源维度的表:调用、未缓存输入、缓存读、缓存写、输出、合计、成本、命中率、最近一次时间
按项目 项目(会话工作目录)维度的表:会话数、调用、各 token 计数、tok/s、合计、命中率、最近时间;点项目名即把整个面板过滤到该项目(再点一次取消)。未归属项目 也在表里
请求日志 复刻 CC Switch 日志表的九列:时间(09/25 15:34)、供应商、计费模型、输入(未缓存主行 + R716,160 缓存读副行)、输出(tokens + tok/s 副行)、总成本($0.1500 四位小数)、用时/首字(41.4s/25.7s,见下)、状态(✓ / 中断,会话日志没有 HTTP 状态码)、来源。行经 POST /usage-stats/api/calls 分页(每页 60 条,加载更多 追加),跟随顶部全部筛选;用时/首字 = 结算 − 请求锚点(最近的 step/start / assistant/attempt,首字 = 首 token − 锚点),无锚点显示 —
Provider 统计 provider 路由维度的表(ccswitch/claude/xxx、bailian、deepseek-official …),列头为「供应商」(原「按路由」,CC Switch 同名页签)
模型统计 provider × model 维度的表,含输出速度、推理 tokens(输出子集)与逐行「过滤」(原「按模型」,CC Switch 同名页签)
按日期 每日明细表(趋势图在页签上方常驻,不再在本页重复渲染)
实时 最后调用距今时间、活跃会话数、实时输出速度卡 + 平均输出速度卡 + 最近一次调用拆解(224 tokens / 2.30s = 97.3 tok/s)、近 1 小时 KPI、最近调用明细(时间 / 路由 / 模型 / 各计数 / 生成耗时与 tok/s / 命中率 / 会话 / 轮步)
缓存 命中 / 写入 / 未命中三段占比条 + 按来源、按模型(前 20)的命中率

筛选栏(在 CC Switch 的 全部来源 ▼ / 全部模型 ▼ / 10s ▼ / 当天 ▼ 前面多了「项目」):

  • 项目 下拉(第一项):全部项目(默认)+ 每个项目一行。项目 = 会话的工作目录(cwd),所以统计能回答"这个项目花了多少 token"。
    • 选项的 value 是完整路径(D:\ai\project\dsh),标签是最后一段目录名(dsh),完整路径放在 title 里;同名目录(如两个 app)会自动带上父目录名(one/app、two/app)以便区分。
    • 调用数与会话数都写在标签里:dsh(1,056) · 4 会话。
    • 没有工作目录的会话统一算作「未归属项目」,是一个真实选项(不是被丢弃)。
    • 注意 <option> 的值必须互不相同:全部项目 和 未归属项目 在协议上都对应空串,所以后者用内部哨兵值,选中后再映射回空串发给服务端;否则两个 option 值相同,<select> 不会触发 change(就是下面「一个模型一个选项」那类 bug)。
    • 与 模型来源 / 模型 可叠加过滤(服务端 project + source + model 同时生效)。
  • 模型来源 下拉:全部来源 + 每个来源的调用数;模型 下拉随来源级联(选来源后只列该来源的模型,且会清掉已选的跨来源模型)。 两个下拉的候选来自服务端的 facets —— 即使当前已按某维度过滤,选项仍然完整;点表里的来源/模型/路由行也会双向联动。
    • 一个模型一个选项:API 只按模型名过滤,所以模型下拉也按模型名合并(同名模型的多个来源合并计数,来源列表放进 title,多于一个时标注「N 个来源」)。早期版本按 (模型, 来源) 出选项,于是同一个模型出现多个 value 完全相同的 option:<select> 在值没变时不触发 change,从同名模型的另一条来源点过去毫无反应,而且多余的 selected 会让显示跳到另一行 —— 这就是「选模型后页面不一定更新」。
    • 若某个已应用的模型从 facets 里消失(例如切到了不含它的来源),该筛选会被清掉,而不是"界面上看不见、却仍在生效"。
  • 日期区间选择器:当天 ▼ 触发,弹层照着 CC Switch 排:左边是预设行(当天 / 昨天 / 1d / 7d / 14d / 30d / 本月 / 自定义)+ 支持日期与时间 + 开始时间与结束时间两个框(每框内各有一个日期输入和一个时间输入,各自带原生日历/时钟图标,精确到分钟)+ 结束时间跟随当前时刻 勾选 + 取消 / 确定;右边是月历(‹ 2026年9月 ›、日一二三四五六、上月余日淡显、今天高亮、选中日填充)。点日历格子改的是当前激活的那一侧(默认开始时间),改过任一侧就自动切成「自定义」。
    • 弹层里改的都是草稿:取消 丢弃、确定 才应用并请求;预设行本身不触发请求。
    • 结束时间跟随当前时刻 勾上后结束时刻留空,表示"到此刻为止"——服务端窗口的末端就是 now,所以每次轮询都会自动延长。
    • 手改日期/时间或点日历格子会自动取消"跟随当前时刻",所以显式选的结束时刻不会被悄悄换成 now。
    • 应用后的区间以本地时区换算成显式 from/to 发给服务端;1d 是滚动 24 小时,昨天 是整整一个本地日,本月 是本地 1 号 00:00 → 此刻(随月份推进自然变长,月末不会越过当月)。
  • 刷新间隔 下拉:关闭 / 5 / 10 / 30 / 60 秒(默认 10);另有立即刷新、表头排序、清除筛选;开关状态存在 localStorage。
  • 趋势粒度:小时 / 天 / 自动,默认小时(当天 = 24~25 个桶)。曲线按小时聚合、随轮询实时刷新;最后那个「当前小时」仍在累计时,图例里会写 当前小时仍在累计。切到 天 就是每天一个桶;自动 按窗口宽度选小时/天。窗口很宽时(如 30 天 + 小时)桶会按整数倍加宽以保持在 1500 点以内,图例写清步长,绝不悄悄截断数据。
  • 入口(侧栏行 + 主栏页面):插件注册成 DSH 的全局面板——main 插槽放页面、sidebar.panellist 插槽放侧栏行,行序 order: 31,正好在 CC Switch(dsh-ccswitch-local,order: 30)下面一行。点击侧栏行即把主栏切到「使用统计」页;页面里的 ✕ 或 Esc 回到对话(ctx.layout.selectPanel(null))。
    • 早期版本是自挂 DOM 的悬浮按钮(document.body + position: fixed):那块 DOM 不属于应用,宿主下一次渲染就会把它清掉,于是"图标老是消失,刷新才回来"。改为插槽注册后由 React/宿主拥有生命周期,不再会消失。
    • 侧栏图标是 currentColor 的 svg,尺寸跟随插槽传入的 props.size(与宿主其它面板图标一致,自动跟随主题)。
  • 深链:http://127.0.0.1:3080/?usage-stats=1(或 #usage-stats)打开页面即切到该面板,不用点侧栏。 (注意:带 token 首次进入时服务端会 303 到 / 并丢掉查询串,所以这条要在已鉴权的页面上用,例如刷新时带上。)

口径(重要)

TokenUsage 的三个输入计数是互不重叠的(见 @deepseek-ai/dsh-llm 的 types.d.ts):

未缓存输入 = usage.inputTokens        缓存命中 = usage.cacheReadTokens
缓存写入   = usage.cacheWriteTokens   输出     = usage.outputTokens(reasoningTokens 是它的子集)

计费输入 billedInput = inputTokens + cacheReadTokens + cacheWriteTokens
缓存命中率 = cacheReadTokens / billedInput     ← 与 CC Switch 的 cache_hit_rate 同分母口径(不含输出)
合计 Tokens = billedInput + outputTokens
  • 一次「计费调用」= 一条带 usage 的 assistant/message;同一个 (turn, step) 有多条采样时最后一条生效 (与 token-meter「最终采样替换流式采样」一致)。
  • 路由与模型取 data.message.source.{provider, model}(kind === 'model'), 老日志缺这个字段时回退到最近的 request/context / request/header 快照。
  • 走 OpenAI 协议的路由通常不上报缓存写,此时「缓存写入」显示 N/A 而不是 0。
  • 日期按浏览器时区分桶(客户端把 tzOffsetMinutes 传给服务端)。

输出速度(tok/s)口径(重要)

DSH 的 assistant/message.data.stream 是带时间戳的模型流,所以生成窗口可以直接从日志算出来:

生成窗口 genMs   = 结算时间 − 首个 stream 时间戳(time / time0 取最早)
单次速度         = outputTokens ÷ genMs × 1000
平均输出速度     = Σ(可测调用的 outputTokens) ÷ Σ(它们的 genMs) × 1000   ← 按 tokens 加权,不是"每次速度取平均"
实时输出速度     = 同上,只统计落在最近 liveWindowMs(默认 60 秒)内的调用

**但只有真正解码过的窗口才算数。**日志里有一类调用根本没有解码时间线,早期版本把它们当成"生成极快", 于是看板上出现过 36,000 tok/s 这种数字。判定规则(任一不满足即视为"不可测",显示 — 并计入 N 次未计入):

规则 参数 为什么
流里必须有内容分片(text-chunks / reasoning-chunks) — 只输出工具调用的消息(tool-call-chunks)在 1–2ms 内把分片一次性刷出,量到的是"刷出耗时"而不是解码耗时:72 tokens / 2ms = 36,000 tok/s
窗口 ≥ 最短生成窗口 minGenMs(默认 200ms) 几十毫秒内的输出是刷出/收尾,不是解码
分片间隔均值 ≥ 0.5ms — 有些记录只保留了分片顺序、没有到达时间(实测整段 dt 全是 0–2ms),那不是时间线
算出的速率 ≤ 上限 maxOutputRate(默认 500 tok/s) 本机持续解码实测最高 ~282 tok/s(deepseek-flash);超过几百说明这段窗口没覆盖整次生成(实测有 8 条记录覆盖 17 秒生成的情况),此时"未知"比一个假数字诚实
  • 分子也只算可测调用:genOutputTokens / genMs。若用整桶输出除以可测时间,工具刷出的那部分 token 会把平均值抬起来。
  • 只有可测调用才会成为"最近一次"注脚(最近一次可测:…),所以那里不会再出现四位数。
  • 实测(19 个会话、6900+ 次调用):不可测占 38%(主要是工具调用),修正后最高 483 tok/s、>1000 的为 0 次,加权平均 111.5 tok/s(修正前含刷出是 124.0)。
  • 分母是"生成时间",不是墙上时间:调用之间的空闲不会把速度拉低;reasoningTokens 是输出子集,已含在 outputTokens 里。
  • 与 DSH 自身性能遥测同源(decodeMs = 结算 − 首 token、decodeTokens = outputTokens),不含 prefill/排队时间:它衡量解码速度,不是端到端延迟。
  • 测不到就写 —,不写 0:Hero 与实时页都会显示 N/M 次可测。整段没有输出时同样显示 —(而不是"0 tok/s"这种误导值)。
  • 实时块在窗口内无调用时给出 idle: true(面板显示"最近无生成,等待下一次调用结算")与最近一次调用的明细 (模型 224 tokens / 2.30s = 97.3 tok/s)。
  • 速度还落在按模型 / 按来源 / 按路由 / 每次调用各层,方便比较中转与官方路由、不同模型的快慢。

成本口径与价目来源(重要)

DSH 只记录 token,不记录钱;面板上的每个金额都是 src/pricing.js 用它自己那份价目算出来的。

  • 价目来自 DeepSeek 官方定价页 https://api-docs.deepseek.com/quick_start/pricing(核对日期 2026-09-22): deepseek-flash(即 DeepSeek-V4.1-Flash)高峰 输入 $0.3 / 缓存命中 $0.006 / 输出 $1.2(每 1M tokens), deepseek-v4-pro 高峰 $1.32 / $0.044 / $3.96;低峰是高峰的一半。
  • 高峰 = UTC 周一至周五 01:00–04:00 与 06:00–10:00,其余时段(含周末)为低峰。节假日无法离线判定, 所以落在高峰窗口的工作日一律按高峰计价;要修正就在覆盖文件里列 holidays。
  • 覆盖文件 $DSH_HOME/usage-stats-pricing.json: { "holidays": ["2026-10-01"], "models": { "my-model": { "input": 1, "cacheRead": 0.1, "output": 4 } } }
  • 没价就不算:不在表里的模型按 CC Switch 的做法显示 未定价,并且不计入成本曲线;Hero 会标出「N 次未定价」。 窗口内没有任何调用时显示 —(不是 $0、也不是 未定价)。
  • 逐调用计价(每次调用按它自己的时间戳判高低峰),再汇总到各维度与曲线。

安装

本插件零第三方依赖(只用 Node 内置模块 + DSH 宿主提供的接口),不需要 npm install, 下载后按下面步骤挂载即可。以下 <插件目录> 指你解压/克隆后存放本插件的完整路径, 例如 C:\Users\你的用户名\downloads\dsh-usage-stats(不要照抄示例路径)。

前置要求

  • DeepSeek Harness(dsh)已安装,且你知道自己的 profile 名:
    • 桌面版 → desktop(配置在 ~/.dsh/profiles/desktop/)
    • 命令行/网页版 → web(配置在 ~/.dsh/profiles/web/)
  • Node.js ≥ 20(DSH 自带运行时即可,无需单独安装)。

方式一:dsh plugin add(推荐,自动完成挂载)

dsh plugin --profile desktop add link:<插件目录>

例如:

dsh plugin --profile desktop add link:C:\Users\你的用户名\downloads\dsh-usage-stats

如果这条命令被 pnpm 的 minimumReleaseAge 策略拦住(报依赖发布太近之类的错), 用下面的「方式二」手工挂载,效果一样。

方式二:手工挂载(profile 补丁层)

  1. 打开(没有就新建)profile 的补丁文件 ~/.dsh/profiles/<profile名>/cordis.patch.yml,加入:

    - insert:
        - id: usage-stats
          name: 'dsh-usage-stats'
    
  2. 让 DSH 能加载到插件代码,二选一:

    • 推荐:在 ~/.dsh/profiles/<profile名>/package.json 的 dependencies 里加一行 "dsh-usage-stats": "link:<插件目录>",然后在 profile 目录执行 pnpm install;
    • 或手动创建目录链接:cmd /c mklink /J "%USERPROFILE%\.dsh\profiles\<profile名>\node_modules\dsh-usage-stats" "<插件目录>"。
  3. 完全重启 dsh(不是刷新页面,新插件在启动时才会组合进 bundle 列表)。 重启后按终端打印的新地址打开页面,左侧栏会出现「用量统计」,点击进入面板。

卸载

dsh plugin --profile <profile名> remove dsh-usage-stats

或手动:删掉补丁层里那两行、删 node_modules\dsh-usage-stats 目录、删 profiles\<profile名>\package.json 里的依赖行,然后重启 dsh。


配置

补丁层的插入行可以带 config(全部可选):

- insert:
    - id: usage-stats
      name: 'dsh-usage-stats'
      config:
        routePrefix: /usage-stats/api   # 路由前缀,撞车会在注册时抛错
        defaultDays: 30                 # 默认窗口
        retentionDays: 90               # 只保留这么多天的调用记录
        maxSessions: 500                # 单次折叠的会话上限
        readConcurrency: 4              # 并发读会话日志
        budgetMs: 15000                 # 单次折叠的时间预算,超时该次标记 partial
        recentLimit: 80                 # 实时视图返回的最近调用条数
        liveDebounceMs: 750             # session/event 触发的预热折叠去抖
        liveTtlMs: 2000                 # 活会话折叠的新鲜度(0 = 每次都重读)
        listTtlMs: 2000                 # 会话列举 + revision 检查的复用窗口
        fullRefoldMs: 300000            # 活会话尾部折叠多久后强制一次全量折叠
        liveWindowMs: 60000             # 「实时输出速度」的统计窗口
        minGenMs: 200                   # 短于此的窗口不算解码(工具调用的刷出)
        maxOutputRate: 500              # 速率上限,超过说明窗口没覆盖整次生成
        failBackoffMs: 60000            # 读不了的会话日志的退避间隔
        coldRevCheckMs: 60000           # 冷会话 revision 的信任时长(0 = 每次都复核)
        persist: true                   # 折叠缓存是否落盘(重启不再重读历史)
        storePath: …                    # 默认 $DSH_HOME/storages/usage-stats/fold-cache.json
        saveDebounceMs: 4000            # 折叠缓存写入的合并窗口

persist: false 会让每次进程启动都重新折叠全部历史(调试用);storePath 指到别处可以隔离多套数据。 嫌切换后仍然慢,先降 listTtlMs / liveTtlMs(换取新鲜度),或把面板里的「刷新」间隔调大(减少请求)。


HTTP 接口

前缀路由,仅 POST,且过与 /api 网关同一套浏览器信任栅栏(Host 必须是回环或 ctx.webRuntime.trustedHosts,sec-fetch-site: cross-site 与跨站 Origin 一律 403):

方法 作用
POST /usage-stats/api/summary 面板主数据:totals / today / yesterday / lastHour / bySource / byProvider / byModel / byProject / bySession / byDay / granularity / seriesStepMinutes / series / facets / pricing / rates / recent / live / meta
POST /usage-stats/api/calls 最近调用分页(limit / offset),与 summary 同一套 source / provider / model / project 筛选;请求日志页签用它分页
POST /usage-stats/api/health 读取器是否就绪、配置、折叠缓存状态、实时计数
POST /usage-stats/api/refresh 丢弃折叠缓存,下次 summary 全量重扫

参数:days 或 hours(短窗)、from/to、source、provider、model、project、granularity(auto/minute/hour/day)、tzOffsetMinutes、recentLimit、refreshLive(绕过活会话 TTL)、refresh(清空折叠缓存)。

project 的取值就是会话 cwd 的完整路径;空串是有效筛选(只看「未归属项目」),所以客户端在 全部项目 时不带这个字段,而不是传空串。


数据存在哪(有没有数据库)

先说结论:DSH 没有可查的"用量数据库",所以本插件自己带了一个持久化折叠缓存。

  • DSH 的持久化单位是会话日志(session.v3.jsonl.zstd,多帧 zstd、追加写)。 dsh-session-query-sqlite 是会话文本的 FTS 索引,不是 token 账本;投影存储里的 tokenUsage 只有"每会话累计",没有 provider / model / 日期维度。想按来源·模型·日期出调用级明细, 就必须自己从会话日志折叠 —— 没有地方可以 SELECT。
  • 所以折叠结果由本插件自己落盘:$DSH_HOME/storages/usage-stats/fold-cache.json (临时文件 + rename 原子替换,写入按 saveDebounceMs 合并)。它是派生数据:删掉只是退回 "重新折叠一次",不会丢任何真实数据;文件损坏或版本不符则忽略并从头折叠,绝不信任半吊子内容。 里面只有 token 计数、模型/路由 id、会话 id 与 cwd —— 不含凭据、不含消息正文。
    • 缓存带版本号(FOLD_CACHE_VERSION,当前 4):记录结构变了就整体作废、下次请求重新折叠一次。v2 → v3 是因为每条调用新增了 project / projectLabel(项目维度);v3 → v4 是因为新增了 durationMs / ttftMs(请求日志的 用时/首字)与条目的 requestAt 锚点。
  • 于是"切到昨天"的代价从"读遍所有会话日志"变成"读一个小 JSON + 内存聚合";之后的请求再走三层缓存: 折叠复用 → revision 信任(coldRevCheckMs,默认 60 秒)→ 列举复用(listTtlMs)。

实测(隔离实例,4 个会话、90 天保留):

场景 「昨天」查询
新进程、无折叠缓存(改之前的行为) 798 ms(fold 716 ms,重读 4 条日志)
同进程第二次 7 ms
重启进程、有折叠缓存 113 ms(fold 48 ms,scanned=0:一条会话日志都没重读)
重启后第二次 / 30 天窗口 6 ms / 21 ms(93 次调用、12.34M tokens 照常返回)

性能与一致性

  • 增量折叠:ctx.sessionPersistence.stat(id).revision 变了才重读;没有 revision 的后端用 staleMs(默认 30 秒)兜底。冷会话的 revision 被信任 coldRevCheckMs(默认 60 秒)。

  • 活会话做短缓存:liveTtlMs(默认 2 秒)内不重复读活会话日志。早期版本"每次都重读活会话", 而活会话里就有正在跑的这个大会话,实测一次 summary 要 0.9–1.4 秒,表现为「筛选框反应很慢」。 session/event 一落地就把下一次折叠标记为绕过 TTL,所以实时性没有被牺牲; ⟳ 按钮走 refreshLive(只绕过 TTL),不再清空整个缓存。

  • 活会话尾部折叠:Session.snapshotEvents(fromSeq) 支持偏移量,所以只走"上次之后"的内存事件, 不重读、不解压整条日志;fullRefoldMs(默认 5 分钟)定期强制一次全量折叠兜底。

  • 按区块重绘:面板是 渲染帧 + 五个区块(hero/filters/tabs/body/foot),重绘只替换内容变了的那一块。 以前每次重绘都重建整块 innerHTML,于是「点开日期弹层」也要重新生成 5 条各 720 段的曲线 SVG—— 这才是宽区间下"点一下就卡"的真凶。现在开弹层只动 filters。

  • "交互中不重绘"只针对会被重建毁掉的控件:<select> 与 <input type=date|time> 的原生弹层; 而且用户主动提交的改动一律立即重绘(paint(true)),只有后台轮询才排队等 focusout。 早期版本把守卫写成"面板内有焦点就不重绘":点 确定 时焦点仍在日期输入框里,于是响应到了、DOM 不更新, 面板停在旧数据上——这就是"选了日期刷新不出来"。又因为被移除的元素不一定触发 focusout, 那次被挂起的重绘可能永远不会补上。

  • 图表缓存与抽稀:曲线按签名(粒度 / 步长 / 数值 / 是否计价)记忆化,数据没变就不重新生成; 超过 240 个点时按整倍数合并后绘制(720 点 → 240 点,路径命令 −67%),并把实际步长写进图注 ("每 3 小时"),所以图上不会假装是逐小时。

  • 载荷瘦身:series 只带图表真正读的 10 个字段(原来每桶 27 个)。30 天 × 小时的实测: 302.6 KB → 191.3 KB(其中曲线部分 229.7 → 118.4 KB)。曲线数值和工具提示完全一致。

  • 请求不丢失:轮询在飞时用户改筛选,旧写法 if (state.loading) return 会把这次改动静默丢掉 ("点了没反应,十几秒后才生效");现在旧世代的响应直接丢弃、被取代的请求 abort(), 最新一次请求的结果说了算。

  • 切换筛选时的加载态:只有切换筛选(来源 / 模型 / 区间 / 粒度 / 清除)会把各数据模块换成转圈占位, 单纯打开面板或点 ⟳ 不会闪白——那时保留现有数字,只让 ⟳ 自己转(data-busy)。

  • 时间预算:一次折叠超过 budgetMs 会提前返回并把 partial: true 标在 live 上, 下次请求接着扫(已折叠的会话直接复用),因此冷启动几秒后收敛到全量。

  • 失败隔离:单个会话读失败只记进 meta.errors,不会让整个面板失败;并按 failBackoffMs(默认 60 秒)退避, 避免一份坏日志每次都白白吃掉一轮读盘时间。

  • 请求成本分层(实测,隔离实例 4 个会话、60 天窗口,连续四次 summary):

    次序 往返 折叠 说明
    冷启动 3199 ms 3010 ms 重扫 4 个会话日志
    折叠已缓存、但列举过期 402 ms 311 ms 只剩 listSessions + 逐个 stat
    列举 + revision 都在窗口内 42–47 ms 0 ms 纯内存聚合
    超过 listTtlMs 后 508 ms — 重新列举并重判 revision

    结论很直白:折叠缓存命中后,剩下的时间几乎全花在"问 DSH 有哪些会话、每个会话变了没"。 所以 listTtlMs(默认 2 秒)把列举与 revision 检查一起缓存,请求从数百毫秒掉到几十毫秒。 页脚的「扫描耗时」括号里就能看到 重扫 / 复用 / 尾部 / 列举复用,不用猜。


自查与测试

cd <插件目录>

# 离线用同一份折叠代码扫真实会话日志(不经过 DSH),打印各维度汇总
node scripts/scan-sessions.mjs --days 60
node scripts/scan-sessions.mjs --days 60 --verbose --json

# 单元 / 集成 / 客户端 bundle 冒烟(共 76 项,无需测试框架)
npm test

# 契约检查:把真实 /usage-stats/api/summary 的响应喂进来,确认客户端读的
# 每个字段(series[].bucket、facets.sources[].source、totals.* …)都还在
node scripts/check-payload.mjs payload.json --granularity day

# 真浏览器验证(无依赖:Chrome + CDP,Node 内置 WebSocket):
# 打开页面 → 点开面板 → 切窗口/粒度/筛选/标签,逐项读回面板状态与 KPI,
# 并收集页面 console 报错;启动行里的 token URL 直接粘进来
node scripts/browser-probe.mjs "http://127.0.0.1:3080/?token=<启动时打印的 token>" --drive --shot panel.png

# 模拟真实用户操作(点开始时间框 → 点日期输入 → 设日期 → 点确定)
node scripts/browser-probe.mjs "<同样的 URL>" --scenario user --shot user-flow.png

# 驱动两个筛选下拉(选项唯一性、选中后是否真的刷新、来源级联)
node scripts/browser-probe.mjs "<同样的 URL>" --scenario filters

scripts/browser-probe.mjs 是这套东西里最有价值的一件:单元测试只能证明「我喂给它的形状它认得」, 而它在真实 Chrome + 真实 DSH 外壳 + 真实会话数据里验证「面板真的能打开、真的显示对数字」。 --scenario user 更进一步:它按用户的手势顺序操作日期组件,并在每一步之后检查"用户正在碰的那个控件还活着"。

scripts/scan-sessions.mjs 自己实现了多帧 zstd 读取(zlib.zstdDecompressSync 只解第一帧, 会话日志是一批帧拼起来的),插件本身不解析 zstd——它走 DSH 的读取器。 scripts/check-payload.mjs 挡的是另一类事故:服务端改了字段名而客户端没跟上(下拉框空、曲线空白), 它同时校验 series 的桶宽、顺序,以及「各桶 calls 之和 == totals.calls」。

验证记录(2026-09-21 / 09-22 / 09-23,本机):

检查 结果
离线扫真实日志(18~19 个会话文件,60 天) 2009+ 次计费调用 / 6.99 亿 tokens / 命中率 96.66%(数据随用随涨)
真实运行时(隔离 DSH_HOME,端口 3099,4 个会话日志) sessionQuery+sessionPersistence 都就绪,93 次调用 / 12.34M tokens / 命中率 91.25%,partial=false
真实载荷契约(隔离实例,60 天窗口) granularity=day,series 60 桶(2026-07-25 → 09-22),各桶 calls 之和 == 93 == totals.calls,facets 与 bySource 一致
客户端半 / 的 boot graph 里出现 dsh-usage-stats/client.js,应用 combo 内含 __ModuleLoader__ 注册、us-panel 样式与 /usage-stats/api
侧栏入口(真浏览器,--scenario sidebar) 「用量统计」行与「CC Switch」同一列(x=46)、在其下面一行(y=207 vs 167,间隔 40px = 一行高),宿主原生样式与选中态;点击 → 主栏出现看板(Hero + 曲线);切到 CC Switch 再切回,侧栏行始终在、页面正常回来;0 个 console error
侧栏入口(回归) 旧的自挂悬浮层被宿主重渲染清掉("图标老是消失,刷新才回来");改插槽注册后 DOM 归宿主所有。单元级断言:document.body 里没有任何残留元素、两个插槽注册(main / sidebar.panellist order 31)
项目维度(真浏览器,--scenario project) 筛选顺序实测 ["项目","模型来源","模型"](项目在最前);选项 全部项目 / ai_b(1,660)·1 会话 / dsh(1,056)·4 会话,value 无重复;全部项目 = 2716 次调用 / 1,264,440,063 tokens;选 ai_b → 1660 次(54 ms)、选 dsh → 1056 次(65 ms),chip 项目:xx ✕ 出现、清除后回到 2716;1660 + 1056 = 2716 = 全量;按项目 表列出 2 行并带「工作目录」列,点行名即过滤(→1660)
项目维度(真服务端契约) 60 天窗口:facets.projects = ai_b(1660 次/1 会话)、dsh(1056 次/4 会话);逐项过滤之和 2716 = 全量;不存在的路径 → 0;project 与 source 叠加生效;check-payload.mjs 新增 6 项断言全绿(含 byProject 求和 == totals.calls、sessionIds 不泄漏 Set)
悬停十字线(真浏览器 --scenario hover) 真实 Input.dispatchMouseEvent 驱动:竖线位置与数学期望三次完全一致(589.2=589.2 / 187.2=187.2 / 728.1=728.1);浮卡时间戳与图上点位一致(2026/09/20 12:00);5 行与图表点位逐字相等(rowsMatch: true);合并完整性:图表按 factor=3 合并(715 → 239 点),卡片值 = 对应 3 个原始桶之和(42,568 = 42,568);移出绘图区垂线与卡片同时隐藏;0 console error
请求日志(真浏览器 --scenario log) 九列表头与 CC Switch 逐字一致(时间/供应商/计费模型/输入/输出/总成本/用时/首字/状态/来源);真实行 09/23 23:16 · bailian · deepseek-v4.1-flash · 1,185 + R409,472 · 838/136 tok/s · 12.6s/6.4s · ✓ · bailian,其中 12.6s/6.4s 与 API 的 durationMs=12573 / ttftMs=6402 交叉一致;页脚 显示 60 / 共 2,716 条;加载更多 60→120 行(offset=120、done=false);清掉项目 chip 后日志随筛选重建(1660 → 2716);v3 折叠缓存被正确拒绝(store.error: version 3)→ v4 重折后 payload contract OK(含 2 项新增的用时/首字断言);0 console error
布局(真 DOM,compareDocumentPosition) chartBeforeTabs: true、tabsBeforeBody: true——图表常驻在页签行上方、页签只管下方内容;chartCount: 1(按日期页签不再重复渲染同一张图,也避免同一 gradient id 出现两份);移动区域后悬停回归全部照常(垂线三次精确 589.9/188.4/703.6、rowsMatch: true、合并完整性 42,568 = 42,568);0 console error
列对齐(真浏览器 --scenario align) 根因:#root .us-table th(id+class+type)特异性压过 #root .us-num(id+class),数字列表头左对齐、数值右对齐 → 每列列名都偏在数值左边(用户截图「列名和值没有对齐」)。补 th.us-num 右对齐覆盖后,8 个页签逐列实测表头与首个数据格的文字边缘(数字列比右缘、文本列比左缘,容差 4px):projects(12列) / log(9) / routes(9) / models(12) / providers(9) / days(8) / cache(5) / realtime(11) 全部 issues: [],列数与格数逐一相等;0 console error
本月快捷选择(真浏览器 --scenario preset) 预设行实测 ["当天","昨天","1d","7d","14d","30d","本月","自定义"];确定后 rangeKey=thisMonth、触发器 📅本月▾、服务端回显 from = 09-01 00:00 本地(fromDay 1 / hour 0 / minute 0)、to = 此刻、弹层关闭;同窗口 API 对照 calls=2716 一致;0 console error。首跑读到 1660 是探针 profile 里上一个场景残留的 project=ai_b 持久化筛选(chip 项目:ai_b ✕ 在),场景开头补清 chip 后 1660 → 2716——顺带实证了筛选持久化与 calls 对筛选的正确响应
真浏览器 · CC Switch 复刻 点开面板 → open=1;Hero 读出 真实消耗 Tokens 12,337,353、总成本 $0.257、tiles 新增输入 107.0 万 / Output 10.6 万 / 创建 0 / 命中 1116.2 万 / 命中率 91.3%;图例 输入 · 输出 · 缓存命中 · 缓存创建 · 成本;0 个 console error
真浏览器 · 曲线与默认粒度 默认 当天 + 小时(服务端 granularity=hour、stepMinutes=60);data-granularity="minute" 已不存在;SVG 路径含 C(贝塞尔缓坡)、linearGradient(输入曲线下的渐变阴影)、drop-shadow(实线柔光)、stroke-dasharray="5 4"(缓存/成本虚线)
成本数学(单元级) 一次高峰调用 + 一次低峰调用(每项各 1M 输入/缓存/输出)= $1.506 + $0.753 = $2.259,与官方价目手算一致;未定价模型计入 unpricedModels 且不产生金额
真浏览器 · 深链 ?usage-stats=1 打开页面即切到面板(无需点击,走 ctx.layout.selectPanel)
延迟(修复前,用户实例 3080) 连续三次 summary = 1317 / 1441 / 925 ms —— 旧服务端每次重读活会话日志
延迟(修复后,真浏览器实测) 面板内一次刷新往返 155 / 88 / 72 ms(首次、refreshLive 强制重读、之后复用)
下拉框不被销毁 聚焦 [data-model] 后触发刷新:sameNode=true(同一 DOM 节点存活)、flushedAfterBlur=true(失焦后补画)、panelStillThere=true
输出速度 · 真实日志(离线扫 19 个会话) 2528/2528 次调用都有可用的 stream 时间戳;30 天窗口平均 134.93 tok/s(生成合计 13231s)、最近 60 秒实时 107.30 tok/s;按模型区分度合理:deepseek-flash 282.4、bailian/deepseek-v4.1-flash 114.9、qwen3.8-flash 78.3 tok/s
输出速度 · 真实服务端 30 天窗口平均 129.91 tok/s(93/93 可测、812s)且数据陈旧时 live.idle=true;把窗口末端挪到最后一次调用上 → 实时 117.46 tok/s(5 次 / 40.1s / 4710 tokens),最近一次拆解 778tok/6.58s = 118.2 tok/s;自洽校验 速度 × 生成秒 == 输出 tokens 通过
输出速度 · 真浏览器 Hero tile 实测 ◎ 平均输出 = 130 tok/s、⚡ 实时输出 = —(该窗口最近 60 秒无生成,故按要求显示破折号而非 0);实时页两张速度卡 + 每次调用 tok/s 列都在;0 个 console error
契约检查(rates 块) rates 存在、平均 内部自洽(129.9 tok/s × 812.1s ≈ 105501 tokens)、measuredCalls ≤ calls、idle ⇒ live.calls == 0、recent[].genMs/outputPerSec、byModel[].outputPerSec 全部通过
加载态(真浏览器) 切模型:skeletonShown=true、staleFiguresHidden=true(旧数字立刻消失)、filtersStayUsable=true、41–105 ms 后恢复数据视图;聚焦下拉框时刷新 sameNode=true;0 个 console error
请求成本分层(隔离实例) 冷 3199 ms → 折叠命中但列举过期 402 ms → 列举/revision 都命中 42–47 ms;页脚显示 重扫/复用/尾部/列举复用
持久化(真重启实验) 新进程无缓存时「昨天」查询 798 ms(重读 4 条日志)→ 写入 37KB 折叠缓存 → 重启进程后同一个查询 113 ms、scanned=0(一条日志都没重读),第二次 6 ms;坏 JSON / 版本不符 → 忽略并从头折叠(有测试)
日期弹层(真浏览器) 弹层断言全绿:presets=7、开始时间/结束时间、type="date"×2、type="time"×2、月历 us-cal-grid 与 42 个日格、data-follow-now、取消/确定、支持日期与时间;点日格 → 开始时间随之变化且预设自动切成「自定义」;取消 后 rangeKey 与草稿都不变;确定 后 7 天窗口 82–142ms 内生效
重绘与载荷 开日期弹层只重建 filters,不再重算 720 点曲线;曲线按签名记忆化 + 超过 240 点抽稀(路径命令 −67%);series 每桶 27 → 10 字段,30 天×小时实测 302.6 KB → 191.3 KB(曲线 229.7 → 118.4 KB);真实区间切换 43–60ms(此前同点位 74–97ms)
模拟真实用户操作(--scenario user) 点开面板 → 开日期弹层 → 点开始时间框 → 点日期输入(原生弹层)→ 设 09-21 00:00 → 设结束 09-21 23:59 → 点 确定:每一步后输入框都 stillConnected/sameNode=true(原生弹层不会被重绘毁掉);确定 后 customTo="2026-09-21T23:59"(没被 followNow 清空)、请求区间正好 9/21 00:00→9/22 00:00、24 个小时桶、DOM 里 hero 变成 12,337,353(修前是 0)、83ms 完成
筛选下拉(真浏览器 · 多来源数据,--scenario filters) deepseek-v4.1-flash 同时来自 2 个来源时:模型下拉只有 3 个选项 ["", deepseek-v4.1-flash, qwen3.8-flash]、duplicateValues=[](修前是同名 3 个相同 value);选中后 54ms 内 calls=548(两路合并)、byModel 2 行、Hero 变 256,721,794;切来源 → 模型自动清空且 Hero 变 364,091,306;连续选两个不同模型 → distinctState=2;0 console error
输出速度修复(真实日志,6900+ 次调用) 修正前:1.4% 的调用 >1000 tok/s,最高 36,000 tok/s(72 tokens / 2ms 的工具调用刷出)、8% 的"窗口"<250ms。修正后:>1000 为 0 次、最高 483 tok/s,加权平均 111.5 tok/s(含刷出是 124.0);不可测 38% 全部标为 — 并显示「N 次未计入」
输出速度修复(真实运行时) 隔离实例实测 average 109.9 tok/s、可测 1683/2716、未计入 1033;measuredOutputTokens 1,358,325 / total 1,736,288(分子只算可测);最近 60 行可测调用最高 275.5 tok/s、>500 的 0 次
测试 76 项全绿(node --check 通过;无测试框架依赖)

与 CC Switch 的差别(有意为之)

  • 成本只认官方价目:CC Switch 的价格表带倍率/折扣、从 models.dev 同步;这里只用 DeepSeek 官方价目 (高峰/低峰两档),价目外的模型显示 未定价。所以同一段用量,两边的金额可能对不上——以本插件的口径为准, 它的来源 URL 写在 src/pricing.js 与这里。
  • 不读 CC Switch 数据库:CC Switch 统计的是 Claude Code / Codex / Gemini CLI 的日志; 本插件统计的是 DSH 自己的调用(其中包含经由 dsh-ccswitch-local 转发的那些路由)。
  • 没有请求耗时/状态码:DSH 的 assistant/message 不落这两项,所以没有 CC Switch 的 timing/status 列。
  • 没有「Codex 用量维护」与定价配置面板:那两块是 CC Switch 为多 CLI 摄入与自定义定价准备的; 这里的定价覆盖走 $DSH_HOME/usage-stats-pricing.json 文件。
  • 强调色用 DSH 主题 token:图上那个蓝色不是写死的,而是 --dsw-alias-brand-primary; 在宿主主题里它可能渲染成近黑。想固定成 CC Switch 的蓝,改这一个变量即可。

已知限制

  • compaction/summary 也可能带 usage,本插件只统计 assistant/message(与 token-meter 一致)。
  • 会话标题不取:session.v3 日志没有标题事件,会话标签用 cwd 目录名。
  • 项目就是会话的工作目录:同一个仓库在不同路径(副本、worktree、D:\x vs \\wsl$\...)会被算成不同项目;反过来,同一目录下的所有会话算一个项目。日志里没有"项目"这个字段,所以这是能拿到的最准确归属。没有 cwd 的会话统一进「未归属项目」。
  • 一次折叠的会话上限与保留天数由配置控制,超出部分不参与统计。
  • 中国法定节假日无法离线判定,落在高峰窗口的工作日按高峰计价;用覆盖文件的 holidays 修正。
  • 客户端用 DSH 的插槽注册(main + sidebar.panellist),行序 order: 31 写在「CC Switch」下面。 这条依赖宿主已经声明的插槽契约(dsh-client-ui-sidebar 的 sidebar.panellist 与 dsh-client-ui-layout 的 main), 所以 package.json 的 dsh.client.inject 声明了这两个包来保证激活顺序;插槽缺失时不会退回自挂 DOM,而是记一条错误日志。
  • 图表在点很密时会合并相邻桶(>240 点),图注写明实际步长;series 数据本身不截断。

代码结构

文件 职责
src/index.js cordis 宿主插件:注册 /usage-stats/api 前缀路由、信任栅栏、配置解析、实时 session/event 预热
src/collect.js 会话枚举与读取(sessionQuery 优先,sessionPersistence 兜底)、revision 增量缓存、尾部折叠、持久化折叠缓存、并发与时间预算
src/store.js 折叠缓存的落盘与回读(原子写、坏文件不信任)
src/aggregate.js 纯折叠与聚合:assistant/message → 调用记录(含 project/projectLabel)→ 各维度桶(来源/路由/模型/项目/会话/日/趋势)、命中率、成本、趋势序列(planSeries 决定桶宽);compactSeries 只留图表需要的字段
src/pricing.js 价目表(DeepSeek 官方 + 覆盖文件)、高峰/低峰判定、逐调用计价
src/fence.js 浏览器信任栅栏(与 /api 网关、better-sidebar 行为一致)
lib/index.js 包入口(main),只做 src/index.js 的具名再导出
lib/client.js 浏览器半:window.__ModuleLoader__.load({id, factory}) 手写 bundle(除宿主模块表提供的 react 外无外部依赖);注册 main 页面 + sidebar.panellist 侧栏行,帧+五区块渲染、Hero、曲线图、日期区间选择器都在这一个文件里
scripts/scan-sessions.mjs 离线扫描真实会话日志的自查脚本
scripts/check-payload.mjs 服务端载荷 ↔ 客户端读法的契约检查
scripts/browser-probe.mjs 真浏览器(Chrome + CDP)驱动验证:--drive 跑交互流程,--scenario user 模拟日期组件的真实点击顺序,--scenario filters 驱动两个筛选下拉
scripts/restart-dsh.ps1 停掉并前台重启 dsh web(新插件挂载后需要)

宿主半不能用 export default:cordis 会把 default 当函数式插件解析,从而忽略模块级 inject,整棵插件树会以 cannot get property "webServer" without inject 启动失败(本项目已在隔离实例上踩过)。 test/plugin.test.js 专门钉住这一点。