Build image / build-and-push (push) Successful in 17s
- Remove config.AgentConfig.Ready() — dead after the refactor (no non-test
callers), and its doc comment ("route registration and capabilities gate on
this") was now false. EffectiveAgent.Ready() carries the same logic and IS
used, see below.
- agentHolder now uses eff.Ready() and eff.APIKey instead of an inline check and
a redundant apiKey field it held separately. This makes EffectiveAgent.APIKey/
Ready() production-used rather than test-only, drops the duplicated readiness
check, and simplifies newAgentHolder's signature.
- settingsPayload no longer swallows an EffectiveAgent error into a misleading
empty "effective" view (which would read as "nothing configured"). It returns
the error; the handlers surface it as a 500. EffectiveAgent re-reads the row
GetInstanceSettings just returned, so a failure there is a real DB fault.
- Frontend streamChat handles 503 (assistant disabled at runtime) distinctly
from 404 (endpoint absent) — the backend returns 503 AGENT_DISABLED now, which
the old code mislabelled.
- Fixed the api.go comment that still said a disabled request gets a 404 — it's
a 503.
- updateSettings uses the shared parseNullable[bool] instead of a bespoke
parseNullableBool (now removed).
- NewRunner drops its empty-spec guard; agentmodel.Resolve already owns that
check, so one place decides what a valid spec is.
- rebuild-on-resolve-error now documents WHY it keeps the current Runner rather
than tearing down a working assistant on a transient DB blip: the state is
persisted, the next rebuild reconciles, and killing a live assistant on a read
hiccup is worse than a brief stale window.
Not changed: the store's version-conflict fallback returning GetInstanceSettings'
error instead of ErrVersionConflict when that read also fails. That's the exact
pattern every other version-guarded update uses (gardens/objects/plants); making
only this one differ would be the inconsistency. A DB read failing immediately
after the guarded UPDATE on the same local SQLite file is a disk-fault edge case,
and surfacing that error is defensible.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
246 lines
8.4 KiB
TypeScript
246 lines
8.4 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() })
|
||
|
||
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<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) {
|
||
// 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<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) })
|
||
},
|
||
}
|
||
}
|