feat: 语音流式输入管线 + VAD前端集成 + 插件-工具合并清理

- 前端: VAD语音检测(@ricky0123/vad-web) + useVoiceInput双模式(流式WS/REST)
- Gateway: VoiceStreamManager代理WS流式STT到voice-service
- Voice-service: DashScope REST → Realtime WS → Whisper三级引擎 + ffmpeg转码
- 共享模块: pkg/audio(音频转换) + pkg/dashscope(ASR REST客户端)
- 清理: 移除旧plugin-manager和pkg/plugins,完成插件→工具合并
- 文档: 完善gateway-api.md和voice-service.md语音API文档
- 工具: scripts/voice/ 语音转换脚本集

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:50:40 +08:00
parent 258cf81b25
commit 6ef9e082a6
91 changed files with 4091 additions and 3929 deletions
+64 -25
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import type { ChatMode, MessageAttachment } from '@/types/chat';
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
import { useVoiceInput } from '@/hooks/useVoiceInput';
import { uploadFile } from '@/api/files';
import { useChatStore } from '@/store/chatStore';
interface ChatInputProps {
onSend: (content: string, mode: ChatMode, attachments?: MessageAttachment[]) => void;
onSendVoiceStream?: (msg: import('@/types/chat').WSClientMessage) => void;
disabled?: boolean;
}
@@ -19,7 +21,7 @@ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
const SUPPORTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'];
const MAX_IMAGES = 5;
export function ChatInput({ onSend, disabled }: ChatInputProps) {
export function ChatInput({ onSend, onSendVoiceStream, disabled }: ChatInputProps) {
const [content, setContent] = useState('');
const [mode, setMode] = useState<ChatMode>('text');
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
@@ -30,27 +32,50 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
const isTyping = useChatStore((s) => s.isTyping);
const {
isListening,
isSupported,
isListening: isSRListening,
isSupported: isSRSpported,
isFallbackMode,
interimText,
finalText,
error,
startListening,
stopListening,
error: srError,
startListening: startSR,
stopListening: stopSR,
resetText,
} = useSpeechRecognition();
// 当 finalText 更新时,追加到输入框
// VAD-based voice input (primary when supported)
const {
isListening: isVADListening,
isSpeaking: isVADSpeaking,
isSupported: isVADSupported,
interimText: vadInterimText,
finalText: vadFinalText,
error: vadError,
startListening: startVAD,
stopListening: stopVAD,
} = useVoiceInput({
onTranscription: (text: string) => {
setContent((prev) => {
const trimmed = prev.trimEnd();
return (trimmed ? trimmed + ' ' : '') + text;
});
},
sendMessage: onSendVoiceStream,
});
const isListening = isVADSupported ? isVADListening : isSRListening;
const voiceError = isVADSupported ? vadError : srError;
// 当 SR finalText 更新时,追加到输入框 (仅非 VAD 模式)
useEffect(() => {
if (finalText) {
if (!isVADSupported && finalText) {
setContent((prev) => {
const trimmed = prev.trimEnd();
return (trimmed ? trimmed + ' ' : '') + finalText;
});
resetText();
}
}, [finalText, resetText]);
}, [isVADSupported, finalText, resetText]);
const handleSend = useCallback(async () => {
const trimmed = content.trim();
@@ -121,13 +146,13 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
if (e.key === 'V' && e.ctrlKey && e.shiftKey) {
e.preventDefault();
if (isListening) {
stopListening();
isVADSupported ? stopVAD() : stopSR();
} else {
startListening();
isVADSupported ? startVAD() : startSR();
}
}
},
[handleSend, isListening, startListening, stopListening]
[handleSend, isListening, isVADSupported, startVAD, stopVAD, startSR, stopSR]
);
// 粘贴图片
@@ -260,11 +285,11 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
const handleVoiceToggle = useCallback(() => {
if (isListening) {
stopListening();
isVADSupported ? stopVAD() : stopSR();
} else {
startListening();
isVADSupported ? startVAD() : startSR();
}
}, [isListening, startListening, stopListening]);
}, [isListening, isVADSupported, startVAD, stopVAD, startSR, stopSR]);
return (
<div
@@ -305,8 +330,17 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
</div>
)}
{/* 实时识别文本提示 */}
{isListening && interimText && (
{/* VAD 语音状态提示 */}
{isVADSupported && isVADListening && (
<div className="text-sm text-pink-500 dark:text-pink-400 italic px-1" aria-live="polite">
{isVADSpeaking
? (vadInterimText || '检测到语音,正在识别...')
: '正在聆听...'}
</div>
)}
{/* 实时识别文本提示 (仅非 VAD 模式) */}
{!isVADSupported && isListening && interimText && (
<div
className="interim-text text-sm text-pink-500 dark:text-pink-400 italic px-1"
aria-live="polite"
@@ -317,12 +351,12 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
)}
{/* 错误提示 */}
{error && (
{voiceError && (
<div
className="text-xs text-red-500 dark:text-red-400 px-1"
role="alert"
>
{error}
{voiceError}
</div>
)}
@@ -434,18 +468,20 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
className="flex-1 resize-none rounded-xl border border-pink-200 dark:border-pink-800 bg-white dark:bg-gray-800 px-4 py-2 text-sm text-gray-700 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-pink-400 focus:border-transparent disabled:opacity-50"
/>
{/* 语音输入按钮 (仅浏览器支持时显示) */}
{isSupported && (
{/* 语音输入按钮 (VAD 或浏览器 SpeechRecognition/MediaRecorder 支持时显示) */}
{(isVADSupported || isSRSpported) && (
<button
onClick={handleVoiceToggle}
disabled={disabled || uploading}
aria-label={isListening ? '停止语音输入' : '开始语音输入'}
aria-pressed={isListening}
title={isListening ? '停止聆听 (Ctrl+Shift+V)' : '语音输入 (Ctrl+Shift+V)'}
title={isListening ? '停止聆听 (Ctrl+Shift+V)' : isVADSupported ? '语音输入 (自动检测说话)' : '语音输入 (Ctrl+Shift+V)'}
className={`p-2 rounded-xl transition-all flex-shrink-0 border-2 ${
isListening
? 'voice-btn-active bg-red-500 border-red-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-300'
: isVADSpeaking
? 'bg-yellow-400 border-yellow-400 text-white'
: 'bg-gray-100 dark:bg-gray-700 border-gray-200 dark:border-gray-600 text-gray-500 hover:text-red-500 hover:border-red-300'
} disabled:opacity-40 disabled:cursor-not-allowed`}
>
<svg
@@ -461,7 +497,7 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
)}
{/* 不支持时显示禁用按钮 */}
{!isSupported && (
{!isVADSupported && !isSRSpported && (
<button
disabled
title="您的浏览器不支持语音识别"
@@ -508,7 +544,10 @@ export function ChatInput({ onSend, disabled }: ChatInputProps) {
{/* 语音输入状态提示 */}
{isListening && (
<p className="text-xs text-red-400 text-center animate-pulse">
{isFallbackMode ? '🎤 后端语音识别中...' : '🎤 正在聆听...'}
{isVADSupported
? (isVADSpeaking ? '🔊 检测到语音,正在识别...' : '🎤 正在聆听...')
: (isFallbackMode ? '🎤 后端语音识别中...' : '🎤 正在聆听...')
}
<span className="text-gray-400 ml-2">(Ctrl+Shift+V )</span>
</p>
)}