dsh-composer-ux 0.6.2 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +571 -0
- package/README.md +153 -16
- package/cordis.patch.yml +92 -0
- package/lib/client.js +2559 -266
- package/lib/client.js.map +4 -4
- package/lib/index.js +2172 -26
- package/lib/index.js.map +4 -4
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
> separate model call before you send, automatic **`x-opencode-session` request-header injection** so
|
|
8
8
|
> OpenCode (Go) routes work inside DSH, and — on Windows — a **default-terminal switch** that replaces the
|
|
9
9
|
> model's PowerShell tool with **Git Bash** (git-anchored discovery, WSL excluded, per-session tool-surface
|
|
10
|
-
> trim, official-identical sandbox/approval/timeout semantics),
|
|
10
|
+
> trim, official-identical sandbox/approval/timeout semantics), a **composer stats-line upgrade** that renders the
|
|
11
|
+
> built-in cache-hit percentage with **three decimals** (keeping upstream's "never round a partial hit up to 100%"
|
|
12
|
+
> rule), a **cost capsule** whose peak/off-peak prices you can edit **per model** in settings (empty field =
|
|
13
|
+
> official list price; peak/off-peak is decided by **each usage event's own timestamp**, not by when you look
|
|
14
|
+
> at the panel), with **Chinese public holidays fetched automatically** (State-Council schedule via
|
|
15
|
+
> `holiday-cn`, GitHub raw + jsDelivr mirror, built-in table as offline fallback; your own date list always
|
|
16
|
+
> wins), plus a **one-click "restart DSH"** button
|
|
11
17
|
> in the settings-card header (market-proven self-restart: detached node helper, port-release wait,
|
|
12
18
|
> hidden-console relaunch, same-origin fence, boot-id reload).
|
|
13
|
-
> Install with `dsh plugin --profile <name> add
|
|
14
|
-
> ships in this repository, so there is **no build step and no build authorization**. License: MIT.
|
|
19
|
+
> Install with `dsh plugin --profile <name> add dsh-composer-ux` (npm) — the built `lib/`
|
|
20
|
+
> ships in the package **and** in this repository, so there is **no build step and no build authorization**. License: MIT.
|
|
15
21
|
|
|
16
22
|
DeepSeek Harness Web 输入体验增强插件:
|
|
17
23
|
|
|
@@ -19,21 +25,24 @@ DeepSeek Harness Web 输入体验增强插件:
|
|
|
19
25
|
|
|
20
26
|
> 上图即 0.5.0 的设置页(六栏都开着)。注意标题行**只有卡级开关与从属控件** —— 右键菜单那 7 个条目开关已按实测反馈搬进卡内的「自定义」档(见第 0 条)。
|
|
21
27
|
|
|
22
|
-
0. **两层开关:总开关 +
|
|
28
|
+
0. **两层开关:总开关 + 每栏开关**。设置页顶部「启用输入增强」是**总开关(默认开)**,它是一道总闸;八张折叠卡里**前七张**的标题行右端各有一个**卡级开关**,决定「这一栏要不要生效」(第 8 张「金额」没有开关 —— 金额胶囊常驻显示)。两处都满足才生效(`enabled && 该栏开关`)。前六栏默认**关**;第 7 栏「统计行」默认**开**(它是唯一例外,理由见下条)。
|
|
23
29
|
- **兼容性**:同一个产物同时支持 **DSH 0.1.6 与 0.1.7**。两代的设置服务形状不同(`settingsScope.bind` → `configForms.get`、宿主 `settings.register` 被删除、事件名换代),代码按**能力探测**分支,不维护两份产物。
|
|
24
30
|
- ⚠️ **升级 DSH 到 0.1.7 之前先备份 `~/.dsh/settings.yaml`**:0.1.7 的官方升级会把它改名成 `settings.yaml.imported` 并逐节导入 profile patch(一次性、不可逆),而本插件这一段在旧版上**导不进去**(那时还没有 Config 字段表),旧值只会留在那个 `.imported` 文件里。
|
|
25
31
|
- **老用户不会被升级弄坏**:卡级开关的默认值不是硬编码的 `false`,而是按「设置文档里有没有『你在用』的痕迹」迁移 —— 碰过的栏保持开着、没碰过的才是关;全新安装(空白文档)才是六栏全关。「快捷指令」那一栏多一条文件判据(0.3.0 起条目存在 `quick-prompts.json`,设置文档里看不出来)。
|
|
26
|
-
-
|
|
32
|
+
- **例外:0.7.0 的「统计行」是唯一默认开的栏**(它不参与上面那套"碰过才开"的迁移)。那五栏都**已经发布过**,一律默认关会在升级那一刻把用户正在用的东西当场关掉;而「统计行」是新能力,没有痕迹可依,用户要的是"装完立刻看得到效果"。`node test/check-sections.mjs` 把它单独列一行说明。
|
|
33
|
+
- **升级前可以先看一眼**:`node test/check-sections.mjs` 拿你真实的设置文档跑一遍迁移,打印七栏会变成什么(**只读**,不写任何文件)。文件名按版本试:0.1.7 把 `settings.yaml` 改名成了 `settings.yaml.imported`,两个都读。
|
|
27
34
|
- **关掉一栏 = 这一块完全不介入**,且栏内的值全部保留(打开即原样恢复):键位关 → 输入框按 DSH 原生键位;右键菜单关 → 本插件不介入;快捷指令关 → 输入框那枚按钮消失;设置面板关 → 不拖大小;OpenCode 请求头关 → 停止注入并撤销已写入的头;默认终端关 → 保持 PowerShell。
|
|
28
35
|
- 「OpenCode 请求头」那一栏**没有额外的开关**:原来的「附加请求头」本来就是「这一栏要不要生效」,直接搬到了标题行。
|
|
29
36
|
1. **设置 → 输入体验**(设置页新增条目)
|
|
30
|
-
- 版式对齐社区插件 `@linxin666/dsh-web-all` 的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名 `dsh-composer-ux` 的一行描述 +
|
|
37
|
+
- 版式对齐社区插件 `@linxin666/dsh-web-all` 的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名 `dsh-composer-ux` 的一行描述 + 总开关」;其下八个栏目为**可折叠卡片** —— 标题行左侧是「标题 + 一句动态概览 + 展开箭头」,右侧是**这一栏的开关与从属控件**(设置面板的缩放开关、默认终端的三档都在这里),长说明与其余控件在展开后的内容区;**默认全部折叠、不记忆展开状态**,可同时展开多个。标题行**只放卡级开关与从属控件**:右键菜单那 7 个条目开关按用户后来的反馈**搬回了卡内**(它们只对「自定义」档有意义,见下一条)。
|
|
31
38
|
- **键位**:分别配置「发送键」「换行键」——常用预设(Enter / Ctrl+Enter / Alt+Enter / Shift+Enter)+ 点击「自定义…」后直接按任意组合键录制(Esc 取消,Backspace 清除),支持清空为「无」;发送与换行不能设为相同按键;可一键恢复默认。
|
|
32
39
|
- **右键菜单**:三档「菜单来源」(官方不介入 / 浏览器菜单 / 自定义菜单,见下「右键菜单」一节);卡内**只显示当前选中那一档的说明**(点官方看官方的、点浏览器看浏览器的、点自定义看自定义的;自定义档还带 Chrome / Edge 与 Firefox 的剪贴板授权说明);那 7 个条目(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)**只在「自定义」档显示**、逐个开关(旁边一个「全部开启」)。这三件事有**真渲染测试**盯着(`node test/settings-render.mjs`:把设置页渲染成 HTML,断言三档正文互斥、那 7 行只在自定义档、标题行没有第二个入口)。
|
|
33
40
|
- **快捷指令**:分类增删改 / 条目增删改与上下移 / 跨分类移动 / 每条一个插入模式(关 · 每次 · 仅首次)/ 优化强度三档(详见下节)。
|
|
34
41
|
- **设置面板**:边缘调整大小一个开关(标题行);尺寸预设与拖拽说明在展开区。**导航列滚动由 DSH 官方的设置页自带**(0.1.7 起 `.navList` 就有 `overflow-y: auto`),所以 0.6.0 把「导航滚动」那个开关整项删掉了 —— 留着就是重复实现。
|
|
35
42
|
- **OpenCode 请求头**:给 OpenCode 的模型请求自动附加 `x-opencode-session`(详见下节)。
|
|
36
43
|
- **默认终端**:Windows 上把模型用的终端工具从 PowerShell 换成 Git Bash(三档在标题行;详见下节)。
|
|
44
|
+
- **统计行**:输入框下方那行「缓存命中 xx%」按三位小数显示(0.7.0 新增,**唯一默认开的栏**,只有一个开关;详见下节)。
|
|
45
|
+
- **金额**:输入框下方一颗极简金额胶囊(本会话费用估算,按 DeepSeek 官方刊例价;点开看三分项明细)。**这一栏没有卡级开关**(胶囊常驻显示),卡内是**按模型改高峰/空闲两档单价**的编辑器(0.9.1 新增;留空=沿用官方价,可自加任意模型名;详见下节)。
|
|
37
46
|
- **重启 DSH**:卡片抬头右端(GitHub 链接左边)那枚按钮,两步确认后原地重启(详见「维护:重启 DSH」)。
|
|
38
47
|
2. **快捷指令按钮**:输入框工具行里、「展开」按钮左侧的胶囊按钮,点开是常备提示词清单 + 「优化提示词」。
|
|
39
48
|
3. **键位生效**(仅主聊天输入框):默认值 = 现状(Enter 发送、Shift+Enter 换行、Ctrl+Enter 加速提交),改动即时生效并持久保存。
|
|
@@ -191,6 +200,103 @@ Windows 上 DSH 给模型的终端工具是 **PowerShell**(工具名 `pwsh`)
|
|
|
191
200
|
- **一个诚实的副作用**:会话内工具面会随档位变化,而**会话历史里可能还留着旧工具名**。如果模型按旧名字调用,会拿到 unknown tool 之类的错 —— 让它换成 `bash` 重试即可(卡片上也写了这句)。
|
|
192
201
|
- **非 Windows**:直接不接管(官方 bash 工具本来就在),卡片显示一行说明。
|
|
193
202
|
|
|
203
|
+
## 官方持久终端(`terminal_*` 六件套)
|
|
204
|
+
|
|
205
|
+
模型侧除了上面那个**一次性**的 `bash` 工具,官方还有一套**会话跨调用存活**的持久终端工具:`terminal_open` / `terminal_read` / `terminal_send` / `terminal_signal` / `terminal_close` / `terminal_list`(REPL、dev server、需要交互输入的程序靠它们)。这一档本插件**常驻挂载**,不需要任何设置项。
|
|
206
|
+
|
|
207
|
+
- **为什么需要"我们来挂"**:这 6 个工具的包 `@deepseek-ai/dsh-tool-terminal` 官方标为**可选**(`packages/terminal/tool-terminal/README.zh.md`:「需要选择启用」),没有任何 bundle 默认挂它;而且 **0.1.7-rc.2 的桌面版根本没随包发布它** —— `app.asar` 的 284 个 `@deepseek-ai` 包里有 `dsh-terminal`、`dsh-terminal-bash`、`dsh-api-terminal-controller`、`dsh-client-ui-sidebar-terminal`,唯独没有 `dsh-tool-terminal`(全盘无路径痕迹)。所以本插件把它写成自己的依赖(`package.json` 的 `dependencies`,区间 `>=0.1.7-rc.1 <0.1.8`),再由 `cordis.patch.yml` 里一行挂上。
|
|
208
|
+
- **要挂的是三件套,不是一个包**:`cordis.patch.yml` 里插三行 —— ① `@deepseek-ai/dsh-terminal`(提供 `ctx.terminals` 服务)② `@deepseek-ai/dsh-terminal-bash`(注册 `type=shell` 的 PTY 后端)③ `@deepseek-ai/dsh-tool-terminal`(那 6 个模型工具)。**缺一行都不行,而且必须在同一层。**
|
|
209
|
+
- **为什么不能只挂 ③(真机踩到的坑)**:③ 的源码里写着 `const inject = ["terminals", "tools", "systemPrompt"]`,而 ① 在官方组合里只出现在 `sdk-minimal` 与 web-app 的 **minimal 预设**里(`packages/bundle/web-app/presets/minimal.patch.yml` 的 `persistent-shell` 组),standard 预设和 profile 层都没有 —— 于是 ③ 在 desktop profile 这层**静默 pending**:不报错、不白屏、一个工具都不注册,只有模型工具表里空着。(这跟 0.6.2 白屏是同一形状,只是那次发生在客户端半、被启动审计抓住。)**「包能解析到 + 守卫放行」≠「它激活了」**——这条教训写在 `cordis.patch.yml` 顶部。
|
|
210
|
+
- **为什么官方放预设、我们放 profile 层**:官方 minimal 预设用 `cordis:group` + `isolate: { terminals: true }` 给**每个 agent 一份**服务;我们放 profile 层是一份共享服务,会话归属由 API 自己保证(`spawn(owner, …)` 按 owner 隔离、工具里 `requireAgent(exec.agent)`),功能等价。而且 **profile 层注册的模型工具能被 agent 看见** —— 同层的 `@changfenhuang/dsh-genui` 就是这么把 `render_ui` 送进工具表的。
|
|
211
|
+
- **③ 没随应用发布,所以要自己声明依赖**:`@deepseek-ai/dsh-tool-terminal` 在随包发布的那份 `app.asar` 里**不存在**(`dsh-terminal`、`dsh-terminal-bash`、`dsh-api-terminal-controller`、`dsh-client-ui-sidebar-terminal` 都在,唯独没有它;`dsh-web-app` 的依赖里也没声明 ⇒ 从没被安装过),所以写成插件自己的依赖(`package.json` 的 `dependencies`,区间 `>=0.1.7-rc.1 <0.1.8 || >=0.2.0-rc.1 <0.3.0` —— **两个窗口各带 pre 比较器**,否则 `0.2.0-rc.1` 这种 pre 版本用普通区间匹配不上)。① ② 则是**安装目录里就有**的官方包,按包名引用即可解析。
|
|
212
|
+
- **装的版本要跟「运行时版本」对齐,不是跟桌面壳版本对齐**:DSH 的兼容性预检(`plugin-compatibility.ts`)拿「包的 `@deepseek-ai/dsh-*` peer 区间 vs 运行时版本」比,不满足就**直接给这一行打 `disabled`**(patch 里看不出来,`plugin_manager list_plugins` 里是 `enabled:false / fiberPhase:null`)。而**桌面壳自称的版本不是运行时版本** —— 读 `app.asar` 里 `@deepseek-ai/dsh-app-boot` 的 `version` 才是(本机实测:壳写 `0.1.7-rc.2`,官方包全是 `0.2.0-rc.1`)。本机因此一度整行被静默判掉,换装 `dsh-tool-terminal@0.2.0-rc.1` 后立刻生效。
|
|
213
|
+
- **PTY 的 bash 必须排除 WSL**:`C:\Windows\System32\bash.exe` 与 `…\Microsoft\WindowsApps\bash.exe` 是 WSL 启动器,会把 `D:\x` 解释成 `/mnt/d/x`,与模型手里的 Windows 路径/工作目录全不兼容(上面「默认终端」栏也是硬排除它的)。探测里用 `path.basename/dirname` 判掉这两种;真机实测:排除前 PTY 起成 WSL bash(`pwd` = `/mnt/d/1zcode/dsh插件`),排除后是 Git Bash(`uname -a` = `MINGW64_NT-… Msys`、`pwd` = `/d/1zcode/dsh插件`、`grep`/`head` 都在 `/usr/bin`)。
|
|
214
|
+
- **守卫只能加在 ③ 上**:DSH 的兼容性预检只处理 **peer 版本冲突**,「包装不上 / 解析不到」它不管 ⇒ 会落到 Loader 导入失败 ⇒ 整棵树挂 ⇒ 白屏。所以 ③ 带 `!!js` 自检:解析不到就 `disabled: true`,而**加载器根本不会初始化 disabled 行**,于是「缺包」退化成「这个功能不存在」。① ② **不能**加同一个守卫 —— 守卫用 profile 目录的 `createRequire`,解析不到共享层里的包,加了会把服务永远禁掉。守卫的**基准**取 `DSH_HOME`(有就用)否则 `os.homedir() + '/.dsh'`,再扫 `profiles/node_modules` 与 `profiles/*/node_modules`;`ctx.get('profileContext')` / `DSH_PROFILE_DIR` 只当额外候选且各自 try/catch —— 不依赖 DSH 内部上下文更稳(**更正一句**:先前我把该行不出现归因于"基准取不到",后来证明真因是上面那条版本不匹配;基准改成这样属于防御性加固,不是那个 bug 的修复)。另两个坑:表达式必须用 `try/catch` 包住(`disabled` 抛错算「条目失败」,一样白屏);③ 不能带 `group: true`(会迫使加载器初始化 disabled 行)。
|
|
215
|
+
- **shellPath 得自己探**:官方后端默认 `shellPath: '/bin/bash'`(`dsh-terminal-bash/src/config.ts:54`,Windows 上解析不到),所以 ② 用同步 `!!js` 探测(PATH 上那份 git 反推 `…/Git/bin/bash.exe`,加 Program Files / LOCALAPPDATA / msys64 等落点);探不到就让 `shellPath` 留空并回落官方 pwsh 方言(`resolvePwshPath`)——与插件「找不到 bash 就保持 PowerShell」的策略一致。`timeoutMs` 对齐官方 minimal 预设的 300000。
|
|
216
|
+
- **升级 DSH 时要改那个区间**:`dsh-tool-terminal` 的 peer 精确锁 `0.1.7-rc.2`(7 个官方包)。DSH 换大版本后,要么把依赖区间放宽到新窗口,要么让它被预检禁掉 —— 后者只是这 6 个工具静默消失,不会白屏。`test/terminal-mount.mjs` 有一条断言盯着这个窗口。
|
|
217
|
+
- **与「默认终端」栏互补、互不依赖**:那一栏决定**模型用哪个 shell**(Git Bash / PowerShell),这一档决定**模型有没有持久会话**。档位选 `PowerShell`(插件完全不介入终端)时,这 6 个工具照常在。
|
|
218
|
+
- **客户端不用我们写一行**:官方客户端已经注册了这 6 个工具的 `tool.call.toolview` 渲染位(运行时 Slot 里可见),工具一亮就有官方样式的卡片。
|
|
219
|
+
|
|
220
|
+
## 统计行(缓存命中三位小数)
|
|
221
|
+
|
|
222
|
+
输入框下方那行用量统计里的「缓存命中 12%」,官方给的是**整数**;这一栏把它改成**三位小数**(`缓存命中 12.346%`)。
|
|
223
|
+
|
|
224
|
+
- **设置页位置**:设置 → 输入体验 → 「统计行」(0.7.0 新增的第 7 栏,**唯一默认开的栏**)。**只有一个开关**(标题行上那一个)—— 这一栏只有一件事可开可关,所以不另设子开关(与「OpenCode 请求头」那一栏同一处理)。
|
|
225
|
+
- **为什么会有这一栏**:社区插件 [dsh-cache-precision](https://github.com/Cheng-cheng9669/dsh-cache-precision) 提供了这条路子,用户看到后要求把同样的能力并进本插件。**能力移植、实现没有照抄** —— 下面每条都与它不同,且各有测试盯着。
|
|
226
|
+
- **数字口径与官方完全一致**:`缓存读 ÷(未缓存输入 + 缓存读 + 缓存写)`(三个桶互不重叠,与官方 `billedInputTokens()` 同一份)。所以屏幕上那两个百分比不会互相打架。
|
|
227
|
+
- **不撒谎(最重要的一条)**:官方的整数档有一条很少人注意的规则 —— 当整数四舍五入会把「没满」显示成 `100%` 时,它会**自动多给几位**(例如 `99.95%`),源码注释原话是 "without rounding a partial hit to 100%"。本插件把默认档抬到三位后**保留**了这条规则:三位小数一旦会凑成 `100.000%` 就继续加位。阶梯如下:
|
|
228
|
+
|
|
229
|
+
| 真实命中率 | 官方(整数档) | 本插件(三位档) | 直接 `toFixed(3)`(不采纳) |
|
|
230
|
+
| --- | --- | --- | --- |
|
|
231
|
+
| 12.3456% | `12%` | `12.346%` | `12.346%` |
|
|
232
|
+
| 99.995% | `100%` | `99.995%` | `99.995%` |
|
|
233
|
+
| 99.999% | `100%` | `99.999%` | `99.999%` |
|
|
234
|
+
| 99.9995% | `99.9995%` | `99.9995%` | **`100.000%`**(把没满说成满) |
|
|
235
|
+
| 真的满命中 | `100%` | `100.000%` | `100.000%` |
|
|
236
|
+
|
|
237
|
+
- 判据不是"我觉得",而是拿**官方源码当基准逐例对**:`test/stats-line.mjs` 把 DSH 的
|
|
238
|
+
`packages/client/ui-chat/src/client/chat/token-format.ts`(rc.1)那 4 个函数原样抄进来,
|
|
239
|
+
对 400 个分母全量 + 大分母边界 + 3000 组伪随机比对 `digits = 0` 档,**逐例一致**。
|
|
240
|
+
- **只改输入框下面这一行**(用户明确选择的范围):点开统计行的弹窗、每轮用量弹窗里的百分比保持官方原样
|
|
241
|
+
—— 那两个弹窗里数字是独立的 `<dd>12.3%</dd>`,与"整段必须就是 `缓存命中 xx%`"这条判据天然不冲突
|
|
242
|
+
(有 5 条"不许碰"的断言盯着)。顺带一句:弹窗自己用的是官方 1 位小数档,所以会有"下面 12.346%、弹窗 12.3%"的口径差,这是选择而不是漏改。
|
|
243
|
+
- **同步无障碍名字**:官方那颗胶囊的 `aria-label` 是 `` `${总数} · 缓存命中 12%` ``。只改可见文字的话,
|
|
244
|
+
**读屏用户听到的还是整数**,所以两处一起改(社区插件没管这一处)。
|
|
245
|
+
- **定位与作用面**:靠官方稳定属性 `[data-composer-stats]`(`StatsPills` 的根元素),**不靠 CSS Modules 类名**(那串带哈希,升级就会变)。
|
|
246
|
+
- 本插件自己渲染一个 `display:none` 的锚点落在**同一个** composer dock 里,由它往上找"同时装着我和统计行"的最近祖先(上限 6 层),`MutationObserver` 只盯这一小块。页面上有多个 composer 时也只动自己那一个。
|
|
247
|
+
- **刻意不跟社区插件的一处**:它在 `document.body` 上跑 TreeWalker + 观察器,等于给整个页面挂监听(每个流式 token 都会喂它)。本插件宁可功能不生效,也不留一个全页监听。
|
|
248
|
+
- **只改文本,不碰那一行的样式**。参考实现还给那一行写行内 `max-width` 想放宽 260px,**没有移植** —— 它的前提在 DSH 0.1.7 上不成立:`--dsh-chat-content-width` 只作用在消息列与输入卡片上(`.card` / `.composerHero`),而真正包着统计行的 `.dock` / `.composerStack` 没有宽度上限;`.composerHero` 又只在空白会话生效,统计行却只在活动会话里渲染,两者永不同时出现。所以那一行本来就能比聊天列宽得多,而参考实现那个上限反而比可用宽度小 —— 平时不生效、内容极长时还会**比官方更早截断**。完整证据链与撤掉的理由见 `src/client/stats-line.ts` 文件头与 CHANGELOG 的 `[0.7.0]` 节。
|
|
249
|
+
- **关掉会怎样**:把官方那一版**原样写回去**(三位小数退回官方整数档),不留半截状态。
|
|
250
|
+
- **护栏**:`node test/stats-line.mjs`(52 项)盯小数语义、两个字符串变换与"不许碰"的范围;`node test/stats-dom.mjs`(17 项)用**按官方源码复刻的假 DOM** 盯定位与改写范围(这两件坏事都是静默的),并断言"不写任何行内样式";两条都已进 `npm test`。
|
|
251
|
+
`test/mutation-guards.mjs` 的 `AF`–`AM` 八条变异盯"退化成 `toFixed(3)` / 判据放宽 / 默认改成关 / 扫全页 / 不同步 `aria-label` / 去掉层数上限 / 撤掉的加宽长回来 / 加宽常量重新引入"这八种退化。
|
|
252
|
+
|
|
253
|
+
## 金额(输入框下方显示本会话费用 + 设置页可改峰谷单价)
|
|
254
|
+
|
|
255
|
+
- **设置页位置**:第 8 张卡「金额」(**没有卡级开关**:胶囊常驻显示)。卡里分六块:**单价**、**节假日**、**峰谷提醒**、**账号余额**、**同步**、**计价说明**。
|
|
256
|
+
- **单价**:按模型改单价(未缓存输入 / 缓存命中 / 输出,元每 1M tokens)。**DeepSeek 系模型有高峰 / 空闲两档**(内置 `deepseek-flash`、`deepseek-v4-pro` 两行常显,官方现役就这两个;`deepseek-v4-flash` 等旧名是别名,不单列),**非 DeepSeek 模型只有一档「平坦价」**(它们没有峰谷概念,摆两档会让人以为谷价时段还能再省一半)。下面一行「再加一个模型」可以填**任意模型名**(中转/自建路由的名字),同名模型在不同渠道价不同时写成 **`provider:model`**(例如 `opencode:gpt-5.6-luna`)。
|
|
257
|
+
- **留空=沿用官方价**:输入框里的灰字占位就是官方价(DeepSeek 行来自官方价目表)。清空的传播是往上收的:一档三项全空 → 那一档消失;一个模型全部档位都空 → 那一整行消失;整表全空 → 设置里这个键直接删掉。
|
|
258
|
+
- **非法文本不静默清空**:`1,02`、`2元`、`-1` 会留在框里**标红**(`parsePriceText` 三态),不写进设置 —— 把它当"清空"的话,价格会悄悄回到官方价,而用户以为改成功了。
|
|
259
|
+
- 写入是**合并 + 认回声**:连改几格只发一次设置写入(300ms 合并);用规范化比对分辨"设置里来的新值"与"自己刚写进去的回声",否则回声会把还没写完的第二格编辑冲掉。「恢复默认」会清掉这张表与节假日/提醒/余额开关,但**不清同步来的价格档**(那是历史账单)。
|
|
260
|
+
- 改价**即时生效**(胶囊与明细页当场跟着变),不用刷新页面、不用重启。
|
|
261
|
+
- **长什么样**:不点击时是输入框下方一颗极简胶囊(`¥0.42`,与官方那行统计**同排、排在其后**,order 100);点开后是贴在它上方的浮层 —— 各 route 的 token 与花费、合计、**高峰档 / 空闲档各自的小计**、三分项(未缓存输入 / 缓存命中 / 输出)各自的 token 数与花费、缓存命中率、**峰谷状态行**(`空闲档 · 2 小时 15 分钟后转高峰`)、**两档生效单价**。
|
|
262
|
+
- **0.11.0 起浮层只留"数字 + 状态"**:原来最后三行静态说明(刊例价快照行 / 价格档段 / 峰谷判定段)搬去设置页的「计价说明」块。三行的去留各不相同:**峰谷判定段**在设置页本来就有一份同义文字,直接删(纯去重);**快照行**整行搬走,但"这份价是你自己填的"缩成单价行上的 `· 已自定义` 小标(只在你覆盖过价时出现);**价格档段**改成**只在非默认时出现** —— 本会话用到的档 ≠ 当前生效的档才显示。最后这条不能简单删:跨了 9-10 调价的会话,浮层上的单价是今天的价、金额却是当时的价,不说明反而是误导。用户 2026-09-29 的原话是"图里这些说明放到设置页面,使点开胶囊变得更简洁"。
|
|
263
|
+
- **字号与亮度照抄官方那颗胶囊,不自己定**:文字大小/行高取官方 `StatsPills.module.css` 里 **`.root`** 的两条(`calc(var(--dsh-content-font-size-secondary, 13px) - 1px)` 与 `calc(20px + var(--dsh-content-font-delta-secondary, 0px))`),亮度取 `.pill` 的"静止 `label-tertiary`、hover/展开 `label-secondary`"。**坑**:那两条在 `.root` 上,不在 `.pill` 上 —— `.pill` 里的 `font: inherit` 只是为了抵消 button 的 UA 字体;本条目是同一槽位里的另一条记录,不在那颗 `.root` 里,只写 `font: inherit` 会继承输入框那一层(默认 14px),真机上看就是"插件输入框下方的字比官方的大"(2026-09-28 用户反馈)。所以这里显式写 `.root` 的两条,并**不用 `font:` 简写**(简写会把字号重置回继承)。
|
|
264
|
+
- **为什么是浮层而不是就地展开**:输入卡片会裁掉溢出内容,官方那几颗胶囊(ContextMeter、时间胶囊)也都是浮层;这里用 React portal 挂到 `document.body`,位置由胶囊的视口矩形算出,点别处或按 Esc 关闭。
|
|
265
|
+
- **钱是怎么算的**:`未缓存输入 × miss价 + 缓存命中 × hit价 + 输出 × out价`(每 1M tokens)。`cacheWriteTokens` **不单独计价**(官方价表只有"缓存命中/未命中"两行输入价,且实测本机投影里恒为 0,所以它只进"计费输入"的显示、不进费用)。**峰谷按每笔用量真正发生的时间判定**,高峰价是空闲价的 2 倍;用户覆盖价由设置页「金额」栏提供。
|
|
266
|
+
- **峰谷规则(0.10.0 补全)**:北京时间**工作日** 09:00–12:00、14:00–18:00 为高峰(UTC 01–04、06–10);**中国法定节假日全天谷价**(官方规则明写"不含法定节假日");**周末全天谷价**——但注意这条是 2026-08-23(周日)00:00 起才生效的,**在那之前的周六周日仍有高峰时段**(2026-08-22 那个周六就是),所以历史账要按当时的规则算。**调休补班的周末仍按谷价**(官方说的是"周六、周日全天谷价",2026-10-10 那个周六也算谷价),所以自动获取只收"放假"那几天。
|
|
267
|
+
- **法定节假日自动获取(0.11.0)**:中国的法定节假日**算不出来**(清明/端午/中秋走农历,放几天、哪天调休补班是国务院办公厅每年年底才公布的政策决定),所以本插件的做法是"**日期表来自公开数据源,判定规则留在自己手里**":
|
|
268
|
+
- **数据源**:[`NateScarlet/holiday-cn`](https://github.com/NateScarlet/holiday-cn)(MIT)每年一个 `{year}.json`,条目形如 `{"name":"中秋节","date":"2026-09-25","isOffDay":true}`,并自带 `papers` 字段指向**国务院办公厅通知原文**(2026 那份指到 `www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm`)—— 数据可自行核对。主用 GitHub raw,失败自动退 **jsDelivr 镜像**(同一份文件的两个入口)。
|
|
269
|
+
- **覆盖范围**:**今年 + 明年**(按**北京**年判)。次年安排通常在前一年 11 月前后公布,所以**国务院一公布就会被自动取回**,不用等插件发版。2027 那份现在只有 `{"year":2027,"days":[]}` —— "还没公布"与"抓不到"是**两种不同的话**,界面分别说"20xx 年的安排还没公布"和"抓取失败"。
|
|
270
|
+
- **优先级:你手填的 > 自动获取的 ∪ 内置表**。手填**整份覆盖**(你的意思不该被后台悄悄改掉);自动那份与内置表**取并集**而不是替换 —— 自动获取只覆盖"今年+明年",到了 2027 年 1 月,2026 年中秋/国庆那几天就不在里面了,拿它替换会让**2026 年 9 月的旧会话被重新按高峰价显示(整整 2 倍)**。内置表(`DEFAULT_PEAK_HOLIDAYS`,2026 中秋 09-25~27、国庆 10-01~07)永远在兜底,离线也不会瞎算。
|
|
271
|
+
- **开关复用**「自动同步」那把(默认仍**关**):同一个语义"这个插件可以自己出网",没必要再加一个开关。打开后每个年份最多 **30 天**复核一次(进程启动先查一次 + 每 30 分钟查一次"到期没有"),没有到期的年份**一个请求都不发**。也可以随时点设置页「同步」里的「获取法定节假日」立即取一次(手动点击 = 明确的同意,不受 30 天窗口限制)。
|
|
272
|
+
- **纪律**:抓不到就什么都不改(两个入口都失败 → 报失败、本地表原样不动);抓到并写入设置后立刻 `invalidateMoney()`(不然后台取回新表、峰谷却仍按旧表判 —— 折叠结果里固化的是判定当时的事实)。
|
|
273
|
+
- **设置页看得见什么**:节假日那块显示「当前生效:N 个日期 —— 来源:自定义 / 自动获取(含获取时间)/ 内置」,并有一个可展开的**生效日期清单**;「同步」区显示"法定节假日上次获取:… (已有 2026、2027 年)"。输入框里**只放你手填的那一份**,留空才代表"用自动获取的"。卡片的标题行概览也会写 `节假日自动 N 天`。
|
|
274
|
+
- **价格历史档:旧账按当时的价固定(0.10.0)**:官方 2026-09-10 12:00(北京)调过 Flash 的价(未缓存输入 0.22→0.15、输出 0.66→0.60、命中 0.007→0.003,美元/1M)。如果一律用"当前价"重算,8 月跑的会话金额会跟着变、跟当时的真实账单对不上。所以价目表按**生效时刻**分档(`legacy` 峰谷制之前 / `peak-2026-08` / `flash-2026-09-10`),折叠时给每条用量打上那一刻的档位 id,route key 因此多一维:同一模型在调价前后是**两条**分列,各按当时的价结算。Pro 那次**没调价**(腾讯云公告只列了 Flash 两个型号,官方页今天仍单列 Pro 价),所以它两档同值。
|
|
275
|
+
- **峰谷为什么必须按事件时间判(0.9.1 修的既有缺陷)**:官方按**请求发生时刻**计费,而 0.8.0 拿"你打开面板的那一秒"当所有用量的时刻 —— 昨晚(空闲档)跑的会话今天上午 10 点看,整份会按高峰价显示,差 2 倍而屏幕上只是个数字。现在宿主半折叠时用每条事件的 `time` 判档、按 `(provider, model, 档位, 价格档)` 拆桶,**同一步的替换增量沿用该步第一次判定的档**(否则一次跨过 09:00 的请求会被拆成两档、凭空多出一个"高峰用量");事件既没有 `request/header` 时间、自己也没有 `time` 时,判定函数收到 `NaN`,宿主半退回"现在"(=旧行为),绝不静默判成空闲档。
|
|
276
|
+
- **峰谷提醒(0.10.0)**:明细页常显一行「现在哪一档、还有多久切换」(例如 `空闲档 · 2 小时 15 分钟后转高峰`,因节假日/周末而谷价时会写明原因),胶囊的 `title` 也带上;设置里可调**提前几分钟**(1–60,默认 5)、**进峰前/离峰前**分别提不提醒,以及要不要额外发**浏览器系统通知**(需要授权;去重放在**模块级**按切换点记 —— 放组件里会因为设置一变、组件重挂而连发几条同样的通知)。相位规则与宿主半**同源**(节假日表由 `/composer-ux/usage` 回给客户端),不会出现"胶囊说还有 3 分钟进峰、面板说不是"。
|
|
277
|
+
- **账号余额(0.10.0)**:查官方 `GET https://api.deepseek.com/user/balance`。**API Key 只在宿主半**读:从 `llm-deepseek` 那一行的 `apiKeyEnv`(默认 `DEEPSEEK_API_KEY`)经 `credentials.resolve()` 取,与官方适配器同一条路,浏览器拿不到。**安全底线**:端点白名单只放行 `api.deepseek.com`(拒子域名、伪装域、`http://`、非 443 端口),baseURL 被指向第三方时**一个请求都不发**、也**不把 baseURL 原文回给浏览器**(只回主机名)。余额总额用分项相加(`granted + topped_up`,不用平台自己的 `total_balance`);拿不准就不显示(不显示 0、也不显示旧值)。
|
|
278
|
+
- **一键同步(0.10.0 两条路,0.11.0 加第三条)**:卡里三个按钮,代价差两个数量级所以分开:
|
|
279
|
+
- **同步官方价**:抓官方中英文两页(各约 24 KB)解析成人民币/美元两列,**只有和当前档不同才新增一个价格档**(原地改档 = 把历史账重算)。解析器对着真页面夹具逐项钉住(`test/official-pricing.mjs`,45 条)。
|
|
280
|
+
- ⚠️ **2026-09-29 事故与修法**:官方英文价格页在**不带结尾斜杠**的路径上开始返回**另一页**(200 OK、4.8 万字节的"快速开始"内容、没有价格表),于是同步直接报失败。好在纪律是"抓不到就不覆盖本地价",**只是同步不工作、没有算错钱**。修法:两个 URL 都补结尾斜杠,并新增 `officialPricingCandidates()` —— 每页按"带斜杠 → 不带斜杠"依次试,**解析成功才算到手**;站点以后再对调两个形状,同步不用改代码就能自愈(变异条目 `CL`/`CM`)。
|
|
281
|
+
- **同步第三方价目**:抓 [models.dev](https://models.dev) 的 `api.json`(**5.2 MB**、215 个 provider、7831 个模型),压成约 **450 KB** 落 `$DSH_HOME/storages/composer-ux/prices.json`(设置文档里只留"什么时候同步的、多少条",不然 settings.yaml 要被撑大)。models.dev 里的 DeepSeek 行**整块丢掉** —— 它只有平坦的谷价、没有峰谷与历史档语义,留着迟早被谁误用成"DeepSeek 单价",结果是所有峰价被静默算成谷价。
|
|
282
|
+
- **获取法定节假日**(0.11.0):抓 `holiday-cn` 的"今年 + 明年"两个 JSON,只收 `isOffDay:true`。手动点击 = 明确的同意,不受 30 天复核窗口限制。
|
|
283
|
+
- **自动同步**(默认**关**):勾上后宿主半每天最多自动抓一次官方价格页,并**每个年份最多 30 天复核一次节假日**(进程启动先查一次 + 每 30 分钟查一次"到期没有")。"该不该出网"由纯函数判定(`autoSyncDue` / `holidaySyncDue`):开关不是真 `true`、时间读不到、距上次成功不够久 —— 都不发请求。失败只写日志,界面仍显示上次成功的时间。
|
|
284
|
+
- 三条都守同一条纪律:**抓失败绝不覆盖本地数据**(网络抛错 / 非 2xx / 正文过短 / 只有一页成功 / 节假日两个入口都失败,各种情形都有断言)。
|
|
285
|
+
- **写完价目要立刻生效,靠的是"写的人通知",不是设置变更事件**:0.1.7 的 `settings/document-updated` 只在 `describe()` 里比对 raw 变化后发出,而我们自己 `mutate` 写设置时并不调 `describe()` —— 设置页开着时会被界面刷新顺带触发,**设置页没开着**(例如后台的自动同步跑完)就不会发;而折叠缓存按 `seq` 去重、改规则不会重折旧事件,金额会**静静地停在旧价格档**上、界面上看不出。所以宿主半加了模块级 `moneyInvalidators` 注册表:用量路由登记"重读规则 + 作废价目缓存 + 作废折叠缓存",两条同步路径写完设置直接调 `invalidateMoney()`,事件只作补充。
|
|
286
|
+
- **内置第三方价目快照(0.10.0)**:`src/provider-prices.ts`(**生成**的,别手改:`node scripts/gen-provider-prices.mjs <models.dev-api.json>`)收了 **11 个 provider / 346 个模型**,快照日期写在文件头。**没点过同步时**就是非 DeepSeek 模型的兜底价;点过一次「同步第三方价目」后最新数据会盖住同名条目,界面分别标明"内置快照价"还是已同步。查价优先级:**用户覆盖价 > 已同步价目 > 内置快照 > 未定价**。
|
|
287
|
+
- **非 DeepSeek 模型定价(0.10.0)**:按 `(provider, model)` 查价 —— 精确 provider → `PROVIDER_ALIASES` 别名(`deepseek-official`→`deepseek`、`kimi-coding`→`moonshotai`…)→ **按模型 id 全局唯一匹配**(`glm-5` 在两家都有的价时**拒绝猜**)。**认不出价就说"未定价"**(单价全 0,明细页写明"去同步第三方价目或给这行填个价"),**不再把 DeepSeek 的 flash 价静默套到第三方模型头上** —— 编一个看着合理的假数字,比承认不知道更糟。
|
|
288
|
+
- **为什么金额必须自己算**:DSH 送到浏览器的 `tokenUsage` 投影**只有 token 桶**,全库没有一处把"钱"送到客户端;`llm-pi-ai` 里那个 `cost` 只活在 provider 内部,而且用户自定义的路由(profile 里手写的 provider)拿到的是 `NO_COST`(全 0)。所以费用只能由本插件按刊例价算。
|
|
289
|
+
- **数据全部来自官方、不新增采集**:token 桶读 `tokenUsage` 投影(与官方统计行**同一份**),模型读 `modelSelection` 投影(来自 `request/header` 事件)。会话中途换过模型时按**最后一次请求**的模型计价(近似,浮层里写明)。
|
|
290
|
+
- **按 route 分列与"逐笔准时"的取数**(点开才去问宿主半;折叠态那颗胶囊也走同一条路由):面板里列出每条 route(provider + model + 高峰/空闲)的 token 与花费,**金额由宿主半按档算好**(用户覆盖价一并读自设置),所以"各行加起来等于总额""三项相加等于合计"都精确成立。逐请求归因只能宿主半做,走只读路由 `GET /composer-ux/usage?sessionId=…`(`src/host.ts` 注册):首次(或发现 `seq` 落后)用官方 `sessionQuery.readSession(sessionId)` 完整读一次,之后靠 `session/event` 订阅**增量**喂折叠缓存(`createUsageCache`,纯逻辑在 [src/usage-fold.ts](src/usage-fold.ts))—— 所以胶囊取价是 O(1),不必反复重读整份日志。**播种窗口**(读日志期间追加的事件)会先缓冲、读完按 `seq` 补上:不这么做的话水位会跳过快照里那些中间事件,金额**永久少算**(`feed()` 的 seq 幂等,补也补不进去)。**事件形状按本版官方 `SessionEventMap` 的真实声明**(`assistant/attempt` 的 `stream` 里取最后一条 usage 块,或 `assistant/message` 的 `data.usage`;**本版没有 `assistant/chunk` 事件**),口径对齐官方 `token-meter` 的 `usage-projection`(同一步替换、`llm/retry-started` 重开替换槽)。`test/usage-fold.mjs`(89 项)逐条钉住差分、归属、重试、脏数据、逐笔分档与增量缓存;分列出不来时面板会显示"读了 N 条事件、M 条 usage、来源哪条路",一眼就能定位。
|
|
291
|
+
- **折叠态那个数字怎么来的**:客户端投影只有累计 token 桶、**没有任何时间信息**,所以"逐笔准时"的数字只能从宿主半拿(`src/client/session-cost.ts`):最短 600ms 间隔、期间有变化排一次**尾随**请求(保证流结束后的数字是准的)、同一时刻只有一个请求在飞、旧响应按序号丢弃。宿主半那份还没到手、或会话日志与投影对不上时,退回本地的"按当前档位估算"并在面板里说明是哪种口径 —— 胶囊从不空着。
|
|
292
|
+
- **命中率与旁边那行必然是同一个数**:面板只在"分列与投影**四个桶全等**"(`agreesWithProjection` 是精确比较)时才把分列当权威,此时分列算出的命中率与官方胶囊逐位相同;否则退回投影口径。**两个坑**:客户端投影的未缓存输入叫 `uncachedInputTokens`、日志里叫 `inputTokens` —— 第一版在 `billedInputTokens()` 里认对了名字,却在**调用处**传了 `inputTokens`,于是分母丢掉整块未缓存输入、命中率恒 100%(旁边官方胶囊 98.206%)。现在调用处也走官方键名,`test/client-registration.mjs` 同时钉住"调用处键名"与"与输入框下面那一行同一套函数"。
|
|
293
|
+
- **计价按 `(provider, model)`**(0.10.0 起):DeepSeek 路由(provider 名含 `deepseek` 或模型名以 `deepseek` 开头)走官方价目表与历史档;其它 provider 走同步来的第三方价目,认不出就写"未定价"。所以用 OpenCode(Zen)之类的接口跑 DeepSeek 模型,用量仍按 **DeepSeek 官方价**进这个金额;跑非 DeepSeek 模型则按 models.dev 的价估算。代价如浮层所写:Zen 可能有自己的加价/订阅,这个数字不等于你付给 Zen 的钱。
|
|
294
|
+
- **别名与历史档的出处**:`deepseek-v4-flash` / `deepseek-v4-flash-vision-exp` / `deepseek-v4.1-flash` / `deepseek-chat` / `deepseek-reasoner` **全部**按 `deepseek-flash` 计价(前三个是官方脚注里"旧名仍可调用、由 V4.1-Flash 服务并按 Flash 价计费";后两个是官方 2026-04-24 更新日志写的"分别指向 v4-flash 的非思考 / 思考模式",**与 v4-pro 无关**,且已于 2026-07-24 停用)。三个历史档的出处逐条写在各档的 `source` 字段:`legacy`(峰谷制之前)的数与 2026-08-13 涨价公告的"涨前价"逐项对上(Flash 命中 0.02 / 未命中 1 / 输出 2,Pro 命中 0.025 / 未命中 3 / 输出 6),`peak-2026-08`(2026-08-17 0 时北京起)与 `flash-2026-09-10`(V4.1-Flash 上线同日降价)对应官方更新日志的两个条目。
|
|
295
|
+
- **真实日志里会出现哪些 provider 名**(2026-09-29 拿真会话核对):本机见过 `go`(自建别名,baseURL `opencode.ai/zen/go/v1`)、`opencode-go`(DSH 内置)与 **`deepseek-account`**(桌面端账号通道)。三个都按**官方价目**算 —— 判据只是"名字含 `deepseek` 或模型名以 `deepseek` 开头",与具体名字无关,所以不需要额外配置。
|
|
296
|
+
- **出处与对拍**:价目表、模型别名、峰谷规则与格式分档最早来自 [`dsh-plugin-usage-meter`](https://github.com/fancr-code/dsh-plugin-usage-meter) 1.9.1(**MIT**,Copyright (c) 2026 fancr-code);0.10.0 起**人民币列改用官方人民币页原值**,历史档 / 节假日表 / 周末生效点与 [`dsh-cost-meter`](https://github.com/Han-1413141/dsh-cost-meter) 1.7.44 交叉核对过。`node test/pricing.mjs` 把那份实现的几个函数**原样抄进来当基准**逐样本对拍:刊例价表逐项、峰谷判定(含节假日与周末边界)、定价解析(模型 × 币种 × 时段 × 覆盖价)、费用、金额、Token 格式,外加历史档与"节假日对金额的影响"两节(同一笔用量在国庆当天与普通工作日相差恰好 2 倍)。
|
|
297
|
+
- **新增的三条宿主路由都过了官方那道关卡**:`connection.requestRejection(request)`(Host/Origin 围栏 + 浏览器登录令牌)。`webServer` 是可以绑 `0.0.0.0` 的,漏掉它等于把会话用量、设置写入与**账号余额**摊给同网段任何一台机器 —— 2026-09-29 评审时发现并补上(此前只有重启路由挂了这道关卡)。
|
|
298
|
+
- **与出处有意不同的两处**(都钉在测试里):① 金额**至少保留两位小数**(出处会把 `0.1` 显示成 `¥0.1`、把 `0` 显示成 `¥0.`);② **覆盖价的币种语义**——出处是"有覆盖价时先整体重建人民币档、美元再从人民币折算",写成"在美元列上套覆盖价再折算"会差一个汇率(≈15 倍)而屏幕上只是个数字,所以专门有一条断言守着。
|
|
299
|
+
|
|
194
300
|
## 维护:重启 DSH
|
|
195
301
|
|
|
196
302
|
装了新插件、改了宿主半代码(比如本插件的「默认终端」、键位 schema)之后,DSH 需要重启才会加载新代码。设置页「输入体验」卡片**抬头右端**(GitHub 链接左边)有一枚「重启 DSH」。
|
|
@@ -224,10 +330,21 @@ dsh-composer-ux/
|
|
|
224
330
|
├── cordis.dev.patch.yml # 本地开发覆盖层(file:/// 绝对路径,已 gitignore,不随包发布)
|
|
225
331
|
├── CHANGELOG.md # 版本更新日志
|
|
226
332
|
├── build.mjs # esbuild 构建:lib/index.js(Host)+ lib/client.js(浏览器),并做发行后处理
|
|
333
|
+
├── scripts/
|
|
334
|
+
│ ├── gen-provider-prices.mjs # 从 models.dev 的 api.json 生成 src/provider-prices.ts(内置第三方价目快照)
|
|
335
|
+
│ ├── live-smoke.mjs # 手动联网复核:官方页 / models.dev / 余额端点 / 节假日数据源(只读;不进 npm test)
|
|
336
|
+
│ └── dsh-shape-check.mjs # 手动形状核对:我们依赖的宿主服务名/方法/事件是否还在(对着 DSH 源码检;只读)
|
|
227
337
|
├── src/
|
|
228
|
-
│ ├── host.ts # Host
|
|
338
|
+
│ ├── host.ts # Host 半:settings namespace + 请求头镜像 + 「默认终端」+ 金额三条路由(用量/价目同步/余额)+ 「重启 DSH」
|
|
229
339
|
│ ├── settings-contract.ts # 字段/默认值/菜单元数据 + 栏开关的迁移判据(零依赖共享)
|
|
230
340
|
│ ├── restart.ts # 「重启 DSH」:助手进程/等端口/隐藏控制台/信任关卡/优雅退出(零 import)
|
|
341
|
+
│ ├── pricing.ts # 金额:历史价档 / 节假日与峰谷判定 / provider 感知定价 / 覆盖价 / 费用与格式(零 import,两半共用)
|
|
342
|
+
│ ├── usage-fold.ts # 金额:会话事件按 (provider, model, 峰谷档, 价格档) 归因折叠 + 增量缓存(零 import,宿主半用)
|
|
343
|
+
│ ├── official-pricing.ts # 金额:官方价格页 HTML → 人民币/美元两列价(真页面夹具逐项钉住,零 import)
|
|
344
|
+
│ ├── price-sync.ts # 金额:抓官方两页合成价格档 + models.dev 压成第三方价目 + 落盘 + autoSyncDue(宿主半,允许 node API)
|
|
345
|
+
│ ├── holiday-sync.ts # 金额:法定节假日自动获取(holiday-cn 今年+明年 → 日期表;补班日不收;到期判定/镜像兜底/失败不改动)
|
|
346
|
+
│ ├── provider-prices.ts # 金额:内置第三方价目**快照**(生成物,11 provider / 346 模型;由 scripts/gen-provider-prices.mjs 生成)
|
|
347
|
+
│ ├── balance.ts # 金额:官方余额响应消毒 + 查询端点白名单(零 import,两半共用)
|
|
231
348
|
│ ├── client.tsx # Browser 半:设置页 + 菜单浮层 + 拦截器
|
|
232
349
|
│ ├── terminal/ # 「默认终端」(Windows:pwsh → Git Bash),全部零官方运行时依赖
|
|
233
350
|
│ │ ├── discover.ts # 探测:git 锚定反推 bash、硬排除 WSL、多候选排序(纯函数)
|
|
@@ -239,9 +356,17 @@ dsh-composer-ux/
|
|
|
239
356
|
│ └── client/
|
|
240
357
|
│ ├── chords.ts # 键位编码/录制校验
|
|
241
358
|
│ ├── interceptors.ts # keydown/contextmenu 捕获拦截 + 回放
|
|
242
|
-
│ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目 +
|
|
359
|
+
│ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目 + 默认终端 / 统计行卡片)
|
|
243
360
|
│ ├── settings-style.ts # 折叠卡片样式表(dsh-ux-* 类名注入)
|
|
244
361
|
│ ├── ContextMenuHost.tsx # shell.overlay 右键菜单
|
|
362
|
+
│ ├── stats-line.ts # 统计行:小数语义(不撒谎)+ 两个字符串变换(纯逻辑)
|
|
363
|
+
│ ├── stats-dom.ts # 统计行:定位与改写(DOM 助手,不 import React)
|
|
364
|
+
│ ├── StatsLineEntry.tsx # 统计行:composer dock 上的隐形条目 + 观察器(React 胶水)
|
|
365
|
+
│ ├── CostChipEntry.tsx # 金额:composer dock 上的金额胶囊 + portal 浮层明细(React 胶水)
|
|
366
|
+
│ ├── session-cost.ts # 金额:向宿主半取"逐笔准时"的费用(节流 + 尾随 + 单飞)
|
|
367
|
+
│ ├── money-admin.ts # 金额:价目同步与余额查询的宿主调用封装(两条独立路径)
|
|
368
|
+
│ ├── peak-alert.ts # 金额:峰谷相位、倒计时文案与(可选)系统通知
|
|
369
|
+
│ ├── CostCard.tsx # 金额:设置页那一栏(单价 / 节假日 / 峰谷提醒 / 余额 / 同步价目)
|
|
245
370
|
│ └── styles.ts # --dsw-* 令牌内联样式
|
|
246
371
|
├── docs/settings-panel-0.5.0.png # README 顶部那张设置页截图(用 tag 固定的 raw 链接引用,不进 npm 包)
|
|
247
372
|
├── test/
|
|
@@ -250,11 +375,20 @@ dsh-composer-ux/
|
|
|
250
375
|
│ ├── quick-commands.mjs # 快捷指令 / 优化接口 / 路由注册 / 重启机制
|
|
251
376
|
│ ├── quick-store.mjs # 快捷指令文件存储与写回路径
|
|
252
377
|
│ ├── terminal-policy.mjs # 默认终端:探测 / 渲染 / 升级审批 / bash 工具 / 宿主半接线
|
|
253
|
-
│ ├──
|
|
378
|
+
│ ├── terminal-mount.mjs # 官方持久终端三件套:挂载行结构 + 用加载器同一个求值器真跑 !!js 探测与守卫
|
|
379
|
+
│ ├── stats-line.mjs # 统计行:小数语义(与官方源码逐例对)/ 文本与 aria-label 变换 / 开关
|
|
380
|
+
│ ├── stats-dom.mjs # 统计行:假 DOM 钉住定位、改写范围与"不碰样式"
|
|
381
|
+
│ ├── pricing.mjs # 金额:与出处实现(MIT)原文逐样本对拍 + 有意差异 + 历史档与节假日
|
|
382
|
+
│ ├── usage-fold.mjs # 金额:按 route 归因的折叠规则(差分 / 归属 / 脏数据 / 价格档 / clear())
|
|
383
|
+
│ ├── price-sync.mjs # 金额:价目同步的消毒与四条失败路径(抓不到绝不覆盖本地价)
|
|
384
|
+
│ ├── official-pricing.mjs # 金额:官方价格页解析(真页面夹具,45 条)
|
|
385
|
+
│ ├── balance.mjs # 金额:余额响应消毒(80 条)+ 端点白名单(伪装域逐例)
|
|
386
|
+
│ ├── client-registration.mjs # 客户端注册协议、右键行为、抬头按钮、七栏开关、金额栏护栏与设置写入校验
|
|
254
387
|
│ ├── settings-service-adopt.mjs # 客户端两代设置服务认领(settingsScope / configForms / 都没有)
|
|
255
|
-
│ ├── mutation-guards.mjs #
|
|
256
|
-
│ ├── settings-render.mjs #
|
|
257
|
-
│ ├──
|
|
388
|
+
│ ├── mutation-guards.mjs # 变异测试(手动跑):把每条护栏拆掉,测试必须变红
|
|
389
|
+
│ ├── settings-render.mjs # 真渲染测试:借 profile 的 react 把设置页渲染成 HTML(已进 npm test)
|
|
390
|
+
│ ├── cost-panel-render.mjs # 真渲染测试:同样的手法渲染「金额浮层」(把 portal / 展开态 / 宿主数据在内存里替换掉)
|
|
391
|
+
│ ├── check-sections.mjs # 升级前自查(只读):拿真实设置文档跑一遍栏开关迁移(settings.yaml / settings.yaml.imported 都试)
|
|
258
392
|
│ └── opencode-header-wire-probe.mjs # 线级探针:本地端点,用来看 DSH 出网请求带了什么头
|
|
259
393
|
└── lib/ # 构建产物(运行所需)
|
|
260
394
|
```
|
|
@@ -263,10 +397,13 @@ dsh-composer-ux/
|
|
|
263
397
|
|
|
264
398
|
```sh
|
|
265
399
|
node build.mjs # 产出 lib/index.js + lib/client.js
|
|
266
|
-
npm test #
|
|
267
|
-
node test/mutation-guards.mjs #
|
|
268
|
-
node test/settings-render.mjs #
|
|
269
|
-
node test/
|
|
400
|
+
npm test # 18 个套件;当前 1736 passed, 0 failed(2026-09-29 实测)
|
|
401
|
+
node test/mutation-guards.mjs # 手动跑:变异测试,证明那套护栏真的在咬人(89 条,须单独跑)
|
|
402
|
+
node test/settings-render.mjs # 已进 npm test:把设置页真渲染成 HTML,断言版式与互斥显示、以及"非默认设置"下的「金额」卡(78 条)
|
|
403
|
+
node test/cost-panel-render.mjs # 已进 npm test:把「金额浮层」真渲染成 HTML,断言分列 / 峰谷行 / 未定价 / 内置快照价 / 明文没混进 markdown 记号(19 条)
|
|
404
|
+
node scripts/live-smoke.mjs # 手动跑:**联网复核**(官方页 vs 写死的价目表逐格对比 / models.dev vs 内置快照逐条对比 / 余额端点白名单与状态码);只读,不写任何文件
|
|
405
|
+
node scripts/dsh-shape-check.mjs # 手动跑:**对着 DSH 源码**核对我们依赖的宿主形状(服务名 / 方法 / 事件 / 版本),升级 DSH 后必跑;只读
|
|
406
|
+
node test/check-sections.mjs # 只读:升级前看七栏会变成什么
|
|
270
407
|
node test/host-header-mirror.mjs # 只跑宿主半的请求头测试(写入/撤销/改名/幂等/不误删)
|
|
271
408
|
node test/host-settings-generations.mjs # 只跑宿主半的「两代设置服务」兼容(0.1.6 / 0.1.7 形状)
|
|
272
409
|
node test/terminal-policy.mjs # 只跑「默认终端」(探测 / 渲染 / 审批 / 工具 / 接线)
|
package/cordis.patch.yml
CHANGED
|
@@ -7,6 +7,98 @@
|
|
|
7
7
|
# 安装:
|
|
8
8
|
# dsh plugin --profile <你的 profile> add github:fangwen9527/dsh-composer-ux
|
|
9
9
|
# 本地源码开发请改用 cordis.dev.patch.yml(file:/// 绝对路径,见 README「加载」一节)。
|
|
10
|
+
#
|
|
11
|
+
# ── 官方持久终端三件套:服务 + 后端 + 模型工具 ───────────────────────────────
|
|
12
|
+
#
|
|
13
|
+
# 目标:让模型拿到官方那 6 个持久终端工具(terminal_open / terminal_read /
|
|
14
|
+
# terminal_send / terminal_signal / terminal_close / terminal_list),与插件已有的
|
|
15
|
+
# 「默认终端」栏互补 —— 那一栏决定**模型用哪个 shell**,这一档决定**模型有没有
|
|
16
|
+
# 跨调用存活的会话**(REPL、dev server、需要交互输入的程序)。
|
|
17
|
+
#
|
|
18
|
+
# **三行缺一不可,而且必须在同一层**(2026-09-28 真机踩到,经过见下):
|
|
19
|
+
# ① composer-ux-terminal `@deepseek-ai/dsh-terminal` 提供 ctx.terminals
|
|
20
|
+
# ② composer-ux-terminal-bash `@deepseek-ai/dsh-terminal-bash` 注册 type=shell 的 PTY 后端
|
|
21
|
+
# ③ composer-ux-tool-terminal `@deepseek-ai/dsh-tool-terminal` 注册那 6 个模型工具
|
|
22
|
+
#
|
|
23
|
+
# 为什么不能只挂 ③:
|
|
24
|
+
# ③ 的源码里写着 `const inject = ["terminals", "tools", "systemPrompt"]`
|
|
25
|
+
# (`@deepseek-ai/dsh-tool-terminal/lib/index.js`)—— **它必须等到 `terminals` 服务**。
|
|
26
|
+
# 而 ①(dsh-terminal)在官方组合里只出现在 `sdk-minimal` 与 web-app 的 **minimal 预设**
|
|
27
|
+
# 里(`packages/bundle/web-app/presets/minimal.patch.yml` 的 `persistent-shell` 组),
|
|
28
|
+
# standard 预设**没有**、profile 层也没有 ⇒ 在 desktop profile 这层 `ctx.terminals`
|
|
29
|
+
# 根本不存在。
|
|
30
|
+
# 后果不是报错,而是「静默 pending」:cordis 把 ③ 挂着等依赖,**一个工具都不注册**,
|
|
31
|
+
# 网页照常起来(宿主半的 pending 不在 web 启动审计里),只有模型工具表里空着。
|
|
32
|
+
# —— 这就是 0.6.2 白屏那件事的**同一形状**(usage-meter 也是 pending),只是那次
|
|
33
|
+
# 发生在客户端半、被启动审计抓住。**「包能解析到 + 守卫放行」≠「它激活了」。**
|
|
34
|
+
#
|
|
35
|
+
# 为什么官方放在预设里、我们放在 profile 层:
|
|
36
|
+
# 官方 minimal 预设用 `cordis:group` + `isolate: { terminals: true }` 给**每个 agent
|
|
37
|
+
# 一份** terminals 服务(预设是 per-agent 的)。我们挂在 profile 层,是一份共享服务;
|
|
38
|
+
# 会话归属由 API 自己保证(`spawn(owner, …)` 按 owner 隔离、工具里 `requireAgent(exec.agent)`),
|
|
39
|
+
# 功能等价。挂这一层的另一个理由:**profile 层注册的模型工具能被 agent 看见** ——
|
|
40
|
+
# 同层的 `@changfenhuang/dsh-genui` 就是这么把 `render_ui` 送进工具表的。
|
|
41
|
+
#
|
|
42
|
+
# 关于依赖与守卫(三行各有各的规矩):
|
|
43
|
+
# · ③ `@deepseek-ai/dsh-tool-terminal` **没有随 0.1.7-rc.2 桌面版发布**(app.asar 的
|
|
44
|
+
# 284 个 @deepseek-ai 包里有 dsh-terminal、dsh-terminal-bash、
|
|
45
|
+
# dsh-api-terminal-controller、dsh-client-ui-sidebar-terminal,唯独没有它;
|
|
46
|
+
# dsh-web-app 的依赖里也没声明 ⇒ 从没被安装过),所以它写成插件自己的依赖
|
|
47
|
+
# (package.json 的 dependencies,区间 >=0.1.7-rc.1 <0.1.8),并带自检守卫。
|
|
48
|
+
# · ① ② 是**安装目录里就有**的官方包(像 dsh-mobile 按包名引用
|
|
49
|
+
# `@deepseek-ai/dsh-host-directory-picker-browse` 那样即可解析),
|
|
50
|
+
# 所以 **① ② 不能加下面那种自检守卫** —— 守卫用 profile 目录的 createRequire,
|
|
51
|
+
# 解析不到安装目录里的包,加了会把服务永远禁掉、又退回静默 pending。
|
|
52
|
+
#
|
|
53
|
+
# ③ 的自检式守卫(**不能删、也不能加 group: true**):
|
|
54
|
+
# DSH 的兼容性预检只处理「peer 版本冲突」——那种情况它会把行禁掉并往 stderr 报一句
|
|
55
|
+
# (packages/boot/app-boot/src/compatibility-preflight.ts:63-66);但「包装不上 /
|
|
56
|
+
# 解析不到」它不管,会直接落到 Loader 导入失败 ⇒ 整棵树挂 ⇒ 白屏。所以 ③ 自己
|
|
57
|
+
# `createRequire().resolve()` 一次:解析不到就 `disabled: true`,而加载器**根本不会
|
|
58
|
+
# 初始化 disabled 行**(packages/boot/app-boot/src/config-schema/document.ts:113),
|
|
59
|
+
# 于是「缺包」退化成「这个功能不存在」而不是「网页起不来」。
|
|
60
|
+
# 两个坑:表达式必须 try/catch 包住(`disabled` 表达式抛错算「条目失败」而不是「被禁用」,
|
|
61
|
+
# packages/boot/app-boot/src/index.ts:914);这一行不能带 `group: true`(会迫使加载器
|
|
62
|
+
# 初始化 disabled 行,自检当场失效)。
|
|
63
|
+
#
|
|
64
|
+
# **守卫的「基准目录」不能依赖 DSH 内部上下文**(2026-09-28 第二次踩到,实测):
|
|
65
|
+
# 第一版取 `ctx.get('profileContext')?.dir` 当基准,结果开机时它拿不到 ⇒ 表达式返回
|
|
66
|
+
# 「装不上」⇒ **工具行被自己的守卫禁掉了**(`plugin_manager list_plugins` 实测:
|
|
67
|
+
# `include:composer-ux-tool-terminal` 是 `enabled:false, fiberPhase:null`,
|
|
68
|
+
# 而同层的前两行都是 `active`)。失败方向是安全的(没白屏),但功能静默消失。
|
|
69
|
+
# 现在基准改成**只靠通用 Node API 推**:`DSH_HOME`(有就用)否则 `os.homedir() + '/.dsh'`
|
|
70
|
+
# —— 这正是 DSH 自己算 home 的方式(`发布工具/precheck.mjs` 同款写法);
|
|
71
|
+
# 然后在 `profiles/node_modules`、以及 `profiles/*` 每个子目录的 `node_modules` 里找,
|
|
72
|
+
# 再把 `profileContext` 与 `DSH_PROFILE_DIR` 作为**额外**候选(有更好,没有也不影响)。
|
|
73
|
+
# 判据是"任一候选能解析到 ⇒ 不禁用"。测试里有一条「基准发现」用例专门盯着这个坑:
|
|
74
|
+
# 即便 `ctx.get` 抛错,只要包在 profile 的 node_modules 里,守卫也必须返回 false。
|
|
75
|
+
#
|
|
76
|
+
# 求值器(`@deepseek-ai/cordis-plugin-loader` 的 interpolate,从 app.asar 里读出):
|
|
77
|
+
# new Function('ctx', 'expr', 'with (ctx) { return eval(expr) }')
|
|
78
|
+
# 即非严格模式求值、`ctx` 与 `process` 都在作用域内、**同步**(写 await 会拿到 Promise
|
|
79
|
+
# ⇒ `disabled` 恒真 / 配置恒为 Promise)。测试用同一个包装真跑这些表达式。
|
|
10
80
|
- insert:
|
|
11
81
|
- id: composer-ux
|
|
12
82
|
name: dsh-composer-ux
|
|
83
|
+
|
|
84
|
+
# ① 服务本体:提供 ctx.terminals(③ 的必需依赖)。
|
|
85
|
+
- id: composer-ux-terminal
|
|
86
|
+
name: '@deepseek-ai/dsh-terminal'
|
|
87
|
+
|
|
88
|
+
# ② PTY 后端:注册 backendType=shell。官方后端默认 `shellPath: '/bin/bash'`
|
|
89
|
+
# (dsh-terminal-bash/src/config.ts:54),Windows 上解析不到 ⇒ 这里同步探测出
|
|
90
|
+
# 绝对路径;探不到就让 shellPath 留空并回落官方 pwsh 方言(resolvePwshPath),
|
|
91
|
+
# 与插件「找不到 bash 就保持 PowerShell」的策略一致。
|
|
92
|
+
# timeoutMs 对齐官方 minimal 预设的 persistent-shell(300000 = 5 分钟)。
|
|
93
|
+
- id: composer-ux-terminal-bash
|
|
94
|
+
name: '@deepseek-ai/dsh-terminal-bash'
|
|
95
|
+
config:
|
|
96
|
+
backendType: shell
|
|
97
|
+
timeoutMs: 300000
|
|
98
|
+
shellDialect: !!js "(() => { if (process.platform !== 'win32') return 'bash'; try { const fs = process.getBuiltinModule('node:fs'); const p = process.getBuiltinModule('node:path'); const e = process.env; const wsl = (x) => { const a = p.basename(p.dirname(x)).toLowerCase(); const b = p.basename(p.dirname(p.dirname(x))).toLowerCase(); return a === 'windowsapps' || (a === 'system32' && b === 'windows') }; const dirs = String(e.PATH || '').split(p.delimiter).filter((d) => d.length > 0); const fixed = [(e.LOCALAPPDATA || '') + '/Programs/Git', 'C:/Program Files/Git', 'C:/Program Files (x86)/Git', 'D:/Git', 'C:/msys64', 'D:/msys64']; const cands = []; for (const d of dirs) { cands.push(p.join(d, 'bash.exe'), p.join(d, '..', 'bin', 'bash.exe'), p.join(d, '..', '..', 'bin', 'bash.exe')) } for (const r of fixed) { cands.push(r + '/bin/bash.exe', r + '/usr/bin/bash.exe') } return cands.some((x) => !wsl(x) && fs.existsSync(x)) ? 'bash' : 'pwsh' } catch { return 'pwsh' } })()"
|
|
99
|
+
shellPath: !!js "(() => { if (process.platform !== 'win32') return ''; try { const fs = process.getBuiltinModule('node:fs'); const p = process.getBuiltinModule('node:path'); const e = process.env; const wsl = (x) => { const a = p.basename(p.dirname(x)).toLowerCase(); const b = p.basename(p.dirname(p.dirname(x))).toLowerCase(); return a === 'windowsapps' || (a === 'system32' && b === 'windows') }; const dirs = String(e.PATH || '').split(p.delimiter).filter((d) => d.length > 0); const fixed = [(e.LOCALAPPDATA || '') + '/Programs/Git', 'C:/Program Files/Git', 'C:/Program Files (x86)/Git', 'D:/Git', 'C:/msys64', 'D:/msys64']; const cands = []; for (const d of dirs) { cands.push(p.join(d, 'bash.exe'), p.join(d, '..', 'bin', 'bash.exe'), p.join(d, '..', '..', 'bin', 'bash.exe')) } for (const r of fixed) { cands.push(r + '/bin/bash.exe', r + '/usr/bin/bash.exe') } return cands.find((x) => !wsl(x) && fs.existsSync(x)) ?? '' } catch { return '' } })()"
|
|
100
|
+
|
|
101
|
+
# ③ 模型工具:6 个 terminal_*。带自检守卫(见上面的长注释)。
|
|
102
|
+
- id: composer-ux-tool-terminal
|
|
103
|
+
name: '@deepseek-ai/dsh-tool-terminal'
|
|
104
|
+
disabled: !!js "(() => { try { const m = process.getBuiltinModule('node:module'); const p = process.getBuiltinModule('node:path'); const fs = process.getBuiltinModule('node:fs'); const os = process.getBuiltinModule('node:os'); const home = (typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.length > 0) ? process.env.DSH_HOME : p.join(os.homedir(), '.dsh'); const profiles = p.join(home, 'profiles'); const roots = [p.join(profiles, 'node_modules')]; let explicit; try { explicit = ctx.get('profileContext')?.dir } catch { explicit = undefined } if (typeof explicit === 'string' && explicit.length > 0) roots.push(p.join(explicit, 'node_modules')); if (typeof process.env.DSH_PROFILE_DIR === 'string' && process.env.DSH_PROFILE_DIR.length > 0) roots.push(p.join(process.env.DSH_PROFILE_DIR, 'node_modules')); let names = []; try { names = fs.readdirSync(profiles, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name) } catch { names = [] } for (const n of names) roots.push(p.join(profiles, n, 'node_modules')); return !roots.some((r) => { try { m.createRequire(p.join(r, 'package.json')).resolve('@deepseek-ai/dsh-tool-terminal'); return true } catch { return false } }); } catch { return true } })()"
|