// ============================================================ // CamTalk — 主应用组件(Web 端两栏布局:视频 + 聊天) // 侧边栏为 overlay 抽屉式,不挤压主界面空间 // ============================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { useVisionSession } from "./hooks/useVisionSession"; import { useSessionList } from "./hooks/useSessionList"; import { useScenarios } from "./hooks/useScenarios"; import { VideoPreview } from "./components/VideoPreview"; import { ChatPanel } from "./components/ChatPanel"; import { ConfigPanel } from "./components/ConfigPanel"; import { SessionSidebar } from "./components/SessionSidebar"; import { ToastContainer } from "./components/Toast"; import { LandingPage } from "./components/LandingPage"; import { CreateScenarioModal } from "./components/CreateScenarioModal"; import { EditScenarioModal } from "./components/EditScenarioModal"; import { AuthProvider, useAuth } from "./lib/auth"; import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; import { I18nContext, parseLocale, t } from "./lib/i18n"; import { renderScenarioIconById } from "./lib/scenarioIcons"; import type { Locale } from "./lib/i18n"; import type { Theme } from "./types"; import type { UserScenario } from "./lib/api/scenarios"; import "./App.css"; // ---- 统一 SVG 图标系统(Feather-style,stroke-based) ---- const SVG_PROPS = { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; /** 系统情景 SVG 图标映射 */ function scenarioSvgIcon(id: string) { switch (id) { case "free_chat": return (); case "interviewer": return (); case "english_teacher": return (); case "debate": return (); case "interpreter": return (); default: return null; } } /** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */ function AppContent() { const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth(); const [showConfig, setShowConfig] = useState(false); const [showCreateScenario, setShowCreateScenario] = useState(false); const [editingScenario, setEditingScenario] = useState(null); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false); const timerRef = useRef | null>(null); // ---- 自建情景管理 ---- const { allScenarios, createScenario, updateScenario, deleteScenario, } = useScenarios(accessToken); // 切换主题时更新 的 data-theme 属性 useEffect(() => { document.documentElement.setAttribute("data-theme", theme); }, [theme]); const handleThemeChange = useCallback((t: Theme) => { setTheme(t); saveTheme(t); }, []); const formatTime = (s: number) => { const m = Math.floor(s / 60); const sec = s % 60; return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; }; // ---- 会话列表 ---- const { sessions, activeSessionId, isLoading: sessionsLoading, createSession, deleteSession, renameSession, selectSession, loadSessions, } = useSessionList(accessToken); // ---- 视觉会话 ---- const { messages, setMessages, currentReply, isProcessing, isAudioPlaying, isSpeaking, isVADReady, vadError, connectionStatus, videoRef, stream, config, updateConfig, stats, startSession, stopSession, stopVideo, interrupt, isCameraOn, isMicOn, toggleCamera, toggleMic, sendTextMessage, cameras, mics, switchDevice, } = useVisionSession(accessToken, activeSessionId); const isConnected = connectionStatus === "connected"; // 连接计时器 const elapsedRef = useRef(0); useEffect(() => { if (isConnected) { elapsedRef.current = 0; setElapsed(0); // eslint-disable-line react-hooks/set-state-in-effect -- 连接时重置计时器 } }, [isConnected]); useEffect(() => { if (!isConnected) { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } return; } const id = setInterval(() => { elapsedRef.current += 1; setElapsed(elapsedRef.current); }, 1000); timerRef.current = id; return () => clearInterval(id); }, [isConnected]); const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]); // ---- 初始化:加载完成后如果没有会话,创建一个 ---- const initializedRef = useRef(false); useEffect(() => { if (!sessionsLoading && !initializedRef.current) { initializedRef.current = true; if (sessions.length === 0) { createSession(); } } }, [sessionsLoading, sessions.length, createSession]); // ---- 侧边栏打开时刷新会话列表 ---- useEffect(() => { if (sidebarOpen) { loadSessions(); } }, [sidebarOpen, loadSessions]); // ---- 侧边栏操作 ---- const handleNewSession = useCallback(async () => { // 如果已连接,先断开 if (connectionStatus === "connected") { await stopSession(); } // 通过后端 API 创建会话 await createSession(); setMessages([]); setSidebarOpen(false); }, [createSession, setMessages, connectionStatus, stopSession]); const handleSelectSession = useCallback(async (id: string) => { // 如果已连接,先断开 if (connectionStatus === "connected") { await stopSession(); } // 从后端 API 加载目标会话的消息 const loaded = await selectSession(id); setMessages(loaded); setSidebarOpen(false); }, [connectionStatus, stopSession, selectSession, setMessages]); const handleDeleteSession = useCallback(async (id: string) => { await deleteSession(id); if (id === activeSessionId) { // 断开连接并清空消息 if (connectionStatus === "connected") { await stopSession(); } setMessages([]); } }, [deleteSession, activeSessionId, setMessages, connectionStatus, stopSession]); // ---- 识别画面 ---- const handleRecognize = useCallback(() => { if (isProcessing) return; sendTextMessage(tr("controls.recognize")); }, [isProcessing, sendTextMessage, tr]); // ---- 场景卡片点击 ---- const handleSceneCard = useCallback((prompt: string) => { sendTextMessage(prompt); }, [sendTextMessage]); // ---- 情景选择 ---- const handleSelectScenario = useCallback((scenarioId: string) => { const sc = allScenarios.find((s) => s.id === scenarioId); const updates: Partial = { scenario: scenarioId }; // 如果情景有默认语言,同步切换(仅系统预置情景) if (sc && !sc.isCustom) { const systemScenario = sc as any; if (systemScenario.defaultLanguage) { updates.language = systemScenario.defaultLanguage; } } updateConfig(updates); // 插入系统提示消息 const scenarioName = sc ? `${sc.icon} ${sc.nameKey ? tr(sc.nameKey) : sc.name}` : scenarioId; setMessages(prev => [...prev, { id: uuidv4(), role: "system", content: tr("chat.scenarioSwitched").replace("{name}", scenarioName), timestamp: Date.now(), }]); }, [updateConfig, setMessages, tr]); // 当前情景对象 const activeScenario = allScenarios.find((s) => s.id === (config.scenario || "free_chat")) || allScenarios[0]; // ---- 键盘快捷键 ---- useEffect(() => { const handler = (e: KeyboardEvent) => { // Cmd/Ctrl+H: 打开/关闭历史侧边栏 if ((e.metaKey || e.ctrlKey) && e.key === "h") { e.preventDefault(); setSidebarOpen((v) => !v); } // Escape: 关闭侧边栏 if (e.key === "Escape" && sidebarOpen) { setSidebarOpen(false); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [sidebarOpen]); // Auth guard:未登录时显示登录页(放在所有 hooks 之后以遵守 Rules of Hooks) if (isLoading) { return (

Loading...

); } if (!isAuthenticated) { return ; } return (
{/* ---- 顶部导航栏 ---- */}
{/* 侧边栏开关 */}

CamTalk

{tr("app.title")}
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
{/* ---- 会话历史侧边栏(Overlay 抽屉式) ---- */} setSidebarOpen((v) => !v)} onNewSession={handleNewSession} onSelectSession={handleSelectSession} onDeleteSession={handleDeleteSession} onRenameSession={renameSession} /> {showConfig && ( setShowConfig(false)} onCreateScenario={() => { setShowConfig(false); setShowCreateScenario(true); }} onEditScenario={(scenario) => { setShowConfig(false); setEditingScenario(scenario); }} onDeleteScenario={deleteScenario} /> )} {/* 创建情景 Modal */} {showCreateScenario && ( setShowCreateScenario(false)} onSubmit={async (data) => { await createScenario(data); }} /> )} {/* 编辑情景 Modal */} {editingScenario && ( setEditingScenario(null)} onSubmit={async (id, data) => { await updateScenario(id, data); }} /> )} {/* ---- 主体:视频 + 聊天(两栏) ---- */}
{/* 左侧:视频预览 + 控制栏 */}
{isConnected && (
LIVE {formatTime(elapsed)}
)} {config.detailLevel === "high" && (
HD
)} {/* AI 视觉状态指示 */} {isConnected && (
AI
)} {isSpeaking && (
{tr("video.listening")}
)} {isAudioPlaying && config.ttsEnabled && (
{tr("video.playing")}
)} {vadError && (
{vadError}
)} {!isConnected && !stream && (
{tr("video.placeholder")}
)} {isConnected && !isCameraOn && (
{tr("video.cameraOff")} {tr("video.cameraOff.hint")}
)}
{/* ---- 情景选择芯片条(视频下方) ---- */}
{tr("settings.scenario")}
{allScenarios.map((sc) => ( ))} {/* 新建情景快捷入口 */}
{/* 视频下方控制区(三层结构) */}
{!isConnected ? ( <> {/* 未连接态:主 CTA */} ) : isCameraOn ? ( <> {/* 视频通话态:核心控制工具栏 */}
{isProcessing && ( )}
{/* 设备选择器 */}
) : ( <> {/* 文字对话态:视频已结束 */}
{tr("video.ended")} {tr("video.ended.hint")}
)}
{/* 右侧:聊天面板 */}
{tr("chat.title")}
{isConnected && stats.queryCount > 0 && ( {stats.queryCount} {tr("statusbar.recognitions")} {stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`} {` · ${formatTime(elapsed)}`} )}
{connectionStatus === "disconnected" && messages.length > 0 && (
{tr("chat.reconnecting")}
)}
); } function App() { const [locale, setLocale] = useState(() => parseLocale(loadConfig().language)); const i18nValue = useMemo(() => ({ locale, t: (key: string) => t(key, locale), }), [locale]); // 监听配置变化以更新 locale useEffect(() => { const handler = () => { const cfg = loadConfig(); setLocale(parseLocale(cfg.language)); }; // 自定义事件,由 saveConfig 触发 window.addEventListener("camtalk-config-changed", handler); return () => window.removeEventListener("camtalk-config-changed", handler); }, []); return ( ); } export default App;