// ============================================================ // CamTalk — 主应用组件(Web 端双栏布局) // ============================================================ import { useCallback, useEffect, useMemo, useRef, 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 { 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"; /** 内部组件,确保在 I18nContext.Provider 内部使用 useVisionSession */ function AppContent() { const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); 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 { messages, currentReply, isProcessing, isAudioPlaying, isSpeaking, isVADReady, vadError, connectionStatus, videoRef, stream, config, updateConfig, stats, mode, isObserving, toggleMode, startSession, stopSession, interrupt, isCameraOn, isMicOn, toggleCamera, toggleMic, sendTextMessage, } = useVisionSession(); 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]); return (
{/* ---- 顶部导航栏 ---- */}

CamTalk

{tr("app.title")}
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && ( {stats.queryCount} {tr("app.stats.requests")} · {stats.totalTokens} tokens )} {isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
{showConfig && ( setShowConfig(false)} /> )} {/* ---- 主体:左侧视频 + 右侧聊天 ---- */}
{/* 左侧:视频预览 + 控制栏 */}
{isConnected && (
LIVE {formatTime(elapsed)}
)} {config.detailLevel === "high" && (
HD
)} {isObserving && (
{tr("video.observing")}
)} {isSpeaking && (
{tr("video.listening")}
)} {isAudioPlaying && config.ttsEnabled && (
{tr("video.playing")}
)} {isConnected && !isVADReady && !vadError && (
{tr("video.initVad")}
)} {vadError && (
⚠️ {vadError}
)} {isProcessing && (
)} {!isConnected && !stream && (
{tr("video.clickToStart")}
)} {isConnected && !isCameraOn && (
{tr("video.cameraOff")} {tr("video.cameraOff.hint")}
)}
{/* 视频下方控制栏 */}
{!isConnected ? ( ) : (
{isProcessing && ( )}
)}
{/* 右侧:聊天面板 */}
{tr("chat.title")} {isConnected && mode === "observation" && ( {tr("chat.mode.observation")} )}
{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;