diff --git a/frontend/src/App.css b/frontend/src/App.css index cc45dce..bdbe21e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -53,6 +53,26 @@ body { font-weight: 700; } +.header-right { + display: flex; + align-items: center; + gap: 8px; +} + +.btn-icon { + background: none; + border: none; + font-size: 1.2rem; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background 0.2s; +} + +.btn-icon:hover { + background: var(--color-surface); +} + .status { font-size: 0.85rem; padding: 4px 12px; @@ -364,3 +384,67 @@ body { @keyframes spin { to { transform: rotate(360deg); } } + +/* ---- Config Panel ---- */ + +.config-panel { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: 16px; + margin: 8px 0; +} + +.config-panel__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + font-weight: 600; +} + +.config-panel__close { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 1rem; + padding: 2px 6px; +} + +.config-panel__close:hover { + color: var(--color-text); +} + +.config-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + font-size: 0.9rem; +} + +.config-row select, +.config-row input[type="checkbox"] { + background: var(--color-bg); + color: var(--color-text); + border: 1px solid var(--color-border); + border-radius: 4px; + padding: 4px 8px; + font-size: 0.85rem; +} + +/* ---- Detail Badge ---- */ + +.detail-badge { + position: absolute; + top: 8px; + right: 8px; + background: rgba(37, 99, 235, 0.8); + color: white; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.5px; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dd55531..0fcb64d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,13 +2,17 @@ // CamTalk — 主应用组件 // ============================================================ +import { useState } from "react"; import { useVisionSession } from "./hooks/useVisionSession"; import { VideoPreview } from "./components/VideoPreview"; import { ChatPanel } from "./components/ChatPanel"; +import { ConfigPanel } from "./components/ConfigPanel"; import { ToastContainer } from "./components/Toast"; import "./App.css"; function App() { + const [showConfig, setShowConfig] = useState(false); + const { messages, currentReply, @@ -20,6 +24,8 @@ function App() { connectionStatus, videoRef, stream, + config, + updateConfig, startSession, stopSession, interrupt, @@ -31,20 +37,44 @@ function App() {

CamTalk

- - {isConnected - ? "已连接" - : connectionStatus === "connecting" - ? "连接中..." - : "未连接"} - +
+ + {isConnected + ? "已连接" + : connectionStatus === "connecting" + ? "连接中..." + : "未连接"} + + {isConnected && ( + + )} +
+ {showConfig && ( + setShowConfig(false)} + /> + )} +
+ {config.detailLevel === "high" && ( +
HD
+ )} {isSpeaking &&
🎤 正在聆听...
} - {isAudioPlaying &&
🔊 正在播放...
} + {isAudioPlaying && config.ttsEnabled && ( +
🔊 正在播放...
+ )} {isConnected && !isVADReady && !vadError && (
正在初始化语音检测... diff --git a/frontend/src/components/ConfigPanel/index.tsx b/frontend/src/components/ConfigPanel/index.tsx new file mode 100644 index 0000000..a56306f --- /dev/null +++ b/frontend/src/components/ConfigPanel/index.tsx @@ -0,0 +1,55 @@ +// ============================================================ +// ConfigPanel — 会话配置面板 +// 职责:TTS 开关、detail level 切换、语言选择 +// ============================================================ + +import type { SessionConfig } from "../../types"; + +interface ConfigPanelProps { + config: SessionConfig; + onUpdate: (partial: Partial) => void; + onClose: () => void; +} + +export function ConfigPanel({ config, onUpdate, onClose }: ConfigPanelProps) { + return ( +
+
+ 设置 + +
+ + + + + + +
+ ); +} diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index dfbb554..9217c55 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -11,17 +11,24 @@ import { encodeAudioToBase64, dataUrlToBase64 } from "../lib/audio"; import { getErrorMessage } from "../lib/errors"; import { TTSPlayer } from "../lib/ttsPlayer"; import { showToast } from "../lib/toast"; +import { loadConfig, saveConfig } from "../lib/storage"; import { useCamera } from "../components/CameraManager"; import { useMicrophone } from "../components/MicManager"; import { useVAD } from "../components/EdgeProcessor"; import { useWebSocketManager } from "../components/WebSocketManager"; -import type { ChatMessage, ServerMessage, LLMDoneMessage } from "../types"; +import type { ChatMessage, SessionConfig, ServerMessage, LLMDoneMessage } from "../types"; + +const MAX_HISTORY_ROUNDS = 10; export function useVisionSession() { const [messages, setMessages] = useState([]); const [currentReply, setCurrentReply] = useState(""); const [isProcessing, setIsProcessing] = useState(false); const [isAudioPlaying, setIsAudioPlaying] = useState(false); + const [config, setConfig] = useState(loadConfig); + + // 对话历史(role + content),用于多轮上下文 + const historyRef = useRef>([]); // TTS 播放器 const ttsPlayerRef = useRef(null); @@ -44,6 +51,40 @@ export function useVisionSession() { isProcessingRef.current = isProcessing; }, [isProcessing]); + // WebSocket 连接成功后发送 config + useEffect(() => { + if (status === "connected") { + send({ + type: "config", + payload: { + tts_enabled: config.ttsEnabled, + detail_level: config.detailLevel, + language: config.language, + }, + }); + } + }, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送 + + /** 更新会话配置 */ + const updateConfig = useCallback((partial: Partial) => { + setConfig((prev) => { + const next = { ...prev, ...partial }; + saveConfig(next); + // 如果已连接,立即发送更新 + if (status === "connected") { + send({ + type: "config", + payload: { + tts_enabled: next.ttsEnabled, + detail_level: next.detailLevel, + language: next.language, + }, + }); + } + return next; + }); + }, [status, send]); + // VAD:语音结束时自动发送 query const { isSpeaking, @@ -111,6 +152,13 @@ export function useVisionSession() { case "llm_done": { const done = msg as LLMDoneMessage; + // 记录到对话历史 + historyRef.current.push({ role: "assistant", content: done.full_text }); + // 裁剪历史到最近 N 轮 + if (historyRef.current.length > MAX_HISTORY_ROUNDS * 2) { + historyRef.current = historyRef.current.slice(-MAX_HISTORY_ROUNDS * 2); + } + setMessages((prev) => [ ...prev, { @@ -145,14 +193,6 @@ export function useVisionSession() { return unsub; }, [getTTSPlayer]); - // 连接断开时显示提示 - useEffect(() => { - if (status === "disconnected") { - // 只在非主动断开时提示(通过检查是否有活跃会话判断) - // 这里简单处理,由 App 层根据状态显示 - } - }, [status]); - /** 启动会话 */ const startSession = useCallback(async () => { // 1. 获取摄像头和麦克风 @@ -182,6 +222,7 @@ export function useVisionSession() { setMessages([]); setCurrentReply(""); setIsProcessing(false); + historyRef.current = []; }, [stopVAD, stopMic, stopCamera, disconnect]); /** 打断当前回复 */ @@ -192,9 +233,11 @@ export function useVisionSession() { setIsAudioPlaying(false); // 将未完成的流式内容保存为最终消息 if (currentReply) { + const interrupted = currentReply + "(已打断)"; + historyRef.current.push({ role: "assistant", content: interrupted }); setMessages((prev) => [ ...prev, - { role: "assistant", content: currentReply + "(已打断)", timestamp: Date.now() }, + { role: "assistant", content: interrupted, timestamp: Date.now() }, ]); } setCurrentReply(""); @@ -212,6 +255,8 @@ export function useVisionSession() { connectionStatus: status, videoRef, stream, + config, + updateConfig, startSession, stopSession, interrupt, diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..34fc369 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,35 @@ +// ============================================================ +// Storage — localStorage 封装 +// 职责:会话配置持久化 +// ============================================================ + +import type { SessionConfig } from "../types"; + +const CONFIG_KEY = "camtalk:config"; + +const DEFAULT_CONFIG: SessionConfig = { + ttsEnabled: true, + detailLevel: "low", + language: "zh-CN", +}; + +/** 加载配置,无存储时返回默认值 */ +export function loadConfig(): SessionConfig { + try { + const raw = localStorage.getItem(CONFIG_KEY); + if (!raw) return DEFAULT_CONFIG; + const parsed = JSON.parse(raw) as Partial; + return { ...DEFAULT_CONFIG, ...parsed }; + } catch { + return DEFAULT_CONFIG; + } +} + +/** 保存配置到 localStorage */ +export function saveConfig(config: SessionConfig): void { + try { + localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + } catch { + // localStorage 不可用时静默失败 + } +}