feat: 新增会话历史侧边栏,支持新建/切换/删除/重命名会话
类似 ChatGPT 的侧边栏布局,消息历史持久化到 localStorage。 - 新增 SessionSummary 类型和 localStorage 读写函数 - 新增 useSessionList Hook 管理会话列表 CRUD - 新增 SessionSidebar 组件(展开/折叠、行内重命名) - App.tsx 协调 useSessionList 和 useVisionSession - 消息变化时自动持久化,切换/新建会话时保存并加载 - 新增 i18n 翻译 key(中/英/日)
This commit is contained in:
@@ -208,6 +208,193 @@ body {
|
|||||||
overflow: hidden;
|
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 {
|
.video-panel {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// CamTalk — 主应用组件(Web 端双栏布局)
|
// CamTalk — 主应用组件(Web 端三栏布局:侧边栏 + 视频 + 聊天)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useVisionSession } from "./hooks/useVisionSession";
|
import { useVisionSession } from "./hooks/useVisionSession";
|
||||||
|
import { useSessionList } from "./hooks/useSessionList";
|
||||||
import { VideoPreview } from "./components/VideoPreview";
|
import { VideoPreview } from "./components/VideoPreview";
|
||||||
import { ChatPanel } from "./components/ChatPanel";
|
import { ChatPanel } from "./components/ChatPanel";
|
||||||
import { ConfigPanel } from "./components/ConfigPanel";
|
import { ConfigPanel } from "./components/ConfigPanel";
|
||||||
|
import { SessionSidebar } from "./components/SessionSidebar";
|
||||||
import { ToastContainer } from "./components/Toast";
|
import { ToastContainer } from "./components/Toast";
|
||||||
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
import { loadConfig, loadTheme, saveTheme } from "./lib/storage";
|
||||||
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
import { I18nContext, parseLocale, t } from "./lib/i18n";
|
||||||
@@ -14,11 +16,12 @@ import type { Locale } from "./lib/i18n";
|
|||||||
import type { Theme } from "./types";
|
import type { Theme } from "./types";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
|
|
||||||
/** 内部组件,确保在 I18nContext.Provider 内部使用 useVisionSession */
|
/** 内部组件,确保在 I18nContext.Provider 内部使用 hooks */
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
const [theme, setTheme] = useState<Theme>(loadTheme);
|
const [theme, setTheme] = useState<Theme>(loadTheme);
|
||||||
const [elapsed, setElapsed] = useState(0);
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
// 切换主题时更新 <html> 的 data-theme 属性
|
// 切换主题时更新 <html> 的 data-theme 属性
|
||||||
@@ -37,8 +40,21 @@ function AppContent() {
|
|||||||
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
return `${m.toString().padStart(2, "0")}:${sec.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- 会话列表 ----
|
||||||
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
createSession,
|
||||||
|
deleteSession,
|
||||||
|
renameSession,
|
||||||
|
persistSession,
|
||||||
|
selectSession,
|
||||||
|
} = useSessionList();
|
||||||
|
|
||||||
|
// ---- 视觉会话 ----
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
|
setMessages,
|
||||||
currentReply,
|
currentReply,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isAudioPlaying,
|
isAudioPlaying,
|
||||||
@@ -93,6 +109,58 @@ function AppContent() {
|
|||||||
|
|
||||||
const { t: tr } = useMemo(() => ({ t: (key: string) => t(key, parseLocale(config.language)) }), [config.language]);
|
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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* ---- 顶部导航栏 ---- */}
|
{/* ---- 顶部导航栏 ---- */}
|
||||||
@@ -134,9 +202,21 @@ function AppContent() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ---- 主体:左侧视频 + 右侧聊天 ---- */}
|
{/* ---- 主体:侧边栏 + 视频 + 聊天 ---- */}
|
||||||
<div className="workspace">
|
<div className="workspace">
|
||||||
{/* 左侧:视频预览 + 控制栏 */}
|
{/* 最左侧:会话历史侧边栏 */}
|
||||||
|
<SessionSidebar
|
||||||
|
sessions={sessions}
|
||||||
|
activeSessionId={activeSessionId}
|
||||||
|
collapsed={sidebarCollapsed}
|
||||||
|
onToggleCollapse={() => setSidebarCollapsed((v) => !v)}
|
||||||
|
onNewSession={handleNewSession}
|
||||||
|
onSelectSession={handleSelectSession}
|
||||||
|
onDeleteSession={handleDeleteSession}
|
||||||
|
onRenameSession={renameSession}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 中间:视频预览 + 控制栏 */}
|
||||||
<div className="video-panel">
|
<div className="video-panel">
|
||||||
<div className="video-container">
|
<div className="video-container">
|
||||||
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
<VideoPreview ref={videoRef} isStreaming={!!stream} />
|
||||||
|
|||||||
160
frontend/src/components/SessionSidebar/index.tsx
Normal file
160
frontend/src/components/SessionSidebar/index.tsx
Normal file
@@ -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<string | null>(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 (
|
||||||
|
<div className="sidebar sidebar--collapsed">
|
||||||
|
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.expand")}>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
<button className="sidebar__new-btn" onClick={onNewSession} title={t("sidebar.new")}>
|
||||||
|
✚
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sidebar">
|
||||||
|
<div className="sidebar__header">
|
||||||
|
<button className="sidebar__new-btn sidebar__new-btn--full" onClick={onNewSession}>
|
||||||
|
✚ {t("sidebar.new")}
|
||||||
|
</button>
|
||||||
|
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.collapse")}>
|
||||||
|
◀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar__list">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||||
|
onClick={() => onSelectSession(session.id)}
|
||||||
|
>
|
||||||
|
{editingId === session.id ? (
|
||||||
|
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input
|
||||||
|
className="sidebar__edit-input"
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(e) => setEditTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleConfirmRename();
|
||||||
|
if (e.key === "Escape") handleCancelRename();
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="sidebar__edit-btn" onClick={handleConfirmRename}>✓</button>
|
||||||
|
<button className="sidebar__edit-btn" onClick={handleCancelRename}>✕</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="sidebar__item-content">
|
||||||
|
<div className="sidebar__item-title">{session.title}</div>
|
||||||
|
<div className="sidebar__item-meta">
|
||||||
|
{session.messageCount > 0 && (
|
||||||
|
<span>{session.messageCount}{t("sidebar.messages")}</span>
|
||||||
|
)}
|
||||||
|
<span>{formatRelativeTime(session.lastActiveAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sidebar__item-actions">
|
||||||
|
<button
|
||||||
|
className="sidebar__action-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleStartRename(session.id, session.title);
|
||||||
|
}}
|
||||||
|
title={t("sidebar.rename")}
|
||||||
|
>
|
||||||
|
✏
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteSession(session.id);
|
||||||
|
}}
|
||||||
|
title={t("sidebar.delete")}
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
frontend/src/hooks/useSessionList.ts
Normal file
117
frontend/src/hooks/useSessionList.ts
Normal file
@@ -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<SessionSummary[]>(() => loadSessionSummaries());
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(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<SessionSummary> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -458,6 +458,7 @@ export function useVisionSession() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
messages,
|
||||||
|
setMessages,
|
||||||
currentReply,
|
currentReply,
|
||||||
isProcessing,
|
isProcessing,
|
||||||
isAudioPlaying,
|
isAudioPlaying,
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ export const enUS: TranslationMap = {
|
|||||||
"error.INTERNAL_ERROR": "Internal server error, please retry",
|
"error.INTERNAL_ERROR": "Internal server error, please retry",
|
||||||
"error.unknown": "Unknown error",
|
"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
|
// Device errors
|
||||||
"error.vadInit": "VAD initialization failed",
|
"error.vadInit": "VAD initialization failed",
|
||||||
"error.cameraAccess": "Cannot access camera",
|
"error.cameraAccess": "Cannot access camera",
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ export const jaJP: TranslationMap = {
|
|||||||
"error.INTERNAL_ERROR": "サーバー内部エラー。再試行してください",
|
"error.INTERNAL_ERROR": "サーバー内部エラー。再試行してください",
|
||||||
"error.unknown": "不明なエラー",
|
"error.unknown": "不明なエラー",
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
"sidebar.new": "新しいチャット",
|
||||||
|
"sidebar.expand": "サイドバーを展開",
|
||||||
|
"sidebar.collapse": "サイドバーを折りたたむ",
|
||||||
|
"sidebar.empty": "会話履歴がありません",
|
||||||
|
"sidebar.messages": "件のメッセージ",
|
||||||
|
"sidebar.rename": "名前を変更",
|
||||||
|
"sidebar.delete": "削除",
|
||||||
|
|
||||||
// Device errors
|
// Device errors
|
||||||
"error.vadInit": "VAD初期化に失敗しました",
|
"error.vadInit": "VAD初期化に失敗しました",
|
||||||
"error.cameraAccess": "カメラにアクセスできません",
|
"error.cameraAccess": "カメラにアクセスできません",
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ export const zhCN: TranslationMap = {
|
|||||||
"error.INTERNAL_ERROR": "服务内部错误,请重试",
|
"error.INTERNAL_ERROR": "服务内部错误,请重试",
|
||||||
"error.unknown": "未知错误",
|
"error.unknown": "未知错误",
|
||||||
|
|
||||||
|
// Sidebar
|
||||||
|
"sidebar.new": "新对话",
|
||||||
|
"sidebar.expand": "展开侧边栏",
|
||||||
|
"sidebar.collapse": "收起侧边栏",
|
||||||
|
"sidebar.empty": "暂无会话记录",
|
||||||
|
"sidebar.messages": "条消息",
|
||||||
|
"sidebar.rename": "重命名",
|
||||||
|
"sidebar.delete": "删除",
|
||||||
|
|
||||||
// Device errors
|
// Device errors
|
||||||
"error.vadInit": "VAD 初始化失败",
|
"error.vadInit": "VAD 初始化失败",
|
||||||
"error.cameraAccess": "无法访问摄像头",
|
"error.cameraAccess": "无法访问摄像头",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 职责:会话配置持久化
|
// 职责:会话配置持久化
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import type { SessionConfig, Theme } from "../types";
|
import type { ChatMessage, SessionConfig, SessionSummary, Theme } from "../types";
|
||||||
|
|
||||||
const CONFIG_KEY = "camtalk:config";
|
const CONFIG_KEY = "camtalk:config";
|
||||||
const THEME_KEY = "camtalk:theme";
|
const THEME_KEY = "camtalk:theme";
|
||||||
@@ -52,3 +52,51 @@ export function saveTheme(theme: Theme): void {
|
|||||||
localStorage.setItem(THEME_KEY, theme);
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
} catch { /* ignore */ }
|
} 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 */ }
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export interface Session {
|
|||||||
config: SessionConfig;
|
config: SessionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 会话摘要(侧边栏列表用) */
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastActiveAt: number;
|
||||||
|
messageCount: number;
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 聊天消息 ----
|
// ---- 聊天消息 ----
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user