Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #71.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
// 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) })
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user