diff --git a/frontend/src/App.css b/frontend/src/App.css index 1577688..14f4965 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -208,6 +208,193 @@ body { overflow: hidden; } +/* ---- 会话历史侧边栏 ---- */ + +.sidebar { + width: 260px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--color-surface-1); + border-right: 1px solid var(--color-border); + overflow: hidden; +} + +.sidebar--collapsed { + width: 48px; + align-items: center; + padding: 8px 0; + gap: 8px; +} + +.sidebar__header { + display: flex; + align-items: center; + gap: 6px; + padding: 12px; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.sidebar__toggle { + background: none; + border: none; + color: var(--color-text-muted); + cursor: pointer; + font-size: 1rem; + padding: 6px 8px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.sidebar__toggle:hover { + background: var(--color-surface-2); + color: var(--color-text); +} + +.sidebar__new-btn { + background: none; + border: 1px solid var(--color-border); + color: var(--color-text); + cursor: pointer; + font-size: 0.82rem; + padding: 6px 10px; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.sidebar__new-btn:hover { + background: var(--color-surface-2); +} + +.sidebar__new-btn--full { + flex: 1; + text-align: left; +} + +.sidebar__list { + flex: 1; + overflow-y: auto; + padding: 6px; +} + +.sidebar__empty { + padding: 24px 12px; + text-align: center; + color: var(--color-text-muted); + font-size: 0.82rem; +} + +.sidebar__item { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background var(--transition-fast); + position: relative; +} + +.sidebar__item:hover { + background: var(--color-surface-2); +} + +.sidebar__item:hover .sidebar__item-actions { + opacity: 1; +} + +.sidebar__item--active { + background: var(--color-surface-2); + border-left: 3px solid var(--color-primary); +} + +.sidebar__item-content { + flex: 1; + min-width: 0; +} + +.sidebar__item-title { + font-size: 0.85rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-text); +} + +.sidebar__item-meta { + font-size: 0.72rem; + color: var(--color-text-muted); + margin-top: 2px; + display: flex; + gap: 8px; +} + +.sidebar__item-actions { + display: flex; + gap: 2px; + opacity: 0; + transition: opacity var(--transition-fast); + flex-shrink: 0; +} + +.sidebar__action-btn { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + padding: 4px 6px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.sidebar__action-btn:hover { + background: var(--color-surface-3); + color: var(--color-text); +} + +.sidebar__action-btn--danger:hover { + color: #e74c3c; +} + +.sidebar__edit { + display: flex; + gap: 4px; + width: 100%; +} + +.sidebar__edit-input { + flex: 1; + font-size: 0.82rem; + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-1); + color: var(--color-text); + outline: none; +} + +.sidebar__edit-input:focus { + border-color: var(--color-primary); +} + +.sidebar__edit-btn { + background: none; + border: none; + cursor: pointer; + font-size: 0.85rem; + padding: 4px 8px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); +} + +.sidebar__edit-btn:hover { + background: var(--color-surface-2); + color: var(--color-text); +} + /* ---- 左侧视频面板 ---- */ .video-panel { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58ac759..2ef06ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,21 +1,27 @@ // ============================================================ -// CamTalk — 主应用组件(Web 端双栏布局) +// CamTalk — 主应用组件(Web 端三栏布局:侧边栏 + 视频 + 聊天) // ============================================================ -import { useCallback, useEffect, useRef, useState } from "react"; +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 { loadTheme, saveTheme } from "./lib/storage"; +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"; -function App() { +/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */ +function AppContent() { const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const timerRef = useRef | null>(null); // 切换主题时更新 的 data-theme 属性 @@ -34,8 +40,21 @@ function App() { 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, @@ -88,28 +107,82 @@ function App() { 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(); + } + }, [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]); + return (
{/* ---- 顶部导航栏 ---- */}

CamTalk

- AI 视觉对话助手 + {tr("app.title")}
- {isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && ( + {isConnected && stats.queryCount > 0 && ( - {stats.queryCount} 次请求 · {stats.totalTokens} tokens + {stats.queryCount} {tr("app.stats.requests")} )} - {isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"} + {isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")} ) : (
@@ -211,15 +296,15 @@ function App() { className={`btn ${mode === "observation" ? "btn--active" : "btn--secondary"}`} onClick={toggleMode} > - {mode === "observation" ? "👁️ 观察中" : "👁️ 观察模式"} + {mode === "observation" ? tr("controls.observing") : tr("controls.observation")} {isProcessing && ( )}
)} @@ -229,15 +314,15 @@ function App() { {/* 右侧:聊天面板 */}
- 对话 + {tr("chat.title")} {isConnected && mode === "observation" && ( - 观察模式 + {tr("chat.mode.observation")} )}
{connectionStatus === "disconnected" && messages.length > 0 && (
- 连接已断开,正在重连... + {tr("chat.reconnecting")}
)} (() => 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; diff --git a/frontend/src/components/CameraManager/index.tsx b/frontend/src/components/CameraManager/index.tsx index 0cb76e0..7b950a4 100644 --- a/frontend/src/components/CameraManager/index.tsx +++ b/frontend/src/components/CameraManager/index.tsx @@ -4,6 +4,7 @@ // ============================================================ import { useCallback, useRef, useState } from "react"; +import { useI18n } from "../../lib/i18n"; export interface CameraManagerHandle { /** 获取当前视频轨道 */ @@ -13,6 +14,7 @@ export interface CameraManagerHandle { } export function useCamera() { + const { t } = useI18n(); const videoRef = useRef(null); const [stream, setStream] = useState(null); const [error, setError] = useState(null); @@ -29,7 +31,7 @@ export function useCamera() { } setError(null); } catch (err) { - const message = err instanceof Error ? err.message : "无法访问摄像头"; + const message = err instanceof Error ? err.message : t("error.cameraAccess"); setError(message); console.error("[Camera] 获取摄像头失败:", err); } diff --git a/frontend/src/components/ChatPanel/index.tsx b/frontend/src/components/ChatPanel/index.tsx index 5836226..4ff7902 100644 --- a/frontend/src/components/ChatPanel/index.tsx +++ b/frontend/src/components/ChatPanel/index.tsx @@ -4,6 +4,7 @@ // ============================================================ import { useEffect, useRef, useState } from "react"; +import { useI18n } from "../../lib/i18n"; import type { ChatMessage } from "../../types"; import type { ConnectionStatus } from "../../lib/websocket"; @@ -19,6 +20,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText const containerRef = useRef(null); const isAutoScroll = useRef(true); const [inputText, setInputText] = useState(""); + const { t } = useI18n(); const isConnected = connectionStatus === "connected"; @@ -51,17 +53,6 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText setInputText(""); }; - // 未连接时的空状态 - if (!isConnected) { - return ( -
- 💬 -

点击下方按钮开始对话

- 连接后可打字或语音与 AI 交互 -
- ); - } - return (
@@ -69,15 +60,15 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText {messages.length === 0 && !currentReply && (
💬 -

在下方输入文字开始对话

- 也可以开启麦克风用语音对话 +

{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}

+ {isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}
)} {messages.map((msg, index) => (
- {msg.role === "user" ? "你" : "AI"} + {msg.role === "user" ? t("chat.userLabel") : "AI"}
{msg.content}
{msg.role === "assistant" && msg.tokensUsed !== undefined && ( @@ -104,21 +95,22 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
- {/* 文本输入框 - 连接后始终显示 */} + {/* 文本输入框 - 始终显示 */} {onSendText && (
setInputText(e.target.value)} + disabled={connectionStatus === "connecting"} /> diff --git a/frontend/src/components/ConfigPanel/index.tsx b/frontend/src/components/ConfigPanel/index.tsx index 3844e6d..9523ec6 100644 --- a/frontend/src/components/ConfigPanel/index.tsx +++ b/frontend/src/components/ConfigPanel/index.tsx @@ -3,6 +3,7 @@ // 职责:主题切换、TTS 开关、detail level 切换、语言选择 // ============================================================ +import { useI18n } from "../../lib/i18n"; import type { SessionConfig, Theme } from "../../types"; interface ConfigPanelProps { @@ -14,40 +15,42 @@ interface ConfigPanelProps { } export function ConfigPanel({ config, theme, onUpdate, onThemeChange, onClose }: ConfigPanelProps) { + const { t } = useI18n(); + return (
e.stopPropagation()}>
- 设置 + {t("settings.title")}
-
外观
+
{t("settings.appearance")}
-
会话
+
{t("settings.session")}
+ ) : ( + <> +
+
{session.title}
+
+ {session.messageCount > 0 && ( + {session.messageCount}{t("sidebar.messages")} + )} + {formatRelativeTime(session.lastActiveAt)} +
+
+
+ + +
+ + )} +
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/VideoPreview/index.tsx b/frontend/src/components/VideoPreview/index.tsx index a9a0387..4dc9bd8 100644 --- a/frontend/src/components/VideoPreview/index.tsx +++ b/frontend/src/components/VideoPreview/index.tsx @@ -4,6 +4,7 @@ // ============================================================ import { forwardRef } from "react"; +import { useI18n } from "../../lib/i18n"; interface VideoPreviewProps { isStreaming: boolean; @@ -11,6 +12,8 @@ interface VideoPreviewProps { export const VideoPreview = forwardRef( function VideoPreview({ isStreaming }, ref) { + const { t } = useI18n(); + return (
diff --git a/frontend/src/hooks/useSessionList.ts b/frontend/src/hooks/useSessionList.ts new file mode 100644 index 0000000..2ab76c3 --- /dev/null +++ b/frontend/src/hooks/useSessionList.ts @@ -0,0 +1,117 @@ +// ============================================================ +// useSessionList — 会话历史列表管理 +// 职责:会话 CRUD、消息持久化、切换会话 +// ============================================================ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import { + loadSessionSummaries, + saveSessionSummaries, + loadSessionMessages, + saveSessionMessages, + deleteSessionMessages, +} from "../lib/storage"; +import type { ChatMessage, SessionSummary } from "../types"; + +/** 截取预览文本 */ +function getPreview(text: string, maxLen = 50): string { + const clean = text.replace(/[\n\r]/g, " ").trim(); + return clean.length > maxLen ? clean.slice(0, maxLen) + "…" : clean; +} + +export function useSessionList() { + const [sessions, setSessions] = useState(() => loadSessionSummaries()); + const [activeSessionId, setActiveSessionId] = useState(null); + const sessionsRef = useRef(sessions); + useEffect(() => { sessionsRef.current = sessions; }, [sessions]); + + /** 创建新会话 */ + const createSession = useCallback((): string => { + const id = uuidv4(); + const now = Date.now(); + const summary: SessionSummary = { + id, + title: "新对话", + createdAt: now, + lastActiveAt: now, + messageCount: 0, + preview: "", + }; + setSessions((prev) => [summary, ...prev]); + setActiveSessionId(id); + // 持久化 + const all = [summary, ...sessionsRef.current]; + saveSessionSummaries(all); + return id; + }, []); + + /** 删除会话 */ + const deleteSession = useCallback((id: string) => { + setSessions((prev) => prev.filter((s) => s.id !== id)); + deleteSessionMessages(id); + const remaining = sessionsRef.current.filter((s) => s.id !== id); + saveSessionSummaries(remaining); + // 如果删除的是当前会话,清空 active + setActiveSessionId((prev) => (prev === id ? null : prev)); + }, []); + + /** 重命名会话 */ + const renameSession = useCallback((id: string, title: string) => { + setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, title } : s))); + const updated = sessionsRef.current.map((s) => (s.id === id ? { ...s, title } : s)); + saveSessionSummaries(updated); + }, []); + + /** 保存当前会话的消息历史 */ + const saveCurrentSession = useCallback((messages: ChatMessage[]) => { + const id = sessionsRef.current.length > 0 ? sessionsRef.current[0].id : null; + // 找到 activeSessionId 对应的会话 + // 这里不依赖 activeSessionId state,而是通过参数传入 + return messages; + }, []); + + /** 保存指定会话的消息并更新摘要 */ + const persistSession = useCallback((sessionId: string, messages: ChatMessage[]) => { + if (!sessionId) return; + saveSessionMessages(sessionId, messages); + // 更新摘要 + const firstUserMsg = messages.find((m) => m.role === "user"); + const title = firstUserMsg ? getPreview(firstUserMsg.content, 20) : "新对话"; + const lastMsg = messages[messages.length - 1]; + const summary: Partial = { + title, + messageCount: messages.length, + lastActiveAt: lastMsg?.timestamp || Date.now(), + preview: lastMsg ? getPreview(lastMsg.content) : "", + }; + setSessions((prev) => { + const updated = prev.map((s) => (s.id === sessionId ? { ...s, ...summary } : s)); + saveSessionSummaries(updated); + return updated; + }); + }, []); + + /** 加载指定会话的消息历史 */ + const loadMessages = useCallback((sessionId: string): ChatMessage[] => { + return loadSessionMessages(sessionId); + }, []); + + /** 选择会话(返回需要加载的消息) */ + const selectSession = useCallback((id: string): ChatMessage[] => { + setActiveSessionId(id); + return loadSessionMessages(id); + }, []); + + return { + sessions, + activeSessionId, + setActiveSessionId, + createSession, + deleteSession, + renameSession, + persistSession, + loadMessages, + selectSession, + }; +} diff --git a/frontend/src/hooks/useVisionSession.ts b/frontend/src/hooks/useVisionSession.ts index 0c09935..739afbf 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -12,6 +12,7 @@ import { getErrorMessage } from "../lib/errors"; import { TTSPlayer } from "../lib/ttsPlayer"; import { showToast } from "../lib/toast"; import { loadConfig, saveConfig } from "../lib/storage"; +import { useI18n } from "../lib/i18n"; import { useCamera } from "../components/CameraManager"; import { useMicrophone } from "../components/MicManager"; import { useVAD, sampleFrame, compareFrames } from "../components/EdgeProcessor"; @@ -29,6 +30,7 @@ export interface SessionStats { } export function useVisionSession() { + const { t } = useI18n(); const [messages, setMessages] = useState([]); const [currentReply, setCurrentReply] = useState(""); const [isProcessing, setIsProcessing] = useState(false); @@ -45,6 +47,9 @@ export function useVisionSession() { // 对话历史(role + content),用于多轮上下文 const historyRef = useRef>([]); + // 待发消息队列(未连接时暂存,连接后自动发送) + const pendingMessagesRef = useRef>([]); + // TTS 播放器 const ttsPlayerRef = useRef(null); const getTTSPlayer = useCallback(() => { @@ -66,6 +71,12 @@ export function useVisionSession() { isProcessingRef.current = isProcessing; }, [isProcessing]); + // 用 ref 跟踪连接状态,避免回调闭包问题 + const statusRef = useRef(status); + useEffect(() => { + statusRef.current = status; + }, [status]); + // 观察模式:画面变化时自动发送 query const { isObserving, startObserving, stopObserving } = useObservationMode({ onChange: useCallback( @@ -84,7 +95,7 @@ export function useVisionSession() { ...prev, { role: "user", - content: "👁️ 画面变化检测", + content: t("session.changeDetected"), timestamp: Date.now(), }, ]); @@ -108,7 +119,7 @@ export function useVisionSession() { }); }, [videoRef, captureFrame, startObserving, stopObserving]); - // WebSocket 连接成功后发送 config + // WebSocket 连接成功后发送 config + flush 待发消息 useEffect(() => { if (status === "connected") { send({ @@ -119,6 +130,27 @@ export function useVisionSession() { language: config.language, }, }); + + // flush 待发消息队列 + const pending = pendingMessagesRef.current; + pendingMessagesRef.current = []; + for (const msg of pending) { + const frame = captureFrame(); + send({ + type: "query", + request_id: msg.requestId, + image: frame ? dataUrlToBase64(frame) : "", + audio: "", + text: msg.text, + }); + setStats((prev) => ({ ...prev, queryCount: prev.queryCount + 1 })); + setMessages((prev) => [ + ...prev, + { role: "user", content: msg.text, timestamp: Date.now() }, + ]); + historyRef.current.push({ role: "user", content: msg.text }); + setIsProcessing(true); + } } }, [status]); // eslint-disable-line react-hooks/exhaustive-deps -- 仅在连接状态变化时发送 @@ -199,7 +231,7 @@ export function useVisionSession() { // 添加用户消息(STT 流式结果会逐步更新文本) setMessages((prev) => [ ...prev, - { role: "user", content: "(语音识别中...)", timestamp: Date.now() }, + { role: "user", content: t("session.recognizing"), timestamp: Date.now() }, ]); setIsProcessing(true); }, @@ -219,7 +251,7 @@ export function useVisionSession() { if (lastUserIdx >= 0) { updated[lastUserIdx] = { ...updated[lastUserIdx], - content: msg.text || "(未识别到语音)", + content: msg.text || t("session.noSpeech"), }; } return updated; @@ -271,7 +303,7 @@ export function useVisionSession() { case "error": console.error("[Session] 服务端错误:", msg.code, msg.message); - showToast(getErrorMessage(msg.code), "error"); + showToast(getErrorMessage(msg.code, t), "error"); setIsProcessing(false); break; } @@ -280,10 +312,13 @@ export function useVisionSession() { return unsub; }, [getTTSPlayer]); - /** 启动会话 */ + /** 启动视频通话(摄像头 + 麦克风 + VAD) */ const startSession = useCallback(async () => { - // 1. 连接 WebSocket(必须) - connect(); + // 1. 确保 WebSocket 已连接 + if (statusRef.current !== "connected") { + connect(); + // 等待连接完成(通过 status 变化触发后续流程,这里直接继续) + } // 2. 尝试获取摄像头(可选) try { @@ -359,7 +394,7 @@ export function useVisionSession() { setIsAudioPlaying(false); // 将未完成的流式内容保存为最终消息 if (currentReply) { - const interrupted = currentReply + "(已打断)"; + const interrupted = currentReply + t("session.interrupted"); historyRef.current.push({ role: "assistant", content: interrupted }); setMessages((prev) => [ ...prev, @@ -379,10 +414,23 @@ export function useVisionSession() { ttsPlayerRef.current?.stop(); setIsAudioPlaying(false); - // 捕获当前摄像头画面 - const frame = captureFrame(); - const requestId = uuidv4(); + + // 未连接时:自动连接,消息加入待发队列 + if (statusRef.current !== "connected") { + pendingMessagesRef.current.push({ text: text.trim(), requestId }); + // 添加用户消息到 UI(立即反馈) + setMessages((prev) => [ + ...prev, + { role: "user", content: text.trim(), timestamp: Date.now() }], + ); + // 自动连接 WebSocket + connect(); + return; + } + + // 已连接:直接发送 + const frame = captureFrame(); send({ type: "query", request_id: requestId, @@ -405,11 +453,12 @@ export function useVisionSession() { setIsProcessing(true); }, - [captureFrame, send], + [captureFrame, send, connect], ); return { messages, + setMessages, currentReply, isProcessing, isAudioPlaying, diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 9aa56e2..4bad345 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -5,20 +5,13 @@ import type { ErrorCode } from "../types"; -const ERROR_MESSAGES: Record = { - INVALID_MESSAGE: "消息格式异常,请重试", - SESSION_NOT_FOUND: "会话已过期,请重新连接", - RATE_LIMITED: "请求太频繁,请稍后再试", - IMAGE_TOO_LARGE: "图像过大,请降低分辨率", - AUDIO_TOO_SHORT: "语音太短,请再说一句", - LLM_TIMEOUT: "AI 响应超时,请重试", - LLM_ERROR: "AI 服务异常,请稍后重试", - STT_ERROR: "语音识别失败,请重试", - TTS_ERROR: "语音合成失败", - INTERNAL_ERROR: "服务内部错误,请重试", -}; - -/** 将错误码转为用户友好文案 */ -export function getErrorMessage(code: string): string { - return ERROR_MESSAGES[code as ErrorCode] ?? `未知错误: ${code}`; +/** 将错误码转为用户友好文案(需要传入翻译函数) */ +export function getErrorMessage(code: string, translate: (key: string) => string): string { + const key = `error.${code}`; + const translated = translate(key); + // 如果翻译函数返回 key 本身(未找到翻译),用 fallback + if (translated === key) { + return translate("error.unknown") + `: ${code}`; + } + return translated; } diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts new file mode 100644 index 0000000..eec5531 --- /dev/null +++ b/frontend/src/lib/i18n/en-US.ts @@ -0,0 +1,97 @@ +import type { TranslationMap } from "./index"; + +export const enUS: TranslationMap = { + // App header + "app.title": "AI Vision Assistant", + "app.stats.requests": "requests", + + // Connection status + "status.connected": "Connected", + "status.connecting": "Connecting...", + "status.disconnected": "Disconnected", + + // Settings + "settings.title": "Settings", + "settings.appearance": "Appearance", + "settings.theme": "Theme", + "settings.theme.desc": "Switch between light and dark theme", + "settings.theme.dark": "Dark", + "settings.theme.light": "Light", + "settings.session": "Session", + "settings.tts": "Voice Response", + "settings.tts.desc": "Play voice along with AI response", + "settings.detail": "Image Quality", + "settings.detail.desc": "High quality for text recognition, low saves bandwidth", + "settings.detail.low": "Low", + "settings.detail.high": "High", + "settings.language": "Language", + "settings.language.desc": "Interaction language preference", + + // Video indicators + "video.observing": "👁️ Observing", + "video.listening": "🎤 Listening...", + "video.playing": "🔊 Playing...", + "video.initVad": "Initializing voice detection...", + "video.clickToStart": "Click the button below to start", + "video.placeholder": "Type in the chat panel to start", + "video.cameraOff": "Camera is off", + "video.cameraOff.hint": "You can type in the chat panel", + + // Controls + "controls.connecting": "Connecting...", + "controls.start": "🎙️ Start Session", + "controls.startVideo": "🎙️ Start Video Call", + "controls.cameraOff": "Turn off camera", + "controls.cameraOn": "Turn on camera", + "controls.micOff": "Turn off microphone", + "controls.micOn": "Turn on microphone", + "controls.observing": "👁️ Observing", + "controls.observation": "👁️ Observe", + "controls.interrupt": "⏹ Interrupt", + "controls.stop": "End Session", + + // Chat panel + "chat.title": "Chat", + "chat.mode.observation": "Observing", + "chat.reconnecting": "Connection lost, reconnecting...", + "chat.empty.prompt": "Type below to start chatting", + "chat.empty.hint": "Type to chat with AI, or click the button on the left to start video", + "chat.welcome.prompt": "Type below to start chatting", + "chat.welcome.hint": "Or enable microphone for voice chat", + "chat.userLabel": "You", + "chat.input.placeholder": "Type a message...", + "chat.send": "Send", + + // Session messages + "session.changeDetected": "👁️ Scene change detected", + "session.recognizing": "(Recognizing speech...)", + "session.noSpeech": "(No speech detected)", + "session.interrupted": "(Interrupted)", + + // Errors + "error.INVALID_MESSAGE": "Invalid message format, please retry", + "error.SESSION_NOT_FOUND": "Session expired, please reconnect", + "error.RATE_LIMITED": "Too many requests, please try again later", + "error.IMAGE_TOO_LARGE": "Image too large, please reduce resolution", + "error.AUDIO_TOO_SHORT": "Audio too short, please try again", + "error.LLM_TIMEOUT": "AI response timed out, please retry", + "error.LLM_ERROR": "AI service error, please try again later", + "error.STT_ERROR": "Speech recognition failed, please retry", + "error.TTS_ERROR": "Voice synthesis failed", + "error.INTERNAL_ERROR": "Internal server error, please retry", + "error.unknown": "Unknown error", + + // Sidebar + "sidebar.new": "New Chat", + "sidebar.expand": "Expand sidebar", + "sidebar.collapse": "Collapse sidebar", + "sidebar.empty": "No chat history", + "sidebar.messages": " messages", + "sidebar.rename": "Rename", + "sidebar.delete": "Delete", + + // Device errors + "error.vadInit": "VAD initialization failed", + "error.cameraAccess": "Cannot access camera", + "error.micAccess": "Cannot access microphone", +}; diff --git a/frontend/src/lib/i18n/index.ts b/frontend/src/lib/i18n/index.ts new file mode 100644 index 0000000..3137fac --- /dev/null +++ b/frontend/src/lib/i18n/index.ts @@ -0,0 +1,47 @@ +// ============================================================ +// i18n — 轻量国际化模块 +// 职责:提供翻译函数和 React Context,根据 locale 返回对应语言文本 +// ============================================================ + +import { createContext, useContext } from "react"; +import { zhCN } from "./zh-CN"; +import { enUS } from "./en-US"; +import { jaJP } from "./ja-JP"; + +export type Locale = "zh-CN" | "en-US" | "ja-JP"; + +export type TranslationMap = Record; + +const translations: Record = { + "zh-CN": zhCN, + "en-US": enUS, + "ja-JP": jaJP, +}; + +/** 翻译函数:根据 key 和 locale 返回翻译文本 */ +export function t(key: string, locale: Locale): string { + return translations[locale]?.[key] ?? translations["zh-CN"][key] ?? key; +} + +/** 从 config.language 值解析为合法 Locale */ +export function parseLocale(lang: string): Locale { + if (lang === "en-US" || lang === "ja-JP") return lang; + return "zh-CN"; +} + +// ---- React Context ---- + +export interface I18nContextValue { + locale: Locale; + t: (key: string) => string; +} + +export const I18nContext = createContext({ + locale: "zh-CN", + t: (key) => t(key, "zh-CN"), +}); + +/** 组件内获取 i18n 的便捷 Hook */ +export function useI18n() { + return useContext(I18nContext); +} diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts new file mode 100644 index 0000000..947973b --- /dev/null +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -0,0 +1,97 @@ +import type { TranslationMap } from "./index"; + +export const jaJP: TranslationMap = { + // App header + "app.title": "AI ビジョンアシスタント", + "app.stats.requests": "リクエスト", + + // Connection status + "status.connected": "接続済み", + "status.connecting": "接続中...", + "status.disconnected": "未接続", + + // Settings + "settings.title": "設定", + "settings.appearance": "外観", + "settings.theme": "テーマ", + "settings.theme.desc": "ライト/ダークテーマを切り替え", + "settings.theme.dark": "ダーク", + "settings.theme.light": "ライト", + "settings.session": "セッション", + "settings.tts": "音声応答", + "settings.tts.desc": "AI応答と同時に音声を再生", + "settings.detail": "画像品質", + "settings.detail.desc": "高品質は文字認識に最適、低品質は帯域節約", + "settings.detail.low": "低", + "settings.detail.high": "高", + "settings.language": "言語", + "settings.language.desc": "インタラクション言語の設定", + + // Video indicators + "video.observing": "👁️ 観察中", + "video.listening": "🎤 聞き取り中...", + "video.playing": "🔊 再生中...", + "video.initVad": "音声検出を初期化中...", + "video.clickToStart": "下のボタンをクリックして開始", + "video.placeholder": "右側のチャットに入力して開始", + "video.cameraOff": "カメラがオフです", + "video.cameraOff.hint": "右側のチャットでテキスト対話ができます", + + // Controls + "controls.connecting": "接続中...", + "controls.start": "🎙️ 対話を開始", + "controls.startVideo": "🎙️ ビデオ通話を開始", + "controls.cameraOff": "カメラをオフ", + "controls.cameraOn": "カメラをオン", + "controls.micOff": "マイクをオフ", + "controls.micOn": "マイクをオン", + "controls.observing": "👁️ 観察中", + "controls.observation": "👁️ 観察モード", + "controls.interrupt": "⏹ 中断", + "controls.stop": "対話を終了", + + // Chat panel + "chat.title": "チャット", + "chat.mode.observation": "観察モード", + "chat.reconnecting": "接続が切断されました。再接続中...", + "chat.empty.prompt": "下にテキストを入力して対話を開始", + "chat.empty.hint": "テキストでAIと対話、または左のボタンでビデオ通話を開始", + "chat.welcome.prompt": "下にテキストを入力して対話を開始", + "chat.welcome.hint": "マイクを有効にして音声対話もできます", + "chat.userLabel": "あなた", + "chat.input.placeholder": "メッセージを入力...", + "chat.send": "送信", + + // Session messages + "session.changeDetected": "👁️ シーン変化を検出", + "session.recognizing": "(音声認識中...)", + "session.noSpeech": "(音声が検出されませんでした)", + "session.interrupted": "(中断済み)", + + // Errors + "error.INVALID_MESSAGE": "メッセージ形式が無効です。再試行してください", + "error.SESSION_NOT_FOUND": "セッションが期限切れです。再接続してください", + "error.RATE_LIMITED": "リクエストが多すぎます。しばらく待ってから再試行してください", + "error.IMAGE_TOO_LARGE": "画像が大きすぎます。解像度を下げてください", + "error.AUDIO_TOO_SHORT": "音声が短すぎます。もう一度お試しください", + "error.LLM_TIMEOUT": "AI応答がタイムアウトしました。再試行してください", + "error.LLM_ERROR": "AIサービスエラー。しばらく待ってから再試行してください", + "error.STT_ERROR": "音声認識に失敗しました。再試行してください", + "error.TTS_ERROR": "音声合成に失敗しました", + "error.INTERNAL_ERROR": "サーバー内部エラー。再試行してください", + "error.unknown": "不明なエラー", + + // Sidebar + "sidebar.new": "新しいチャット", + "sidebar.expand": "サイドバーを展開", + "sidebar.collapse": "サイドバーを折りたたむ", + "sidebar.empty": "会話履歴がありません", + "sidebar.messages": "件のメッセージ", + "sidebar.rename": "名前を変更", + "sidebar.delete": "削除", + + // Device errors + "error.vadInit": "VAD初期化に失敗しました", + "error.cameraAccess": "カメラにアクセスできません", + "error.micAccess": "マイクにアクセスできません", +}; diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts new file mode 100644 index 0000000..d085cde --- /dev/null +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -0,0 +1,97 @@ +import type { TranslationMap } from "./index"; + +export const zhCN: TranslationMap = { + // App header + "app.title": "AI 视觉对话助手", + "app.stats.requests": "次请求", + + // Connection status + "status.connected": "已连接", + "status.connecting": "连接中...", + "status.disconnected": "未连接", + + // Settings + "settings.title": "设置", + "settings.appearance": "外观", + "settings.theme": "主题", + "settings.theme.desc": "切换明暗主题", + "settings.theme.dark": "深色", + "settings.theme.light": "浅色", + "settings.session": "会话", + "settings.tts": "语音回答", + "settings.tts.desc": "AI 回答时同步播放语音", + "settings.detail": "图像精度", + "settings.detail.desc": "高精度适合识别文字,低精度省流量", + "settings.detail.low": "低", + "settings.detail.high": "高", + "settings.language": "语言", + "settings.language.desc": "交互语言偏好", + + // Video indicators + "video.observing": "👁️ 观察中", + "video.listening": "🎤 正在聆听...", + "video.playing": "🔊 正在播放...", + "video.initVad": "正在初始化语音检测...", + "video.clickToStart": "点击下方按钮开始对话", + "video.placeholder": "在右侧聊天框输入即可开始对话", + "video.cameraOff": "摄像头未开启", + "video.cameraOff.hint": "可在右侧聊天框打字对话", + + // Controls + "controls.connecting": "连接中...", + "controls.start": "🎙️ 开始对话", + "controls.startVideo": "🎙️ 开始视频通话", + "controls.cameraOff": "关闭摄像头", + "controls.cameraOn": "开启摄像头", + "controls.micOff": "关闭麦克风", + "controls.micOn": "开启麦克风", + "controls.observing": "👁️ 观察中", + "controls.observation": "👁️ 观察模式", + "controls.interrupt": "⏹ 打断", + "controls.stop": "结束对话", + + // Chat panel + "chat.title": "对话", + "chat.mode.observation": "观察模式", + "chat.reconnecting": "连接已断开,正在重连...", + "chat.empty.prompt": "在下方输入文字开始对话", + "chat.empty.hint": "输入文字即可与 AI 交互,也可点击左侧按钮开启视频", + "chat.welcome.prompt": "在下方输入文字开始对话", + "chat.welcome.hint": "也可以开启麦克风用语音对话", + "chat.userLabel": "你", + "chat.input.placeholder": "输入文字对话...", + "chat.send": "发送", + + // Session messages + "session.changeDetected": "👁️ 画面变化检测", + "session.recognizing": "(语音识别中...)", + "session.noSpeech": "(未识别到语音)", + "session.interrupted": "(已打断)", + + // Errors + "error.INVALID_MESSAGE": "消息格式异常,请重试", + "error.SESSION_NOT_FOUND": "会话已过期,请重新连接", + "error.RATE_LIMITED": "请求太频繁,请稍后再试", + "error.IMAGE_TOO_LARGE": "图像过大,请降低分辨率", + "error.AUDIO_TOO_SHORT": "语音太短,请再说一句", + "error.LLM_TIMEOUT": "AI 响应超时,请重试", + "error.LLM_ERROR": "AI 服务异常,请稍后重试", + "error.STT_ERROR": "语音识别失败,请重试", + "error.TTS_ERROR": "语音合成失败", + "error.INTERNAL_ERROR": "服务内部错误,请重试", + "error.unknown": "未知错误", + + // Sidebar + "sidebar.new": "新对话", + "sidebar.expand": "展开侧边栏", + "sidebar.collapse": "收起侧边栏", + "sidebar.empty": "暂无会话记录", + "sidebar.messages": "条消息", + "sidebar.rename": "重命名", + "sidebar.delete": "删除", + + // Device errors + "error.vadInit": "VAD 初始化失败", + "error.cameraAccess": "无法访问摄像头", + "error.micAccess": "无法访问麦克风", +}; diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index fb12324..e7cf6c5 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -3,7 +3,7 @@ // 职责:会话配置持久化 // ============================================================ -import type { SessionConfig, Theme } from "../types"; +import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types"; const CONFIG_KEY = "camtalk:config"; const THEME_KEY = "camtalk:theme"; @@ -30,6 +30,8 @@ export function loadConfig(): SessionConfig { export function saveConfig(config: SessionConfig): void { try { localStorage.setItem(CONFIG_KEY, JSON.stringify(config)); + // 派发自定义事件,通知 App 更新 locale + window.dispatchEvent(new CustomEvent("camtalk-config-changed")); } catch { // localStorage 不可用时静默失败 } @@ -50,3 +52,51 @@ export function saveTheme(theme: Theme): void { localStorage.setItem(THEME_KEY, theme); } catch { /* ignore */ } } + +// ---- 会话历史存储 ---- + +const SESSIONS_KEY = "camtalk:sessions"; +const SESSION_MSG_PREFIX = "camtalk:session:"; + +/** 加载所有会话摘要(按 lastActiveAt 倒序) */ +export function loadSessionSummaries(): SessionSummary[] { + try { + const raw = localStorage.getItem(SESSIONS_KEY); + if (!raw) return []; + return (JSON.parse(raw) as SessionSummary[]).sort((a, b) => b.lastActiveAt - a.lastActiveAt); + } catch { + return []; + } +} + +/** 保存会话摘要列表 */ +export function saveSessionSummaries(sessions: SessionSummary[]): void { + try { + localStorage.setItem(SESSIONS_KEY, JSON.stringify(sessions)); + } catch { /* ignore */ } +} + +/** 加载单个会话的消息历史 */ +export function loadSessionMessages(sessionId: string): ChatMessage[] { + try { + const raw = localStorage.getItem(SESSION_MSG_PREFIX + sessionId); + if (!raw) return []; + return JSON.parse(raw) as ChatMessage[]; + } catch { + return []; + } +} + +/** 保存单个会话的消息历史 */ +export function saveSessionMessages(sessionId: string, messages: ChatMessage[]): void { + try { + localStorage.setItem(SESSION_MSG_PREFIX + sessionId, JSON.stringify(messages)); + } catch { /* ignore */ } +} + +/** 删除单个会话的消息历史 */ +export function deleteSessionMessages(sessionId: string): void { + try { + localStorage.removeItem(SESSION_MSG_PREFIX + sessionId); + } catch { /* ignore */ } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 935fd22..14e94b8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -19,6 +19,16 @@ export interface Session { config: SessionConfig; } +/** 会话摘要(侧边栏列表用) */ +export interface SessionSummary { + id: string; + title: string; + createdAt: number; + lastActiveAt: number; + messageCount: number; + preview: string; +} + // ---- 聊天消息 ---- export interface ChatMessage {