From 66261e91c6e50d8dd0c6a7ff1b058bf1df5bc6c2 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 8 Jun 2026 02:09:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=8C=89=E9=92=AE=EF=BC=8C=E4=BD=8D=E4=BA=8E?= =?UTF-8?q?=E5=8F=91=E9=80=81=E6=8C=89=E9=92=AE=E5=B7=A6=E4=BE=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SVG线条图标风格,只用hover交互,不做边框 - 图标路径:static/icons/mic.svg - 测试页面:static/voice_test.html --- static/icons/mic.svg | 1 + static/src/components/input/InputComposer.vue | 246 +++++++++++++++++- .../styles/components/input/_composer.scss | 84 ++++++ static/voice_test.html | 111 ++++++++ 4 files changed, 436 insertions(+), 6 deletions(-) create mode 100644 static/icons/mic.svg create mode 100644 static/voice_test.html diff --git a/static/icons/mic.svg b/static/icons/mic.svg new file mode 100644 index 0000000..d0dc8ed --- /dev/null +++ b/static/icons/mic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index 023cf77..26643a5 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -205,15 +205,52 @@ > + - + + @@ -1138,6 +1175,192 @@ const editor = useEditor({ } }); +/* ═══════════════════ 语音输入 ═══════════════════ */ + +const isRecording = ref(false); +const recognitionInstance = ref(null); +let voiceAnchorPos = 0; +let lastVoiceTextLength = 0; + +const isWebSpeechSupported = computed(() => { + if (typeof window === 'undefined') return false; + const hasSR = 'SpeechRecognition' in window; + const hasWebkit = 'webkitSpeechRecognition' in window; + console.log('[VoiceInput] 检测支持:', { hasSR, hasWebkit, supported: hasSR || hasWebkit }); + return hasSR || hasWebkit; +}); + +const getSpeechRecognitionConstructor = (): typeof SpeechRecognition | null => { + if (typeof window === 'undefined') return null; + const Ctor = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null; + console.log('[VoiceInput] 构造函数:', Ctor ? (Ctor.name || 'webkitSpeechRecognition') : 'null'); + return Ctor; +}; + +const stopVoiceRecording = () => { + console.log('[VoiceInput] stopVoiceRecording called, isRecording:', isRecording.value); + if (recognitionInstance.value) { + try { + recognitionInstance.value.stop(); + console.log('[VoiceInput] recognition.stop() called'); + } catch (e) { + console.warn('[VoiceInput] recognition.stop() error:', e); + } + recognitionInstance.value = null; + } + isRecording.value = false; + voiceAnchorPos = 0; + lastVoiceTextLength = 0; +}; + +const initSpeechRecognition = () => { + const SpeechRecognitionCtor = getSpeechRecognitionConstructor(); + if (!SpeechRecognitionCtor) return null; + + const recognition = new SpeechRecognitionCtor(); + recognition.lang = 'zh-CN'; + recognition.continuous = true; + recognition.interimResults = true; + recognition.maxAlternatives = 1; + + // 记录锚点位置(录音开始时的光标位置) + const instance = editor.value; + if (instance) { + voiceAnchorPos = instance.state.selection.from; + } else { + voiceAnchorPos = 0; + } + lastVoiceTextLength = 0; + console.log('[VoiceInput] init: anchorPos=', voiceAnchorPos); + + recognition.onresult = (event: SpeechRecognitionEvent) => { + console.log('[VoiceInput] onresult fired:', { + resultLength: event.results.length, + resultIndex: (event as any).resultIndex + }); + let fullText = ''; + for (let i = 0; i < event.results.length; i++) { + const r = event.results[i]; + console.log(`[VoiceInput] result[${i}]:`, { transcript: r[0]?.transcript, isFinal: r.isFinal, confidence: r[0]?.confidence }); + fullText += r[0].transcript; + } + + console.log('[VoiceInput] fullText:', JSON.stringify(fullText)); + if (!fullText) { + console.log('[VoiceInput] fullText empty, skip'); + return; + } + + const ed = editor.value; + if (!ed) { + console.warn('[VoiceInput] editor is null'); + return; + } + + // 从锚点位置替换语音文本:先删旧、再插新 + const { state, view } = ed; + const docSize = state.doc.content.size; + // ProseMirror 合法位置范围是 [0, docSize],docSize 本身是有效位置(文档末尾) + const safeAnchor = Math.max(0, Math.min(voiceAnchorPos, docSize)); + const oldEnd = Math.min(safeAnchor + lastVoiceTextLength, docSize); + + let tr = state.tr; + if (oldEnd > safeAnchor) { + tr = tr.delete(safeAnchor, oldEnd); + } + tr = tr.insertText(fullText, safeAnchor); + + const newCursor = Math.min(safeAnchor + fullText.length, tr.doc.content.size); + tr = tr.setSelection(TextSelection.create(tr.doc, newCursor)); + view.dispatch(tr); + view.focus(); + + lastVoiceTextLength = fullText.length; + console.log('[VoiceInput] inserted ok, lastVoiceTextLength=', lastVoiceTextLength); + + emit('update:input-message', getEditorPlainText()); + emit('input-change'); + nextTick(() => { + adjustTextareaSize(); + }); + }; + + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + console.warn('[VoiceInput] onerror:', { error: event.error, message: (event as any).message }); + if (event.error === 'aborted') { + console.log('[VoiceInput] aborted (user stop), ignore'); + return; + } + // no-speech / network / not-allowed / service-not-allowed 等全部打印 + console.warn('[VoiceInput] stopping due to error:', event.error); + stopVoiceRecording(); + }; + + recognition.onstart = () => { + console.log('[VoiceInput] onstart: 录音已开始'); + }; + + recognition.onaudiostart = () => { + console.log('[VoiceInput] onaudiostart: 音频捕获开始'); + }; + + recognition.onsoundstart = () => { + console.log('[VoiceInput] onsoundstart: 检测到声音'); + }; + + recognition.onspeechstart = () => { + console.log('[VoiceInput] onspeechstart: 检测到语音'); + }; + + recognition.onspeechend = () => { + console.log('[VoiceInput] onspeechend: 语音结束'); + }; + + recognition.onsoundend = () => { + console.log('[VoiceInput] onsoundend: 声音结束'); + }; + + recognition.onaudioend = () => { + console.log('[VoiceInput] onaudioend: 音频捕获结束'); + }; + + recognition.onend = () => { + console.log('[VoiceInput] onend: 录音结束, isRecording=', isRecording.value); + isRecording.value = false; + recognitionInstance.value = null; + voiceAnchorPos = 0; + lastVoiceTextLength = 0; + }; + + return recognition; +}; + +const toggleVoiceRecording = () => { + console.log('[VoiceInput] toggleVoiceRecording, isRecording=', isRecording.value); + if (isRecording.value) { + stopVoiceRecording(); + return; + } + + const recognition = initSpeechRecognition(); + if (!recognition) { + console.warn('[VoiceInput] 浏览器不支持语音识别'); + return; + } + + try { + recognition.start(); + console.log('[VoiceInput] recognition.start() called'); + recognitionInstance.value = recognition; + isRecording.value = true; + } catch (error) { + console.warn('[VoiceInput] 启动语音识别失败:', error); + stopVoiceRecording(); + } +}; + +/* ═══════════════════ runtime queue 动画 ═══════════════════ */ + const RUNTIME_QUEUE_ANIM_DURATION = 300; const RUNTIME_QUEUE_ANIM_EASING = 'cubic-bezier(0.25, 0.8, 0.25, 1)'; const runtimeQueueList = ref(null); @@ -1759,6 +1982,16 @@ watch(goalBannerCollapsed, async () => { emitComposerHeight(); }); +// 发送消息或输入锁定时,自动停止语音录音 +watch( + () => props.streamingMessage || props.inputLocked, + (shouldStop) => { + if (shouldStop && isRecording.value) { + stopVoiceRecording(); + } + } +); + onMounted(() => { document.addEventListener('click', closeProjectGitMenu); adjustTextareaSize(); @@ -1782,6 +2015,7 @@ onMounted(() => { }); onBeforeUnmount(() => { + stopVoiceRecording(); document.removeEventListener('click', closeProjectGitMenu); editor.value?.destroy(); runtimeQueueItemRefs.forEach((el) => { diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 648c19d..449099d 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -803,6 +803,90 @@ body[data-theme='light'] { background-color: color-mix(in srgb, var(--on-accent) 40%, transparent); } +.input-actions-right { + display: flex; + align-items: center; + gap: 6px; +} + +/* 语音输入按钮 */ +.voice-btn { + color: var(--text-primary); +} + +:root[data-theme='classic'] .voice-btn, +:root:not([data-theme]) .voice-btn { + color: var(--accent); +} + +.voice-btn:hover:not(:disabled) { + background: var(--hover-bg); +} + +.voice-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.voice-btn .mic-icon { + display: block; + flex-shrink: 0; +} + +/* 声纹动画 */ +.voice-btn--recording { + background: var(--hover-bg) !important; +} + +.voice-wave { + display: flex; + align-items: center; + justify-content: center; + gap: 2.5px; + height: 18px; +} + +.voice-wave-bar { + display: inline-block; + width: 2.5px; + height: 16px; + border-radius: 2px; + background: currentColor; + transform-origin: center; + animation: voice-wave-pulse 0.9s ease-in-out infinite; +} + +.voice-wave-bar:nth-child(1) { + animation-delay: 0s; + animation-duration: 0.75s; +} + +.voice-wave-bar:nth-child(2) { + animation-delay: 0.15s; + animation-duration: 0.9s; +} + +.voice-wave-bar:nth-child(3) { + animation-delay: 0.3s; + animation-duration: 1.05s; +} + +.voice-wave-bar:nth-child(4) { + animation-delay: 0.1s; + animation-duration: 0.85s; +} + +@keyframes voice-wave-pulse { + 0%, 100% { + transform: scaleY(0.25); + opacity: 0.6; + } + 50% { + transform: scaleY(1); + opacity: 1; + } +} + .file-input-hidden { position: absolute; opacity: 0; diff --git a/static/voice_test.html b/static/voice_test.html new file mode 100644 index 0000000..4570cbc --- /dev/null +++ b/static/voice_test.html @@ -0,0 +1,111 @@ + + + + + Web Speech API 诊断 + + + +

Web Speech API 诊断

+

点击按钮后对着麦克风说话,等待 3-5 秒,观察下方日志。

+ + +
+ + + +