Refine chat session storage and title handling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user