# 开发与维护 面向维护者。用户视角的说明在 [README.md](README.md)。 - [数据从哪来](#数据从哪来) - [为什么是「替换」而不是「累加」](#为什么是替换而不是累加) - [状态文件与配置](#状态文件与配置) - [HTTP 接口](#http-接口) - [代码结构](#代码结构) - [刻意的设计取舍](#刻意的设计取舍) - [开发](#开发) - [预览台](#预览台) - [回退](#回退) - [为什么客户端有测试缝](#为什么客户端有测试缝) - [升级路径](#升级路径) ## 数据从哪来 唯一的来源是宿主端的 `ctx.on('session/event', (session, event) => …)`: - 用量取 `assistant/message` 事件里的 `data.usage`(`inputTokens` / `outputTokens` / `cacheReadTokens` / `cacheWriteTokens`,`totalTokens` 可能缺省)。`assistant/attempt` 也可以从 `data.stream` 里最后一个 `{type:'chunk', chunk:{type:'usage'}}` 取到同一份数字。 - **归属取每一次调用自己的路由**:`data.message.source = {kind:'model', provider, model}`, 而不是「这个会话用的是什么」。同一段对话换模型、换供应商,账本会分别记在各自名下。 - **时间取事件的 `time`**,折算成本地日期键,所以跨午夜的调用落在它真正发生的那一天。 ## 为什么是「替换」而不是「累加」 朴素地把每个 `assistant/message` 加起来会**重复计数**:同一次调用在流错误后重试时会再报一次 结算,坐标 `(turn, step)` 相同。DSH 自己的 token 投影为此维护「每个会话最后一个样本」, 本插件沿用同一套语义: - 账本里保留每个会话最后一个样本(`samples`),同 `(turn, step)` 再报时**先减掉旧的、 再加上新的**,所以重试算一次调用而不是两次。 - `llm/retry-started` 事件会清掉对应槽位,让重试从零开始计。 - 样本自带 `dayKey` / `provider` / `model`,所以替换发生在「原来那一天的原来那个桶」里, 跨午夜重试也不会把用量搬到第二天。 - 会话桶与每日账本出自**同一批事件、同一条先减后加路径**,不是从聊天记录反推的第二份真相。 两个配套的正确性细节: - **`seq` 重放保护**:每个会话记住最后处理过的事件序号,序号不大于它的直接丢弃。 历史补录与实时流因此可以安全地重叠。 - **原子写**:状态文件先写临时文件再 `rename`,进程被中断也不会留下半个 JSON。 ## 状态文件与配置 | 路径 | 内容 | |---|---| | `$DSH_HOME/abacus/usage.json` | 账本:`days`(按日期/供应商/模型)、`hours`(按小时,键 `YYYY-MM-DDTHH`)、`sessions`(按会话)、`samples`(替换槽位)、`history`(补录书签)、`hoursBuiltAt`(小时桶的重建书签)、`replayGuardAt`(副本日志的检查书签) | | `$DSH_HOME/abacus/providers.json` | 供应商余额与套餐快照(带 `updatedAt`,重启后仍然可用) | | `$DSH_HOME/abacus/upstream.json` | 从上游抓到的价目表与节假日表,各带自己的 `fetchedAt`;抓不到就用内置表 | | `$DSH_HOME/abacus/config.json` | 插件配置 | `$DSH_HOME` 默认是 `~/.dsh`,可用环境变量覆盖。 2.0.0 之前这四份文件在 `$DSH_HOME/usage-stat/` 下。改名后 `UsageService` 构造时会调用 `adoptLegacyState`:**新目录不存在、旧目录非空**才逐项复制(`force: false`,不覆盖已存在的 同名文件),复制完把 `adoptedLegacyState` 置真;旧目录原样留着当备份,之后每次启动都不会再 读它 —— 所以「已经在新目录里写过数据」与「旧目录还躺在那里」不会互相污染,也不需要合并。 这一步是同步的:`Upstream` 在自己的构造函数里就读写缓存,晚一步就会先建出一个空目录。 配置也可以直接改文件(改完刷新页面即可; 宿主端每次读文件都会做一次范围校验): ```json { "enabled": true, "pollIntervalSec": 60, "probeEnabled": true, "probeIntervalSec": 300, "alertEnabled": true, "alertBalanceBelow": 0, "alertWindow5h": 0, "alertWindowWeek": 0, "alertWindowMonth": 0, "prices": {} } ``` 字段含义、范围与默认值见 [README 的配置表](README.md#配置)。 ### 日桶与小时桶 `hours` 与 `days` 是**同一次 `applySample` 写出来的两张表**,不是两遍扫描同一串事件 —— 两遍扫就是一个账本开始自相矛盾的起点。代价是必须守住一条不变式: > **某一天的日桶 = 该天所有小时桶之和。** `test/document.test.mjs` 直接断言这条(含跨小时边界的重试:旧小时桶要被减掉)。小时键从 `sample.atMs` 推导而不是另存字段,所以样本记录不可能报出自相矛盾的日与小时。 旧文件没有 `hours` 键时读作「未按这个分辨率记录」而不是零:`UsageService` 会用 `hoursBuiltAt === undefined` 判定,从会话日志里**重建一次**(清空后单遍折叠,不走 seq 守卫, 因为这次扫描的目的就是重建,半满的图会把每个已有调用再算一遍)。之后的实时事件与强制补录 都走同一次折叠,所以两边的口径永远一致。 ## HTTP 接口 前缀 `/api/abacus`。**宿主端注册时带前导斜杠,浏览器请求时不带**:GUI 用 `` 提供服务,不带斜杠才会在子路径部署下解析到入口目录而不是站点根。 | 方法 | 路径 | 作用 | |---|---|---| | GET | `/overview` | 浏览器轮询的那一份文档:配置、今日明细、可用日期、供应商列表、当前余量提醒、版本号、补录状态 | | GET | `/day?date=YYYY-MM-DD` | 某个保留日的明细;非法日期返回 400 | | GET | `/range?from=&to=` | 一个统计窗口的合计、供应商明细与逐日序列;空串表示「一直回溯到保留边界」,`from > to` 返回 400 | | GET | `/session?id=` | 某个会话的累计用量,含它自己的分供应商、分模型明细 | | GET | `/providers` | 供应商余额与套餐快照 + 可探测路由目录 | | POST | `/probe` | 立刻重查一遍供应商(不等下一个周期) | | POST | `/backfill` | 立刻把 DSH 已保存的会话日志重放一遍(不等启动时那一次) | | POST | `/config` | 局部更新配置(有界 body + 逐字段校验) | | POST | `/refresh` | 落盘并返回一份重新组装的 overview | 窗口聚合(`/range`)**不在** `/overview` 里:它取决于用户选了哪段时间,不属于每 60 秒 轮询一次的那份文档。设置页打开时按所选范围单独请求,并跟着 `overview.updatedAt` 一起刷新。 `/probe` 是唯一会主动打供应商端点的路由,而且只在账户页打开时才被前端调用。 所有路由都过一道信任栅栏:回环地址(桌面端)直接放行,装了配对服务时已配对设备的 cookie 也放行,其余 403。响应一律 `cache-control: no-store`。 ### 余额与套餐的凭据从哪来 宿主端调 DSH 的 `credentials` 服务(`ctx.get('credentials')`),顺序是: 1. 记录 `llm-pi-ai/<路由>`:`kind === 'api-key'` 取 `key`;`kind === 'grant'` 取 `payload.access`(Codex 另取 `payload.chatgpt_account_id`)。 2. 否则读 `llm-pi-ai` 命名空间里该路由的 `apiKeyEnv`(DeepSeek 路由回退到 `llm-deepseek.apiKeyEnv ?? 'DEEPSEEK_API_KEY'`),再用 `credentials.resolve(名字)`。 3. 都没有 → `{kind: 'none'}`,界面显示「未配置 API Key」。 `credentials` 服务的 API 接受纯字符串(`credentialRef` / `credentialKey` 只是编译期品牌, 运行时就是原字符串),所以宿主端仍然不 import 任何 `@deepseek-ai/*` 包。 ### 历史补录的实现 DSH 的会话日志是 `$DSH_HOME/sessions/**/session.v4.jsonl.zstd`,**多个 zstd 帧拼在一起**, `zstdDecompressSync` 只解第一帧、流式解压会在第二帧报 `prefix_unknown`。所以 `lib/host/history.js` 按魔数 `28 b5 2f fd` 切帧后逐帧解压,再按行解析成与实时事件同形状的 信封,交给同一套折叠逻辑。补录落盘一个书签(`doc.history`),启动时只在没有书签时才扫全量; 扫到的每一个会话都复用 `seq` 重放保护,所以补录与实时流重叠也不会重复计数。 ### 回放与实时流如何不打架 补录在启动时跑,而实时事件同一时刻就到了,所以两条路径必须约定清楚,否则「补录」反而会把历史吃掉: - **扫描期间落地的实时事件先排队**(`this.deferred`),扫描结束的 `finally` 里再由 `drainDeferred()` 重放。不排队的话,一条实时事件会把 `doc.sessions[id].lastSeq` 推高,随后回放读到「`seq <= lastSeq`」就把**整份日志**当成已计过而丢弃 —— 插件启动前那一段历史永远补不回来,而补录存在的唯一理由就是它。 - **`seq` 守卫是按会话 id 的**,所以「整会话被复制成另一个会话」躲得过去。判别靠结算键:`settlementKeyOf` 用 DSH 的**消息 id**(`id:`)作键,没有 id 时退回 `时间+turn+step+供应商+模型`(`at:`);扫描时只有 `id:` 键参与「日志彼此包含」的判定,`at:` 键只用于日志内去重。用时间戳当键会把 legitimately 相同的两次调用判成副本(实测两次同样的调用在同一毫秒结算),用 id 不会。 - **发现副本时整份账本重建一次**,因为日桶无法事后分辨哪一半是副本;`replayGuardAt` 记住「检查过了」,之后不再重建。代价是日志已被 DSH 删掉的调用会在重建时丢。 - **样本槽的 `atMs` 不合法就丢弃这个槽**(`deserializeDocument`)而不置 0:置 0 会让下一次替换从「纪元 0 那个小时」里减掉旧样本,而日桶仍留着它,于是「某天 = 该天各小时之和」永久破裂且无法追溯是哪一次调用。 ### 价目表、峰谷价与上游抓取 金额是**读取时的视图**,不写进账本:`lib/core/pricing.js` 的 `costOfHourBuckets(hours, table, {atMs, holidays})` 每次查询都按当前表重算,所以改一个价格就改掉了它生效范围内的全部历史。价目表的形状是 `{模型: [行, ...]}`,每行带一个 `from` 生效日期(`''` = 从最早算起)——**涨价是加一行,不是改一行**, `priceOf(table, model, atMs)` 取 `atMs` 当时生效的那一行。用户填的行与内置行合并时按日期插入: 同一天的用户行覆盖内置行,更晚的日期把内置序列接长。 峰谷价:内置行带 `offPeak: true`,表示表里的数字是**空闲时段**价,工作日 9:00-12:00 与 14:00-18:00 按 `PEAK_FACTOR = 2` 计;用户自己填的行没有这个标记(他填的价就是他要的价)。 判定用**本机本地时间**——DSH 已经按本地日期归档,插件里再引入第二个时区是 bug 温床; 节假日来自 `lib/core/holidays.js` 的表,落在高峰窗口内的假日按空闲计(因为中国法定节假日没有 可计算的规则,只有一张每年公布的日历)。 `lib/host/upstream.js` 每天抓一次 DeepSeek 官方定价页、每月抓一次节假日表(`timor.tech` 的 公开接口),结果存 `upstream.json`。两条铁律:**解析失败一律返回 `{}`,绝不抛错**(抓取是附加 功能,不能拖垮启动);**`refresh()` 单飞且不覆盖已有的好数据**。解析器 `parseDeepSeekPrices` 先按 `` 切单元格、再按 `` 切行——反过来(先剥标签)会把行分隔符一起剥掉,这是写它时 踩到的坑。内置的 `BUILTIN_PRICES` 是**空闲**价,与 `offPeak: true` 必须一致,否则高峰时段会被 翻两倍。 ## 代码结构 ``` dsh-abacus/ ├─ package.json # 包契约:exports{"." , "./client"} + dsh{bundle,client} ├─ cordis.patch.yml # 一行 insert,把本插件挂进 profile 的插件名册 ├─ icon.svg ├─ lib/ │ ├─ index.js # 宿主端插件体:建服务、注册路由、跨 fiber 单实例保护 │ ├─ dsh-home.js # $DSH_HOME 解析 │ ├─ client.js # 浏览器端(ModuleLoader 格式,无构建步骤) │ ├─ core/ │ │ ├─ totals.js # 桶算术(emptyTotals / addTotals / totalTokens) │ │ ├─ document.js # 持久化文档 + 替换折叠 + 汇总 + 剪枝 + 反序列化 │ │ ├─ samples.js # 会话事件 → 样本(用量、归属、坐标、重试边界) │ │ ├─ adapters.js # 余额/套餐的端点表与纯解析函数(无网络) │ │ ├─ alerts.js # 余量提醒的阈值判定(纯函数,无 I/O) │ │ ├─ pricing.js # 价目表与金额折算(纯函数,无 I/O,钱是读时的视图) │ │ ├─ holidays.js # 中国法定节假日表与判定(纯函数,无 I/O) │ │ ├─ providers.js # 供应商显示名兜底表(纯装饰) │ │ └─ types.js # 跨线传输的形状(JSDoc typedef) │ └─ host/ │ ├─ usage-service.js # 账本服务:订阅、折叠、视图、配置、持久化、历史回放 │ ├─ probe-service.js # 供应商探测:凭据 → 请求 → 快照(失败保留上一次成功值) │ ├─ upstream.js # 定时抓 DeepSeek 定价页与节假日表,解析失败一律保留旧值 │ ├─ credentials.js # 凭据解析(记录 → 环境引用 → 无),纯字符串调 credentials 服务 │ ├─ history.js # 逐帧解压 DSH 的会话日志(拼接 zstd)并还原成事件 │ ├─ version.js # 从 package.json 读版本号,供界面显示 │ ├─ routes.js # 九个路由 + 统一的栅栏/405 处理 │ ├─ config.js # 默认值、范围、校验、局部更新 │ ├─ access.js # 回环 + 配对设备信任栅栏 │ └─ http.js # 有界 JSON body 读取 + JSON 写回 ├─ tools/ │ ├─ check-tokens.mjs # 审计客户端用到的 --dsw-* 主题 token 是否仍被 shell 定义 │ ├─ verify-served.mjs # 核验运行中的宿主端是否在服务当前这份 lib/client.js │ ├─ preview.mjs # 把真实组件渲染成静态页并截图,用于离线视觉评审 │ ├─ bump.mjs # 升版本:改 package.json、开一节本地 CHANGELOG、同步 README 徽章 │ └─ rollback.mjs # 一条命令退回改版前的标签 ├─ preview/ # preview.mjs 的产物(png + 缓存的 tokens.css;html 不入库)+ 说明各图的 README ├─ .github/workflows/ │ └─ test.yml # CI:每次 push 跑 npm test(不跑需要活宿主的两支脚本) └─ test/ ├─ harness.mjs # 假 DOM、假 hook 运行时、loadBundle 与夹具(测试与预览台共用) ├─ document.test.mjs # 纯折叠的单元测试(替换语义、跨午夜、剪枝、脏数据) ├─ service.test.mjs # 账本服务 + 路由的集成测试(真事件、真 handler、真落盘) ├─ probe.test.mjs # 适配器表、凭据解析、探测服务(注入假 fetch,不碰网络) ├─ alerts.test.mjs # 余量提醒阈值(0 = 关、低于才命中、读不到就不猜) ├─ pricing.test.mjs # 价目表解析与金额折算(未定价只报告、缓存写按输入价、改价改历史) ├─ history.test.mjs # 多帧 zstd 的解帧、事件解析、日志发现 ├─ client.test.mjs # 浏览器端的接线与渲染测试(手写 DOM + hook 运行时) ├─ plugin.test.mjs # lib/index.js 本身:九个路由、单次订阅、跨 fiber 挂载保护 └─ tools.test.mjs # tools/ 里各脚本的判定逻辑(纯函数 + 一次真仓库干跑) ``` ## 刻意的设计取舍 - **没有构建步骤**。`lib/client.js` 就是浏览器加载的那份产物,宿主端是普通 ESM。 代价是客户端不能写 JSX、也不能从其他文件 import;收益是「改完即生效」, 以及升级 DSH 时不需要重装工具链。客户端 CSS 以字符串形式内联注入一次。 - **没有 schemastery `Config`**。用宿主端设置表单意味着插件要跟着宿主设置面板的版本走, 而且用户改不了插件的私有状态。所以配置放在插件自己的 JSON 里,由 `POST /api/abacus/config` 编辑、由 `normalizeConfig` 每次校验。 代价:改配置不会重挂插件行(好处是服务与路由不会中断)。 - **只用 `--dsw-*` 语义 token 上色**,不写死颜色,所以跟随主题,也跟随用户皮肤对这些 token 的重映射。 - **左下角那张卡是 DOM 注入的**。侧边栏 foot 唯一的 slot(`sidebar.footer.action`)是 一行横向 action,放不下整块卡片。所以卡片是自带 React root 的独立容器,插在 foot 区 Settings 行之前,并由一个按帧合并的 body MutationObserver 在 shell 重渲染后重新就位。 - **「本次对话」胶囊走正规 slot**(`conversation.composer.dock`)。关闭态直接读该 slot 环境自带的 `useProjection('tokenUsage')`,所以**不需要任何 HTTP 请求**,也不可能和正文里 每轮的用量行对不上;**点开**时才按需向 `/api/abacus/session` 取这块对话自己的分供应商、 分模型明细。面板用 portal 挂在 `document.body` 上,样式与定位逻辑照着 shell 自己的 「上下文已用 N%」面板写(`--dsw-*` token 与同一套排版),所以它看起来是 GUI 的一部分。 悬停提示被刻意去掉了:一个重复面板内容、且只有一行字的 tooltip 是面板更差的副本。 - **余额与套餐没有本地估算**。这两个数字只可能来自供应商的接口,所以插件宁可显示 「为什么没有」也不显示一个猜出来的数:查不到就写原因,凭据没了就**丢掉**上次的事实 (而不是留着一个无法再验证的数字),请求失败则保留上一次成功值并标注可能已过期。 - **探测有独立的开关与周期**(`probeEnabled` / `probeIntervalSec`)。记账是本地的、 廉价的;探测是要出门的、可能被限流的。把两者绑在一个开关上,会让「我只想统计用量」 变成「我得允许插件打供应商端点」。 - **提醒在读取时判定,不写进快照**。红点是拿当前阈值去量当前快照算出来的,所以改完阈值 立刻生效、不需要重查一次供应商;也正因如此,红点不可能指向一个卡片上没有的读数。 代价是每次读取都要算一遍,但那是几个数的比较。 - **红点是唯一刻意偏离 shell 配色的地方**。它用的是 shell 的静态色 token (`--dsw-static-red-400`,带 `currentColor` 兜底),而不是语义 token —— 一个跟随主题 墨色的警示标记读起来不像警示。除此之外整份 CSS 只用语义 token。 - **金额是读取时算出来的视图,不写进账本**。参考实现(`@linxin666/dsh-usage`)在折叠每个 调用时就把 cost 盖进桶里;这里刻意不这么做,因为价目表是用户可编辑的 —— 把价格折进历史 意味着改一次价只影响以后的调用,而用户改价十有八九是因为之前填错了。所以 `costOfBuckets` 每次都拿当前价目表重新算。代价是每次读 `/range` 要遍历一遍窗口内的桶, 但那是几万次乘法,不是 I/O。 - **未定价的模型上报,不当 0 计**。把未知价格当成 0 会让合计看起来像免费的,比不给数字更糟。 所以 `costOfBuckets` 同时返回 `unpricedTokens` / `unpriced[]`,界面把它们单独说出来。 - **缓存写缺价时按输入价计**。多数供应商就是这么计的,而留一个空的缓存写字段更可能意味着 「没单独定价」而不是「免费」。 - **金额折算的范围在客户端收窄,不重新请求**。宿主发整棵树(每个供应商、每个模型、各自的 定价状态),`costScope` 在客户端切;否则切一次范围就要一次往返,而且总额、计费 token 数 与未定价提示有可能来自不同的响应而互相矛盾。 - **客户端的价目表解析是第二份实现**(客户端不能 require 插件代码)。这是被 `test/client.test.mjs` 用**同一串输入同时喂给两份实现并比对结果**钉住的:一旦两边分叉, 测试就红。 ## 开发 ```bash # 语法检查 node --check lib/host/usage-service.js # 测试(--test-isolation=none 让八个文件在同一个进程里跑; # `node --test test/` 会为每个文件 spawn 子进程,在受限沙箱里带管道 stdio 的 spawn 会 EPERM) npm test # 主题 token 审计:客户端用到的 --dsw-* 是否仍被正在运行的 shell 定义 # (shell 改名/删除 token 会让卡片静默裸奔,所以这条要能失败) node tools/check-tokens.mjs # 产物核验:正在运行的宿主端是否真的在服务当前这份 lib/client.js # (客户端是热替换的,但「我改了文件」和「页面跑的是改后的」是两件事) node tools/verify-served.mjs # 视觉评审:把真实组件渲染成 preview/*.html 并用无头 Chrome 截图 node tools/preview.mjs # 升版本:改 package.json、在本地 CHANGELOG 里开一节、同步 README 里的版本徽章 # (CHANGELOG.md 在 .gitignore 里:发布出去的仓库只有第一个提交,改动明细留在本机) npm run bump -- minor # 调试:让被吞掉的会话事件异常抛出来 export DSH_ABACUS_DEBUG=1 ``` `test/probe.test.mjs` 全部注入假 `fetch` 与假 ctx,**不碰网络**:供应商端点的解析、 凭据解析、以及探测服务的失败/过期语义都能在没有网络、没有凭据的机器上验证。 `test/tools.test.mjs` 测的是 `tools/` 里各脚本**做决定的那一半**,所以它们的判断不再只能 靠在装好的环境上跑一次来验证。凡是需要「正在运行的 DSH」的检查(`check-tokens`、 `verify-served`)都不在 CI 里:它们审计的是装好的 shell,不是这个仓库。 改 `lib/client.js` 后刷新页面(HMR 轮询约 0.5 秒);改宿主端任何文件后重启 DSH。 ## 预览台 改样式最难的一步是「看一眼」。桌面端的 DevTools 端口不对外,插件也拿不到应用窗口的 截图,所以 `tools/preview.mjs` 换了条路:用**测试台那套 hook 运行时**把真实的 `UsageSection` 与侧边栏那张卡渲染出来,前者套进一个明确标注为 mock 的设置对话框 外壳、后者套进一个 248px 宽的侧边栏替身,写成 `preview/*.html`,再用无头 Chrome 截成 `preview/*.png`(`usage` / `account` / `settings` / `foot` / `foot-collapsed`)。样式表 逐字取自 `lib/client.js`,`--dsw-*` 取值实时从正在运行的 shell 抓(拿不到时用 `preview/tokens.css` 缓存)。 它因此能看到真实排版,代价是**看不到 hover、焦点环、动画和真实滚动**,也不证明 插件在 DSH 里挂得上 —— 那是 `verify-served.mjs` 的事。 每张图的画布是**按内容量出来的**,不是固定尺寸:页面在 load 时把 `document.documentElement.scrollHeight` 写进 ``,`shoot()` 先 用 `--dump-dom` 读回这个值,再按它设窗口高度。以前所有截图共用一个 860×1120 的窗口, 侧边栏那张卡(248px 宽的一列)因此落在多半是空白的画布上,放进 README 里就成了一大段 空白间隔。 两个无头 Chrome 的坑,写在这里免得下次再踩: - **Windows 上窗口宽度有 500px 下限**。要 328px 宽的侧边栏截图,实际布局仍按 500px 走, 而 `--screenshot` 只截左上角你要的那块 —— 所以侧边栏替身是**左对齐**而不是居中的: 居中会把它推出被截取的区域,截出来只剩右半张卡。 - **量高度时要用很矮的窗口**(`contentHeight` 用 200px)。窗口给多高, `scrollHeight` 至少就是多高,量出来的会是窗口而不是内容。 样例数据里的时间是**写死的**(`FIXED_NOW`)、版本号是从 `package.json` 读的,所以连跑两次 得到的 `preview/*.png` 逐字节相同 —— `preview/*.png` 出现 diff 就说明设计真的动了,而不是 时钟又走了一格。这两处以前分别是 `Date.now()` 和写死的 `0.8.0`。 侧边栏替身里那几行会话标题是**编的**(`RAIL_ROWS`),不是任何人的真实对话列表:这些图会 进仓库、进 README,`test/tools.test.mjs` 把这个列表钉住,改它必须是有意为之。 `--client=<路径> --tag=<名字>` 可以渲染另一份 bundle,用来做前后对比: ```bash git show baseline-0.6.2:lib/client.js > /tmp/before.js node tools/preview.mjs --client=/tmp/before.js --tag=before ``` `--cache-write=` 给窗口塞进缓存写。只有 Anthropic 那类供应商会报这个数字, 所以默认样例里它是 0、那一格不显示 —— 想知道「有缓存写时长什么样」就得显式打开: ```bash node tools/preview.mjs --cache-write=860000 --tag=cache-write ``` ## 回退 改版前打了标签,不满意可以整份退回: ```bash node tools/rollback.mjs # 先看会撤掉哪些改动(不动文件) node tools/rollback.mjs --yes # 退回最近一次打标签的版本 node tools/rollback.mjs --tag=baseline-0.6.2 --yes # 或指定更早的标签 ``` 不带 `--tag=` 时退回到**最新的** `baseline-*` 标签(按版本号比大小,不是按字符串), 也就是「把上一次改版收回去」;想一路退到最初的设计就显式点名 `baseline-0.6.2`。 客户端是热替换的,退回后刷新页面即生效。想看差异: `git diff baseline-0.6.2 -- lib/client.js`;想再回到新版:`git checkout HEAD -- .`。 **`baseline-*` 标签只存在于本地仓库,刻意不推送到 GitHub**:它们是给「不满意就退回去」 用的工作标签,不是发布版本。远端只有 `main` 分支。 ## 仓库里为什么只有一个提交 发布出去的仓库看上去是**第一版**:一个 `main`、一个提交、没有 changelog、没有版本演进。 开发过程完整地留在本机,不随发布出去: | 留在本地 | 是什么 | | --- | --- | | `CHANGELOG.md` | 逐版本的改动明细;在 `.gitignore` 里,`tools/bump.mjs` 照常维护它 | | 分支 `history` | 真正的开发历史(所有中间提交) | | `baseline-*` 标签 | 每次改版前的回退点 | | `../dsh-abacus-history.bundle` | 上面三者的整包备份(`git bundle`,可异地还原) | 也就是说:`main` 是一个孤提交,只用来发布;想知道「这行代码是怎么变成现在这样的」就去 `history` 分支。新增改动时直接提交到 `main`(发布会把整棵树推上去),或者照下面的办法重新 压成一个提交: ```bash git branch -f history main # 开发历史先落地 git checkout --orphan fresh # 无父提交的新根 git add -A && git commit -m "Initial release " git branch -M fresh main ``` **推送前确认 `git log --oneline` 只有一行**;`git push --force origin main` 会重写远端, 这一步要先得到用户明确同意。 ## 为什么客户端有测试缝 `lib/client.js` 的末尾导出了 `exports.__internals`,里面是几个组件和纯函数。 模块加载器只读 `apply` / `inject` / `name`,所以这个面在浏览器里是惰性的; 它的唯一用途是让 `test/client.test.mjs` 能在 Node 里把真正的产物跑起来。 这不是洁癖。这一版最严重的一个 bug 就在这里:factory 往一个**从未声明过的 `exports`** 上写东西,于是浏览器一执行 bundle 就抛 `exports is not defined`,插件从未 apply, 三处界面全空 —— 而宿主端一切正常,日志干净,路由 200。宿主侧的任何测试都抓不到它。 现在那个测试文件会把手写的假 DOM、假 `require`、假 React hook 运行时塞给真实的 `lib/client.js`,断言 bundle 能执行、能导出 `apply`、`apply` 不抛、两个 slot 注册正确、 卡片插在 Settings 行之前、以及各组件的渲染输出。 ## 升级路径 - 账本文档带 `version` 字段。**加字段可以不改版本**(反序列化会忽略不认识的键); **改已有字段的含义必须同时加读取迁移**。 - **改名是一次破坏性升级**(`dsh-usage-stat` → `dsh-abacus`,2.0.0):插件 id、路由前缀 `/api/abacus`、状态目录 `$DSH_HOME/abacus/`、`localStorage` 键名全变了,旧装必须 `remove` 再 `add`。三处迁移都在启动时一次性完成,各自有测试守着: `adoptLegacyState`(状态目录复制,`test/service.test.mjs`)、`migrateStoredKeys` (旧键的值搬进新键、旧键不删,`test/client.test.mjs`)、以及 README 里写给用户的 迁移段。旧键/旧目录都**只读不删**,降级回 1.1.0 仍能找到自己的偏好与账本。 - 事件契约只依赖 DSH 稳定面:`session/event` 的 `assistant/message` / `assistant/attempt` / `request/header` / `request/context` / `llm/retry-started`,以及 `session.id`、`event.seq`、 `event.time`。这些都在 `@deepseek-ai/dsh-session` 的已知事件表里。 - 客户端只依赖三个**加载器已种下的**模块:`react` 的 `createElement`、`react-dom` 的 `createPortal`、`react-dom/client` 的 `createRoot`,外加 `slots` 服务。 **不从 `@deepseek-ai/dsh-client-*` 运行时 import**(DSH 明确不建议), 只把 `useProjection`、`sessionId` 当普通 props 用。假 `require` 会拒绝任何其他 id, 所以这条约束由测试守着。 - 主题 token 是唯一无法靠类型挡住的耦合,所以交给 `tools/check-tokens.mjs` 每次升级后跑一遍。 - 侧边栏落位依赖 shell 的 `[class*="sidebarCol"]` / `[class*="footArea"]` / `[class*="settingsArea"]` 三个真实存在的类名,对策是**有界重试 + MutationObserver 自我复位** (命中不了就退化到 foot 末尾,不崩)。 - 供应商端点表(`lib/core/adapters.js`)是**外部契约**,会随供应商改版而失效。 它的每一行都是纯数据 + 纯解析函数,所以改起来是改数据而不是改流程; 失效时的表现是那一行显示「查询失败:HTTP …」,而不是崩溃或显示错数。