@fufuf-c/dsh-token
全本地的Token用量与成本统计分析仪表盘。 适合需要深度分析Token构成、缓存节省及月度预算的本地用户。
安裝
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:fufuf-c/dsh-token說明文件
閱讀完整 README ↗dsh-token
DSH 本地 Token 用量统计插件——以 Token 为第一视角:不只回答"花了多少钱、用了多少量",还回答 Token 从哪来、缓存省了多少、哪些会话在烧 Token。数据全部来自本地会话日志,零上传、无在线依赖。仪表盘为 Apple 风格四 Tab 界面,开箱即用。
亮点
- 四段 Token 构成:未命中 / 缓存命中 / 缓存写入 / 输出,并给出缓存省下的钱;
- 成本诚实:无内置价且无自定义价的模型成本计 0,但
kpi.totals显式报出unpricedTokens/unpricedModelCount,界面与导出都标注"未计入" —— 估算成本是"已定价部分的成本 + 未定价 token 数",不是完整账单; - 全局筛选:时间(今天/3天/7天/30天/自定义)+ 模型 + 会话 + 工作目录,URL 可分享;
- 会话下钻:四联统计、逐模型构成、逐请求明细、上下文增长曲线、🔥 异常激增标记、
?session=深链; - 成本与预算:单价表(内置 DeepSeek 官方价目,高峰/空闲分档,时段可改)、月度预算 环形进度 + 月底外推 + 80%/100% 阈值、按模型/按会话归因;
- 图表:多模型叠加趋势(图例可逐个关)、日/周/月粒度、24 桶小时直方图、活跃度月历热力图;
- 快路径聚合:无
session/wd筛选时查询读 day/month 聚合索引,复杂度 O(天数); 带这两个筛选时整会话跳过(它们是会话级前缀判定)。两条路径输出等价由单测锁定; - 落盘分片:
store.json只存元数据,逐会话记录在shards/;分片自带模型表, 局部重写不会让整数下标错位;单会话损坏只影响它自己; - 增量扫描:未变化会话按 revision 跳过;扫描周期无变化时跳过重建与落盘;
retention.days修剪超龄记录,默认 0 = 永久保留; - 刷新可配:
refresh.pageSec(页面重取,默认 60s)、scanSec(后台重扫,默认 300s)、onOpen(进入界面先扫一次,默认开);填0关闭; - store 损坏不静默清零:形态体检不合格就改名
store.json.corrupt-留证再从日志重建; - 跨 DSH 版本自适应:
sessionPersistence两代 API 按能力探测适配,不绑宿主版本号; - 失败会话隔离:被宿主确定性拒绝的老日志在冷却窗口内跳过,瞬时错误始终重试。
性能与实现取舍的逐条论证见
scripts/invariants.mjs(95 条编号不变量, 每条写明"锁什么、为什么、被哪个测试锁住")。
界面
六个视图,截图取自一份全虚构的示例库(18 个会话 / 474 次请求,由
scripts/screenshot-harness.mjs 用固定种子生成),不含任何真实使用记录。点图看原图。
图片挂在 Release 资产上而不是用仓库相对路径:README 同时是 npm 页,相对路径在 npmjs.com 上会裂图,而
raw.githubusercontent.com在部分网络环境下不可达。商店侧 以仓库里的screenshots.json为准,与这里无关。
![]() | |
| 今天 · 四段构成与小时分布 | ![]() |
| 活动 · 趋势 / 逐模型 / 粒度切换 | |
![]() | |
| 会话 · 排序 / 搜索 / 激增标记 | ![]() |
| 会话详情 · 指标 / 构成 / 增长曲线 | |
![]() | |
| 逐请求时间线 · 明细表 | ![]() |
| 设置 · 预算 / 单价 / 高峰时段 |
架构
web/index.html **拼接产物**(交付物)仪表盘单页 · Apple 风格(默认页;四 Tab:iOS 大标题 / 玻璃材质 /
活动圆环 hero / 屏幕使用时间式图表;纯原生 JS + Canvas,单文件无外部资源)
lib/client.js 宿主直供的浏览器模块(`exports["./client"]`,手写、不经打包器):
四个入口 —— 侧边栏「Token 统计」= 全局面板(应用内整屏仪表盘,
注册在 sidebar.panellist + main,本体是 iframe 嵌 /dsh-token),
会话内「本会话用量」= 原生 React 单会话面板(不是 iframe),
设置页「Token 统计」= 插件信息面板。
只 require 平台种子模块 react(宿主模块表提供,非 npm 依赖)
lib/core.mjs 数据层核心(**纯数据**:store 进、结果出,不碰文件也不认识 Cordis 服务):
折叠、聚合、筛选、导出、价格、异常检测、
会话标题提取(session/title → 首条用户消息回退)、
day/month 聚合快路径(O(天数) 查询)+ 惰性重建、store 形态体检
(聚合运行时状态挂 store 的**非枚举**字段 `__agg`,单一入口 `aggState(store)`,不落盘)
lib/session-source.mjs 会话日志读取与扫描层(唯一需要 sessionPersistence 服务的一层):
sessionPersistence 新旧两代 API 适配、增量水位线、
确定性失败会话隔离 + retention 修剪
(依赖方向:index → session-source → core;core 不认识前两者)
lib/design-tokens.mjs 设计 token **单一来源**:两处界面的调色板/语义色/文本色/阴影
由 scripts/build-tokens.mjs 注入 web/src/style.css 与 lib/client.js
(**注入源文件**,再由 build-web 带进产物),LOCAL 里显式登记"有意不一致"的项
**仅构建期使用**:不在 package.json 的 files 里,运行时无任何 import
(由 V63b 守卫 —— 运行时 import 它会让用户侧 MODULE_NOT_FOUND)
scripts/invariants.mjs **不变量登记表**:112 条防退化守卫的"锁什么 / 为什么 / 被哪个测试锁定"
(V1–V73e),机器可读。test/invariants.test.mjs **双向强制**:
登记了没测试 → 失败;有 V 编号测试没登记 → 失败;缺号必须在 GAPS 里解释
scripts/web-parts.mjs 网页源文件的拼接清单(按序排列的文件名)
scripts/build-web.mjs 把 web/src/** 拼成 web/index.html;--check 逐字节校验(漂移即失败)
web/src/** 仪表盘源文件(HTML 外壳 + style.css + app/*.js 共 20 份)
web/index.html **拼接产物**(交付物):仪表盘单页 · Apple 风格
lib/index.js Host 插件外壳:扫描(sessionPersistence)、/dsh-token 路由、
/token-stats 命令、5 分钟增量扫描(无变化不落盘)、
页面缓存(按 mtime+size+外观偏好失效)+ gzip、发送时注入外观偏好、
POST 同源校验 + 1MB body 上限、
store 形态体检与损坏留证、启动自检;
**store 载入与分片读取为异步**(0.9.4,并发 8),
查询入口有空库兜底(载入完成前不返回 500)
test/ node:test 断言(397 项):数据层、价格/时段、扫描与隔离、客户端注册、
页面渲染、宿主语义(时区口径/未定价/惰性重建/store 体检)、
界面回归(配色按模型名稳定/占比口径/单桶趋势/监听幂等/自定义范围)、
设计 token 一致性与引用闭合、宿主适配不回退
(面板必须"接"宿主字体族/正文字号/底色,不允许抄成固定值)、
画布绘制(2D 调用序列录制:渐变档位/路径闭合/坐标有限/同输入可复现)、
**结构化转义**(tpl/raw 语义 + 恶意输入实测 + 源码级裸拼接守卫)、
**异步载入与分片重建的等价性**(与同步路径逐字段相同)、
**不变量登记表强制**(登记 ↔ 测试双向一致)
scripts/build.mjs 发布前完整性校验(文件、dsh 字段、各模块可解析、内联脚本语法)
scripts/build-tokens.mjs 设计 token 注入器(`--check` 用于 CI/测试,漂移即失败)
scripts/e2e-smoke.mjs 真实 http 栈端到端冒烟(零依赖,临时 DSH_HOME;含外观偏好注入与
「偏好进页面缓存键」:只改设置、不动页面文件,注入值也必须立刻换新)
scripts/verify-tarball.mjs 打包产物验收:把 tarball 解到全新目录当"新装"启动一遍
scripts/settings-snapshot.mjs / canvas-snapshot.mjs
重构安全网:把"设置页输出"与"画布 2D 调用序列"打成快照,
改动前后 diff 为 0 才算没改行为(还能当变异测试用:故意改坏一处,
确认对应的网真能抓到)
scripts/realstore-equivalence.mjs / realstore-coldscan.mjs
拿**本机真实 store** 比对旧版/新版全部查询,以及真实 zstd 会话日志
的冷启动全量重扫(多帧解码按 DSH 的 scanZstdFrames 口径逐帧解)
scripts/bench-refactor.mjs / bench-one.mjs / gen-stress-store.mjs
性能对照:交错采样取中位数 + 配对符号检验,可生成 20 万请求压力库
(这些 scripts/ 与 test/ **不打进 npm 包**,只在仓库里)
数据源 = $DSH_HOME/sessions(默认 ~/.dsh/sessions),经 DSH 官方 sessionPersistence 服务解码(session.jsonl.zstd 为多帧 Zstd + chunk 打包)。持久化在 $DSH_HOME/dsh-token/:store.json 存配置与元数据(水位线 / 隔离清单 / 统计 / 会话 meta),shards/.json 存逐会话记录(0.9.0 起;备份请连目录一起备)。
三个入口各有明确分工
同一个插件在 DSH 界面上有三个入口,名字不同、界面也不同 —— 避免"从不同地方点开、长得一模一样、 显示的数据却不一样"的困惑:
| 入口 | 插槽 | 名字 | 打开后是什么 |
|---|---|---|---|
| 侧边栏(全局面板区) | sidebar.panellist + main | Token 统计 | 全量仪表盘:在应用内整屏打开(四个 Tab、图表、全局筛选、导出)。图标/行样式由外壳画,面板本体 iframe 嵌 /dsh-token |
| 对话区域上方的页签 | conversation.view | 本会话用量 | 只服务当前对话的原生面板(见下)。不嵌 iframe,走 /api/session?id=…&brief=1 只取汇总 |
| 设置页 | settings.section | Token 统计 | 数据源路径、缓存文件、会话/请求数、最近扫描统计 |
/dsh-token 单页仍可独立打开(同样的内容、同样的 API),面板只是把它嵌进来 ——
两者共用同一份页面,不存在"面板里一套、页面里另一套"。
为什么侧边栏入口在 sidebar.panellist 而不是 sidebar.footer.action:后者被外壳渲染成
display:flex 且不换行的一行,任何写死 width:100% 的入口都会与别的插件各占一半、
挤在设置上方;那半截宽度由外壳算出,从入口内部修不掉。sidebar.panellist 是面板图标位,
行样式全部由外壳负责,天然与内置面板同区同款。
视觉规则:外壳跟随宿主,整屏视图跟随产品 —— 但凡是宿主已经表达过的偏好,一律"接"而不是"抄"。
-
侧边栏图标位、设置页那一段 —— 它们是 DSH 外壳的一部分,用 DSH 的设计变量 (
--dsw-alias-*)画;图标位更是整个交给外壳(行高/内边距/底色/选中态都由它画), 和旁边的面板、设置项浑然一体,不抢戏。 -
对话区域里的「本会话用量」是一整屏视图 —— 它跟随产品:复用
/dsh-token仪表盘那一套 iOS 设计语言(液态玻璃卡片、大数字 hero、彩色图标小卡、分类条形列表),调色板与语义色 跟仪表盘逐一对齐(缓存命中=绿、未命中=红、输出=蓝、缓存写入=灰,高峰=橙、空闲=蓝)。 -
但"跟随产品"只到识别度为止,不越过宿主已经表达过的偏好。以下四件事是绑定关系, 不是抄写关系 —— 抄一份固定值会在用户改设置或宿主换主题时静默失效:
维度 面板接的宿主变量 效果 字体族 --dsw-font-family(DSH 留给主题覆写的钩子)宿主的主题换了字体,面板与仪表盘一起跟着换 正文字号 --dsh-content-font-size(「外观」里的内容字号,12–17px)面板与相邻页签同一个字号;行高走 --dsh-content-font-delta同步变化底色 --dsw-alias-bg-base(会话根节点 ConversationRoot 的底色)面板与相邻页签无缝,切页签不会看到一大块异色 深浅色 body[data-ds-dark-theme]跟宿主外壳同一个开关 面板自有的调色板(
--d-*)退居兜底:宿主 token 缺失时(如单独打开web/index.html)仍有产品底色。 -
/dsh-token是独立文档,看不到宿主 DOM 上的data-ds-dark-theme,所以由宿主在发送页面时 注入window.__DSH_TOKEN_THEME__(读的是同一个ui-theme设置段)。页面的「自动」因此跟随 DSH 外观,而不是只跟随操作系统 —— 用户把 DSH 钉成深色、系统却是浅色时,两者不会反着来。 注入缺失(例如直接以文件打开本页)时退回"只跟随系统"。注入结果进页面缓存键,改设置立即生效。
面板内容:
| 区块 | 说明 |
|---|---|
| 顶部 | 「复制摘要」(把本会话用量打成纯文本进剪贴板,成功/失败都有反馈)/「刷新」/「完整仪表盘 ↗」(强调色实心胶囊) |
| hero | 42px 大数字 + 费用 + 汇总行(请求数 · 活跃天数 · 起止时刻)+ 逐小时柱 |
| 时段分布 | 24 根柱(北京时间):蓝=空闲价、橙=高峰价,并给出高峰/空闲的 token 与花费,以及生效的规则文字 |
| 构成 | 细堆叠条 + 双列图例,每段带 token、占比与费用 —— 直接看出"钱花在哪一段"(输出单价是缓存命中的数百倍) |
| 亮点 | 四块彩色图标小卡:缓存省下 / 缓存命中率 / 高峰时段 / 平均每次请求 |
| 按模型 | 分类条形列表:tokens、费用、请求数、占比;未定价模型明确标注 |
| 异常提示 | 有异常增长时给出次数、解释与「查看逐请求明细」深链(?session=) |
| 脚注 | 计价口径 + 高峰规则出处 |
会话面板在库中查不到这条会话时会自动补一次增量扫描再复查(刚建的对话不必等 5 分钟周期)。 高峰规则文字来自服务端下发的生效配置,不在客户端写死官方时段 —— 用户改了高峰时段,面板文字跟着变。
DSH 版本兼容
sessionPersistence 在 DSH 0.1.5-rc.2 从"按 id 直读"改写为"句柄式",本插件按能力探测同时支持两代,无需按宿主版本分发:
| 能力 | ≤ 0.1.4 | ≥ 0.1.5-rc.2 |
|---|---|---|
| 列举会话 | listSnapshots() | list() |
| 读取事件 | readFrom(id, 0) | open(id,'read') → handle.read(0) → handle.close() |
| 会话头 | 返回值 meta | handle.header |
| 路径推导 | locate(meta) | locate(meta)(仍存在,仅作兜底) |
同时,TokenUsage 口径在 0.1.5-rc.2 明确为互斥口径:inputTokens 只计未命中输入,
totalTokens = input + read + write + out。插件据此按等式判据(totalTokens === input + out
且带缓存)识别"折叠口径"的极老日志并回退相减,miss 因此不再被"相减启发式"少算。
store 结构版本随口径修正升到 v4;v4 → v5 是单价改为官方分档 + 高峰/空闲双价; v5 → v6 是小时桶从"宿主本地时区"改为"北京时间"(与高峰取档同口径)。 旧 store 首次加载会自动触发一次全量重扫,之后回到秒级增量。
关于 dsh.engines / dsh.compatibility
这两个字段不是门禁:DSH 宿主不读取它们(dsh plugin add 只检查 dsh.bundle),
DshManifest 类型里也没有 compatibility。它们的作用是给插件市场/人工核对留一份
"测过哪些宿主版本"的记录,dsh.engines.dsh 是下限而非精确集合。改宿主版本时
请一并更新这里,别让它烂成过期文档。
当前声明为 >=0.1.5-rc.1 || >=0.1.6-0 || >=0.1.7-0。后两段不是冗余:按 SemVer
规则,预发布版本不落在普通范围里 —— 0.1.6-alpha.1 不满足 >=0.1.5-rc.1,
0.1.7-alpha.1 既不满足 >=0.1.5-rc.1 也不满足 >=0.1.6-0。只写前面几条会把
这两个预发布判成不兼容(本插件实际支持它们)。每加一条 >=0.x-0 才能把"0.x 及其预发布"
纳入范围。这仍然只是声明,不构成门禁。
0.1.7-alpha.1:宿主 revision 粒度回归(0.9.6 修复)
宿主 @deepseek-ai/dsh-session-persistence-jsonl 在 0.1.7-alpha.1 改了 list()
里 revision 的构造(对比 0.1.6-alpha.1 的同名函数):
| 宿主版本 | 遗留会话(v3)的 revision |
|---|---|
| ≤ 0.1.6 | `` = dev:ino:size:mtimeNs:ctimeNs |
| ≥ 0.1.7 | :(仅当来源版本 本包没有打包器:lib/*.js、lib/core.mjs 是直接发布的源文件,浏览器端 |
lib/client.js也由宿主直接提供。唯一的"构建"是web/index.html—— 它由web/src/**(20 份源文件)经scripts/build-web.mjs拼接而成, 拼接结果与源逐字节一致(内联脚本是单个 IIFE +'use strict',按行区间 切片再按同序拼回不改变语义)。交付物始终是那个单文件web/index.html, 插件原样返回它,测试也直接读它 —— 拆分只为好维护,不引入多文件请求。 tarball 与 git 安装拿到的是同一份字节。发布前的把关靠npm run verify(完整性/语法校验)与npm test(397 项断言 + token/产物一致性校验),两者都挂在prepack上,所以npm pack不会漏跑。
安装后打开 http://127.0.0.1:3080,开箱即用:
- 仪表盘默认即为 Apple 风格四 Tab 页(今天 / 活动 / 会话 / 设置,底部悬浮 Tab 栏);
- 首次启动会做一次全量扫描(自动提取每个会话的标题),之后每次重启均为秒级增量;
- 侧边栏出现 Token 统计 全局面板入口(在「技能中心」等内置面板同一区,图标与行样式由外壳画);
点击在应用内整屏打开仪表盘,
/dsh-token单页仍可独立打开; - 对话区域上方出现 本会话用量 页签:只服务当前对话的原生面板(不嵌 iframe,内容见上表);
- 设置 → Token 统计 页展示数据源、扫描统计、模型清单、被跳过(隔离)的会话清单, 并管理单价表与高峰时段;
- 斜杠命令
/token-stats [today|3d|7d|30d|month|all]。 - 想自定义仪表盘外观:改
web/src/**(源文件)后跑node scripts/build-web.mjs重新拼接 出web/index.html,Host 按 (mtime, size, 外观偏好) 失效缓存,刷新即生效,免重启。⚠ 这条只适用于 clone 仓库的场景。 npm / git / Release tarball 三种安装方式拿到的 是发布产物:
package.json的files只列web/index.html,web/src/**与scripts/**不在包里(由 V49/V50 锁定"产物在、源不在")。想改外观请先git clone https://github.com/fufuf-c/dsh-token,再按scripts/web-parts.mjs的 拼接清单改。 0.8.9 起不再支持webPagePath指向外部文件 —— 那条配置面只校验扩展名, 等于给本机任意进程一个"读盘上任意.html"的原语(UNC 路径还会触发对外 SMB 连接、 外泄 NTLM 凭证),而页面字节又是同源返回的,构成脚本执行面。
与 dsh-usage-vista 系列可共存(不同 URL 前缀 / bundle id / 入口 id)。
路由与 API 契约
全部同源,Cache-Control: no-store。
| 端点 | 方法 | 说明 |
|---|---|---|
/dsh-token | GET | 仪表盘单页(见"页面替换") |
/dsh-token/api/kpi | GET | KPI:?range=&models=&session=&wd=&from=&to= |
/dsh-token/api/series | GET | 序列:`?granularity=day |
/dsh-token/api/hours | GET | 24 桶小时分布(同上筛选) |
/dsh-token/api/heatmap | GET | 12 个月日总量:?months=12&… |
/dsh-token/api/sessions | GET | 会话排行:`?sort=recent |
/dsh-token/api/session | GET | 单会话下钻:?id=;&brief=1 省掉逐请求数组(会话内面板用,大会话可省成 MB 载荷)。两种模式都带节奏字段:hours/hoursPeak(24 桶,北京时间,后者是按高峰价计费的部分)、tiers(高峰/空闲各自的 tokens/费用/请求数)、costParts(四段各自花掉的钱)、unpriced(未定价模型的 tokens)、activeDays、schedule(生效的高峰规则) |
/dsh-token/api/meta | GET | 数据源路径/会话/请求/模型/扫描统计/价格族 |
/dsh-token/api/config | GET/POST | 配置:{prices?, resetPrices?, budget?:{monthly}, retention?:{days}, refresh?:{pageSec?, scanSec?, onOpen?}, peakHours?("9-12, 14-18" 或 [[9,12],[14,18]];null=官方默认,[]=无高峰), peakDays?("1-5" 或 [1,2,3,4,5];null=默认,[]=无)};GET 额外返回 defaults(模型键 → 官方默认价,含 peak/idle)、归一化后的 prices、生效时段 schedule/peakHours/peakDays 及其官方默认值、以及 refresh(归一化后)+ defaultsRefresh + refreshBounds。webPagePath 已在 0.8.9 移除(传了会 400) |
/dsh-token/api/scan | POST | 触发增量扫描 |
/dsh-token/api/export.csv | GET | 日×模型明细(当前筛选) |
/dsh-token/api/export.json | GET | 逐请求原始明细(当前筛选);超过 10 万条时返回 413 + 可操作的原因(避免单次 68MB 响应体卡死标签页) |
主机/来源栅栏(0.8.9 起对 GET 与 POST 一视同仁):请求的 Host 头必须落在"本机可达"白名单内 —— localhost/*.localhost、IPv4 回环(127.0.0.0/8)、IPv6 回环(::1、::ffff:127.0.0.0/8)、RFC1918 私网、链路本地、CGNAT、IPv6 唯一本地/链路本地放行;域名与公网 IP 字面量一律拒绝。域名拒绝是因为 DNS rebinding 只能借域名生效(rebind 后 Origin.host 与 Host 会同时是攻击者域名,同源等式恒真);公网 IP 字面量拒绝是因为 bind 0.0.0.0 时任何人都能以裸 IP 访问,而浏览器发起的跨站请求的 Origin 必然是域名 —— 拒掉不伤任何正常用法。带 Origin 头时还须与 Host 完全一致;另拒绝 Sec-Fetch-Site: cross-site(与 Host 检查是两条独立线索)。主机名比较前会规范化(去尾部点、统一小写),所以 127.0.0.1. 与 LOCALHOST 不会被误拒。经 LAN 的私网 IP 访问(反代/多机)仍可写入配置,curl 无 Origin 调用照旧放行。请求体上限 1MB(超限排空剩余 body,keep-alive 不串流)。页面响应带 Content-Security-Policy(仅内联脚本/样式 + 同源连接)与 X-Content-Type-Options: nosniff;CSV 导出对 =/+/-/@ 开头的单元格做公式注入防护。筛选参数(贯穿所有数据端点):range=today|3d|7d|30d|month|all|custom(custom 配 from/to 日键 YYYY-MM-DD)、models=(逗号分隔 provider:model 键)、session=(会话 ID 前缀)、wd=(工作目录前缀)。
kpi 响应:
{
"range": { "label", "fromDay", "toDay" },
"totals": { "miss", "read", "write", "out", "total", "requests", "cost", "saved",
"unpricedTokens": 650, // 无内置价且无自定义价的 tokens(cost 里记 0)
"unpricedModelCount": 1 }, // 涉及几个模型
"h





