Files
CamTalk/frontend/src/App.tsx
2026-06-14 16:31:23 +08:00

498 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ============================================================
// 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 { 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 [showConfig, setShowConfig] = useState(false);
const [theme, setTheme] = useState<Theme>(loadTheme);
const [elapsed, setElapsed] = useState(0);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [visionMode, setVisionMode] = useState<VisionMode>("ondemand");
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 切换主题时更新 <html> 的 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();
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]);
return (
<div className="app">
{/* ---- 顶部导航栏 ---- */}
<header className="header">
<div className="header__left">
{/* 侧边栏开关 */}
<button
className="header__menu-btn"
onClick={() => setSidebarOpen((v) => !v)}
title={tr("sidebar.expand")}
aria-label={tr("sidebar.expand")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1 className="header__title">CamTalk</h1>
<span className="header__subtitle">{tr("app.title")}</span>
</div>
<div className="header__right">
<span className={`badge badge--${connectionStatus}`}>
{isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
</span>
<button
className="btn-icon"
onClick={() => setShowConfig((v) => !v)}
title={tr("settings.title")}
aria-label={tr("settings.title")}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
</div>
</header>
{/* ---- 会话历史侧边栏Overlay 抽屉式) ---- */}
<SessionSidebar
sessions={sessions}
activeSessionId={activeSessionId}
open={sidebarOpen}
onToggle={() => setSidebarOpen((v) => !v)}
onNewSession={handleNewSession}
onSelectSession={handleSelectSession}
onDeleteSession={handleDeleteSession}
onRenameSession={renameSession}
/>
{showConfig && (
<ConfigPanel
config={config}
theme={theme}
onUpdate={updateConfig}
onThemeChange={handleThemeChange}
onClose={() => setShowConfig(false)}
/>
)}
{/* ---- 主体:视频 + 聊天(两栏) ---- */}
<div className="workspace">
{/* 左侧:视频预览 + 控制栏 */}
<div className="video-panel">
<div className="video-container">
<VideoPreview ref={videoRef} isStreaming={!!stream} />
{isConnected && (
<div className="live-badge">
<span className="live-badge__dot" />
LIVE {formatTime(elapsed)}
</div>
)}
{config.detailLevel === "high" && (
<div className="detail-badge">HD</div>
)}
{/* AI 视觉状态指示 */}
{isConnected && visionMode !== "chat" && (
<div className={`ai-vision-indicator ${isObserving ? "ai-vision-indicator--active" : ""}`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
<span>{isObserving ? tr("video.observing") : "AI"}</span>
</div>
)}
{isSpeaking && (
<div className="video-indicator">{tr("video.listening")}</div>
)}
{isAudioPlaying && config.ttsEnabled && (
<div className="video-indicator video-indicator--audio">{tr("video.playing")}</div>
)}
{isConnected && !isVADReady && !vadError && (
<div className="video-indicator video-indicator--loading">{tr("video.initVad")}</div>
)}
{vadError && (
<div className="video-indicator video-indicator--error"> {vadError}</div>
)}
{isProcessing && (
<div className="video-overlay">
<div className="video-overlay__spinner" />
</div>
)}
{!isConnected && !stream && (
<div className="video-placeholder">
<svg className="video-placeholder__icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span>{tr("video.placeholder")}</span>
</div>
)}
{isConnected && !isCameraOn && (
<div className="video-placeholder">
<svg className="video-placeholder__icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span>{tr("video.cameraOff")}</span>
<span className="video-placeholder__hint">{tr("video.cameraOff.hint")}</span>
</div>
)}
</div>
{/* 视频下方控制区(三层结构) */}
<div className="video-controls">
{!isConnected ? (
<>
{/* 未连接态:主 CTA */}
<button className="btn btn--primary btn--lg" onClick={startSession}>
{connectionStatus === "connecting" ? tr("controls.connecting") : tr("controls.startVideo")}
</button>
{/* 设备选择器 */}
<div className="video-controls__devices">
<div className="device-select-wrapper">
<label className="device-select-label">📷 {tr("controls.device.camera")}</label>
<select className="device-select" defaultValue="default">
<option value="default">{tr("controls.device.default")}</option>
</select>
</div>
<div className="device-select-wrapper">
<label className="device-select-label">🎤 {tr("controls.device.mic")}</label>
<select className="device-select" defaultValue="default">
<option value="default">{tr("controls.device.default")}</option>
</select>
</div>
</div>
{/* 模式切换器 */}
<div className="video-controls__mode">
<button
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("realtime")}
>
{tr("controls.mode.realtime")}
</button>
<button
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("ondemand")}
>
{tr("controls.mode.ondemand")}
</button>
<button
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("chat")}
>
{tr("controls.mode.chat")}
</button>
</div>
</>
) : (
<>
{/* 通话态:核心控制工具栏 */}
<div className="video-controls__toolbar">
<button
className={`btn btn--ctrl ${isCameraOn ? "btn--ctrl-on" : "btn--ctrl-off"}`}
onClick={toggleCamera}
title={isCameraOn ? tr("controls.cameraOff") : tr("controls.cameraOn")}
>
📷
</button>
<button
className={`btn btn--ctrl ${isMicOn ? "btn--ctrl-on" : "btn--ctrl-off"} ${isSpeaking ? "btn--speaking" : ""}`}
onClick={toggleMic}
title={isMicOn ? tr("controls.micOff") : tr("controls.micOn")}
>
🎤
</button>
{/* 识别画面按钮(按需模式下显示) */}
{visionMode === "ondemand" && (
<button
className="btn--recognize"
onClick={handleRecognize}
disabled={isProcessing}
>
🔍 {tr("controls.recognize")}
</button>
)}
{isProcessing && (
<button className="btn btn--warning" onClick={interrupt}>
{tr("controls.interrupt")}
</button>
)}
<button className="btn btn--danger" onClick={stopSession}>
{tr("controls.stop")}
</button>
</div>
{/* 通话态模式切换 */}
<div className="video-controls__mode">
<button
className={`mode-btn ${visionMode === "realtime" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("realtime")}
>
{tr("controls.mode.realtime")}
</button>
<button
className={`mode-btn ${visionMode === "ondemand" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("ondemand")}
>
{tr("controls.mode.ondemand")}
</button>
<button
className={`mode-btn ${visionMode === "chat" ? "mode-btn--active" : ""}`}
onClick={() => setVisionMode("chat")}
>
{tr("controls.mode.chat")}
</button>
</div>
</>
)}
</div>
</div>
{/* 右侧:聊天面板 */}
<div className="chat-panel-wrapper">
<div className="chat-panel-header">
<span>{tr("chat.title")}</span>
<div className="chat-panel-header__right">
{isConnected && mode === "observation" && (
<span className="chat-panel-header__mode">{tr("chat.mode.observation")}</span>
)}
{isConnected && stats.queryCount > 0 && (
<span className="chat-panel-header__stats">
{stats.queryCount} {tr("statusbar.recognitions")}
{stats.totalTokens > 0 && ` · ${stats.totalTokens.toLocaleString()} ${tr("statusbar.tokens")}`}
{` · ${formatTime(elapsed)}`}
</span>
)}
</div>
</div>
<div className="chat-panel-body">
{connectionStatus === "disconnected" && messages.length > 0 && (
<div className="system-message system-message--warning">
{tr("chat.reconnecting")}
</div>
)}
<ChatPanel
messages={messages}
currentReply={currentReply}
connectionStatus={connectionStatus}
isMicOn={isMicOn}
isSpeaking={isSpeaking}
onSendText={sendTextMessage}
onToggleMic={toggleMic}
onSceneCard={handleSceneCard}
/>
</div>
</div>
</div>
<ToastContainer />
</div>
);
}
function App() {
const [locale, setLocale] = useState<Locale>(() => 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 (
<I18nContext.Provider value={i18nValue}>
<AppContent />
</I18nContext.Provider>
);
}
export default App;