ewoowe/dsh-session-usage-plugin

dsh-session-usage-plugin

Session usage view: tokens, wall time and cache share per model and per turn, as a conversation view.

待分类dshdsh-plugins

dsh-session-usage

English | 中文

会话用量视图:会话标签页里的一个「用量」,一张会话总计加两张表。

  1. 模型用量统计(默认):每个用过的模型一行,按总量降序。
  2. 消息用量统计:每轮一行,可点表头按任意数字列排序。

这是一个独立安装到 profile 的插件,不修改 deepseek-harness 的任何源码(packages/ 未改动)。

两个叶子

「用量」视图,选中「模型用量统计」叶子

「用量」视图,选中「消息用量统计」叶子

模型用量统计消息用量统计
一行 =一个模型一轮(不是一条消息)
列模型 · 轮次 · 输入 · 输出 · 用时 · 缓存命中时间 · 消息 · 模型 · 输入 · 输出 · 用时 · 缓存命中
排序固定,总量降序点表头切换
覆盖已加载窗口内的轮次同左

一行是一轮,不是一条消息。 一条助手回复与提出它的那句提示词属于同一轮、共用同一份用量, 逐条列出来会把同一笔 token 在每轮里数两遍——这是用量表最不能犯的那个算术错误。所以行由发起 该轮的那句提示词标识。一轮里换过模型时,整轮记在产出回复的那个模型上。

会话总计那一行的三个胶囊来自 ISession.projections(宿主按整条日志算好),所以翻页、压缩 窗口都不会让它们跳动。

数字从哪来

数字来源
会话用量 / 用时 / 缓存命中客户端投影 tokenUsage、sessionStats——整条日志口径
每轮用量会话自己的事件窗口,交给宿主的 deriveTurnTokenUsage(@deepseek-ai/dsh-token-meter/client)折叠
每轮模型该轮事件里的路由元数据,回退到 assistant/message 的 message.source
每轮用时该轮 turn/start 与 turn/end 两个事件自己的时刻之差(不读时钟)
模型显示名remote.session.modelCatalog(),与输入框选择器同一写法

用量不自己聚合。 哪些事件算数、最终消息如何覆盖流式样本、重试如何叠加、不完整的轮次返回 空——这些规则很细,宿主已经在 deriveTurnTokenUsage 里定死了(ui-chat 建轮尾用的就是它)。 自己再实现一遍,第一次偏差会一直隐形,直到有人比较两个界面。

缓存命中率是移植,不是重写。 format.ts 里的 formatCacheHitPercent 是 ui-chat token-format.ts 那一份的移植:同一个投影、同一份算法,所以本插件、输入框那两个胶囊与底部 状态栏印出的百分比逐字相同。宿主那条规则值得说明——部分命中接近满分时不夹紧成 99.9, 而是加小数位(99.6 / 99.95 这种形状),既真实、又明显不是满分。

三条刻意的口径

① 输入 = 三个不相交桶之和。 uncachedInputTokens + cacheReadTokens + cacheWriteTokens。 宿主把 inputTokens 只算作未缓存的那部分,把它当成总量会让折叠直接拒绝整轮。

② 算不出来就留「—」,并且照常留行。 隐藏该行会让行数与覆盖度行报出的轮次数不符;而破折号 不是一个小数字,所以按任意列排序时,它在两个方向上都排最后——否则降序时一列「—」会占据 表头,读起来像"这些最贵"。

③ 并列与都未知时按 seq。 于是排序永远不会任意打乱相等的行;把它写成 seq 而不是 "输入顺序",这条规则在本插件之外也成立。

覆盖范围与「载入全部历史」

明细只能折叠已加载的事件窗口,所以覆盖度行同时报出两个数:

已统计 12 / 22 轮 · 其中 10 轮不在已加载窗口

「载入全部历史」走宿主自己的跳转加载器(binding.session.loadThrough(SessionSeq(0))): 它自带无进展守卫(空页仍然声称还有历史时结束循环,而不是空转),每页 200 条消息——是本插件 自己翻页的四倍,所以整段会话的往返次数只有四分之一。代价写在按钮的提示里:载入不回缩, 刷新前一直有效,且无法中途取消。

时间范围

表格上方有一排范围按钮,默认「本会话」——也就是之前的行为,完全不变:

档范围
本会话没有边界:整场会话,读宿主投影
今天本地 0 点 → 此刻
24 小时滚动 24 小时:此刻往前一天
昨天昨天 0 点 → 今天 0 点(左闭右开)
3 天三天前本地 0 点 → 此刻
7 天七天前本地 0 点 → 此刻
自定义你选的两端(本地时区;结束留空 = 此刻)

两种形状是刻意不同的,而且都是你自己会用的说法:「今天」是一个日历日;而「最近一天」念出来的 是「昨天此刻到此刻」——不是昨天 0 点,后者会让清晨的读数少算掉大半天。

「昨天」是固定档里唯一两端都定的那个。 其余各档都延伸到此刻(「最近三天」包含今天),而「昨天」是一个 闭合的日历日——只给下界的话,它会把今天也吞进来。上界是开的:昨天 0 点到今天 0 点整,所以 今天 0 点整开始的那一轮归今天,两档在零点相接而不重叠。

「自定义」由你定两端,用的是两个原生日期时间输入,读写的都是本地时区(用 UTC 会让字段 显示的时间和你选的时间差几个小时)。结束留空 = 到此刻。未填起始时表格是空的,而不是显示全部—— 半填状态下显示全部会读成「自定义被忽略了」。它也是唯一记住具体时刻的档:其余各档都是相对此刻 算出来的,只有它存着两个瞬间。

选定范围后,顶部那三个胶囊不再是投影。没有任何投影带时间维度,所以它们改为从范围内的轮次 求和,命中率取和的比(先把缓存读取与计费输入分别求和,再套用宿主那份格式化)。和的比不是比 的平均:按轮平均会让 3 个 token 的一轮与 30 万的一轮等权。

范围是会话内的。 本插件只读当前会话的投影与事件窗,客户端够不到跨会话的日志(宿主侧的会话 查询没有对应的 remote endpoint),所以「今天」= 这个会话今天用了多少。

点一个范围不会自动载入任何东西。 窗口只装得下最近一段,所以在跑了一个月的会话上点「7 天」, 起初只会看到窗口碰巧覆盖的那部分——覆盖度行会明确写出缺口(· 更早的轮次还没载入),因为在范围内 「已统计 N / N 轮」这句话本身看不出还有更早的。

要补上缺口,由你点那个按钮(「载入范围内更早的历史」)。它是精确的:逐页往前拉(每页 50 条消息, Session Controller 自己的 pager),窗口最早一轮一早已于范围起点就停,而不是一路拖到会话开头; 上限 40 页(2000 条),超过则由旁边的「载入全部历史」一次到位。

为什么不让它自动:一页拉进来的是消息本体,而宿主无法卸载——窗口只会变大,真正的代价是 「看 7 天」所触发的载入,而不是它画出来的表。所以这个代价由选择它的人来付。

范围同样是记住的(见下),而它是五个偏好里唯一含义会移动的一个:「今天」永远是打开时的今天, 而不是选中它那天。

导出

范围行下面有四个按钮,两两一组:前两个给数字,后两个给对话。

按钮内容
统计 CSV与表同样的列;紧凑格式(8.2K、1分12秒),末尾一行合计
统计 JSON原始数字,含 seq、完整 usage 分桶、路由来源——给程序用
对话 MD每轮的完整提示词与回复(Markdown)
对话 JSONL范围内每轮的原始事件,一行一个 JSON,与宿主日志同形

三处刻意的取舍:

  • CSV 用紧凑数字,JSON 用原始数字。 前者是人在表格里读的(8.2K 比 8214 好扫), 后者是程序读的(它没法把格式化逆回去)。缓存那一列在 CSV 里不带 %——带后缀的数字 在求和前还得先剪掉尾巴,那不是数字,是标签。
  • CSV 的合计行不假装分桶。 范围内"输入 / 输出"的拆分这个视图从没算过,编一个出来就等于 在导出里放一个任何界面都不认同的数;合计行只写四处都对得上的:轮数、总量、用时、缓存命中。
  • Markdown 用的是全文。 表里那行是截断的预览,而截断是视图的事(在表里)——点了导出的人 要的是对话本身,不是"能塞进一格的那部分"。

导出的就是屏幕上那些:同一个范围、同一个顺序、同样的轮次——缺口也一并如实, 未载入的更早历史不会出现在文件里(与表一致)。空范围时四个按钮禁用: 一个只有表头的文件,不是比"没有什么可写"更好的回答。

CSV 的字段会按 RFC 4180 转义(含逗号、引号或换行时加引号、内部引号翻倍)—— 提示词里这些字符是常态,不转义不是"看起来乱",而是整行串列:表格会安静地读出一组 和界面不一样的数字。

记住你的位置

五个读者偏好,都存在 localStorage(键带 dsh-session-usage. 前缀):

键记住什么
…message-sort消息表的排序列与方向
…leaf停在哪个子标签
…scroll页面滚动到哪儿
…range停在哪个时间范围
…custom-range自定义区间的两端

两层,两层都承重:一个模块级值覆盖卸载(两个叶子是条件渲染的,切换叶子会卸载表格,而 useState 会静默把排序清回窗口自身顺序),localStorage 覆盖刷新。

  • 存储是可选的:每次访问都兜住异常(宿主有 profile 以 --no-webstorage 运行),此时内存层 仍然覆盖本次页面——那里"忘了排序"是少个便利,不是一张坏掉的表。
  • 存进去的值都要校验:手改过、或由列不同的版本写入的值会被认出来并当作"没有偏好"。认不出 的排序列会让比较全变成 NaN,行序乱成一片——看起来像排序 bug,其实不是。
  • 这些偏好是全局的:切会话不会重置它们(它们描述读者,不描述会话)。

形态:composer-overlay

本视图声明宿主的 data-conversation-composer-overlay,也就是 trajectory 视图用的那套模式。 一个属性换来三件事:

  • 转录的宽度把手不再渲染。 它们锚在内容列上、命中带宽达 40px,正好落在用量表右列的数字上, 一个只想看数字的指针会不断把它招出来,一拖还会改掉内容列宽。
  • 输入框抬成底部浮层,所以表格底部按宿主发布的 --dsh-composer-height 留白——否则长表的 最后几行会压在输入框下面。
  • 滚动交给视图自己,由根元素承担(一个滚动区管住胶囊、两个叶子与覆盖度行)。

这个模式是声明式的:属性随视图一起消失,宿主自己收尾。

语言

插件自己的文案提供 zh 与 en 两种(宿主自带的两种),全部在 src/client/locales.ts。 两本字典都标注为 Record<MessagesKey, string>:往 MessagesKey 加一个键,两本补齐之前 编译不过,所以不会出现「某个键只是忘了翻译、于是静默退回英文」。这个保证比回退链本身更重要。

数字与时长同样走本插件的字典(12.2K / 1.2M、45.2秒 / 2分42秒),因为每一种语言有 自己的写法,它们不是可以照抄的常量。

目录

session-usage-plugin/
  package.json        dsh.bundle + dsh.client 声明、exports 映射
  cordis.patch.yml    层补丁:一行 insert,把自己登记为 Loader 条目(无 config 块)
  build.mjs           构建脚本(tsdown 编程接口)
  tsconfig.json       类型解析:给 IDE 与 npx tsc 用(noEmit,只读)
  src/
    index.ts                  Node 半:不提供服务、不提供配置,只让 Loader 行能解析
    client/
      index.ts                浏览器半:注册「用量」视图与模型名查找
      UsageView.tsx           容器:会话总计、两个叶子、覆盖度行、偏好与形态
      ModelUsageTable.tsx     按模型表;模型名回退到原始 id
      MessageUsageTable.tsx   按轮表;排序规则与未知值归位
      turn-facts.ts           事件窗 → 每轮事实(模型、用量、缓存、用时、提示词)
      usage-by-model.ts       每轮事实 → 每个模型一行
      format.ts               紧凑数字/时长 + 移植宿主的缓存命中格式
      preferences.ts          五个读者偏好(排序 / 叶子 / 滚动 / 范围 / 自定义区间)
      model-names.ts          模型显示名:宿主目录 → id 的查找表
      Coverage.tsx            覆盖度行与「载入全部历史」
      table-styles.ts         两张表共用的表格 chrome 与页面根样式
      time-range.ts           固定范围、其本地日历边界,以及自定义区间的本地时间换算
      locales.ts              zh / en 两本字典
  lib/                构建产物(index.js / client.js)

安装

从 npm 装(发布的包里已带 lib/):

pnpm dsh plugin --profile web add dsh-session-usage

或者从本仓库装——这时构建由你自己跑,且改了 src 就要重跑:

# ① 构建(改了 src 就要重跑)—— 全程在仓库根执行
cd session-usage-plugin && npm run build && cd ..

# ② 安装到 profile(此时已回到仓库根)
pnpm dsh plugin --profile web add ./session-usage-plugin

构建也可用 node_modules/.bin/tsx session-usage-plugin/build.mjs(在仓库根执行,不必进子目录)。 注意这条依赖 node_modules/.bin/ 下的 shell 桩,Windows 上没有它,请用上面的 npm run build。

然后重启 pnpm dsh web(新增 bundle 层、以及 bundle 内容变化,都需要重启)。

卸载:

pnpm dsh plugin --profile web remove dsh-session-usage

开发

cd session-usage-plugin
npx tsc -p tsconfig.json                     # 类型检查(无输出 = 0 错误)
npm run build                                # 产出 lib/index.js 与 lib/client.js

typescript 是 devDependency,所以类型检查在插件目录内就能跑,不需要宿主仓库。 oxlint 是宿主仓库自己的 linter,本插件没有装它;把插件放进 dsh 仓库下时才可用:

cd .. && node_modules/.bin/oxlint session-usage-plugin

重启后即可在会话标签页里看到「用量」。会话视图的标签是标签页,不是弹窗——它和「对话」 「轨迹」并排,占据会话区整块。

已知限制

  • 明细只覆盖已加载的事件窗口。 覆盖度行会把这件事说出来(已统计 N / M 轮),更早的部分 由「载入全部历史」翻入——载入不回缩,刷新前一直有效,且无法取消。正在进行的轮次要等它 结束后才会出现用量。
  • 缓存命中按轮取,与每轮对话框同一精度;进行中的轮次拿不到(宿主对该情形 fail closed), 此时该格留「—」。
  • 模型显示名依赖 remote 层。 组合里没有它时,表格退回打印原始 id(如 deepseek-flash), 其余行为不变。
  • 只有 zh 与 en。 语言包让其他语言可选时,本插件的文案会走 en 回退。
  • 偏好是全局的,不按会话区分:滚动位置在内容更短的会话里会被浏览器夹回范围内。
  • 输入框在用量视图里被置为 inert 并给出原因(conversation.blocks 是外壳自己的单槽登记处, 视图无法隐藏它,这是契约提供的唯一杠杆)。

实现要点

  • 一次折叠供两个叶子。 容器持有事件窗的折叠(按窗口自身 revision 记忆化)、覆盖度与总计, 两张表是纯展示的——所以两张表不可能各自描述会话的不同切片。
  • 轮询而非订阅:空闲会话上一次 tick 只花一次整数比较,而仪表盘没有任何交互能靠实时推送改善。
  • 两种状态各归其位:每轮事实来自事件窗(会话数据),五个偏好来自 store(读者数据)。 它们不共用存储,也不共用生命周期。
  • Node 半是空的,且是刻意的:dsh.bundle 层按包名插入一行 Loader 条目,那一行必须能解析, 而这个插件的全部工作都在另一半。