Spaces:
Sleeping
Sleeping
| 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<ChatStore>; | |
| 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<ChatPreferences>; | |
| 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); | |
| } | |