| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964 |
- /**
- * 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;
- }
- });
|