diff --git a/package-lock.json b/package-lock.json index ae9fa19..f7b32e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "framer-motion": "^12.38.0", + "idb": "^8.0.3", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", @@ -15843,6 +15844,12 @@ "node": ">=0.10.0" } }, + "node_modules/idb": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz", + "integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==", + "license": "ISC" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", diff --git a/package.json b/package.json index 38f8c4b..9080f31 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "framer-motion": "^12.38.0", + "idb": "^8.0.3", "js-cookie": "^3.0.5", "next": "^16.1.6", "next-auth": "^4.24.5", diff --git a/src/components/chat/AgentComposer.tsx b/src/components/chat/AgentComposer.tsx index a6484a9..1f4b558 100644 --- a/src/components/chat/AgentComposer.tsx +++ b/src/components/chat/AgentComposer.tsx @@ -25,6 +25,7 @@ import AttachFileRounded from "@mui/icons-material/AttachFileRounded"; type AgentComposerProps = { input: string; inputRef: React.RefObject; + isHydrating?: boolean; isStreaming: boolean; isListening: boolean; isSttSupported: boolean; @@ -40,6 +41,7 @@ type AgentComposerProps = { export const AgentComposer = ({ input, inputRef, + isHydrating = false, isStreaming, isListening, isSttSupported, @@ -52,7 +54,7 @@ export const AgentComposer = ({ onPresetSelect, }: AgentComposerProps) => { const theme = useTheme(); - const canSend = input.trim().length > 0 && !isStreaming; + const canSend = input.trim().length > 0 && !isStreaming && !isHydrating; const [isPresetOpen, setIsPresetOpen] = React.useState(false); return ( @@ -160,11 +162,12 @@ export const AgentComposer = ({ onSend(); } }} - placeholder="描述你的分析目标,或点击上方指令库..." + placeholder={isHydrating ? "正在加载对话记录..." : "描述你的分析目标,或点击上方指令库..."} fullWidth multiline maxRows={5} variant="standard" + disabled={isHydrating} InputProps={{ disableUnderline: true, sx: { px: 1, py: 0.5, fontSize: "1rem", lineHeight: 1.6, fontWeight: 500, color: "text.primary" }, @@ -199,7 +202,7 @@ export const AgentComposer = ({ ) : ( - Powered by DeepSeek V3 · TJWater Agent Intelligence + Powered by DeepSeek V4 · TJWater Agent Intelligence diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index b627e72..3885d58 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -7,37 +7,32 @@ import { Avatar, Box, IconButton, - ListItemIcon, - ListItemText, - Menu, - MenuItem, Stack, + Tooltip, Typography, alpha, useTheme, } from "@mui/material"; -import AddCommentRounded from "@mui/icons-material/AddCommentRounded"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; +import HistoryRounded from "@mui/icons-material/HistoryRounded"; type AgentHeaderProps = { isStreaming: boolean; - menuAnchorEl: HTMLElement | null; - onMenuOpen: (event: React.MouseEvent) => void; - onMenuClose: () => void; + isHistoryOpen: boolean; + onHistoryToggle: () => void; onNewConversation: () => void; onClose: () => void; }; export const AgentHeader = ({ isStreaming, - menuAnchorEl, - onMenuOpen, - onMenuClose, + isHistoryOpen, + onHistoryToggle, onNewConversation, onClose, }: AgentHeaderProps) => { const theme = useTheme(); - const isMenuOpen = Boolean(menuAnchorEl); return ( - - - - - TJWater Agent - - + + + TJWater Agent - - + + + - - - - - - - - + + + + + + + + - - - - - + + + + + + + + + + + + + + + + ); }; diff --git a/src/components/chat/AgentHistoryPanel.tsx b/src/components/chat/AgentHistoryPanel.tsx new file mode 100644 index 0000000..e29fdfd --- /dev/null +++ b/src/components/chat/AgentHistoryPanel.tsx @@ -0,0 +1,408 @@ +"use client"; + +import React from "react"; +import { motion } from "framer-motion"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Divider, + IconButton, + Paper, + Stack, + TextField, + Tooltip, + Typography, + alpha, +} from "@mui/material"; +import EditNoteRounded from "@mui/icons-material/EditNoteRounded"; +import DeleteOutlineRounded from "@mui/icons-material/DeleteOutlineRounded"; +import ChatBubbleOutlineRounded from "@mui/icons-material/ChatBubbleOutlineRounded"; +import SearchRounded from "@mui/icons-material/SearchRounded"; +import WarningRounded from "@mui/icons-material/WarningRounded"; +import type { ChatSessionSummary } from "./GlobalChatbox.types"; + +type AgentHistoryPanelProps = { + sessions: ChatSessionSummary[]; + activeSessionId?: string; + isHydrating?: boolean; + onNewSession: () => void; + onSelectSession: (sessionId: string) => void; + onDeleteSession: (sessionId: string) => void; +}; + +const formatRelativeDate = (timestamp: number) => { + const date = new Date(timestamp); + const now = new Date(); + const isSameDay = date.toDateString() === now.toDateString(); + if (isSameDay) { + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + } + + return date.toLocaleDateString("zh-CN", { + month: "numeric", + day: "numeric", + }); +}; + +const getDayStart = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + +const getSessionGroupLabel = (timestamp: number) => { + const now = new Date(); + const todayStart = getDayStart(now); + const yesterdayStart = todayStart - 24 * 60 * 60 * 1000; + const lastWeekStart = todayStart - 7 * 24 * 60 * 60 * 1000; + + if (timestamp >= todayStart) return "今天"; + if (timestamp >= yesterdayStart) return "昨天"; + if (timestamp >= lastWeekStart) return "过去 7 天"; + return "更早"; +}; + +export const AgentHistoryPanel = ({ + sessions, + activeSessionId, + isHydrating = false, + onNewSession, + onSelectSession, + onDeleteSession, +}: AgentHistoryPanelProps) => { + const [keyword, setKeyword] = React.useState(""); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [pendingDeleteSessionId, setPendingDeleteSessionId] = React.useState(null); + + const filteredSessions = React.useMemo(() => { + const normalizedKeyword = keyword.trim().toLowerCase(); + if (!normalizedKeyword) return sessions; + return sessions.filter((session) => session.title.toLowerCase().includes(normalizedKeyword)); + }, [keyword, sessions]); + + const groupedSessions = React.useMemo(() => { + const groups = new Map(); + + filteredSessions.forEach((session) => { + const label = getSessionGroupLabel(session.updatedAt); + const existing = groups.get(label); + if (existing) { + existing.push(session); + } else { + groups.set(label, [session]); + } + }); + + return Array.from(groups.entries()); + }, [filteredSessions]); + + const pendingDeleteSession = filteredSessions.find( + (session) => session.id === pendingDeleteSessionId, + ); + + return ( + <> + + + + + 历史会话 + + + 本地保存于浏览器 + + + + + + + + + + + + + setKeyword(event.target.value)} + placeholder="搜索历史会话" + size="small" + fullWidth + disabled={isHydrating} + InputProps={{ + startAdornment: , + sx: { + borderRadius: 3, + bgcolor: alpha("#fff", 0.62), + fontSize: "0.85rem", + }, + }} + /> + + + + + + {sessions.length === 0 ? ( + + + + 暂无历史会话 + + + 新建对话后会自动出现在这里 + + + ) : filteredSessions.length === 0 ? ( + + + + 未找到匹配会话 + + + 试试其他关键词 + + + ) : ( + + {groupedSessions.map(([groupLabel, groupSessions]) => ( + + + {groupLabel} + + + + {groupSessions.map((session) => { + const isActive = session.id === activeSessionId; + + return ( + onSelectSession(session.id)} + sx={{ + px: 1.25, + py: 1, + borderRadius: 3, + cursor: isHydrating ? "default" : "pointer", + bgcolor: isActive ? alpha("#00acc1", 0.12) : alpha("#fff", 0.56), + border: `1px solid ${isActive ? alpha("#00acc1", 0.25) : alpha("#fff", 0.72)}`, + boxShadow: isActive ? `0 8px 20px ${alpha("#00acc1", 0.12)}` : `0 4px 12px ${alpha("#000", 0.03)}`, + transition: "all 0.2s ease", + pointerEvents: isHydrating ? "none" : "auto", + "&:hover": { + bgcolor: isActive ? alpha("#00acc1", 0.14) : alpha("#fff", 0.86), + borderColor: alpha("#00acc1", 0.2), + }, + }} + > + + + + {session.title} + + + {formatRelativeDate(session.updatedAt)} + + + + + + { + event.stopPropagation(); + setPendingDeleteSessionId(session.id); + setIsDeleteDialogOpen(true); + }} + sx={{ + width: 24, + height: 24, + color: "text.secondary", + "&:hover": { + color: "error.main", + bgcolor: alpha("#ef5350", 0.08), + }, + }} + > + + + + + + + ); + })} + + + ))} + + )} + + + + setIsDeleteDialogOpen(false)} + TransitionProps={{ + onExited: () => setPendingDeleteSessionId(null) + }} + PaperProps={{ + sx: { + borderRadius: 4, + bgcolor: alpha("#fff", 0.85), + backdropFilter: "blur(24px)", + boxShadow: `0 16px 40px ${alpha("#000", 0.12)}`, + border: `1px solid ${alpha("#fff", 0.6)}`, + minWidth: 320, + }, + }} + > + + + + + + 删除确认 + + + + + 确定要删除 + {pendingDeleteSession ? ( + + “{pendingDeleteSession.title}” + + ) : ( + "该会话" + )} + 吗? +
+ 此操作不可撤销,删除后聊天记录将永久丢失。 +
+
+ + + + +
+ + ); +}; diff --git a/src/components/chat/GlobalChatbox.tsx b/src/components/chat/GlobalChatbox.tsx index 375771d..b636dff 100644 --- a/src/components/chat/GlobalChatbox.tsx +++ b/src/components/chat/GlobalChatbox.tsx @@ -5,6 +5,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material"; import { AgentComposer } from "./AgentComposer"; import { AgentHeader } from "./AgentHeader"; +import { AgentHistoryPanel } from "./AgentHistoryPanel"; import { AgentWorkspace } from "./AgentWorkspace"; import { Blob } from "./GlobalChatbox.parts"; import type { Props } from "./GlobalChatbox.types"; @@ -17,7 +18,7 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const [input, setInput] = useState(""); const [width, setWidth] = useState(520); const [isResizing, setIsResizing] = useState(false); - const [headerMenuAnchorEl, setHeaderMenuAnchorEl] = useState(null); + const [isHistoryOpen, setIsHistoryOpen] = useState(false); const bottomRef = useRef(null); const inputRef = useRef(null); @@ -47,15 +48,20 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { const handleToolCall = useAgentToolActions(); const { messages, + chatSessions, + activeStorageSessionId, branchGroups, branchTransition, + isHydrating, isStreaming, sendPrompt, regenerate, editAndResubmit, cycleBranch, abort, - reset, + createSession, + removeSession, + switchSession, } = useAgentChatSession({ onToolCall: handleToolCall, onBeforeSend: stopListening, @@ -88,24 +94,34 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { }, 0); }, []); - const handleHeaderMenuOpen = useCallback((event: React.MouseEvent) => { - setHeaderMenuAnchorEl(event.currentTarget); - }, []); - - const handleHeaderMenuClose = useCallback(() => { - setHeaderMenuAnchorEl(null); - }, []); - const handleNewConversation = useCallback(() => { handleStopSpeech(); stopListening(); - reset(); + void createSession(); setInput(""); - handleHeaderMenuClose(); window.setTimeout(() => { inputRef.current?.focus(); }, 0); - }, [handleHeaderMenuClose, handleStopSpeech, reset, stopListening]); + }, [createSession, handleStopSpeech, stopListening]); + + const handleHistoryToggle = useCallback(() => { + setIsHistoryOpen((prev) => !prev); + }, []); + + const handleSelectSession = useCallback( + (storageSessionId: string) => { + setInput(""); + void switchSession(storageSessionId); + }, + [switchSession], + ); + + const handleDeleteSession = useCallback( + (storageSessionId: string) => { + void removeSession(storageSessionId); + }, + [removeSession], + ); const handleMouseDown = useCallback((event: React.MouseEvent) => { event.preventDefault(); @@ -198,45 +214,91 @@ export const GlobalChatbox: React.FC = ({ open, onClose }) => { - + + setIsHistoryOpen(false)} + sx={{ + position: "absolute", + inset: 0, + bgcolor: alpha("#000", 0.05), + backdropFilter: "blur(2px)", + opacity: isHistoryOpen ? 1 : 0, + pointerEvents: isHistoryOpen ? "auto" : "none", + transition: "opacity 0.3s ease", + zIndex: 10, + }} + /> + + { + handleNewConversation(); + setIsHistoryOpen(false); + }} + onSelectSession={(id) => { + handleSelectSession(id); + setIsHistoryOpen(false); + }} + onDeleteSession={handleDeleteSession} + /> + - + + + + + + ); diff --git a/src/components/chat/GlobalChatbox.types.ts b/src/components/chat/GlobalChatbox.types.ts index f4e3d5c..35e75a9 100644 --- a/src/components/chat/GlobalChatbox.types.ts +++ b/src/components/chat/GlobalChatbox.types.ts @@ -61,8 +61,39 @@ export type Props = { export type SpeechState = "idle" | "playing" | "paused"; -export type PersistedChatState = { +export type LegacyPersistedChatState = { messages: Message[]; sessionId?: string; branchGroups?: BranchGroup[]; }; + +export type ChatSessionRecord = { + id: string; + title: string; + createdAt: number; + updatedAt: number; + sessionId?: string; + messages: Message[]; + branchGroups: BranchGroup[]; +}; + +export type ChatSessionSummary = { + id: string; + title: string; + createdAt: number; + updatedAt: number; +}; + +export type ChatStorageMeta = { + key: "chat-meta"; + activeSessionId?: string; + migratedFromLocalStorage?: boolean; +}; + +export type LoadedChatState = { + storageSessionId?: string; + title?: string; + messages: Message[]; + sessionId?: string; + branchGroups: BranchGroup[]; +}; diff --git a/src/components/chat/GlobalChatbox.utils.ts b/src/components/chat/GlobalChatbox.utils.ts index 44d1f46..a02769e 100644 --- a/src/components/chat/GlobalChatbox.utils.ts +++ b/src/components/chat/GlobalChatbox.utils.ts @@ -1,8 +1,7 @@ -import type { BranchGroup, Message, PersistedChatState } from "./GlobalChatbox.types"; +import type { BranchGroup, Message } from "./GlobalChatbox.types"; export const createId = () => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -export const CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; export const PRESET_PROMPTS = [ "分析当前管网中的水力瓶颈管道,并给出改造建议。", "帮我分析当前管网压力异常点,并按风险等级排序。", @@ -29,34 +28,6 @@ export const stripMarkdown = (md: string): string => .replace(/<[^>]+>/g, "") .trim(); -export const getInitialChatState = (): PersistedChatState => { - if (typeof window === "undefined") { - return { messages: [], sessionId: undefined }; - } - try { - const storedRaw = window.localStorage.getItem(CHAT_STORAGE_KEY); - if (!storedRaw) return { messages: [], sessionId: undefined }; - const parsed = JSON.parse(storedRaw) as PersistedChatState; - if (!Array.isArray(parsed.messages)) { - console.error("[GlobalChatbox] Invalid persisted messages format."); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], sessionId: undefined }; - } - return { - messages: Array.isArray(parsed.messages) ? parsed.messages : [], - sessionId: parsed.sessionId, - branchGroups: Array.isArray(parsed.branchGroups) ? parsed.branchGroups : [], - }; - } catch (error) { - console.error( - "[GlobalChatbox] Failed to read persisted chat state:", - error, - ); - window.localStorage.removeItem(CHAT_STORAGE_KEY); - return { messages: [], sessionId: undefined }; - } -}; - export const cloneMessage = (message: Message): Message => ({ ...message, progress: message.progress ? [...message.progress] : undefined, diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts new file mode 100644 index 0000000..1a540e5 --- /dev/null +++ b/src/components/chat/chatStorage.ts @@ -0,0 +1,346 @@ +import { openDB, type DBSchema } from "idb"; + +import type { + BranchGroup, + ChatSessionRecord, + ChatSessionSummary, + ChatStorageMeta, + LegacyPersistedChatState, + LoadedChatState, + Message, +} from "./GlobalChatbox.types"; +import { + cloneBranchGroups, + cloneMessages, + createId, +} from "./GlobalChatbox.utils"; + +const CHAT_DB_NAME = "tjwater-agent-chat"; +const CHAT_DB_VERSION = 1; +const SESSION_STORE = "sessions"; +const META_STORE = "meta"; +const META_KEY = "chat-meta" as const; +const LEGACY_CHAT_STORAGE_KEY = "tjwater_agent_chat_state_v1"; + +type ChatDB = DBSchema & { + sessions: { + key: string; + value: ChatSessionRecord; + indexes: { + "by-updatedAt": number; + }; + }; + meta: { + key: string; + value: ChatStorageMeta; + }; +}; + +const emptyLoadedChatState = (): LoadedChatState => ({ + storageSessionId: undefined, + title: undefined, + messages: [], + sessionId: undefined, + branchGroups: [], +}); + +const sanitizeMessages = (messages: Message[] | undefined) => + Array.isArray(messages) ? cloneMessages(messages) : []; + +const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => + Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; + +const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => { + if (!session) return emptyLoadedChatState(); + return { + storageSessionId: session.id, + title: session.title, + messages: sanitizeMessages(session.messages), + sessionId: session.sessionId, + branchGroups: sanitizeBranchGroups(session.branchGroups), + }; +}; + +const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({ + id: session.id, + title: session.title, + createdAt: session.createdAt, + updatedAt: session.updatedAt, +}); + +const buildSessionTitle = (messages: Message[]) => { + const firstUserMessage = messages.find((message) => message.role === "user"); + if (!firstUserMessage) return "新对话"; + const title = firstUserMessage.content.replace(/\s+/g, " ").trim(); + if (!title) return "新对话"; + return title.length > 24 ? `${title.slice(0, 24)}...` : title; +}; + +const getDb = () => + openDB(CHAT_DB_NAME, CHAT_DB_VERSION, { + upgrade(db) { + if (!db.objectStoreNames.contains(SESSION_STORE)) { + const sessionStore = db.createObjectStore(SESSION_STORE, { keyPath: "id" }); + sessionStore.createIndex("by-updatedAt", "updatedAt"); + } + + if (!db.objectStoreNames.contains(META_STORE)) { + db.createObjectStore(META_STORE, { keyPath: "key" }); + } + }, + }); + +const readLegacyChatState = (): LegacyPersistedChatState | null => { + if (typeof window === "undefined") return null; + + try { + const storedRaw = window.localStorage.getItem(LEGACY_CHAT_STORAGE_KEY); + if (!storedRaw) return null; + + const parsed = JSON.parse(storedRaw) as LegacyPersistedChatState; + if (!Array.isArray(parsed.messages)) { + window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); + return null; + } + + return { + messages: sanitizeMessages(parsed.messages), + sessionId: parsed.sessionId, + branchGroups: sanitizeBranchGroups(parsed.branchGroups), + }; + } catch (error) { + console.error("[GlobalChatbox] Failed to read legacy chat state:", error); + window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); + return null; + } +}; + +const clearLegacyChatState = () => { + if (typeof window === "undefined") return; + window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); +}; + +const getMeta = async () => { + const db = await getDb(); + return db.get(META_STORE, META_KEY); +}; + +const setMeta = async (meta: Omit) => { + const db = await getDb(); + await db.put(META_STORE, { + key: META_KEY, + ...meta, + }); +}; + +const getLatestSession = async () => { + const db = await getDb(); + const sessions = await db.getAll(SESSION_STORE); + if (sessions.length === 0) return undefined; + return sessions.sort((left, right) => right.updatedAt - left.updatedAt)[0]; +}; + +const migrateLegacyLocalStorage = async () => { + const meta = await getMeta(); + if (meta?.migratedFromLocalStorage) return; + + const legacyState = readLegacyChatState(); + if (!legacyState) { + await setMeta({ + activeSessionId: meta?.activeSessionId, + migratedFromLocalStorage: true, + }); + return; + } + + const hasContent = + legacyState.messages.length > 0 || + (legacyState.branchGroups?.length ?? 0) > 0 || + Boolean(legacyState.sessionId); + + if (!hasContent) { + clearLegacyChatState(); + await setMeta({ + activeSessionId: undefined, + migratedFromLocalStorage: true, + }); + return; + } + + const now = Date.now(); + const sessionRecord: ChatSessionRecord = { + id: createId(), + title: buildSessionTitle(legacyState.messages), + createdAt: now, + updatedAt: now, + sessionId: legacyState.sessionId, + messages: sanitizeMessages(legacyState.messages), + branchGroups: sanitizeBranchGroups(legacyState.branchGroups), + }; + + const db = await getDb(); + await db.put(SESSION_STORE, sessionRecord); + clearLegacyChatState(); + await setMeta({ + activeSessionId: sessionRecord.id, + migratedFromLocalStorage: true, + }); +}; + +export const loadActiveChatState = async (): Promise => { + if (typeof window === "undefined") return emptyLoadedChatState(); + + await migrateLegacyLocalStorage(); + + const meta = await getMeta(); + const db = await getDb(); + + if (meta?.activeSessionId) { + const activeSession = await db.get(SESSION_STORE, meta.activeSessionId); + if (activeSession) { + return toLoadedChatState(activeSession); + } + } + + const latestSession = await getLatestSession(); + if (!latestSession) { + return emptyLoadedChatState(); + } + + await setMeta({ + activeSessionId: latestSession.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return toLoadedChatState(latestSession); +}; + +export const saveActiveChatState = async ( + state: LoadedChatState, +): Promise => { + if (typeof window === "undefined") return state.storageSessionId; + + const hasContent = + state.messages.length > 0 || + state.branchGroups.length > 0 || + Boolean(state.sessionId); + + const db = await getDb(); + const existingSession = state.storageSessionId + ? await db.get(SESSION_STORE, state.storageSessionId) + : undefined; + const meta = await getMeta(); + + if (!hasContent) { + if (state.storageSessionId) { + await db.delete(SESSION_STORE, state.storageSessionId); + } + await setMeta({ + activeSessionId: undefined, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + return undefined; + } + + const now = Date.now(); + const storageSessionId = state.storageSessionId ?? createId(); + const computedTitle = buildSessionTitle(state.messages); + const preferredTitle = state.title?.trim(); + const finalTitle = preferredTitle || computedTitle; + const nextRecord: ChatSessionRecord = { + id: storageSessionId, + title: finalTitle, + createdAt: existingSession?.createdAt ?? now, + updatedAt: now, + sessionId: state.sessionId, + messages: sanitizeMessages(state.messages), + branchGroups: sanitizeBranchGroups(state.branchGroups), + }; + + await db.put(SESSION_STORE, nextRecord); + await setMeta({ + activeSessionId: storageSessionId, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return storageSessionId; +}; + +export const listChatSessions = async (): Promise => { + if (typeof window === "undefined") return []; + + await migrateLegacyLocalStorage(); + + const db = await getDb(); + const sessions = await db.getAll(SESSION_STORE); + return sessions + .sort((left, right) => right.updatedAt - left.updatedAt) + .map(toSessionSummary); +}; + +export const createEmptyChatSession = async (): Promise => { + if (typeof window === "undefined") return emptyLoadedChatState(); + + await migrateLegacyLocalStorage(); + + const now = Date.now(); + const session: ChatSessionRecord = { + id: createId(), + title: "新对话", + createdAt: now, + updatedAt: now, + sessionId: undefined, + messages: [], + branchGroups: [], + }; + + const db = await getDb(); + await db.put(SESSION_STORE, session); + const meta = await getMeta(); + await setMeta({ + activeSessionId: session.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return toLoadedChatState(session); +}; + +export const loadChatSessionById = async (sessionId: string): Promise => { + if (typeof window === "undefined") return emptyLoadedChatState(); + + await migrateLegacyLocalStorage(); + + const db = await getDb(); + const session = await db.get(SESSION_STORE, sessionId); + if (!session) { + return emptyLoadedChatState(); + } + + const meta = await getMeta(); + await setMeta({ + activeSessionId: session.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return toLoadedChatState(session); +}; + +export const deleteChatSession = async (sessionId: string): Promise => { + if (typeof window === "undefined") return undefined; + + const db = await getDb(); + await db.delete(SESSION_STORE, sessionId); + + const remainingSessions = await db.getAll(SESSION_STORE); + const nextActiveSession = remainingSessions.sort( + (left, right) => right.updatedAt - left.updatedAt, + )[0]; + const meta = await getMeta(); + + await setMeta({ + activeSessionId: nextActiveSession?.id, + migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + }); + + return nextActiveSession?.id; +}; diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index bd82922..f3105cd 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -9,16 +9,23 @@ import type { BranchGroup, BranchTransition, ChatProgress, + ChatSessionSummary, + LoadedChatState, Message, - PersistedChatState, } from "../GlobalChatbox.types"; import { - CHAT_STORAGE_KEY, cloneBranchGroups, cloneMessages, createId, - getInitialChatState, } from "../GlobalChatbox.utils"; +import { + createEmptyChatSession, + deleteChatSession, + listChatSessions, + loadActiveChatState, + loadChatSessionById, + saveActiveChatState, +} from "../chatStorage"; type UseAgentChatSessionOptions = { onToolCall: ( @@ -88,24 +95,20 @@ export const useAgentChatSession = ({ onToolCall, onBeforeSend, }: UseAgentChatSessionOptions) => { - const initialChatStateRef = useRef(null); - if (initialChatStateRef.current === null) { - initialChatStateRef.current = getInitialChatState(); - } + const storageSessionIdRef = useRef(undefined); + const hydrationCompletedRef = useRef(false); + const hydrationNonceRef = useRef(0); - const [messages, setMessages] = useState( - initialChatStateRef.current.messages, - ); - const [sessionId, setSessionId] = useState( - initialChatStateRef.current.sessionId, - ); - const [branchGroups, setBranchGroups] = useState( - initialChatStateRef.current.branchGroups ?? [], - ); + const [messages, setMessages] = useState([]); + const [sessionTitle, setSessionTitle] = useState(undefined); + const [sessionId, setSessionId] = useState(undefined); + const [branchGroups, setBranchGroups] = useState([]); + const [chatSessions, setChatSessions] = useState([]); const [branchTransition, setBranchTransition] = useState(null); const [isStreaming, setIsStreaming] = useState(false); + const [isHydrating, setIsHydrating] = useState(true); const abortRef = useRef(null); - const sessionIdRef = useRef(initialChatStateRef.current.sessionId); + const sessionIdRef = useRef(undefined); const cancelPromiseRef = useRef | null>(null); useEffect(() => { @@ -113,13 +116,74 @@ export const useAgentChatSession = ({ }, [sessionId]); useEffect(() => { - const state: PersistedChatState = { messages, sessionId, branchGroups }; - try { - window.localStorage.setItem(CHAT_STORAGE_KEY, JSON.stringify(state)); - } catch (error) { - console.error("[GlobalChatbox] Failed to persist chat state:", error); - } - }, [branchGroups, messages, sessionId]); + let cancelled = false; + + const hydrate = async () => { + try { + const [loadedState, sessions] = await Promise.all([ + loadActiveChatState(), + listChatSessions(), + ]); + if (cancelled) return; + + storageSessionIdRef.current = loadedState.storageSessionId; + sessionIdRef.current = loadedState.sessionId; + hydrationCompletedRef.current = true; + hydrationNonceRef.current += 1; + + setMessages(loadedState.messages); + setSessionTitle(loadedState.title); + setSessionId(loadedState.sessionId); + setBranchGroups(loadedState.branchGroups); + setChatSessions(sessions); + } catch (error) { + console.error("[GlobalChatbox] Failed to hydrate chat state:", error); + } finally { + if (!cancelled) { + setIsHydrating(false); + } + } + }; + + void hydrate(); + + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + if (isHydrating || !hydrationCompletedRef.current) return; + + const currentHydrationNonce = hydrationNonceRef.current; + const persistTimer = window.setTimeout(() => { + const state: LoadedChatState = { + storageSessionId: storageSessionIdRef.current, + title: sessionTitle, + messages, + sessionId, + branchGroups, + }; + + void saveActiveChatState(state) + .then((storageSessionId) => { + if (hydrationNonceRef.current !== currentHydrationNonce) return; + storageSessionIdRef.current = storageSessionId; + return listChatSessions(); + }) + .then((sessions) => { + if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return; + setChatSessions(sessions); + }) + .catch((error) => { + console.error("[GlobalChatbox] Failed to persist chat state:", error); + }); + }, 150); + + return () => { + window.clearTimeout(persistTimer); + }; + }, [branchGroups, isHydrating, messages, sessionId, sessionTitle]); useEffect(() => { setBranchGroups((prev) => { @@ -182,7 +246,7 @@ export const useAgentChatSession = ({ assistantMessage, }: PromptRunOptions) => { const prompt = rawPrompt.trim(); - if (!prompt || isStreaming) return; + if (!prompt || isStreaming || isHydrating) return; await cancelPromiseRef.current?.catch(() => undefined); onBeforeSend?.(); @@ -240,6 +304,11 @@ export const useAgentChatSession = ({ assistantMessageId: nextAssistantMessage.id, appendArtifact, }); + } else if (event.type === "session_title") { + const nextTitle = event.title.trim(); + if (nextTitle) { + setSessionTitle(nextTitle); + } } else if (event.type === "done") { setMessages((prev) => prev.map((message) => { @@ -321,7 +390,7 @@ export const useAgentChatSession = ({ setIsStreaming(false); } }, - [appendArtifact, isStreaming, messages, onBeforeSend, onToolCall], + [appendArtifact, isHydrating, isStreaming, messages, onBeforeSend, onToolCall], ); const abort = useCallback(() => { @@ -356,13 +425,115 @@ export const useAgentChatSession = ({ cancelPromiseRef.current = trackedCancelPromise; } setMessages([]); + setSessionTitle(undefined); setBranchGroups([]); setBranchTransition(null); setSessionId(undefined); sessionIdRef.current = undefined; + storageSessionIdRef.current = undefined; setIsStreaming(false); }, []); + const createSession = useCallback(async () => { + if (isHydrating || isStreaming) return; + + const controller = abortRef.current; + controller?.abort(); + setBranchTransition(null); + + const newState = await createEmptyChatSession(); + const sessions = await listChatSessions(); + + hydrationNonceRef.current += 1; + storageSessionIdRef.current = newState.storageSessionId; + sessionIdRef.current = newState.sessionId; + setMessages(newState.messages); + setSessionTitle(newState.title); + setSessionId(newState.sessionId); + setBranchGroups(newState.branchGroups); + setChatSessions(sessions); + setIsStreaming(false); + }, [isHydrating, isStreaming]); + + const switchSession = useCallback( + async (nextStorageSessionId: string) => { + if (isHydrating || isStreaming || storageSessionIdRef.current === nextStorageSessionId) { + return; + } + + setIsHydrating(true); + try { + const [nextState, sessions] = await Promise.all([ + loadChatSessionById(nextStorageSessionId), + listChatSessions(), + ]); + + hydrationNonceRef.current += 1; + storageSessionIdRef.current = nextState.storageSessionId; + sessionIdRef.current = nextState.sessionId; + setBranchTransition(null); + setMessages(nextState.messages); + setSessionTitle(nextState.title); + setSessionId(nextState.sessionId); + setBranchGroups(nextState.branchGroups); + setChatSessions(sessions); + } catch (error) { + console.error("[GlobalChatbox] Failed to switch chat session:", error); + } finally { + setIsHydrating(false); + } + }, + [isHydrating, isStreaming], + ); + + const removeSession = useCallback( + async (targetStorageSessionId: string) => { + if (isHydrating || isStreaming) return; + + try { + const nextActiveSessionId = await deleteChatSession(targetStorageSessionId); + const sessions = await listChatSessions(); + setChatSessions(sessions); + + if (storageSessionIdRef.current !== targetStorageSessionId) { + return; + } + + if (!nextActiveSessionId) { + hydrationNonceRef.current += 1; + storageSessionIdRef.current = undefined; + sessionIdRef.current = undefined; + setBranchTransition(null); + setMessages([]); + setSessionTitle(undefined); + setSessionId(undefined); + setBranchGroups([]); + return; + } + + setIsHydrating(true); + const [nextState, sessionsAfterDelete] = await Promise.all([ + loadChatSessionById(nextActiveSessionId), + listChatSessions(), + ]); + hydrationNonceRef.current += 1; + storageSessionIdRef.current = nextState.storageSessionId; + sessionIdRef.current = nextState.sessionId; + setBranchTransition(null); + setMessages(nextState.messages); + setSessionTitle(nextState.title); + setSessionId(nextState.sessionId); + setBranchGroups(nextState.branchGroups); + setChatSessions(sessionsAfterDelete); + } catch (error) { + console.error("[GlobalChatbox] Failed to delete chat session:", error); + } finally { + setIsHydrating(false); + } + }, + [isHydrating, isStreaming], + ); + const sendPrompt = useCallback( async (rawPrompt: string) => { await runPrompt({ prompt: rawPrompt }); @@ -371,7 +542,7 @@ export const useAgentChatSession = ({ ); const regenerate = useCallback(async () => { - if (isStreaming || messages.length === 0) return; + if (isHydrating || isStreaming || messages.length === 0) return; let lastUserIndex = messages.length - 1; while (lastUserIndex >= 0 && messages[lastUserIndex].role !== "user") { @@ -400,11 +571,11 @@ export const useAgentChatSession = ({ userMessage: nextUserMessage, assistantMessage: nextAssistantMessage, }); - }, [isStreaming, messages, runPrompt]); + }, [isHydrating, isStreaming, messages, runPrompt]); const editAndResubmit = useCallback( async (messageId: string, newContent: string) => { - if (isStreaming) return; + if (isHydrating || isStreaming) return; const trimmedContent = newContent.trim(); if (!trimmedContent) return; @@ -483,12 +654,12 @@ export const useAgentChatSession = ({ assistantMessage: nextAssistantMessage, }); }, - [isStreaming, messages, runPrompt], + [isHydrating, isStreaming, messages, runPrompt], ); const cycleBranch = useCallback( (rootMessageId: string, direction: -1 | 1) => { - if (isStreaming) return; + if (isHydrating || isStreaming) return; setBranchGroups((prev) => { const next = cloneBranchGroups(prev); @@ -519,13 +690,16 @@ export const useAgentChatSession = ({ return next; }); }, - [isStreaming, messages], + [isHydrating, isStreaming, messages], ); return { messages, + chatSessions, + activeStorageSessionId: storageSessionIdRef.current, branchGroups, branchTransition, + isHydrating, isStreaming, sessionId, sendPrompt, @@ -533,6 +707,9 @@ export const useAgentChatSession = ({ editAndResubmit, cycleBranch, abort, + createSession, reset, + removeSession, + switchSession, }; }; diff --git a/src/lib/chatStream.ts b/src/lib/chatStream.ts index 187cd59..f32a61b 100644 --- a/src/lib/chatStream.ts +++ b/src/lib/chatStream.ts @@ -4,6 +4,7 @@ import { config } from "@config/config"; export type StreamEvent = | { type: "token"; sessionId: string; content: string } | { type: "done"; sessionId: string } + | { type: "session_title"; sessionId: string; title: string } | { type: "progress"; sessionId: string; @@ -182,6 +183,12 @@ export const streamAgentChat = async ({ type: "done", sessionId: parsed.session_id ?? "", }); + } else if (event === "session_title") { + onEvent({ + type: "session_title", + sessionId: parsed.session_id ?? "", + title: typeof parsed.title === "string" ? parsed.title : "", + }); } else if (event === "error") { onEvent({ type: "error",