dsh-composer-ux
English summary — A DeepSeek Harness Web plugin that upgrades the composer (chat input) experience: configurable send / newline keys, a native-style 7-item right-click context menu, a resizable & scrollable settings panel with persisted size, a global on/off switch, a quick-command panel (built-in prompts, click-to-insert, per-item "always append on send"), a prompt optimizer that runs a separate model call before you send, automatic
x-opencode-sessionrequest-header injection so OpenCode (Go) routes work inside DSH, and — on Windows — a default-terminal switch that replaces the model's PowerShell tool with Git Bash (git-anchored discovery, WSL excluded, per-session tool-surface trim, official-identical sandbox/approval/timeout semantics), a composer stats-line upgrade that renders the built-in cache-hit percentage with three decimals (keeping upstream's "never round a partial hit up to 100%" rule), a cost capsule whose peak/off-peak prices you can edit per model in settings (empty field = official list price; peak/off-peak is decided by each usage event's own timestamp, not by when you look at the panel), with Chinese public holidays fetched automatically (State-Council schedule viaholiday-cn, GitHub raw + jsDelivr mirror, built-in table as offline fallback; your own date list always wins), plus a one-click "restart DSH" button in the settings-card header (market-proven self-restart: detached node helper, port-release wait, hidden-console relaunch, same-origin fence, boot-id reload). Install withdsh plugin --profile <name> add dsh-composer-ux(npm) — the builtlib/ships in the package and in this repository, so there is no build step and no build authorization. License: MIT.
看哪一份随你:小白版(三步上手 + 常见问题,讲人话)README.simple.md · 英文版 README.en.md · 本页(中文全版,最详细)。
DeepSeek Harness Web 输入体验增强插件:

上图即 0.5.0 的设置页(六栏都开着)。注意标题行只有卡级开关与从属控件 —— 右键菜单那 7 个条目开关已按实测反馈搬进卡内的「自定义」档(见第 0 条)。
- 两层开关:总开关 + 每栏开关。设置页顶部「启用输入增强」是总开关(默认开),它是一道总闸;八张折叠卡里前七张的标题行右端各有一个卡级开关,决定「这一栏要不要生效」(第 8 张「金额」没有开关 —— 金额胶囊常驻显示)。两处都满足才生效(
enabled && 该栏开关)。前六栏默认关;第 7 栏「统计行」默认开(它是唯一例外,理由见下条)。- 兼容性:同一个产物同时支持 DSH 0.1.6 与 0.1.7。两代的设置服务形状不同(
settingsScope.bind→configForms.get、宿主settings.register被删除、事件名换代),代码按能力探测分支,不维护两份产物。 - ⚠️ 升级 DSH 到 0.1.7 之前先备份
~/.dsh/settings.yaml:0.1.7 的官方升级会把它改名成settings.yaml.imported并逐节导入 profile patch(一次性、不可逆),而本插件这一段在旧版上导不进去(那时还没有 Config 字段表),旧值只会留在那个.imported文件里。 - 老用户不会被升级弄坏:卡级开关的默认值不是硬编码的
false,而是按「设置文档里有没有『你在用』的痕迹」迁移 —— 碰过的栏保持开着、没碰过的才是关;全新安装(空白文档)才是六栏全关。「快捷指令」那一栏多一条文件判据(0.3.0 起条目存在quick-prompts.json,设置文档里看不出来)。 - 例外:0.7.0 的「统计行」是唯一默认开的栏(它不参与上面那套"碰过才开"的迁移)。那五栏都已经发布过,一律默认关会在升级那一刻把用户正在用的东西当场关掉;而「统计行」是新能力,没有痕迹可依,用户要的是"装完立刻看得到效果"。
node test/check-sections.mjs把它单独列一行说明。 - 升级前可以先看一眼:
node test/check-sections.mjs拿你真实的设置文档跑一遍迁移,打印七栏会变成什么(只读,不写任何文件)。文件名按版本试:0.1.7 把settings.yaml改名成了settings.yaml.imported,两个都读。 - 关掉一栏 = 这一块完全不介入,且栏内的值全部保留(打开即原样恢复):键位关 → 输入框按 DSH 原生键位;右键菜单关 → 本插件不介入;快捷指令关 → 输入框那枚按钮消失;设置面板关 → 不拖大小;OpenCode 请求头关 → 停止注入并撤销已写入的头;默认终端关 → 保持 PowerShell。
- 「OpenCode 请求头」那一栏没有额外的开关:原来的「附加请求头」本来就是「这一栏要不要生效」,直接搬到了标题行。
- 兼容性:同一个产物同时支持 DSH 0.1.6 与 0.1.7。两代的设置服务形状不同(
- 设置 → 输入体验(设置页新增条目)
- 版式对齐社区插件
@linxin666/dsh-web-all的「Web 插件」页:顶部常显「中文名 + 内嵌英文包名dsh-composer-ux的一行描述 + 总开关」;其下八个栏目为可折叠卡片 —— 标题行左侧是「标题 + 一句动态概览 + 展开箭头」,右侧是这一栏的开关与从属控件(设置面板的缩放开关、默认终端的三档都在这里),长说明与其余控件在展开后的内容区;默认全部折叠、不记忆展开状态,可同时展开多个。标题行只放卡级开关与从属控件:右键菜单那 7 个条目开关按用户后来的反馈搬回了卡内(它们只对「自定义」档有意义,见下一条)。 - 键位:分别配置「发送键」「换行键」——常用预设(Enter / Ctrl+Enter / Alt+Enter / Shift+Enter)+ 点击「自定义…」后直接按任意组合键录制(Esc 取消,Backspace 清除),支持清空为「无」;发送与换行不能设为相同按键;可一键恢复默认。
- 右键菜单:三档「菜单来源」(官方不介入 / 浏览器菜单 / 自定义菜单,见下「右键菜单」一节);卡内只显示当前选中那一档的说明(点官方看官方的、点浏览器看浏览器的、点自定义看自定义的;自定义档还带 Chrome / Edge 与 Firefox 的剪贴板授权说明);那 7 个条目(撤销 / 重做 / 剪切 / 复制 / 粘贴 / 删除 / 全选)只在「自定义」档显示、逐个开关(旁边一个「全部开启」)。这三件事有真渲染测试盯着(
node test/settings-render.mjs:把设置页渲染成 HTML,断言三档正文互斥、那 7 行只在自定义档、标题行没有第二个入口)。 - 快捷指令:分类增删改 / 条目增删改与上下移 / 跨分类移动 / 每条一个插入模式(关 · 每次 · 仅首次)/ 优化强度三档(详见下节)。
- 设置面板:边缘调整大小一个开关(标题行);尺寸预设与拖拽说明在展开区。导航列滚动由 DSH 官方的设置页自带(0.1.7 起
.navList就有overflow-y: auto),所以 0.6.0 把「导航滚动」那个开关整项删掉了 —— 留着就是重复实现。 - OpenCode 请求头:给 OpenCode 的模型请求自动附加
x-opencode-session(详见下节)。 - 默认终端:Windows 上把模型用的终端工具从 PowerShell 换成 Git Bash(三档在标题行;详见下节)。
- 统计行:输入框下方那行「缓存命中 xx%」按三位小数显示(0.7.0 新增,唯一默认开的栏,只有一个开关;详见下节)。
- 金额:输入框下方一颗极简金额胶囊(本会话费用估算,按 DeepSeek 官方刊例价;点开看三分项明细)。这一栏没有卡级开关(胶囊常驻显示),卡内是按模型改高峰/空闲两档单价的编辑器(0.9.1 新增;留空=沿用官方价,可自加任意模型名;详见下节)。
- 重启 DSH:卡片抬头右端(GitHub 链接左边)那枚按钮,两步确认后原地重启(详见「维护:重启 DSH」)。
- 版式对齐社区插件
- 快捷指令按钮:输入框工具行里、「展开」按钮左侧的胶囊按钮,点开是常备提示词清单 + 「优化提示词」。
- 键位生效(仅主聊天输入框):默认值 = 现状(Enter 发送、Shift+Enter 换行、Ctrl+Enter 加速提交),改动即时生效并持久保存。
安装
# 安装
dsh plugin --profile web add dsh-composer-ux
# 更新到最新版
dsh plugin --profile web update dsh-composer-ux
npm 包:dsh-composer-ux —— 预构建产物,安装期不在本地执行任何代码,也不需要 pnpm 的构建授权。(--profile web 换成你自己的 profile 名即可。)
其它安装方式(GitHub 源码 / 锁定 commit / 本地目录)
# 从 GitHub 安装(等价;lib/ 构建产物已提交,没有 prepare 脚本,因此不需要 pnpm 的构建授权)
dsh plugin --profile web add github:fangwen9527/dsh-composer-ux
# 锁定 commit 安装(更安全:后续推送无法悄悄改变实际运行的内容)
dsh plugin --profile web add github:fangwen9527/dsh-composer-ux#<commit-sha>
# 本地目录 / 源码开发(等价于 link;改完 src/ 先跑 node build.mjs)
dsh plugin --profile web add <你克隆或解压出来的目录>
本插件按官方「打包与安装插件」规范打包为可安装组合包(bundle):package.json 声明 dsh.bundle.patch → ./cordis.patch.yml,该层以包名插入插件行 dsh-composer-ux,装进 profile 后由 pnpm/Node 从 node_modules 解析到 lib/index.js。
装完按 DSH 提示重启一次(Host 半的插件代码只在进程启动时 import),客户端半刷新页面即生效。
发现渠道:仓库已打官方发现用的 dsh-plugin topic(另带 deepseek-harness / cordis / dsh / opencode 等关键词);社区目录(mydsh.dev、dshbase.com、dsplugin.app)按该 topic 自动同步收录。仓库里的 cordis.patch.yml 就是随包发布的组合层文件,dsh.bundle.patch 指向它即可。
从 DSH 源码检出直接 --patch 挂载的本地开发方式见下文「加载」一节。
键位规则
- 目标限定在 DSH Web 主聊天输入框(
[data-composer-input]);其他输入框不受影响。 - IME 安全:中文/日文输入法组合期间(
isComposing/keyCode 229)一律放行,绝不误发、误换行。 - 发送/换行通过回放官方按键管线实现(发送 = 普通 Enter,换行 = Shift+Enter),因此所有官方保护均保留:忙时排队 / 油门(steer)、连发防重、空草稿不发送、
/@触发器菜单打开时 Enter 仍优先选择菜单项。 - 未绑定的 Enter 系按键(如把「发送」清空后按 Enter)不会触发任何动作;Ctrl+Enter / ⌘+Enter 未被绑定时保留官方「加速提交」行为(绑定为空闲草稿的队列推进等)。
- 单个字母/数字键不允许裸绑定(会与打字冲突);纯修饰键不会触发录制。
- 部分 Ctrl/Meta 组合被浏览器占用(如 Ctrl+L、Ctrl+W、Ctrl+J),录制时无法在页面捕获——建议使用 Alt/Shift 组合或 Enter 系键位。
右键菜单
设置 → 输入体验 → 右键菜单 里是三选一的菜单来源(只影响右键,不影响键位与其它功能):
- 官方(默认):本插件完全不介入,DSH 与其它插件自己的右键处理原样生效。DSH 官方输入框本身没有右键菜单,所以通常看到的就是浏览器的菜单。
- 浏览器:固定使用浏览器自带的菜单(样子随浏览器而变),并在本插件这一层挡住其它插件的菜单;好处是粘贴免授权、零配置。
- 自定义:使用本插件固定样式的菜单(未选中文本时「剪切 / 复制 / 删除」置灰)。这一档的「粘贴」要读剪贴板,浏览器会先要一次授权,三家处理不同:
| 浏览器 | 首次/之后 | 改或撤销授权 | 免掉弹窗的办法 |
|---|---|---|---|
| Chrome / Edge | 弹出后点「允许」即记住这个站点,之后不再问 | 地址栏最左的网站图标 → 网站设置(Edge 叫「此站点的权限」)→ 剪贴板;或 chrome://settings/content/clipboard(Edge 是 edge://settings/content/clipboard) |
不需要:允许一次即可 |
| Firefox | 每次点「粘贴」都会弹一个只有「粘贴(P)」一项的小窗(约 1 秒后才可点),点它才完成 | 无(没有站点授权面板) | 关不掉:这是 Firefox 的安全机制,网页不允许静默读剪贴板。不想多这一步就按 Ctrl+V,或把「菜单来源」切成「浏览器 / 官方」档 |
0.4.0 实测更正:早先文档里教过「改
about:config里某个剪贴板首选项就不弹窗」——那是错的,那个弹窗与任何首选项都无关(用户在 Firefox 上照做后弹窗照旧)。依据:MDN Clipboard API 的安全说明(读到不允许的内容时,浏览器弹的临时菜单里只有一个 Paste 项、约 1 秒后才可点)、caniuse(只有带clipboardRead权限的扩展不显示粘贴提示)。能免掉它的设置都属于「允许任何网站静默读剪贴板」那一类,本插件不教、也不建议改。
chrome:///edge://这些地址不能做成网页里的链接(浏览器禁止页面跳转到内部协议),只能手输或复制粘贴到地址栏。被拒绝授权或无响应时,菜单里会提示「请用 Ctrl+V 粘贴」。
自定义档的细节:
- 样式与系统编辑器菜单一致(深色圆角、三分组、快捷键右对齐);被关闭的条目不显示(分隔线自动合并);只在这档下才显示那 7 个条目开关与「全部开启」。
- 撤销 / 重做 / 剪切 / 复制 / 删除 / 全选 走标准编辑命令;粘贴 读取剪贴板后在光标处插入纯文本——这是浏览器的安全限制。
- 菜单打开时点击外部、Esc、滚动或窗口变化都会关闭。
- 该档
preventDefault掉浏览器菜单,并和「浏览器」档一样挡住同一层里其它插件的捕获监听(否则两边会各弹一个菜单)。
快捷指令与提示词优化
输入框工具行里、「展开」按钮的左侧有一个同款胶囊按钮「快捷指令」(槽位 conversation.input.right,order 89 < 官方「展开」的 90)。点开展开面板:
- 快捷指令清单(分类两级结构):点条目把内容插入输入框(原有内容保留、另起一行)。面板顶部是分类标签(点它切换,
+新增一个分类);分类的改名 / 排序 / 删除,以及条目的增删改、上下移、恢复内置 9 条,都在 设置 → 输入体验 → 快捷指令清单 里。 - 插入模式(三选一,每条一个):「关」只插入不附加;「每次」在你点发送时(Enter 或官方发送按钮)自动拼到消息末尾一起发出;「仅首次」只在这个会话的第一条消息上附加。输入框里都不提前显示;多条按列表顺序拼接、条目间空一行。判定与「每次」一样跨分类生效(按条目 id 找,不限于当前分类),原文为空时不附加(交还官方原语义)。
- 「仅首次」的判据是官方会话快照里的
blank(这个会话还没有任何消息):发完第一条它自己就为 false,所以不需要插件自己记状态;刷新页面、切走再切回来都不会重复附加。 - 两种模式同时存在时,新会话的第一条消息里两批都附:「每次」的那几条在前、「仅首次」的那几条在后,一起拼到末尾(第二条起就只剩「每次」的了)。
- 为什么是一个三选一控件、而不是两个勾选框:「每次」与「仅首次」互斥,两个独立勾选框能造出「同时又每次又仅首次」的矛盾状态;三选一只发出一个 mode,由
withInsertMode一次把两个标志写对。 - 手工编辑文件时若把
autoSend与autoSendFirst都写成true,按「每次」处理(每次插入本来就包含第一次),并在下次写盘时修正回互斥状态。
- 「仅首次」的判据是官方会话快照里的
- 优化提示词:把输入框里的话交给另一个 AI 整理成一条能直接发给工作 AI 的清晰指令。四档强度(0.14.0 起照 dsh-prompt-optimizer):关闭 / 轻度 / 标准(默认)/ 重度 —— 档位=依据预算与思考方式;旧设置里的 普通/高级/极端 会自动迁移成 轻度/标准/重度。
- 0.12.0 起结果进「结果框」,不再自动改写你的输入框:点「优化提示词」(或工具行的 ✨ 按钮)后面板里展开一个结果框 —— 跑的时候逐条显示它在核实什么,跑完把成品放进一个可编辑的文本框;你原来写的话一直留在输入框里,改都还能改。
- 逐条流式,而且每条都已通过校验:宿主边收边扫,把已经闭合、且引文在你原话里逐字存在的条目一条条推过来;对不上的走
dropped如实记账。所以框里出现的每一条都是"成品里可能出现的那条",不会先闪一下再消失。成品要等整段输出到齐、逐条校验装配完才存在,由最后的done一次给出。 - 「插入输入框」由你决定何时写回:覆盖全文(
Ctrl+Z可还原),斜杠命令前缀(/goal)原样拼回。若你在优化期间改过输入框,第一次点只提示、第二次点才覆盖 —— 不静默吃掉你刚写的内容。 - 「重新优化」= 用框里那一轮的原文再跑一轮(不是拿输入框现在的内文:那时你多半已经把成品插进去了);你手改过框里的成品时会先提示一次。
- 「取消」/
Esc在跑的时候 = 中止并保留已生成的部分:条目流水留着,成品如实留空(取消时它本来就不存在)。空闲时Esc= 关面板;结果框的「✕」只收起(0.13.2 起结果留着,点面板那一半就能再展开);输入框里已插入的内容不受影响;面板关掉不会清空结果框 —— 一次优化是真花了钱的,不该因为你点了一下别处就没了。 - 0.12.0 起默认携带会话上下文(设置里可关):优化时会把当前会话最近的往来 (你的话与工作 AI 的话各取最近 4 条、共约 1600 字)一并交给模型 —— "它那个也顺手改一下" 这种离开上文就没意义的短句,只有带上上文才能猜对"它"指谁。三条边界写死在提示词里: 只用于消歧义、不要回应它、上下文不算依据(引文仍必须出自你的原话,否则逐字校验会把它丢掉)。 上下文只发给你自己配置的那条模型路由;只读 content 里的文本块(推理块不要)、只认真正来自你的消息(插件/系统注入的不算)。
- 0.12.0 起有记忆链:你把成品插进输入框、改了几处、又点一次优化时,宿主会把上一版成品 一起给模型(截断 1500 字),它只需围绕变化点调整、不必从零重写(你已确认过的地方不会被改回去)。 不带的三种情况:同一段原文重跑(同文重试)、草稿就是上一轮成品(对成品重跑)、上一轮失败/取消;“重新优化”只在你手改过框里成品时才带。
- 面板分两半(0.13.1):中部现在是「优化提示词」(结果框)与「快捷指令」(分类 + 条目列表)二选一, 中间一个切换按钮(▸ / ▾)换着看;打开面板默认展开快捷指令,标题行的三档档位与 ✨ 按钮始终露在外面 —— 所以「跑一次优化」永远是一步,不必先展开。点 ✨ 会自己切到优化那半(否则结果被藏在收起的一半里, 看起来像没反应);手动切回快捷指令不会被抢回去;点结果框的「✕」会顺手切回快捷指令。
- 修掉「展开后看不到按钮」(0.13.1):以前结果框和快捷指令列表挤同一块固定高度,结果框一长 (条目多 + 成品多行)就把列表挤没、再把自己底部的**「插入输入框 / 重新优化 / 复制」顶出面板边界被裁掉**。 现在两半互斥、各自内部滚,而且结果框拆成「可滚内容区 + 钉在底部的按钮行」—— 面板再矮也不裁那排按钮。
- 优化只从「快捷指令」面板进(0.12.0 起):工具行只有一枚「快捷指令」按钮,点开面板再点「✨ 优化提示词」。
0.13.2:✕ = 收起(结果留着),另给一个「丢弃」
起因(用户原话):「现在它提示词优化是悬浮的特别是我点叉关闭之后居然恢复不了」。
- ✕ 改成「收起」:结果不再被删,只是标记成"先别显示";面板切回「快捷指令」那半,切换按钮写 「上次的结果在这儿」,点它就能再展开(按钮 tooltip 明确写「结果留着」)。
- 新增「丢弃」(结果框标题行里、✕ 左边,样式弱化):点两次才真丢(第一次换成「点第二次丢弃」, 4 秒后自己复位),丢掉时才清状态 + 删磁盘那份。
- 收起状态跨重启记住(用户选的 A):状态信封加了一个可选的
hidden,不升版本号 —— 没收起时不写这个字段,文件形状与 0.13.0/0.13.1 完全一致,降级回旧版也认得;老文件没有该字段 = 没收起。 - 新跑一轮(✨ / 重新优化)会自动把框显示出来,不会被收起状态挡住。
- 结果框不再画成一张独立卡片(去掉自己的背景与圆角边框,只留一条细分隔线)—— 这就是用户说的"悬浮"观感:它现在看起来是面板里的一段,而不是浮在上面的小窗。
实机(0.13.1,2026-09-30 重启后当场核过)
① 中部是「快捷指令」那半(默认):分类标签 + 条目列表 + 底部计数;标题行始终有「快捷指令 + 普通/高级/极端 + ✨ 优化提示词」,
中间的切换按钮是 ▸ 优化提示词,右侧写明另一半的状态(这里写的是「上次的结果在这儿」):

② 点切换按钮 → 换成「优化提示词」那半(列表收起,▾ + 「点此回到快捷指令」):结果框里是条目流水、可编辑成品与记账行,
底部那排「插入输入框 / 重新优化 / 复制」完整可见 —— 这正是 0.13.1 修的 bug:以前结果框一长,这排按钮会被面板裁掉:

③ 面板已经到了自己的高度上限(约 60vh),按钮依然在:成品输入框右侧出现自己的滚动条,内容区滚而按钮不动 —— 这就是"两半互斥 + 结果框拆成可滚区 + 钉底按钮行"的现场效果:

说明:面板里那份结果是用户自己跑的一次优化(
go/deepseek-flash,用时 9.7 秒,7 条补全),不是我为了截图新花的调用; 截图时也没有改动它(没点「插入输入框」、没点 ✕,只切换了两半、开关了面板)。 0.11.1 曾试过在工具行加一枚独立的「✨ 优化」按钮(点一下 = 开面板 + 立刻开跑),用户看到实机后明确否掉 —— 「我让你集合到快捷指令里的提示词优化按钮里」,于是 0.12.0 删掉它,回到单一入口。
- 秒表:面板主按钮与结果框都显示已等待秒数,结束时报「用时 x.x 秒」。边界如实说:阶段只能到"等待模型响应" —— 更细的进度由逐条流水本身给出(它就是"它在干活"的证据)。
- 斜杠命令保护:
/goal 帮我写周报只把命令后面的正文送去模型,插入时把/goal原样拼回;只有命令没有正文时直接提示、不发请求。/path/to/file这类路径不会被误判成命令(判据SLASH_COMMAND_RE,两端共用)。 - 推理强度钳最低档:按路由真实暴露的档位(
llm.resolveModelInfo)选最省那一档 —— 推理模型不指定档位时会先空转很久;查不到档位表就什么都不传(绝不乱造适配器不认的值)。 - 客户端断连即中止:关页面/切走会话时
res.on('close')立即 abort 这次模型调用,不再白烧额度(用res而非req:req的 close 在请求体读完就触发)。 - 0.6.0 起它不再"自由改写":模型只产出条目,每条
rewrite/requirement/quality必须附一段在你原话里逐字存在的引文;宿主逐条做字面比对,对不上就只丢那一条并记账。rewrite按引文位置回填,没被覆盖的原话原样保留,其余条目按节追加在末尾。于是"替你发明一条你没说过的需求"在结构上做不到。 - 三档的差别是依据预算:普通 = 只做语言层修复;高级 = 可以补"能指回原话某一句"的必要要求;极端 = 再加分阶段计划与预案。成品有篇幅预算(普通档 1.4 倍),超了就按固定顺序丢可选的节,并把"省略了几条"写进成品(绝不静默截断)。
- 状态行会如实交代这一轮的结果:
N 条补全 · 丢弃 M 条 · 重试过一次 · 自定义提示词;模型没按条目契约输出时走整段照收的兜底并标注未校验依据(保证改造不会让原本能用的优化变成失败),输出像条目信封但半截坏掉时直接失败、绝不把坏 JSON 写进输入框。
实机(0.12.0)
工具行只有一枚「⚡ 快捷指令」(优化从面板进,见下面第 2 张): 0.11.1 曾在工具行并排加过一枚独立的「✨ 优化」按钮,用户看到实机后否掉 —— 它撤了, 秒表读数与忙碌态都在面板主按钮上,信息一点没少。

逐条流式:点开面板 → 点「✨ 优化提示词」→ 条目一条一条出现, 只显示已经闭合、且通过逐字校验的条目(没通过的不会伪装成通过); 框顶是秒表读数与「取消」,跑的时候按钮是压暗的:

完成态:优化完成 · 用时 17.5 秒。每条下面挂着它引用的那句原话,
成品可以直接在框里改,点「插入输入框」才写回你的输入框:

写回之后:输入框里就是成品(Ctrl+Z 可还原),可以接着改或直接发:

设置页那一栏新增「携带会话上下文」(默认开,用来消歧义;上下文不算引文依据):

上面这 5 张是 0.12.0 发布前在真机上截的(同一份产物)。截图验收当场抓到一个只在深色主题出现的 真 bug:结果框那颗「插入输入框」是白底白字(
--dsw-alias-label-inverse这个令牌名不存在, 兜底成#fff,而深色主题里brand-primary恰好是近白)—— 已修,并补了对象级护栏; 修前的样子留在docs/optimize-button-contrast-before.png里做记录。
数据存在哪
快捷指令(分类 + 条目 + 每条的插入模式)存在 $DSH_HOME/quick-prompts.json(默认 ~/.dsh/quick-prompts.json):
- 与会话、项目无关:换仓库、换会话都在;可以直接备份、搬迁、手工编辑(改完在设置页点「重新读取」)。
- 宿主半用「临时文件 →
fsync→rename」原子替换,并用<file>.lock串行化写入;写到一半断电不会留下半截 JSON,并发保存也不会互相截断。 - 文件读不动(不是合法 JSON / 结构认不出 / 空文件)时,先把坏文件改名隔离成
quick-prompts.json.bad-<时间戳>再如实报错,绝不静默返回空列表。这一条是刻意的:若回退成「读不懂就当空列表」,用户下一次保存就会把空列表写回去,真数据被覆盖。 - 文件字段名与 lnyuqian/dsh-quick-prompts 对齐(
categories/name/title/text/autoSend/order),同一个文件两边都读得懂。⚠️ 但不要同时装两个插件——同一个文件两个写者会互相覆盖(对方还是非原子写)。- 「仅首次」是我们加的扩展键
autoSendFirst: true(只在为真时写出来)。对方的插件会忽略它,并在它重新保存时丢掉这个键——也就是说「仅首次」在那边会退化成「关」。
- 「仅首次」是我们加的扩展键
- 0.2.x 存在设置文档里的那份旧列表会在首次读取时自动迁移成「默认」分类;旧值保留在设置文档里不清空,万一回滚到 0.2.x 还能看到自己那几条。
- 单条提示词上限从 4000 字提到 20 万字(只做防呆,不再截断正常提示词);送去「优化」的原文仍限 8000 字(那是给模型的输入,不能跟着涨)。
为什么优化要走宿主的 HTTP 接口
出网请求由宿主的模型适配器发出,浏览器侧碰不到模型路由;宿主半与客户端半之间也没有别的受支持通道。所以「优化提示词」是一次往返:浏览器 POST /composer-ux/optimize → 宿主用 ctx.get('llm').stream(...) 独立跑一次模型调用 → 把装配好的正文回给浏览器填进输入框。这条路径与 WestFox-AwA/dsh-prompt-optimizer 同构。
提示词的来历(如实写):0.14.0 起,解释层的提示词与条目本体是逐字移植它的(见 NOTICE):
po06/lib/interpreter.js 的 SYSTEM_PROMPT(硬规则 1–8)与 HARD_NOTE_SYSTEM(硬邦邦基调)、
po06/lib/strategy.js 的 TIER_STRATEGY(四档策略)与 DOMAINS(六领域质量维度)一字未改,
只把【输出格式】那一节改写成我们自己的成品契约(我们产出条目、由宿主装配成「原话 + 辅助小节」)。
三处刻意差异与理由写在 src/optimizer-transplant.ts 的文件头。
更早的来历:0.5.x 那三档是逐字提取自它 0.5 线的 lib/index.js(BSD-3-Clause,作者「啃轮胎的西狐」)。0.6.0 起机制与提示词都改按它 0.6 线(po06/lib/interpreter.js 的 SYSTEM_PROMPT + validateProvenance、po06/lib/compiler.js 的固定节序与预算丢弃)重写,不再是逐字提取 —— 那些文件里没有现成可抄的"三档改写提示词",能借的是机制本身:条目化产出、逐字引文、只丢单条、降级出声。落在 src/optimizer-prompt.ts(提示词)与 src/optimizer-assemble.ts(校验 + 装配)两个文件,署名与来源说明保留在各个文件头。
优化用的模型跟随你当前的默认模型(agentDefaultModel.currentSelection()),不额外配置;每次优化会花一次模型调用,但不占对话轮次、不进会话历史。
逐轮台账(只记元数据,不记原文)
优化跑完只留一句话("3 条补全 · 丢弃 1 条"),过一会儿就没了;「这轮为什么长这样」也没处查。0.13.0 起每轮往 $DSH_HOME/composer-ux/optimize-log.jsonl 追加一条元数据:
档位 · 草稿字数 · 上下文轮数与字数 · 条目数 · 丢弃条数与原因 · 是否重试/整段照收 · 耗时 · 路由 · 成败
一条用户原文都不写(草稿、成品、逐字引文都不进这个文件)—— 这不是靠自觉,而是模块类型上就没有承载它的字段。还有一处必须说明的坑:装配层那几条丢弃原因的模板里嵌着模型给的引文(引文不是原话里的逐字片段:「…」,最多 40 字),而引文很可能就是你原话的变体,所以写台账前会消毒:刮掉所有成对引号/括号里的内容,只留机器写的固定前缀(引文不是原话里的逐字片段:)。
node scripts/recap.mjs # 最近 20 轮(表格)
node scripts/recap.mjs --last 5 # 最近 5 轮
node scripts/recap.mjs --session 6f2a # 只看某会话(sessionId 前缀)
node scripts/recap.mjs --json # 机器读
node scripts/recap.mjs --clear # 清空
台账文件超 512 KB 会从最旧的行开始丢(并留一条 rotated 标记说明丢了多少行);坏行逐行跳过并计数,不会因为一行坏掉就把整份台账判成"读不出来"。写台账失败绝不影响优化本身(旁路失败只少一条记录,不能拖垮一次已经跑完的调用)。开关在设置 → 插件 → 输入快捷指令 → 记录每轮优化台账(默认开:只写数字与原因,所以默认开不冒隐私风险)。
重启
真机验证(2026-10-01)
Windows 桌面版实测通过(此前会弹「应用无法启动或已意外停止」那个恢复框):
- 双击
restart-webui.bat让新版本生效 → 点界面上的「重启」→ 没有弹出恢复框; - 窗口自己回来了,大概几秒(用户确认,非手动重开);
- 助手日志两次运行(19:08:55 / 19:10:31)一个错都没写,且此后无新的壳崩溃日志。
机制:桌面形态连壳一起换掉 —— taskkill /F /PID <壳>(只用 /PID)→ 等旧宿主退出 →
用应用 exe 重建(删掉 ELECTRON_RUN_AS_NODE);杀壳前先确认 exe 存在,拿不到就退回"只换宿主"。
后结果框还在
结果框原先只在内存里:关面板不清空,但重启一次 DSH 就丢了刚跑出来的成品,想看只能再花一次模型调用。0.13.0 起它会被存下来 —— 写进 $DSH_HOME/composer-ux/optimize-dock.json,下次启动或刷新页面时恢复。
三条硬纪律(都是上游踩出来的教训,照抄做法):
| 纪律 | 为什么 |
|---|---|
原子写(临时文件 → rename) |
半份 JSON 会被读成"状态损坏" |
损坏隔离(改名成 <名>.corrupt-<时间戳>.json) |
那份文件里可能是你唯一一份成品,绝不能静默覆盖或删掉 |
| 超 64 KB 拒写(不是截半) | 宁可这次没存上,也不要存半份 |
信封里带版本号:版本不认识就不解析、也不动文件(可能是你从新版降级回来,那份状态还在)。读回来的状态一律先过净化:认不出(被手工改过/旧版本写的/写坏)就当"没有可恢复的结果"——宁可框是空的,也不拿半份脏数据糊界面。重启前没跑完的那一轮恢复成"已取消",不假装还在跑(秒表也不会走)。
⚠️ 隐私边界(用户 2026-09-29 明确选 A):这份文件里有内容 —— 成品正文、条目、逐字引文、发起时的草稿。因为"重启后结果框还在"本质上就是把这些存下来;它与
quick-prompts.json同性质。只记元数据的那一份是上面的逐轮台账,两者刻意分成两个文件。
开关:设置 → 插件 → 输入快捷指令 → 重启后保留结果框(默认开),旁边有「清空结果框状态」 (磁盘那份 + 当前框里这份一起清)。关掉开关时宿主既不读也不写,并把已存的那份删掉。
只读查证项目文件(默认关)
草稿里常有"改这个文件里的东西""按现在的写法来" —— 不查就只能猜。打开这个开关后,解释层可以调三个
只读工具:read(读一个文件)、glob(按通配符列文件)、grep(按正则找内容)。
默认关,因为它真的会多花时间与 token;开了才走工具轮次(0.5 默认开、0.6 改成默认关,理由一样)。
| 边界 | 值 |
|---|---|
| 放行条件 | 开关开 ∧ 拿得到本会话工作目录(拿不到就一个工具都不派 —— 没有围栏的根就不猜路径) |
| 围栏 | 一切路径必须落在工作目录内;realpath 之后再比前缀,所以符号链接也逃不出去;越界拒绝并记账 |
| 只读 | 只有 readdir / stat / readFile,一个写调用都没有,也不执行任何东西 |
| 上限 | 单文件 200KB · 单次结果 4000 字 · 遍历 400 文件 · glob 50 命中 · grep 40 命中 · 深度 6 |
| 轮次 | 每轮 ≤3 次调用、总 ≤3 轮、总时限 20 秒;触顶如实标注(toolCapped),不假装查全了 |
| 跳过 | 点文件/点目录(配置与缓存)与 node_modules / dist / build / coverage 这类目录 |
两条口径值得单独说:
- 查到的内容不能当引文。
rewrite/requirement/quality的quote仍必须是你原话里的逐字片段 —— 从文件读来的句子写进 quote 会被装配层逐条丢弃。工具内容只用于理解与消歧义。 - 模型查完文件写散文时,这一轮会回落。真机上模型很爱在查证后写"我看过 xxx,所以建议……",
那不是我们的条目 JSON。我们只在真的解析出条目时才认工具路径;否则换成不带工具说明的系统提示
再跑一次单轮优化。回落时台账与结果里保留真实发生过的轮次/次数(那几次调用是真花了钱的),
并标注
toolFallback,不假装没派过工具。
开关:设置 → 插件 → 输入快捷指令 → 优化时允许只读查证项目文件(默认关)。
「发送时附加」为什么不自造提交
Enter 那一路沿用既有的合成 Enter 回放;官方发送按钮那一路在捕获阶段认下点击、先把附加内容写回编辑器、再用同一个按钮重放一次点击。这样官方对「发送 / 排队 / 打断」的判定原样生效,本插件不做第二套提交语义。发送键与停止键共用同一个位置,靠图形区分——停止渲染 <rect>(方块),发送渲染 <path>(箭头),与界面文案、语言无关。
OpenCode 请求头
OpenCode 的接口要求客户端在每次请求里带上一个稳定的会话 ID 请求头(官方 Go 文档「可以在哪里使用?」第 3 条:为每段对话在 x-opencode-session 中发送会话 ID,以便其优化路由与提示词缓存)。DSH 的 Models 设置页明确不提供请求头编辑器(源码注释与 README.zh.md 都写明 profile headers 属于部署配置),所以本插件把这一项代办了。
- 设置页位置:设置 → 输入体验 → 「OpenCode 请求头」(折叠栏目):开关 / 头名 / 值 / 作用路由,附一行宿主半回写的「状态」。
- 落点:
llm-pi-ai的 provider profile ——providers.<路由>.headers.<头名>。这是 DSH 里唯一受支持的出网请求头入口:它作为 pi-ai 的optionsHeaders最后合并(能覆盖默认头),且该适配器每次请求都重读配置,所以改完下一次请求即生效,不用重启、不用手工改settings.yaml。 - 写入方式:
settings.mutate的路径寻址——只动我们那一个键,绝不重述或删除你写在同一个 profile 里的其它字段(models/apiKeyEnv/ 其它 headers 都不碰)。 - 目标路由怎么选:profile 的
models是必填项,凭空造一个只有 headers 的路由会让整份配置校验失败,所以只写已存在的路由。判据:- 「作用路由」留空时自动匹配——路由名以
opencode开头(DSH 内置的opencode-go走这条,它的 baseURL 由 pi-ai 目录内置、配置里读不到),或该路由的baseURL主机是opencode.ai(含子域)。后一条是为自建别名路由准备的:例如把 OpenCode 端点配成go: { baseURL: https://opencode.ai/zen/go/v1 },名字里没有 opencode 也能被认出来。 - 「作用路由」填了名单时只看名单(逗号或空格分隔),且只保留其中确实存在的路由。
- 判据只看主机名:
https://opencode.ai.evil.example/v1这类仿冒主机不会被匹配。
- 「作用路由」留空时自动匹配——路由名以
- 撤销:关栏目开关、关插件总开关、或改头名,都会自动清掉此前写入的那一个键;并且只清「值等于当前配置值或记账值」的,用户自己手写的同名头不会被误删。
- 值:所有对话共用同一个值(栏目里可改、可「重新生成」);第一次启用若留空,会自动生成一个 UUID 并保存沿用。OpenCode 文档原话是要「每段对话」一个 ID——按会话变化的头 DSH 的配置层做不到(
llm/stream钩子被设计成只能读不能改,GenerateOptions里没有headers字段),所以这里退一步用固定值。已知代价:所有对话挤同一个上游(没有负载分散);单段对话内的缓存命中不受影响。另外它不保证缓存一定命中(还取决于上游模型与网关策略)。 - 自校验:写入前用与 llm-pi-ai 相同的规则(
new Headers())校验头名与头值;非法值被拒绝,原因写进栏目的「状态」行,而不是把整条路由弄坏。 - 实测结论(本机验证过,不是推断):
- 它是硬门槛,不是优化项。 同一端点、同一模型,只把这条请求头去掉,OpenCode Go 直接返回 HTTP 400
MissingSessionID:"Request is missing x-opencode-session and cannot be routed efficiently." —— 没有它,DSH 里的 opencode-go 完全不可用。 - 带着它时真实调用成功,且前缀缓存在工作:同一段前缀连发两次,
input174 → 46、cacheRead192 → 320(总前缀 366 不变),第二次有更多内容直接命中缓存。 - 线级证据:用
test/opencode-header-wire-probe.mjs起一个本地端点,DSH 发过去的推理请求上确实带着x-opencode-session: <值>,以及它自己的user-agent: deepseek-harness/0.1.5-rc.2 (+https://github.com/deepseek-ai/deepseek-harness)(正好满足 OpenCode 文档对客户端标识的第 2 条要求)。
- 它是硬门槛,不是优化项。 同一端点、同一模型,只把这条请求头去掉,OpenCode Go 直接返回 HTTP 400
默认终端(Windows)
Windows 上 DSH 给模型的终端工具是 PowerShell(工具名 pwsh),而模型的训练语料里 bash 占绝对多数。这一栏把终端换成 Git Bash:模型看到的工具就叫 bash,pwsh 从它的工具列表里消失 —— 一个会话只面对一个终端工具。
- 设置页位置:设置 → 输入体验 → 「默认终端」(折叠栏目):档位 / Git Bash 路径 /「自动发现」/ 候选点选,附两行宿主半回写的只读状态(当前生效 shell、状态行)。
- 三档:
自动(探测到 Git Bash 就用,找不到就保持 PowerShell —— 默认,开箱即用)/Git Bash(强制换,没探测到会回落并在状态行说明)/PowerShell(保持 DSH 默认,本插件完全不介入终端)。 - 探测顺序(同一路径只留优先级最高的那一次;多个候选会在卡片里列出来让你点,并标注来源):
- 你在设置里填的路径;
- PATH 上正在用的那份 git(由
git.exe反推同一个安装根 —— 本机就是这样命中D:\Git的); - Git 的官方落点:
Program Files\Git、Program Files (x86)\Git、%ProgramW6432%\Git、%LOCALAPPDATA%\Programs\Git(安装器以普通用户身份运行时默认落这里,以管理员运行才落 Program Files); - Scoop(
%SCOOP%\apps\git\current)与 Chocolatey 便携包(…\chocolatey\lib\git.portable\tools); - GitHub Desktop 内嵌(
%LOCALAPPDATA%\GitHubDesktop\app-*\resources\app\git)、旧 GitHub for Windows 的 PortableGit(%LOCALAPPDATA%\GitHub\PortableGit_*)、Visual Studio 内嵌(…\Microsoft Visual Studio\<年份>\<版本>\Common7\IDE\…\Team Explorer\Git)—— 这三处带版本号,靠"列一眼子目录"枚举(新的排前面); - MSYS2(
C:\msys64\usr\bin)、Cygwin(C:\cygwin64\bin); - 各盘符根下的
Git/PortableGit/msys64/cygwin64(覆盖装在D:\Git、D:\PortableGit、自己解压到任意盘的情况); - Niubash(
%LOCALAPPDATA%\Programs\Niubash\niu.exe); - PATH 兜底(这里的
bash.exe很可能就是 WSL 的启动器,见下条)。
- 为什么不"扫全盘":探测只做有限次存在性检查 + 极少数目录列举(带版本号的那三处),全程不递归遍历、不跑进程,因此没有"装完卡几秒"这种代价。找不到就如实说找不到,由你手填路径。
- "裸本体"不列为候选:同一个 Git 安装根下如果既有
bin\bash.exe又有usr\bin\bash.exe(或mingw64\bin\bash.exe),只列前者。本机实测两者的差别:前者会给出MSYSTEM=MINGW64、把PATH前置成/mingw64/bin:/usr/bin,于是head/grep/uname都在、中文文件名当参数也正常;后者MSYSTEM为空、PATH只有继承来的 Windows PATH(里面只有D:\Git\cmd),coreutils 全部 command not found —— 交给模型就是命令大面积失败。没有bin\bash.exe兄弟的来源(例如 MSYS2 只提供usr\bin\bash.exe)照常列出;你自己手填的路径不受这条限制(那是你的选择)。 - 为什么「先找 git」:Git for Windows 不一定装在
Program Files(本机就在D:\Git)。只按固定目录找会一边找不到、一边退到 PATH,而 Windows 上 PATH 里的bash.exe很可能就是 WSL 的启动器。 - WSL 被硬排除:
C:\Windows\System32\bash.exe与…\Microsoft\WindowsApps\bash.exe一律不用 —— 它把D:\x解释成/mnt/d/x,与模型手里的 Windows 路径、工作目录、%TEMP%全都不兼容。只有在它确实存在时,卡片的状态行才会提一句「已排除 N 个 WSL 的 bash.exe」,不会在没装 WSL 的机器上凭空报警。 - 改完立刻生效:宿主半会在
agent/created(新会话)与设置变更时遍历所有在跑会话重新下发,不需要开新会话;切回 PowerShell 会把之前下发的限制撤销。 - 与官方终端逐字对齐的行为:工具描述、参数与输出 JSON Schema、
[stderr]分段、(no output)兜底、标记顺序(沙箱拒绝 → 升级提示 → 超时 →[killed by signal: X]或[exit code: N]在最末)、终端卡片(exit 状态拆成 pill,可点开看命令 / cwd / 输出)、后台任务(run_in_background+job_output/job_kill)、超时(默认 120s、上限 600s)、输出截断并把完整输出落盘、沙箱约束与sandbox_permissions升级审批。非零退出不是错误,只是末尾一个标记。 - 失败不伤会话:探测不到 bash、宿主没有
subprocess、某个会话本来就看不到pwsh(此时官方restrict会拒绝)、非 Windows —— 一律只降级并在状态行写明原因,不抛错、不 veto 别的插件、不影响会话本身。 - 一个诚实的副作用:会话内工具面会随档位变化,而会话历史里可能还留着旧工具名。如果模型按旧名字调用,会拿到 unknown tool 之类的错 —— 让它换成
bash重试即可(卡片上也写了这句)。 - 非 Windows:直接不接管(官方 bash 工具本来就在),卡片显示一行说明。
官方持久终端(terminal_* 六件套)
模型侧除了上面那个一次性的 bash 工具,官方还有一套会话跨调用存活的持久终端工具:terminal_open / terminal_read / terminal_send / terminal_signal / terminal_close / terminal_list(REPL、dev server、需要交互输入的程序靠它们)。这一档本插件常驻挂载,不需要任何设置项。
- 为什么需要"我们来挂":这 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里一行挂上。 - 要挂的是三件套,不是一个包:
cordis.patch.yml里插三行 —— ①@deepseek-ai/dsh-terminal(提供ctx.terminals服务)②@deepseek-ai/dsh-terminal-bash(注册type=shell的 PTY 后端)③@deepseek-ai/dsh-tool-terminal(那 6 个模型工具)。缺一行都不行,而且必须在同一层。 - 为什么不能只挂 ③(真机踩到的坑):③ 的源码里写着
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顶部。 - 为什么官方放预设、我们放 profile 层:官方 minimal 预设用
cordis:group+isolate: { terminals: true }给每个 agent 一份服务;我们放 profile 层是一份共享服务,会话归属由 API 自己保证(spawn(owner, …)按 owner 隔离、工具里requireAgent(exec.agent)),功能等价。而且 profile 层注册的模型工具能被 agent 看见 —— 同层的@changfenhuang/dsh-genui就是这么把render_ui送进工具表的。 - ③ 没随应用发布,所以要自己声明依赖:
@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 版本用普通区间匹配不上)。① ② 则是安装目录里就有的官方包,按包名引用即可解析。 - 装的版本要跟「运行时版本」对齐,不是跟桌面壳版本对齐: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后立刻生效。 - 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)。 - 守卫只能加在 ③ 上: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 行)。 - 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。 - 升级 DSH 时要改那个区间:
dsh-tool-terminal的 peer 精确锁0.1.7-rc.2(7 个官方包)。DSH 换大版本后,要么把依赖区间放宽到新窗口,要么让它被预检禁掉 —— 后者只是这 6 个工具静默消失,不会白屏。test/terminal-mount.mjs有一条断言盯着这个窗口。 - 与「默认终端」栏互补、互不依赖:那一栏决定模型用哪个 shell(Git Bash / PowerShell),这一档决定模型有没有持久会话。档位选
PowerShell(插件完全不介入终端)时,这 6 个工具照常在。 - 客户端不用我们写一行:官方客户端已经注册了这 6 个工具的
tool.call.toolview渲染位(运行时 Slot 里可见),工具一亮就有官方样式的卡片。
统计行(缓存命中三位小数)
输入框下方那行用量统计里的「缓存命中 12%」,官方给的是整数;这一栏把它改成三位小数(缓存命中 12.346%)。
设置页位置:设置 → 输入体验 → 「统计行」(0.7.0 新增的第 7 栏,唯一默认开的栏)。只有一个开关(标题行上那一个)—— 这一栏只有一件事可开可关,所以不另设子开关(与「OpenCode 请求头」那一栏同一处理)。
为什么会有这一栏:社区插件 dsh-cache-precision 提供了这条路子,用户看到后要求把同样的能力并进本插件。能力移植、实现没有照抄 —— 下面每条都与它不同,且各有测试盯着。
数字口径与官方完全一致:
缓存读 ÷(未缓存输入 + 缓存读 + 缓存写)(三个桶互不重叠,与官方billedInputTokens()同一份)。所以屏幕上那两个百分比不会互相打架。不撒谎(最重要的一条):官方的整数档有一条很少人注意的规则 —— 当整数四舍五入会把「没满」显示成
100%时,它会自动多给几位(例如99.95%),源码注释原话是 "without rounding a partial hit to 100%"。本插件把默认档抬到三位后保留了这条规则:三位小数一旦会凑成100.000%就继续加位。阶梯如下:真实命中率 官方(整数档) 本插件(三位档) 直接 toFixed(3)(不采纳)12.3456% 12%12.346%12.346%99.995% 100%99.995%99.995%99.999% 100%99.999%99.999%99.9995% 99.9995%99.9995%100.000%(把没满说成满)真的满命中 100%100.000%100.000%- 判据不是"我觉得",而是拿官方源码当基准逐例对:
test/stats-line.mjs把 DSH 的packages/client/ui-chat/src/client/chat/token-format.ts(rc.1)那 4 个函数原样抄进来, 对 400 个分母全量 + 大分母边界 + 3000 组伪随机比对digits = 0档,逐例一致。
- 判据不是"我觉得",而是拿官方源码当基准逐例对:
只改输入框下面这一行(用户明确选择的范围):点开统计行的弹窗、每轮用量弹窗里的百分比保持官方原样 —— 那两个弹窗里数字是独立的
<dd>12.3%</dd>,与"整段必须就是缓存命中 xx%"这条判据天然不冲突 (有 5 条"不许碰"的断言盯着)。顺带一句:弹窗自己用的是官方 1 位小数档,所以会有"下面 12.346%、弹窗 12.3%"的口径差,这是选择而不是漏改。同步无障碍名字:官方那颗胶囊的
aria-label是`${总数} · 缓存命中 12%`。只改可见文字的话, 读屏用户听到的还是整数,所以两处一起改(社区插件没管这一处)。定位与作用面:靠官方稳定属性
[data-composer-stats](StatsPills的根元素),不靠 CSS Modules 类名(那串带哈希,升级就会变)。- 本插件自己渲染一个
display:none的锚点落在同一个 composer dock 里,由它往上找"同时装着我和统计行"的最近祖先(上限 6 层),MutationObserver只盯这一小块。页面上有多个 composer 时也只动自己那一个。 - 刻意不跟社区插件的一处:它在
document.body上跑 TreeWalker + 观察器,等于给整个页面挂监听(每个流式 token 都会喂它)。本插件宁可功能不生效,也不留一个全页监听。
- 本插件自己渲染一个
只改文本,不碰那一行的样式。参考实现还给那一行写行内
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]节。关掉会怎样:把官方那一版原样写回去(三位小数退回官方整数档),不留半截状态。
护栏:
node test/stats-line.mjs(52 项)盯小数语义、两个字符串变换与"不许碰"的范围;node test/stats-dom.mjs(17 项)用按官方源码复刻的假 DOM 盯定位与改写范围(这两件坏事都是静默的),并断言"不写任何行内样式";两条都已进npm test。test/mutation-guards.mjs的AF–AM八条变异盯"退化成toFixed(3)/ 判据放宽 / 默认改成关 / 扫全页 / 不同步aria-label/ 去掉层数上限 / 撤掉的加宽长回来 / 加宽常量重新引入"这八种退化。
金额(输入框下方显示本会话费用 + 设置页可改峰谷单价)
- 设置页位置:第 8 张卡「金额」(没有卡级开关:胶囊常驻显示)。卡里分六块:单价、节假日、峰谷提醒、账号余额、同步、计价说明。
- 单价:按模型改单价(未缓存输入 / 缓存命中 / 输出,元每 1M tokens)。DeepSeek 系模型有高峰 / 空闲两档(内置
deepseek-flash、deepseek-v4-pro两行常显,官方现役就这两个;deepseek-v4-flash等旧名是别名,不单列),非 DeepSeek 模型只有一档「平坦价」(它们没有峰谷概念,摆两档会让人以为谷价时段还能再省一半)。下面一行「再加一个模型」可以填任意模型名(中转/自建路由的名字),同名模型在不同渠道价不同时写成provider:model(例如opencode:gpt-5.6-luna)。 - 留空=沿用官方价:输入框里的灰字占位就是官方价(DeepSeek 行来自官方价目表)。清空的传播是往上收的:一档三项全空 → 那一档消失;一个模型全部档位都空 → 那一整行消失;整表全空 → 设置里这个键直接删掉。
- 非法文本不静默清空:
1,02、2元、-1会留在框里标红(parsePriceText三态),不写进设置 —— 把它当"清空"的话,价格会悄悄回到官方价,而用户以为改成功了。 - 写入是合并 + 认回声:连改几格只发一次设置写入(300ms 合并);用规范化比对分辨"设置里来的新值"与"自己刚写进去的回声",否则回声会把还没写完的第二格编辑冲掉。「恢复默认」会清掉这张表与节假日/提醒/余额开关,但不清同步来的价格档(那是历史账单)。
- 改价即时生效(胶囊与明细页当场跟着变),不用刷新页面、不用重启。
- 单价:按模型改单价(未缓存输入 / 缓存命中 / 输出,元每 1M tokens)。DeepSeek 系模型有高峰 / 空闲两档(内置
- 长什么样:不点击时是输入框下方一颗极简胶囊(
¥0.42,与官方那行统计同排、排在其后,order 100);点开后是贴在它上方的浮层 —— 各 route 的 token 与花费、合计、高峰档 / 空闲档各自的小计、三分项(未缓存输入 / 缓存命中 / 输出)各自的 token 数与花费、缓存命中率、峰谷状态行(空闲档 · 2 小时 15 分钟后转高峰)、两档生效单价。- 0.11.0 起浮层只留"数字 + 状态":原来最后三行静态说明(刊例价快照行 / 价格档段 / 峰谷判定段)搬去设置页的「计价说明」块。三行的去留各不相同:峰谷判定段在设置页本来就有一份同义文字,直接删(纯去重);快照行整行搬走,但"这份价是你自己填的"缩成单价行上的
· 已自定义小标(只在你覆盖过价时出现);价格档段改成只在非默认时出现 —— 本会话用到的档 ≠ 当前生效的档才显示。最后这条不能简单删:跨了 9-10 调价的会话,浮层上的单价是今天的价、金额却是当时的价,不说明反而是误导。用户 2026-09-29 的原话是"图里这些说明放到设置页面,使点开胶囊变得更简洁"。
- 0.11.0 起浮层只留"数字 + 状态":原来最后三行静态说明(刊例价快照行 / 价格档段 / 峰谷判定段)搬去设置页的「计价说明」块。三行的去留各不相同:峰谷判定段在设置页本来就有一份同义文字,直接删(纯去重);快照行整行搬走,但"这份价是你自己填的"缩成单价行上的
- 字号与亮度照抄官方那颗胶囊,不自己定:文字大小/行高取官方
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:简写(简写会把字号重置回继承)。 - 为什么是浮层而不是就地展开:输入卡片会裁掉溢出内容,官方那几颗胶囊(ContextMeter、时间胶囊)也都是浮层;这里用 React portal 挂到
document.body,位置由胶囊的视口矩形算出,点别处或按 Esc 关闭。 - 钱是怎么算的:
未缓存输入 × miss价 + 缓存命中 × hit价 + 输出 × out价(每 1M tokens)。cacheWriteTokens不单独计价(官方价表只有"缓存命中/未命中"两行输入价,且实测本机投影里恒为 0,所以它只进"计费输入"的显示、不进费用)。峰谷按每笔用量真正发生的时间判定,高峰价是空闲价的 2 倍;用户覆盖价由设置页「金额」栏提供。 - 峰谷规则(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 那个周六也算谷价),所以自动获取只收"放假"那几天。
- 法定节假日自动获取(0.11.0):中国的法定节假日算不出来(清明/端午/中秋走农历,放几天、哪天调休补班是国务院办公厅每年年底才公布的政策决定),所以本插件的做法是"日期表来自公开数据源,判定规则留在自己手里":
- 数据源:
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 镜像(同一份文件的两个入口)。 - 覆盖范围:今年 + 明年(按北京年判)。次年安排通常在前一年 11 月前后公布,所以国务院一公布就会被自动取回,不用等插件发版。2027 那份现在只有
{"year":2027,"days":[]}—— "还没公布"与"抓不到"是两种不同的话,界面分别说"20xx 年的安排还没公布"和"抓取失败"。 - 优先级:你手填的 > 自动获取的 ∪ 内置表。手填整份覆盖(你的意思不该被后台悄悄改掉);自动那份与内置表取并集而不是替换 —— 自动获取只覆盖"今年+明年",到了 2027 年 1 月,2026 年中秋/国庆那几天就不在里面了,拿它替换会让2026 年 9 月的旧会话被重新按高峰价显示(整整 2 倍)。内置表(
DEFAULT_PEAK_HOLIDAYS,2026 中秋 09-25~27、国庆 10-01~07)永远在兜底,离线也不会瞎算。 - 开关复用「自动同步」那把(默认仍关):同一个语义"这个插件可以自己出网",没必要再加一个开关。打开后每个年份最多 30 天复核一次(进程启动先查一次 + 每 30 分钟查一次"到期没有"),没有到期的年份一个请求都不发。也可以随时点设置页「同步」里的「获取法定节假日」立即取一次(手动点击 = 明确的同意,不受 30 天窗口限制)。
- 纪律:抓不到就什么都不改(两个入口都失败 → 报失败、本地表原样不动);抓到并写入设置后立刻
invalidateMoney()(不然后台取回新表、峰谷却仍按旧表判 —— 折叠结果里固化的是判定当时的事实)。 - 设置页看得见什么:节假日那块显示「当前生效:N 个日期 —— 来源:自定义 / 自动获取(含获取时间)/ 内置」,并有一个可展开的生效日期清单;「同步」区显示"法定节假日上次获取:… (已有 2026、2027 年)"。输入框里只放你手填的那一份,留空才代表"用自动获取的"。卡片的标题行概览也会写
节假日自动 N 天。
- 数据源:
- 价格历史档:旧账按当时的价固定(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 价),所以它两档同值。 - 峰谷为什么必须按事件时间判(0.9.1 修的既有缺陷):官方按请求发生时刻计费,而 0.8.0 拿"你打开面板的那一秒"当所有用量的时刻 —— 昨晚(空闲档)跑的会话今天上午 10 点看,整份会按高峰价显示,差 2 倍而屏幕上只是个数字。现在宿主半折叠时用每条事件的
time判档、按(provider, model, 档位, 价格档)拆桶,同一步的替换增量沿用该步第一次判定的档(否则一次跨过 09:00 的请求会被拆成两档、凭空多出一个"高峰用量");事件既没有request/header时间、自己也没有time时,判定函数收到NaN,宿主半退回"现在"(=旧行为),绝不静默判成空闲档。 - 峰谷提醒(0.10.0):明细页常显一行「现在哪一档、还有多久切换」(例如
空闲档 · 2 小时 15 分钟后转高峰,因节假日/周末而谷价时会写明原因),胶囊的title也带上;设置里可调提前几分钟(1–60,默认 5)、进峰前/离峰前分别提不提醒,以及要不要额外发浏览器系统通知(需要授权;去重放在模块级按切换点记 —— 放组件里会因为设置一变、组件重挂而连发几条同样的通知)。相位规则与宿主半同源(节假日表由/composer-ux/usage回给客户端),不会出现"胶囊说还有 3 分钟进峰、面板说不是"。 - 账号余额(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、也不显示旧值)。 - 一键同步(0.10.0 两条路,0.11.0 加第三条):卡里三个按钮,代价差两个数量级所以分开:
- 同步官方价:抓官方中英文两页(各约 24 KB)解析成人民币/美元两列,只有和当前档不同才新增一个价格档(原地改档 = 把历史账重算)。解析器对着真页面夹具逐项钉住(
test/official-pricing.mjs,45 条)。- ⚠️ 2026-09-29 事故与修法:官方英文价格页在不带结尾斜杠的路径上开始返回另一页(200 OK、4.8 万字节的"快速开始"内容、没有价格表),于是同步直接报失败。好在纪律是"抓不到就不覆盖本地价",只是同步不工作、没有算错钱。修法:两个 URL 都补结尾斜杠,并新增
officialPricingCandidates()—— 每页按"带斜杠 → 不带斜杠"依次试,解析成功才算到手;站点以后再对调两个形状,同步不用改代码就能自愈(变异条目CL/CM)。
- ⚠️ 2026-09-29 事故与修法:官方英文价格页在不带结尾斜杠的路径上开始返回另一页(200 OK、4.8 万字节的"快速开始"内容、没有价格表),于是同步直接报失败。好在纪律是"抓不到就不覆盖本地价",只是同步不工作、没有算错钱。修法:两个 URL 都补结尾斜杠,并新增
- 同步第三方价目:抓 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 单价",结果是所有峰价被静默算成谷价。 - 获取法定节假日(0.11.0):抓
holiday-cn的"今年 + 明年"两个 JSON,只收isOffDay:true。手动点击 = 明确的同意,不受 30 天复核窗口限制。 - 自动同步(默认关):勾上后宿主半每天最多自动抓一次官方价格页,并每个年份最多 30 天复核一次节假日(进程启动先查一次 + 每 30 分钟查一次"到期没有")。"该不该出网"由纯函数判定(
autoSyncDue/holidaySyncDue):开关不是真true、时间读不到、距上次成功不够久 —— 都不发请求。失败只写日志,界面仍显示上次成功的时间。 - 三条都守同一条纪律:抓失败绝不覆盖本地数据(网络抛错 / 非 2xx / 正文过短 / 只有一页成功 / 节假日两个入口都失败,各种情形都有断言)。
- 写完价目要立刻生效,靠的是"写的人通知",不是设置变更事件:0.1.7 的
settings/document-updated只在describe()里比对 raw 变化后发出,而我们自己mutate写设置时并不调describe()—— 设置页开着时会被界面刷新顺带触发,设置页没开着(例如后台的自动同步跑完)就不会发;而折叠缓存按seq去重、改规则不会重折旧事件,金额会静静地停在旧价格档上、界面上看不出。所以宿主半加了模块级moneyInvalidators注册表:用量路由登记"重读规则 + 作废价目缓存 + 作废折叠缓存",两条同步路径写完设置直接调invalidateMoney(),事件只作补充。
- 同步官方价:抓官方中英文两页(各约 24 KB)解析成人民币/美元两列,只有和当前档不同才新增一个价格档(原地改档 = 把历史账重算)。解析器对着真页面夹具逐项钉住(
- 内置第三方价目快照(0.10.0):
src/provider-prices.ts(生成的,别手改:node scripts/gen-provider-prices.mjs <models.dev-api.json>)收了 11 个 provider / 346 个模型,快照日期写在文件头。没点过同步时就是非 DeepSeek 模型的兜底价;点过一次「同步第三方价目」后最新数据会盖住同名条目,界面分别标明"内置快照价"还是已同步。查价优先级:用户覆盖价 > 已同步价目 > 内置快照 > 未定价。 - 非 DeepSeek 模型定价(0.10.0):按
(provider, model)查价 —— 精确 provider →PROVIDER_ALIASES别名(deepseek-official→deepseek、kimi-coding→moonshotai…)→ 按模型 id 全局唯一匹配(glm-5在两家都有的价时拒绝猜)。认不出价就说"未定价"(单价全 0,明细页写明"去同步第三方价目或给这行填个价"),不再把 DeepSeek 的 flash 价静默套到第三方模型头上 —— 编一个看着合理的假数字,比承认不知道更糟。 - 为什么金额必须自己算:DSH 送到浏览器的
tokenUsage投影只有 token 桶,全库没有一处把"钱"送到客户端;llm-pi-ai里那个cost只活在 provider 内部,而且用户自定义的路由(profile 里手写的 provider)拿到的是NO_COST(全 0)。所以费用只能由本插件按刊例价算。 - 数据全部来自官方、不新增采集:token 桶读
tokenUsage投影(与官方统计行同一份),模型读modelSelection投影(来自request/header事件)。会话中途换过模型时按最后一次请求的模型计价(近似,浮层里写明)。 - 按 route 分列与"逐笔准时"的取数(点开才去问宿主半;折叠态那颗胶囊也走同一条路由):面板里列出每条 route(provider + model + 高峰/空闲)的 token 与花费,金额由宿主半按档算好(用户覆盖价一并读自设置),所以"各行加起来等于总额""三项相加等于合计"都精确成立。逐请求归因只能宿主半做,走只读路由
GET /composer-ux/usage?sessionId=…(src/host.ts注册):首次(或发现seq落后)用官方sessionQuery.readSession(sessionId)完整读一次,之后靠session/event订阅增量喂折叠缓存(createUsageCache,纯逻辑在 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、来源哪条路",一眼就能定位。 - 折叠态那个数字怎么来的:客户端投影只有累计 token 桶、没有任何时间信息,所以"逐笔准时"的数字只能从宿主半拿(
src/client/session-cost.ts):最短 600ms 间隔、期间有变化排一次尾随请求(保证流结束后的数字是准的)、同一时刻只有一个请求在飞、旧响应按序号丢弃。宿主半那份还没到手、或会话日志与投影对不上时,退回本地的"按当前档位估算"并在面板里说明是哪种口径 —— 胶囊从不空着。 - 命中率与旁边那行必然是同一个数:面板只在"分列与投影四个桶全等"(
agreesWithProjection是精确比较)时才把分列当权威,此时分列算出的命中率与官方胶囊逐位相同;否则退回投影口径。两个坑:客户端投影的未缓存输入叫uncachedInputTokens、日志里叫inputTokens—— 第一版在billedInputTokens()里认对了名字,却在调用处传了inputTokens,于是分母丢掉整块未缓存输入、命中率恒 100%(旁边官方胶囊 98.206%)。现在调用处也走官方键名,test/client-registration.mjs同时钉住"调用处键名"与"与输入框下面那一行同一套函数"。 - 计价按
(provider, model)(0.10.0 起):DeepSeek 路由(provider 名含deepseek或模型名以deepseek开头)走官方价目表与历史档;其它 provider 走同步来的第三方价目,认不出就写"未定价"。所以用 OpenCode(Zen)之类的接口跑 DeepSeek 模型,用量仍按 DeepSeek 官方价进这个金额;跑非 DeepSeek 模型则按 models.dev 的价估算。代价如浮层所写:Zen 可能有自己的加价/订阅,这个数字不等于你付给 Zen 的钱。 - 别名与历史档的出处:
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 上线同日降价)对应官方更新日志的两个条目。 - 真实日志里会出现哪些 provider 名(2026-09-29 拿真会话核对):本机见过
go(自建别名,baseURLopencode.ai/zen/go/v1)、opencode-go(DSH 内置)与deepseek-account(桌面端账号通道)。三个都按官方价目算 —— 判据只是"名字含deepseek或模型名以deepseek开头",与具体名字无关,所以不需要额外配置。 - 出处与对拍:价目表、模型别名、峰谷规则与格式分档最早来自
dsh-plugin-usage-meter1.9.1(MIT,Copyright (c) 2026 fancr-code);0.10.0 起人民币列改用官方人民币页原值,历史档 / 节假日表 / 周末生效点与dsh-cost-meter1.7.44 交叉核对过。node test/pricing.mjs把那份实现的几个函数原样抄进来当基准逐样本对拍:刊例价表逐项、峰谷判定(含节假日与周末边界)、定价解析(模型 × 币种 × 时段 × 覆盖价)、费用、金额、Token 格式,外加历史档与"节假日对金额的影响"两节(同一笔用量在国庆当天与普通工作日相差恰好 2 倍)。 - 六条自开路由全部过了官方那道关卡:
connection.requestRejection(request)(Host/Origin 围栏 + 浏览器登录令牌)。webServer是可以绑0.0.0.0的,漏掉它等于把会话用量、设置写入与账号余额摊给同网段任何一台机器 —— 2026-09-29 评审时先补了金额那三条,同日对比社区插件时又发现**「优化提示词」与「快捷指令存储」这两条也漏着**(一个花你的模型额度、一个会覆盖你的提示词库),一并补上,且关卡摆在读请求体之前(被拒的请求连 body 都不读)。现在 6/6:用量、价目同步、余额、重启、优化、快捷指令存储(终端状态那条在terminal/host.ts里自带同一道关卡)。变异CN/CO守着这两条。 - 与出处有意不同的两处(都钉在测试里):① 金额至少保留两位小数(出处会把
0.1显示成¥0.1、把0显示成¥0.);② 覆盖价的币种语义——出处是"有覆盖价时先整体重建人民币档、美元再从人民币折算",写成"在美元列上套覆盖价再折算"会差一个汇率(≈15 倍)而屏幕上只是个数字,所以专门有一条断言守着。
维护:重启 DSH
装了新插件、改了宿主半代码(比如本插件的「默认终端」、键位 schema)之后,DSH 需要重启才会加载新代码。设置页「输入体验」卡片抬头右端(GitHub 链接左边)有一枚「重启 DSH」。
机制照搬插件市场 dsh-market(它的 src/restart.ts 里挂着一串 issue 号,每条都是"重启按钮按下去没用"的具体死法):
- 两步确认:点按钮先问宿主半"会怎么重启、当前有几个会话在跑",确认条出现在抬头正下方,点「确认重启」才真重启 —— 重启会打断正在跑的会话(包括正在生成的那一轮)。
- 分离一个 node 助手进程(
node -e <源码>,detached + unref),宿主自己 500ms 后退出(延迟是为了让这个 HTTP 响应先发出去)。 - 助手等端口真的空出来:每 250ms
connect探一次,最多 30 秒,通了再等 300ms(Windows 的 TIME_WAIT 尾巴)。固定 sleep 会让新宿主EADDRINUSE当场死掉。 - 用隐藏控制台的 PowerShell 起新宿主:Windows 上
detached=DETACHED_PROCESS= 没有控制台,新宿主之后起的每个控制台子进程都会弹一个黑窗口;powershell -NoProfile -WindowStyle Hidden给它一个隐藏控制台,助手那层再带windowsHide。 - 起来之后再验证 20 秒:端口没人监听就把诊断写进日志 —— 本来该记日志的宿主进程已经退出了,重启失败必须留证据。
- 界面靠
boot号判断成功:每 1.5 秒问一次状态,号变了(说明新进程接管了端口)就location.reload();60 秒还没变才报超时,并把日志路径告诉你。
其它几点:
- 日志:助手写
<系统临时目录>/composer-ux-restart-<时间戳>.out.log|err.log(失败时界面会把路径显示出来)。 - 两道关卡:先过官方
connection.requestRejection(Host/Origin 围栏 + 浏览器令牌),再过本插件自己的"回环 peer + 无转发头 +Origin与Host同源"——这是"杀进程"的接口,跨站页面一定带自己的Origin,挡在这里。 - 不该从界面里杀掉的宿主会拒绝:宿主正被调试器附着(
--inspect/inspector.url()),或者它在 systemd 下当服务跑(重启权归 supervisor,自己重启会把 cgroup 里的接管进程一起收掉)。这时按钮禁用并说明原因。 - 退出走
process.emit('SIGTERM')而不是process.kill(pid,'SIGTERM'):DSH 在apps/cli/src/profile-boot.ts注册了 SIGTERM handler(先fiber.dispose()再退出,自带 5 秒上限)。而 Windows 上process.kill等价于TerminateProcess—— 本机实测 handler 一次都跑不到。兜底:10 秒后还活着就exit(0)。 - 第一次点的时候,正在跑的宿主还是上一版(新路由要重启后才加载):界面会如实说明"这次走旧机制,重启之后按钮就是新版了"。
- 为什么 DSH 需要插件自己干这件事:官方没有重启宿主的机制(插件市场那边只提示「更改将在下次启动生效」),所以这件事只能由插件做。
证据与未验证(哪条有实测、哪条只是设计)
立这一节的起因:上游 WestFox-AwA/dsh-prompt-optimizer 的 README 里有一张已验证 / 未验证表,而且把"效果"这条最重要的缺口如实列为未验证 (他们的留出评估跑完了 S1,结论是"没测出差别",连曾作为主要卖点的"该不该问"判据都被自己判为无效)。 这比"把设计写成能力"诚实得多,照抄。下面只有本仓里能查到的证据才写进左表。
已验证(有可复现的证据)
| 能力 | 证据(都能在仓里重跑) |
|---|---|
| 结果框持久化:原子写 / 损坏隔离 / 版本信封 / 净化边界 | node test/optimize-state.mjs(69 条,含真路由 GET/POST 往返与"太大拒写");变异 DL/DM/DN/DO/DP/DQ |
| 重启后恢复的接线(只在框空着时恢复、去抖存盘、开关关掉不发请求) | 同上第 6 节(源码级断言 —— 组件行为在浏览器里,本仓测不到) |
| 面板分两半 + 结果框按钮钉底(0.13.1) | node test/panel-split-render.mjs(20 条,react-dom/server 真渲染:默认展开哪半、两半互斥、三个按钮在滚动区之外)+ client-registration.mjs 2.0g 节(11 条源码级) |
| 台账只记元数据、不落原文 | node test/optimize-ledger.mjs(50 条)+ test/quick-commands.mjs 5e 节:真路由跑一轮后在磁盘上的台账文件里搜不到草稿里那个独特的词 |
| 台账的原因消毒(模型给的引文不再被写进磁盘) | 同上;变异 DH(去掉消毒立刻红) |
| 台账轮转 / 逐行容错 / 清空 | node scripts/recap.mjs 真进程跑:--last / --session / --json / --clear 与坏行计数都在 test/optimize-ledger.mjs 第 6 节 |
| 只读工具围栏(含符号链接)/ 各类上限 / 越界拒绝 | node test/optimize-tools.mjs(66 条,真实临时目录;含"根目录本身是符号链接"那类 macOS 坑) |
| 工具循环的消息形状 / 轮次封顶 / 异常必须降级 | 同上第 6 节(假 llm 驱动整条循环,逐条检查 role:'tool' 与 toolCallId) |
| 工具路径:默认关、开了才派、查完吐散文必须回落、拿不到工作目录就不派 | test/quick-commands.mjs 5g 节(13 条,真路由 + 假 llm) |
| 工具读到的内容不进台账 | 同 5g 节:断言台账里只有工具名与次数、没有读到的文件内容 |
| 变异守卫真的会咬人,且必须是指定的那条测试变红 | npm run test:mutations:164 条全部咬住(判据是 red.some(line => line.includes(item.expect)),不是"有红就行") |
| 发布门禁三条判据 | npm run gates 全过;check:tag 实测 v0.12.0 的 tag 名与所指提交里的版本一致 |
| 三平台 CI | GitHub Actions:ubuntu / windows / macos × Node 20(npm ci → typecheck → build → test → check:pack → check:docs) |
未验证(只是设计成那样,或只在本机验过)
| 缺口 | 说明 |
|---|---|
| 效果(最重要的一条) | 我们没有任何受控评估 —— 没有留出集、没有 A/B、没有成本基线。"这样优化之后模型做得更好"在本仓里没有一条证据,只是设计意图。上游花钱做过一次,结论是"没测出差别",所以这一格必须留空。 |
| 0.13.0 的真机(实机)行为 | ①重启恢复、②台账落盘、⑤只读工具这三块只在测试与假 llm 里跑过,没有在真实 DSH 里点过(0.12.0 那 5 张截图也不覆盖新功能)。 |
| 0.13.2 的「✕ = 收起 / 丢弃 / 收起跨重启」 | test/panel-split-render.mjs 第 6 节(真渲染:收起时不渲染结果框本体、自动切换不抢面板)+ 变异 EE/EF/EG/EH/EI → 真机待重启后验(本人 2026-09-30 报的就是这条) |
| 0.13.1 的面板分半 | ✅ 有真机证据:2026-09-30 08:14 重启后当场验过「默认哪半、切换按钮换半、跑完自动切到优化半、到了高度上限按钮仍在」 |
| (三张截图见上面「实机(0.13.1)」);但没验"没有结果框时首次打开"的默认态(那要开一次干净的会话),这条只有渲染测试罩着。 | |
| 只读工具的收益与成本 | 真实项目里派工具能省多少来回、多花多少 token 与时间,没有数据。默认关闭正是因为这份不确定。 |
| "工具内容不会被当成引文"在真实模型上的表现 | 结构上成立(quote 必须逐字命中用户原话,否则逐条丢弃,有测试)。但模型会不会因此产出更差的结果,没测。 |
| 原子写的原子性本身 | 单线程测试观测不到"写到一半";只有"不留临时文件"这条卫生断言,没有变异证明(已在提交信息里写明,不假装验过)。 |
| 围栏的跨平台性 | 围栏与路径归一在 Windows 本机验过;Linux/macOS 只有 CI 跑同一批测试(符号链接那条在 POSIX 上是普通 symlink)。没有在 macOS/Linux 上真机装过。 |
| 会话上下文「只用于消歧义」 | 0.12.0 起就声明过:这是结构约束(上下文内容不能当引文),不是对模型行为的保证。 |
sync-types 的边界 |
它是形状核对,不是类型代码生成:宿主 API 真变了但形状没变的那种情况查不出来。 |
| 界面语言 | 只有中文界面(上游做了 zh/en 跟随 DSH)。英文用户能读 README.en.md,但界面本身不跟语言。 |
结构
仓库根目录(克隆或解压出来即是):
dsh-composer-ux/
├── package.json # dsh.client 清单(platform: web)+ exports["./client"] + dsh.bundle.patch 清单
├── cordis.patch.yml # 组合包层(随包发布):按包名 dsh-composer-ux 插入插件行
├── cordis.dev.patch.yml # 本地开发覆盖层(file:/// 绝对路径,已 gitignore,不随包发布)
├── CHANGELOG.md # 版本更新日志
├── README.en.md # 英文版说明(独立可读;中文全版仍是权威)
├── README.simple.md # 小白版说明(三步上手 + 常见问题,讲人话)
├── build.mjs # esbuild 构建:lib/index.js(Host)+ lib/client.js(浏览器),并做发行后处理
├── tsconfig.json # typecheck 配置(只开 strictNullChecks,理由见文件内注释)
├── types/dsh-externals.d.ts # @deepseek-ai/* 的手写类型面(不去追那些版本线对不上的包)
├── .github/workflows/ci.yml # 三平台 CI:ubuntu / windows / macos × node 20
├── scripts/
│ ├── gen-provider-prices.mjs # 从 models.dev 的 api.json 生成 src/provider-prices.ts(内置第三方价目快照)
│ ├── live-smoke.mjs # 手动联网复核:官方页 / models.dev / 余额端点 / 节假日数据源(只读;不进 npm test)
│ └── dsh-shape-check.mjs # 手动形状核对:我们依赖的宿主服务名/方法/事件是否还在(对着 DSH 源码检;只读)
├── src/
│ ├── host.ts # Host 半:settings namespace + 请求头镜像 + 「默认终端」+ 金额三条路由(用量/价目同步/余额)+ 「重启 DSH」
│ ├── settings-contract.ts # 字段/默认值/菜单元数据 + 栏开关的迁移判据(零依赖共享)
│ ├── restart.ts # 「重启 DSH」:助手进程/等端口/隐藏控制台/信任关卡/优雅退出(零 import)
│ ├── pricing.ts # 金额:历史价档 / 节假日与峰谷判定 / provider 感知定价 / 覆盖价 / 费用与格式(零 import,两半共用)
│ ├── usage-fold.ts # 金额:会话事件按 (provider, model, 峰谷档, 价格档) 归因折叠 + 增量缓存(零 import,宿主半用)
│ ├── official-pricing.ts # 金额:官方价格页 HTML → 人民币/美元两列价(真页面夹具逐项钉住,零 import)
│ ├── price-sync.ts # 金额:抓官方两页合成价格档 + models.dev 压成第三方价目 + 落盘 + autoSyncDue(宿主半,允许 node API)
│ ├── prompt-context.ts # 优化:会话上下文(挑往来 / 收敛预算 / 渲染成块,纯函数;0.12.0)
│ ├── optimizer-assemble.ts # 优化:逐字校验 + 装配 + 流式扫描(批处理与流式共用同一份校验)
│ ├── optimizer-prompt.ts # 优化:三档系统提示词 + 固定 JSON 契约(自定义提示词不能替换契约)
│ ├── optimize-ledger.ts # 优化:逐轮台账(只记元数据 + 原因消毒 + 轮转/容错,0.13.0 新增)
│ ├── optimize-state.ts # 优化:结果框状态落盘(原子写 + 损坏隔离 + 体积上限 + 版本信封,0.13.0 新增)
│ ├── optimize-tools.ts # 优化:只读查证工具(read/glob/grep:围栏 + 各上限 + 跳过规则,0.13.0 新增)
│ ├── optimize-tool-loop.ts # 优化:工具循环(工具结果消息形状 + 轮次封顶 + 异常必降级,0.13.0 新增)
│ ├── holiday-sync.ts # 金额:法定节假日自动获取(holiday-cn 今年+明年 → 日期表;补班日不收;到期判定/镜像兜底/失败不改动)
│ ├── provider-prices.ts # 金额:内置第三方价目**快照**(生成物,11 provider / 346 模型;由 scripts/gen-provider-prices.mjs 生成)
│ ├── balance.ts # 金额:官方余额响应消毒 + 查询端点白名单(零 import,两半共用)
│ ├── client.tsx # Browser 半:设置页 + 菜单浮层 + 拦截器
│ ├── terminal/ # 「默认终端」(Windows:pwsh → Git Bash),全部零官方运行时依赖
│ │ ├── discover.ts # 探测:git 锚定反推 bash、硬排除 WSL、多候选排序(纯函数)
│ │ ├── render.ts # 结果渲染与 exit 状态解析(与官方逐字对齐)
│ │ ├── sandbox.ts # 沙箱策略面 + 升级审批(fail-closed,与官方同语义)
│ │ ├── tool.ts # bash 工具定义:schema / 执行 / 后台 / 中止 / 终端卡片
│ │ ├── contracts.ts # 字段、三档、候选净化、生效判定、状态行文案(纯函数)
│ │ └── host.ts # 宿主半接线:按会话下发 restrict+register+section、立刻覆盖在跑会话
│ └── client/
│ ├── chords.ts # 键位编码/录制校验
│ ├── interceptors.ts # keydown/contextmenu 捕获拦截 + 回放
│ ├── SettingsSection.tsx # 设置页「输入体验」(顶部常显 + 折叠栏目 + 默认终端 / 统计行卡片)
│ ├── settings-style.ts # 折叠卡片样式表(dsh-ux-* 类名注入)
│ ├── ContextMenuHost.tsx # shell.overlay 右键菜单
│ ├── QuickCommandsButton.tsx # 快捷指令:工具行入口按钮(order 89)
│ ├── QuickCommandsPanel.tsx # 快捷指令:展开面板(分类两级 + 条目 + 优化入口)
│ ├── optimize-clock.ts # 优化:面板与结果框共用的秒表读数(0.11.1 新增)
│ ├── OptimizeDock.tsx # 优化:面板内结果框(逐条流水 + 可编辑成品 + 插入,0.12.0 新增)
│ ├── optimize-dock.ts # 优化:结果框的纯函数状态机与文案(0.12.0 新增,可单测)
│ ├── optimize-ledger.ts # 优化:逐轮台账(只记元数据 + 原因消毒 + 轮转,0.13.0 新增)
│ ├── optimize-state.ts # 优化:结果框状态的客户端读写层(GET/POST + 读回来先净化,0.13.0 新增)
│ ├── quick-commands.ts # 快捷指令/优化:输入框桥接、插入与写回、斜杠命令与秒表纯函数
│ ├── stats-line.ts # 统计行:小数语义(不撒谎)+ 两个字符串变换(纯逻辑)
│ ├── stats-dom.ts # 统计行:定位与改写(DOM 助手,不 import React)
│ ├── StatsLineEntry.tsx # 统计行:composer dock 上的隐形条目 + 观察器(React 胶水)
│ ├── CostChipEntry.tsx # 金额:composer dock 上的金额胶囊 + portal 浮层明细(React 胶水)
│ ├── session-cost.ts # 金额:向宿主半取"逐笔准时"的费用(节流 + 尾随 + 单飞)
│ ├── money-admin.ts # 金额:价目同步与余额查询的宿主调用封装(两条独立路径)
│ ├── peak-alert.ts # 金额:峰谷相位、倒计时文案与(可选)系统通知
│ ├── CostCard.tsx # 金额:设置页那一栏(单价 / 节假日 / 峰谷提醒 / 余额 / 同步价目)
│ └── styles.ts # --dsw-* 令牌内联样式
├── docs/settings-panel-0.5.0.png # README 顶部那张设置页截图(用 tag 固定的 raw 链接引用,不进 npm 包)
├── docs/promo/ # 一页推广 PPT(生成脚本 + 产物;不进 npm 包)
├── test/
│ ├── host-header-mirror.mjs # 请求头镜像的行为测试(假 settings 驱动构建产物)
│ ├── host-settings-generations.mjs # 宿主半两代设置服务(0.1.6 的 get/register 与 0.1.7 的 describe)
│ ├── quick-commands.mjs # 快捷指令 / 优化接口 / 路由注册 / 重启机制
│ ├── quick-store.mjs # 快捷指令文件存储与写回路径
│ ├── terminal-policy.mjs # 默认终端:探测 / 渲染 / 升级审批 / bash 工具 / 宿主半接线
│ ├── terminal-mount.mjs # 官方持久终端三件套:挂载行结构 + 用加载器同一个求值器真跑 !!js 探测与守卫
│ ├── stats-line.mjs # 统计行:小数语义(与官方源码逐例对)/ 文本与 aria-label 变换 / 开关
│ ├── stats-dom.mjs # 统计行:假 DOM 钉住定位、改写范围与"不碰样式"
│ ├── pricing.mjs # 金额:与出处实现(MIT)原文逐样本对拍 + 有意差异 + 历史档与节假日
│ ├── restart-screen.mjs # 重启屏:阶段机 / 进度自适应 / 标记兜底 / 假 DOM 跑一遍(0.15.0)
│ ├── usage-fold.mjs # 金额:按 route 归因的折叠规则(差分 / 归属 / 脏数据 / 价格档 / clear())
│ ├── price-sync.mjs # 金额:价目同步的消毒与四条失败路径(抓不到绝不覆盖本地价)
│ ├── official-pricing.mjs # 金额:官方价格页解析(真页面夹具,45 条)
│ ├── balance.mjs # 金额:余额响应消毒(80 条)+ 端点白名单(伪装域逐例)
│ ├── client-registration.mjs # 客户端注册协议、右键行为、抬头按钮、七栏开关、金额栏护栏与设置写入校验
│ ├── settings-service-adopt.mjs # 客户端两代设置服务认领(settingsScope / configForms / 都没有)
│ ├── mutation-guards.mjs # 变异测试(手动跑):把每条护栏拆掉,测试必须变红
│ ├── settings-render.mjs # 真渲染测试:借 profile 的 react 把设置页渲染成 HTML(已进 npm test)
│ ├── cost-panel-render.mjs # 真渲染测试:同样的手法渲染「金额浮层」(把 portal / 展开态 / 宿主数据在内存里替换掉)
│ ├── check-sections.mjs # 升级前自查(只读):拿真实设置文档跑一遍栏开关迁移(settings.yaml / settings.yaml.imported 都试)
│ └── opencode-header-wire-probe.mjs # 线级探针:本地端点,用来看 DSH 出网请求带了什么头
└── lib/ # 构建产物(运行所需)
构建
CI(GitHub Actions,.github/workflows/ci.yml)是 ubuntu / windows / macos × node 20 三平台矩阵,
每一步都是 npm ci → typecheck → build → npm test;先 build 再 test 是刻意的
(测试打的是构建产物,不重建就是拿旧产物测新源码)。本机也能复现 CI 的两条边界:
DSH_REPO_PATH=<不存在的路径>(走"没有 DSH 检出"的退路)与 TZ=UTC(CI 的时区)。
npm ci # 按 lockfile 安装(CI 用这条)
npm run typecheck # tsc --noEmit(类型面在 types/dsh-externals.d.ts,手写)
npm run sync-types # 对着 DSH 源码核对本插件依赖的**宿主形状**(只读,需 DSH 检出)
# 名字沿用社区插件工程面的叫法;我们**不做类型代码生成**:
# 手写类型面 + 形状核对,比生成一份"跟着版本线漂"的类型更诚实
node build.mjs # 产出 lib/index.js + lib/client.js
# ⚠️ 宿主半会**内联** schemastery / cosmokit:
# 优先用 DSH 检出里的 vendor 副本,检出不在时退到 node_modules
# 里同版本的 npm 包(两者逐字节相同)——CI 上走的就是退路
npm test # 24 个套件;当前 2384 passed, 0 failed(2026-09-29 实测;CI 三平台同样全绿)
# 走 scripts/run-tests.mjs:顺带把"多少套件/多少条"记进 test/.last-run.json
npm run test:mutations # 手动跑:变异测试,证明那套护栏真的在咬人(164 条,须单独跑)
# 同样记录结果,供下面的文档门禁核对
npm run gates # 发版门禁三条一起跑:tag 指向 / 包内容 / 文档数字
npm run check:tag # ① tag 名里的版本 == 该 tag 所指提交里的 package.json 版本
npm run check:pack # ② npm pack 的文件清单 == files 白名单(不许夹带 src/test/scripts/docs)
npm run check:docs # ③ README 的套件数/条数 == 最近一次真实运行;引用的截图本地都在
node test/settings-render.mjs # 已进 npm test:把设置页真渲染成 HTML,断言版式与互斥显示、以及"非默认设置"下的「金额」卡(78 条)
node test/cost-panel-render.mjs # 已进 npm test:把「金额浮层」真渲染成 HTML,断言分列 / 峰谷行 / 未定价 / 内置快照价 / 明文没混进 markdown 记号(19 条)
node scripts/live-smoke.mjs # 手动跑:**联网复核**(官方页 vs 写死的价目表逐格对比 / models.dev vs 内置快照逐条对比 / 余额端点白名单与状态码);只读,不写任何文件
node scripts/dsh-shape-check.mjs # 手动跑:**对着 DSH 源码**核对我们依赖的宿主形状(服务名 / 方法 / 事件 / 版本),升级 DSH 后必跑;只读
node test/check-sections.mjs # 只读:升级前看七栏会变成什么
node test/host-header-mirror.mjs # 只跑宿主半的请求头测试(写入/撤销/改名/幂等/不误删)
node test/host-settings-generations.mjs # 只跑宿主半的「两代设置服务」兼容(0.1.6 / 0.1.7 形状)
node test/terminal-policy.mjs # 只跑「默认终端」(探测 / 渲染 / 审批 / 工具 / 接线)
# 线级探针(可选):起一个本地端点,再把某条路由临时指过来,即可看到真实出网请求头
node test/opencode-header-wire-probe.mjs 8799 600000
# 若 DSH 源码检出不在默认路径:
$env:DSH_REPO_PATH='D:/DeepSeek Harness'; node build.mjs
加载(从 DSH 源码检出)
cd <你的 DeepSeek Harness 检出目录>
pnpm dsh web --patch <本仓库的绝对路径>/cordis.dev.patch.yml
把尖括号替换成你的真实路径;插件行的 name 必须是 file:/// 形式的绝对 URL(Windows 下 D:/... 裸路径无法被 ESM loader 导入)。该文件只在本机存在(已 gitignore),内容如下:
- insert:
- id: composer-ux
name: 'file:///<本仓库的绝对路径>/lib/index.js'
开发时热更新
- 改完
src/后执行node build.mjs; - Client 半(
lib/client.js):客户端包在激活时读进内存,之后要靠 bundle 重新扫描(rev 变化)才会下发新内容;刷新浏览器即生效。 - Host 半(
lib/index.js):必须重启 DSH 进程。Cordis 加载器只在插件行的name变化时才重新import(vendor/loader/lib/index.js),而 ESM 按 URL 缓存模块,改了文件内容不会失效——实测加临时探针并改动插件行name触发重载,探针都没有出现在启动日志里。所以:功能开关是随点随生效的,但宿主半代码的更新要重启一次。 - 加/删/改插件行本身(
cordis.patch.yml)是 live 的(profile 的patchReload: live),不用重启。
设置持久化
除快捷指令之外的设置存在 profile 的 cordis.patch.yml 里本插件那一行($DSH_HOME/profiles/<profile>/cordis.patch.yml 的 - id: composer-ux → config)——0.1.7 起上游已废弃 $DSH_HOME/settings.yaml(它被改名成 settings.yaml.imported 后一次性导入);0.1.6 及以前才是 settings.yaml 的 composer-ux 分区。删掉那一项即恢复这些设置的默认值。
快捷指令(分类 + 条目 + 插入模式)不在设置文档里,它存 $DSH_HOME/quick-prompts.json(见上「数据存在哪」)。设置文档里那份 quickPrompts 只是 0.2.x 的迁移种子:只在 quick-prompts.json 不存在时被读一次(用来把旧列表搬成「默认」分类),平时不参与任何行为,插件也不会再写它。
- 确认不需要这份回滚兜底后,可以从设置文档里手动删掉它(连同
quickPrompts:这一项)——不影响插件运行;之后若 JSON 丢失,只会回落到内置 9 条。删掉后它不会自己长回来。 - 想要真备份请复制
quick-prompts.json,不要指望这份种子:它是旧快照,不会随你的后续修改更新。
⚠️ 设置写不进去 / 点了没反应:两种成因与恢复
第一层:写入锁成了孤儿锁。 DSH 用 <profile>/package.json.lock(wx 独占创建 + 内容为持有者 PID)串行化跨进程写入,对争用方的规定是「绝不删除已存在的锁——锁的年龄证明不了持有者已经停下;孤儿锁属于操作者动作」。而硬杀(restart-webui.bat 的 taskkill /T /F)只要正好落在一次设置写入中间,这把锁就永久留在磁盘上。后果不是"某次写入失败",而是该 profile 此后每一次设置写入都在 2 秒后超时:读得到、写不进,界面上只表现为"点了没反应",一句报错都没有(2026-09-23 真机连撞两次,两次都是重启留下的)。
- 0.6.1 起的自动回收:宿主半在启动时读一次锁里的 PID,只有确认该进程已不存在才删(内容认不出、PID 还活着、权限不足无法判定 → 一律不动)。判据与理由见
src/settings-lock.ts,逐条钉在测试第 12 节。 - 手工恢复(自动回收没赶上时):
Get-Content "$env:USERPROFILE\.dsh\profiles\web\package.json.lock" # 先确认持有者 PID 是否还活着
Remove-Item "$env:USERPROFILE\.dsh\profiles\web\package.json.lock" -Force
第二层:写入落盘了,但界面还是旧值。 这说明运行中的进程没有把这次配置变更接进运行时(文件里的值是对的)。设置页自 0.6.1 起会在写入被拒、或"写了但运行时没变"时把原因显示在卡片顶部;遇到第二种情况重启一次 DSH 即可(重启会按文件里的值启动)。
发行包与「装前体检」
插件市场的装前体检会扫描宿主代码里的混淆/动态执行特征(eval / new Function / 超长 base64 块)。本插件的 lib/ 里这些特征为零:
- 内联的 schemastery 带有一条「字符串回调 →
new Function还原」的分支。本插件所有 schema 都传函数回调,从不使用字符串回调,因此build.mjs在打包后会把该分支替换为等价空实现;若将来依赖升级导致模式失配,构建会直接报错退出,不会悄悄带着new Function发行。 - 构建与测试命令见上文「构建」;
lib/为纯 JavaScript,安装时不需要执行任何构建脚本(因此不需要 pnpm 的构建授权,也不会在安装期于用户机器上执行代码)。
与官方版本兼容提示
- 依赖的稳定接口:
settings.section、shell.overlay槽位、ctx.settingsScope、ctx.settings(Host)、[data-composer-input]DOM 标记。 - 浏览器包外部依赖仅限平台种子词(react / react/jsx-runtime / react-dom / @deepseek-ai/cordis / dsh-client-store / ui-slots / ui-primitives),其余全部内联。