client.js 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964
  1. /**
  2. * dsh-voice-client — browser bundle (hand-written in the DSH __ModuleLoader__ format).
  3. *
  4. * Voice interaction for the DeepSeek Harness Web GUI, pure browser-native:
  5. * - ASR: Web Speech API (SpeechRecognition) → fills the composer draft (hold-to-talk or toggle)
  6. * - TTS: Web Speech API (speechSynthesis) → reads assistant replies aloud (manual + auto)
  7. *
  8. * Registered slots:
  9. * - conversation.input.right → mic button (composer tool row)
  10. * - conversation.chat.turnTail → per-turn read-aloud button (+ auto-read on turn close)
  11. * - conversation.composer.dock → voice status line (listening / speaking / errors)
  12. * - settings.section → voice settings panel (language, mode, toggles, rate)
  13. *
  14. * No build step: this file IS the bundle. Edit it and the HMR poll picks up the
  15. * content change (rev change) automatically; a fresh page load picks up the graph row.
  16. */
  17. window.__ModuleLoader__.load({
  18. id: "dsh-voice-client",
  19. factory: (require) => {
  20. var module = { exports: {} };
  21. var exports = module.exports;
  22. Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
  23. let react = require("react");
  24. let jsxRuntime = require("react/jsx-runtime");
  25. const { jsx, jsxs, Fragment } = jsxRuntime;
  26. const { useState, useEffect, useRef, useMemo, useSyncExternalStore } = react;
  27. // ─────────────────────────────── settings ───────────────────────────────
  28. const LS_PREFIX = "dshVoice.";
  29. const DEFAULT_SETTINGS = Object.freeze({
  30. lang: "zh-CN", // 'zh-CN' | 'en-US' | any BCP-47 the engine supports
  31. mode: "toggle", // 'toggle' (click start/stop) | 'hold' (push-to-talk)
  32. asrEngine: "server", // 'server' (local, offline) | 'browser' (Web Speech API) | 'auto'
  33. autoSubmit: false, // send the message right after recognition
  34. autoRead: false, // read assistant replies automatically
  35. ttsEngine: "edge", // 'edge' (natural, online) | 'piper' (offline) | 'browser' (system voice)
  36. ttsVoice: "zh-CN-YunxiNeural", // edge-tts voice (warm male — Jarvis-like)
  37. rate: 1.0, // TTS rate
  38. skipCode: true // skip fenced code blocks when reading
  39. });
  40. function loadSettings() {
  41. const out = { ...DEFAULT_SETTINGS };
  42. try {
  43. for (const key of Object.keys(DEFAULT_SETTINGS)) {
  44. const raw = localStorage.getItem(LS_PREFIX + key);
  45. if (raw === null) continue;
  46. const def = DEFAULT_SETTINGS[key];
  47. if (typeof def === "boolean") out[key] = raw === "true";
  48. else if (typeof def === "number") {
  49. const n = Number(raw);
  50. if (Number.isFinite(n)) out[key] = n;
  51. } else out[key] = raw;
  52. }
  53. } catch { /* storage unavailable — keep defaults */ }
  54. return out;
  55. }
  56. function saveSettings(patch) {
  57. try {
  58. for (const [k, v] of Object.entries(patch)) localStorage.setItem(LS_PREFIX + k, String(v));
  59. } catch { /* storage unavailable */ }
  60. return { ...loadSettings(), ...patch };
  61. }
  62. // ─────────────────────────── shared voice state ──────────────────────────
  63. let voiceState = { recording: false, interim: "", speaking: false, speakingKey: null, error: null };
  64. const voiceListeners = new Set();
  65. function setVoice(patch) {
  66. voiceState = { ...voiceState, ...patch };
  67. for (const fn of voiceListeners) fn();
  68. }
  69. function subscribeVoice(fn) {
  70. voiceListeners.add(fn);
  71. return () => { voiceListeners.delete(fn); };
  72. }
  73. function getVoice() { return voiceState; }
  74. function useVoice() { return useSyncExternalStore(subscribeVoice, getVoice); }
  75. // ───────────────────────────────── TTS ───────────────────────────────────
  76. function ttsSupported() {
  77. return typeof window !== "undefined" && "speechSynthesis" in window;
  78. }
  79. let speakGeneration = 0;
  80. let resumeTimer = null;
  81. let currentAudio = null;
  82. function clearResumeTimer() {
  83. if (resumeTimer !== null) { clearInterval(resumeTimer); resumeTimer = null; }
  84. }
  85. /** Stop any in-flight playback WITHOUT invalidating the generation counter. */
  86. function cancelPlayback() {
  87. clearResumeTimer();
  88. if (ttsSupported()) { try { window.speechSynthesis.cancel(); } catch { /* noop */ } }
  89. if (currentAudio) {
  90. try { currentAudio.pause(); currentAudio.currentTime = 0; } catch { /* noop */ }
  91. try { currentAudio.removeAttribute("src"); currentAudio.load(); } catch { /* noop */ }
  92. }
  93. }
  94. function ttsStop() {
  95. speakGeneration++;
  96. cancelPlayback();
  97. setVoice({ speaking: false, speakingKey: null });
  98. }
  99. /** Sentence-aware chunking. Chrome's speechSynthesis stalls on very long utterances. */
  100. function splitChunks(text) {
  101. const chunks = [];
  102. let current = "";
  103. for (const ch of text) {
  104. current += ch;
  105. if ("。!?;.!?;\n".includes(ch) || current.length >= 120) {
  106. const trimmed = current.trim();
  107. if (trimmed) chunks.push(trimmed);
  108. current = "";
  109. }
  110. }
  111. const rest = current.trim();
  112. if (rest) chunks.push(rest);
  113. return chunks;
  114. }
  115. /** Strip code fences + markdown syntax so TTS reads clean prose (not "星号星号"). */
  116. function cleanMarkdown(text) {
  117. return text
  118. .replace(/```[\s\S]*?```/g, " ") // fenced code blocks
  119. .replace(/`[^`\n]+`/g, " ") // inline code
  120. .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images
  121. .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → text
  122. .replace(/^#{1,6}\s*/gm, "") // headings
  123. .replace(/^\s*[-*+]\s+/gm, "") // bullet lists
  124. .replace(/^\s*\d+[.)]\s+/gm, "") // numbered lists
  125. .replace(/\*\*([^*\n]+)\*\*/g, "$1") // bold
  126. .replace(/\*([^*\n]+)\*/g, "$1") // italic
  127. .replace(/__([^_\n]+)__/g, "$1") // bold (underscore)
  128. .replace(/_([^_\n]+)_/g, "$1") // italic (underscore)
  129. .replace(/<[^>]+>/g, " ") // html tags
  130. .replace(/\|/g, ",") // table pipes → pause
  131. .replace(/-{3,}/g, " ") // horizontal rules
  132. .replace(/[ \t]+/g, " ") // collapse spaces
  133. .replace(/\s*\n\s*\n+/g, "\n") // collapse blank lines
  134. .trim();
  135. }
  136. /**
  137. * Speak text aloud. `key` identifies the message so per-message stop works
  138. * (the same key is stored in voiceState.speakingKey while speaking).
  139. */
  140. function speakText(text, key) {
  141. if (!text || !text.trim()) return;
  142. const engine = loadSettings().ttsEngine;
  143. if (engine === "browser" || !ttsSupported()) return speakBrowser(text, key);
  144. return speakServer(text, key);
  145. }
  146. /** Browser engine: chunked speechSynthesis (system voices). */
  147. function speakBrowser(text, key) {
  148. if (!ttsSupported()) return;
  149. const gen = ++speakGeneration;
  150. clearResumeTimer();
  151. const synth = window.speechSynthesis;
  152. try { synth.cancel(); } catch { /* noop */ }
  153. const chunks = splitChunks(text);
  154. setVoice({ speaking: true, speakingKey: key ?? null, error: null });
  155. // Heartbeat: Chrome can stall synthesis after ~15s of continuous audio.
  156. resumeTimer = setInterval(() => { try { synth.resume(); } catch { /* noop */ } }, 10000);
  157. let index = 0;
  158. const next = () => {
  159. if (gen !== speakGeneration) return; // interrupted / stopped
  160. if (index >= chunks.length) {
  161. if (gen === speakGeneration) {
  162. clearResumeTimer();
  163. setVoice({ speaking: false, speakingKey: null });
  164. }
  165. return;
  166. }
  167. const settings = loadSettings();
  168. const u = new SpeechSynthesisUtterance(chunks[index++]);
  169. u.lang = settings.lang;
  170. u.rate = settings.rate;
  171. u.onend = next;
  172. u.onerror = (ev) => {
  173. if (gen !== speakGeneration) return;
  174. if (ev.error === "interrupted" || ev.error === "canceled") return;
  175. clearResumeTimer();
  176. setVoice({ speaking: false, speakingKey: null });
  177. };
  178. try { synth.speak(u); } catch { setVoice({ speaking: false, speakingKey: null }); }
  179. };
  180. next();
  181. }
  182. /** Server engine: edge-tts / piper via /api/voice/tts, played as audio. */
  183. function speakServer(text, key) {
  184. const gen = ++speakGeneration;
  185. cancelPlayback(); // stop current audio without re-incrementing the generation
  186. setVoice({ speaking: true, speakingKey: key ?? null, error: null });
  187. const settings = loadSettings();
  188. fetch("/api/voice/tts", {
  189. method: "POST",
  190. headers: { "content-type": "application/json" },
  191. body: JSON.stringify({ text, engine: settings.ttsEngine, voice: settings.ttsVoice, rate: settings.rate })
  192. })
  193. .then((res) => {
  194. if (!res.ok) throw new Error("HTTP " + res.status);
  195. return res.blob();
  196. })
  197. .then((blob) => {
  198. if (gen !== speakGeneration) return;
  199. const url = URL.createObjectURL(blob);
  200. // Reuse a DOM-attached audio element for reliable autoplay.
  201. if (currentAudio === null) {
  202. currentAudio = document.createElement("audio");
  203. currentAudio.style.display = "none";
  204. document.body.appendChild(currentAudio);
  205. }
  206. const audio = currentAudio;
  207. audio.onended = () => {
  208. if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null });
  209. URL.revokeObjectURL(url);
  210. maybeResume();
  211. };
  212. audio.onerror = () => {
  213. if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "语音播放失败" });
  214. URL.revokeObjectURL(url);
  215. };
  216. audio.src = url;
  217. audio.play().catch((err) => {
  218. if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "播放被阻止: " + String(err && err.name || err) });
  219. });
  220. })
  221. .catch((err) => {
  222. if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "本地语音服务不可用: " + String(err && err.message || err) });
  223. });
  224. }
  225. // ───────────────────────────────── ASR ───────────────────────────────────
  226. function asrSupported() {
  227. return typeof window !== "undefined" && Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
  228. }
  229. let recognizer = null; // browser engine instance (SpeechRecognition)
  230. let activeEngine = null; // 'browser' | 'server'
  231. let listening = false; // whether the mic should stay on (held / toggled-on)
  232. let finalTranscript = "";
  233. let baseDraft = ""; // draft text before recording started
  234. let lastWritten = ""; // what updateDraft last wrote (to detect user edits)
  235. let liveWriting = true; // still allowed to overwrite the draft live
  236. let commitOnEnd = null; // { readDraft, setDraft, submit } resolved when recording ends
  237. let browserNetworkFailed = false; // auto-mode memory: browser ASR is unusable here
  238. let lastMicCtx = null; // latest mic context (set by MicButton) for auto-resume
  239. let loopActive = false; // hands-free loop running (自由交流 mode)
  240. /**
  241. * Write the recognized text LIVE into the composer draft — but stop the
  242. * moment the user edits the draft themselves, so their typing is never
  243. * clobbered by the next recognition update.
  244. */
  245. function updateDraft(text) {
  246. if (!commitOnEnd || !commitOnEnd.setDraft) return;
  247. if (liveWriting && commitOnEnd.readDraft) {
  248. const current = commitOnEnd.readDraft();
  249. if (current !== lastWritten) liveWriting = false; // user took over editing
  250. }
  251. if (!liveWriting) return;
  252. const prefix = baseDraft ? baseDraft + " " : "";
  253. const next = prefix + text;
  254. lastWritten = next;
  255. commitOnEnd.setDraft(next);
  256. }
  257. /** End-of-recording: reconcile the draft and optionally auto-submit. */
  258. function commitFinal() {
  259. const ctxAtEnd = commitOnEnd;
  260. commitOnEnd = null;
  261. const text = finalTranscript.trim();
  262. finalTranscript = "";
  263. if (ctxAtEnd === null) return;
  264. // If the user edited mid-recording, append the final transcript to their draft.
  265. if (!liveWriting && text && ctxAtEnd.readDraft && ctxAtEnd.setDraft) {
  266. const cur = ctxAtEnd.readDraft().trimEnd();
  267. ctxAtEnd.setDraft(cur ? cur + " " + text : text);
  268. }
  269. if (text && loadSettings().autoSubmit && ctxAtEnd.submit) {
  270. try { ctxAtEnd.submit(); } catch { /* submission refused (busy) */ }
  271. }
  272. }
  273. /** Auto-resume the mic loop after the reply finishes reading (自由交流 mode). */
  274. function maybeResume() {
  275. const s = loadSettings();
  276. if (!s.autoSubmit || !s.autoRead) return; // only in 自由交流 (hands-free) mode
  277. if (!loopActive) return;
  278. if (listening) return;
  279. if (lastMicCtx === null) return;
  280. setTimeout(() => {
  281. if (!listening && loopActive && loadSettings().autoSubmit) startListening(lastMicCtx);
  282. }, 700);
  283. }
  284. /** Manual stop: end the hands-free loop too. */
  285. function stopLoop() {
  286. loopActive = false;
  287. stopListening();
  288. }
  289. /**
  290. * Precise mic preflight via getUserMedia — the call that triggers the
  291. * OS/browser permission prompt and reports WHICH layer denied access.
  292. */
  293. async function preflightMic() {
  294. if (typeof navigator === "undefined" || !navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
  295. return { ok: false, reason: "unsupported" };
  296. }
  297. try {
  298. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  299. for (const track of stream.getTracks()) track.stop();
  300. return { ok: true };
  301. } catch (err) {
  302. const name = (err && err.name) || "";
  303. if (name === "NotAllowedError" || name === "SecurityError") return { ok: false, reason: "permission" };
  304. if (name === "NotFoundError" || name === "OverconstrainedError") return { ok: false, reason: "device" };
  305. return { ok: false, reason: "other", detail: String((err && err.message) || err) };
  306. }
  307. }
  308. function micErrorMessage(res) {
  309. return res.reason === "permission"
  310. ? "麦克风权限被拒绝。请检查两步:① macOS「系统设置 → 隐私与安全性 → 麦克风」勾选允许 Chrome;② 地址栏左侧锁形图标 → 网站设置 → 麦克风选「允许」。改完后刷新页面再试。"
  311. : res.reason === "device"
  312. ? "未检测到可用的麦克风设备,请在 macOS「系统设置 → 声音 → 输入」确认有输入设备。"
  313. : res.reason === "unsupported"
  314. ? "此环境不支持麦克风访问(getUserMedia 不可用)。"
  315. : "无法访问麦克风: " + (res.detail || res.reason);
  316. }
  317. /** Entry point: pick the engine from settings, then start it. */
  318. function startListening(ctx) {
  319. if (listening) return;
  320. ttsStop(); // speaking while the user talks would be confusing — interrupt
  321. commitOnEnd = ctx ?? null;
  322. finalTranscript = "";
  323. baseDraft = (ctx && ctx.readDraft ? ctx.readDraft() : "").trimEnd();
  324. lastWritten = baseDraft;
  325. liveWriting = true;
  326. setVoice({ recording: true, interim: "", error: null });
  327. const engine = loadSettings().asrEngine;
  328. if (engine === "browser") return startBrowserEngine(ctx);
  329. if (engine === "server") return startServerEngine(ctx);
  330. // 'auto'
  331. if (browserNetworkFailed) return startServerEngine(ctx);
  332. startBrowserEngine(ctx);
  333. }
  334. // ── browser engine (Web Speech API — needs Google's cloud in Chrome) ────
  335. function startBrowserEngine(ctx) {
  336. if (!asrSupported()) {
  337. listening = false;
  338. commitOnEnd = null;
  339. setVoice({ recording: false, error: "当前浏览器不支持语音识别(SpeechRecognition)。请在设置里改用「本地服务」识别引擎。" });
  340. return;
  341. }
  342. preflightMic().then((res) => {
  343. if (!res.ok) {
  344. listening = false;
  345. commitOnEnd = null;
  346. setVoice({ recording: false, interim: "", error: micErrorMessage(res) });
  347. return;
  348. }
  349. startRecognizer(ctx);
  350. });
  351. }
  352. function startRecognizer(ctx) {
  353. const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
  354. const settings = loadSettings();
  355. const rec = new SR();
  356. rec.lang = settings.lang;
  357. rec.continuous = true;
  358. rec.interimResults = true;
  359. rec.maxAlternatives = 1;
  360. rec.onresult = (ev) => {
  361. let interim = "";
  362. for (let k = ev.resultIndex; k < ev.results.length; k++) {
  363. const result = ev.results[k];
  364. if (result.isFinal) finalTranscript += result[0].transcript;
  365. else interim += result[0].transcript;
  366. }
  367. setVoice({ interim: interim.trim() });
  368. updateDraft((finalTranscript + interim).trim());
  369. };
  370. rec.onerror = (ev) => {
  371. if (ev.error === "aborted") return; // normal when we stop() / abort()
  372. listening = false;
  373. const e = ev.error;
  374. if (e === "not-allowed") {
  375. setVoice({ error: "麦克风权限被拒绝。请检查 macOS「系统设置 → 隐私与安全性 → 麦克风」是否允许 Chrome,以及地址栏锁形图标 → 网站设置中麦克风是否为「允许」,然后刷新页面重试。", recording: false, interim: "" });
  376. } else if (e === "service-not-allowed" || e === "network") {
  377. browserNetworkFailed = true;
  378. const hint = loadSettings().asrEngine === "auto"
  379. ? "已自动切换到本地识别服务,下次点击收音将使用本地识别。"
  380. : "请在设置里把「识别引擎」改为「本地服务」。";
  381. setVoice({ error: "浏览器语音识别依赖 Google 服务,当前网络不可用。" + hint, recording: false, interim: "" });
  382. } else if (e === "no-speech") {
  383. setVoice({ recording: false, interim: "" });
  384. } else if (e === "audio-capture") {
  385. setVoice({ error: "无法捕获麦克风音频,请检查麦克风设备是否被其他应用占用。", recording: false, interim: "" });
  386. } else {
  387. setVoice({ error: "语音识别错误: " + e, recording: false, interim: "" });
  388. }
  389. };
  390. rec.onend = () => {
  391. if (listening) {
  392. try { rec.start(); } catch { listening = false; setVoice({ recording: false, interim: "" }); }
  393. return;
  394. }
  395. setVoice({ recording: false, interim: "" });
  396. commitFinal();
  397. };
  398. recognizer = rec;
  399. activeEngine = "browser";
  400. listening = true;
  401. try { rec.start(); } catch {
  402. listening = false;
  403. commitOnEnd = null;
  404. setVoice({ recording: false, error: "无法启动语音识别" });
  405. }
  406. }
  407. // ── server engine (local sherpa-onnx via /api/voice/asr WebSocket) ──────
  408. let serverWs = null;
  409. let serverMicStream = null;
  410. let serverAudioCtx = null;
  411. let serverSource = null;
  412. let serverProcessor = null;
  413. function serverError(message) {
  414. listening = false;
  415. commitOnEnd = null;
  416. activeEngine = null;
  417. if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; }
  418. teardownCapture();
  419. setVoice({ recording: false, interim: "", error: message });
  420. }
  421. function teardownCapture() {
  422. if (serverMicStream) { for (const t of serverMicStream.getTracks()) t.stop(); serverMicStream = null; }
  423. if (serverSource) { try { serverSource.disconnect(); } catch { /* noop */ } serverSource = null; }
  424. if (serverProcessor) { try { serverProcessor.disconnect(); } catch { /* noop */ } serverProcessor = null; }
  425. if (serverAudioCtx) { try { serverAudioCtx.close(); } catch { /* noop */ } serverAudioCtx = null; }
  426. }
  427. function startServerEngine(ctx) {
  428. if (typeof WebSocket === "undefined") {
  429. serverError("当前浏览器不支持 WebSocket,无法连接本地识别服务。");
  430. return;
  431. }
  432. const wsUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/voice/asr";
  433. let ws;
  434. try { ws = new WebSocket(wsUrl); } catch {
  435. serverError("无法创建本地识别连接。");
  436. return;
  437. }
  438. serverWs = ws;
  439. ws.binaryType = "arraybuffer";
  440. const isCurrent = () => serverWs === ws;
  441. ws.onopen = () => { if (isCurrent()) startServerCapture(ws); };
  442. ws.onmessage = (ev) => {
  443. if (!isCurrent()) return;
  444. let msg;
  445. try { msg = JSON.parse(ev.data); } catch { return; }
  446. if (msg.type === "partial") {
  447. setVoice({ interim: msg.text || "" });
  448. updateDraft(msg.text || "");
  449. } else if (msg.type === "final") {
  450. finalTranscript = msg.text || "";
  451. setVoice({ interim: "" });
  452. updateDraft(finalTranscript);
  453. } else if (msg.type === "endpoint") {
  454. // Silence detected → auto-stop in 自由交流 mode.
  455. if (loadSettings().autoSubmit) stopListening();
  456. } else if (msg.type === "error") serverError(msg.message || "本地识别服务错误");
  457. };
  458. ws.onerror = () => { if (isCurrent()) serverError("本地识别服务未连接。请确认 dsh-voice-server 已安装并已重启服务,或在设置里改用浏览器引擎。"); };
  459. ws.onclose = () => {
  460. if (!isCurrent()) return;
  461. serverWs = null;
  462. teardownCapture();
  463. setVoice({ recording: false, interim: "" });
  464. if (activeEngine === "server") {
  465. activeEngine = null;
  466. commitFinal();
  467. }
  468. };
  469. activeEngine = "server";
  470. listening = true;
  471. }
  472. function startServerCapture(ws) {
  473. if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
  474. serverError("此环境不支持麦克风访问(getUserMedia 不可用)。");
  475. return;
  476. }
  477. navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
  478. if (ws.readyState !== ws.OPEN) { for (const t of stream.getTracks()) t.stop(); return; }
  479. serverMicStream = stream;
  480. const Ctx = window.AudioContext || window.webkitAudioContext;
  481. const ctx = new Ctx({ sampleRate: 16000 });
  482. serverAudioCtx = ctx;
  483. const source = ctx.createMediaStreamSource(stream);
  484. // ScriptProcessor is deprecated but universally supported; 16k mono PCM is what sherpa expects.
  485. const processor = ctx.createScriptProcessor(4096, 1, 1);
  486. const zeroGain = ctx.createGain();
  487. zeroGain.gain.value = 0; // keep the graph connected without feeding the speakers
  488. processor.onaudioprocess = (ev) => {
  489. const input = ev.inputBuffer.getChannelData(0);
  490. const pcm = new Int16Array(input.length);
  491. for (let i = 0; i < input.length; i++) {
  492. const s = Math.max(-1, Math.min(1, input[i]));
  493. pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
  494. }
  495. if (ws.readyState === ws.OPEN) ws.send(pcm.buffer);
  496. };
  497. source.connect(processor);
  498. processor.connect(zeroGain);
  499. zeroGain.connect(ctx.destination);
  500. serverSource = source;
  501. serverProcessor = processor;
  502. setVoice({ recording: true, interim: "", error: null });
  503. }).catch((err) => {
  504. serverError(micErrorMessage({ reason: (err && err.name === "NotAllowedError" || err && err.name === "SecurityError") ? "permission" : (err && err.name === "NotFoundError" ? "device" : "other"), detail: String(err && err.message || err) }));
  505. });
  506. }
  507. function stopListening() {
  508. if (!listening) return;
  509. listening = false;
  510. setVoice({ recording: false, interim: "" });
  511. if (activeEngine === "browser") {
  512. if (recognizer !== null) { try { recognizer.stop(); } catch { /* noop */ } }
  513. return;
  514. }
  515. if (activeEngine === "server") {
  516. teardownCapture();
  517. if (serverWs && serverWs.readyState === serverWs.OPEN) {
  518. serverWs.send(JSON.stringify({ type: "stop" })); // server replies final, then closes → onclose commits
  519. } else {
  520. commitFinal();
  521. }
  522. }
  523. }
  524. function cancelListening() {
  525. if (activeEngine === "browser" && recognizer !== null) {
  526. listening = false;
  527. commitOnEnd = null;
  528. try { recognizer.abort(); } catch { /* noop */ }
  529. } else if (activeEngine === "server") {
  530. listening = false;
  531. commitOnEnd = null;
  532. teardownCapture();
  533. if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; }
  534. }
  535. setVoice({ recording: false, interim: "" });
  536. }
  537. // ─────────────────────────────── styles ──────────────────────────────────
  538. const css = [
  539. ".dsh-voice-btn{display:inline-grid;place-items:center;width:28px;height:28px;padding:0;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary,#a8b3c4);cursor:pointer;font-size:14px;line-height:1}",
  540. ".dsh-voice-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,140,160,.16));color:var(--dsw-alias-label-primary,#e8edf4)}",
  541. ".dsh-voice-btn:disabled{opacity:.35;cursor:not-allowed}",
  542. ".dsh-voice-btn-active{color:#ff6b6b}",
  543. ".dsh-voice-btn-recording{color:#ff5252;animation:dshVoicePulse 1.2s ease-in-out infinite}",
  544. ".dsh-voice-mode{display:inline-flex;align-items:center;gap:4px;width:auto;height:26px;padding:0 10px;font-size:12px;font-weight:500;white-space:nowrap;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
  545. ".dsh-voice-mode:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e8edf4)}",
  546. "@keyframes dshVoicePulse{0%,100%{opacity:1}50%{opacity:.45}}",
  547. ".dsh-voice-dock{display:flex;align-items:center;gap:8px;min-height:18px;font-size:12px;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
  548. ".dsh-voice-dock .dsh-voice-interim{color:var(--dsw-alias-label-tertiary,#7c8798);font-style:italic;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}",
  549. ".dsh-voice-dock .dsh-voice-error{color:#ff6b6b}",
  550. ".dsh-voice-read{display:inline-flex;align-items:center;margin-left:6px}",
  551. ".dsh-voice-settings{display:flex;flex-direction:column;gap:12px;padding:16px}",
  552. ".dsh-voice-settings h3{margin:0 0 4px;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e8edf4)}",
  553. ".dsh-voice-row{display:flex;align-items:center;justify-content:space-between;gap:16px;font-size:13px;color:var(--dsw-alias-label-secondary,#a8b3c4)}",
  554. ".dsh-voice-row label{display:flex;align-items:center;gap:8px;cursor:pointer}",
  555. ".dsh-voice-row input[type=range]{width:140px}",
  556. ".dsh-voice-row select{background:var(--dsw-alias-bg-layer-2,#1b2230);color:var(--dsw-alias-label-primary,#e8edf4);border:1px solid var(--dsw-alias-border-l1,#2a3446);border-radius:8px;padding:4px 8px;font-size:13px}"
  557. ].join("");
  558. const styleTagId = "dsh-voice-client/styles";
  559. if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(styleTagId) + "]") === null) {
  560. const tag = document.createElement("style");
  561. tag.dataset.plugin = "dsh-voice-client";
  562. tag.dataset.pluginCss = styleTagId;
  563. tag.textContent = css;
  564. document.head.appendChild(tag);
  565. }
  566. // ─────────────────────────────── locale ──────────────────────────────────
  567. const NS = "voice";
  568. const zh = {
  569. "mic.title.hold": "语音输入:按住说话",
  570. "mic.title.toggle": "语音输入:点击开始 / 再点结束",
  571. "mic.title.unsupported": "当前浏览器不支持语音识别,请使用 Chrome 或 Edge",
  572. "mic.recording": "松开完成识别",
  573. "status.listening": "正在聆听…",
  574. "status.speaking": "正在朗读…",
  575. "read.speak": "朗读这条回复",
  576. "read.stop": "停止朗读",
  577. "settings.title": "语音交流",
  578. "settings.lang": "识别 / 朗读语言",
  579. "settings.engine": "识别引擎",
  580. "settings.engine.server": "本地服务(推荐,离线)",
  581. "settings.engine.browser": "浏览器(依赖 Google 服务)",
  582. "settings.engine.auto": "自动(优先浏览器,失败回退本地)",
  583. "settings.mode": "说话方式",
  584. "settings.mode.hold": "按住说话",
  585. "settings.mode.toggle": "点击切换",
  586. "mode.dictation": "听写模式",
  587. "mode.chat": "自由交流模式",
  588. "mode.toChat": "切换到自由交流模式(说完自动发送并朗读回复)",
  589. "mode.toDictation": "切换到听写模式(手动发送)",
  590. "settings.autoSubmit": "识别后自动发送",
  591. "settings.autoRead": "自动朗读回复",
  592. "settings.tts": "朗读语音引擎",
  593. "settings.tts.edge": "微软神经语音(自然,需联网)",
  594. "settings.tts.piper": "Piper 本地(离线)",
  595. "settings.tts.browser": "系统语音",
  596. "settings.voice": "朗读音色",
  597. "settings.rate": "朗读语速",
  598. "settings.skipCode": "朗读时跳过代码块",
  599. "settings.test": "试听",
  600. "settings.test.text": "你好,我是你的语音助手。现在可以开始语音交流了。",
  601. "settings.unsupported": "当前浏览器不支持语音识别(请使用 Chrome 或 Edge),但朗读功能可用。"
  602. };
  603. const en = {
  604. "mic.title.hold": "Voice input: hold to talk",
  605. "mic.title.toggle": "Voice input: click to start / click again to stop",
  606. "mic.title.unsupported": "Speech recognition is not supported by this browser; use Chrome or Edge",
  607. "mic.recording": "Release to finish",
  608. "status.listening": "Listening…",
  609. "status.speaking": "Speaking…",
  610. "read.speak": "Read this reply aloud",
  611. "read.stop": "Stop reading",
  612. "settings.title": "Voice",
  613. "settings.lang": "Recognition / reading language",
  614. "settings.engine": "Recognition engine",
  615. "settings.engine.server": "Local service (offline, recommended)",
  616. "settings.engine.browser": "Browser (needs Google service)",
  617. "settings.engine.auto": "Auto (browser first, fall back to local)",
  618. "settings.mode": "Input mode",
  619. "settings.mode.hold": "Hold to talk",
  620. "settings.mode.toggle": "Click to toggle",
  621. "mode.dictation": "Dictation",
  622. "mode.chat": "Voice chat",
  623. "mode.toChat": "Switch to voice chat (auto-send and read replies)",
  624. "mode.toDictation": "Switch to dictation (send manually)",
  625. "settings.autoSubmit": "Auto-send after recognition",
  626. "settings.autoRead": "Auto-read replies",
  627. "settings.tts": "Reading voice engine",
  628. "settings.tts.edge": "Microsoft neural (natural, online)",
  629. "settings.tts.piper": "Piper local (offline)",
  630. "settings.tts.browser": "System voice",
  631. "settings.voice": "Reading voice",
  632. "settings.rate": "Reading rate",
  633. "settings.skipCode": "Skip code blocks when reading",
  634. "settings.test": "Test",
  635. "settings.test.text": "Hi, I am your voice assistant. Let's start talking.",
  636. "settings.unsupported": "Speech recognition is not supported by this browser (use Chrome or Edge); reading aloud still works."
  637. };
  638. // ─────────────────────────── helper: assistant text ──────────────────────
  639. function assistantTextOf(node) {
  640. if (!node || node.kind !== "assistant" || !Array.isArray(node.blocks)) return "";
  641. const text = node.blocks.filter((b) => b.kind === "text").map((b) => b.text).join("");
  642. const cleaned = loadSettings().skipCode ? cleanMarkdown(text) : text;
  643. return cleaned;
  644. }
  645. // ───────────────────── component: VoiceModeToggle ────────────────────────
  646. function VoiceModeToggle(props) {
  647. const { t } = props;
  648. const [, force] = useState(0);
  649. const chat = loadSettings().autoSubmit; // true = 自由交流, false = 听写
  650. const toggle = () => {
  651. saveSettings({ autoSubmit: !chat, autoRead: !chat });
  652. force((n) => n + 1);
  653. };
  654. const label = chat ? "💬 " + t("mode.chat") : "✍️ " + t("mode.dictation");
  655. return jsx("button", {
  656. type: "button",
  657. className: "dsh-voice-btn dsh-voice-mode" + (chat ? " dsh-voice-btn-active" : ""),
  658. title: chat ? t("mode.toDictation") : t("mode.toChat"),
  659. "aria-label": chat ? t("mode.toDictation") : t("mode.toChat"),
  660. onClick: toggle,
  661. children: label
  662. });
  663. }
  664. // ─────────────────────────── component: MicButton ────────────────────────
  665. function MicButton(props) {
  666. const { t, input, inputActions } = props;
  667. const voice = useVoice();
  668. const recording = voice.recording;
  669. const serverOk = typeof navigator !== "undefined" && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
  670. const supported = asrSupported() || serverOk;
  671. const mode = loadSettings().mode;
  672. const draftRef = useRef("");
  673. draftRef.current = typeof input?.draft === "string" ? input.draft : "";
  674. const actionsRef = useRef({ setDraft: null, submit: null });
  675. actionsRef.current = {
  676. setDraft: typeof inputActions?.setDraft === "function" ? inputActions.setDraft : null,
  677. submit: typeof inputActions?.submit === "function" ? inputActions.submit : null
  678. };
  679. const makeCtx = () => ({
  680. readDraft: () => draftRef.current,
  681. setDraft: (text) => actionsRef.current.setDraft?.(text),
  682. submit: () => actionsRef.current.submit?.()
  683. });
  684. // Publish the mic context so the auto-resume loop can restart listening.
  685. useEffect(() => { lastMicCtx = makeCtx(); });
  686. const handleStart = () => {
  687. if (!supported || recording) return;
  688. loopActive = loadSettings().autoSubmit === true; // hands-free loop only in 自由交流
  689. startListening(makeCtx());
  690. };
  691. const handleStop = () => {
  692. if (!recording) return;
  693. stopLoop();
  694. };
  695. const handleToggle = () => {
  696. if (recording) handleStop(); else handleStart();
  697. };
  698. const title = !supported ? t("mic.title.unsupported")
  699. : recording ? t("mic.recording")
  700. : mode === "hold" ? t("mic.title.hold") : t("mic.title.toggle");
  701. const buttonProps = {
  702. type: "button",
  703. className: "dsh-voice-btn" + (recording ? " dsh-voice-btn-recording" : ""),
  704. title,
  705. "aria-label": title,
  706. disabled: !supported,
  707. children: recording ? "⏹" : "🎤"
  708. };
  709. if (mode === "hold") {
  710. buttonProps.onPointerDown = (e) => {
  711. e.preventDefault();
  712. try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* noop */ }
  713. handleStart();
  714. };
  715. buttonProps.onPointerUp = (e) => {
  716. e.preventDefault();
  717. handleStop();
  718. };
  719. buttonProps.onPointerCancel = () => { handleStop(); };
  720. } else {
  721. buttonProps.onClick = handleToggle;
  722. }
  723. return jsx("button", buttonProps);
  724. }
  725. // ─────────────────────────── component: ReadAloud ────────────────────────
  726. const spokenTurns = new Set(); // session:seq keys already auto-read this fiber
  727. function ReadAloud(props) {
  728. const { t, messageId, useSession } = props;
  729. const voice = useVoice();
  730. const sessionId = useSession((s) => (s ? s.sessionId : undefined));
  731. const node = useSession((s) => (s && Array.isArray(s.nodes) ? s.nodes.find((n) => n.kind === "assistant" && n.messageId === messageId) : undefined));
  732. const text = useMemo(() => assistantTextOf(node), [node]);
  733. const key = "msg:" + (sessionId ? sessionId + ":" : "") + String(messageId);
  734. const isSpeaking = voice.speaking && voice.speakingKey === key;
  735. // Auto-read when the turn closes (component mounts after finalize).
  736. useEffect(() => {
  737. if (!loadSettings().autoRead || !text || !sessionId) return;
  738. if (spokenTurns.has(key)) return;
  739. spokenTurns.add(key);
  740. const timer = setTimeout(() => speakText(text, key), 400);
  741. return () => clearTimeout(timer);
  742. }, [key, text, sessionId]);
  743. const toggle = () => {
  744. if (isSpeaking) ttsStop();
  745. else speakText(text, key);
  746. };
  747. return jsx("div", {
  748. className: "dsh-voice-read",
  749. children: jsx("button", {
  750. type: "button",
  751. className: "dsh-voice-btn" + (isSpeaking ? " dsh-voice-btn-active" : ""),
  752. title: isSpeaking ? t("read.stop") : t("read.speak"),
  753. "aria-label": isSpeaking ? t("read.stop") : t("read.speak"),
  754. disabled: !text,
  755. onClick: toggle,
  756. children: isSpeaking ? "🔇" : "🔊"
  757. })
  758. });
  759. }
  760. // ──────────────────────── component: VoiceStatusLine ─────────────────────
  761. function VoiceStatusLine(props) {
  762. const { t } = props;
  763. const voice = useVoice();
  764. if (!voice.recording && !voice.speaking && !voice.error) return null;
  765. let content;
  766. if (voice.error) {
  767. content = jsx("span", { className: "dsh-voice-error", children: "⚠️ " + voice.error });
  768. } else if (voice.recording) {
  769. content = jsxs(Fragment, {
  770. children: [
  771. jsx("span", { children: "🎙️ " + t("status.listening") }),
  772. voice.interim ? jsx("span", { className: "dsh-voice-interim", children: "「" + voice.interim + "」" }) : null
  773. ]
  774. });
  775. } else {
  776. content = jsx("span", { children: "🔊 " + t("status.speaking") });
  777. }
  778. return jsx("div", { className: "dsh-voice-dock", children: content });
  779. }
  780. // ───────────────────────── component: VoiceSettings ──────────────────────
  781. function VoiceSettings(props) {
  782. const { t } = props;
  783. const [settings, setSettings] = useState(loadSettings);
  784. const update = (patch) => setSettings(saveSettings(patch));
  785. const lang = settings.lang;
  786. const mode = settings.mode;
  787. const engine = settings.asrEngine;
  788. const row = (label, control) => jsx("div", { className: "dsh-voice-row", children: [jsx("span", { children: label }), control] });
  789. const children = [
  790. jsx("h3", { children: "🗣️ " + t("settings.title") }),
  791. !asrSupported() ? jsx("div", { className: "dsh-voice-row dsh-voice-error", children: t("settings.unsupported") }) : null,
  792. row(t("settings.engine"), jsx("select", {
  793. value: engine,
  794. onChange: (e) => update({ asrEngine: e.target.value }),
  795. children: [
  796. jsx("option", { value: "server", children: t("settings.engine.server") }),
  797. jsx("option", { value: "browser", children: t("settings.engine.browser") }),
  798. jsx("option", { value: "auto", children: t("settings.engine.auto") })
  799. ]
  800. })),
  801. row(t("settings.lang"), jsx("select", {
  802. value: lang,
  803. onChange: (e) => update({ lang: e.target.value }),
  804. children: [
  805. jsx("option", { value: "zh-CN", children: "中文(普通话)" }),
  806. jsx("option", { value: "zh-TW", children: "中文(台湾)" }),
  807. jsx("option", { value: "en-US", children: "English (US)" }),
  808. jsx("option", { value: "en-GB", children: "English (UK)" }),
  809. jsx("option", { value: "ja-JP", children: "日本語" }),
  810. jsx("option", { value: "ko-KR", children: "한국어" })
  811. ]
  812. })),
  813. row(t("settings.mode"), jsxs("span", {
  814. children: [
  815. jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "hold", onChange: () => update({ mode: "hold" }) }), t("settings.mode.hold")] }),
  816. jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "toggle", onChange: () => update({ mode: "toggle" }) }), t("settings.mode.toggle")] })
  817. ]
  818. })),
  819. row(t("settings.autoSubmit"), jsx("input", { type: "checkbox", checked: settings.autoSubmit, onChange: (e) => update({ autoSubmit: e.target.checked }) })),
  820. row(t("settings.autoRead"), jsx("input", { type: "checkbox", checked: settings.autoRead, onChange: (e) => update({ autoRead: e.target.checked }) })),
  821. row(t("settings.tts"), jsx("select", {
  822. value: settings.ttsEngine,
  823. onChange: (e) => update({ ttsEngine: e.target.value }),
  824. children: [
  825. jsx("option", { value: "edge", children: t("settings.tts.edge") }),
  826. jsx("option", { value: "piper", children: t("settings.tts.piper") }),
  827. jsx("option", { value: "browser", children: t("settings.tts.browser") })
  828. ]
  829. })),
  830. settings.ttsEngine === "edge" ? row(t("settings.voice"), jsx("select", {
  831. value: settings.ttsVoice,
  832. onChange: (e) => update({ ttsVoice: e.target.value }),
  833. children: [
  834. jsx("option", { value: "zh-CN-XiaoyiNeural", children: "晓伊(温暖活泼 女声)" }),
  835. jsx("option", { value: "zh-CN-XiaoxiaoNeural", children: "晓晓(温柔 女声)" }),
  836. jsx("option", { value: "zh-CN-YunxiNeural", children: "云希(温暖 男声)" }),
  837. jsx("option", { value: "zh-CN-YunjianNeural", children: "云健(沉稳 男声)" }),
  838. jsx("option", { value: "zh-CN-YunxiaNeural", children: "云夏(活泼 男声)" }),
  839. jsx("option", { value: "zh-CN-YunyangNeural", children: "云扬(新闻 男声)" })
  840. ]
  841. })) : null,
  842. row(t("settings.rate"), jsx("input", {
  843. type: "range", min: 0.5, max: 2, step: 0.1, value: settings.rate,
  844. onChange: (e) => update({ rate: Number(e.target.value) }),
  845. title: String(settings.rate)
  846. })),
  847. row(t("settings.skipCode"), jsx("input", { type: "checkbox", checked: settings.skipCode, onChange: (e) => update({ skipCode: e.target.checked }) })),
  848. row(t("settings.test"), jsx("button", {
  849. type: "button",
  850. className: "dsh-voice-btn",
  851. onClick: () => speakText(t("settings.test.text"), "voice-test"),
  852. children: "🔊"
  853. }))
  854. ];
  855. return jsx("section", { className: "dsh-voice-settings", children });
  856. }
  857. // ─────────────────────────────── registration ────────────────────────────
  858. const inject = ["slots", "locale"];
  859. function apply(ctx) {
  860. ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-voice: dictionaries");
  861. // Core: read-aloud button (+ auto-read) on each finalized assistant message.
  862. ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({
  863. name: "conversation.chat.assistant-actions",
  864. id: "dsh-voice-read",
  865. order: 100,
  866. locale: NS
  867. }, ReadAloud));
  868. // Core: mic button in the composer tool row.
  869. ctx.slots.inject("conversation.input.right", () => ctx.slots.register({
  870. name: "conversation.input.right",
  871. id: "dsh-voice-mic",
  872. order: 100,
  873. locale: NS
  874. }, MicButton));
  875. // Voice status line under the composer.
  876. ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({
  877. name: "conversation.composer.dock",
  878. id: "dsh-voice-status",
  879. order: 100,
  880. locale: NS
  881. }, VoiceStatusLine));
  882. // Voice settings section.
  883. ctx.slots.inject("settings.section", () => ctx.slots.register({
  884. name: "settings.section",
  885. id: "dsh-voice-settings",
  886. order: 25,
  887. label: () => ctx.locale.bind(NS)("settings.title"),
  888. locale: NS
  889. }, VoiceSettings));
  890. // Dictation / voice-chat mode switch in the composer tool row (left).
  891. ctx.slots.inject("conversation.input.left", () => ctx.slots.register({
  892. name: "conversation.input.left",
  893. id: "dsh-voice-mode-toggle",
  894. order: 100,
  895. locale: NS
  896. }, VoiceModeToggle));
  897. }
  898. exports.apply = apply;
  899. exports.inject = inject;
  900. return module.exports;
  901. }
  902. });