# DSH 桌面客户端集成 > 相关:[01-方案设计.md](01-方案设计.md) | [02-进展与下一步.md](02-进展与下一步.md) 本文回答一件事:**这个插件怎么在 DSH 桌面客户端里用起来**,以及为什么这样实现。 --- ## 一、形态:输入框旁的小挂件 在对话输入框的工具行里,模型选择器与提交按钮之间,多了一个 AI Passport 小挂件: ``` ┌─────────────────────────────────────────────────────────┐ │ 对话内容… │ │ │ │ [+ 附件] [●已连接] [模型 ▾] [计划] [🎤] [▶ 发送] │ │ ↑ │ │ AI Passport 挂件 │ └─────────────────────────────────────────────────────────┘ ``` 挂件本身只占一行内联空间,显示**当前最该知道的那一件事**: | 状态 | 显示 | | --- | --- | | 未连接 | 灰点 + 「未连接」 | | 扫描/连接中 | 黄点 + 「扫描中」「连接中」 | | 已连接、无任务在跑 | 绿点 + 「已连接」 | | 有等待中的审批 | 绿点 + 右侧红色数字角标 | 点开是一个 180px 的浮层,**右对齐挂件**(`--ap-pop-x`,默认 24px,用于再向左微调), 从上到下依次是: 1. **动作结果**(有操作时才出现)—— 上一次面板动作返回的文字,避免"点了没反应"的错觉 2. **设备** —— 设备名 + 固件 / 电量 / 信号 / 握手四格;断开 / 诊断蓝牙;未连接时给出「扫描并连接」与失败原因 3. **语音** —— 一行搞定:`语音 已启用`(状态跟在标题右侧,绿色=已启用);只有"确实没装 / 上次准备失败"时,才在**状态右边**再挂一个小的「准备模型」按钮。下载/加载期间该位置显示进度说明 (插件**启动时**就探测一次识别目录、带 3 次重试,所以首屏状态就是准的; 打开弹窗再刷一次,未就绪期间每 5 秒继续刷,就绪后按钮自行消失) > 待审批、任务列表、DeepSeek 余额三块已移除:审批由设备端审批页处理(插件只保留角标), > 余额由设备端长按下键查看,任务区按用户要求去掉。 > > **"已启用"的判定**(2026-10-06 修正):DSH 在推理时接受 `ready` **或 `standby`** 两种资源 > (`standby` = 模型已装好、只是没预热,第一次录音会自动唤醒 worker)。早先按 `phase === 'ready'` > 判定,模型一闲置就显示"未启用" —— 真机反馈"模型早就配好了却显示未启用"即此。 > 现在:`ready|standby` → 已启用;`unprepared|failed|cancelled` → 给「准备模型」; > `checking|downloading|preparing|loading|waking` → 显示进度说明。 > > 附带一个事实澄清:主机快照里**从来没有** `tasks` 列表(只有给设备端用的 `taskState`), > 所以挂件那块任务 UI 一直是"暂无任务"的空壳;`device.refresh` 也早已退化成"重推一次状态"。 > 独立控制面板页(`lib/panel/static`)里同样的任务区也一直是空的,**本次未动**。 --- ## 二、语音:识别结果填进当前输入框 设备上长按「确定」录音 → 音频经 BLE 到电脑 → 本地识别 → **设备端弹出识别结果卡**: 单击「确定」= 填入光标处、双击 = 整段替换并直接发送、长按 = 重说。 挂件在这一链里负责执行与回执:认领结果(`voice.take`)→ 调 `inputActions` → 回执(`voice.ack`), 设备端据此给出「已填入 / 已发送」的提示。 为什么是"填进去"而不是"直接发送":语音识别必然有误识别,尤其人名、路径、代码标识符。 让用户看一眼再发,比事后发现任务跑错了要便宜得多。设备侧也有对应的确认步骤。 ### 怎么插进输入框 用的是 DSH 官方语音输入插件**同一套**动词。`conversation.input.activity` 槽位的宿主 (`ui-conversation`)会给每个注册者注入一个 `inputActions`: ```js inputActions.captureInsertion() // 取当前光标位置 + 草稿修订号 inputActions.insertText(text, span) // 在光标处插入,返回是否成功 ``` 关键设计: - 用 `insertText` 而**不是** `setDraft`。`setDraft` 会整段替换,把用户已经写了一半的内容冲掉; `insertText` 只在光标处插入。 - `insertText` 带 `draftRev` 冲突检测:如果识别期间用户改了草稿,插入会被拒绝而不是覆盖。 此时挂件不静默失败,会通过 `voice.ack` 把失败原因回给设备(toast)。 - 挂件对 `inputActions` 缺失是**容错**的:拿不到就只是不做插入,其余功能照常。 这样 DSH 以后调整这个槽位的契约时,最坏结果是少一个动作,而不是挂件整个崩掉。 --- ## 三、为什么用同源 HTTP 而不是 Remote 客户端半区跑在页面里,宿主半区(BLE、任务、审批)跑在 Node 里,两者要通信。 DSH 里有一等公民 `ctx.remote.*`,但它**用不了**: `ctx.remote.` 那些命名空间由宿主侧 `@Remote` 装饰器在 **DSH 自己的构建期** 经 Typert 生成 codec 与网关路由。第三方包在自己的仓库里构建,无法注册新的 Remote 命名空间。 所以走**同源 HTTP**:宿主半区用 `ctx.webServer.register()` 挂 `/dsh-passport/*`, 客户端与页面同源,直接 `fetch` 即可。 | 端点 | 方法 | 用途 | | --- | --- | --- | | `/dsh-passport/state.json` | GET | 完整状态快照 | | `/dsh-passport/action` | POST | 唯一写入口 | | `/dsh-passport/events` | GET | SSE(完整面板页用) | | `/dsh-passport` | GET | 完整控制面板页(浏览器打开时用) | 这套接口**同时**服务挂件与完整面板页,于是两边共用一套鉴权、一套数据源, 不会出现"挂件看到的和面板看到的不一样"。 ### 为什么挂件用轮询而不是 SSE 挂件是常驻的、每个窗口一份。SSE 连接会一直占着(同源并发连接数有限), 而挂件需要的信息量只有几百字节,2 秒轮询在桌面上完全无感。 真正需要实时性的审批由宿主半区**主动推给设备**,挂件迟 2 秒看到不影响正确性—— 用户是在设备上按键决定的。 页面隐藏时轮询自动降频到 10 秒,避免后台空转。 --- ## 四、客户端 bundle 的硬约束 客户端半区(`lib/client.js`)是**预先构建好的浏览器 bundle**,由 `@deepseek-ai/dsh-client-modules` 扫描、拼接、下发。格式是硬约束,任一条不满足都会让 **client 包组合整体失败**, 表现为"客户端起不来",排查成本极高: 1. 文件是 `window.__ModuleLoader__.load({ id, factory })` 调用; 2. **`id` 必须严格等于包名**(`dsh-ai-passport`); 3. factory 内用注入的 `require` 取共享依赖,只能取 DSH 提供的那些 (本插件只用了 `react`); 4. 导出 `apply(ctx)` 与 `inject`(硬依赖服务名数组); 5. bundle 路径由 `package.json` 的 `exports["./client"]` 解析; 6. `package.json` 必须有 `dsh.client` 段,否则根本不会被扫描。 因为**没有构建步骤**(本插件刻意不引入打包链),所以 `client.js` 里不写 JSX, 全部用 `React.createElement`。 ### 这一层怎么测 `test/client-bundle.test.js` 用一个假的 `window.__ModuleLoader__` 与假的 `require` 把 bundle 真跑一遍,卡住上面每一条: - id 与包名一致; - 只 require 允许的模块(多 require 一个不存在的模块就会在这里失败,而不是等 DSH 启动); - 导出形状正确; - `apply()` 确实在 `conversation.input.activity` 注册了组件; - 样式 effect 返回清理函数(否则热重载几次就会堆一串重复 `