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() {
+ {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 不可用时静默失败
+ }
+}