@oadank/dsh-input-tools 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,10 +29,22 @@
29
29
  - **语音设置页**(设置 → 语音服务):引擎折叠卡片、小米三模型分区、克隆样本管理、ASR 模式、试听(合成/原声)。
30
30
  - **语音消息复制按钮**:用户/AI 语音条尾部复制转写文本。
31
31
 
32
- ## 安装(三步)
32
+ ## 安装
33
33
 
34
34
  > `~/.dsh` 指 DSH Web 运行时目录(Windows 为 `C:\Users\<你>\.dsh`)。
35
35
 
36
+ ### 方式一(推荐):npm 一键安装
37
+
38
+ ```bash
39
+ dsh plugin --profile web add @oadank/dsh-input-tools
40
+ ```
41
+
42
+ 自动完成:下载 npm 包 → 装入 profile → 注册 `cordis.patch.yml` → 重启 dsh-web 生效。
43
+ (补丁脚本 `patches/apply-voice-patch.ps1` 也已随包发布,路径在
44
+ `node_modules\@oadank\dsh-input-tools\patches\`。)
45
+
46
+ ### 方式二:手动拷包(离线/开发)
47
+
36
48
  1. **拷插件包**:把本仓库 `lib/`、`package.json` 拷到
37
49
  `~/.dsh/profiles/node_modules/@oadank/dsh-input-tools/`
38
50
  (`@oadank` 目录不存在就创建)
@@ -83,3 +95,40 @@ ASR 模式都在设置页「语音服务」分区配置。
83
95
  由 `dsh-composer-plugin`(client 工具条)与 `dsh-host-voice`(host 语音)合并重写而来,
84
96
  2026-08-21 统一为单包双入口。原 dsh-host-voice 的 git 历史备份在
85
97
  `plugins/_archive/dsh-voice-plugin-git-*.bundle`。
98
+
99
+ ## 语音源码补丁(完整体验原生语音消息,可选)
100
+
101
+ dsh 的 npm 安装版(0.1.0-rc.7)**契约不支持原生语音消息**(voice content、语音消息气泡、
102
+ AI 语音回复条均为本地源码增强,官方源码/官方发布版默认都没有)。要用完整语音体验,
103
+ **必须使用官方源码 + 本补丁**(推荐),或直接使用本项目 fork(oadank/deepseek-harness)。
104
+
105
+ ### 完整安装流程(小白照做)
106
+
107
+ 1. **官方源码安装**(无 git 先装 git;Windows 建议装到 D:\opt 下):
108
+ ```bash
109
+ git clone https://github.com/deepseek-ai/deepseek-harness.git
110
+ cd deepseek-harness
111
+ git checkout 141eb6fef8 # 官方 dsh-0.1.0-rc.8 release 合并点
112
+ ```
113
+ 2. **打语音补丁**(Windows 管理员 PowerShell,脚本会自动探测源码位置,
114
+ 也可以在源码仓库根目录直接运行):
115
+ ```powershell
116
+ # 插件 npm 安装后,补丁在本机位置:
117
+ cd node_modules\@oadank\dsh-input-tools\patches
118
+ powershell -ExecutionPolicy Bypass -File apply-voice-patch.ps1
119
+ ```
120
+ 脚本自动:探测/指定源码仓库 → 校验补丁可应用 → 备份未提交改动 → 应用 → 幂等(已打跳过)。
121
+ 3. **构建并启动**:
122
+ ```bash
123
+ pnpm install
124
+ pnpm run build:web # 前端语音气泡渲染在此步生效
125
+ dsh --profile web # 或注册 nssm 服务方式启动
126
+ ```
127
+ 4. **安装语音插件**:`dsh plugin --profile web add @oadank/dsh-input-tools`
128
+ 5. **可选:本地 ASR**:运行插件内 `scripts\install-asr.ps1`(管理员),
129
+ 自动下载 sherpa-onnx + SenseVoice 模型、注册 `asr` 常驻服务(端口 18790)。
130
+
131
+ > 补丁/脚本已随 npm 包发布(`patches/` 目录),git 仓库同步维护。
132
+ > 警告:补丁基于官方 rc.8(141eb6fef8)。官方 master 更新后补丁可能冲突,请 checkout 到该基线或等待补丁更新。回滚:git apply -R 或 git checkout -- 文件。勿在官方 master 上直接打补丁。
133
+
134
+ **npm 版(rc.7)说明**:语音输入(录音→ASR→发送)可用;AI 语音可合成(音频生成);但语音消息气泡/语音回复条受 rc.7 前端限制无法原生显示(插件 DOM 注入方案受 React 重渲染影响不稳定,已禁用)。完整体验请使用上面的源码版。
package/lib/client.js CHANGED
@@ -33,6 +33,78 @@ window.__ModuleLoader__.load({
33
33
  // ── 附件槽桥:官方 onAddImages 由 attachments 槽组件挂载时存入,left 按钮调用 ──
34
34
  let sharedOnAddImages = null;
35
35
 
36
+ // ── [2026-08-21] 语音气泡(聊天界面 DOM 注入,安装即用,不依赖 dsh 源码支持)────
37
+ // 录音 → 存服务器(/voice/outbox/save)→ ASR 转文本 → 发【用户语音】标记文本;
38
+ // observer 发现带标记的消息 → 注入语音条(可播放)。dsh 原生支持 voice 的版本
39
+ // (rc.8 本地改造)走多模态直发,消息本身没有该标记,不会触发注入(官方渲染语音条)。
40
+ let voiceBubbleStarted = false;
41
+ const pendingVoiceQueue = []; // [{ voiceId, ext }] 待消费的录音(FIFO)
42
+ const injectedVoiceEls = new WeakSet(); // 已注入的元素
43
+ const VOICE_MSG_MARK = "【用户语音】";
44
+
45
+ function startVoiceBubbleObserver() {
46
+ if (voiceBubbleStarted || typeof MutationObserver === "undefined") return;
47
+ voiceBubbleStarted = true;
48
+ const tryInject = () => {
49
+ const els = Array.from(document.querySelectorAll("div,span,p,li"));
50
+ for (const el of els) {
51
+ if (injectedVoiceEls.has(el)) continue;
52
+ if (el.querySelector("audio[data-voice-bubble]")) { injectedVoiceEls.add(el); continue; }
53
+ const text = el.textContent ?? "";
54
+ // [2026-08-21] AI 语音回复:**已禁用**。DOM 注入在 React 重渲染下会随 Tool call 展开/折叠
55
+ // 重复注入、位置漂移、无限累积(用户实测图1-4),修不干净。AI 语音条走源码版(voice/reply
56
+ // 事件原生渲染);rc.7 上 AI 语音音频已生成但界面不显示,属 rc.7 硬伤,引导用户使用源码版。
57
+ // if (text.includes("语音已发送") && text.includes("voiceId") && text.includes("sha256:")) { ... 注入 ... }
58
+ if (!text.includes(VOICE_MSG_MARK)) continue;
59
+ // 只处理"叶子级"文本块:若子元素已含标记(父容器),跳过避免重复注入
60
+ let childHasMark = false;
61
+ for (const c of el.children) {
62
+ if ((c.textContent ?? "").includes(VOICE_MSG_MARK)) { childHasMark = true; break; }
63
+ }
64
+ if (childHasMark) continue;
65
+ const meta = pendingVoiceQueue.shift();
66
+ if (meta) injectVoiceCard(el, `/voice/outbox/${meta.voiceId}.${meta.ext || "webm"}`, 1); /* 用户消息气泡内 */
67
+ injectedVoiceEls.add(el);
68
+ }
69
+ };
70
+ const obs = new MutationObserver(() => tryInject());
71
+ obs.observe(document.body, { childList: true, subtree: true, characterData: true });
72
+ tryInject();
73
+ }
74
+
75
+ function injectVoiceCard(anchorEl, audioSrc, hop = 1) {
76
+ try {
77
+ // [2026-08-21 修] 不再硬编码爬 7 层(之前导致 AI 语音条藏到 Tool call 折叠块里)——
78
+ // 改为可指定爬层数:
79
+ // 1 = 用户消息:爬 1 层到消息气泡内(气泡可能就在文本的父级)
80
+ // 6 = AI 语音回复:爬 6 层穿透 Tool call 折叠卡到主 assistant message 行
81
+ let host = anchorEl;
82
+ for (let i = 0; i < hop && host.parentElement; i++) host = host.parentElement;
83
+ if (!host || host.querySelector("audio[data-voice-bubble]")) return;
84
+ const audio = document.createElement("audio");
85
+ audio.src = audioSrc;
86
+ audio.preload = "metadata";
87
+ audio.dataset.voiceBubble = "1";
88
+ const btn = document.createElement("button");
89
+ btn.type = "button";
90
+ btn.style.cssText = "display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border:none;border-radius:50%;background:rgba(229,72,77,.18);color:#e5484d;cursor:pointer;font-size:14px;flex:none;line-height:1;";
91
+ btn.textContent = "▶";
92
+ btn.onclick = () => { if (audio.paused) { void audio.play(); btn.textContent = "⏸"; } else { audio.pause(); btn.textContent = "▶"; } };
93
+ audio.onended = () => { btn.textContent = "▶"; };
94
+ audio.onerror = () => { btn.textContent = "⚠"; btn.title = "音频加载失败"; };
95
+ const dur = document.createElement("span");
96
+ dur.style.cssText = "font-size:11px;opacity:.75;min-width:26px;";
97
+ audio.onloadedmetadata = () => {
98
+ const s = Math.round(audio.duration || 0);
99
+ dur.textContent = s ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}` : "";
100
+ };
101
+ const card = document.createElement("div");
102
+ card.style.cssText = "display:inline-flex;align-items:center;gap:8px;background:rgba(229,72,77,.1);border:1px solid rgba(229,72,77,.28);border-radius:999px;padding:4px 12px;margin-top:6px;width:fit-content;max-width:260px;align-self:flex-start;";
103
+ card.append(btn, dur);
104
+ host.appendChild(card);
105
+ } catch { /* 注入失败不影响消息 */ }
106
+ }
107
+
36
108
  // ── 源码 SVG 图标 ────────────────────────────────────────────
37
109
  const svgProps = { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": true };
38
110
  const imageIcon = h("svg", svgProps,
@@ -69,6 +141,8 @@ window.__ModuleLoader__.load({
69
141
  const timerRef = useRef(null);
70
142
  const fileRef = useRef(null);
71
143
  const voiceSupported = typeof navigator !== "undefined" && typeof MediaRecorder !== "undefined";
144
+ // [2026-08-21] 语音气泡注入:页面一挂载就监听消息流,给【用户语音】消息贴语音条
145
+ useEffect(() => { startVoiceBubbleObserver(); }, []);
72
146
 
73
147
  const sendVoiceBlob = useCallback(async (blob) => {
74
148
  if (connection === undefined) return;
@@ -79,31 +153,72 @@ window.__ModuleLoader__.load({
79
153
  reader.onerror = reject;
80
154
  reader.readAsDataURL(blob);
81
155
  });
82
- try {
156
+ const fail = (msg) => {
157
+ setVoiceError(msg);
158
+ if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
159
+ voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
160
+ };
161
+ const sendAsText = async (text) => {
162
+ // [2026-08-21] 降级路径:XDN(npm rc.7) 不支持 voice content。带【用户语音】标记让 AI
163
+ // 知道这是语音转的文本,可以按规则(自动 TTS)回复。
164
+ const marked = "【用户语音】" + text;
83
165
  const response = await connection.api.sessions.prompt({
84
166
  sessionId, mode: "queue",
85
- content: [{ type: "voice", mediaType, data }],
167
+ content: [{ type: "text", text: marked }],
86
168
  });
87
- // [本地改造 2026-08-21] 该 RPC 失败不 throw,而是返回 result.ok=false——
88
- // 必须检查返回值,否则 ASR 识别失败(VOICE_ASR_FAILED)会静默无反馈。
89
169
  const result = response?.result;
90
170
  if (!result || !result.ok) {
91
- const err = result?.error;
92
- const msg = (err && typeof err.message === "string" && err.message !== "")
93
- ? err.message
94
- : "语音发送失败,请重试";
95
- setVoiceError(msg);
96
- if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
97
- voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
171
+ fail((result?.error && typeof result.error.message === "string" && result.error.message !== "")
172
+ ? result.error.message : "语音发送失败,请重试");
98
173
  }
174
+ };
175
+ const sendAsVoice = async () => {
176
+ // 首选:多模态直发(AI 能听原音,消息渲染为语音气泡)——本机 lecoo / dev rc.8 支持
177
+ const response = await connection.api.sessions.prompt({
178
+ sessionId, mode: "queue",
179
+ content: [{ type: "voice", mediaType, data }],
180
+ });
181
+ return response?.result;
182
+ };
183
+ try {
184
+ // [2026-08-21 修] 先直发 voice,失败时降级 ASR 转文本(rc.7 兼容)。
185
+ // 这样本机/rc.8 享受多模态(AI 听到原音 + 语音消息气泡),XDN/rc.7 自动降级不报错。
186
+ let result;
187
+ try {
188
+ result = await sendAsVoice();
189
+ } catch (voiceErr) {
190
+ result = null;
191
+ }
192
+ // 直发成功(rc.8/dev):result.ok true
193
+ if (result && result.ok) return;
194
+ // 失败或不支持:尝试降级
195
+ const errMsg = result?.error?.message ?? "";
196
+ // 只有"contract/payload"类错误才降级;其他业务错误直接提示
197
+ const isContractError = /invalid payload|schema|contract|not supported|unsupported/i.test(errMsg);
198
+ if (!isContractError && result && !result.ok) {
199
+ fail(errMsg || "语音发送失败,请重试");
200
+ return;
201
+ }
202
+ // 走 ASR 转文本(降级路径:dsh 契约不支持 voice content)
203
+ // 先把录音存到服务器(语音气泡数据源),再识别再发【用户语音】标记文本
204
+ try {
205
+ const sv = await (await fetch("/voice/outbox/save", {
206
+ method: "POST", headers: { "content-type": "application/json" },
207
+ body: JSON.stringify({ audioBase64: data, mediaType }),
208
+ })).json().catch(() => ({}));
209
+ if (sv?.ok) pendingVoiceQueue.push({ voiceId: sv.voiceId, ext: sv.ext ?? "webm" });
210
+ } catch { /* 存档失败不阻塞发送 */ }
211
+ const tr = await fetch("/asr/transcribe", {
212
+ method: "POST", headers: { "content-type": "application/json" },
213
+ body: JSON.stringify({ audioBase64: data, mediaType }),
214
+ });
215
+ const td = await tr.json().catch(() => ({}));
216
+ if (!td?.ok) { fail(td?.error ?? "语音识别失败,请检查 ASR 配置"); return; }
217
+ const text = typeof td?.text === "string" ? td.text.trim() : "";
218
+ if (text === "") { fail("没听清,请再说一次"); return; }
219
+ await sendAsText(text);
99
220
  } catch (e) {
100
- // 传输层异常兜底
101
- const msg = (e && typeof e.message === "string" && e.message !== "")
102
- ? e.message
103
- : "语音发送失败,请重试";
104
- setVoiceError(msg);
105
- if (voiceErrorTimerRef.current !== null) window.clearTimeout(voiceErrorTimerRef.current);
106
- voiceErrorTimerRef.current = window.setTimeout(() => setVoiceError(null), 6000);
221
+ fail(String(e && typeof e.message === "string" && e.message !== "" ? e.message : e));
107
222
  }
108
223
  }, [connection, sessionId]);
109
224
 
@@ -389,6 +504,13 @@ window.__ModuleLoader__.load({
389
504
  function VoiceSettingsSection() {
390
505
  const [config, setConfig] = useState(null);
391
506
  const [meta, setMeta] = useState(null);
507
+ // [2026-08-21] 语音能力状态面板(安装即用能力 vs dsh 原生契约支持)
508
+ const [caps, setCaps] = useState(null);
509
+ useEffect(() => {
510
+ fetch("/voice/capabilities").then((r) => r.json()).then((d) => {
511
+ if (d?.ok) setCaps(d.capabilities);
512
+ }).catch(() => { /* 检测失败不阻塞设置页 */ });
513
+ }, []);
392
514
  // [本地改造 2026-08-21] 服务商卡片折叠状态(去复选框后由折叠控制显隐,默认展开)
393
515
  const [openCards, setOpenCards] = useState({ edge: true, xiaomi: true, local: true, ali: true });
394
516
  const toggleCard = (key) => setOpenCards((s) => ({ ...s, [key]: !s[key] }));
@@ -737,6 +859,23 @@ window.__ModuleLoader__.load({
737
859
  // [2026-08-21] 试听失败错误横幅(原来静默无提示)
738
860
  previewErr !== null ? h("div", { style: { fontSize: "12.5px", lineHeight: "1.6", color: "#e5484d", border: "1px solid rgba(229,72,77,.4)", borderRadius: "8px", padding: "8px 10px", background: "rgba(229,72,77,.08)", whiteSpace: "pre-wrap" } },
739
861
  "试听失败:" + previewErr) : null,
862
+ // [2026-08-21] 语音能力状态面板(安装即用 vs dsh 原生契约支持)
863
+ caps !== null ? h("div", { style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "6px", background: "rgba(128,128,128,.05)", fontSize: "12.5px", lineHeight: "1.5" } },
864
+ h("div", { style: { fontSize: "12px", fontWeight: 600, opacity: .8 } }, "语音能力"),
865
+ h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
866
+ h("span", { style: { color: "#3ecf8e" } }, "✅"), " 语音输入(录音+识别+发送)", h("span", { style: { marginLeft: "auto", opacity: .7 } }, "插件自带")),
867
+ h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
868
+ h("span", { style: { color: "#3ecf8e" } }, "✅"), " 聊天语音气泡(可点播放)", h("span", { style: { marginLeft: "auto", opacity: .7 } }, "插件内置")),
869
+ h("div", { style: { display: "flex", alignItems: "center", gap: "6px" } },
870
+ caps.voiceContentContract === true
871
+ ? h("span", { style: { color: "#3ecf8e" } }, "✅")
872
+ : h("span", { style: { color: "#e5a53a" } }, "⚠️"),
873
+ " dsh 原生语音消息(多模态直发)",
874
+ h("span", { style: { marginLeft: "auto", opacity: .7 } },
875
+ caps.voiceContentContract === true ? "当前 dsh 支持" : "当前 dsh 不支持,自动转文字发送")),
876
+ caps.voiceContentContract === true ? null : h("div", { style: { fontSize: "12px", opacity: .75, marginTop: "2px" } },
877
+ "说明:当前 dsh(npm 安装版)契约不支持原生语音消息,语音自动转文字发送,AI 通过【用户语音】标记识别。原生语音消息需使用「含语音改造的 dsh」——注意:官方源码/官方发布版均无此功能,语音能力是语音插件配套的 dsh 本地改造(本机 lecoo 的 dev 仓库即为改造版),正整理提交官方。"),
878
+ ) : null,
740
879
  // ⑤ ASR 语音识别(必填项,无开关)
741
880
  h("div", { style: { border: "1px solid var(--dsw-alias-border-l1,#333a45)", borderRadius: "10px", padding: "10px 12px", display: "flex", flexDirection: "column", gap: "8px", background: "rgba(128,128,128,.05)" } },
742
881
  h("div", { style: { display: "flex", alignItems: "center", gap: "8px" } },
package/lib/index.js CHANGED
@@ -168,6 +168,53 @@ async function saveVoiceConfig(config) {
168
168
  // ──────────────────────────────────────────────────────────────
169
169
  const MAX_VOICE_BYTES = 25 * 1024 * 1024
170
170
 
171
+ // [2026-08-21] 语音气泡(聊天界面 DOM 注入):录音暂存 DSH_HOME/voice-outbox/
172
+ const VOICE_OUTBOX_EXT = {
173
+ 'audio/webm': 'webm',
174
+ 'audio/ogg': 'ogg',
175
+ 'audio/mp4': 'mp4',
176
+ 'audio/m4a': 'm4a',
177
+ 'audio/wav': 'wav',
178
+ 'audio/mpeg': 'mp3',
179
+ }
180
+ const VOICE_OUTBOX_MIME = {
181
+ webm: 'audio/webm', ogg: 'audio/ogg', mp4: 'audio/mp4', m4a: 'audio/m4a',
182
+ wav: 'audio/wav', mp3: 'audio/mpeg',
183
+ }
184
+
185
+ /** 检测当前 dsh 的 connection 契约是否原生支持 voice content(rc.8 本地改造有;npm 官方版无)。
186
+ * 优先从 dsh 进程实际运行的位置解析(dev 仓库 cwd / npm 全局),避免误报。 */
187
+ /** 从音频文件头嗅探媒体类型(对象存储无扩展名,TTS 输出可能是 wav/mp3)。 */
188
+ function sniffAudioType(bytes) {
189
+ if (bytes.length >= 4 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) return 'audio/wav'
190
+ if (bytes.length >= 3 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) return 'audio/mpeg'
191
+ if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) return 'audio/mpeg'
192
+ if (bytes.length >= 4 && bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) return 'audio/ogg'
193
+ return 'audio/mpeg'
194
+ }
195
+
196
+ async function detectVoiceContractSupport() {
197
+ const markers = ['literal("voice")', "literal('voice')"]
198
+ const containsVoice = (s) => markers.some((m) => s.includes(m))
199
+ // 1) dev 仓库(本机 lecoo:dsh 由 apps/cli tsx 直接跑,cwd=仓库根)
200
+ for (const rel of [
201
+ join('packages', 'client', 'connection', 'lib', 'client.js'),
202
+ join('node_modules', '@deepseek-ai', 'dsh-client-connection', 'lib', 'client.js'),
203
+ ]) {
204
+ try {
205
+ if (containsVoice(readFileSync(join(process.cwd(), rel), 'utf8'))) return true
206
+ } catch { /* 下一个候选 */ }
207
+ }
208
+ // 2) npm 安装(XDN:dsh 在全局 node_modules,从插件解析链向上找)
209
+ try {
210
+ const { createRequire } = await import('node:module')
211
+ const require = createRequire(import.meta.url)
212
+ const p = require.resolve('@deepseek-ai/dsh-client-connection/lib/client.js')
213
+ if (containsVoice(readFileSync(p, 'utf8'))) return true
214
+ } catch { /* 找不到 */ }
215
+ return false
216
+ }
217
+
171
218
  function voiceStorageRoot() {
172
219
  const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
173
220
  return resolve(join(home, 'attachments', 'v1'))
@@ -1087,6 +1134,75 @@ async function apply(ctx) {
1087
1134
  }
1088
1135
  },
1089
1136
  }))
1137
+
1138
+ // [2026-08-21] 语音气泡(聊天界面 DOM 注入)配套:录音文件存取 + 能力检测。
1139
+ // 独立 prefix:聊天界面语音条的前端注入需要能播放"用户刚才那段语音"的音频 URL。
1140
+ disposers.push(ctx.webServer.register({
1141
+ kind: 'prefix',
1142
+ path: '/voice',
1143
+ handler: async (req, res) => {
1144
+ const url = new URL(req.url ?? '/', 'http://x')
1145
+ try {
1146
+ // 能力检测:插件自带能力 vs dsh 原生契约支持(用于设置页状态面板)
1147
+ if (url.pathname === '/voice/capabilities' && req.method === 'GET') {
1148
+ return sendJson(res, 200, {
1149
+ ok: true,
1150
+ capabilities: {
1151
+ voiceInput: true, // 录音+ASR+发送:插件自带
1152
+ voiceBubble: 'plugin-dom', // 聊天语音气泡:插件 DOM 注入(安装即用)
1153
+ voiceContentContract: await detectVoiceContractSupport(), // dsh 原生契约是否支持 voice content
1154
+ },
1155
+ })
1156
+ }
1157
+ // 保存录音(语音气泡数据源):DSH_HOME/voice-outbox/<voiceId>.<ext>
1158
+ if (url.pathname === '/voice/outbox/save' && req.method === 'POST') {
1159
+ const body = await readJsonBody(req)
1160
+ const b64 = typeof body?.audioBase64 === 'string' ? body.audioBase64 : ''
1161
+ const mediaType = typeof body?.mediaType === 'string' ? body.mediaType : 'audio/webm'
1162
+ if (b64 === '') return sendJson(res, 400, { ok: false, error: '缺少音频数据' })
1163
+ const ext = VOICE_OUTBOX_EXT[mediaType] ?? 'webm'
1164
+ const voiceId = randomUUID()
1165
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1166
+ const dir = join(homeDir, 'voice-outbox')
1167
+ await mkdir(dir, { recursive: true })
1168
+ await writeFile(join(dir, `${voiceId}.${ext}`), Buffer.from(b64, 'base64'))
1169
+ return sendJson(res, 200, { ok: true, voiceId, mediaType, ext })
1170
+ }
1171
+ // 读取录音:GET /voice/outbox/<voiceId>
1172
+ const outboxMatch = url.pathname.match(/^\/voice\/outbox\/([0-9a-f-]{36})\.([a-z0-9]+)$/)
1173
+ if (outboxMatch && req.method === 'GET') {
1174
+ const [, voiceId, ext] = outboxMatch
1175
+ const homeDir = process.env.DSH_HOME ?? join(homedir(), '.dsh')
1176
+ const file = join(homeDir, 'voice-outbox', `${voiceId}.${ext}`)
1177
+ const bytes = await readFile(file).catch(() => null)
1178
+ if (bytes === null) return sendJson(res, 404, { ok: false, error: '音频不存在' })
1179
+ const mediaType = VOICE_OUTBOX_MIME[ext] ?? 'audio/webm'
1180
+ res.writeHead(200, { 'Content-Type': mediaType, 'Content-Length': bytes.length })
1181
+ res.end(bytes)
1182
+ return
1183
+ }
1184
+ // [2026-08-21] AI 语音回复:按内容寻址读 send_voice 生成的语音对象
1185
+ // GET /voice/object/<sha256>(对象存于 DSH_HOME/attachments/v1/objects/<前2位>/<sha>)
1186
+ const objMatch = url.pathname.match(/^\/voice\/object\/([0-9a-f]{64})$/)
1187
+ if (objMatch && req.method === 'GET') {
1188
+ const sha = objMatch[1]
1189
+ const file = join(voiceStorageRoot(), 'objects', sha.slice(0, 2), sha)
1190
+ const bytes = await readFile(file).catch(() => null)
1191
+ if (bytes === null) return sendJson(res, 404, { ok: false, error: '语音不存在' })
1192
+ res.writeHead(200, {
1193
+ 'Content-Type': sniffAudioType(bytes),
1194
+ 'Content-Length': bytes.length,
1195
+ 'Cache-Control': 'public, max-age=86400',
1196
+ })
1197
+ res.end(bytes)
1198
+ return
1199
+ }
1200
+ return sendJson(res, 404, { ok: false, error: 'not found' })
1201
+ } catch (error) {
1202
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : 'unknown' })
1203
+ }
1204
+ },
1205
+ }))
1090
1206
  }
1091
1207
 
1092
1208
  // 1) turn/end 自动语音回复(规则同 api-proxy 原实现)
@@ -1094,7 +1210,10 @@ async function apply(ctx) {
1094
1210
  if (event.type !== 'turn/end') return
1095
1211
  const turn = event.data.turn
1096
1212
  // 去重:本轮若已通过 send_voice 发过语音,则跳过兜底,避免 AI 回复两条内容相近的语音
1097
- if (session.events.some((ev) => ev.type === 'voice/reply' && ev.data?.turn === turn)) return
1213
+ // [2026-08-21 修] rc.7 session.events 结构不同/可能缺失——容错处理
1214
+ let alreadyReplied = false
1215
+ try { alreadyReplied = session.events?.some?.((ev) => ev.type === 'voice/reply' && ev.data?.turn === turn) ?? false } catch { /* 忽略 */ }
1216
+ if (alreadyReplied) return
1098
1217
  void (async () => {
1099
1218
  try {
1100
1219
  const events = session.events
@@ -1256,8 +1375,11 @@ async function apply(ctx) {
1256
1375
  },
1257
1376
  },
1258
1377
  async execute(args, exec) {
1259
- const agent = exec.agent
1260
- if (agent === undefined) return { ok: false, error: 'no session context' }
1378
+ // [2026-08-21 修] rc.7 的 defineTool execute 可能只有 (args) 签名(exec undefined)——
1379
+ // 直接 exec.agent throw,导致 dsh 拿不到工具结果、会话消息序列断裂
1380
+ // (OpenAI 报 "assistant message with tool_calls must be followed by tool messages")。
1381
+ const agent = exec?.agent
1382
+ if (agent === undefined) return { ok: false, error: 'no session context (tool exec signature unsupported)' }
1261
1383
  const session = agent.session
1262
1384
  const text = args.text.trim()
1263
1385
  if (text === '') return { ok: false, error: 'text is empty' }
@@ -1269,17 +1391,26 @@ async function apply(ctx) {
1269
1391
  const attachment = await saveVoiceFile(
1270
1392
  voiceStorageRoot(), audio.data, audio.mediaType, audio.durationMs,
1271
1393
  )
1272
- const turn = session.events
1273
- .filter((event) => event.type === 'turn/start')
1274
- .at(-1)?.data.turn ?? 0
1275
- session.append('voice/reply', {
1276
- turn,
1277
- voiceId: attachment.voiceId,
1278
- mediaType: attachment.mediaType,
1279
- bytes: attachment.bytes,
1280
- transcript: text,
1281
- ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1282
- })
1394
+ // [2026-08-21 修] rc.7 的 session 没有 voice/reply 事件(官方契约无)——
1395
+ // session.events / session.append rc.7 上不存在或结构不同,直接调用会崩
1396
+ // "Cannot read properties of undefined (reading 'prepare')"。全部容错:
1397
+ // 语音已生成并存档,事件仅作"渲染提示",append 失败不影响工具成功。
1398
+ let turn = 0
1399
+ try {
1400
+ turn = session.events
1401
+ .filter((event) => event.type === 'turn/start')
1402
+ .at(-1)?.data.turn ?? 0
1403
+ } catch { /* rc.7 结构差异:忽略 */ }
1404
+ try {
1405
+ session.append('voice/reply', {
1406
+ turn,
1407
+ voiceId: attachment.voiceId,
1408
+ mediaType: attachment.mediaType,
1409
+ bytes: attachment.bytes,
1410
+ transcript: text,
1411
+ ...(attachment.durationMs === undefined ? {} : { durationMs: attachment.durationMs }),
1412
+ })
1413
+ } catch { /* rc.7 无 append:忽略(语音条由插件 DOM 注入渲染) */ }
1283
1414
  return {
1284
1415
  ok: true,
1285
1416
  voiceId: attachment.voiceId,
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@oadank/dsh-input-tools",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/oadank/dsh-input-tools.git"
10
+ },
7
11
  "exports": {
8
12
  ".": { "default": "./lib/index.js" },
9
13
  "./client": { "default": "./lib/client.js" },
@@ -14,6 +18,7 @@
14
18
  "lib",
15
19
  "scripts",
16
20
  "assets",
21
+ "patches",
17
22
  "README.md",
18
23
  "cordis.patch.yml"
19
24
  ],
@@ -0,0 +1,112 @@
1
+ # ============================================================
2
+ # dsh-input-tools 语音源码补丁脚本(Windows)
3
+ # 作用:给【官方源码】版 deepseek-harness 打上语音增强补丁
4
+ # (原生语音消息气泡 / AI 语音回复 / voice content 契约)
5
+ # 适用版本:官方 deepseek-harness rc.8(commit 141eb6fef8,
6
+ # 即 dsh-0.1.0-rc.8 release 合并点)
7
+ # 用法:在源码仓库根目录执行(或 -RepoPath 指定):
8
+ # powershell -ExecutionPolicy Bypass -File apply-voice-patch.ps1
9
+ # 前置:已 git clone 官方仓库并 checkout 到 rc.8 基线
10
+ # ============================================================
11
+ param(
12
+ [string]$RepoPath = ""
13
+ )
14
+
15
+ $ErrorActionPreference = "Stop"
16
+ $Patch = Join-Path $PSScriptRoot "dsh-voice-rc8.patch"
17
+ $Marker = "本地改造 2026-08-14" # 补丁内的指纹标记(api-request-trust.ts)
18
+
19
+ # ── 自动探测源码仓库位置(小白无需知道路径)────────────────────
20
+ function Find-Repo {
21
+ # 1. 显式指定
22
+ if ($RepoPath -ne "" -and (Test-Path (Join-Path $RepoPath ".git"))) { return $RepoPath }
23
+ # 2. 当前目录及向上 6 层(脚本可能放在源码仓库里/附近)
24
+ $cur = (Get-Location).Path
25
+ for ($i = 0; $i -le 6; $i++) {
26
+ if (Test-Path (Join-Path $cur ".git")) { return $cur }
27
+ $parent = Split-Path $cur -Parent
28
+ if ($parent -eq $cur) { break }
29
+ $cur = $parent
30
+ }
31
+ # 3. 找不到了——不猜固定路径(客户源码位置可能在任何盘符),返回空让用户输入
32
+ return $null
33
+ }
34
+
35
+ Write-Host "==== dsh 语音源码补丁 ====" -ForegroundColor Cyan
36
+ if ($RepoPath -eq "") {
37
+ $found = Find-Repo
38
+ if ($found) {
39
+ $RepoPath = $found
40
+ Write-Host "自动检测到源码仓库: $RepoPath" -ForegroundColor Green
41
+ } else {
42
+ # 找不到 → 交互式让用户输入源码路径(不限定任何盘符/位置)
43
+ Write-Host "`n未自动检测到源码仓库。" -ForegroundColor Yellow
44
+ Write-Host "请把源码仓库根目录(git clone 出来的 deepseek-harness 文件夹)路径告诉我:"
45
+ $input = Read-Host "dsh 源码仓库路径"
46
+ if ($input -ne "" -and (Test-Path (Join-Path $input ".git"))) {
47
+ $RepoPath = $input.Trim().Trim('"').Trim("'")
48
+ Write-Host "已使用: $RepoPath" -ForegroundColor Green
49
+ } else {
50
+ Write-Host "路径无效或不是 git 仓库(需要包含 .git 目录)。" -ForegroundColor Red
51
+ Write-Host "可用 -RepoPath 参数直接指定:" -ForegroundColor Yellow
52
+ Write-Host " powershell -ExecutionPolicy Bypass -File apply-voice-patch.ps1 -RepoPath D:\anywhere\deepseek-harness" -ForegroundColor Yellow
53
+ exit 1
54
+ }
55
+ }
56
+ }
57
+ Write-Host "源码目录: $RepoPath"
58
+ Write-Host "补丁文件: $Patch"
59
+
60
+ # 1. 校验 git 仓库
61
+ if (-not (Test-Path (Join-Path $RepoPath ".git"))) {
62
+ Write-Host " 错误:$RepoPath 不是 git 仓库(未 git clone 官方源码?)" -ForegroundColor Red
63
+ exit 1
64
+ }
65
+ Set-Location $RepoPath
66
+
67
+ # 2. 幂等:已打过则跳过
68
+ if (Test-Path "packages\client\connection\src\api-request-trust.ts") {
69
+ if (Select-String -Path "packages\client\connection\src\api-request-trust.ts" -Pattern $Marker -Quiet) {
70
+ Write-Host " 检测到语音补丁已应用,跳过。" -ForegroundColor Green
71
+ exit 0
72
+ }
73
+ }
74
+
75
+ # 3. 应用前自检(git apply --check 不实际改动)
76
+ Write-Host "`n[1/3] 校验补丁可应用..." -ForegroundColor Yellow
77
+ git apply --check $Patch
78
+ if ($LASTEXITCODE -ne 0) {
79
+ Write-Host " 补丁无法应用:请确认源码是官方 rc.8(git checkout 到 commit 141eb6fef8 或 tag dsh-0.1.0-rc.8)。" -ForegroundColor Red
80
+ Write-Host " 官方更新后的 master 与本补丁不兼容,请勿在 master 上直接应用。" -ForegroundColor Yellow
81
+ exit 1
82
+ }
83
+ Write-Host " 校验通过。" -ForegroundColor Green
84
+
85
+ # 4. 备份当前未提交改动(用于回滚)
86
+ Write-Host "`n[2/3] 备份当前工作区改动..." -ForegroundColor Yellow
87
+ $Backup = Join-Path $RepoPath (".voice-patch-backup-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".patch")
88
+ git diff > $Backup 2>$null
89
+ if ((Get-Item $Backup).Length -gt 0) {
90
+ Write-Host " 已有未提交改动,已备份到 $Backup" -ForegroundColor Green
91
+ } else {
92
+ Remove-Item $Backup -Force -ErrorAction SilentlyContinue
93
+ Write-Host " 工作区干净,无需备份。" -ForegroundColor Green
94
+ }
95
+
96
+ # 5. 应用补丁
97
+ Write-Host "`n[3/3] 应用语音补丁..." -ForegroundColor Yellow
98
+ git apply $Patch
99
+ if ($LASTEXITCODE -ne 0) {
100
+ Write-Host " 补丁应用失败(已备份可回滚)" -ForegroundColor Red
101
+ exit 1
102
+ }
103
+ Write-Host " 补丁已应用!" -ForegroundColor Green
104
+
105
+ Write-Host ""
106
+ Write-Host "==== 完成 ====" -ForegroundColor Cyan
107
+ Write-Host "后续步骤:"
108
+ Write-Host " 1. pnpm install"
109
+ Write-Host " 2. pnpm run build:web (构建前端,语音气泡渲染在此步生效)"
110
+ Write-Host " 3. dsh --profile web 或 nssm 服务方式启动"
111
+ Write-Host " 4. 安装语音插件:dsh plugin --profile web add @oadank/dsh-input-tools"
112
+ Write-Host "回滚:git checkout -- <文件> 或 git apply -R $Patch"