// ============================================================ // CamTalk — 主应用组件(Web 端两栏布局:视频 + 聊天) // 侧边栏为 overlay 抽屉式,不挤压主界面空间 // ============================================================ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVisionSession } from "./hooks/useVisionSession"; import { useSessionList } from "./hooks/useSessionList"; 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 { AuthPage } from "./components/AuthPage"; import { AuthProvider, useAuth } from "./lib/auth"; import { loadConfig, loadTheme, saveTheme } from "./lib/storage"; import { I18nContext, parseLocale, t } from "./lib/i18n"; import type { Locale } from "./lib/i18n"; import type { Theme } from "./types"; import "./App.css"; /** AI 视觉模式 */ type VisionMode = "realtime" | "ondemand" | "chat"; /** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */ function AppContent() { const { isAuthenticated, isLoading, user, logout, accessToken } = useAuth(); const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); const [sidebarOpen, setSidebarOpen] = useState(false); const [visionMode, setVisionMode] = useState("ondemand"); const timerRef = useRef | null>(null); // 切换主题时更新 的 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, createSession, deleteSession, renameSession, persistSession, selectSession, } = useSessionList(); // ---- 视觉会话 ---- const { messages, setMessages, currentReply, isProcessing, isAudioPlaying, isSpeaking, isVADReady, vadError, connectionStatus, videoRef, stream, config, updateConfig, stats, mode, isObserving, startSession, stopSession, interrupt, isCameraOn, isMicOn, toggleCamera, toggleMic, sendTextMessage, } = useVisionSession(accessToken); 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 (!initializedRef.current) { initializedRef.current = true; if (sessions.length === 0) { createSession(); } } }, [sessions.length, createSession]); // ---- 自动保存:messages 变化时持久化到当前会话 ---- const messagesRef = useRef(messages); useEffect(() => { messagesRef.current = messages; }, [messages]); useEffect(() => { if (activeSessionId && messages.length > 0) { persistSession(activeSessionId, messages); } }, [messages, activeSessionId, persistSession]); // ---- 侧边栏操作 ---- const handleNewSession = useCallback(() => { createSession(); setMessages([]); // 如果已连接,断开 if (connectionStatus === "connected") { stopSession(); } setSidebarOpen(false); }, [createSession, setMessages, connectionStatus, stopSession]); const handleSelectSession = useCallback((id: string) => { // 保存当前会话 if (activeSessionId && messagesRef.current.length > 0) { persistSession(activeSessionId, messagesRef.current); } // 如果已连接,断开 if (connectionStatus === "connected") { stopSession(); } // 加载目标会话 const loaded = selectSession(id); setMessages(loaded); }, [activeSessionId, persistSession, connectionStatus, stopSession, selectSession, setMessages]); const handleDeleteSession = useCallback((id: string) => { deleteSession(id); if (id === activeSessionId) { setMessages([]); } }, [deleteSession, activeSessionId, setMessages]); // ---- 识别画面 ---- const handleRecognize = useCallback(() => { if (isProcessing) return; sendTextMessage(tr("controls.recognize")); }, [isProcessing, sendTextMessage, tr]); // ---- 场景卡片点击 ---- const handleSceneCard = useCallback((prompt: string) => { sendTextMessage(prompt); }, [sendTextMessage]); // ---- 键盘快捷键 ---- 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)} /> )} {/* ---- 主体:视频 + 聊天(两栏) ---- */}
{/* 左侧:视频预览 + 控制栏 */}
{isConnected && (
LIVE {formatTime(elapsed)}
)} {config.detailLevel === "high" && (
HD
)} {/* AI 视觉状态指示 */} {isConnected && visionMode !== "chat" && (
{isObserving ? tr("video.observing") : "AI"}
)} {isSpeaking && (
{tr("video.listening")}
)} {isAudioPlaying && config.ttsEnabled && (
{tr("video.playing")}
)} {isConnected && !isVADReady && !vadError && (
{tr("video.initVad")}
)} {vadError && (
⚠️ {vadError}
)} {isProcessing && (
)} {!isConnected && !stream && (
{tr("video.placeholder")}
)} {isConnected && !isCameraOn && (
{tr("video.cameraOff")} {tr("video.cameraOff.hint")}
)}
{/* 视频下方控制区(三层结构) */}
{!isConnected ? ( <> {/* 未连接态:主 CTA */} {/* 设备选择器 */}
{/* 模式切换器 */}
) : ( <> {/* 通话态:核心控制工具栏 */}
{/* 识别画面按钮(按需模式下显示) */} {visionMode === "ondemand" && ( )} {isProcessing && ( )}
{/* 通话态模式切换 */}
)}
{/* 右侧:聊天面板 */}
{tr("chat.title")}
{isConnected && mode === "observation" && ( {tr("chat.mode.observation")} )} {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;