// ============================================================ // 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 ( {/* ---- 顶部导航栏 ---- */} {/* 侧边栏开关 */} setSidebarOpen((v) => !v)} title={tr("sidebar.expand")} aria-label={tr("sidebar.expand")} > CamTalk {tr("app.title")} {isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")} setShowConfig((v) => !v)} title={tr("settings.title")} aria-label={tr("settings.title")} > {/* ---- 会话历史侧边栏(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) => ( handleSelectScenario(sc.id)} title={sc.description ? sc.description : sc.descKey ? tr(sc.descKey) : sc.name} > {scenarioSvgIcon(sc.id) || renderScenarioIconById(sc.icon, 14) || sc.icon} {sc.nameKey ? tr(sc.nameKey) : sc.name} ))} {/* 新建情景快捷入口 */} setShowCreateScenario(true)} title={tr("scenario.create.button")} > {tr("scenario.createChip")} {/* 视频下方控制区(三层结构) */} {!isConnected ? ( <> {/* 未连接态:主 CTA */} {connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")} > ) : isCameraOn ? ( <> {/* 视频通话态:核心控制工具栏 */} {isCameraOn ? ( <> > ) : ( <> > )} {isMicOn ? ( <> > ) : ( <> > )} {tr("controls.recognize")} {isProcessing && ( {tr("controls.interrupt")} )} {tr("controls.stopVideo")} {/* 设备选择器 */} switchDevice("camera", e.target.value === "default" ? "" : e.target.value)} > {tr("controls.device.default")} {cameras.map((d) => ( {d.label} ))} switchDevice("mic", e.target.value === "default" ? "" : e.target.value)} > {tr("controls.device.default")} {mics.map((d) => ( {d.label} ))} > ) : ( <> {/* 文字对话态:视频已结束 */} {tr("video.ended")} {tr("video.ended.hint")} {tr("controls.resumeVideo")} {tr("controls.endSession")} > )} {/* 右侧:聊天面板 */} {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;
Loading...