feat: 添加语音输入按钮,位于发送按钮左侧
- SVG线条图标风格,只用hover交互,不做边框 - 图标路径:static/icons/mic.svg - 测试页面:static/voice_test.html
This commit is contained in:
parent
b6818bc0d0
commit
66261e91c6
1
static/icons/mic.svg
Normal file
1
static/icons/mic.svg
Normal 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 |
@ -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) => {
|
||||
|
||||
@ -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
111
static/voice_test.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user