diff --git a/package-lock.json b/package-lock.json index f7b32e5..ae9fa19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,6 @@ "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", @@ -15844,12 +15843,6 @@ "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 da5bcab..8460d87 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "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/AgentHeader.test.tsx b/src/components/chat/AgentHeader.test.tsx deleted file mode 100644 index a3d0dd1..0000000 --- a/src/components/chat/AgentHeader.test.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import React from "react"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { ThemeProvider, createTheme } from "@mui/material/styles"; - -import { AgentHeader } from "./AgentHeader"; - -jest.mock("next/image", () => ({ - __esModule: true, - default: (props: React.ComponentProps<"img">) => {props.alt, -})); - -const renderWithTheme = (ui: React.ReactElement) => - render({ui}); - -describe("AgentHeader", () => { - it("submits a renamed active session title", () => { - const onRenameSessionTitle = jest.fn(); - - renderWithTheme( - , - ); - - fireEvent.click(screen.getByRole("button", { name: "修改对话标题" })); - fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), { - target: { value: "更新后的标题" }, - }); - fireEvent.click(screen.getByLabelText("确认")); - - expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题"); - }); -}); diff --git a/src/components/chat/AgentHeader.tsx b/src/components/chat/AgentHeader.tsx index d6454ff..ee19419 100644 --- a/src/components/chat/AgentHeader.tsx +++ b/src/components/chat/AgentHeader.tsx @@ -44,7 +44,7 @@ export const AgentHeader = ({ onClose, }: AgentHeaderProps) => { const theme = useTheme(); - const displayTitle = sessionTitle?.trim() || "TJWater Agent"; + const displayTitle = sessionTitle?.trim() || "新对话"; const [isEditingTitle, setIsEditingTitle] = React.useState(false); const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); diff --git a/src/components/chat/chatStorage.test.ts b/src/components/chat/chatStorage.test.ts index 18f7119..e602d3b 100644 --- a/src/components/chat/chatStorage.test.ts +++ b/src/components/chat/chatStorage.test.ts @@ -1,142 +1,99 @@ -import type { ChatSessionRecord } from "./GlobalChatbox.types"; import { createEmptyChatSession, - loadChatSessionById, + loadActiveChatState, saveActiveChatState, - updateChatSessionTitle, } from "./chatStorage"; -type StoreName = "sessions" | "meta"; +const apiFetch = jest.fn(); -const stores: Record> = { - sessions: new Map(), - meta: new Map(), -}; - -const mockDb = { - get: jest.fn(async (storeName: StoreName, key: string) => stores[storeName].get(key)), - getAll: jest.fn(async (storeName: StoreName) => Array.from(stores[storeName].values())), - put: jest.fn(async (storeName: StoreName, value: { id?: string; key?: string }) => { - const key = storeName === "sessions" ? value.id : value.key; - if (!key) { - throw new Error(`Missing key for store ${storeName}`); - } - stores[storeName].set(key, value); - return key; - }), - delete: jest.fn(async (storeName: StoreName, key: string) => { - stores[storeName].delete(key); - }), -}; - -jest.mock("idb", () => ({ - openDB: jest.fn(async () => mockDb), +jest.mock("@/lib/apiFetch", () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), })); -describe("chatStorage timestamp semantics", () => { - let now = new Date("2026-05-19T09:00:00+08:00").getTime(); - let dateNowSpy: jest.SpyInstance; - +describe("chatStorage backend-only persistence", () => { beforeEach(() => { - stores.sessions.clear(); - stores.meta.clear(); - mockDb.get.mockClear(); - mockDb.getAll.mockClear(); - mockDb.put.mockClear(); - mockDb.delete.mockClear(); window.localStorage.clear(); - now = new Date("2026-05-19T09:00:00+08:00").getTime(); - dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now); + apiFetch.mockReset(); }); - afterEach(() => { - dateNowSpy.mockRestore(); - }); + it("loads the active remote session when localStorage has an active id", async () => { + window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1"); - it("keeps anchor and content timestamps when reopening an old session", async () => { - const record: ChatSessionRecord = { - id: "old-session", - title: "很久之前的会话", - isTitleManuallyEdited: false, - createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(), - updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(), - sessionId: "remote-1", - messages: [ - { - id: "message-1", - role: "user", - content: "老问题", - branchRootId: "message-1", - }, - ], - branchGroups: [], - }; - stores.sessions.set(record.id, record); - - const loadedState = await loadChatSessionById(record.id); - now = new Date("2026-05-19T09:30:00+08:00").getTime(); - await saveActiveChatState(loadedState); - - expect(stores.sessions.get(record.id)).toMatchObject({ - createdAt: record.createdAt, - updatedAt: record.updatedAt, + apiFetch.mockImplementation(async (url: string) => { + if (url.endsWith("/api/v1/agent/chat/session/chat-active-1")) { + return { + ok: true, + json: async () => ({ + id: "chat-active-1", + title: "已存在会话", + is_title_manually_edited: false, + session_id: "chat-active-1", + messages: [], + branch_groups: [], + }), + } as Response; + } + throw new Error(`Unexpected request ${url}`); }); + + const loaded = await loadActiveChatState(); + + expect(loaded.storageSessionId).toBe("chat-active-1"); + expect(loaded.title).toBe("已存在会话"); }); - it("does not change timestamps when renaming a session", async () => { - const record: ChatSessionRecord = { - id: "rename-session", - title: "旧标题", + it("creates a backend conversation when saving the first non-empty state", async () => { + apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url.endsWith("/api/v1/agent/chat/session")) { + expect(init?.method).toBe("POST"); + return { + ok: true, + json: async () => ({ session_id: "chat-new-1" }), + } as Response; + } + + if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) { + expect(init?.method).toBe("PUT"); + expect(JSON.parse(String(init?.body))).toMatchObject({ + title: "新对话", + is_title_manually_edited: false, + }); + return { + ok: true, + json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }), + } as Response; + } + + throw new Error(`Unexpected request ${url}`); + }); + + const savedSessionId = await saveActiveChatState({ + storageSessionId: undefined, + title: "新对话", isTitleManuallyEdited: false, - createdAt: new Date("2026-04-10T08:00:00+08:00").getTime(), - updatedAt: new Date("2026-04-10T08:05:00+08:00").getTime(), - sessionId: "remote-2", messages: [ { id: "message-2", role: "user", - content: "保留时间", + content: "第一条消息", branchRootId: "message-2", }, ], + sessionId: undefined, branchGroups: [], - }; - stores.sessions.set(record.id, record); - - now = new Date("2026-05-19T11:00:00+08:00").getTime(); - await updateChatSessionTitle(record.id, "新标题", { - isTitleManuallyEdited: true, }); - expect(stores.sessions.get(record.id)).toMatchObject({ - title: "新标题", - isTitleManuallyEdited: true, - createdAt: record.createdAt, - updatedAt: record.updatedAt, - }); + expect(savedSessionId).toBe("chat-new-1"); + expect(window.localStorage.getItem("tjwater_agent_active_session_id_v2")).toBe( + "chat-new-1", + ); }); - it("anchors createdAt to the first real message time for a new empty session", async () => { - const emptyState = await createEmptyChatSession(); - const storageSessionId = emptyState.storageSessionId; + it("does not persist a blank new session before there is chat content", async () => { + const session = await createEmptyChatSession(); - now = new Date("2026-05-19T09:05:00+08:00").getTime(); - await saveActiveChatState({ - ...emptyState, - messages: [ - { - id: "message-3", - role: "user", - content: "第一条消息", - branchRootId: "message-3", - }, - ], - sessionId: "remote-3", - }); - - expect(stores.sessions.get(storageSessionId!)).toMatchObject({ - createdAt: now, - updatedAt: now, - }); + expect(session.storageSessionId).toBeUndefined(); + expect(session.title).toBe("新对话"); + expect(apiFetch).not.toHaveBeenCalled(); }); }); diff --git a/src/components/chat/chatStorage.ts b/src/components/chat/chatStorage.ts index 46fc1d6..dd2a974 100644 --- a/src/components/chat/chatStorage.ts +++ b/src/components/chat/chatStorage.ts @@ -1,39 +1,21 @@ -import { openDB, type DBSchema } from "idb"; +import { apiFetch } from "@/lib/apiFetch"; +import { config } from "@config/config"; import type { BranchGroup, - ChatSessionRecord, ChatSessionSummary, - ChatStorageMeta, - LegacyPersistedChatState, LoadedChatState, Message, } from "./GlobalChatbox.types"; -import { - cloneBranchGroups, - cloneMessages, - createId, -} from "./GlobalChatbox.utils"; +import { cloneBranchGroups, cloneMessages } 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"; +const ACTIVE_SESSION_STORAGE_KEY = "tjwater_agent_active_session_id_v2"; -type ChatDB = DBSchema & { - sessions: { - key: string; - value: ChatSessionRecord; - indexes: { - "by-updatedAt": number; - }; - }; - meta: { - key: string; - value: ChatStorageMeta; - }; +type RemoteSessionPayload = { + id?: string; + title?: string; + created_at?: string | number; + updated_at?: string | number; }; const emptyLoadedChatState = (): LoadedChatState => ({ @@ -51,17 +33,6 @@ const sanitizeMessages = (messages: Message[] | undefined) => const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; -const serializeConversationState = (state: { - messages: Message[]; - branchGroups: BranchGroup[]; - sessionId?: string; -}) => - JSON.stringify({ - messages: sanitizeMessages(state.messages), - branchGroups: sanitizeBranchGroups(state.branchGroups), - sessionId: state.sessionId ?? null, - }); - const hasChatContent = (state: { messages: Message[]; branchGroups: BranchGroup[]; @@ -72,8 +43,8 @@ const hasChatContent = (state: { Boolean(state.sessionId); const compareSessionsByAnchorTime = ( - left: Pick, - right: Pick, + left: Pick, + right: Pick, ) => { const createdAtDiff = right.createdAt - left.createdAt; if (createdAtDiff !== 0) return createdAtDiff; @@ -84,167 +55,203 @@ const compareSessionsByAnchorTime = ( return right.id.localeCompare(left.id); }; -const toLoadedChatState = ( - session: ChatSessionRecord | undefined, -): LoadedChatState => { - if (!session) return emptyLoadedChatState(); - return { - storageSessionId: session.id, - title: session.title, - isTitleManuallyEdited: session.isTitleManuallyEdited ?? false, - messages: sanitizeMessages(session.messages), - sessionId: session.sessionId, - branchGroups: sanitizeBranchGroups(session.branchGroups), - }; +const toMillis = (value: string | number | undefined) => + typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now(); + +const normalizeTitle = (value?: string) => value?.trim() || "新对话"; + +const getStoredActiveSessionId = () => { + if (typeof window === "undefined") return undefined; + const stored = window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY)?.trim(); + return stored || undefined; }; -const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({ - id: session.id, - title: session.title, - createdAt: session.createdAt, - updatedAt: session.updatedAt, -}); - -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 = () => { +const setStoredActiveSessionId = (sessionId?: string) => { if (typeof window === "undefined") return; - window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY); + if (sessionId) { + window.localStorage.setItem(ACTIVE_SESSION_STORAGE_KEY, sessionId); + return; + } + window.localStorage.removeItem(ACTIVE_SESSION_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 fetchRemoteChatSessions = async (): Promise => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, { + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, }); -}; - -const getLatestSession = async () => { - const db = await getDb(); - const sessions = await db.getAll(SESSION_STORE); - if (sessions.length === 0) return undefined; - return sessions.sort(compareSessionsByAnchorTime)[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; + if (!response.ok) { + throw new Error(await response.text()); } - - 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: "新对话", - isTitleManuallyEdited: false, - createdAt: now, - updatedAt: now, - sessionId: legacyState.sessionId, - messages: sanitizeMessages(legacyState.messages), - branchGroups: sanitizeBranchGroups(legacyState.branchGroups), + const payload = (await response.json()) as { + sessions?: RemoteSessionPayload[]; }; + return (payload.sessions ?? []) + .map((session) => ({ + id: session.id ?? "", + title: normalizeTitle(session.title), + createdAt: toMillis(session.created_at), + updatedAt: toMillis(session.updated_at), + })) + .filter((session) => Boolean(session.id)) + .sort(compareSessionsByAnchorTime); +}; - const db = await getDb(); - await db.put(SESSION_STORE, sessionRecord); - clearLegacyChatState(); - await setMeta({ - activeSessionId: sessionRecord.id, - migratedFromLocalStorage: true, +const fetchRemoteChatSession = async (sessionId: string): Promise => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, + { + method: "GET", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + if (response.status === 404) { + return emptyLoadedChatState(); + } + throw new Error(await response.text()); + } + const payload = (await response.json()) as { + id: string; + title?: string; + is_title_manually_edited?: boolean; + session_id?: string; + messages?: Message[]; + branch_groups?: BranchGroup[]; + }; + return { + storageSessionId: payload.id, + title: normalizeTitle(payload.title), + isTitleManuallyEdited: payload.is_title_manually_edited ?? false, + messages: sanitizeMessages(payload.messages), + sessionId: payload.session_id, + branchGroups: sanitizeBranchGroups(payload.branch_groups), + }; +}; + +const createRemoteChatSession = async (payload?: { + sessionId?: string; + parentSessionId?: string; +}) => { + const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + session_id: payload?.sessionId, + parent_session_id: payload?.parentSessionId, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, }); + if (!response.ok) { + throw new Error(await response.text()); + } + const body = (await response.json()) as { + session_id?: string; + }; + const sessionId = body.session_id?.trim(); + if (!sessionId) { + throw new Error("backend did not return session_id"); + } + return sessionId; +}; + +const saveRemoteChatState = async ( + sessionId: string, + state: LoadedChatState, +): Promise => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title: normalizeTitle(state.title), + is_title_manually_edited: state.isTitleManuallyEdited ?? false, + messages: sanitizeMessages(state.messages), + branch_groups: sanitizeBranchGroups(state.branchGroups), + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } + const payload = (await response.json()) as { id?: string; session_id?: string }; + return payload.id ?? payload.session_id ?? sessionId; +}; + +const updateRemoteChatSessionTitle = async ( + sessionId: string, + title: string, + isTitleManuallyEdited?: boolean, +) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}/title`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + title, + is_title_manually_edited: isTitleManuallyEdited, + }), + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok) { + throw new Error(await response.text()); + } +}; + +const deleteRemoteChatSession = async (sessionId: string) => { + const response = await apiFetch( + `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`, + { + method: "DELETE", + projectHeaderMode: "include", + userHeaderMode: "include", + skipAuthRedirect: true, + }, + ); + if (!response.ok && response.status !== 404) { + throw new Error(await response.text()); + } }; 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 activeSessionId = getStoredActiveSessionId(); + if (activeSessionId) { + const activeSession = await fetchRemoteChatSession(activeSessionId); + if (activeSession.storageSessionId) { + return activeSession; } + setStoredActiveSessionId(undefined); } - const latestSession = await getLatestSession(); + const sessions = await fetchRemoteChatSessions(); + const latestSession = sessions[0]; if (!latestSession) { return emptyLoadedChatState(); } - - await setMeta({ - activeSessionId: latestSession.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - - return toLoadedChatState(latestSession); + setStoredActiveSessionId(latestSession.id); + return await fetchRemoteChatSession(latestSession.id); }; export const saveActiveChatState = async ( @@ -252,68 +259,28 @@ export const saveActiveChatState = async ( ): Promise => { if (typeof window === "undefined") return state.storageSessionId; - const hasContent = hasChatContent(state); - - 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, - }); + if (!hasChatContent(state)) { + setStoredActiveSessionId(undefined); return undefined; } - const now = Date.now(); - const storageSessionId = state.storageSessionId ?? createId(); - const preferredTitle = state.title?.trim(); - const finalTitle = preferredTitle || existingSession?.title || "新对话"; - const hasContentChanged = - !existingSession || - (existingSession && serializeConversationState(existingSession)) !== - serializeConversationState(state); - const shouldAnchorCreatedAtToFirstMessage = - existingSession && !hasChatContent(existingSession) && hasContent; - const nextRecord: ChatSessionRecord = { - id: storageSessionId, - title: finalTitle, - isTitleManuallyEdited: - state.isTitleManuallyEdited ?? - existingSession?.isTitleManuallyEdited ?? - false, - createdAt: shouldAnchorCreatedAtToFirstMessage - ? now - : existingSession?.createdAt ?? now, - updatedAt: hasContentChanged ? now : existingSession?.updatedAt ?? now, - sessionId: state.sessionId, - messages: sanitizeMessages(state.messages), - branchGroups: sanitizeBranchGroups(state.branchGroups), - }; + let remoteSessionId = state.sessionId ?? state.storageSessionId; + if (!remoteSessionId) { + remoteSessionId = await createRemoteChatSession(); + } - await db.put(SESSION_STORE, nextRecord); - await setMeta({ - activeSessionId: storageSessionId, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, + const savedSessionId = await saveRemoteChatState(remoteSessionId, { + ...state, + storageSessionId: remoteSessionId, + sessionId: remoteSessionId, }); - - return storageSessionId; + setStoredActiveSessionId(savedSessionId); + return savedSessionId; }; 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(compareSessionsByAnchorTime).map(toSessionSummary); + return await fetchRemoteChatSessions(); }; export const updateChatSessionTitle = async ( @@ -327,45 +294,21 @@ export const updateChatSessionTitle = async ( const normalizedTitle = title.trim(); if (!normalizedTitle) return; - - const db = await getDb(); - const session = await db.get(SESSION_STORE, storageSessionId); - if (!session) return; - - await db.put(SESSION_STORE, { - ...session, - title: normalizedTitle, - isTitleManuallyEdited: - options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false, - }); + await updateRemoteChatSessionTitle( + storageSessionId, + normalizedTitle, + options?.isTitleManuallyEdited, + ); }; export const createEmptyChatSession = async (): Promise => { if (typeof window === "undefined") return emptyLoadedChatState(); - await migrateLegacyLocalStorage(); - - const now = Date.now(); - const session: ChatSessionRecord = { - id: createId(), + setStoredActiveSessionId(undefined); + return { + ...emptyLoadedChatState(), title: "新对话", - isTitleManuallyEdited: false, - 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 ( @@ -373,21 +316,11 @@ export const loadChatSessionById = async ( ): 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 loaded = await fetchRemoteChatSession(sessionId); + if (loaded.storageSessionId) { + setStoredActiveSessionId(sessionId); } - - const meta = await getMeta(); - await setMeta({ - activeSessionId: session.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - - return toLoadedChatState(session); + return loaded; }; export const deleteChatSession = async ( @@ -395,19 +328,8 @@ export const deleteChatSession = async ( ): 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( - compareSessionsByAnchorTime, - )[0]; - const meta = await getMeta(); - - await setMeta({ - activeSessionId: nextActiveSession?.id, - migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, - }); - + await deleteRemoteChatSession(sessionId); + const nextActiveSession = (await listChatSessions())[0]; + setStoredActiveSessionId(nextActiveSession?.id); return nextActiveSession?.id; }; diff --git a/src/components/chat/hooks/useAgentChatSession.test.tsx b/src/components/chat/hooks/useAgentChatSession.test.tsx index 14f391f..79163da 100644 --- a/src/components/chat/hooks/useAgentChatSession.test.tsx +++ b/src/components/chat/hooks/useAgentChatSession.test.tsx @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { useAgentChatSession } from "./useAgentChatSession"; +import { streamAgentChat } from "@/lib/chatStream"; jest.mock("@/lib/chatStream", () => ({ abortAgentChat: jest.fn(async () => undefined), @@ -12,6 +13,7 @@ jest.mock("@/lib/chatStream", () => ({ const loadActiveChatState = jest.fn(); const listChatSessions = jest.fn(); +const updateChatSessionTitle = jest.fn(); jest.mock("../chatStorage", () => ({ deleteChatSession: jest.fn(async () => undefined), @@ -26,13 +28,15 @@ jest.mock("../chatStorage", () => ({ branchGroups: [], })), saveActiveChatState: jest.fn(async (state) => state.storageSessionId), - updateChatSessionTitle: jest.fn(async () => undefined), + updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args), })); describe("useAgentChatSession", () => { beforeEach(() => { loadActiveChatState.mockReset(); listChatSessions.mockReset(); + updateChatSessionTitle.mockReset(); + jest.mocked(streamAgentChat).mockReset(); loadActiveChatState.mockResolvedValue({ storageSessionId: undefined, @@ -98,4 +102,46 @@ describe("useAgentChatSession", () => { }, ]); }); + + it("ignores generated session titles after the title was edited manually", async () => { + listChatSessions.mockResolvedValue([]); + loadActiveChatState.mockResolvedValue({ + storageSessionId: "session-1", + title: "手动标题", + isTitleManuallyEdited: true, + messages: [], + sessionId: "session-1", + branchGroups: [], + }); + jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "session_title", + sessionId: "session-1", + title: "自动标题", + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("帮我分析一下"); + }); + + expect(result.current.sessionTitle).toBe("手动标题"); + expect(updateChatSessionTitle).not.toHaveBeenCalledWith( + "session-1", + "自动标题", + expect.anything(), + ); + }); });