重构聊天会话管理,支持会话历史和存储
This commit is contained in:
@@ -0,0 +1,346 @@
|
||||
import { openDB, type DBSchema } from "idb";
|
||||
|
||||
import type {
|
||||
BranchGroup,
|
||||
ChatSessionRecord,
|
||||
ChatSessionSummary,
|
||||
ChatStorageMeta,
|
||||
LegacyPersistedChatState,
|
||||
LoadedChatState,
|
||||
Message,
|
||||
} from "./GlobalChatbox.types";
|
||||
import {
|
||||
cloneBranchGroups,
|
||||
cloneMessages,
|
||||
createId,
|
||||
} 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";
|
||||
|
||||
type ChatDB = DBSchema & {
|
||||
sessions: {
|
||||
key: string;
|
||||
value: ChatSessionRecord;
|
||||
indexes: {
|
||||
"by-updatedAt": number;
|
||||
};
|
||||
};
|
||||
meta: {
|
||||
key: string;
|
||||
value: ChatStorageMeta;
|
||||
};
|
||||
};
|
||||
|
||||
const emptyLoadedChatState = (): LoadedChatState => ({
|
||||
storageSessionId: undefined,
|
||||
title: undefined,
|
||||
messages: [],
|
||||
sessionId: undefined,
|
||||
branchGroups: [],
|
||||
});
|
||||
|
||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||
|
||||
const sanitizeBranchGroups = (branchGroups: BranchGroup[] | undefined) =>
|
||||
Array.isArray(branchGroups) ? cloneBranchGroups(branchGroups) : [];
|
||||
|
||||
const toLoadedChatState = (session: ChatSessionRecord | undefined): LoadedChatState => {
|
||||
if (!session) return emptyLoadedChatState();
|
||||
return {
|
||||
storageSessionId: session.id,
|
||||
title: session.title,
|
||||
messages: sanitizeMessages(session.messages),
|
||||
sessionId: session.sessionId,
|
||||
branchGroups: sanitizeBranchGroups(session.branchGroups),
|
||||
};
|
||||
};
|
||||
|
||||
const toSessionSummary = (session: ChatSessionRecord): ChatSessionSummary => ({
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
});
|
||||
|
||||
const buildSessionTitle = (messages: Message[]) => {
|
||||
const firstUserMessage = messages.find((message) => message.role === "user");
|
||||
if (!firstUserMessage) return "新对话";
|
||||
const title = firstUserMessage.content.replace(/\s+/g, " ").trim();
|
||||
if (!title) return "新对话";
|
||||
return title.length > 24 ? `${title.slice(0, 24)}...` : title;
|
||||
};
|
||||
|
||||
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;
|
||||
window.localStorage.removeItem(LEGACY_CHAT_STORAGE_KEY);
|
||||
};
|
||||
|
||||
const getMeta = async () => {
|
||||
const db = await getDb();
|
||||
return db.get(META_STORE, META_KEY);
|
||||
};
|
||||
|
||||
const setMeta = async (meta: Omit<ChatStorageMeta, "key">) => {
|
||||
const db = await getDb();
|
||||
await db.put(META_STORE, {
|
||||
key: META_KEY,
|
||||
...meta,
|
||||
});
|
||||
};
|
||||
|
||||
const getLatestSession = async () => {
|
||||
const db = await getDb();
|
||||
const sessions = await db.getAll(SESSION_STORE);
|
||||
if (sessions.length === 0) return undefined;
|
||||
return sessions.sort((left, right) => right.updatedAt - left.updatedAt)[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 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: buildSessionTitle(legacyState.messages),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
sessionId: legacyState.sessionId,
|
||||
messages: sanitizeMessages(legacyState.messages),
|
||||
branchGroups: sanitizeBranchGroups(legacyState.branchGroups),
|
||||
};
|
||||
|
||||
const db = await getDb();
|
||||
await db.put(SESSION_STORE, sessionRecord);
|
||||
clearLegacyChatState();
|
||||
await setMeta({
|
||||
activeSessionId: sessionRecord.id,
|
||||
migratedFromLocalStorage: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const loadActiveChatState = async (): Promise<LoadedChatState> => {
|
||||
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 latestSession = await getLatestSession();
|
||||
if (!latestSession) {
|
||||
return emptyLoadedChatState();
|
||||
}
|
||||
|
||||
await setMeta({
|
||||
activeSessionId: latestSession.id,
|
||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||
});
|
||||
|
||||
return toLoadedChatState(latestSession);
|
||||
};
|
||||
|
||||
export const saveActiveChatState = async (
|
||||
state: LoadedChatState,
|
||||
): Promise<string | undefined> => {
|
||||
if (typeof window === "undefined") return state.storageSessionId;
|
||||
|
||||
const hasContent =
|
||||
state.messages.length > 0 ||
|
||||
state.branchGroups.length > 0 ||
|
||||
Boolean(state.sessionId);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const storageSessionId = state.storageSessionId ?? createId();
|
||||
const computedTitle = buildSessionTitle(state.messages);
|
||||
const preferredTitle = state.title?.trim();
|
||||
const finalTitle = preferredTitle || computedTitle;
|
||||
const nextRecord: ChatSessionRecord = {
|
||||
id: storageSessionId,
|
||||
title: finalTitle,
|
||||
createdAt: existingSession?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
sessionId: state.sessionId,
|
||||
messages: sanitizeMessages(state.messages),
|
||||
branchGroups: sanitizeBranchGroups(state.branchGroups),
|
||||
};
|
||||
|
||||
await db.put(SESSION_STORE, nextRecord);
|
||||
await setMeta({
|
||||
activeSessionId: storageSessionId,
|
||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||
});
|
||||
|
||||
return storageSessionId;
|
||||
};
|
||||
|
||||
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||
if (typeof window === "undefined") return [];
|
||||
|
||||
await migrateLegacyLocalStorage();
|
||||
|
||||
const db = await getDb();
|
||||
const sessions = await db.getAll(SESSION_STORE);
|
||||
return sessions
|
||||
.sort((left, right) => right.updatedAt - left.updatedAt)
|
||||
.map(toSessionSummary);
|
||||
};
|
||||
|
||||
export const createEmptyChatSession = async (): Promise<LoadedChatState> => {
|
||||
if (typeof window === "undefined") return emptyLoadedChatState();
|
||||
|
||||
await migrateLegacyLocalStorage();
|
||||
|
||||
const now = Date.now();
|
||||
const session: ChatSessionRecord = {
|
||||
id: createId(),
|
||||
title: "新对话",
|
||||
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 (sessionId: string): Promise<LoadedChatState> => {
|
||||
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 meta = await getMeta();
|
||||
await setMeta({
|
||||
activeSessionId: session.id,
|
||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||
});
|
||||
|
||||
return toLoadedChatState(session);
|
||||
};
|
||||
|
||||
export const deleteChatSession = async (sessionId: string): Promise<string | undefined> => {
|
||||
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(
|
||||
(left, right) => right.updatedAt - left.updatedAt,
|
||||
)[0];
|
||||
const meta = await getMeta();
|
||||
|
||||
await setMeta({
|
||||
activeSessionId: nextActiveSession?.id,
|
||||
migratedFromLocalStorage: meta?.migratedFromLocalStorage ?? true,
|
||||
});
|
||||
|
||||
return nextActiveSession?.id;
|
||||
};
|
||||
Reference in New Issue
Block a user