Build image / build-and-push (push) Successful in 15s
The conversational surface, in the editor rather than on its own page. The reason is the feedback loop: watching the canvas change as the agent works IS the confirmation, which is exactly what makes "act freely without asking first" tolerable. It also means the agent never has to guess which garden you mean. Tool calls surface as they happen, in the app's own vocabulary — "Clearing a bed", "Looking up a plant" — not raw tool names or JSON. That is the difference between the panel feeling like it's doing something and feeling like it's hung, which matters because a replant makes a dozen calls over tens of seconds. An unknown tool degrades to readable words rather than showing snake_case at the user, so the client can lag the server by a tool without looking broken. The canvas refreshes as each step lands, not just at the end. Refreshing only on completion would put the whole point of siting the chat here — watching it work — behind the same wait that streaming exists to remove. Undo sits on the turn itself, so the common case never involves opening the History panel. It uses #49's useUndo, not a second implementation, which meant giving that hook an UndoTarget: the chat knows a turn's change set id but not its tally, and fabricating counts to satisfy the type would have produced a confidently wrong "1 of 1 changes undone". describeUndo now says "Partly undone" when it has no denominator rather than inventing one. The panel is only offered when the instance actually has the assistant configured, via a new /capabilities read. The routes 404 without a key, so without this the client would have to probe for a 404 to find out — and a tab that opens onto an apology is worse than no tab. Streaming is hand-rolled over fetch rather than EventSource, which can only issue GETs and this needs a POST body. The wire format is still SSE, so a proxy that understands it doesn't buffer and the server wouldn't change if EventSource became viable. Partial frames are buffered across chunks and a malformed frame is skipped rather than killing a working stream. Errors read as sentences, and as different sentences: a permission refusal, a timeout and a model failure want different reactions. Every failure path refreshes, because something may have landed before it failed — and says where to look for it. Closes #57 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
189 lines
6.2 KiB
TypeScript
189 lines
6.2 KiB
TypeScript
// 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<typeof agentMessageSchema>
|
|
|
|
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<AgentMessage[]> =>
|
|
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
|
|
})
|
|
}
|
|
|
|
export function useClearAgentHistory(gardenId: number) {
|
|
const qc = useQueryClient()
|
|
return useMutation({
|
|
mutationFn: async (): Promise<void> => {
|
|
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<string, string> = {
|
|
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<void> {
|
|
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<Uint8Array>
|
|
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) })
|
|
}
|
|
}
|