wfengq/dsh-Tally

tally-ui

提供输入框费用条、每轮与会话费用详情、用量看板、日/月预算和设置,并通过 dsh-tally-balance-deepseek 显示 DeepSeek 余额。

用量与计费

dsh-tally-ui

dsh-tally 的参考界面插件,运行在 dsh 的 Web 界面(dsh --profile web)里。它只通过同源的 Remote API 命名空间 tally 读写 dsh-tally 的数据,自己不读凭据、不发起其他网络请求。

安装

先安装 dsh-tally,再安装本插件,然后重启 dsh:

dsh plugin --profile web add dsh-tally@beta dsh-tally-ui@beta

浏览器端代码只在 dsh 启动时加载,所以安装或升级后需要重启 dsh。需要显示 DeepSeek 余额时,再安装 dsh-tally-balance-deepseek。

注意:浏览器端代码只会挂到以包名(dsh-tally-ui)命名的 Loader 条目上,请用 dsh plugin add 安装,不要在 patch 文件里用路径引用 lib/client.js。

功能

界面共有六处,布局照 dsh 自带页面的规范:设置内容放在统一材质的圆角卡片里,每行左边是名称和说明、右边是控件,按钮、输入框、开关、弹窗、菜单、折叠行都用 dsh 的公共组件。下表的“截图”一列是 e2e 测试(tests/e2e/web.e2e.test.ts)运行后保存在 tests/e2e/screenshots/ 里的文件,可以对照查看(截图不提交到仓库)。所有金额都按币种分开显示,不做换算;没有价格时显示“未定价”,不会显示 ¥0;小于 0.0001 的金额显示为 <¥0.0001。

位置元素内容截图
输入框下方费用条(composerDock)一行:今日 · 本月 · 预算进度(设了预算才显示,取最接近上限的那条,80% 起警告色、超过 100% 改为“已超预算 ¥x”)· 当前价格时段和倒计时。窗口变窄时依次省略时段、本月、预算,始终不换行。外观和交互与旁边 dsh 自带的统计按钮一致:圆角按钮带图标,悬停只高亮;点击在上方打开详情(本会话、今日、本月、每条预算、余额、当前时段,以及“打开用量看板”),再次点击、点别处或按 Esc 关闭。余额在详情里点击即可刷新,失败时继续显示上一次的余额并标注“已过期”01-conversation.png、dock-light.png、dock-dark.png、04-balance-stale.png
每轮回复末尾本轮费用(turnTail)这一轮的费用,样式和交互与 dsh 自带的“Usage … tok”按钮一致:点击在上方打开详情(输入、缓存命中、输出 token、缓存命中率、请求次数(含重试)和价格版本,价格版本链接到价格来源)01-conversation.png、02-turn-details.png
会话标题栏右侧会话累计(headerUtilities)“本会话 ¥x”(含子代理)。点击在下方打开详情:按模型拆分费用和请求次数,列出子代理会话,并可“打开用量看板”(在排行里高亮这个会话)01-conversation.png
右侧栏用量看板(sidebarTab)顶部可选近 7 天 / 近 30 天 / 本月,“⋯”菜单里是“导出 CSV”。从上到下:概览(今日、本月两个大数字,所选范围合计,每条预算的进度条,未定价请求数)、每日费用(高峰 / 低谷堆叠柱状图)、排行(会话 / 模型 / 工作区切换,默认前 10 名,可“显示全部”,已删除的会话带标签)、缓存与峰谷(默认收起:缓存命中率趋势和低谷节省)。有新的用量记入后以及重连后自动刷新05-dashboard.png、dashboard-light.png、dashboard-dark.png
设置 → 用量与费用设置页(始终显示)预算卡片(每条一行,显示“已用 / 上限”和进度,“⋯”菜单里编辑或删除;添加和编辑在弹窗里完成,工作区从账本里出现过的里面选)、界面卡片(五个开关,各带一句说明)、问题卡片(有问题才出现;无法读取的会话日志合并成一行,展开看明细)、“高级”分组(默认收起:价格目录、价格覆盖、重建账本)。价格覆盖也是“列表 + 弹窗”,路由和目标模型都从下拉里选06-settings-budget.png、08-settings-elements.png、settings-light.png、settings-dark.png
页面顶部浮层预算提醒(budgetToast)预算达到 80% 或 100% 时弹出,例如“今日预算已用 82%(¥8.2 / ¥10)”或“本月预算已超出(¥102 / ¥100)”,带“查看”按钮打开用量看板;约 8 秒后自动消失,多条依次显示07-budget-toast.png

其余截图:09-turn-tail-off.png(关闭“每轮对话的费用”后新一轮不再显示本轮费用)、10-after-reload.png(重新加载页面后费用条数值不变)。

页面断线重连(包括重新加载)后,插件会在收到 watch 流的 hello 帧时重新拉取全部数据,所以重连前后显示的数值一致;修改价格覆盖、重建账本后 dsh-tally 也会发出 hello,界面随之刷新。在设置页增删预算后,费用条和看板立即跟着更新。

界面文案有中文和英文两套,跟随 dsh 的界面语言。

元素开关

elements 下的五个开关控制各个界面元素是否显示,默认都打开。它们是 volatile 配置项:在“设置 → 用量与费用 → 界面元素”里切换后立即生效,不需要重新加载插件,也不需要刷新页面。设置页里本插件的部分本身不能关闭。

配置项默认值说明
elements.composerDocktrue输入框下方的费用条。
elements.turnTailtrue每轮回复末尾的本轮费用。
elements.headerUtilitiestrue会话标题栏的会话累计费用。
elements.sidebarTabtrue右侧栏的用量看板(关闭后从导引中移除)。
elements.budgetToasttrue预算达到提醒线时的弹出提示。

只有在本机(loopback)打开的页面才能修改设置:远程访问的页面处于 memory 模式,设置只保存在浏览器里,开关、价格覆盖会显示为只读并说明原因。预算不走配置文件,而是通过 Remote API 写入 dsh-tally 的预算文件。

也可以直接写在 profile 的 patch 文件里:

- id: tally-ui
  name: dsh-tally-ui
  config:
    elements:
      turnTail: false

构建说明

pnpm run build(仓库根目录)依次执行:

  1. tsc -p .:编译 Host 端入口 src/index.ts 到 lib/index.js。Host 端只声明插件名和配置项(界面元素开关),apply 为空。
  2. tsc -p tsconfig.client.json:只生成浏览器端代码的类型声明,输出到 lib/types。
  3. tsdown:把 src/client/index.ts 打包成 lib/client.js。配置照搬 dsh 官方 packages/client/tsdown.client.ts 里的 clientConfig():
    • 格式为 cjs,平台为 browser,输出文件名固定为 client.js,带 sourcemap;
    • 用 banner 和 footer 把整个模块包成 window.__ModuleLoader__.load({ id: 'dsh-tally-ui', factory: (require) => { … } }),由 dsh 的客户端模块加载器在需要时执行;
    • 只有宿主共享模块保持为 require()(tsdown 0.23 的 deps.neverBundle,即旧的 external),其余全部内联。
  4. node scripts/check-client-bundle.mjs packages/tally-ui/lib/client.js:扫描 bundle 里所有 require("…"),出现共享模块表以外的模块就报错退出。

bundle 共享模块限制:dsh 的客户端模块加载器只向插件提供固定的宿主共享模块表:react、react/jsx-runtime、react-dom、react-dom/client、@deepseek-ai/cordis、@deepseek-ai/dsh-client-store、@deepseek-ai/dsh-client-ui-slots、@deepseek-ai/dsh-client-ui-primitives、@deepseek-ai/dsh-client-ui-dockkit。bundle 里 require() 其他任何模块都会在浏览器中失败,所以 zod、dsh-tally/remote、本包自己的代码和样式全部内联;其他 @deepseek-ai/dsh-client-* 包只能 import type(用于类型和声明合并),不能引入运行时值。目前的 bundle 实际只引用了 react、react/jsx-runtime 和 @deepseek-ai/dsh-client-ui-primitives。

样式:采用“factory 执行时注入 <style>”的方式,不使用 .module.css。src/client 下的 .css 文件在打包时被转换成一段代码,factory 执行时向 document.head 插入一个 <style data-plugin="dsh-tally-ui" data-plugin-css="dsh-tally-ui/<文件名>">(与 dsh 官方插件的标记相同,插件卸载时由 dsh 回收)。类名不做哈希,统一加 dsh-tally- 前缀避免冲突。样式只使用 --dsw-alias-* 语义变量和 --dsw-elevation-*,不写主题选择器和字面颜色值。

接口描述:浏览器端调用 Remote API 需要一份带严格编解码器的接口描述(TypertRemoteContribution)。dsh 官方用构建期生成器产出这份描述,但生成器依赖 dsh 仓库内部的工作区结构,所以由 dsh-tally 手写(dsh-tally/remote,见 packages/tally/src/remote/contract.ts),本包在启动时用其中的 mountTallyRemote(ctx) 挂载:如果另一个客户端插件已经挂载过 tally(remote.tally 服务已存在),就跳过挂载,直接使用已有的命名空间;否则调用 ctx.remote.$mount()。两个插件同时挂载时,输掉的一方会收到宿主的 client api: direct method tally/… is already mounted(或 Typert 注册表的 Remote package "dsh-tally" is already registered),同样视为成功。

执行中的判断规则(Task 1):两条备用方案都没有用到,最终的端到端验收也是在这个前提下通过的。

  • Host 端:编译后的 lib 里,标注了 @Remote 的方法能被 dsh 网关直接识别(网关从运行中的服务读取装饰器标记,参数名从编译后的函数读取),不需要改用 bindTypertRemote 手动注册。
  • 浏览器端:手写的接口描述可以被 ctx.remote.$mount() 接受,不需要改用官方生成器 typertPlugin。因此也不需要在发布前复核生成器版本与 dsh 版本的对应关系。

宿主表单接口的两个细节(在真实 dsh 中验证时发现):

  • 宿主的 ConfigForm 是一个类,getSnapshot / subscribe 依赖 this,传给 React 的 useSyncExternalStore 前必须包一层,不能直接传方法引用。
  • ConfigForm.set(field, value) 的 field 只是一层键名,'elements.turnTail' 会被当成一个字面键而被拒绝;嵌套字段要用 mutate([{ op: 'set', path: ['elements', 'turnTail'], value }])。

测试

  • pnpm test:组件和数据层的单元测试(jsdom),以及 bundle 共享模块检查。
  • pnpm test:e2e:在真实的 dsh --profile web 中,用 Chromium 依次验证费用条(含与 dsh 自带统计按钮的外观和交互一致)、本轮费用(含点击详情,样式与 dsh 自带按钮一致)、会话累计、余额(含刷新失败后的“已过期”)、用量看板(含 CSV 下载)、设置页(新建预算后出现预算提醒、关闭 turnTail 后新一轮不再显示本轮费用)以及页面重新加载后数值不变;全过程页面没有 console.error,dsh 的 stderr 没有 ExperimentalWarning 和 failed to import。模型请求由 @deepseek-ai/dsh-llm-mock-server 应答,余额由测试内的本地服务应答。测试固定使用网页内的目录选择器(不弹出系统文件夹窗口),所以在 Windows、macOS 和 Linux 上行为一致。本机第一次运行前需要装一次 Playwright 的浏览器:pnpm exec playwright install chromium。

许可证

MIT