diff --git a/backend/internal/ai/stt/mimo.go b/backend/internal/ai/stt/mimo.go index 68e0c0d..a2da512 100644 --- a/backend/internal/ai/stt/mimo.go +++ b/backend/internal/ai/stt/mimo.go @@ -158,7 +158,8 @@ func (m *MiMoService) Recognize(ctx context.Context, audio []byte, opts Options) } if len(mResp.Choices) == 0 { - return "", fmt.Errorf("stt: mimo returned empty choices") + // MiMo 返回空结果,视为无法识别(非错误),返回空文本 + return "", nil } text := strings.TrimSpace(mResp.Choices[0].Message.Content) diff --git a/backend/internal/ai/stt/mimo_test.go b/backend/internal/ai/stt/mimo_test.go index 78ab038..e4bda09 100644 --- a/backend/internal/ai/stt/mimo_test.go +++ b/backend/internal/ai/stt/mimo_test.go @@ -103,9 +103,12 @@ func TestMiMoService_Recognize_EmptyChoices(t *testing.T) { defer srv.Close() wav := makeValidWAV([]byte{0x00, 0x00}) - _, err := s.Recognize(context.Background(), wav, Options{}) - if err == nil { - t.Fatal("expected error for empty choices") + text, err := s.Recognize(context.Background(), wav, Options{}) + if err != nil { + t.Fatalf("unexpected error for empty choices: %v", err) + } + if text != "" { + t.Errorf("expected empty string for empty choices, got %q", text) } } diff --git a/backend/internal/orchestrator/pipeline.go b/backend/internal/orchestrator/pipeline.go index 914b70e..87d8146 100644 --- a/backend/internal/orchestrator/pipeline.go +++ b/backend/internal/orchestrator/pipeline.go @@ -133,24 +133,48 @@ func (p *Pipeline) ProcessQuery( } } else { // 语音模式:执行 STT 语音识别 - log.Infow("开始语音识别", "request_id", req.RequestID) + log.Infow("开始语音识别", "request_id", req.RequestID, "audio_bytes", len(audio)) sttResult, err := p.sttService.Recognize(ctx, audio, stt.Options{ Encoding: "pcm_s16le", SampleRate: 16000, Language: sess.Config.Language, }) if err != nil { - log.Errorw("语音识别失败", "error", err) + log.Errorw("语音识别失败", "error", err, "audio_bytes", len(audio)) sender.SendError(models.WsError{ Type: "error", RequestID: req.RequestID, Code: "STT_ERROR", - Message: "语音识别失败", + Message: "语音识别失败: " + err.Error(), }) return err } userText = sttResult + // STT 返回空文本:未识别到语音,发送结果后直接返回(不调 LLM) + if strings.TrimSpace(userText) == "" { + log.Infow("语音识别结果为空", "request_id", req.RequestID) + userText = "(未识别到语音)" + if err := sender.SendSTTResult(models.WsSTTResult{ + Type: "stt_result", + RequestID: req.RequestID, + Text: userText, + IsFinal: true, + }); err != nil { + log.Errorw("发送 STT 结果失败", "error", err) + } + // 发送空的 llm_done 以结束本轮处理 + latency := time.Since(startTime).Milliseconds() + _ = sender.SendLLMDone(models.WsLLMDone{ + Type: "llm_done", + RequestID: req.RequestID, + FullText: "", + Model: p.model, + LatencyMs: latency, + }) + return nil + } + // 发送 STT 结果 if err := sender.SendSTTResult(models.WsSTTResult{ Type: "stt_result", diff --git a/docker-compose.yml b/docker-compose.yml index 009c14a..55ccdcb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,7 +30,7 @@ services: postgres: # 轩辕镜像加速,避免 Docker Hub 拉取超时 - image: docker.xuanyuan.me/library/postgres:15-alpine + image: docker.m.daocloud.io/library/postgres:15-alpine container_name: camtalk-postgres environment: POSTGRES_USER: camtalk diff --git a/frontend/src/App.css b/frontend/src/App.css index 6d1d39c..7264e5b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -590,7 +590,6 @@ body { animation: pulse 1.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite; } -.video-indicator--loading { color: var(--color-warning); } .video-indicator--audio { left: auto; right: 16px; color: var(--color-primary); } .video-indicator--error { color: var(--color-error); animation: none; } @@ -651,26 +650,6 @@ body { animation: pulse 2s cubic-bezier(0.25, 0.46, 0.45, 0.94) infinite; } -.video-overlay { - position: absolute; - inset: 0; - background: rgba(10, 10, 15, 0.4); - display: flex; - align-items: center; - justify-content: center; - backdrop-filter: blur(3px); - -webkit-backdrop-filter: blur(3px); -} - -.video-overlay__spinner { - width: 36px; - height: 36px; - border: 2.5px solid rgba(255, 255, 255, 0.15); - border-top-color: rgba(255, 255, 255, 0.8); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - /* ---- 视频控制栏 ---- */ .video-controls { @@ -998,6 +977,52 @@ body { border: 1px solid rgba(251, 191, 36, 0.15); } +.system-message--info { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: rgba(59, 130, 246, 0.06); + color: var(--color-text-muted); + border: 1px solid rgba(59, 130, 246, 0.12); +} + +/* ---- Typing Indicator(跳动点) ---- */ + +.typing-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 0; +} + +.typing-indicator__dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-text-muted); + animation: typing-bounce 1.2s ease-in-out infinite; +} + +.typing-indicator__dot:nth-child(2) { + animation-delay: 0.2s; +} + +.typing-indicator__dot:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typing-bounce { + 0%, 60%, 100% { + opacity: 0.3; + transform: translateY(0); + } + 30% { + opacity: 1; + transform: translateY(-4px); + } +} + /* ---- Drawer (Config Panel) ---- */ .drawer-overlay { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d0e9473..b82bdca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -306,17 +306,9 @@ function AppContent() { {isAudioPlaying && config.ttsEnabled && (
{tr("video.playing")}
)} - {isConnected && !isVADReady && !vadError && ( -
{tr("video.initVad")}
- )} {vadError && (
⚠️ {vadError}
)} - {isProcessing && ( -
-
-
- )} {!isConnected && !stream && (
@@ -473,6 +465,9 @@ function AppContent() { messages={messages} currentReply={currentReply} connectionStatus={connectionStatus} + isProcessing={isProcessing} + isVADReady={isVADReady} + vadError={vadError} isMicOn={isMicOn} isSpeaking={isSpeaking} onSendText={sendTextMessage} diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 5be520a..f4515c9 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -13,6 +13,9 @@ interface ChatPanelProps { messages: ChatMessage[]; currentReply?: string; connectionStatus: ConnectionStatus; + isProcessing?: boolean; + isVADReady?: boolean; + vadError?: string; isMicOn?: boolean; isSpeaking?: boolean; onSendText?: (text: string) => void; @@ -34,6 +37,9 @@ export function ChatPanel({ messages, currentReply, connectionStatus, + isProcessing, + isVADReady, + vadError, isMicOn, isSpeaking, onSendText, @@ -144,6 +150,44 @@ export function ChatPanel({
)} + {/* 连接中状态 */} + {connectionStatus === "connecting" && ( +
+ + + + + + {t("chat.connecting")} +
+ )} + + {/* VAD 初始化中 */} + {isConnected && isVADReady === false && !vadError && ( +
+ + + + + + {t("chat.vadInit")} +
+ )} + + {/* AI 处理中(尚未开始流式输出) */} + {isProcessing && !currentReply && ( +
+
AI
+
+ + + + + +
+
+ )} +
diff --git a/frontend/src/components/EdgeProcessor/index.tsx b/frontend/src/components/EdgeProcessor/index.tsx index 4fd9b26..b03e3f9 100644 --- a/frontend/src/components/EdgeProcessor/index.tsx +++ b/frontend/src/components/EdgeProcessor/index.tsx @@ -132,7 +132,7 @@ function getOffscreen(): HTMLCanvasElement { export function sampleFrame(video: HTMLVideoElement): Uint8ClampedArray | null { if (video.readyState < 2) return null; const canvas = getOffscreen(); - const ctx = canvas.getContext("2d"); + const ctx = canvas.getContext("2d", { willReadFrequently: true }); if (!ctx) return null; ctx.drawImage(video, 0, 0, DETECT_WIDTH, DETECT_HEIGHT); return ctx.getImageData(0, 0, DETECT_WIDTH, DETECT_HEIGHT).data; diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 0b68bf6..6b474a1 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -15,7 +15,7 @@ import { loadConfig, saveConfig } from "../lib/storage"; import { useI18n } from "../lib/i18n"; import { useCamera } from "../components/CameraManager"; import { useMicrophone } from "../components/MicManager"; -import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor"; +import { useVAD, sampleFrame } from "../components/EdgeProcessor"; import { useWebSocketManager } from "../components/WebSocketManager"; import { useObservationMode } from "./useObservationMode"; import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types"; @@ -194,24 +194,13 @@ export function useVisionSession(accessToken?: string | null) { ttsPlayerRef.current?.stop(); setIsAudioPlaying(false); + // 尝试捕获图像帧(允许为 null,纯语音场景无需画面) const frame = captureFrame(); - if (!frame) { - console.warn("[Session] 无法捕获图像帧"); - return; - } - // 关键帧检测:与上一帧对比,相似度过高则跳过 + // 更新帧参考(供观察模式使用,但不以此阻断语音请求) const video = videoRef.current; if (video) { const currentSample = sampleFrame(video); - if (currentSample && prevFrameRef.current) { - const { similarity } = compareFrames(prevFrameRef.current, currentSample); - if (similarity > 0.9) { - console.log(`[Session] 画面无变化 (similarity=${similarity.toFixed(2)}),跳过`); - prevFrameRef.current = currentSample; - return; - } - } if (currentSample) { prevFrameRef.current = currentSample; } @@ -221,7 +210,7 @@ export function useVisionSession(accessToken?: string | null) { send({ type: "query", request_id: requestId, - image: dataUrlToBase64(frame), + image: frame ? dataUrlToBase64(frame) : "", audio: encodeAudioToBase64(audio), }); @@ -304,6 +293,20 @@ export function useVisionSession(accessToken?: string | null) { case "error": console.error("[Session] 服务端错误:", msg.code, msg.message); showToast(getErrorMessage(msg.code, t), "error"); + // STT 失败时:将"语音识别中..."占位消息替换为失败提示 + if (msg.code === "STT_ERROR") { + setMessages((prev) => { + const updated = [...prev]; + const lastUserIdx = updated.findLastIndex((m) => m.role === "user"); + if (lastUserIdx >= 0 && updated[lastUserIdx].content === t("session.recognizing")) { + updated[lastUserIdx] = { + ...updated[lastUserIdx], + content: t("session.sttFailed"), + }; + } + return updated; + }); + } setIsProcessing(false); break; } diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index 835c5fa..0ac2f10 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -54,6 +54,8 @@ export const enUS: TranslationMap = { "chat.title": "Chat", "chat.mode.observation": "Observing", "chat.reconnecting": "Connection lost, reconnecting...", + "chat.connecting": "Connecting to server...", + "chat.vadInit": "Initializing voice detection...", "chat.empty.prompt": "Type below to start chatting", "chat.empty.hint": "Type to chat with AI, or click the button on the left to start video", "chat.welcome.prompt": "Type below to start chatting", @@ -65,6 +67,7 @@ export const enUS: TranslationMap = { // Session messages "session.changeDetected": "👁️ Scene change detected", "session.recognizing": "(Recognizing speech...)", + "session.sttFailed": "(Speech recognition failed, please try again)", "session.noSpeech": "(No speech detected)", "session.interrupted": "(Interrupted)", diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 74f4764..8eb152f 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -54,6 +54,8 @@ export const jaJP: TranslationMap = { "chat.title": "チャット", "chat.mode.observation": "観察モード", "chat.reconnecting": "接続が切断されました。再接続中...", + "chat.connecting": "サーバーに接続中...", + "chat.vadInit": "音声検出を初期化中...", "chat.empty.prompt": "下にテキストを入力して対話を開始", "chat.empty.hint": "テキストでAIと対話、または左のボタンでビデオ通話を開始", "chat.welcome.prompt": "下にテキストを入力して対話を開始", @@ -65,6 +67,7 @@ export const jaJP: TranslationMap = { // Session messages "session.changeDetected": "👁️ シーン変化を検出", "session.recognizing": "(音声認識中...)", + "session.sttFailed": "(音声認識に失敗しました。もう一度お試しください)", "session.noSpeech": "(音声が検出されませんでした)", "session.interrupted": "(中断済み)", diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index 8fc5716..a851b89 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -54,6 +54,8 @@ export const zhCN: TranslationMap = { "chat.title": "对话", "chat.mode.observation": "观察模式", "chat.reconnecting": "连接已断开,正在重连...", + "chat.connecting": "正在连接服务...", + "chat.vadInit": "正在初始化语音检测...", "chat.empty.prompt": "在下方输入文字开始对话", "chat.empty.hint": "输入文字即可与 AI 交互,也可点击左侧按钮开启视频", "chat.welcome.prompt": "在下方输入文字开始对话", @@ -65,6 +67,7 @@ export const zhCN: TranslationMap = { // Session messages "session.changeDetected": "👁️ 画面变化检测", "session.recognizing": "(语音识别中...)", + "session.sttFailed": "(语音识别失败,请重试)", "session.noSpeech": "(未识别到语音)", "session.interrupted": "(已打断)",