import { writable, get } from 'svelte/store' import { streamChat, fetchSessions, fetchMessages, deleteSession as apiDeleteSession } from '$lib/api' import type { ChatEvent, Session, Message } from '$lib/api' export interface ChatMessage { id: string role: 'user' | 'assistant' text: string tools: ToolCallResult[] } export interface ToolCallResult { type: 'tool_use' | 'tool_result' name: string id?: string args?: any result?: any error?: string } function mid(): string { return crypto.randomUUID() } export const messages = writable([]) export const streaming = writable(false) export const currentSession = writable(null) export const sessions = writable([]) export const sessionMessages = writable([]) export const error = writable(null) let activeController: AbortController | null = null export async function loadSessions() { const list = await fetchSessions() sessions.set(list) } // mergeToolCalls collapses a persisted tool_calls array into one entry per // call id. Nomos persists the tool_use and tool_result as two separate // entries sharing the same id (matching the SSE event pair); Chat.svelte // renders tools in a keyed {#each ... (tool.id)}, which throws on duplicate // keys and silently aborts the whole message list. Live-streamed messages // never hit this because sendMessage() merges tool_result into the existing // tool_use entry in place rather than appending a second one. function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] { const byId = new Map() for (const tc of raw ?? []) { const key = tc.id ?? crypto.randomUUID() const existing = byId.get(key) byId.set(key, existing ? { ...existing, ...tc, id: key } : { ...tc, id: key }) } return Array.from(byId.values()) } export async function loadSessionMessages(sessionId: string) { currentSession.set(sessionId) const msgs = await fetchMessages(sessionId) sessionMessages.set(msgs) const chatMsgs: ChatMessage[] = msgs.map((m) => ({ id: m.id, role: m.role as 'user' | 'assistant', text: m.content?.text ?? (typeof m.content === 'string' ? m.content : ''), tools: mergeToolCalls(m.content?.tool_calls) })) messages.set(chatMsgs) } export function sendMessage(text: string) { error.set(null) streaming.set(true) const userMsg: ChatMessage = { id: mid(), role: 'user', text, tools: [] } messages.update((ms) => [...ms, userMsg]) const assistantMsg: ChatMessage = { id: mid(), role: 'assistant', text: '', tools: [] } messages.update((ms) => [...ms, assistantMsg]) let activeTools: Map = new Map() activeController = streamChat( text, get(currentSession), // continue the active session so the agent keeps context (ev: ChatEvent) => { if (ev.type === 'session') { currentSession.set(ev.data) } else if (ev.type === 'tool_use') { const tr: ToolCallResult = { type: 'tool_use', name: ev.data.name, id: ev.data.id, args: ev.data.args } activeTools.set(ev.data.id, tr) messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { last.tools = [...last.tools, tr] } return [...ms] }) } else if (ev.type === 'tool_result') { const existing = activeTools.get(ev.data.id) if (existing) { const updated: ToolCallResult = { ...existing, type: 'tool_result', result: ev.data.result, error: ev.data.error } activeTools.set(ev.data.id, updated) messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { last.tools = last.tools.map((t) => t.id === ev.data.id ? updated : t ) } return [...ms] }) } } else if (ev.type === 'text_delta') { messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { last.text += ev.data } return [...ms] }) } else if (ev.type === 'text') { // Final authoritative content for the turn; replaces accumulated deltas. messages.update((ms) => { const last = ms[ms.length - 1] if (last && last.role === 'assistant') { last.text = ev.data } return [...ms] }) } else if (ev.type === 'done') { currentSession.set(ev.data?.session_id ?? ev.session_id) } else if (ev.type === 'error') { error.set(ev.data) } }, (err: string) => { error.set(err) }, () => { streaming.set(false) activeController = null loadSessions() } ) } export function newChat() { cancelStream() currentSession.set(null) messages.set([]) error.set(null) } export function cancelStream() { if (activeController) { activeController.abort() activeController = null streaming.set(false) } } export async function deleteSession(sessionId: string) { const ok = await apiDeleteSession(sessionId) if (!ok) return if (get(currentSession) === sessionId) { newChat() } loadSessions() }