// The garden assistant's client (#57). // // The chat lives in the editor, not on its own page, because watching the canvas // change as the agent works IS the confirmation — which is what makes acting // without asking first tolerable. So this module's job is as much about // surfacing progress as it is about sending a message. import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { z } from 'zod' import { API_BASE, api } from './api' import { gardenFullKey } from './objects' import { historyKey } from './history' const capabilitiesSchema = z.object({ agent: z.boolean() }) /** Whether this instance has the assistant configured. Without it the panel * isn't rendered at all — a dead button is worse than no button. */ export function useCapabilities() { return useQuery({ queryKey: ['capabilities'] as const, queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')), staleTime: Infinity, // server config; it doesn't change under a running page }) } export const agentMessageSchema = z.object({ id: z.number(), conversationId: z.number(), role: z.enum(['user', 'assistant']), body: z.string(), changeSetId: z.number().optional(), createdAt: z.string(), }) export type AgentMessage = z.infer const historySchema = z.object({ messages: z.array(agentMessageSchema) }) export function agentHistoryKey(gardenId: number) { return ['gardens', gardenId, 'agent-history'] as const } /** The stored conversation, so a reload doesn't lose the thread — which is * exactly when someone reloads, to check whether a change actually landed. */ export function useAgentHistory(gardenId: number, enabled: boolean) { return useQuery({ queryKey: agentHistoryKey(gardenId), enabled, queryFn: async (): Promise => historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages, }) } export function useClearAgentHistory(gardenId: number) { const qc = useQueryClient() return useMutation({ mutationFn: async (): Promise => { await api.delete(`/gardens/${gardenId}/agent/history`) }, onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }), }) } /** One completed turn, as the server reports it. */ export interface AgentTurn { reply: string changeSetId?: number steps: number truncated?: boolean } /** A step the model just finished, named in the app's own vocabulary. */ export interface AgentStep { index: number tools: string[] } // What each tool is doing, in words. Raw tool names ("fill_region") tell you the // agent is busy; these tell you what it's busy DOING, which is the difference // between the panel feeling alive and feeling hung. const TOOL_LABELS: Record = { list_gardens: 'Looking at your gardens', describe_garden: 'Reading the garden', create_object: 'Adding a bed', move_object: 'Moving a bed', place_planting: 'Planting', fill_region: 'Filling a bed', clear_object: 'Clearing a bed', find_plant: 'Looking up a plant', create_plant: 'Adding a plant to your catalog', add_journal_entry: 'Writing a journal note', } export function describeStep(step: AgentStep): string { if (step.tools.length === 0) return 'Thinking' const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' ')) // Repeated tools in one step read as one action, not a list of identical ones. return [...new Set(labels)].join(', ') } export interface StreamHandlers { onStep: (step: AgentStep) => void onDone: (turn: AgentTurn) => void onError: (message: string) => void } /** * Send a message and stream the reply. * * Hand-rolled rather than EventSource, which can only issue GETs — this needs a * POST body. The wire format is still SSE so a proxy that understands it doesn't * buffer, and so switching to EventSource later wouldn't change the server. */ export async function streamChat( gardenId: number, message: string, handlers: StreamHandlers, signal?: AbortSignal, ): Promise { let res: Response try { res = await fetch(`${API_BASE}/agent/chat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ gardenId, message }), credentials: 'same-origin', signal, }) } catch { handlers.onError('Could not reach the server.') return } if (!res.ok || !res.body) { handlers.onError( res.status === 404 ? "This instance doesn't have the assistant configured." : 'The assistant is not available right now.', ) return } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' for (;;) { let chunk: ReadableStreamReadResult try { chunk = await reader.read() } catch { // An aborted read is the caller navigating away, not a failure worth // reporting back to them. if (signal?.aborted) return handlers.onError('The connection dropped partway through.') return } if (chunk.done) break buffer += decoder.decode(chunk.value, { stream: true }) // SSE frames are separated by a blank line; anything after the last one is // a partial frame to keep for the next chunk. const frames = buffer.split('\n\n') buffer = frames.pop() ?? '' for (const frame of frames) { const line = frame.split('\n').find((l) => l.startsWith('data:')) if (!line) continue let event: unknown try { event = JSON.parse(line.slice(5).trim()) } catch { continue // a malformed frame shouldn't kill a working stream } const e = event as { step?: AgentStep; done?: AgentTurn; error?: string } if (e.error) handlers.onError(e.error) else if (e.step) handlers.onStep(e.step) else if (e.done) handlers.onDone(e.done) } } } /** Refresh everything a turn may have changed: the canvas, the history list, * and the conversation itself. */ export function useAgentRefresh(gardenId: number) { const qc = useQueryClient() return () => { void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) }) void qc.invalidateQueries({ queryKey: historyKey(gardenId) }) void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }) } }