/** * ChatCore — Headless chat state controller for @geepers/chat * * Manages conversation state, localStorage persistence, and SSE streaming. * Emits typed events so any UI layer (or no UI at all) can consume state changes. * * Uses the package's internal SSE client for actual streaming; this class * handles state, persistence, and orchestration. */ import { SSEChat } from './sse.js'; import type { ChatMessage as SSEChatMessage } from './sse.js'; import { MODELS, isKnownProvider, } from './providers.js'; import { ConversationStorage } from './storage.js'; import type { ChatConfig, ChatEvent, ChatEventHandler, ChatEventType, ConversationRecord, Message, MessageRole, Provider, } from '../types/index.js'; const DEFAULT_GATEWAY = 'https://api.dr.eamer.dev/v1'; const DEFAULT_STORAGE_KEY = 'gchat_conversations'; // ========== Helpers ========== function generateId(): string { // crypto.randomUUID() is available in all modern browsers and Node 15+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID(); } // Fallback for very old environments return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; } function generateMessageId(): string { return generateId(); } function now(): number { return Date.now(); } // ========== Event Emitter (minimal, typed) ========== type HandlerMap = Map>>; class EventEmitter { private handlers: HandlerMap = new Map(); on(type: ChatEventType, handler: ChatEventHandler): void { if (!this.handlers.has(type)) { this.handlers.set(type, new Set()); } this.handlers.get(type)!.add(handler as ChatEventHandler); } off(type: ChatEventType, handler: ChatEventHandler): void { this.handlers.get(type)?.delete(handler as ChatEventHandler); } emit(event: ChatEvent): void { this.handlers.get(event.type)?.forEach(h => h(event)); } } // ========== ChatCore ========== export class ChatCore { private config: Required> & ChatConfig; private emitter: EventEmitter; private sseClient: SSEChat; private storage: ConversationStorage; /** Current active provider */ private _provider: Provider; /** Current active model (can override per-provider default) */ private _model: string; /** System prompt for the current conversation */ private _systemPrompt: string; /** Ordered message history for the current conversation (excludes system) */ private _messages: Message[]; /** ID of the current conversation (UUID) */ private _conversationId: string | null; /** Whether a stream is currently active */ private _isStreaming: boolean; /** AbortController for the current stream */ private _abortController: AbortController | null; constructor(config: ChatConfig = {}) { this.config = { gateway: DEFAULT_GATEWAY, storageKey: DEFAULT_STORAGE_KEY, theme: 'auto', ...config, }; this.emitter = new EventEmitter(); // Resolve provider const initialProvider: Provider = (config.provider && isKnownProvider(config.provider)) ? config.provider : 'xai'; this._provider = initialProvider; this._model = config.model ?? MODELS[initialProvider].model; this._systemPrompt = config.systemPrompt ?? ''; this._messages = []; this._conversationId = null; this._isStreaming = false; this._abortController = null; // Build SSE client this.sseClient = new SSEChat({ apiUrl: `${this.config.gateway}/llm/chat`, apiKey: this._resolveApiKey(), }); // Persistence layer this.storage = new ConversationStorage(this.config.storageKey); // Restore conversation if requested if (config.conversationId) { this.loadConversation(config.conversationId); } else { // Start a fresh conversation this._conversationId = generateId(); } } // ========== Getters ========== get provider(): Provider { return this._provider; } get model(): string { return this._model; } get systemPrompt(): string { return this._systemPrompt; } get messages(): Readonly { return this._messages; } get conversationId(): string | null { return this._conversationId; } get isStreaming(): boolean { return this._isStreaming; } // ========== Event subscriptions ========== on(type: ChatEventType, handler: ChatEventHandler): void { this.emitter.on(type, handler); } off(type: ChatEventType, handler: ChatEventHandler): void { this.emitter.off(type, handler); } // ========== Configuration mutations ========== setProvider(provider: string): void { if (!isKnownProvider(provider)) return; this._provider = provider; this._model = MODELS[provider].model; this.sseClient.setApiKey(this._resolveApiKey()); } setModel(model: string): void { this._model = model; } setSystemPrompt(prompt: string): void { this._systemPrompt = prompt; } setApiKey(key: string): void { this.sseClient.setApiKey(key); } // ========== Core actions ========== /** * Send a user message and stream the assistant response. * Rejects if a stream is already active. */ async send(content: string): Promise { if (this._isStreaming) { throw new Error('A stream is already in progress. Call stop() first.'); } if (!content.trim()) return; // Add user message to state const userMessage = this._addMessage('user', content); this.emitter.emit({ type: 'message', message: userMessage }); // Create placeholder for assistant response const assistantId = generateMessageId(); const assistantPlaceholder: Message = { id: assistantId, role: 'assistant', content: '', timestamp: now(), provider: this._provider, }; this._messages.push(assistantPlaceholder); this._isStreaming = true; this._abortController = new AbortController(); this.emitter.emit({ type: 'stream-start', messageId: assistantId }); // Notify lifecycle callback this.config.onStreamStart?.(assistantId); // Build messages array for the API (includes optional system prompt) const apiMessages: SSEChatMessage[] = []; if (this._systemPrompt.trim()) { apiMessages.push({ role: 'system', content: this._systemPrompt.trim() }); } // Only send user/assistant turns (no system messages in history) for (const m of this._messages) { if (m.role !== 'system' && m.id !== assistantId) { apiMessages.push({ role: m.role as 'user' | 'assistant', content: m.content }); } } let accumulated = ''; try { await this.sseClient.send({ provider: this._provider, model: this._model, messages: apiMessages, signal: this._abortController.signal, onChunk: (chunk: string, acc: string) => { accumulated = acc; // Update the placeholder in-place assistantPlaceholder.content = acc; this.emitter.emit({ type: 'stream-chunk', chunk, accumulated: acc, messageId: assistantId, }); }, onDone: (fullText: string) => { assistantPlaceholder.content = fullText; assistantPlaceholder.timestamp = now(); }, onError: (error: Error) => { this.emitter.emit({ type: 'error', error }); this.config.onError?.(error); }, }); } catch (error) { // AbortError is expected when stop() is called — treat as graceful end if (!(error instanceof Error && error.name === 'AbortError')) { const err = error instanceof Error ? error : new Error(String(error)); this.emitter.emit({ type: 'error', error: err }); this.config.onError?.(err); } } finally { this._isStreaming = false; this._abortController = null; // Finalise assistant message const finalMessage: Message = { ...assistantPlaceholder, content: accumulated || assistantPlaceholder.content }; // Update in place so references stay valid assistantPlaceholder.content = finalMessage.content; this.emitter.emit({ type: 'stream-end', message: assistantPlaceholder }); this.config.onStreamEnd?.(assistantPlaceholder); this.config.onMessage?.(assistantPlaceholder); // Persist after every completed exchange this._saveCurrentConversation(); } } /** * Abort the current stream. The partial assistant response is kept. */ stop(): void { if (this._abortController) { this._abortController.abort(); } } /** * Clear messages and start a fresh conversation with a new UUID. * Saves the current conversation before clearing if it has messages. */ clearHistory(): void { this._saveCurrentConversation(); this._messages = []; this._conversationId = generateId(); this.emitter.emit({ type: 'conversation-change', conversationId: this._conversationId }); } // ========== Persistence ========== /** * Load a saved conversation by ID. * Saves the current conversation first. */ loadConversation(id: string): boolean { this._saveCurrentConversation(); const record = this.storage.get(id); if (!record) return false; this._conversationId = id; this._messages = record.messages ?? []; this._provider = isKnownProvider(record.provider) ? record.provider : 'xai'; this._model = MODELS[this._provider].model; this._systemPrompt = record.systemPrompt ?? ''; this.emitter.emit({ type: 'conversation-change', conversationId: id }); return true; } /** * Return all saved conversations, sorted newest first. */ getConversations(): ConversationRecord[] { return this.storage.all(); } /** * Delete a saved conversation by ID. * If deleting the active conversation, starts a fresh one. */ deleteConversation(id: string): void { this.storage.delete(id); if (id === this._conversationId) { this.clearHistory(); } } // ========== Private helpers ========== private _resolveApiKey(): string { if (this.config.apiKey) return this.config.apiKey; // Fall back to localStorage (mirrors IO Suite auth pattern) try { return localStorage.getItem('api_key') ?? ''; } catch { return ''; } } private _addMessage(role: MessageRole, content: string, provider?: Provider): Message { const message: Message = { id: generateMessageId(), role, content, timestamp: now(), provider, }; this._messages.push(message); return message; } private _saveCurrentConversation(): void { if (!this._conversationId || this._messages.length === 0) return; const existing = this.storage.get(this._conversationId); const record = ConversationStorage.build( this._conversationId, [...this._messages], this._provider, this._systemPrompt ); // Preserve original createdAt if record already exists if (existing) record.createdAt = existing.createdAt; this.storage.save(record); } }