/** * Zustand store for SpiceBook chat conversations. * Persists to localStorage with conversation history management. */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export interface ChatMessage { role: 'user' | 'assistant'; text: string; timestamp: number; } export interface Conversation { id: string; title: string; messages: ChatMessage[]; createdAt: number; updatedAt: number; } const MAX_CONVERSATIONS = 20; const MAX_MESSAGES = 50; interface ChatStore { conversations: Conversation[]; activeConversationId: string | null; panelOpen: boolean; streaming: boolean; // Actions openPanel: () => void; closePanel: () => void; togglePanel: () => void; createConversation: () => string; setActiveConversation: (id: string) => void; addUserMessage: (text: string) => void; addAssistantMessage: (text: string) => void; appendToLastAssistant: (text: string) => void; setStreaming: (streaming: boolean) => void; deleteConversation: (id: string) => void; getActiveConversation: () => Conversation | null; } function generateId(): string { return crypto.randomUUID().slice(0, 8); } function titleFromQuestion(question: string): string { const trimmed = question.trim().slice(0, 50); return trimmed.length < question.trim().length ? `${trimmed}…` : trimmed; } export const useChatStore = create()( persist( (set, get) => ({ conversations: [], activeConversationId: null, panelOpen: false, streaming: false, openPanel: () => set({ panelOpen: true }), closePanel: () => set({ panelOpen: false }), togglePanel: () => set((s) => ({ panelOpen: !s.panelOpen })), createConversation: () => { const id = generateId(); const now = Date.now(); const conv: Conversation = { id, title: 'New conversation', messages: [], createdAt: now, updatedAt: now, }; set((s) => { const conversations = [conv, ...s.conversations].slice(0, MAX_CONVERSATIONS); return { conversations, activeConversationId: id }; }); return id; }, setActiveConversation: (id: string) => set({ activeConversationId: id }), addUserMessage: (text: string) => { const now = Date.now(); set((s) => { const convId = s.activeConversationId; if (!convId) return s; return { conversations: s.conversations.map((c) => { if (c.id !== convId) return c; const messages = [...c.messages, { role: 'user' as const, text, timestamp: now }] .slice(-MAX_MESSAGES); const title = c.messages.length === 0 ? titleFromQuestion(text) : c.title; return { ...c, messages, title, updatedAt: now }; }), }; }); }, addAssistantMessage: (text: string) => { const now = Date.now(); set((s) => { const convId = s.activeConversationId; if (!convId) return s; return { conversations: s.conversations.map((c) => { if (c.id !== convId) return c; const messages = [ ...c.messages, { role: 'assistant' as const, text, timestamp: now }, ].slice(-MAX_MESSAGES); return { ...c, messages, updatedAt: now }; }), }; }); }, appendToLastAssistant: (text: string) => { set((s) => { const convId = s.activeConversationId; if (!convId) return s; return { conversations: s.conversations.map((c) => { if (c.id !== convId) return c; const messages = [...c.messages]; const last = messages[messages.length - 1]; if (last && last.role === 'assistant') { messages[messages.length - 1] = { ...last, text: last.text + text }; } return { ...c, messages, updatedAt: Date.now() }; }), }; }); }, setStreaming: (streaming: boolean) => set({ streaming }), deleteConversation: (id: string) => { set((s) => { const conversations = s.conversations.filter((c) => c.id !== id); const activeConversationId = s.activeConversationId === id ? conversations[0]?.id ?? null : s.activeConversationId; return { conversations, activeConversationId }; }); }, getActiveConversation: () => { const s = get(); return s.conversations.find((c) => c.id === s.activeConversationId) ?? null; }, }), { name: 'spicebook-chat', partialize: (state) => ({ conversations: state.conversations, activeConversationId: state.activeConversationId, }), }, ), );