From e95b1603c1b657966c1be567d0c744f055b6b021 Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 14:34:30 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=20UI=20=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E5=8C=96=EF=BC=8C=E6=94=AF=E6=8C=81=E4=B8=AD/?= =?UTF-8?q?=E8=8B=B1/=E6=97=A5=E4=B8=89=E8=AF=AD=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前语言设置只影响后端 AI 行为,前端 UI 文字始终为中文。 新增轻量 i18n 模块(React Context + 翻译字典),切换语言后所有 UI 文字即时刷新。 - 新增 i18n 核心模块和 zh-CN/en-US/ja-JP 翻译文件(约 72 个 key) - App.tsx 拆分为 App(Provider)+ AppContent(业务),确保 Hook 可访问 Context - 所有组件通过 useI18n().t() 获取翻译文本 - errors.ts 改为接受翻译函数参数 - storage.ts saveConfig 时派发事件通知 locale 变化 --- frontend/src/App.tsx | 79 +++++++++++------ .../src/components/CameraManager/index.tsx | 4 +- frontend/src/components/ChatPanel/index.tsx | 16 ++-- frontend/src/components/ConfigPanel/index.tsx | 33 +++---- .../src/components/EdgeProcessor/index.tsx | 4 +- frontend/src/components/MicManager/index.tsx | 4 +- .../src/components/VideoPreview/index.tsx | 5 +- frontend/src/hooks/useVisionSession.ts | 12 +-- frontend/src/lib/errors.ts | 25 ++---- frontend/src/lib/i18n/en-US.ts | 86 +++++++++++++++++++ frontend/src/lib/i18n/index.ts | 47 ++++++++++ frontend/src/lib/i18n/ja-JP.ts | 86 +++++++++++++++++++ frontend/src/lib/i18n/zh-CN.ts | 86 +++++++++++++++++++ frontend/src/lib/storage.ts | 2 + 14 files changed, 418 insertions(+), 71 deletions(-) create mode 100644 frontend/src/lib/i18n/en-US.ts create mode 100644 frontend/src/lib/i18n/index.ts create mode 100644 frontend/src/lib/i18n/ja-JP.ts create mode 100644 frontend/src/lib/i18n/zh-CN.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 58ac759..241bfd0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,17 +2,20 @@ // CamTalk — 主应用组件(Web 端双栏布局) // ============================================================ -import { useCallback, useEffect, useRef, useState } from "react"; +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 { 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 内部使用 useVisionSession */ +function AppContent() { const [showConfig, setShowConfig] = useState(false); const [theme, setTheme] = useState(loadTheme); const [elapsed, setElapsed] = useState(0); @@ -88,28 +91,30 @@ function App() { return () => clearInterval(id); }, [isConnected]); + const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]); + return (
{/* ---- 顶部导航栏 ---- */}

CamTalk

- AI 视觉对话助手 + {tr("app.title")}
{isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && ( - {stats.queryCount} 次请求 · {stats.totalTokens} tokens + {stats.queryCount} {tr("app.stats.requests")} · {stats.totalTokens} tokens )} - {isConnected ? "已连接" : connectionStatus === "connecting" ? "连接中..." : "未连接"} + {isConnected ? tr("status.connected") : connectionStatus === "connecting" ? tr("status.connecting") : tr("status.disconnected")}
)} {isConnected && !isCameraOn && ( @@ -179,8 +184,8 @@ function App() { - 摄像头未开启 - 可在右侧聊天框打字对话 + {tr("video.cameraOff")} + {tr("video.cameraOff.hint")}
)} @@ -189,21 +194,21 @@ function App() {
{!isConnected ? ( ) : (
@@ -211,15 +216,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 +234,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..e19ada5 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"; @@ -56,8 +58,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText return (
💬 -

点击下方按钮开始对话

- 连接后可打字或语音与 AI 交互 +

{t("chat.empty.prompt")}

+ {t("chat.empty.hint")}
); } @@ -69,15 +71,15 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText {messages.length === 0 && !currentReply && (
💬 -

在下方输入文字开始对话

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

{t("chat.welcome.prompt")}

+ {t("chat.welcome.hint")}
)} {messages.map((msg, index) => (
- {msg.role === "user" ? "你" : "AI"} + {msg.role === "user" ? t("chat.userLabel") : "AI"}
{msg.content}
{msg.role === "assistant" && msg.tokensUsed !== undefined && ( @@ -110,7 +112,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText setInputText(e.target.value)} /> @@ -118,7 +120,7 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText type="submit" className="chat-input__send" disabled={!inputText.trim()} - title="发送" + title={t("chat.send")} > ➤ 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")}
- {isConnected && (stats.queryCount > 0 || stats.totalTokens > 0) && ( + {isConnected && stats.queryCount > 0 && ( - {stats.queryCount} {tr("app.stats.requests")} · {stats.totalTokens} tokens + {stats.queryCount} {tr("app.stats.requests")} )} -- 2.49.1 From 92f3f45c41d580bbb5827cb70598d587d51c8a38 Mon Sep 17 00:00:00 2001 From: cfy666 <3087823110@qq.com> Date: Sun, 14 Jun 2026 15:44:25 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E4=BE=A7=E8=BE=B9=E6=A0=8F=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=96=B0=E5=BB=BA/=E5=88=87=E6=8D=A2/?= =?UTF-8?q?=E5=88=A0=E9=99=A4/=E9=87=8D=E5=91=BD=E5=90=8D=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 类似 ChatGPT 的侧边栏布局,消息历史持久化到 localStorage。 - 新增 SessionSummary 类型和 localStorage 读写函数 - 新增 useSessionList Hook 管理会话列表 CRUD - 新增 SessionSidebar 组件(展开/折叠、行内重命名) - App.tsx 协调 useSessionList 和 useVisionSession - 消息变化时自动持久化,切换/新建会话时保存并加载 - 新增 i18n 翻译 key(中/英/日) --- frontend/src/App.css | 187 ++++++++++++++++++ frontend/src/App.tsx | 88 ++++++++- .../src/components/SessionSidebar/index.tsx | 160 +++++++++++++++ frontend/src/hooks/useSessionList.ts | 117 +++++++++++ frontend/src/hooks/useVisionSession.ts | 1 + frontend/src/lib/i18n/en-US.ts | 9 + frontend/src/lib/i18n/ja-JP.ts | 9 + frontend/src/lib/i18n/zh-CN.ts | 9 + frontend/src/lib/storage.ts | 50 ++++- frontend/src/types/index.ts | 10 + 10 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/SessionSidebar/index.tsx create mode 100644 frontend/src/hooks/useSessionList.ts 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 bb41857..2ef06ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,14 @@ // ============================================================ -// CamTalk — 主应用组件(Web 端双栏布局) +// CamTalk — 主应用组件(Web 端三栏布局:侧边栏 + 视频 + 聊天) // ============================================================ 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"; @@ -14,11 +16,12 @@ import type { Locale } from "./lib/i18n"; import type { Theme } from "./types"; import "./App.css"; -/** 内部组件,确保在 I18nContext.Provider 内部使用 useVisionSession */ +/** 内部组件,确保在 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 属性 @@ -37,8 +40,21 @@ function AppContent() { 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, @@ -93,6 +109,58 @@ function AppContent() { 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 (
{/* ---- 顶部导航栏 ---- */} @@ -134,9 +202,21 @@ function AppContent() { /> )} - {/* ---- 主体:左侧视频 + 右侧聊天 ---- */} + {/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */}
- {/* 左侧:视频预览 + 控制栏 */} + {/* 最左侧:会话历史侧边栏 */} + setSidebarCollapsed((v) => !v)} + onNewSession={handleNewSession} + onSelectSession={handleSelectSession} + onDeleteSession={handleDeleteSession} + onRenameSession={renameSession} + /> + + {/* 中间:视频预览 + 控制栏 */}
diff --git a/frontend/src/components/SessionSidebar/index.tsx b/frontend/src/components/SessionSidebar/index.tsx new file mode 100644 index 0000000..7fedd71 --- /dev/null +++ b/frontend/src/components/SessionSidebar/index.tsx @@ -0,0 +1,160 @@ +// ============================================================ +// SessionSidebar — 会话历史侧边栏 +// 职责:展示会话列表、新建/切换/删除/重命名会话 +// ============================================================ + +import { useState } from "react"; +import { useI18n } from "../../lib/i18n"; +import type { SessionSummary } from "../../types"; + +interface SessionSidebarProps { + sessions: SessionSummary[]; + activeSessionId: string | null; + collapsed: boolean; + onToggleCollapse: () => void; + onNewSession: () => void; + onSelectSession: (id: string) => void; + onDeleteSession: (id: string) => void; + onRenameSession: (id: string, title: string) => void; +} + +/** 格式化相对时间 */ +function formatRelativeTime(ts: number): string { + const now = Date.now(); + const diff = now - ts; + const min = 60 * 1000; + const hour = 60 * min; + const day = 24 * hour; + + if (diff < min) return "刚刚"; + if (diff < hour) return `${Math.floor(diff / min)}分钟前`; + if (diff < day) return `${Math.floor(diff / hour)}小时前`; + if (diff < 2 * day) return "昨天"; + if (diff < 7 * day) return `${Math.floor(diff / day)}天前`; + const d = new Date(ts); + return `${d.getMonth() + 1}/${d.getDate()}`; +} + +export function SessionSidebar({ + sessions, + activeSessionId, + collapsed, + onToggleCollapse, + onNewSession, + onSelectSession, + onDeleteSession, + onRenameSession, +}: SessionSidebarProps) { + const { t } = useI18n(); + const [editingId, setEditingId] = useState(null); + const [editTitle, setEditTitle] = useState(""); + + const handleStartRename = (id: string, currentTitle: string) => { + setEditingId(id); + setEditTitle(currentTitle); + }; + + const handleConfirmRename = () => { + if (editingId && editTitle.trim()) { + onRenameSession(editingId, editTitle.trim()); + } + setEditingId(null); + setEditTitle(""); + }; + + const handleCancelRename = () => { + setEditingId(null); + setEditTitle(""); + }; + + if (collapsed) { + return ( +
+ + +
+ ); + } + + return ( +
+
+ + +
+ +
+ {sessions.length === 0 ? ( +
{t("sidebar.empty")}
+ ) : ( + sessions.map((session) => ( +
onSelectSession(session.id)} + > + {editingId === session.id ? ( +
e.stopPropagation()}> + setEditTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleConfirmRename(); + if (e.key === "Escape") handleCancelRename(); + }} + autoFocus + /> + + +
+ ) : ( + <> +
+
{session.title}
+
+ {session.messageCount > 0 && ( + {session.messageCount}{t("sidebar.messages")} + )} + {formatRelativeTime(session.lastActiveAt)} +
+
+
+ + +
+ + )} +
+ )) + )} +
+
+ ); +} 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 0cc65fd..739afbf 100644 --- a/frontend/src/hooks/useVisionSession.ts +++ b/frontend/src/hooks/useVisionSession.ts @@ -458,6 +458,7 @@ export function useVisionSession() { return { messages, + setMessages, currentReply, isProcessing, isAudioPlaying, diff --git a/frontend/src/lib/i18n/en-US.ts b/frontend/src/lib/i18n/en-US.ts index 36b9415..eec5531 100644 --- a/frontend/src/lib/i18n/en-US.ts +++ b/frontend/src/lib/i18n/en-US.ts @@ -81,6 +81,15 @@ export const enUS: TranslationMap = { "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", diff --git a/frontend/src/lib/i18n/ja-JP.ts b/frontend/src/lib/i18n/ja-JP.ts index 66f9419..947973b 100644 --- a/frontend/src/lib/i18n/ja-JP.ts +++ b/frontend/src/lib/i18n/ja-JP.ts @@ -81,6 +81,15 @@ export const jaJP: TranslationMap = { "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": "カメラにアクセスできません", diff --git a/frontend/src/lib/i18n/zh-CN.ts b/frontend/src/lib/i18n/zh-CN.ts index f4a1c34..d085cde 100644 --- a/frontend/src/lib/i18n/zh-CN.ts +++ b/frontend/src/lib/i18n/zh-CN.ts @@ -81,6 +81,15 @@ export const zhCN: TranslationMap = { "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": "无法访问摄像头", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 4c78a0d..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"; @@ -52,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 { -- 2.49.1