// 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() }) export const capabilitiesKey = ['capabilities'] as const /** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered * at all — a dead button is worse than no button. * * Not `staleTime: Infinity` any more: an admin can turn the assistant on or off * in Settings (#79), so this must be able to change under a running page. The * settings save invalidates this key directly; the finite staleTime just means * another admin's change is picked up on the next focus/remount rather than * never. */ export function useCapabilities() { return useQuery({ queryKey: capabilitiesKey, queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')), staleTime: 60_000, }) } 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(', ') } const chatEventSchema = z.object({ step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(), done: z .object({ reply: z.string(), changeSetId: z.number().optional(), steps: z.number(), truncated: z.boolean().optional(), }) .optional(), error: z.string().optional(), // The turn worked but something adjacent to it didn't — currently, the // exchange couldn't be saved. Dropping this on the floor would recreate // exactly the silent swallow the server added it to avoid. warning: z.string().optional(), }) export interface StreamHandlers { onStep: (step: AgentStep) => void onDone: (turn: AgentTurn) => void onError: (message: string) => void /** The turn succeeded, but something alongside it didn't. */ onWarning?: (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 { // An abort here is the caller's own doing — Stop, or navigating away — not a // failure to report back to them. The read loop below already knew this; the // request path did not. if (signal?.aborted) return handlers.onError('Could not reach the server.') return } if (res.status === 401) { // Session expired mid-conversation (#85). Reporting this as "the assistant is // unavailable" would send the user chasing a config problem that isn't there. // Send them to sign in again, preserving where they were. handlers.onError('Your session has expired — please sign in again.') const back = encodeURIComponent(location.pathname + location.search) window.location.assign(`/login?redirect=${back}`) return } if (!res.ok || !res.body) { // 503 is the assistant being turned off at runtime (#79) — the route exists, // there's just no model behind it. Distinct from a 404, which would mean the // whole endpoint is absent. handlers.onError( res.status === 503 ? "The assistant isn't enabled on this instance." : 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 // Parsed AND validated: a malformed or unexpected frame shouldn't kill a // working stream, and shouldn't be trusted into the UI either. const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim())) if (!parsed.success) continue const e = parsed.data if (e.warning) handlers.onWarning?.(e.warning) if (e.error) handlers.onError(e.error) else if (e.step) handlers.onStep(e.step) else if (e.done) handlers.onDone(e.done) } } } function safeJson(raw: string): unknown { try { return JSON.parse(raw) } catch { return null } } /** * Refreshes for the two moments that need different amounts of work. * * Mid-turn, only the canvas can have changed: the change set isn't written until * the turn commits, and the exchange isn't stored until it finishes. Refetching * those on every step would be up to 2×(N−1) requests per turn for data that * cannot have moved. */ export function useAgentRefresh(gardenId: number) { const qc = useQueryClient() const canvas = () => { void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) }) } return { /** After a step: the garden may have changed under the conversation. */ canvas, /** After a turn: the change set and the stored exchange exist now too. */ everything: () => { canvas() void qc.invalidateQueries({ queryKey: historyKey(gardenId) }) void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }) }, } }