Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
233 lines
7.8 KiB
TypeScript
233 lines
7.8 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(', ')
|
||
}
|
||
|
||
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<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 {
|
||
// 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.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
|
||
// 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) })
|
||
},
|
||
}
|
||
}
|