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
+67 -110
View File
@@ -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<StoreName, Map<string, any>> = {
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<number, []>;
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();
});
});