import type { ChatMessage, ChatSession, ModelId, ToolEvent } from './contracts'; const MODEL_IDS: readonly ModelId[] = ['bonsai-1.7b', 'bonsai-4b', 'bonsai-8b', 'bonsai-27b']; export function conversationTitle(messages: ChatMessage[]): string { const firstPrompt = messages.find((message) => message.role === 'user' && message.content.trim())?.content.trim(); return firstPrompt ? firstPrompt.replace(/\s+/g, ' ').slice(0, 80) : 'New chat'; } export function conversationPreview(messages: ChatMessage[]): string { const latest = [...messages].reverse().find((message) => message.content.trim())?.content.trim(); return latest ? latest.replace(/\s+/g, ' ').slice(0, 140) : 'No messages yet'; } export function normalizeConversationModelId(value: string): ModelId { return MODEL_IDS.includes(value as ModelId) ? value as ModelId : 'bonsai-27b'; } export function normalizeConversationToolsEnabled(value: unknown): boolean { return value === true; } export function upsertConversation(sessions: ChatSession[], next: ChatSession): ChatSession[] { return [next, ...sessions.filter((session) => session.id !== next.id)] .sort((left, right) => right.updatedAt - left.updatedAt); } export function normalizeConversationToolEvents( value: unknown, messages: ChatMessage[], ): ToolEvent[] { const events = new Map(); for (const message of messages) { for (const tool of message.tools ?? []) { const interrupted = tool.state === 'queued' || tool.state === 'running'; events.set(tool.id, { id: tool.id, timestamp: message.timestamp, label: tool.name, detail: interrupted ? 'interrupted before completion' : tool.state === 'error' ? 'tool returned a bounded error' : 'local result restored from chat history', state: tool.state === 'complete' ? 'complete' : 'error', }); } } if (Array.isArray(value)) { for (const candidate of value) { if (!candidate || typeof candidate !== 'object') continue; const event = candidate as Partial; if (typeof event.id !== 'string' || typeof event.timestamp !== 'string' || typeof event.label !== 'string' || typeof event.detail !== 'string' || !['running', 'complete', 'error'].includes(event.state ?? '')) continue; events.set(event.id, event as ToolEvent); } } return [...events.values()].slice(-20); } export interface MessageRegenerationPlan { baseMessages: ChatMessage[]; userMessage: ChatMessage; discardedMessageIds: string[]; } export function planMessageRegeneration( messages: ChatMessage[], messageId: string, ): MessageRegenerationPlan | null { const targetIndex = messages.findIndex((message) => message.id === messageId); if (targetIndex < 0) return null; const target = messages[targetIndex]; if (!target || target.role === 'system') return null; let userIndex = target.role === 'user' ? targetIndex : -1; for (let index = targetIndex - 1; userIndex < 0 && index >= 0; index -= 1) { if (messages[index]?.role === 'user') userIndex = index; } const userMessage = messages[userIndex]; if (!userMessage || !userMessage.content.trim()) return null; return { baseMessages: messages.slice(0, userIndex), userMessage, discardedMessageIds: messages.slice(userIndex + 1).map((message) => message.id), }; } export function planUserMessageEdit( messages: ChatMessage[], messageId: string, content: string, editedAt = Date.now(), ): MessageRegenerationPlan | null { const targetIndex = messages.findIndex((message) => message.id === messageId); const target = messages[targetIndex]; const normalized = content.trim(); if (targetIndex < 0 || target?.role !== 'user' || !normalized) return null; return { baseMessages: messages.slice(0, targetIndex), userMessage: { ...target, content: normalized, editedAt }, discardedMessageIds: messages.slice(targetIndex + 1).map((message) => message.id), }; }