diff --git a/frontend/src/App.css b/frontend/src/App.css index cc89700..4cf2215 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -119,10 +119,10 @@ body { .chat-section { flex: 1; - overflow-y: auto; display: flex; flex-direction: column; gap: 12px; + overflow: hidden; } /* ---- Video Preview ---- */ @@ -157,6 +157,8 @@ body { display: flex; flex-direction: column; gap: 12px; + overflow-y: auto; + flex: 1; } .chat-panel--empty { @@ -248,3 +250,111 @@ body { .btn--warning:hover { opacity: 0.9; } + +/* ---- Streaming Cursor ---- */ + +.cursor { + display: inline; + animation: blink 0.8s step-end infinite; + color: var(--color-success); +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* ---- Message Meta ---- */ + +.chat-message__meta { + margin-top: 6px; + font-size: 0.75rem; + color: var(--color-text-muted); +} + +/* ---- System Message ---- */ + +.system-message { + text-align: center; + padding: 8px 16px; + border-radius: var(--radius); + font-size: 0.85rem; +} + +.system-message--warning { + background: rgba(245, 158, 11, 0.15); + color: var(--color-warning); + border: 1px solid rgba(245, 158, 11, 0.3); +} + +/* ---- Toast ---- */ + +.toast-container { + position: fixed; + top: 16px; + right: 16px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; +} + +.toast { + padding: 10px 16px; + border-radius: var(--radius); + font-size: 0.85rem; + cursor: pointer; + animation: slideIn 0.3s ease-out; + max-width: 320px; +} + +.toast--error { + background: rgba(239, 68, 68, 0.9); + color: white; +} + +.toast--warning { + background: rgba(245, 158, 11, 0.9); + color: #000; +} + +.toast--info { + background: rgba(37, 99, 235, 0.9); + color: white; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +/* ---- Video Overlay ---- */ + +.video-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius); +} + +.video-overlay__spinner { + width: 32px; + height: 32px; + border: 3px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 13174ec..9bdb7b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import { useVisionSession } from "./hooks/useVisionSession"; import { VideoPreview } from "./components/VideoPreview"; import { ChatPanel } from "./components/ChatPanel"; +import { ToastContainer } from "./components/Toast"; import "./App.css"; function App() { @@ -52,23 +53,31 @@ function App() { ⚠️ {vadError} )} + {isProcessing && ( +
+
+
+ )}
- - {currentReply && ( -
-
AI
-
{currentReply}
+ {connectionStatus === "disconnected" && messages.length > 0 && ( +
+ 连接已断开,正在重连...
)} +
{!isConnected ? ( ) : ( <> @@ -83,6 +92,8 @@ function App() { )}
+ +
); } diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 0db5641..bed57de 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -1,33 +1,90 @@ // ============================================================ // ChatPanel — 消息展示面板 -// 职责:渲染对话消息列表(用户提问 + AI 回复) +// 职责:渲染对话消息列表、流式光标、元数据、自动滚动 // ============================================================ +import { useEffect, useRef } from "react"; import type { ChatMessage } from "../../types"; +import type { ConnectionStatus } from "../../lib/websocket"; interface ChatPanelProps { messages: ChatMessage[]; + currentReply?: string; + connectionStatus: ConnectionStatus; } -export function ChatPanel({ messages }: ChatPanelProps) { - if (messages.length === 0) { +export function ChatPanel({ messages, currentReply, connectionStatus }: ChatPanelProps) { + const bottomRef = useRef(null); + const containerRef = useRef(null); + const isAutoScroll = useRef(true); + + // 用户上滚时暂停自动滚动,滚到底部时恢复 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = container; + isAutoScroll.current = scrollHeight - scrollTop - clientHeight < 60; + }; + + container.addEventListener("scroll", handleScroll); + return () => container.removeEventListener("scroll", handleScroll); + }, []); + + // 新消息或流式更新时自动滚动 + useEffect(() => { + if (isAutoScroll.current) { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [messages, currentReply]); + + // 空状态 + if (messages.length === 0 && !currentReply) { + if (connectionStatus !== "connected") { + return ( +
+

点击下方按钮开始对话

+
+ ); + } return (
-

开始对话:对着摄像头说话即可

+

对着摄像头说话即可

); } return ( -
+
{messages.map((msg, index) => (
{msg.role === "user" ? "你" : "AI"}
{msg.content}
+ {msg.role === "assistant" && msg.tokensUsed !== undefined && ( +
+ {msg.tokensUsed} tokens + {msg.latencyMs !== undefined && ` · ${(msg.latencyMs / 1000).toFixed(1)}s`} + {msg.model && ` · ${msg.model}`} +
+ )}
))} + + {/* 流式回复(尚未完成) */} + {currentReply && ( +
+
AI
+
+ {currentReply} + +
+
+ )} + +
); } diff --git a/frontend/src/components/Toast/index.tsx b/frontend/src/components/Toast/index.tsx new file mode 100644 index 0000000..e4f371b --- /dev/null +++ b/frontend/src/components/Toast/index.tsx @@ -0,0 +1,51 @@ +// ============================================================ +// Toast — 轻量通知组件 +// 职责:3 秒自动消失的通知提示 +// ============================================================ + +import { useCallback, useEffect, useState } from "react"; +import { registerToastSetter, type ToastItem } from "../../lib/toast"; + +export type { ToastItem }; + +/** Toast 容器组件,放在 App 根部 */ +export function ToastContainer() { + const [toasts, setToasts] = useState([]); + + // 注册全局 setter + useEffect(() => { + registerToastSetter(setToasts); + return () => registerToastSetter(null); + }, []); + + const dismiss = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( +
+ {toasts.map((t) => ( + + ))} +
+ ); +} + +function ToastItemView({ + item, + onDismiss, +}: { + item: ToastItem; + onDismiss: (id: number) => void; +}) { + useEffect(() => { + const timer = setTimeout(() => onDismiss(item.id), 3000); + return () => clearTimeout(timer); + }, [item.id, onDismiss]); + + return ( +
onDismiss(item.id)}> + {item.message} +
+ ); +} diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index e53b91f..a9112f6 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -4,10 +4,12 @@ // 来源:docs/02-系统架构.md 核心 Hook 设计 // ============================================================ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { wsClient } from "../lib/websocket"; import { encodeAudioToBase64, dataUrlToBase64 } from "../lib/audio"; +import { getErrorMessage } from "../lib/errors"; +import { showToast } from "../lib/toast"; import { useCamera } from "../components/CameraManager"; import { useMicrophone } from "../components/MicManager"; import { useVAD } from "../components/EdgeProcessor"; @@ -23,6 +25,12 @@ export function useVisionSession() { const { startMic, stopMic } = useMicrophone(); const { status, connect, disconnect, send } = useWebSocketManager(); + // 用 ref 跟踪 isProcessing,避免 VAD 回调闭包问题 + const isProcessingRef = useRef(false); + useEffect(() => { + isProcessingRef.current = isProcessing; + }, [isProcessing]); + // VAD:语音结束时自动发送 query const { isSpeaking, @@ -33,6 +41,12 @@ export function useVisionSession() { } = useVAD({ onSpeechEnd: useCallback( (audio: Float32Array) => { + // 处理中忽略,防止重复发送 + if (isProcessingRef.current) { + console.warn("[Session] 正在处理中,忽略语音输入"); + return; + } + const frame = captureFrame(); if (!frame) { console.warn("[Session] 无法捕获图像帧"); @@ -47,7 +61,7 @@ export function useVisionSession() { audio: encodeAudioToBase64(audio), }); - // 添加用户消息(STT 结果到达后会更新文本) + // 添加用户消息(STT 流式结果会逐步更新文本) setMessages((prev) => [ ...prev, { role: "user", content: "(语音识别中...)", timestamp: Date.now() }, @@ -62,43 +76,51 @@ export function useVisionSession() { useEffect(() => { const unsub = wsClient.onMessage((msg: ServerMessage) => { switch (msg.type) { - case "stt_result": - if (msg.is_final) { - setMessages((prev) => { - const updated = [...prev]; - const lastUserIdx = updated.findLastIndex((m) => m.role === "user"); - if (lastUserIdx >= 0) { - updated[lastUserIdx] = { ...updated[lastUserIdx], content: msg.text }; - } - return updated; - }); - } + case "stt_result": { + // 流式更新用户消息文本(包括中间结果和最终结果) + setMessages((prev) => { + const updated = [...prev]; + const lastUserIdx = updated.findLastIndex((m) => m.role === "user"); + if (lastUserIdx >= 0) { + updated[lastUserIdx] = { + ...updated[lastUserIdx], + content: msg.text || "(未识别到语音)", + }; + } + return updated; + }); break; + } case "llm_chunk": setCurrentReply((prev) => prev + msg.delta); break; - case "llm_done": + case "llm_done": { + const done = msg as LLMDoneMessage; setMessages((prev) => [ ...prev, { role: "assistant", - content: (msg as LLMDoneMessage).full_text, + content: done.full_text, timestamp: Date.now(), - tokensUsed: (msg as LLMDoneMessage).tokens_used?.total, + tokensUsed: done.tokens_used?.total, + latencyMs: done.latency_ms, + model: done.model, }, ]); setCurrentReply(""); setIsProcessing(false); break; + } case "tts_audio": - // TODO: 音频流播放 + // TODO: 阶段 5 音频流播放 break; case "error": console.error("[Session] 服务端错误:", msg.code, msg.message); + showToast(getErrorMessage(msg.code), "error"); setIsProcessing(false); break; } @@ -107,13 +129,21 @@ export function useVisionSession() { return unsub; }, []); + // 连接断开时显示提示 + useEffect(() => { + if (status === "disconnected") { + // 只在非主动断开时提示(通过检查是否有活跃会话判断) + // 这里简单处理,由 App 层根据状态显示 + } + }, [status]); + /** 启动会话 */ const startSession = useCallback(async () => { // 1. 获取摄像头和麦克风 await startCamera(); const micStream = await startMic(); if (!micStream) { - console.error("[Session] 无法获取麦克风"); + showToast("无法获取麦克风权限", "error"); return; } @@ -130,13 +160,25 @@ export function useVisionSession() { stopMic(); stopCamera(); disconnect(); + // 清理所有对话状态 + setMessages([]); + setCurrentReply(""); + setIsProcessing(false); }, [stopVAD, stopMic, stopCamera, disconnect]); /** 打断当前回复 */ const interrupt = useCallback(() => { send({ type: "interrupt" }); + // 将未完成的流式内容保存为最终消息 + if (currentReply) { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: currentReply + "(已打断)", timestamp: Date.now() }, + ]); + } + setCurrentReply(""); setIsProcessing(false); - }, [send]); + }, [send, currentReply]); return { messages, diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts new file mode 100644 index 0000000..9aa56e2 --- /dev/null +++ b/frontend/src/lib/errors.ts @@ -0,0 +1,24 @@ +// ============================================================ +// 错误码 → 用户友好文案映射 +// 来源:docs/03-接口文档.md §五 错误码 +// ============================================================ + +import type { ErrorCode } from "../types"; + +const ERROR_MESSAGES: Record = { + INVALID_MESSAGE: "消息格式异常,请重试", + SESSION_NOT_FOUND: "会话已过期,请重新连接", + RATE_LIMITED: "请求太频繁,请稍后再试", + IMAGE_TOO_LARGE: "图像过大,请降低分辨率", + AUDIO_TOO_SHORT: "语音太短,请再说一句", + LLM_TIMEOUT: "AI 响应超时,请重试", + LLM_ERROR: "AI 服务异常,请稍后重试", + STT_ERROR: "语音识别失败,请重试", + TTS_ERROR: "语音合成失败", + INTERNAL_ERROR: "服务内部错误,请重试", +}; + +/** 将错误码转为用户友好文案 */ +export function getErrorMessage(code: string): string { + return ERROR_MESSAGES[code as ErrorCode] ?? `未知错误: ${code}`; +} diff --git a/frontend/src/lib/toast.ts b/frontend/src/lib/toast.ts new file mode 100644 index 0000000..3074e90 --- /dev/null +++ b/frontend/src/lib/toast.ts @@ -0,0 +1,29 @@ +// ============================================================ +// Toast 全局状态管理 +// 与 Toast 组件配合使用 +// ============================================================ + +export type ToastType = "error" | "warning" | "info"; + +let nextId = 0; +let _setToasts: React.Dispatch> | null = null; + +export interface ToastItem { + id: number; + type: ToastType; + message: string; +} + +/** 注册 Toast state setter(由 ToastContainer 组件调用) */ +export function registerToastSetter( + setter: React.Dispatch> | null, +) { + _setToasts = setter; +} + +/** 显示一条 Toast */ +export function showToast(message: string, type: ToastType = "info") { + if (!_setToasts) return; + const id = nextId++; + _setToasts((prev) => [...prev, { id, type, message }]); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 2755ae3..4cad08a 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -25,6 +25,8 @@ export interface ChatMessage { imageUrl?: string; timestamp: number; tokensUsed?: number; + latencyMs?: number; + model?: string; } // ---- WebSocket 通用信封 ----