import type { UIMessage } from 'ai'; import { toPlainTextHistory } from '../AiChatMessageHistory'; const MAX_HISTORY_BYTES = 200 * 1024; const MAX_SESSIONS = 30; export interface AiChatSessionSummary { id: string; title: string; updatedAt: number; messageCount: number; } interface AiChatSessionRecord { id: string; title: string; updatedAt: number; messages: UIMessage[]; } export class AiChatHistoryStore { readonly key: string; readonly sessionsKey: string; readonly activeKey: string; constructor(siteDomain: string, uid: string, private readonly storage: Storage = localStorage) { const scope = `${normalizeKeyPart(siteDomain)}:${normalizeKeyPart(uid)}`; this.key = `ai-chat:${scope}`; this.sessionsKey = `ai-chat-sessions:${scope}`; this.activeKey = `ai-chat-active:${scope}`; } load(): UIMessage[] { this.migrateLegacyIfNeeded(); const activeId = this.getActiveSessionId(); if (!activeId) { return []; } const session = this.readSessions().find((entry) => entry.id === activeId); return session ? cloneMessages(session.messages) : []; } save(messages: UIMessage[]): UIMessage[] { this.migrateLegacyIfNeeded(); const retained = toPlainTextHistory(messages.filter(isPersistableMessage)) as UIMessage[]; while (retained.length > 0 && byteLength(JSON.stringify(retained)) > MAX_HISTORY_BYTES) { removeOldestTurn(retained); } const sessions = this.readSessions(); let activeId = this.getActiveSessionId(); // 空快照不得凭空新建会话,否则删除/清空后会立刻被回写冒出一条「新会话」。 if (retained.length === 0) { if (!activeId) { return []; } const existing = sessions.find((entry) => entry.id === activeId); if (!existing) { this.setActiveSessionId(null); return []; } this.writeSessions([ { id: activeId, title: existing.title || '新会话', updatedAt: Date.now(), messages: [], }, ...sessions.filter((entry) => entry.id !== activeId), ]); return []; } if (!activeId) { activeId = createSessionId(); this.setActiveSessionId(activeId); } const existing = sessions.find((entry) => entry.id === activeId); const title = deriveTitle(retained); const updatedAt = Date.now(); const nextSessions = [ { id: activeId, title, updatedAt, messages: retained }, ...sessions.filter((entry) => entry.id !== activeId), ].slice(0, MAX_SESSIONS); this.writeSessions(nextSessions); return cloneMessages(retained); } clear(): void { const activeId = this.getActiveSessionId(); if (!activeId) { this.storage.removeItem(this.key); return; } const next = this.readSessions().filter((entry) => entry.id !== activeId); this.writeSessions(next); this.setActiveSessionId(next[0]?.id ?? null); this.storage.removeItem(this.key); } listSessions(): AiChatSessionSummary[] { this.migrateLegacyIfNeeded(); return this.readSessions().map((entry) => ({ id: entry.id, title: entry.title, updatedAt: entry.updatedAt, messageCount: entry.messages.length, })); } getActiveSessionId(): string | null { try { const value = this.storage.getItem(this.activeKey)?.trim(); return value || null; } catch { return null; } } createSession(): string { this.migrateLegacyIfNeeded(); const sessions = this.readSessions(); const activeId = this.getActiveSessionId(); const active = sessions.find((entry) => entry.id === activeId); // 当前已是空会话时复用,避免连点「新建」刷出一堆空记录。 if (active && active.messages.length === 0) { this.writeSessions([ { ...active, title: '新会话', updatedAt: Date.now(), messages: [] }, ...sessions.filter((entry) => entry.id !== active.id), ]); return active.id; } const id = createSessionId(); this.writeSessions([ { id, title: '新会话', updatedAt: Date.now(), messages: [] }, ...sessions, ].slice(0, MAX_SESSIONS)); this.setActiveSessionId(id); return id; } openSession(sessionId: string): UIMessage[] { this.migrateLegacyIfNeeded(); const session = this.readSessions().find((entry) => entry.id === sessionId); if (!session) { return []; } this.setActiveSessionId(session.id); return cloneMessages(session.messages); } deleteSession(sessionId: string): void { const next = this.readSessions().filter((entry) => entry.id !== sessionId); this.writeSessions(next); if (this.getActiveSessionId() === sessionId) { this.setActiveSessionId(next[0]?.id ?? null); } } private migrateLegacyIfNeeded(): void { try { if (this.storage.getItem(this.sessionsKey)) { return; } const legacyRaw = this.storage.getItem(this.key); if (!legacyRaw) { return; } const parsed: unknown = JSON.parse(legacyRaw); if (!Array.isArray(parsed) || parsed.length === 0) { this.storage.removeItem(this.key); return; } const messages = toPlainTextHistory(parsed.filter(isPersistableMessage) as UIMessage[]) as UIMessage[]; if (messages.length === 0) { this.storage.removeItem(this.key); return; } const id = createSessionId(); this.writeSessions([{ id, title: deriveTitle(messages), updatedAt: Date.now(), messages, }]); this.setActiveSessionId(id); this.storage.removeItem(this.key); } catch { // ignore corrupt legacy payload } } private readSessions(): AiChatSessionRecord[] { try { const raw = this.storage.getItem(this.sessionsKey); if (!raw || byteLength(raw) > MAX_HISTORY_BYTES * 4) { return []; } const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) { return []; } return parsed.flatMap((entry) => { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { return []; } const record = entry as Record; if (typeof record.id !== 'string' || !record.id.trim()) { return []; } if (!Array.isArray(record.messages)) { return []; } const messages = toPlainTextHistory( record.messages.filter(isPersistableMessage) as UIMessage[], ) as UIMessage[]; return [{ id: record.id.trim(), title: typeof record.title === 'string' && record.title.trim() ? record.title.trim().slice(0, 40) : deriveTitle(messages), updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : Date.now(), messages, }]; }); } catch { return []; } } private writeSessions(sessions: AiChatSessionRecord[]): void { try { this.storage.setItem(this.sessionsKey, JSON.stringify(sessions)); } catch { // quota / private mode } } private setActiveSessionId(sessionId: string | null): void { try { if (!sessionId) { this.storage.removeItem(this.activeKey); return; } this.storage.setItem(this.activeKey, sessionId); } catch { // ignore } } } function deriveTitle(messages: UIMessage[]): string { const firstUser = messages.find((message) => message.role === 'user'); const text = firstUser?.parts .filter((part) => part.type === 'text') .map((part) => ('text' in part ? String(part.text) : '')) .join('') .trim(); if (!text) { return '新会话'; } return text.length > 24 ? `${text.slice(0, 24)}…` : text; } function createSessionId(): string { return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } function cloneMessages(messages: UIMessage[]): UIMessage[] { return structuredClone(messages); } function removeOldestTurn(messages: UIMessage[]): void { if (messages.length === 0) { return; } messages.shift(); while (messages.length > 0 && messages[0].role === 'assistant') { messages.shift(); } } function isPersistableMessage(value: unknown): value is UIMessage { if (!value || typeof value !== 'object' || Array.isArray(value)) { return false; } const message = value as Record; return typeof message.id === 'string' && (message.role === 'user' || message.role === 'assistant') && Array.isArray(message.parts) && message.parts.length > 0; } function normalizeKeyPart(value: string): string { const normalized = value.trim().replaceAll(':', '_'); return normalized || 'unknown'; } function byteLength(value: string): number { return new TextEncoder().encode(value).byteLength; }