Refine chat session storage and title handling
Build Push and Deploy / docker-image (push) Successful in 8s
Build Push and Deploy / deploy-fallback-log (push) Has been skipped

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-05-21 17:33:48 +08:00
parent e4d45300b1
commit 4bf99e8069
7 changed files with 330 additions and 453 deletions
-7
View File
@@ -30,7 +30,6 @@
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.5", "echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
@@ -15844,12 +15843,6 @@
"node": ">=0.10.0" "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": { "node_modules/ieee754": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-1
View File
@@ -39,7 +39,6 @@
"echarts": "^6.0.0", "echarts": "^6.0.0",
"echarts-for-react": "^3.0.5", "echarts-for-react": "^3.0.5",
"framer-motion": "^12.38.0", "framer-motion": "^12.38.0",
"idb": "^8.0.3",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "^16.1.6", "next": "^16.1.6",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
-40
View File
@@ -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">) => <img {...props} alt={props.alt ?? ""} />,
}));
const renderWithTheme = (ui: React.ReactElement) =>
render(<ThemeProvider theme={createTheme()}>{ui}</ThemeProvider>);
describe("AgentHeader", () => {
it("submits a renamed active session title", () => {
const onRenameSessionTitle = jest.fn();
renderWithTheme(
<AgentHeader
sessionTitle="原始标题"
canRenameSessionTitle
isStreaming={false}
isHistoryOpen={false}
onHistoryToggle={jest.fn()}
onRenameSessionTitle={onRenameSessionTitle}
onNewConversation={jest.fn()}
onClose={jest.fn()}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "修改对话标题" }));
fireEvent.change(screen.getByPlaceholderText("请输入对话标题"), {
target: { value: "更新后的标题" },
});
fireEvent.click(screen.getByLabelText("确认"));
expect(onRenameSessionTitle).toHaveBeenCalledWith("更新后的标题");
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ export const AgentHeader = ({
onClose, onClose,
}: AgentHeaderProps) => { }: AgentHeaderProps) => {
const theme = useTheme(); const theme = useTheme();
const displayTitle = sessionTitle?.trim() || "TJWater Agent"; const displayTitle = sessionTitle?.trim() || "新对话";
const [isEditingTitle, setIsEditingTitle] = React.useState(false); const [isEditingTitle, setIsEditingTitle] = React.useState(false);
const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || ""); const [draftTitle, setDraftTitle] = React.useState(sessionTitle?.trim() || "");
+67 -110
View File
@@ -1,142 +1,99 @@
import type { ChatSessionRecord } from "./GlobalChatbox.types";
import { import {
createEmptyChatSession, createEmptyChatSession,
loadChatSessionById, loadActiveChatState,
saveActiveChatState, saveActiveChatState,
updateChatSessionTitle,
} from "./chatStorage"; } from "./chatStorage";
type StoreName = "sessions" | "meta"; const apiFetch = jest.fn();
const stores: Record<StoreName, Map<string, any>> = { jest.mock("@/lib/apiFetch", () => ({
sessions: new Map(), apiFetch: (...args: unknown[]) => apiFetch(...args),
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),
})); }));
describe("chatStorage timestamp semantics", () => { describe("chatStorage backend-only persistence", () => {
let now = new Date("2026-05-19T09:00:00+08:00").getTime();
let dateNowSpy: jest.SpyInstance<number, []>;
beforeEach(() => { beforeEach(() => {
stores.sessions.clear();
stores.meta.clear();
mockDb.get.mockClear();
mockDb.getAll.mockClear();
mockDb.put.mockClear();
mockDb.delete.mockClear();
window.localStorage.clear(); window.localStorage.clear();
now = new Date("2026-05-19T09:00:00+08:00").getTime(); apiFetch.mockReset();
dateNowSpy = jest.spyOn(Date, "now").mockImplementation(() => now);
}); });
afterEach(() => { it("loads the active remote session when localStorage has an active id", async () => {
dateNowSpy.mockRestore(); window.localStorage.setItem("tjwater_agent_active_session_id_v2", "chat-active-1");
});
it("keeps anchor and content timestamps when reopening an old session", async () => { apiFetch.mockImplementation(async (url: string) => {
const record: ChatSessionRecord = { if (url.endsWith("/api/v1/agent/chat/session/chat-active-1")) {
id: "old-session", return {
title: "很久之前的会话", ok: true,
isTitleManuallyEdited: false, json: async () => ({
createdAt: new Date("2026-04-01T10:00:00+08:00").getTime(), id: "chat-active-1",
updatedAt: new Date("2026-04-01T10:30:00+08:00").getTime(), title: "已存在会话",
sessionId: "remote-1", is_title_manually_edited: false,
messages: [ session_id: "chat-active-1",
{ messages: [],
id: "message-1", branch_groups: [],
role: "user", }),
content: "老问题", } as Response;
branchRootId: "message-1", }
}, throw new Error(`Unexpected request ${url}`);
],
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,
}); });
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 () => { it("creates a backend conversation when saving the first non-empty state", async () => {
const record: ChatSessionRecord = { apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
id: "rename-session", if (url.endsWith("/api/v1/agent/chat/session")) {
title: "旧标题", 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, 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: [ messages: [
{ {
id: "message-2", id: "message-2",
role: "user", role: "user",
content: "保留时间", content: "第一条消息",
branchRootId: "message-2", branchRootId: "message-2",
}, },
], ],
sessionId: undefined,
branchGroups: [], 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({ expect(savedSessionId).toBe("chat-new-1");
title: "新标题", expect(window.localStorage.getItem("tjwater_agent_active_session_id_v2")).toBe(
isTitleManuallyEdited: true, "chat-new-1",
createdAt: record.createdAt, );
updatedAt: record.updatedAt,
});
}); });
it("anchors createdAt to the first real message time for a new empty session", async () => { it("does not persist a blank new session before there is chat content", async () => {
const emptyState = await createEmptyChatSession(); const session = await createEmptyChatSession();
const storageSessionId = emptyState.storageSessionId;
now = new Date("2026-05-19T09:05:00+08:00").getTime(); expect(session.storageSessionId).toBeUndefined();
await saveActiveChatState({ expect(session.title).toBe("新对话");
...emptyState, expect(apiFetch).not.toHaveBeenCalled();
messages: [
{
id: "message-3",
role: "user",
content: "第一条消息",
branchRootId: "message-3",
},
],
sessionId: "remote-3",
});
expect(stores.sessions.get(storageSessionId!)).toMatchObject({
createdAt: now,
updatedAt: now,
});
}); });
}); });
+215 -293
View File
@@ -1,39 +1,21 @@
import { openDB, type DBSchema } from "idb"; import { apiFetch } from "@/lib/apiFetch";
import { config } from "@config/config";
import type { import type {
BranchGroup, BranchGroup,
ChatSessionRecord,
ChatSessionSummary, ChatSessionSummary,
ChatStorageMeta,
LegacyPersistedChatState,
LoadedChatState, LoadedChatState,
Message, Message,
} from "./GlobalChatbox.types"; } from "./GlobalChatbox.types";
import { import { cloneBranchGroups, cloneMessages } from "./GlobalChatbox.utils";
cloneBranchGroups,
cloneMessages,
createId,
} from "./GlobalChatbox.utils";
const CHAT_DB_NAME = "tjwater-agent-chat"; const ACTIVE_SESSION_STORAGE_KEY = "tjwater_agent_active_session_id_v2";
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 & { type RemoteSessionPayload = {
sessions: { id?: string;
key: string; title?: string;
value: ChatSessionRecord; created_at?: string | number;
indexes: { updated_at?: string | number;
"by-updatedAt": number;
};
};
meta: {
key: string;
value: ChatStorageMeta;
};
}; };
const emptyLoadedChatState = (): LoadedChatState => ({ const emptyLoadedChatState = (): LoadedChatState => ({
@@ -51,17 +33,6 @@ const sanitizeMessages = (messages: Message[] | undefined) =>
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) => const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : []; 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: { const hasChatContent = (state: {
messages: Message[]; messages: Message[];
branchGroups: BranchGroup[]; branchGroups: BranchGroup[];
@@ -72,8 +43,8 @@ const hasChatContent = (state: {
Boolean(state.sessionId); Boolean(state.sessionId);
const compareSessionsByAnchorTime = ( const compareSessionsByAnchorTime = (
left: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">, left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
right: Pick<ChatSessionRecord, "id" | "createdAt" | "updatedAt">, right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
) => { ) => {
const createdAtDiff = right.createdAt - left.createdAt; const createdAtDiff = right.createdAt - left.createdAt;
if (createdAtDiff !== 0) return createdAtDiff; if (createdAtDiff !== 0) return createdAtDiff;
@@ -84,167 +55,203 @@ const compareSessionsByAnchorTime = (
return right.id.localeCompare(left.id); return right.id.localeCompare(left.id);
}; };
const toLoadedChatState = ( const toMillis = (value: string | number | undefined) =>
session: ChatSessionRecord | undefined, typeof value === "number" ? value : value ? new Date(value).getTime() : Date.now();
): LoadedChatState => {
if (!session) return emptyLoadedChatState(); const normalizeTitle = (value?: string) => value?.trim() || "新对话";
return {
storageSessionId: session.id, const getStoredActiveSessionId = () => {
title: session.title, if (typeof window === "undefined") return undefined;
isTitleManuallyEdited: session.isTitleManuallyEdited ?? false, const stored = window.localStorage.getItem(ACTIVE_SESSION_STORAGE_KEY)?.trim();
messages: sanitizeMessages(session.messages), return stored || undefined;
sessionId: session.sessionId,
branchGroups: sanitizeBranchGroups(session.branchGroups),
};
}; };
const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({ const setStoredActiveSessionId = (sessionId?: string) => {
id: session.id,
title: session.title,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
});
const getDb = () =>
openDB<ChatDB>(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; 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 fetchRemoteChatSessions = async (): Promise<ChatSessionSummary[]> => {
const db = await getDb(); const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/sessions`, {
return db.get(META_STORE, META_KEY); method: "GET",
}; projectHeaderMode: "include",
userHeaderMode: "include",
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => { skipAuthRedirect: true,
const db = await getDb();
await db.put(META_STORE, {
key: META_KEY,
...meta,
}); });
}; if (!response.ok) {
throw new Error(await response.text());
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;
} }
const payload = (await response.json()) as {
const hasContent = sessions?: RemoteSessionPayload[];
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),
}; };
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(); const fetchRemoteChatSession = async (sessionId: string): Promise<LoadedChatState> => {
await db.put(SESSION_STORE, sessionRecord); const response = await apiFetch(
clearLegacyChatState(); `${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
await setMeta({ {
activeSessionId: sessionRecord.id, method: "GET",
migratedFromLocalStorage: true, 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<string> => {
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<LoadedChatState> => { export const loadActiveChatState = async (): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState(); if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage(); const activeSessionId = getStoredActiveSessionId();
if (activeSessionId) {
const meta = await getMeta(); const activeSession = await fetchRemoteChatSession(activeSessionId);
const db = await getDb(); if (activeSession.storageSessionId) {
return activeSession;
if (meta?.activeSessionId) {
const activeSession = await db.get(SESSION_STORE, meta.activeSessionId);
if (activeSession) {
return toLoadedChatState(activeSession);
} }
setStoredActiveSessionId(undefined);
} }
const latestSession = await getLatestSession(); const sessions = await fetchRemoteChatSessions();
const latestSession = sessions[0];
if (!latestSession) { if (!latestSession) {
return emptyLoadedChatState(); return emptyLoadedChatState();
} }
setStoredActiveSessionId(latestSession.id);
await setMeta({ return await fetchRemoteChatSession(latestSession.id);
activeSessionId: latestSession.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(latestSession);
}; };
export const saveActiveChatState = async ( export const saveActiveChatState = async (
@@ -252,68 +259,28 @@ export const saveActiveChatState = async (
): Promise<string | undefined> => { ): Promise<string | undefined> => {
if (typeof window === "undefined") return state.storageSessionId; if (typeof window === "undefined") return state.storageSessionId;
const hasContent = hasChatContent(state); if (!hasChatContent(state)) {
setStoredActiveSessionId(undefined);
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; return undefined;
} }
const now = Date.now(); let remoteSessionId = state.sessionId ?? state.storageSessionId;
const storageSessionId = state.storageSessionId ?? createId(); if (!remoteSessionId) {
const preferredTitle = state.title?.trim(); remoteSessionId = await createRemoteChatSession();
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),
};
await db.put(SESSION_STORE, nextRecord); const savedSessionId = await saveRemoteChatState(remoteSessionId, {
await setMeta({ ...state,
activeSessionId: storageSessionId, storageSessionId: remoteSessionId,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true, sessionId: remoteSessionId,
}); });
setStoredActiveSessionId(savedSessionId);
return storageSessionId; return savedSessionId;
}; };
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => { export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
if (typeof window === "undefined") return []; if (typeof window === "undefined") return [];
return await fetchRemoteChatSessions();
await migrateLegacyLocalStorage();
const db = await getDb();
const sessions = await db.getAll(SESSION_STORE);
return sessions.sort(compareSessionsByAnchorTime).map(toSessionSummary);
}; };
export const updateChatSessionTitle = async ( export const updateChatSessionTitle = async (
@@ -327,45 +294,21 @@ export const updateChatSessionTitle = async (
const normalizedTitle = title.trim(); const normalizedTitle = title.trim();
if (!normalizedTitle) return; if (!normalizedTitle) return;
await updateRemoteChatSessionTitle(
const db = await getDb(); storageSessionId,
const session = await db.get(SESSION_STORE, storageSessionId); normalizedTitle,
if (!session) return; options?.isTitleManuallyEdited,
);
await db.put(SESSION_STORE, {
...session,
title: normalizedTitle,
isTitleManuallyEdited:
options?.isTitleManuallyEdited ?? session.isTitleManuallyEdited ?? false,
});
}; };
export const createEmptyChatSession = async (): Promise<LoadedChatState> => { export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState(); if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage(); setStoredActiveSessionId(undefined);
return {
const now = Date.now(); ...emptyLoadedChatState(),
const session: ChatSessionRecord = {
id: createId(),
title: "新对话", 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 ( export const loadChatSessionById = async (
@@ -373,21 +316,11 @@ export const loadChatSessionById = async (
): Promise<LoadedChatState> => { ): Promise<LoadedChatState> => {
if (typeof window === "undefined") return emptyLoadedChatState(); if (typeof window === "undefined") return emptyLoadedChatState();
await migrateLegacyLocalStorage(); const loaded = await fetchRemoteChatSession(sessionId);
if (loaded.storageSessionId) {
const db = await getDb(); setStoredActiveSessionId(sessionId);
const session = await db.get(SESSION_STORE, sessionId);
if (!session) {
return emptyLoadedChatState();
} }
return loaded;
const meta = await getMeta();
await setMeta({
activeSessionId: session.id,
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
});
return toLoadedChatState(session);
}; };
export const deleteChatSession = async ( export const deleteChatSession = async (
@@ -395,19 +328,8 @@ export const deleteChatSession = async (
): Promise<string | undefined> => { ): Promise<string | undefined> => {
if (typeof window === "undefined") return undefined; if (typeof window === "undefined") return undefined;
const db = await getDb(); await deleteRemoteChatSession(sessionId);
await db.delete(SESSION_STORE, sessionId); const nextActiveSession = (await listChatSessions())[0];
setStoredActiveSessionId(nextActiveSession?.id);
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,
});
return nextActiveSession?.id; return nextActiveSession?.id;
}; };
@@ -3,6 +3,7 @@
import { act, renderHook, waitFor } from "@testing-library/react"; import { act, renderHook, waitFor } from "@testing-library/react";
import { useAgentChatSession } from "./useAgentChatSession"; import { useAgentChatSession } from "./useAgentChatSession";
import { streamAgentChat } from "@/lib/chatStream";
jest.mock("@/lib/chatStream", () => ({ jest.mock("@/lib/chatStream", () => ({
abortAgentChat: jest.fn(async () => undefined), abortAgentChat: jest.fn(async () => undefined),
@@ -12,6 +13,7 @@ jest.mock("@/lib/chatStream", () => ({
const loadActiveChatState = jest.fn(); const loadActiveChatState = jest.fn();
const listChatSessions = jest.fn(); const listChatSessions = jest.fn();
const updateChatSessionTitle = jest.fn();
jest.mock("../chatStorage", () => ({ jest.mock("../chatStorage", () => ({
deleteChatSession: jest.fn(async () => undefined), deleteChatSession: jest.fn(async () => undefined),
@@ -26,13 +28,15 @@ jest.mock("../chatStorage", () => ({
branchGroups: [], branchGroups: [],
})), })),
saveActiveChatState: jest.fn(async (state) => state.storageSessionId), saveActiveChatState: jest.fn(async (state) => state.storageSessionId),
updateChatSessionTitle: jest.fn(async () => undefined), updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
})); }));
describe("useAgentChatSession", () => { describe("useAgentChatSession", () => {
beforeEach(() => { beforeEach(() => {
loadActiveChatState.mockReset(); loadActiveChatState.mockReset();
listChatSessions.mockReset(); listChatSessions.mockReset();
updateChatSessionTitle.mockReset();
jest.mocked(streamAgentChat).mockReset();
loadActiveChatState.mockResolvedValue({ loadActiveChatState.mockResolvedValue({
storageSessionId: undefined, 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(),
);
});
}); });