# dsh-voice-call v0.4 候选清单(加什么 / 砍什么) 冷存文档:只在下一轮改进开工前读。基线 harness `0.1.5-rc.2`,本文写于 2026-09-20, 插件版本 `0.3.0`(已发 npm + GitHub)。**所有结论都要在新基线上重新核一遍**, 尤其是任何关于 harness API 能力的话。 配套阅读:`docs/voicemail-read-receipts-0.1.5-rc.2.md`(上一轮的可行性调研,带 path:line 引证)。 --- ## 0. 本轮定的两条前提 - **DeepSeek harness 本身还是开发者预览版**,所以这一轮按「交互本来该是什么样」设计, 不按「现在的 API 给了什么缝隙」设计。API 限制记在待办里,不记在否决里。 - **`durableEvents` / `voice/note` 事件链暂时不动**(见 §3 最后一行)。它的取舍取决于 「harness 会不会哪天开放 plugin event」,这是一个赌注,实测一次就能变成事实, 但那需要动配置、且可能废掉一个会话,所以留到开工时再说。 ## 1. 身份判断(所有加减用这把尺子量) **这个插件不是 TTS 插件,是「agent 主动打断人类、并且人类能当场回话」的那条通道。** TTS 是大宗商品,任何插件都能把文本读出来,做好了也没人记得。真正没人做过的是 **它决定什么时候吵你** —— 来电卡片、接听/拒接/稍后、通话生命周期,这些都是「打扰的礼仪」, 不是「声音的播放」。`src/persona.ts` 那句"the human always holds the answer key" 就是整个产品的位置。 所以每个候选功能问一句:它是让这通电话更像电话,还是只是让它更能出声? ## 2. 加法候选 ### A1. 接起来能回话(双向通话) — 价值最高,成本 M–L 现在电话是单程的:点接听 → 听完 → 卡片消失 → 还是得打字。**不能回话的来电不是电话, 是语音信箱播放器**,而整个「来电」隐喻就断在这里。这是 v0.4/v1.0 唯一能改变插件身份的功能。 仓库里已经有一半:`src/backends/whisper-local.ts`(83 行 STT 后端)+ `src/tools/transcribe.ts` (219 行)。缺的是: 1. 卡片上的麦克风按钮 + `getUserMedia` —— **零 host 缝隙,全部 DIY**(调研文档 §5)。 2. 采到的音频走现有 STT 后端识别。注意 `PromptContentPart` 只有 `text|image|file`, **没有 audio 类型**,所以路径必须是「浏览器采 → 插件侧 STT → 文本注入」, 不是「把音频交给 harness」。 3. 注入成人类消息:`agent.followup / steer / inject` 在 rc.2 上已经可用(调研文档 §1, 含 `dsh-agent/lib/types/runtime-types.d.ts:186/192/200/209` 与 `AgentRegistry.get`), 并且 `createUserMessage` 支持 `source: { kind: 'plugin', plugin }`。 4. 风险点(开工前必须先 spike):从 HTTP handler 作用域(不是 agent 作用域)调 `followup` 的行为没有验证过;麦克风权限、浏览器差异、端到端延迟。 ### A2. 什么时候才准响(打扰策略) — 最便宜、体感最强,成本 S `callMode` 现在是静态四值,agent 想响就响。真实使用里这决定「你敢不敢整天开着它干活」。 候选机制: - **优先级两档**:只有「需要你拍板」才允许振铃;「跑完了 / 顺带汇报」走静默朗读,不打断。 落点就是 `offer_call` 的一个参数 + `src/persona.ts` 的行为约定。 - **同一次 turn 内节流**:一次 turn 里不重复振铃(`currentCoords(session)` 已经能拿到 turn/step 坐标,`src/session-events.ts:33`)。 - **勿扰时段**:纯配置,`ctx.settings.register` 就够。 ### A3. 没听清就重来(回放) — 白送,成本 S 接受的通话音频本来已经落在 `audioDir`(`src/web.ts` 的 prefix 路由就在服务它)。卡片上 加一条回放/重播条即可。用户价值不小:语音是线性媒体,没听清就得让对方重说一遍,很贵。 ### A4. 语音信箱 + 已读回执(原路线图 v0.3) — 成本 M 它是 A1/A2 的**下游**,不是独立功能:只有先有了「没接」和「能回话」,留言才有意义。 技术结论已经在上一轮调研里做完(推送路径通、事件路径不通),开工前读那份文档。 **顺序上排在 A1 之后**,反过来做会先把回执设计悬空。 ### A5. 跨设备振铃(手机接得住) — 唯一能让它变成日常工具的,但别塞进下一版 现在的信任模型是本机 `127.0.0.1` + 同源。让手机浏览器接住同一张卡片,就要把这条缝变成 网络暴露:配对、鉴权、HTTPS/mixed-content、断线重连。这是一个**单独的架构回合**, 不是 v0.4 的一个 feature。列为 v1.x 的主线候选。 ## 3. 砍法候选 | 现状 | 问题 | 建议 | |---|---|---| | 5 个 TTS(`say`/`piper`/`edge-tts`/`fake`/`crispasr`)+ 4 个 STT(`whisper-local`/`openai`/`macos`/`fake`) | `say` 与 `macos` 是 darwin-only,`src/backends/probe.ts:43,48,60` 直接门掉 —— Windows 上永远走不到;`piper` 要额外二进制;`edge-tts` 按 README 自己的说法「只合成不播放」,语义与朗读回复相拧。每个后端都摊上 probe/selection/factory/README 配置表/兼容矩阵。 | 收到 `crispasr + fake` / `whisper-local + fake`。**代价:Linux/macOS 用户装不动**,等真有外部需求再按需求恢复。 | | `readReplies` 自动朗读 + `/voice on/off`(`src/read-replies.ts` 72 行、`src/command.ts` 80 行) | 它是来电卡片的**前代**。同一个 turn 里两种模式会抢着说话(自动读完又打电话),persona 要花钱解释这个冲突,配置面也要解释。 | 去掉「自动朗读一切」,保留 `/voice <一句话>` 当试听/喊话。 | | `callMode: card/ask/direct/off` 四值 | `ask`/`direct` 本质是同一个开关「要不要人类确认」,`off` 等于禁用。四值回落组合是测试矩阵爆炸的来源。 | 收成两轴:是否启用 × 是否需要确认。 | | `src/domain/voicemail.ts`(46 行) | 纯接口,**零实现、零 import**(全仓库只有它自己引用 `VoicemailStore`/`VoicemailEntry`)。 | 不砍,但**必须**在 A4 开工时兑现,否则删。留着就是路线图自欺。 | | `src/rpc/contract.ts`(68 行) | **零 import**。客户端在 `src/client/callcard-api.ts:40` 自己重抄了一份 `VoiceAnswerPayload`,所谓「未来可平移到 connection-RPC」是化石。 | 二选一:让 `callcard-api.ts` 真的 import 它(消除双份真相),或者删。**不要维持现状。** | | `durableEvents` + `voice/note` 事件链(`src/events/call.ts`、`src/session-events.ts`、`src/client/definition.ts`、`src/client/types.ts`、`src/index.ts` 7 处 plumbing) | 按上一轮调研:plugin 事件在 rc.2 上进不了会话(读路径遇未知类型拒绝重建,`Session.append` 写不了 `ignorable`),所以 `durableEvents` 默认 false 且不可能打开。连带后果:`src/client/definition.ts:45,82` 的 `voice-note` 卡片节点**只在事件存在时渲染、而事件永远不存在**,即死 UI。目前体验正常是因为来电卡片走 SSE,与这条链无关。 | **本轮决定:不动。**只要求一点 —— 不再往 README 路线图里加新的「已预留」。 | ## 4. 我的排序建议 **v0.4 = A1 + A2 + A3 打包,顺手砍 §3 的前三行。A4 挪到 v0.5。** 理由:这三条能一句话说清(「它打电话给你,你接起来当场回话,而且它不乱吵你」),是一个 完整可演示的故事;而砍后端矩阵让 A1 的实现只需针对 crispasr 和浏览器,不用给三套平台写降级。 主要代价:A1 是这批里唯一有真实技术不确定性的(非 agent 作用域注入、麦克风权限、延迟), 把它和 A2/A3 绑在一版里,A2/A3 会被 A1 的 spike 卡住。如果想稳,就把 A1 单独切出去当 v0.5 的大动作,v0.4 只做「A2 + A3 + 砍」。 **反对继续往上摞新功能的主要理由**:现在的问题不是功能不够,是「预留」在积累而兑现率是 0 ——README 路线图甚至连标号都错位了:已发布的 0.3.0 装的是迁移 + 卡片改版 + 通话生命周期, 而路线图里 v0.3 那一格还挂着语音信箱(`README.md:275`)。下一版要么兑现一格,要么合并 一格,别再新增一格。 ## 5. 开工前必须结掉的问题 1. **A4 的回执走推送还是走拉取**(agent 主动被告知「他听过了」 vs agent 自己调工具查)—— 上一轮调研留的 A/B/C,还没有答案。它决定 `VoicemailStore` 的接口形状。 2. **A1 从 HTTP handler 作用域调 `followup` 是否可行** —— 一次 spike 就能定,先做它。 3. **砍后端会不会伤到假想中的外部用户** —— 目前是纯本地假设,没有安装量数据。 4. 悬着的旧账,跟本轮无关但别忘了:`export function CallCard` 当初只是为了离线预览 才放宽可见性;接听按钮白字在 `#4176e6` 上是 4.24:1,未走严格 AA; `~/.dsh/profiles/web` 还指着本地 tarball 而不是已发布的 npm 版本。 ## 6. 本仓库特有的坑(写进代码前重读) - esbuild 把 CJK 全转义成 `\uXXXX`,在 `lib/` 里 grep 中文 UI 字符串是**假阴性**。 要比对安装产物就 diff `lib/`,或者 grep ASCII 类名。 - profile 是 `nodeLinker: hoisted`:`pnpm build && pnpm pack` 覆盖同一个 tarball 后, `pnpm install` 会报 "Already up to date" 并把**旧文件**留在 `node_modules/dsh-voice-call`, `--force` 也没用。先 `rm -rf node_modules/dsh-voice-call` 再装,然后确认文件真的变了。 - **不要自己起 `dsh web`。** 之前有个后台实例占着 `127.0.0.1:3080`,他自己启动就 `EADDRINUSE` 崩了。构建、安装、验证,然后把服务器交给他起。 - 所有 `@deepseek-ai/*` 都是 peerDependency(宿主运行时提供),且本地**没装 `react-dom`**, 所以做不了 SSR 预览。可视化验证走 `.verify-backup/uidemo/`(gitignored)里那套 `react/jsx-runtime` shim + 无头 Edge 截图。 - 上传授权是**一次性**的:0.3.0 那次(提交+推送+tag+publish)已经用完,不等于以后可以。 任何对外动作都重新问。