feat: 添加语音输入按钮,位于发送按钮左侧

- SVG线条图标风格,只用hover交互,不做边框
- 图标路径:static/icons/mic.svg
- 测试页面:static/voice_test.html
This commit is contained in:
JOJO 2026-06-08 02:09:04 +08:00
parent b6818bc0d0
commit 66261e91c6
4 changed files with 436 additions and 6 deletions

1
static/icons/mic.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:1;"><path d="M12 19v3m7-12v2a7 7 0 0 1-14 0v-2"/><rect width="6" height="13" x="9" y="2" rx="3"/></svg>

After

Width:  |  Height:  |  Size: 294 B

View File

@ -205,15 +205,52 @@
>
+
</button>
<button
type="button"
class="stadium-btn send-btn"
@click="$emit('send-or-stop')"
:disabled="sendButtonDisabled"
>
<div class="input-actions-right">
<button
v-if="isWebSpeechSupported"
type="button"
class="stadium-btn voice-btn"
:class="{ 'voice-btn--recording': isRecording }"
@click="toggleVoiceRecording"
:disabled="!isConnected || inputLocked"
title="语音输入"
>
<template v-if="!isRecording">
<svg
class="mic-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19v3m7-12v2a7 7 0 0 1-14 0v-2" />
<rect width="6" height="13" x="9" y="2" rx="3" />
</svg>
</template>
<template v-else>
<div class="voice-wave">
<span class="voice-wave-bar" />
<span class="voice-wave-bar" />
<span class="voice-wave-bar" />
<span class="voice-wave-bar" />
</div>
</template>
</button>
<button
type="button"
class="stadium-btn send-btn"
@click="$emit('send-or-stop')"
:disabled="sendButtonDisabled"
>
<span v-if="showStopIcon" class="stop-icon"></span>
<span v-else class="send-icon"></span>
</button>
</div>
</div>
</div>
</div>
@ -1138,6 +1175,192 @@ const editor = useEditor({
}
});
/* ═══════════════════ 语音输入 ═══════════════════ */
const isRecording = ref(false);
const recognitionInstance = ref<SpeechRecognition | null>(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<HTMLElement | null>(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) => {

View File

@ -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;

111
static/voice_test.html Normal file
View File

@ -0,0 +1,111 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Web Speech API 诊断</title>
<style>
body { font-family: system-ui; padding: 24px; line-height: 1.6; }
button { padding: 12px 24px; font-size: 16px; cursor: pointer; }
#log { margin-top: 20px; padding: 16px; background: #f5f5f5; border-radius: 8px; font-family: monospace; font-size: 13px; white-space: pre-wrap; }
.ok { color: #16a34a; }
.err { color: #dc2626; }
.warn { color: #d97706; }
</style>
</head>
<body>
<h2>Web Speech API 诊断</h2>
<p>点击按钮后对着麦克风说话,等待 3-5 秒,观察下方日志。</p>
<button id="btn" onclick="startTest()">开始录音测试</button>
<button onclick="stopTest()">停止</button>
<div id="log"></div>
<script>
let r = null;
const logEl = document.getElementById('log');
function log(msg, type) {
const t = type ? ` class="${type}"` : '';
logEl.innerHTML += `<span${t}>[${new Date().toLocaleTimeString()}] ${msg}</span>\n`;
}
function startTest() {
logEl.innerHTML = '';
log('检测支持...');
const hasSR = 'SpeechRecognition' in window;
const hasWebkit = 'webkitSpeechRecognition' in window;
log(`SpeechRecognition: ${hasSR}, webkitSpeechRecognition: ${hasWebkit}`);
if (!hasSR && !hasWebkit) {
log('浏览器不支持 SpeechRecognition', 'err');
return;
}
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
r = new Ctor();
r.lang = 'zh-CN';
r.continuous = true;
r.interimResults = true;
r.maxAlternatives = 1;
let resultCount = 0;
r.onstart = () => log('✅ onstart: 录音已开始', 'ok');
r.onaudiostart = () => log('✅ onaudiostart: 音频捕获开始', 'ok');
r.onsoundstart = () => log('✅ onsoundstart: 检测到声音', 'ok');
r.onspeechstart = () => log('✅ onspeechstart: 检测到语音', 'ok');
r.onresult = (event) => {
resultCount++;
log(`✅ onresult #${resultCount}: ${event.results.length} 条结果`);
for (let i = 0; i < event.results.length; i++) {
const alt = event.results[i][0];
const isFinal = event.results[i].isFinal;
log(` result[${i}]: "${alt.transcript}" (final=${isFinal}, conf=${alt.confidence?.toFixed(2) ?? '?'})`);
}
};
r.onspeechend = () => log('onspeechend: 语音结束');
r.onsoundend = () => log('onsoundend: 声音结束');
r.onaudioend = () => log('onaudioend: 音频捕获结束');
r.onerror = (event) => {
log(`❌ onerror: ${event.error}`, 'err');
if (event.error === 'not-allowed') {
log('提示: 麦克风权限被拒绝,请检查浏览器权限设置', 'warn');
}
if (event.error === 'network') {
log('提示: 网络错误Chrome 的语音识别依赖 Google 云端服务', 'warn');
}
if (event.error === 'no-speech') {
log('提示: 没有检测到语音,请确保麦克风正常工作且说话清晰', 'warn');
}
};
r.onend = () => {
log(`onend: 录音结束 (共收到 ${resultCount} 次 onresult)`);
if (resultCount === 0) {
log('⚠️ 警告: 录音已结束但没有任何识别结果!', 'warn');
log('可能原因:', 'warn');
log(' 1. 网络无法访问 Google Speech API (在中国大陆最常见)', 'warn');
log(' 2. 说话时间太短或音量太低', 'warn');
log(' 3. Chrome 语音识别服务暂时不可用', 'warn');
}
};
try {
r.start();
log('r.start() 调用成功');
} catch (e) {
log(`❌ start() 失败: ${e.message}`, 'err');
}
}
function stopTest() {
if (r) {
try { r.stop(); log('r.stop() 调用成功'); } catch (e) { log(`stop() 失败: ${e.message}`); }
}
}
</script>
</body>
</html>