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">) =>
,
-}));
-
-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(),
+ );
+ });
});