/** * dsh-voice-client — browser bundle (hand-written in the DSH __ModuleLoader__ format). * * Voice interaction for the DeepSeek Harness Web GUI, pure browser-native: * - ASR: Web Speech API (SpeechRecognition) → fills the composer draft (hold-to-talk or toggle) * - TTS: Web Speech API (speechSynthesis) → reads assistant replies aloud (manual + auto) * * Registered slots: * - conversation.input.right → mic button (composer tool row) * - conversation.chat.turnTail → per-turn read-aloud button (+ auto-read on turn close) * - conversation.composer.dock → voice status line (listening / speaking / errors) * - settings.section → voice settings panel (language, mode, toggles, rate) * * No build step: this file IS the bundle. Edit it and the HMR poll picks up the * content change (rev change) automatically; a fresh page load picks up the graph row. */ window.__ModuleLoader__.load({ id: "dsh-voice-client", factory: (require) => { var module = { exports: {} }; var exports = module.exports; Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); let react = require("react"); let jsxRuntime = require("react/jsx-runtime"); const { jsx, jsxs, Fragment } = jsxRuntime; const { useState, useEffect, useRef, useMemo, useSyncExternalStore } = react; // ─────────────────────────────── settings ─────────────────────────────── const LS_PREFIX = "dshVoice."; const DEFAULT_SETTINGS = Object.freeze({ lang: "zh-CN", // 'zh-CN' | 'en-US' | any BCP-47 the engine supports mode: "toggle", // 'toggle' (click start/stop) | 'hold' (push-to-talk) asrEngine: "server", // 'server' (local, offline) | 'browser' (Web Speech API) | 'auto' autoSubmit: false, // send the message right after recognition autoRead: false, // read assistant replies automatically ttsEngine: "edge", // 'edge' (natural, online) | 'piper' (offline) | 'browser' (system voice) ttsVoice: "zh-CN-YunxiNeural", // edge-tts voice (warm male — Jarvis-like) rate: 1.0, // TTS rate skipCode: true // skip fenced code blocks when reading }); function loadSettings() { const out = { ...DEFAULT_SETTINGS }; try { for (const key of Object.keys(DEFAULT_SETTINGS)) { const raw = localStorage.getItem(LS_PREFIX + key); if (raw === null) continue; const def = DEFAULT_SETTINGS[key]; if (typeof def === "boolean") out[key] = raw === "true"; else if (typeof def === "number") { const n = Number(raw); if (Number.isFinite(n)) out[key] = n; } else out[key] = raw; } } catch { /* storage unavailable — keep defaults */ } return out; } function saveSettings(patch) { try { for (const [k, v] of Object.entries(patch)) localStorage.setItem(LS_PREFIX + k, String(v)); } catch { /* storage unavailable */ } return { ...loadSettings(), ...patch }; } // ─────────────────────────── shared voice state ────────────────────────── let voiceState = { recording: false, interim: "", speaking: false, speakingKey: null, error: null }; const voiceListeners = new Set(); function setVoice(patch) { voiceState = { ...voiceState, ...patch }; for (const fn of voiceListeners) fn(); } function subscribeVoice(fn) { voiceListeners.add(fn); return () => { voiceListeners.delete(fn); }; } function getVoice() { return voiceState; } function useVoice() { return useSyncExternalStore(subscribeVoice, getVoice); } // ───────────────────────────────── TTS ─────────────────────────────────── function ttsSupported() { return typeof window !== "undefined" && "speechSynthesis" in window; } let speakGeneration = 0; let resumeTimer = null; let currentAudio = null; function clearResumeTimer() { if (resumeTimer !== null) { clearInterval(resumeTimer); resumeTimer = null; } } /** Stop any in-flight playback WITHOUT invalidating the generation counter. */ function cancelPlayback() { clearResumeTimer(); if (ttsSupported()) { try { window.speechSynthesis.cancel(); } catch { /* noop */ } } if (currentAudio) { try { currentAudio.pause(); currentAudio.currentTime = 0; } catch { /* noop */ } try { currentAudio.removeAttribute("src"); currentAudio.load(); } catch { /* noop */ } } } function ttsStop() { speakGeneration++; cancelPlayback(); setVoice({ speaking: false, speakingKey: null }); } /** Sentence-aware chunking. Chrome's speechSynthesis stalls on very long utterances. */ function splitChunks(text) { const chunks = []; let current = ""; for (const ch of text) { current += ch; if ("。!?;.!?;\n".includes(ch) || current.length >= 120) { const trimmed = current.trim(); if (trimmed) chunks.push(trimmed); current = ""; } } const rest = current.trim(); if (rest) chunks.push(rest); return chunks; } /** Strip code fences + markdown syntax so TTS reads clean prose (not "星号星号"). */ function cleanMarkdown(text) { return text .replace(/```[\s\S]*?```/g, " ") // fenced code blocks .replace(/`[^`\n]+`/g, " ") // inline code .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") // images .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links → text .replace(/^#{1,6}\s*/gm, "") // headings .replace(/^\s*[-*+]\s+/gm, "") // bullet lists .replace(/^\s*\d+[.)]\s+/gm, "") // numbered lists .replace(/\*\*([^*\n]+)\*\*/g, "$1") // bold .replace(/\*([^*\n]+)\*/g, "$1") // italic .replace(/__([^_\n]+)__/g, "$1") // bold (underscore) .replace(/_([^_\n]+)_/g, "$1") // italic (underscore) .replace(/<[^>]+>/g, " ") // html tags .replace(/\|/g, ",") // table pipes → pause .replace(/-{3,}/g, " ") // horizontal rules .replace(/[ \t]+/g, " ") // collapse spaces .replace(/\s*\n\s*\n+/g, "\n") // collapse blank lines .trim(); } /** * Speak text aloud. `key` identifies the message so per-message stop works * (the same key is stored in voiceState.speakingKey while speaking). */ function speakText(text, key) { if (!text || !text.trim()) return; const engine = loadSettings().ttsEngine; if (engine === "browser" || !ttsSupported()) return speakBrowser(text, key); return speakServer(text, key); } /** Browser engine: chunked speechSynthesis (system voices). */ function speakBrowser(text, key) { if (!ttsSupported()) return; const gen = ++speakGeneration; clearResumeTimer(); const synth = window.speechSynthesis; try { synth.cancel(); } catch { /* noop */ } const chunks = splitChunks(text); setVoice({ speaking: true, speakingKey: key ?? null, error: null }); // Heartbeat: Chrome can stall synthesis after ~15s of continuous audio. resumeTimer = setInterval(() => { try { synth.resume(); } catch { /* noop */ } }, 10000); let index = 0; const next = () => { if (gen !== speakGeneration) return; // interrupted / stopped if (index >= chunks.length) { if (gen === speakGeneration) { clearResumeTimer(); setVoice({ speaking: false, speakingKey: null }); } return; } const settings = loadSettings(); const u = new SpeechSynthesisUtterance(chunks[index++]); u.lang = settings.lang; u.rate = settings.rate; u.onend = next; u.onerror = (ev) => { if (gen !== speakGeneration) return; if (ev.error === "interrupted" || ev.error === "canceled") return; clearResumeTimer(); setVoice({ speaking: false, speakingKey: null }); }; try { synth.speak(u); } catch { setVoice({ speaking: false, speakingKey: null }); } }; next(); } /** Server engine: edge-tts / piper via /api/voice/tts, played as audio. */ function speakServer(text, key) { const gen = ++speakGeneration; cancelPlayback(); // stop current audio without re-incrementing the generation setVoice({ speaking: true, speakingKey: key ?? null, error: null }); const settings = loadSettings(); fetch("/api/voice/tts", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text, engine: settings.ttsEngine, voice: settings.ttsVoice, rate: settings.rate }) }) .then((res) => { if (!res.ok) throw new Error("HTTP " + res.status); return res.blob(); }) .then((blob) => { if (gen !== speakGeneration) return; const url = URL.createObjectURL(blob); // Reuse a DOM-attached audio element for reliable autoplay. if (currentAudio === null) { currentAudio = document.createElement("audio"); currentAudio.style.display = "none"; document.body.appendChild(currentAudio); } const audio = currentAudio; audio.onended = () => { if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null }); URL.revokeObjectURL(url); maybeResume(); }; audio.onerror = () => { if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "语音播放失败" }); URL.revokeObjectURL(url); }; audio.src = url; audio.play().catch((err) => { if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "播放被阻止: " + String(err && err.name || err) }); }); }) .catch((err) => { if (gen === speakGeneration) setVoice({ speaking: false, speakingKey: null, error: "本地语音服务不可用: " + String(err && err.message || err) }); }); } // ───────────────────────────────── ASR ─────────────────────────────────── function asrSupported() { return typeof window !== "undefined" && Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); } let recognizer = null; // browser engine instance (SpeechRecognition) let activeEngine = null; // 'browser' | 'server' let listening = false; // whether the mic should stay on (held / toggled-on) let finalTranscript = ""; let baseDraft = ""; // draft text before recording started let lastWritten = ""; // what updateDraft last wrote (to detect user edits) let liveWriting = true; // still allowed to overwrite the draft live let commitOnEnd = null; // { readDraft, setDraft, submit } resolved when recording ends let browserNetworkFailed = false; // auto-mode memory: browser ASR is unusable here let lastMicCtx = null; // latest mic context (set by MicButton) for auto-resume let loopActive = false; // hands-free loop running (自由交流 mode) /** * Write the recognized text LIVE into the composer draft — but stop the * moment the user edits the draft themselves, so their typing is never * clobbered by the next recognition update. */ function updateDraft(text) { if (!commitOnEnd || !commitOnEnd.setDraft) return; if (liveWriting && commitOnEnd.readDraft) { const current = commitOnEnd.readDraft(); if (current !== lastWritten) liveWriting = false; // user took over editing } if (!liveWriting) return; const prefix = baseDraft ? baseDraft + " " : ""; const next = prefix + text; lastWritten = next; commitOnEnd.setDraft(next); } /** End-of-recording: reconcile the draft and optionally auto-submit. */ function commitFinal() { const ctxAtEnd = commitOnEnd; commitOnEnd = null; const text = finalTranscript.trim(); finalTranscript = ""; if (ctxAtEnd === null) return; // If the user edited mid-recording, append the final transcript to their draft. if (!liveWriting && text && ctxAtEnd.readDraft && ctxAtEnd.setDraft) { const cur = ctxAtEnd.readDraft().trimEnd(); ctxAtEnd.setDraft(cur ? cur + " " + text : text); } if (text && loadSettings().autoSubmit && ctxAtEnd.submit) { try { ctxAtEnd.submit(); } catch { /* submission refused (busy) */ } } } /** Auto-resume the mic loop after the reply finishes reading (自由交流 mode). */ function maybeResume() { const s = loadSettings(); if (!s.autoSubmit || !s.autoRead) return; // only in 自由交流 (hands-free) mode if (!loopActive) return; if (listening) return; if (lastMicCtx === null) return; setTimeout(() => { if (!listening && loopActive && loadSettings().autoSubmit) startListening(lastMicCtx); }, 700); } /** Manual stop: end the hands-free loop too. */ function stopLoop() { loopActive = false; stopListening(); } /** * Precise mic preflight via getUserMedia — the call that triggers the * OS/browser permission prompt and reports WHICH layer denied access. */ async function preflightMic() { if (typeof navigator === "undefined" || !navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") { return { ok: false, reason: "unsupported" }; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); for (const track of stream.getTracks()) track.stop(); return { ok: true }; } catch (err) { const name = (err && err.name) || ""; if (name === "NotAllowedError" || name === "SecurityError") return { ok: false, reason: "permission" }; if (name === "NotFoundError" || name === "OverconstrainedError") return { ok: false, reason: "device" }; return { ok: false, reason: "other", detail: String((err && err.message) || err) }; } } function micErrorMessage(res) { return res.reason === "permission" ? "麦克风权限被拒绝。请检查两步:① macOS「系统设置 → 隐私与安全性 → 麦克风」勾选允许 Chrome;② 地址栏左侧锁形图标 → 网站设置 → 麦克风选「允许」。改完后刷新页面再试。" : res.reason === "device" ? "未检测到可用的麦克风设备,请在 macOS「系统设置 → 声音 → 输入」确认有输入设备。" : res.reason === "unsupported" ? "此环境不支持麦克风访问(getUserMedia 不可用)。" : "无法访问麦克风: " + (res.detail || res.reason); } /** Entry point: pick the engine from settings, then start it. */ function startListening(ctx) { if (listening) return; ttsStop(); // speaking while the user talks would be confusing — interrupt commitOnEnd = ctx ?? null; finalTranscript = ""; baseDraft = (ctx && ctx.readDraft ? ctx.readDraft() : "").trimEnd(); lastWritten = baseDraft; liveWriting = true; setVoice({ recording: true, interim: "", error: null }); const engine = loadSettings().asrEngine; if (engine === "browser") return startBrowserEngine(ctx); if (engine === "server") return startServerEngine(ctx); // 'auto' if (browserNetworkFailed) return startServerEngine(ctx); startBrowserEngine(ctx); } // ── browser engine (Web Speech API — needs Google's cloud in Chrome) ──── function startBrowserEngine(ctx) { if (!asrSupported()) { listening = false; commitOnEnd = null; setVoice({ recording: false, error: "当前浏览器不支持语音识别(SpeechRecognition)。请在设置里改用「本地服务」识别引擎。" }); return; } preflightMic().then((res) => { if (!res.ok) { listening = false; commitOnEnd = null; setVoice({ recording: false, interim: "", error: micErrorMessage(res) }); return; } startRecognizer(ctx); }); } function startRecognizer(ctx) { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; const settings = loadSettings(); const rec = new SR(); rec.lang = settings.lang; rec.continuous = true; rec.interimResults = true; rec.maxAlternatives = 1; rec.onresult = (ev) => { let interim = ""; for (let k = ev.resultIndex; k < ev.results.length; k++) { const result = ev.results[k]; if (result.isFinal) finalTranscript += result[0].transcript; else interim += result[0].transcript; } setVoice({ interim: interim.trim() }); updateDraft((finalTranscript + interim).trim()); }; rec.onerror = (ev) => { if (ev.error === "aborted") return; // normal when we stop() / abort() listening = false; const e = ev.error; if (e === "not-allowed") { setVoice({ error: "麦克风权限被拒绝。请检查 macOS「系统设置 → 隐私与安全性 → 麦克风」是否允许 Chrome,以及地址栏锁形图标 → 网站设置中麦克风是否为「允许」,然后刷新页面重试。", recording: false, interim: "" }); } else if (e === "service-not-allowed" || e === "network") { browserNetworkFailed = true; const hint = loadSettings().asrEngine === "auto" ? "已自动切换到本地识别服务,下次点击收音将使用本地识别。" : "请在设置里把「识别引擎」改为「本地服务」。"; setVoice({ error: "浏览器语音识别依赖 Google 服务,当前网络不可用。" + hint, recording: false, interim: "" }); } else if (e === "no-speech") { setVoice({ recording: false, interim: "" }); } else if (e === "audio-capture") { setVoice({ error: "无法捕获麦克风音频,请检查麦克风设备是否被其他应用占用。", recording: false, interim: "" }); } else { setVoice({ error: "语音识别错误: " + e, recording: false, interim: "" }); } }; rec.onend = () => { if (listening) { try { rec.start(); } catch { listening = false; setVoice({ recording: false, interim: "" }); } return; } setVoice({ recording: false, interim: "" }); commitFinal(); }; recognizer = rec; activeEngine = "browser"; listening = true; try { rec.start(); } catch { listening = false; commitOnEnd = null; setVoice({ recording: false, error: "无法启动语音识别" }); } } // ── server engine (local sherpa-onnx via /api/voice/asr WebSocket) ────── let serverWs = null; let serverMicStream = null; let serverAudioCtx = null; let serverSource = null; let serverProcessor = null; function serverError(message) { listening = false; commitOnEnd = null; activeEngine = null; if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; } teardownCapture(); setVoice({ recording: false, interim: "", error: message }); } function teardownCapture() { if (serverMicStream) { for (const t of serverMicStream.getTracks()) t.stop(); serverMicStream = null; } if (serverSource) { try { serverSource.disconnect(); } catch { /* noop */ } serverSource = null; } if (serverProcessor) { try { serverProcessor.disconnect(); } catch { /* noop */ } serverProcessor = null; } if (serverAudioCtx) { try { serverAudioCtx.close(); } catch { /* noop */ } serverAudioCtx = null; } } function startServerEngine(ctx) { if (typeof WebSocket === "undefined") { serverError("当前浏览器不支持 WebSocket,无法连接本地识别服务。"); return; } const wsUrl = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/api/voice/asr"; let ws; try { ws = new WebSocket(wsUrl); } catch { serverError("无法创建本地识别连接。"); return; } serverWs = ws; ws.binaryType = "arraybuffer"; const isCurrent = () => serverWs === ws; ws.onopen = () => { if (isCurrent()) startServerCapture(ws); }; ws.onmessage = (ev) => { if (!isCurrent()) return; let msg; try { msg = JSON.parse(ev.data); } catch { return; } if (msg.type === "partial") { setVoice({ interim: msg.text || "" }); updateDraft(msg.text || ""); } else if (msg.type === "final") { finalTranscript = msg.text || ""; setVoice({ interim: "" }); updateDraft(finalTranscript); } else if (msg.type === "endpoint") { // Silence detected → auto-stop in 自由交流 mode. if (loadSettings().autoSubmit) stopListening(); } else if (msg.type === "error") serverError(msg.message || "本地识别服务错误"); }; ws.onerror = () => { if (isCurrent()) serverError("本地识别服务未连接。请确认 dsh-voice-server 已安装并已重启服务,或在设置里改用浏览器引擎。"); }; ws.onclose = () => { if (!isCurrent()) return; serverWs = null; teardownCapture(); setVoice({ recording: false, interim: "" }); if (activeEngine === "server") { activeEngine = null; commitFinal(); } }; activeEngine = "server"; listening = true; } function startServerCapture(ws) { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { serverError("此环境不支持麦克风访问(getUserMedia 不可用)。"); return; } navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => { if (ws.readyState !== ws.OPEN) { for (const t of stream.getTracks()) t.stop(); return; } serverMicStream = stream; const Ctx = window.AudioContext || window.webkitAudioContext; const ctx = new Ctx({ sampleRate: 16000 }); serverAudioCtx = ctx; const source = ctx.createMediaStreamSource(stream); // ScriptProcessor is deprecated but universally supported; 16k mono PCM is what sherpa expects. const processor = ctx.createScriptProcessor(4096, 1, 1); const zeroGain = ctx.createGain(); zeroGain.gain.value = 0; // keep the graph connected without feeding the speakers processor.onaudioprocess = (ev) => { const input = ev.inputBuffer.getChannelData(0); const pcm = new Int16Array(input.length); for (let i = 0; i < input.length; i++) { const s = Math.max(-1, Math.min(1, input[i])); pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } if (ws.readyState === ws.OPEN) ws.send(pcm.buffer); }; source.connect(processor); processor.connect(zeroGain); zeroGain.connect(ctx.destination); serverSource = source; serverProcessor = processor; setVoice({ recording: true, interim: "", error: null }); }).catch((err) => { serverError(micErrorMessage({ reason: (err && err.name === "NotAllowedError" || err && err.name === "SecurityError") ? "permission" : (err && err.name === "NotFoundError" ? "device" : "other"), detail: String(err && err.message || err) })); }); } function stopListening() { if (!listening) return; listening = false; setVoice({ recording: false, interim: "" }); if (activeEngine === "browser") { if (recognizer !== null) { try { recognizer.stop(); } catch { /* noop */ } } return; } if (activeEngine === "server") { teardownCapture(); if (serverWs && serverWs.readyState === serverWs.OPEN) { serverWs.send(JSON.stringify({ type: "stop" })); // server replies final, then closes → onclose commits } else { commitFinal(); } } } function cancelListening() { if (activeEngine === "browser" && recognizer !== null) { listening = false; commitOnEnd = null; try { recognizer.abort(); } catch { /* noop */ } } else if (activeEngine === "server") { listening = false; commitOnEnd = null; teardownCapture(); if (serverWs) { try { serverWs.close(); } catch { /* noop */ } serverWs = null; } } setVoice({ recording: false, interim: "" }); } // ─────────────────────────────── styles ────────────────────────────────── const css = [ ".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}", ".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)}", ".dsh-voice-btn:disabled{opacity:.35;cursor:not-allowed}", ".dsh-voice-btn-active{color:#ff6b6b}", ".dsh-voice-btn-recording{color:#ff5252;animation:dshVoicePulse 1.2s ease-in-out infinite}", ".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)}", ".dsh-voice-mode:hover:not(:disabled){color:var(--dsw-alias-label-primary,#e8edf4)}", "@keyframes dshVoicePulse{0%,100%{opacity:1}50%{opacity:.45}}", ".dsh-voice-dock{display:flex;align-items:center;gap:8px;min-height:18px;font-size:12px;color:var(--dsw-alias-label-secondary,#a8b3c4)}", ".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%}", ".dsh-voice-dock .dsh-voice-error{color:#ff6b6b}", ".dsh-voice-read{display:inline-flex;align-items:center;margin-left:6px}", ".dsh-voice-settings{display:flex;flex-direction:column;gap:12px;padding:16px}", ".dsh-voice-settings h3{margin:0 0 4px;font-size:15px;font-weight:600;color:var(--dsw-alias-label-primary,#e8edf4)}", ".dsh-voice-row{display:flex;align-items:center;justify-content:space-between;gap:16px;font-size:13px;color:var(--dsw-alias-label-secondary,#a8b3c4)}", ".dsh-voice-row label{display:flex;align-items:center;gap:8px;cursor:pointer}", ".dsh-voice-row input[type=range]{width:140px}", ".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}" ].join(""); const styleTagId = "dsh-voice-client/styles"; if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(styleTagId) + "]") === null) { const tag = document.createElement("style"); tag.dataset.plugin = "dsh-voice-client"; tag.dataset.pluginCss = styleTagId; tag.textContent = css; document.head.appendChild(tag); } // ─────────────────────────────── locale ────────────────────────────────── const NS = "voice"; const zh = { "mic.title.hold": "语音输入:按住说话", "mic.title.toggle": "语音输入:点击开始 / 再点结束", "mic.title.unsupported": "当前浏览器不支持语音识别,请使用 Chrome 或 Edge", "mic.recording": "松开完成识别", "status.listening": "正在聆听…", "status.speaking": "正在朗读…", "read.speak": "朗读这条回复", "read.stop": "停止朗读", "settings.title": "语音交流", "settings.lang": "识别 / 朗读语言", "settings.engine": "识别引擎", "settings.engine.server": "本地服务(推荐,离线)", "settings.engine.browser": "浏览器(依赖 Google 服务)", "settings.engine.auto": "自动(优先浏览器,失败回退本地)", "settings.mode": "说话方式", "settings.mode.hold": "按住说话", "settings.mode.toggle": "点击切换", "mode.dictation": "听写模式", "mode.chat": "自由交流模式", "mode.toChat": "切换到自由交流模式(说完自动发送并朗读回复)", "mode.toDictation": "切换到听写模式(手动发送)", "settings.autoSubmit": "识别后自动发送", "settings.autoRead": "自动朗读回复", "settings.tts": "朗读语音引擎", "settings.tts.edge": "微软神经语音(自然,需联网)", "settings.tts.piper": "Piper 本地(离线)", "settings.tts.browser": "系统语音", "settings.voice": "朗读音色", "settings.rate": "朗读语速", "settings.skipCode": "朗读时跳过代码块", "settings.test": "试听", "settings.test.text": "你好,我是你的语音助手。现在可以开始语音交流了。", "settings.unsupported": "当前浏览器不支持语音识别(请使用 Chrome 或 Edge),但朗读功能可用。" }; const en = { "mic.title.hold": "Voice input: hold to talk", "mic.title.toggle": "Voice input: click to start / click again to stop", "mic.title.unsupported": "Speech recognition is not supported by this browser; use Chrome or Edge", "mic.recording": "Release to finish", "status.listening": "Listening…", "status.speaking": "Speaking…", "read.speak": "Read this reply aloud", "read.stop": "Stop reading", "settings.title": "Voice", "settings.lang": "Recognition / reading language", "settings.engine": "Recognition engine", "settings.engine.server": "Local service (offline, recommended)", "settings.engine.browser": "Browser (needs Google service)", "settings.engine.auto": "Auto (browser first, fall back to local)", "settings.mode": "Input mode", "settings.mode.hold": "Hold to talk", "settings.mode.toggle": "Click to toggle", "mode.dictation": "Dictation", "mode.chat": "Voice chat", "mode.toChat": "Switch to voice chat (auto-send and read replies)", "mode.toDictation": "Switch to dictation (send manually)", "settings.autoSubmit": "Auto-send after recognition", "settings.autoRead": "Auto-read replies", "settings.tts": "Reading voice engine", "settings.tts.edge": "Microsoft neural (natural, online)", "settings.tts.piper": "Piper local (offline)", "settings.tts.browser": "System voice", "settings.voice": "Reading voice", "settings.rate": "Reading rate", "settings.skipCode": "Skip code blocks when reading", "settings.test": "Test", "settings.test.text": "Hi, I am your voice assistant. Let's start talking.", "settings.unsupported": "Speech recognition is not supported by this browser (use Chrome or Edge); reading aloud still works." }; // ─────────────────────────── helper: assistant text ────────────────────── function assistantTextOf(node) { if (!node || node.kind !== "assistant" || !Array.isArray(node.blocks)) return ""; const text = node.blocks.filter((b) => b.kind === "text").map((b) => b.text).join(""); const cleaned = loadSettings().skipCode ? cleanMarkdown(text) : text; return cleaned; } // ───────────────────── component: VoiceModeToggle ──────────────────────── function VoiceModeToggle(props) { const { t } = props; const [, force] = useState(0); const chat = loadSettings().autoSubmit; // true = 自由交流, false = 听写 const toggle = () => { saveSettings({ autoSubmit: !chat, autoRead: !chat }); force((n) => n + 1); }; const label = chat ? "💬 " + t("mode.chat") : "✍️ " + t("mode.dictation"); return jsx("button", { type: "button", className: "dsh-voice-btn dsh-voice-mode" + (chat ? " dsh-voice-btn-active" : ""), title: chat ? t("mode.toDictation") : t("mode.toChat"), "aria-label": chat ? t("mode.toDictation") : t("mode.toChat"), onClick: toggle, children: label }); } // ─────────────────────────── component: MicButton ──────────────────────── function MicButton(props) { const { t, input, inputActions } = props; const voice = useVoice(); const recording = voice.recording; const serverOk = typeof navigator !== "undefined" && !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia); const supported = asrSupported() || serverOk; const mode = loadSettings().mode; const draftRef = useRef(""); draftRef.current = typeof input?.draft === "string" ? input.draft : ""; const actionsRef = useRef({ setDraft: null, submit: null }); actionsRef.current = { setDraft: typeof inputActions?.setDraft === "function" ? inputActions.setDraft : null, submit: typeof inputActions?.submit === "function" ? inputActions.submit : null }; const makeCtx = () => ({ readDraft: () => draftRef.current, setDraft: (text) => actionsRef.current.setDraft?.(text), submit: () => actionsRef.current.submit?.() }); // Publish the mic context so the auto-resume loop can restart listening. useEffect(() => { lastMicCtx = makeCtx(); }); const handleStart = () => { if (!supported || recording) return; loopActive = loadSettings().autoSubmit === true; // hands-free loop only in 自由交流 startListening(makeCtx()); }; const handleStop = () => { if (!recording) return; stopLoop(); }; const handleToggle = () => { if (recording) handleStop(); else handleStart(); }; const title = !supported ? t("mic.title.unsupported") : recording ? t("mic.recording") : mode === "hold" ? t("mic.title.hold") : t("mic.title.toggle"); const buttonProps = { type: "button", className: "dsh-voice-btn" + (recording ? " dsh-voice-btn-recording" : ""), title, "aria-label": title, disabled: !supported, children: recording ? "⏹" : "🎤" }; if (mode === "hold") { buttonProps.onPointerDown = (e) => { e.preventDefault(); try { e.currentTarget.setPointerCapture(e.pointerId); } catch { /* noop */ } handleStart(); }; buttonProps.onPointerUp = (e) => { e.preventDefault(); handleStop(); }; buttonProps.onPointerCancel = () => { handleStop(); }; } else { buttonProps.onClick = handleToggle; } return jsx("button", buttonProps); } // ─────────────────────────── component: ReadAloud ──────────────────────── const spokenTurns = new Set(); // session:seq keys already auto-read this fiber function ReadAloud(props) { const { t, messageId, useSession } = props; const voice = useVoice(); const sessionId = useSession((s) => (s ? s.sessionId : undefined)); const node = useSession((s) => (s && Array.isArray(s.nodes) ? s.nodes.find((n) => n.kind === "assistant" && n.messageId === messageId) : undefined)); const text = useMemo(() => assistantTextOf(node), [node]); const key = "msg:" + (sessionId ? sessionId + ":" : "") + String(messageId); const isSpeaking = voice.speaking && voice.speakingKey === key; // Auto-read when the turn closes (component mounts after finalize). useEffect(() => { if (!loadSettings().autoRead || !text || !sessionId) return; if (spokenTurns.has(key)) return; spokenTurns.add(key); const timer = setTimeout(() => speakText(text, key), 400); return () => clearTimeout(timer); }, [key, text, sessionId]); const toggle = () => { if (isSpeaking) ttsStop(); else speakText(text, key); }; return jsx("div", { className: "dsh-voice-read", children: jsx("button", { type: "button", className: "dsh-voice-btn" + (isSpeaking ? " dsh-voice-btn-active" : ""), title: isSpeaking ? t("read.stop") : t("read.speak"), "aria-label": isSpeaking ? t("read.stop") : t("read.speak"), disabled: !text, onClick: toggle, children: isSpeaking ? "🔇" : "🔊" }) }); } // ──────────────────────── component: VoiceStatusLine ───────────────────── function VoiceStatusLine(props) { const { t } = props; const voice = useVoice(); if (!voice.recording && !voice.speaking && !voice.error) return null; let content; if (voice.error) { content = jsx("span", { className: "dsh-voice-error", children: "⚠️ " + voice.error }); } else if (voice.recording) { content = jsxs(Fragment, { children: [ jsx("span", { children: "🎙️ " + t("status.listening") }), voice.interim ? jsx("span", { className: "dsh-voice-interim", children: "「" + voice.interim + "」" }) : null ] }); } else { content = jsx("span", { children: "🔊 " + t("status.speaking") }); } return jsx("div", { className: "dsh-voice-dock", children: content }); } // ───────────────────────── component: VoiceSettings ────────────────────── function VoiceSettings(props) { const { t } = props; const [settings, setSettings] = useState(loadSettings); const update = (patch) => setSettings(saveSettings(patch)); const lang = settings.lang; const mode = settings.mode; const engine = settings.asrEngine; const row = (label, control) => jsx("div", { className: "dsh-voice-row", children: [jsx("span", { children: label }), control] }); const children = [ jsx("h3", { children: "🗣️ " + t("settings.title") }), !asrSupported() ? jsx("div", { className: "dsh-voice-row dsh-voice-error", children: t("settings.unsupported") }) : null, row(t("settings.engine"), jsx("select", { value: engine, onChange: (e) => update({ asrEngine: e.target.value }), children: [ jsx("option", { value: "server", children: t("settings.engine.server") }), jsx("option", { value: "browser", children: t("settings.engine.browser") }), jsx("option", { value: "auto", children: t("settings.engine.auto") }) ] })), row(t("settings.lang"), jsx("select", { value: lang, onChange: (e) => update({ lang: e.target.value }), children: [ jsx("option", { value: "zh-CN", children: "中文(普通话)" }), jsx("option", { value: "zh-TW", children: "中文(台湾)" }), jsx("option", { value: "en-US", children: "English (US)" }), jsx("option", { value: "en-GB", children: "English (UK)" }), jsx("option", { value: "ja-JP", children: "日本語" }), jsx("option", { value: "ko-KR", children: "한국어" }) ] })), row(t("settings.mode"), jsxs("span", { children: [ jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "hold", onChange: () => update({ mode: "hold" }) }), t("settings.mode.hold")] }), jsx("label", { children: [jsx("input", { type: "radio", name: "dsh-voice-mode", checked: mode === "toggle", onChange: () => update({ mode: "toggle" }) }), t("settings.mode.toggle")] }) ] })), row(t("settings.autoSubmit"), jsx("input", { type: "checkbox", checked: settings.autoSubmit, onChange: (e) => update({ autoSubmit: e.target.checked }) })), row(t("settings.autoRead"), jsx("input", { type: "checkbox", checked: settings.autoRead, onChange: (e) => update({ autoRead: e.target.checked }) })), row(t("settings.tts"), jsx("select", { value: settings.ttsEngine, onChange: (e) => update({ ttsEngine: e.target.value }), children: [ jsx("option", { value: "edge", children: t("settings.tts.edge") }), jsx("option", { value: "piper", children: t("settings.tts.piper") }), jsx("option", { value: "browser", children: t("settings.tts.browser") }) ] })), settings.ttsEngine === "edge" ? row(t("settings.voice"), jsx("select", { value: settings.ttsVoice, onChange: (e) => update({ ttsVoice: e.target.value }), children: [ jsx("option", { value: "zh-CN-XiaoyiNeural", children: "晓伊(温暖活泼 女声)" }), jsx("option", { value: "zh-CN-XiaoxiaoNeural", children: "晓晓(温柔 女声)" }), jsx("option", { value: "zh-CN-YunxiNeural", children: "云希(温暖 男声)" }), jsx("option", { value: "zh-CN-YunjianNeural", children: "云健(沉稳 男声)" }), jsx("option", { value: "zh-CN-YunxiaNeural", children: "云夏(活泼 男声)" }), jsx("option", { value: "zh-CN-YunyangNeural", children: "云扬(新闻 男声)" }) ] })) : null, row(t("settings.rate"), jsx("input", { type: "range", min: 0.5, max: 2, step: 0.1, value: settings.rate, onChange: (e) => update({ rate: Number(e.target.value) }), title: String(settings.rate) })), row(t("settings.skipCode"), jsx("input", { type: "checkbox", checked: settings.skipCode, onChange: (e) => update({ skipCode: e.target.checked }) })), row(t("settings.test"), jsx("button", { type: "button", className: "dsh-voice-btn", onClick: () => speakText(t("settings.test.text"), "voice-test"), children: "🔊" })) ]; return jsx("section", { className: "dsh-voice-settings", children }); } // ─────────────────────────────── registration ──────────────────────────── const inject = ["slots", "locale"]; function apply(ctx) { ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-voice: dictionaries"); // Core: read-aloud button (+ auto-read) on each finalized assistant message. ctx.slots.inject("conversation.chat.assistant-actions", () => ctx.slots.register({ name: "conversation.chat.assistant-actions", id: "dsh-voice-read", order: 100, locale: NS }, ReadAloud)); // Core: mic button in the composer tool row. ctx.slots.inject("conversation.input.right", () => ctx.slots.register({ name: "conversation.input.right", id: "dsh-voice-mic", order: 100, locale: NS }, MicButton)); // Voice status line under the composer. ctx.slots.inject("conversation.composer.dock", () => ctx.slots.register({ name: "conversation.composer.dock", id: "dsh-voice-status", order: 100, locale: NS }, VoiceStatusLine)); // Voice settings section. ctx.slots.inject("settings.section", () => ctx.slots.register({ name: "settings.section", id: "dsh-voice-settings", order: 25, label: () => ctx.locale.bind(NS)("settings.title"), locale: NS }, VoiceSettings)); // Dictation / voice-chat mode switch in the composer tool row (left). ctx.slots.inject("conversation.input.left", () => ctx.slots.register({ name: "conversation.input.left", id: "dsh-voice-mode-toggle", order: 100, locale: NS }, VoiceModeToggle)); } exports.apply = apply; exports.inject = inject; return module.exports; } });