export type ChatMessage = { role: "user" | "assistant"; content: string; reasoning?: string; sources?: string[]; status?: string; }; export type ChatConversation = { id: string; title: string; createdAt: string; updatedAt: string; personaId: string; temperature: number; messages: ChatMessage[]; }; type ChatStore = { activeId: string | null; conversations: ChatConversation[]; }; type ChatPreferences = { personaId?: string; temperature?: number; }; const STORAGE_KEY = "iamearth.chat.v1"; const PREFERENCES_KEY = "iamearth.chat.preferences.v1"; const MAX_CONVERSATIONS = 20; const MAX_MESSAGES_PER_CONVERSATION = 80; const MAX_MESSAGE_CHARS = 12000; const emptyStore: ChatStore = { activeId: null, conversations: [], }; function now() { return new Date().toISOString(); } function id() { return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `chat-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function isBrowser() { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function sanitizeMessage(message: ChatMessage): ChatMessage { return { ...message, content: message.content.slice(0, MAX_MESSAGE_CHARS), reasoning: message.reasoning?.slice(0, MAX_MESSAGE_CHARS), }; } function sanitizeConversation(conversation: ChatConversation): ChatConversation { return { ...conversation, messages: conversation.messages .slice(-MAX_MESSAGES_PER_CONVERSATION) .map(sanitizeMessage), }; } function sorted(conversations: ChatConversation[]) { return [...conversations].sort( (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt) ); } export function titleFromMessages(messages: ChatMessage[]) { const firstUser = messages.find((message) => message.role === "user")?.content.trim(); if (!firstUser) return "New chat"; return firstUser.length > 48 ? `${firstUser.slice(0, 45)}...` : firstUser; } export function createConversation(personaId: string, temperature: number): ChatConversation { const timestamp = now(); return { id: id(), title: "New chat", createdAt: timestamp, updatedAt: timestamp, personaId, temperature, messages: [], }; } export function loadChatStore(): ChatStore { if (!isBrowser()) return emptyStore; try { const raw = window.localStorage.getItem(STORAGE_KEY); if (!raw) return emptyStore; const parsed = JSON.parse(raw) as Partial; if (!Array.isArray(parsed.conversations)) return emptyStore; const conversations = sorted( parsed.conversations .filter((conversation): conversation is ChatConversation => { return Boolean( conversation && typeof conversation.id === "string" && typeof conversation.title === "string" && Array.isArray(conversation.messages) ); }) .map(sanitizeConversation) ).slice(0, MAX_CONVERSATIONS); const activeId = parsed.activeId === null ? null : conversations.find((conversation) => conversation.id === parsed.activeId)?.id ?? conversations[0]?.id ?? null; return { activeId, conversations }; } catch { return emptyStore; } } export function saveChatStore(store: ChatStore) { if (!isBrowser()) return; try { const conversations = sorted(store.conversations) .map(sanitizeConversation) .slice(0, MAX_CONVERSATIONS); const activeId = store.activeId === null ? null : conversations.find((conversation) => conversation.id === store.activeId)?.id ?? conversations[0]?.id ?? null; window.localStorage.setItem( STORAGE_KEY, JSON.stringify({ activeId, conversations }) ); } catch { // Storage can fail in private browsing or when the quota is full. } } export function loadChatPreferences(): ChatPreferences { if (!isBrowser()) return {}; try { const raw = window.localStorage.getItem(PREFERENCES_KEY); if (!raw) return {}; const parsed = JSON.parse(raw) as Partial; return { personaId: typeof parsed.personaId === "string" ? parsed.personaId : undefined, temperature: typeof parsed.temperature === "number" ? parsed.temperature : undefined, }; } catch { return {}; } } export function saveChatPreferences(preferences: ChatPreferences) { if (!isBrowser()) return; try { window.localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); } catch { // Storage can fail in private browsing or when the quota is full. } } export function upsertConversation( conversations: ChatConversation[], conversation: ChatConversation ) { return sorted([ sanitizeConversation(conversation), ...conversations.filter((item) => item.id !== conversation.id), ]).slice(0, MAX_CONVERSATIONS); } export function removeConversation(conversations: ChatConversation[], idToRemove: string) { return conversations.filter((conversation) => conversation.id !== idToRemove); }