Sessions & Messages

dsh-client-ui-zero-friction

anhaoXie/dsh-client-ui-zero-friction · ★ 0

anhaoXie

Zero-friction single-input workspace for the DeepSeek Harness Web GUI: one composer that auto-routes to real sessions, an attention-ordered queue, host-projection summaries, and a falsifiable friction counter.

README body is shown in Chinese.

License
MIT
Added
2026-10-30
Maintenance
active
Install kind
github
Manifest details
Manifest status
Declared
Manifest path
package.json#dsh
Manifest checked
2026-10-30T09:38:35.759Z
Lifecycle scripts
None found

Static manifest checks report declarations only; they are not compatibility or security review.

安装

dsh plugin --profile web add 'github:anhaoXie/dsh-client-ui-zero-friction#8dfae345a240478ee29f974f7a7707aab07b5422'

GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试

For install and environment setup, plus plugin development tutorials, see DSH community docs ↗

README

Source README · body: zh · Read Chinese README

Table of contents
  1. 它解决什么问题
  2. 能力与数据来源
  3. 安装
  4. 启用
  5. 卸载
  6. 它改动了什么
  7. 实测出来的 API 事实
  8. 踩过的坑
  9. 1. Cordis 只暴露声明过的服务 —— 差点让整个面板变成空的
  10. 2. 我一度得出「必须重启」的错误结论
  11. 3. 测试台曾经"印证"了一个错误的返回值假设
  12. 4. 「跨会话检索」曾经是一个永远返回空的功能
  13. 5. 安装脚本会把应用弄到起不来(写 BOM)
  14. 6. 测试台渲染了「上一个组件」的状态,让规模测试说谎
  15. 7. 「连点两次回车」的竞态,以及测试台一度测不出它
  16. 8. 我把 inbox 的语义搞反了 —— 而且离线测试一直在替我背书
  17. 9. 学习回路在第一次真机使用时,就学出了一条会"越用越错"的规则
  18. 10. 一个看起来像插件崩溃、其实是测试点错了的假警报
  19. 11. "让匹配读懂意思" —— 我试了内容匹配,真机两轮把它否掉了
  20. 12. 语义路由:先探清"能不能不污染会话地调模型",答案是不能,于是造了个专用会话
  21. 变异审计:证明每条守卫测试都会红
  22. 审计本身抓到两件事
  23. 验证记录
  24. 最终验收的做法
  25. 还没在真机上验过的
  26. 开发
  27. 设计要点(为什么这么做)
  28. 已知限制
  29. 许可

零摩擦单框交互 —— 一个真正的 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% 时,发送之前就在预览里警示并列出改投目标(一键选定,不拦回车) ✅ 真实
没证据就问你 标题匹配不上时不再替你猜(旧版会静默"沿用上一次用过的"):按回车弹出选择面板,默认「开个新会话发出去」 ✅ 真实
「开新会话」永远在 输入框边上常驻一个按钮,不理任何阈值与启发式 ✅ 真实
语义路由(可选) 词面对不上时把候选会话交给一个专用助手会话判断,它只回编号或 NEW;带缓存与 45 秒超时兜底 ⚠️ 管道已真机验证,模型答案质量我验不了(需要凭据)
就地汇报视图 选中会话即在中间栏展开它的逐轮「你问 / 汇报」;不跳页面,子智能体作为"下属"列出来 ✅ 真实

关键发现:这些全都不需要读消息正文。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 <路径>   # 完全自定义

手动方式,等价于脚本做的三件事:

  1. 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 指向源码,改动实时生效。

  2. 用 DSH 自带的 pnpm 安装:

    $pnpm = "…\DeepSeek Harness\resources\runtime\pnpm\bin\pnpm.mjs"
    cd ~\.dsh\profiles\desktop
    node $pnpm install
    
  3. 这个包自己是 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") / require("react/jsx-runtime"),无需构建工具链
模块 URL plugins/??<id>/client.js&rev=<hash> —— ?? 既是路径也是查询分隔符,少 rev 必 404
bundle patch 包根放 cordis.patch.yml,package.json 里 dsh.bundle.patch 指向它;包名加进 profile 的 dsh.profile.bundles
ctx.get("remote") 是主机调用通道 ClientRemoteService,方法有 invoke / invokeMethod / invokeSelected / invokeStream / openRemoteStream / createNamespace …;remote.namespaces 是已注册的主机命名空间清单
主机命名空间清单(实测) llm session subagents agentPresets credentials account settings goals commands schedule skills job workspace workspaceFiles terminal fileUploads userQuestions pluginManager … 每个外面包一层 { service, dispose }
llm 命名空间只有列模型/列供应商 直接方法只有 discoverModels / listProviders / listConfigurableProviders,没有补全方法。所以客户端唯一的模型入口是 session.prompt —— 语义路由必须借一个会话当分类器
session 命名空间的方法 create / prompt / list / projections / search / rename / fork / follow / control / cancel / modelCatalog / selectModel / updateQueue …

踩过的坑

这十条都是真踩过的,其中几条差点让功能"看起来能用其实永远是坏的"。

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 本身,不是这个脚本。

三个修法叠在一起:

  1. 写文件改用 [System.IO.File]::WriteAllText(... UTF8Encoding($false)) —— 明确不要 BOM
  2. 写后自校验:读回来检查首字节不是 BOM、并且 ConvertFrom-Json 能解析;失败就立刻用备份回滚并中止
  3. .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,面板从此再也发不出消息)。

有意思的是测试这条的过程中,测试台骗了我两次:

  1. 第一版测试捕获同一个按钮元素点两次 —— 抓到了 bug。但后来"改进"成每次重新取元素,测试就再也抓不到了:mini-React 在 setState 时同步重渲染,第二次取到的是 busy=true 的新闭包。真实 React 是批处理的,不会这样。
  2. 更要命的是,我一度以为这条测试有效——直到按纪律把 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 字片段就是**「工具」。于是以后任何带「工具」的句子都会被吸到那个会话**——包括「帮我做个工具…」。

这条规则会持续地把东西投错地方,而且它会以"我学聪明了"的面目出现。

改法(两层):

  1. 关键词只能来自"消息与目标标题真正共享的片段",删掉"从消息里任意取一段中文"的回退路径 —— 那条路会学出与目标毫无关系的词。
  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() 一句话,真机就把它打回来了。

12. 语义路由:先探清"能不能不污染会话地调模型",答案是不能,于是造了个专用会话

有人明确要求"读懂意思"。我先把客户端能拿到什么都探了一遍(探针就是 __ZF_DEBUG__.probe()):

  • ctx 是个 Proxy,只暴露 fiber / baseUrl
  • ctx.get("remote") 是主机调用通道,remote.namespaces 列出全部主机命名空间: llm session subagents credentials account goals skills terminal …
  • llm 命名空间只有 discoverModels / listProviders / listConfigurableProviders —— 没有补全方法

所以客户端唯一的模型入口是 session.prompt。想用模型判断路由,就必须有一个会话。

于是做法是造一个专用助手会话(不是拿你的会话当分类器——那正是最开始要避免的污染):

候选会话:
1. 零摩擦会话插件|要做,开始吧,因为这样匹配度根本达不到我的要求…
2. 前沿信息获取|现在给你说清楚,这俩完全不是一回事…
3. DSH询问|结论:DSH 根本没有"删除会话"功能,只有"归档"…

用户这句话:「量子计算机的散热方案该怎么设计」
应该发给几号?都不像就回 NEW。只回一个编号或 NEW。

配套的四件事,每一件都是被真机或测试台打回来才补上的:

  1. 防抖 800ms。不然打字过程中每个字符都会触发一次分类。
  2. 先同步占位再 await。ensureRouterSession 里有异步创建,不先占位的话并发调用会各建一个助手会话——测试台抓到过 12 个。
  3. 防抖 effect 不能依赖 rows。第一版放进去了,而 store 每发布一次就重排一次定时器——真机上就是"每来一次通知就多问一次模型",那是烧 token。测试台直接炸出 20 条分类请求。
  4. 助手会话本身要从投递候选里剔除。真机第一次验证就冒出 发给「你是「零摩擦」插件的语义路」——拿插件的工具当对话框是错的。

我验不了的部分:模型答案的质量。隔离实例没有凭据(我也不该拿你的凭据去跑),所以"它答得准不准"只能由你在真机上判断。我验到的是管道:请求确实发出去了、候选列表与目标句确实在里面、不回答时 45 秒如实退回、按回车绝不静默投错。


变异审计:证明每条守卫测试都会红

第 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 没证据时退回"选上一次用过的" 没证据时如实标注「没有把握」
24 语义路由从不发问 没证据时会去问路由助手
25 不复用助手会话(每问一次建一个) 第二次问仍然复用同一个助手会话
26 不采纳助手的回答 助手答了编号 → 预览采纳为「语义路由」
27 分类请求不再要求只回编号或 NEW 并且结尾就是那句指令

第 07 条已移除(编号保留以示来历):它守的是"剥尾部虚词"的分支,而第 9 条那个改动删掉了"任意取一段中文"的回退路径之后,这个分支成了防御性代码,现有夹具再也触发不到。按纪律,审计里不留不会红的条目。

第 28 条同样已移除:它守的是「路由助手会话不该成为候选」,但夹具里会话多、助手会话排不进前 8,变异在夹具上不会红(试过)。该守卫改在真机上验证——会话少时它确实会冒出来(第一次真机验证就抓到了)。

第 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 轮)、你问、结论、上一轮、轮次、上下文占用
16 摘要内容正确 抽出的结论与真实会话内容一致,例如「LB idle timeout 已调平」「88 项操作全部成功,0 失败」「五个功能全部做完并端到端验证通过」
17 「真的在等你」由真信号驱动 离线用例:带 userQuestions.active 的会话即使 running === false 也进入该分组,卡片显示「1 个问题等你回答」
18 真实自动开新会话 真机输入新话题 → 会话数 2→3、回执显示「新话题 · 自动开了新会话」、HUD 新建会话 1
19 真实摩擦账本 真机账本页显示 10-12 投递 1 新建 1 纠正 0 本该打断 1,合计行一致
20 「本该打断 vs 实际打断」可证伪 同一次投递里把握度 35%(旧设计会问),HUD 显示「本该打断你 1 次 · 实际 0 次」——这个数字会随真实使用增长,所以「0 次打断」不是空话
21 修掉 create 契约错误后重验 见坑 #3:这个 bug 是真机测出来的,测试台当时是全绿的
22 探出检索是死功能 真机 search('502') → {ok:false, error:{code:"gateway/internal"}}(见坑 #4)
23 本地检索在真实大纲里命中 真机搜「零摩擦」→ 命中「… · 你问 · 第 2 轮」,高亮 1 处;零 RPC
24 主机索引如实报告 点「探测主机索引」→ 日志原文「主机全文索引在这个 profile 里是关闭的(gateway/internal)」
25 在真实 desktop profile 的副本上完整工作 把 ~/.dsh/profiles/desktop 整体复制到隔离 DSH_HOME 并从 CLI 启动(不是 web 模板)→ 侧栏入口、8 个真实会话、自动摘要、goal 状态(active · 第 6 轮)全部正常
26 install / uninstall 端到端可用 在一个全新 profile 上跑完整循环:安装 → dump-config exit 0 且行入树 → 卸载 → 行移除且 JSON 始终可解析、无 BOM
27 安装器的自校验真的会回滚 见坑 #5:BOM 那个 bug 就是被这条检查暴露的
28 「打开会话」真机可用 点摘要卡片的「打开」→ 主视图切走了(面板还在: false),并显示出那个真实会话的完整对话记录。路由 → 投递 → 打开,三件事全部真机验证完毕
29 打开失败不再静默 openSession 抛错时会在日志里显示「打不开这个会话:…」;离线用例覆盖(含一条把 openSession 换成会抛错的桩的用例)
30 规模:200 个会话 × 40 轮 渲染 2ms、不抛错;「真的在等你」19 个全部渲染(刻意不封顶),其余分组封顶(共 35 张卡片,不是 200 张);摘要面板封顶 12 张并给出「显示全部 200 个」,点开后确实渲染 200 张
31 规模下真机仍正常 复制真实会话启动隔离实例,队列与摘要照常渲染
32 子智能体不会被当成投递目标 一条通篇在说子智能体标题的消息,不会被投进那个子智能体;改投候选里也没有它。离线用例覆盖(真机测不了:需要一个真的子智能体,那要有模型)
33 过滤改动没误伤主会话(真机) 复制真实会话启动实例:无匹配的消息 → 判定「新话题」并真的开了新会话;匹配标题的消息 → 标题匹配 90% → 成功投递。两条分支都在真机上验过
34 连点两次只投递一次 见坑 #7:这条测试在未修复的代码上确实是红的(实际投递 2 次),修复后为 1 次
35 失败之后闸门一定被释放 让 sessions.create 抛错走真实失败路径:如实报错、不误投到别的会话、草稿被还回来,且下一次投递仍然成功
36 规模检索的成本 200 会话 × 40 轮(8000 条大纲)下一次按键 13–18ms,命中被截断到 60 行渲染
37 守卫测试本身是真的 变异审计:全部红在预期的测试上。tools\mutation-audit.cmd 可重复运行
38 审计抓出弱测试并修掉 见「审计本身抓到两件事」
39 审计扩到 12 个变异,全中 新增的 4 个针对加强过的断言(HUD 指标 / 默认页签 / 账本表头 / 封顶提示)
40 修掉"失败时把整轮跑挂" 变异 10 曾让套件崩溃而非干净变红。修完变成干净的 9 条失败
41 审计工具自身的健壮性 恢复源码写进 finally;子进程 stderr 不再中止审计;套件崩溃单独归类
42 投递真的启动了一个 agent 回合(真机) 经插件投一条消息进真实会话后:running 从 false 变 true,turnOutline 从 18 条涨到 19 条,lastAgentError/promptError 仍是 null。不只是 prompt() 返回 ok
43 「🟡 正在跑」分组真机生效 同一个会话在投递后实时从「已停下」移到「正在跑」,卡片显示「在跑 · 刚刚 · 16 轮 · 上下文 62%」
44 摘要随回合实时增长 新回合一进 turnOutline,摘要面板第一张卡片立刻出现新的「你问」。摘要不是快照,是跟着主机投影走的
45 真机实验推翻了 inbox 的语义 让会话 running 后投第二条 → inbox.next-turn 里是刚投进去的那句话(见坑 #8)
46 修正后真机复验 有排队的会话留在「🟡 正在跑」,显示中性提示「队列里还有 2 条等它处理」;不再出现在「真的在等你」
47 变异审计扩到 13 个 新增变异(把排队重新算成"需要你")→ 红在对应测试
48 改投交互真机跑通 点「不是它?改投给…」→ 候选列表正确(排除被改投的那个、排除子智能体、含「取消」)→ 重投成功、纠正 1、账本记下
49 真机发现学习回路会学出泛词 第一次真机改投学到的关键词是「工具」,会劫持以后所有带「工具」的句子(见坑 #9)
50 修正后真机复验 同一场景:rules: []、learned: 0、纠正 1——不再写出那条泛词规则;下一句正常路由,没有被坏规则劫持
51 变异审计扩到 14 个 新增两个变异均红在对应测试上;同时删掉已过时的第 07 条并注明原因
52 最终验收:15/15(真实 desktop profile 的副本) 面板加载 → 真实队列 → 真实摘要 → 本地检索命中真实大纲 → 投递回执 → 改投候选 → 纠正记账 → 不学泛词 → 摩擦账本 → 打开会话切换主视图 → 全程零未捕获异常
53 低把握发送前护栏(真机) 输入「改版的事情」→ 预览显示「延续当前会话 · 50%」→ 出现警示「⚠ 把握只有 50%,可能投错」+ 三个一键改投按钮(列出真实会话标题);此时没有发送
54 发送前改投(真机) 点「改投「零摩擦会话插件」」→ 预览变成「→ 零摩擦会话插件 · 你指定的 · 100%」,警示消失,仍然没有发送
55 就地汇报视图(真机) 点队列卡片 → 中间栏展开该会话的逐轮「你问 / 汇报」(真实内容),头部显示「已停下 · 16 轮 · 上下文 13%」+「改回自动」「原生界面打开」;面板没有被导航走(document.querySelector('.zf-root') 仍存在)
56 改回自动(真机) 点「改回自动」→ 汇报收起、预览恢复「延续当前会话 · 50%」,回到自动路由
57 没证据就问你(真机) 输入「量子计算机的散热方案该怎么设计」→ 预览「没有把握 · 没有任何会话和这句话有共同点 · 20%」→ 回车不发送,弹出面板:[开个新会话发出去] [发给「DSH询问」] [发给「零摩擦会话插件」] [发给「default-workspace」] [取消];日志确认没有回执
58 选了就真的开新会话(真机) 点「开个新会话发出去」→ 回执「已投递给「新会话」· 没有像的,你选择开新会话」,会话数 8→9,HUD 新建会话 1
59 账本新增「问你」列(真机) 账本显示 10-13 投递 1 新建 1 纠正 0 问你 1 本该打断 0,与第 57 步一致
60 内容匹配的假阳性是被真机否掉的 见坑 #11:第一轮「量子计算机的散热方案该怎么设计」被无关会话以 62% 吸走;第二轮「归档是怎么回事」又匹配不上了。最终把它降级成"只排序、不决定"
61 「1+1=2」不再被路由到当前会话(真机报的 bug) 旧判据只看"短 + 没有领域词",一个全新的短问题同样满足。改成必须有真正的续说标记(再/继续/它/这个…)
62 「开新会话」永远可用 输入框边上常驻按钮;即使这句话路由有把握,点它也照样新建并投递(离线用例覆盖)
63 语义路由的管道(真机) 词面对不上时确实建了助手会话并发出分类请求(候选列表 + 目标句都在里面);模型不回答时 45 秒后如实退回,按回车弹询问面板、一条都没发出去
64 模型答案的质量:未验证 隔离实例没有凭据,我也不该拿使用者的凭据去跑。这一条只能由使用者在真机上判断

最终验收的做法

验证分两类环境:

  1. 隔离的 web profile 实例(复制真实 sessions 与 storages,独立 DSH_HOME)——用于功能逐项验证。
  2. 真实 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 直接重跑这个脚本,比重启应用快得多。

设计要点(为什么这么做)

  1. 取消选择,而不是优化选择。 路由不问「要找谁」;判断不了就发给最可能的那个,把「不是它?改投给…」摆在手边。
  2. 路由是纯函数。 routeMessage(text, rows, prefs) 不碰网络、不碰 React,所以能被 verify.cjs 直接测。这是它能被反复调参的前提。
  3. 学到的偏好必须可见、可停用。 一个偷偷学习的黑盒和没有学习是一样的——用户不会信它,也就不会用「不是它」这个动作,学习回路就断了。
  4. 任何服务缺失都不能让界面炸掉。 sessions 拿不到就少一块能力、显示一行说明,而不是白屏。
  5. 该短就短。 唯一保留摩擦的地方是不可逆动作;除此之外不该有「请确认」。
  6. 宁可少学一条,也不要学错一条。 错误的偏好会静默地长期投错地方,而缺失的偏好只是让你再纠正一次。
  7. 护栏放在"发送之前",不是"发送之后"。 投错一条消息会污染那个会话的上下文,还要白烧一轮算力 —— 那是不可逆的。所以把握 < 65% 时,警示和改投目标出现在你按回车之前;但它不拦你:按回车照样发给最可能的那个。零摩擦的意思是"不制造分叉",不是"看着你投错也不吭声"。
  8. 层级式汇报:只跟"上级"说话。 选中一个会话就等于指定它是你的对接人 —— 输入框永远是同一个,它的逐轮汇报就地展开在中间栏,子智能体是它的下属。主按钮是「看汇报」(不跳页面),原生界面 降级成次要入口。真要跳走的时候你才跳。
  9. 打平局时选最近用过的那个。 两个会话标题匹配分数相同时,旧代码保留数组里靠前的那个,而那个顺序来自主机、语义未知。改成按 updatedAt 打平局:更像答案,而且可预测。
  10. 没证据就不猜。 标题匹配和学到的偏好都给不出证据时,旧版会静默替你挑一个(先是"延续当前会话",再是"接到最近会话")。现在如实标成"没把握",把选择摆出来,默认答案是开新会话。唯一例外是看起来在追问的短句(很短、且没有新话题特征),那接着当前会话说是合理的——但仍然会被问一次。
  11. 语义路由借一个专用会话当分类器,而不是借你的会话。 客户端唯一的模型入口是 session.prompt(llm 命名空间没有补全方法),所以想用模型判断就必须有会话。用一个专用助手会话,污染就只落在一个可以随时归档的工具会话上,不落在你的任何对话里。它带缓存、带防抖、带超时;任何一步没成,退回的永远是"让你选",不是"替你猜"。
  12. 相似度只能排序,不能决定。 内容相似度用来回答"如果是这几个,哪个最像",由人拍板。它一旦参与决定,就会在长中文文本上产生 62% 这种看起来很确定的假阳性,而那比"没把握"糟得多——因为它跳过了询问。

已知限制

  • 摘要不是重写过的综述。 现在直接展示主机大纲里那一轮的原文(已截断),够用来判断"要不要点进去"。想要真正的一句话综述,得让某个会话来生成——所以「让它自己总结」那个按钮就是这条路,代价是花掉那个会话一轮。自动(免模型)和综述(花模型)选了前者做默认,因为它零延迟、零成本,而且原文常常比综述更可信。
  • 语义路由的答案质量未经验证。 它依赖一个专用助手会话的模型回答;我没有凭据去跑,所以只知道管道通(请求发出、超时退回、不静默投错),不知道它答得准不准。默认开着,觉得不划算可以在「偏好」里关掉,或点「换一个」换掉那个助手会话。
  • 路由的字面部分仍然是字面的。 标题片段匹配 + 学到的偏好都不理解同义词与指代;那部分靠"没证据就问你"和语义路由补。 它比对标题片段与学到的偏好,不理解同义词、指代和言外之意。这是刻意的取舍:真正的语义路由要过模型(一轮 + 延迟)。缺的那部分用"没证据就问你"补上(见坑 #11)。
  • 「真的在等你」的两个字段未在真机触发过(见上)。
  • 只在 Windows 上测过。 install/uninstall 脚本依赖 PowerShell;客户端插件本身是跨平台的。
  • 开发探针留着没删:window.__ZF_DEBUG__.dump(i) 会把第 i 个会话的完整 projectionValues 和事件窗口结构原样吐出来。会话事件窗口没有文档、磁盘上的会话文件又是二进制,这个探针是唯一可靠的信息来源。只读,不改变任何状态。

许可

MIT