dsh-client-ui-zero-friction
零摩擦单框交互 —— 一个真正的 DSH 客户端插件,跑在现有的 DeepSeek Harness Web GUI 里。
侧栏出现 零摩擦 入口,主区域是一个工作区:一个输入框,打完字回车,插件判断这句话该发给哪个真实会话;左侧是按「多需要你看」排序的注意力队列;右侧是学到的偏好与跨会话检索;顶部是摩擦计数器。
关于截图:开发过程中留下了 17 张真实 GUI 的验证截图,但它们拍到了真实会话的标题与结论,属于私有数据,因此没有随仓库发布。本文里的验证记录保留了每条证据的原始描述,去掉了图片路径。
它解决什么问题
对话越来越多是一个趋势,而每一次「点开某个对话框 → 想清楚该发给谁 → 切过去 → 打字」都是一次分叉。分叉是摩擦的主要来源,等待是另一个。
这个插件试着取消分叉,而不是优化它:
- 不问「你要发给谁」——判断不了就发给最可能的那个,把「不是它?改投给…」摆在手边。误投的代价被压到一次点击。
- 队列不按时间排,按**「多需要你看」排。只有只有人类能推进**的事才进最上面那一组。
- 摘要不用额外调模型——主机已经把
projectionValues.turnOutline 算好挂在列表快照上了。
能力与数据来源
这一版没有 mock 数据。所有东西都接到了运行中的应用上:
| 能力 | 接的是什么 | 状态 |
|---|
| 会话列表 | ctx.sessions.list(Session 控制器的反应式快照) | ✅ 真实 |
| 投递消息 | ctx.sessions.using(id, …).prompt([{type:'text'}], 'queue') | ✅ 真实 |
| 打开会话 | ctx.uiWorkspace.openSession(id)(走 ctx.get 反射,不占激活依赖) | ✅ 真实 |
| 跨会话检索 | 本地搜 projectionValues.turnOutline(主机全文索引在 web/desktop profile 是关的,见坑 #4) | ✅ 真实 |
| 路由偏好 | localStorage,按天持久化,可停用 | ✅ 真实 |
| 摩擦计数 | 真实记录每一次手动切换 / 自动投递 / 纠正 | ✅ 真实 |
| 自动摘要 | projectionValues.turnOutline:主机维护的逐轮大纲,每轮带 prompt + response | ✅ 真实 |
| 自动命名 | 主机自己给的 title(LLM 生成),队列直接显示 | ✅ 由 DSH 提供 |
| 「真的在等你」判定 | userQuestions.active / plan.pending —— 真信号,不靠猜 | ✅ 真实 |
| 进度与健康 | sessionStats.turns、contextPressure、goal.objective/phase/roundsStarted | ✅ 真实 |
| 自动归入(新话题开新会话) | ctx.sessions.create({cwd}):接不住的句子不硬塞进旧会话 | ✅ 真实 |
| 路由候选集 | 只含主会话;子智能体(parentId / origin === "subagent")显式排除,标题上带 ↳ 标记 | ✅ 真实 |
| 摩擦账本 | 按天累计(localStorage),带一个可证伪的指标 | ✅ 真实 |
| 低把握发送前护栏 | 把握 < 65% 时,发送之前就在预览里警示并列出改投目标(一键选定,不拦回车) | ✅ 真实 |
| 没证据就问你 | 标题匹配不上时不再替你猜(旧版会静默"沿用上一次用过的"):按回车弹出选择面板,默认「开个新会话发出去」 | ✅ 真实 |
| 就地汇报视图 | 选中会话即在中间栏展开它的逐轮「你问 / 汇报」;不跳页面,子智能体作为"下属"列出来 | ✅ 真实 |
关键发现:这些全都不需要读消息正文。DSH 已经把一切都算进 projectionValues 里挂在列表快照上了——零额外 RPC、零模型调用、零解析。我原本打算去啃事件窗口(reference.binding.eventSource),探针一跑才发现根本不用。
队列因此从"近似"变成了真判断:
- 🔴 真的在等你 ——
userQuestions.active 或 plan.pending(即使它没在跑)
- 🟡 正在跑 ——
running === true,或队列里还有活等它处理
- ⚪ 已停下 —— 其余,按最后互动时间排
- ↳ 子智能体
摘要卡片给的是:目标(🎯 objective · phase · 第 N 轮)、你问、结论、上一轮、轮次、上下文占用,外加「打开」和「让它自己总结」(后者会向那个会话投一句话,是唯一走模型的路子)。
安装
环境:Windows + DeepSeek Harness 桌面版。脚本会在 pwsh 不可用时回退到 Windows PowerShell 5.1,两者都测过。
一键方式:双击 install.cmd。它是幂等的,并且会先备份 profile、写完自校验、失败自动回滚。
install.cmd # 装进默认的 desktop profile
install.ps1 -ProfileDir <路径> # 装进别的 profile
install.ps1 -DshHome <路径> -ProfileDir <路径> # 完全自定义
手动方式,等价于脚本做的三件事:
-
profile 的 package.json 里 dependencies 增加一项、dsh.profile.bundles 增加一项:
{
"dependencies": {
"dsh-client-ui-zero-friction": "link:<本仓库的绝对路径>"
},
"dsh": { "profile": { "bundles": ["…", "dsh-client-ui-zero-friction"] } }
}
用 link: 而不是 file: 是刻意的:file: 会被 pnpm 硬链接复制,源码改了 profile 里还是旧的;link: 建立 Junction 指向源码,改动实时生效。
-
用 DSH 自带的 pnpm 安装:
$pnpm = "…\DeepSeek Harness\resources\runtime\pnpm\bin\pnpm.mjs"
cd ~\.dsh\profiles\desktop
node $pnpm install
-
这个包自己是 bundle:dsh.bundle.patch 指向 cordis.patch.yml,里面插入一行
- id: dsh-client-ui-zero-friction name: dsh-client-ui-zero-friction。
行 id、包名、浏览器模块 id 三者刻意完全一致 —— 客户端半区是挂在「说明符恰为包名」的那一 Loader 行上的,三者一致可以消掉一整类挂载失败。
dsh plugin --profile desktop add … 用不了:CLI 明确拒绝,profile "desktop" is managed exclusively by the Electron application。所以走的是改 profile 文件 + pnpm 这条路。
install.cmd 里 .cmd 包装器是必需的:.ps1 默认未签名会被执行策略拦下,而且包装器保证用对编码(见坑 #5)。
启用
先刷新页面(Ctrl+R)。如果侧栏没出现「零摩擦」,再重启 DeepSeek Harness。
为什么可能刷新就够:dsh-client-modules 的宿主半区会监听 Loader 图变化并重新组合浏览器模块名单,而 dsh-hmr 的 profile 配置默认是热重载的。所以安装之后应用有可能已经重组过、刷新即可生效。
为什么也可能需要重启:模块名单是宿主半区在启动时组合进 window.__DSH_BOOT__ 的,如果那次热重载没被触发,就得重启才重编。
注意:不要用不带 token 的 http://127.0.0.1:<port> 打开界面——那只会得到 dsh web authentication required。正确的地址是 dsh web 启动时打印的那一条(带 ?token=…)。
卸载
uninstall.cmd # 移除依赖与 bundle 项
uninstall.ps1 -RemoveNodeModules # 连 node_modules / lockfile 一起清
卸载后同样需要刷新或重启。
它改动了什么
只改 profile 的 package.json(加一项依赖 + 加一项 bundle),并用 DSH 自带的 pnpm 在 profile 里建一个指向源码目录的 Junction。原始文件备份在 profile 的 .install-backup\ 下。
实测出来的 API 事实
DSH 的客户端插件 API 没有公开文档,下面每一条都是在真实运行的应用里探出来的(window.__ZF_DEBUG__.dump()),不是从压缩代码里猜的。写别的 DSH 插件时能省很多时间。
| 事实 | 说明 |
|---|
ctx.get(name) 能拿到没声明的服务 | 我用它取 uiWorkspace。ctx.get('uiWorkspace') → 有值,openSession 是函数 |
ctx.name 取没声明的服务会抛错 | cannot get property "uiWorkspace" without inject —— 不是返回 undefined。所以漏写 inject 的后果是异常,被 try/catch 吃掉后就变成"功能静默失效" |
sessions.list.getSnapshot() → { ids, byId, phase } | byId 每项:id / displayTitle / running / blank / updatedAt / cwd / retainedBy / parentId / origin / projectionValues |
sessions.using(id, { source }, cb) | cb 收到 reference;reference.binding = { sessionId, session, eventSource, ctx };投递用 binding.session.prompt([{type:'text', text}], 'queue') → {ok:true} |
sessions.create(opts) | 直接返回新会话 id 字符串,失败抛 SessionCreateError。opts 是 { workspaceId } 或 { cwd } |
sessions.search(query) | 本 profile 下不可用:{ok:false, error:{code:"gateway/internal"}}(全文索引 openAt: never) |
projectionValues.turnOutline | 逐轮 { turn, seq, prompt, response } —— 现成的摘要来源 |
projectionValues 其它有用字段 | title / goal(objective,phase,roundsStarted) / sessionStats.turns / contextPressure / userQuestions.active / inbox / plan.pending / sessionListMetadata.lastPromptAt |
inbox 的真实语义 | 装的是排队等 agent 处理的消息(含你自己刚投的那条),非空代表"agent 还有活要干",不是"在等你"(见坑 #8) |
eventSource.getSnapshot() | { entries, hasMore, revision, change },entry 是原始事件 {type:"event", event:{type, seq, time, data}}(不是渲染好的消息,别指望直接读文本) |
| 客户端 bundle 形态 | window.__ModuleLoader__.load({ id: 包名, factory: (require) => {...} }),可 require("react") / , |
踩过的坑
这十条都是真踩过的,其中几条差点让功能"看起来能用其实永远是坏的"。
1. Cordis 只暴露声明过的服务 —— 差点让整个面板变成空的
第一版我写的是 const inject = ["slots"],但代码里用了 ctx.sessions。Cordis 只把 inject 里声明过的服务挂到插件上下文上,没声明的就是 undefined。
后果极具欺骗性:ctx.sessions 是 undefined → 代码走防御分支 → 面板正常渲染,只是永远显示「会话 0」、永远发不出消息。和一个"确实没有会话"的空实例表现完全一样。
修法:const inject = ["slots", "sessions"]。参照系是 DSH 自己的插件:
ui-conversation 声明 ["slots","sessions","fileUpload","uiSession","uiWorkspace","locale","configForms"],
ui-workspace 声明 ["slots","sessions","workspaces","locale","remote",…]。
uiWorkspace 刻意不声明——它只在点「打开会话」时用到,晚到无所谓,走 ctx.get("uiWorkspace") 反射读取,少一个激活依赖。
tools/verify.cjs 里有一条静态检查专守这个:扫出 bundle 里所有 app.X / ctx.X 访问,凡是不在 inject 里的就报错。
2. 我一度得出「必须重启」的错误结论
因为 GET /plugins/<包名>/client.js 返回 404。那个 URL 是错的。 真实 URL 是 combo 形式:
plugins/??dsh-client-ui-zero-friction/client.js&rev=a740b732a93a
?? 本身既是路径也是查询分隔符,且必须带精确 rev。少了 rev,任何模块都 404,与插件装没装无关。这条假证据一度被当成了结论。
3. 测试台曾经"印证"了一个错误的返回值假设
真机实测发现:会话确实建出来了,但界面报「投递失败:新建会话没有返回 id」。
原因是猜 sessions.create() 返回 { ok: true, value: { sessionId } },而真机是直接返回 id 字符串、失败时抛错(ClientSessions.create 的最后一行就是 return result.value.sessionId)。
真正的问题不在猜错,而在假 sessions 服务也用了我猜的那个结构 —— 于是 49 项回归全绿,却完全测不出这个错误。测试台在用我的假设验证我的假设。
修法:把假服务改成照抄真机契约(返回字符串、失败抛错),并在注释里写明"必须照抄,否则测试台会印证错的假设"。
教训:mock 的返回值必须来自真机观测,不能来自"我觉得应该是这样"。
4. 「跨会话检索」曾经是一个永远返回空的功能
第一版直接调 ctx.sessions.search(query),README 里还写着「接主机的内容索引」。真机一探:
{ "ok": false, "error": { "code": "gateway/internal", "isDSHRemoteError": true } }
web/desktop profile 的主机全文索引是关闭的 —— dsh-web-app 的 patch 把 session-query-sqlite 设成了 openAt: never("Full-text session search is opt-in")。所以那个功能一次都没成功过,而且在界面上表现得只是"没命中"——静默失效,比报错更糟。
修法:改成本地检索。主机已经把每一轮的 prompt/response 算进 projectionValues.turnOutline 了,直接在内存里搜即可 —— 零 RPC、零延迟、不依赖任何主机开关。顺带把「边打边搜」做了(不用按回车),并加了「探测主机索引」按钮:它把主机的真实回答原样报出来,而不是让功能继续静默失效。
教训:一个依赖外部开关的功能,必须有一条路径能让它把"我这里没开"说出来。
5. 安装脚本会把应用弄到起不来(写 BOM)
第一版 install.ps1 用了:
$json | ConvertTo-Json -Depth 12 | Set-Content $pkgFile -Encoding UTF8
在 Windows PowerShell 5.1 下,Set-Content -Encoding UTF8 会写进 UTF-8 BOM。而带 BOM 的 JSON,DSH 的 Loader 解析不了:
❌ Unexpected token '', "{ "n"... is not valid JSON
.cmd 包装器在找不到 pwsh 时会回退到 powershell(5.1),所以这条路真的会被走到。如果当初直接双击 install.cmd,重启后应用就起不来了——而症状会指向 DSH 本身,不是这个脚本。
三个修法叠在一起:
- 写文件改用
[System.IO.File]::WriteAllText(... UTF8Encoding($false)) —— 明确不要 BOM
- 写后自校验:读回来检查首字节不是 BOM、并且
ConvertFrom-Json 能解析;失败就立刻用备份回滚并中止
.ps1 本身加 UTF-8 BOM —— 因为 5.1 读 BOM-less 文件按 ANSI 解,中文注释会让它报"括号不匹配"
顺带发现 .cmd 里不能写中文注释:cmd.exe 按 OEM 代码页读 .cmd,中文字节会被当成命令。
教训:一个会修改"应用启动所依赖的配置"的脚本,必须自己校验自己写出来的东西,并且能回滚。
6. 测试台渲染了「上一个组件」的状态,让规模测试说谎
写规模用例时构造了 200 个会话 × 40 轮,结果它 2ms 跑完、摘要卡片 0 张——因为实际渲染的是上一次那个 4 会话的组件状态。
原因:mini-React 把 hooks 按调用顺序存在一个数组里,渲染第二个组件时没有重置。两个组件的 hook 序列不同,于是 useSyncExternalStore 读到了上一个组件留下的快照。
修法是加 RUNTIME.reset(),并且在渲染另一个组件前必须调用。加完之后规模用例才真的开始测 200 个会话——立刻又抓出两个真问题:摘要面板没封顶(200 张卡片)、队列的「正在跑」分组没封顶(53 张卡片)。
教训:测试台自己出错时,比没有测试台更危险——它会给你绿灯。
7. 「连点两次回车」的竞态,以及测试台一度测不出它
零确认界面里没有对话框兜底,所以同一条消息投递两次是很伤的。闸门原来用的是 React state:
if (text === "" || busy) return; // busy 是 state
state 是异步的:两次事件落在同一次渲染周期里,会都读到 busy === false,于是投递两次。改成同步的 busyRef,并且把整个函数体放进 try/finally(否则路由里任何一处意外抛错,busyRef 就永远停在 true,面板从此再也发不出消息)。
有意思的是测试这条的过程中,测试台骗了我两次:
- 第一版测试捕获同一个按钮元素点两次 —— 抓到了 bug。但后来"改进"成每次重新取元素,测试就再也抓不到了:mini-React 在 setState 时同步重渲染,第二次取到的是
busy=true 的新闭包。真实 React 是批处理的,不会这样。
- 更要命的是,我一度以为这条测试有效——直到按纪律把
busyRef 删掉重跑,它照样全绿。
所以那条测试现在是刻意"捕获同一个闭包"的写法,注释里写明了为什么必须这样。
由此得出的纪律:每一条守卫 bug 的测试,都必须先证明它在未修复的代码上会变红。 做不到这一点,那条测试就只是一句安慰。
8. 我把 inbox 的语义搞反了 —— 而且离线测试一直在替我背书
「真的在等你」这个分组里,原来把三个字段都算作"需要你":
if (activeQuestions.length) needs.push(...); // agent 提问 → 只有你能答 ✓
if (inboxCount) needs.push(inboxCount + " 条排队中"); // ✗ 语义反了
if (planPending) needs.push(...); // 计划待批 → 只有你能批 ✓
真机实验(让会话进入 running,再投一条,看投影怎么变)给出了答案。inbox 里装的是:
"next-turn": [ { "content": [ { "type": "text",
"text": "……第二条:这条应该被排队" } ] } ]
那是刚投进去、排队等 agent 处理的消息。 inbox 非空意味着"agent 还有活要干",不是"你在等"。
后果很实在:那个会话本来在正确的「🟡 正在跑」,被代码抬到「🔴 真的在等你」并标上「需要你:1 条排队中」——而它什么都不需要你做。注意力队列最不能有的就是这种噪音。
更正后:needs 只留 userQuestions.active 与 plan.pending(两者都意味着 agent 停下来等你);有排队工作的会话归入「正在跑」,并以中性提示「队列里还有 N 条等它处理」呈现。
这件事的方法论意义比 bug 本身大:那条离线用例之所以一直是绿的,是因为它断言的就是我那个错的信念——假数据由我构造,于是它忠实地印证了我的误解。只有拿真机投影做实验才能推翻它。
顺带一提,这个实验不需要凭据:让会话进入 running、再投一条,排队就会发生——"没有模型"并不妨碍验证数据模型。
9. 学习回路在第一次真机使用时,就学出了一条会"越用越错"的规则
「改投一次 → 记住这个特征」是这个插件的核心承诺。它从没在真机上点过,所以去点了。
机械上是通的(重投成功、纠正计数 +1、账本记下),但持久化里躺着的规则是:
{ "match": "工具", "sessionId": "…视频摘要与口语练习工具" }
消息是「每日领域前沿信息自动报告工具 再加一个数据源」,改投目标是「视频摘要与口语练习工具」。两者唯一共享的 2 字片段就是**「工具」。于是以后任何带「工具」的句子都会被吸到那个会话**——包括「帮我做个工具…」。
这条规则会持续地把东西投错地方,而且它会以"我学聪明了"的面目出现。
改法(两层):
- 关键词只能来自"消息与目标标题真正共享的片段",删掉"从消息里任意取一段中文"的回退路径 —— 那条路会学出与目标毫无关系的词。
- 排他性检查:这个片段如果在别的会话标题里也出现(或属于明显泛化的词表),就不写规则。
学不到的时候如实说:「这句话和「X」没有共同特征,所以只改了这一次。一条不准确的偏好会长期把东西投错地方,比这次不学更糟。」
取舍说明:这样一来能学到的规则变少了。这是刻意的——一条错的规则会静默地长期投错,而少一条规则只是让你再纠正一次。
10. 一个看起来像插件崩溃、其实是测试点错了的假警报
第一次跑改投实验时,点完候选之后面板整个消失。看起来像渲染崩溃。按纪律去抓浏览器异常——一条都没有。
没有异常才是线索:面板消失不是崩了,而是应用导航走了。点击助手搜的是整个文档,而 DSH 自己的左侧会话列表里有同名会话、且它在 DOM 里更靠前 —— 点中的是原生侧栏。
把点击范围收进 .zf-reroute 之后一切正常。
教训:在宿主页面里做测试,选择器必须限定在自己的容器内;宿主的 UI 里到处是同名的东西。
11. "让匹配读懂意思" —— 我试了内容匹配,真机两轮把它否掉了
有人指出路由"匹配度不太对,希望读懂意思,而不是文字表面匹配"。当时的算法是:
for (const gram of grams(row.displayTitle)) // 只把会话**标题**切成 2/3/4 字片段
if (text.includes(gram)) score += gram.length;
三个缺陷:只比标题不比内容;没有证据时静默替你选("沿用上一次用过的");纯字面。
第一步我加了内容匹配:把会话最近几轮的内容也切成词元,消息在标题里找不到证据时再去内容里找。听上去对,真机跑了两轮:
第一轮(词元取 2/3 字):太松。「量子计算机的散热方案该怎么设计」被一个无关会话以「内容匹配 · 62%」吸走——因为"怎么""设计""方案"在任何长会话里都有。一个 62% 的假阳性比一句"没把握"更糟,因为它会跳过询问。
第二轮(词元改成 3/4 字):该匹配的匹配不上了。「归档是怎么回事」不再命中任何会话——而"归档"恰恰是那个会话真正的关键词,只是它只有 2 个字。
结论:字面匹配在长中文文本上无法区分"有意义的短词"和"到处都有的虚词"。 所以最终做法是把它降级——内容相似度只排序,不决定:
- 路由的决定只看标题匹配与学到的偏好;两者都没有,就是"没把握",交给人
- 内容相似度只用来给询问面板排序(回答"如果是这几个,哪个最像")
代价是"读懂意思"这件事没有真正做成,这里不含糊:真正的语义路由要过模型。插件侧没有可用的语义服务,唯一能过模型的路是"往某个会话投一句话",那要花掉一轮、还有延迟。所以现在的答案是:宁可问你一次,也不假装懂你。
顺带一个方法论的教训:这两轮失败都不是靠推理发现的,是在真实会话上跑出来的。离线夹具里没有长中文内容,第一轮的假阳性根本不会出现——type() 一句话,真机就把它打回来了。
变异审计:证明每条守卫测试都会红
第 7 条那个纪律光立不够,所以这里把它做成了可重复运行的审计:
tools\mutation-audit.cmd # 逐个把修复退回去,跑套件,看是否红在预期的测试上
十四个变异,十四个都红在它该红的那条上:
| 变异(把修复退回) | 红在哪条测试 |
|---|
1 去掉 sessions 声明 | 声明了 sessions 服务 |
2 create 返回值解析退化 | 新话题真的开了一个新会话 |
| 3 路由不再排除子智能体 | 命中子智能体标题时也不会投给它 |
| 4 本地检索返回空 | 边打边搜:不需要回车就已经出结果 |
| 5 摘要面板不封顶 | 摘要面板默认封顶在 12 张(实际 200) |
6 子智能体不带 ↳ 标记 | 子智能体在标题上带 ↳ 标记 |
| 8 检索结果不截断 | 规模检索命中并被截断渲染(实际渲染 8200 行) |
| 9 HUD 去掉「本该打断你」 | HUD 显示「本该打断你 N 次 · 实际 0 次」 |
| 10 默认页签改成偏好 | 摘要面板默认展示 |
| 11 账本表去掉「本该打断」列 | 账本表头是 日期/投递/新建/纠正/本该打断 |
| 12 封顶提示去掉数字 | 封顶处给出「还有 N 个」提示 |
| 13 把排队当成需要你 | 有排队工作的会话不算「需要你」 |
| 14 关键词不再要求与标题有共同特征 | 有共同特征时学到规则,且指向被改投的那个会话 |
| 15 去掉排他性闸门 | 特征在别的会话标题里也有时,不写规则 |
| 16 去掉发送前的低把握警示 | 把握低于 65% 时,发送前就出现警示 |
| 17 选中不再钉住发送目标 | 钉住后真的投给被钉住的会话 |
| 18 看汇报改回跳页面 | 点「看汇报」不会打开原生界面 |
| 19 汇报视图不显示下属 | 汇报视图里点名了它手下的子智能体 |
| 20 询问面板不按内容排序(退回按时间) | 面板把内容最接近的排在第一位 |
| 21 没证据时不问、直接发 | 没证据时按回车不发送,而是把选择面板摆出来 |
| 22 没证据时退回"选上一次用过的" | 没证据时如实标注「没有把握」 |
第 07 条已移除(编号保留以示来历):它守的是"剥尾部虚词"的分支,而第 9 条那个改动删掉了"任意取一段中文"的回退路径之后,这个分支成了防御性代码,现有夹具再也触发不到。按纪律,审计里不留不会红的条目。
第 23 条同样已移除:它守的是"内容匹配的最小片段长度",而第 11 条那个改动把内容相似度降级成"只排序、不决定"之后,这个长度不再影响任何决定,夹具触发不到它。
审计本身抓到两件事
一是弱断言。 原来「边打边搜」断言的是整页文本里含会话标题——可队列里本来就有那个标题,于是检索返回空它照样通过。同理「摩擦计数器显示『二次确认 0』」:那个词早就从 HUD 里换掉了,它能通过是因为输入框下方的提示文案里有「不做二次确认」——它测的是一句文案。这两个都属于同一类:断言的字符串在别处也出现。
二是"失败时把整轮跑挂"。 变异 10 让套件直接崩溃而不是干净地变红。根因是一行详情表达式:
"第一张: " + JSON.stringify(...).slice(0, 150)
JSON.stringify(undefined) 返回的是 undefined 值而不是字符串,.slice 就抛了。这条检查恰恰在它失败的时候把整轮跑挂——比弱断言更糟,因为它连"哪里坏了"都不告诉你。修完,同一个变异从"崩"变成干净的 9 条失败。
审计工具自己也踩了一次:它最初没有 finally 恢复,变异 10 一崩就把源码留在了变异状态。现在恢复写在 finally 里,而且子进程写 stderr 不再中止审计。
验证记录
不是"我觉得能跑",是真的在运行的 DSH 里跑过。下面每一条都是实际执行过的。
| # | 验证项 | 证据 |
|---|
| 1 | 官方 CLI 接受这个包并安装 | 在隔离 profile 里 dsh plugin --profile zfcheck add <路径> → + dsh-client-ui-zero-friction,exit 0,自动用 link: |
| 2 | 组合进 profile 树 | dsh --profile zfcheck --dump-config → exit 0,行 - id: dsh-client-ui-zero-friction |
| 3 | 进入真实浏览器模块名单 | 从运行中服务器的 __DSH_BOOT__ 里读出 {"id":"dsh-client-ui-zero-friction","url":"plugins/??dsh-client-ui-zero-friction/client.js&rev=a740b732a93a","rev":"a740b732a93a","inject":["@deepseek-ai/dsh-api-session-controller"]} |
| 4 | 模块真的能取到 | 用上面的 combo URL 请求 → HTTP 200,27022 字节,内容就是 window.__ModuleLoader__.load({ id: "dsh-client-ui-zero-friction" … |
| 5 | 浏览器里真的激活 | 无头 Chrome 打开真实 GUI → 侧栏出现 零摩擦 |
| 6 | 主面板真的渲染 | 点开后完整出现工作区标题、摩擦计数器、三列布局、输入框与发送按钮 |
| 7 | 降级路径在真实 GUI 里成立 | 零会话时回车 → 界面原样输出「还没有任何会话,先在正常界面里开一个对话。」 |
| 8 | 逻辑回归 | node tools/verify.cjs |
| 9 | 修正 inject 后插件仍能激活 | 隔离实例里侧栏「零摩擦」照常出现——这本身就是声明正确的证明:Cordis 等不到 sessions 就根本不会挂载这个插件 |
| 10 | 真实会话进了队列 | 新建一个会话后,面板显示 会话 1,队列出现「已停下,等你看 / default-workspace」 |
| 11 | 真实路由 | 输入后路由预览实时显示「→ default-workspace · 延续当前会话 · 50%」 |
| 12 | 真实投递成功 | 回车后回执显示**「已投递给「default-workspace」」**,并且 DSH 自己的左侧会话列表同时出现了这条消息,还据此自动给会话起了标题 |
| 13 | 摩擦计数器 | HUD 显示 自动投递 1,与第 12 项那次真实投递一致 |
| 14 | 真实会话全部读到 | 把真实 sessions + storages 复制进隔离 DSH_HOME 后,队列显示 8 个真实会话 |
| 15 | 真实自动摘要 | 摘要面板对每个真实会话给出:目标(active · 第 3 轮 / complete · 第 1 轮 / blocked · 第 40 轮)、你问、、、轮次、上下文占用 |
最终验收的做法
验证分两类环境:
- 隔离的 web profile 实例(复制真实
sessions 与 storages,独立 DSH_HOME)——用于功能逐项验证。
- 真实 desktop profile 的副本(同样的
cordis.yml / package.json / node_modules / 会话数据)——用于最终验收。
第二类是最接近真实使用环境的:同样的 bundle 列表、同样的依赖链接、同样的会话数据。
还没在真机上验过的
🔴 「真的在等你」里 userQuestions.active 与 plan.pending 两个字段的真机触发。 这两个字段只在 agent 真的提问或提交计划时才非空——那需要一个能跑通的模型,而开发环境刻意没有配置凭据(也不应该拿别人的凭据去跑)。这一条由离线用例覆盖,用的字段结构来自真机 dump;分组代码路径与「正在跑」完全相同,而后者已经真机验证过。
开发
node tools/verify.cjs # 离线回归(86 项)
tools\mutation-audit.cmd # 变异审计(14 个变异)
tools/verify.cjs 把真实的 lib/client.js 装进一个假客户端环境里跑:模拟 window.__ModuleLoader__、一个最小 React 运行时(hooks + 渲染 + 事件)、以及一个假 ctx(slots + sessions)。它验证的是:
- 模块契约:是否调了
__ModuleLoader__.load、模块 id 是否等于包名
- 静态检查:有没有"用到却没声明"的服务(坑 #1)
- slot 注册:
main 的 key 是否等于侧栏 sidebar.panellist 的 id
- 渲染:首次渲染不抛错、真实会话标题出现、分组正确、规模下不膨胀
- 交互:路由预览、回车投递真的打到正确的那个会话、走
queue 模式、改投真的重投
- 学习:纠正后写入偏好、下一次路由真的命中;不可靠的特征不写偏好
- 检索:本地搜大纲命中,且零 RPC
- 降级:零会话、以及 sessions 服务整个缺失时,渲染都不能抛错
改完 lib/client.js 直接重跑这个脚本,比重启应用快得多。
设计要点(为什么这么做)
- 取消选择,而不是优化选择。 路由不问「要找谁」;判断不了就发给最可能的那个,把「不是它?改投给…」摆在手边。
- 路由是纯函数。
routeMessage(text, rows, prefs) 不碰网络、不碰 React,所以能被 verify.cjs 直接测。这是它能被反复调参的前提。
- 学到的偏好必须可见、可停用。 一个偷偷学习的黑盒和没有学习是一样的——用户不会信它,也就不会用「不是它」这个动作,学习回路就断了。
- 任何服务缺失都不能让界面炸掉。
sessions 拿不到就少一块能力、显示一行说明,而不是白屏。
- 该短就短。 唯一保留摩擦的地方是不可逆动作;除此之外不该有「请确认」。
- 宁可少学一条,也不要学错一条。 错误的偏好会静默地长期投错地方,而缺失的偏好只是让你再纠正一次。
- 护栏放在"发送之前",不是"发送之后"。 投错一条消息会污染那个会话的上下文,还要白烧一轮算力 —— 那是不可逆的。所以把握 < 65% 时,警示和改投目标出现在你按回车之前;但它不拦你:按回车照样发给最可能的那个。零摩擦的意思是"不制造分叉",不是"看着你投错也不吭声"。
- 层级式汇报:只跟"上级"说话。 选中一个会话就等于指定它是你的对接人 —— 输入框永远是同一个,它的逐轮汇报就地展开在中间栏,子智能体是它的下属。主按钮是「看汇报」(不跳页面),
原生界面 降级成次要入口。真要跳走的时候你才跳。
- 打平局时选最近用过的那个。 两个会话标题匹配分数相同时,旧代码保留数组里靠前的那个,而那个顺序来自主机、语义未知。改成按
updatedAt 打平局:更像答案,而且可预测。
- 没证据就不猜。 标题匹配和学到的偏好都给不出证据时,旧版会静默替你挑一个(先是"延续当前会话",再是"接到最近会话")。现在如实标成"没把握",把选择摆出来,默认答案是开新会话。唯一例外是看起来在追问的短句(很短、且没有新话题特征),那接着当前会话说是合理的——但仍然会被问一次。
- 相似度只能排序,不能决定。 内容相似度用来回答"如果是这几个,哪个最像",由人拍板。它一旦参与决定,就会在长中文文本上产生 62% 这种看起来很确定的假阳性,而那比"没把握"糟得多——因为它跳过了询问。
已知限制
- 摘要不是重写过的综述。 现在直接展示主机大纲里那一轮的原文(已截断),够用来判断"要不要点进去"。想要真正的一句话综述,得让某个会话来生成——所以「让它自己总结」那个按钮就是这条路,代价是花掉那个会话一轮。自动(免模型)和综述(花模型)选了前者做默认,因为它零延迟、零成本,而且原文常常比综述更可信。
- 路由是字面的,不是语义的。 它比对标题片段与学到的偏好,不理解同义词、指代和言外之意。这是刻意的取舍:真正的语义路由要过模型(一轮 + 延迟)。缺的那部分用"没证据就问你"补上(见坑 #11)。
- 「真的在等你」的两个字段未在真机触发过(见上)。
- 只在 Windows 上测过。 install/uninstall 脚本依赖 PowerShell;客户端插件本身是跨平台的。
- 开发探针留着没删:
window.__ZF_DEBUG__.dump(i) 会把第 i 个会话的完整 projectionValues 和事件窗口结构原样吐出来。会话事件窗口没有文档、磁盘上的会话文件又是二进制,这个探针是唯一可靠的信息来源。只读,不改变任何状态。
许可
MIT