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 {