Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #71.
This commit is contained in:
@@ -69,6 +69,7 @@ GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes
|
|||||||
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
||||||
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||||
|
GET /capabilities ← what this instance can do, so the UI offers only what works
|
||||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
|||||||
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
||||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
|
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
|
||||||
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
||||||
- **One rail, tabs inside it.** The inspector, history, journal, and later the chat panel all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
|||||||
|
|
||||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||||
|
|
||||||
|
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs `garden assistant disabled` at startup with which of the three conditions failed, so the answer is in the first few lines of the log.
|
||||||
|
|
||||||
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
||||||
|
|
||||||
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
||||||
@@ -111,7 +113,8 @@ services:
|
|||||||
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
|
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
|
||||||
# PANSY_OIDC_CLIENT_ID: ...
|
# PANSY_OIDC_CLIENT_ID: ...
|
||||||
# PANSY_OIDC_CLIENT_SECRET: ...
|
# PANSY_OIDC_CLIENT_SECRET: ...
|
||||||
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
|
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
|
||||||
|
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
pansy-data:
|
pansy-data:
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
// is set; see csrfGuard).
|
// is set; see csrfGuard).
|
||||||
v1.Use(h.csrfGuard())
|
v1.Use(h.csrfGuard())
|
||||||
v1.GET("/healthz", healthz)
|
v1.GET("/healthz", healthz)
|
||||||
|
// What this instance can actually do, so the UI offers only what works.
|
||||||
|
// Registered after the agent below, because it reports whether the runner
|
||||||
|
// actually built — not merely whether it was configured to.
|
||||||
|
v1.GET("/capabilities", h.capabilities)
|
||||||
|
|
||||||
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
|
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
|
||||||
// /me is the one that needs a session. Feature routers in later issues attach
|
// /me is the one that needs a session. Feature routers in later issues attach
|
||||||
@@ -125,6 +129,17 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
// The garden assistant, registered only when it can actually be offered —
|
// The garden assistant, registered only when it can actually be offered —
|
||||||
// the same shape as OIDC. An instance with no API key serves the app
|
// the same shape as OIDC. An instance with no API key serves the app
|
||||||
// normally and simply doesn't have these routes.
|
// normally and simply doesn't have these routes.
|
||||||
|
if !cfg.Agent.Ready() {
|
||||||
|
// Say WHY, at startup, in the logs an operator is already looking at.
|
||||||
|
// Someone who set the key and sees no assistant otherwise has nothing to
|
||||||
|
// check — and "is the variable reaching the container?" is exactly the
|
||||||
|
// question they need answered.
|
||||||
|
slog.Info("api: garden assistant disabled",
|
||||||
|
"enabled", cfg.Agent.Enabled,
|
||||||
|
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
|
||||||
|
"model", cfg.Agent.Model,
|
||||||
|
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
|
||||||
|
}
|
||||||
if cfg.Agent.Ready() {
|
if cfg.Agent.Ready() {
|
||||||
runner, err := agent.NewRunner(svc, cfg)
|
runner, err := agent.NewRunner(svc, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,6 +195,16 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capabilities reports what this instance can actually do, so the UI offers only
|
||||||
|
// what works.
|
||||||
|
//
|
||||||
|
// It reports whether the runner BUILT, not whether it was configured: a
|
||||||
|
// configured-but-unresolvable model leaves the routes unregistered, and saying
|
||||||
|
// "yes" there would offer a chat tab whose first message 404s.
|
||||||
|
func (h *handlers) capabilities(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
|
||||||
|
}
|
||||||
|
|
||||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||||
func healthz(c *gin.Context) {
|
func healthz(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { TextArea } from '@/components/ui/TextArea'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import {
|
||||||
|
describeStep,
|
||||||
|
streamChat,
|
||||||
|
useAgentHistory,
|
||||||
|
useAgentRefresh,
|
||||||
|
useClearAgentHistory,
|
||||||
|
type AgentStep,
|
||||||
|
type AgentTurn,
|
||||||
|
} from '@/lib/agent'
|
||||||
|
import { useUndo } from '@/lib/history'
|
||||||
|
import { UndoButton } from './UndoButton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talk to the garden assistant, in the editor beside the canvas.
|
||||||
|
*
|
||||||
|
* Here rather than on its own page because watching the garden change as the
|
||||||
|
* agent works IS the confirmation — which is what makes acting without asking
|
||||||
|
* first tolerable. It also means the agent never has to guess which garden you
|
||||||
|
* mean.
|
||||||
|
*/
|
||||||
|
export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||||
|
const history = useAgentHistory(gardenId, true)
|
||||||
|
const clear = useClearAgentHistory(gardenId)
|
||||||
|
const refresh = useAgentRefresh(gardenId)
|
||||||
|
const undo = useUndo(gardenId)
|
||||||
|
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
// The turn in flight: what we sent, the steps so far, and how it ended.
|
||||||
|
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [warning, setWarning] = useState<string | null>(null)
|
||||||
|
const abort = useRef<AbortController | null>(null)
|
||||||
|
const bottom = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
|
||||||
|
// rail to the inspector, so aborting here would mean clicking the canvas
|
||||||
|
// mid-turn silently killed the turn — and the canvas is exactly what you're
|
||||||
|
// meant to be watching. The request continues, the exchange is persisted
|
||||||
|
// server-side, and coming back to this tab shows it. Only Stop aborts, and
|
||||||
|
// even that only stops us READING: the turn keeps running server-side, which
|
||||||
|
// is why its work still lands in History either way.
|
||||||
|
|
||||||
|
// Follow the conversation as it grows, including mid-turn as steps arrive.
|
||||||
|
useEffect(() => {
|
||||||
|
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}, [history.data, pending])
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const message = input.trim()
|
||||||
|
if (!message || pending) return
|
||||||
|
setInput('')
|
||||||
|
setError(null)
|
||||||
|
setWarning(null)
|
||||||
|
setPending({ message, steps: [] })
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abort.current = controller
|
||||||
|
|
||||||
|
void streamChat(
|
||||||
|
gardenId,
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
onStep: (step) => {
|
||||||
|
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||||
|
// The canvas updating under the conversation is the whole point of
|
||||||
|
// putting the chat here, so refresh it as each step lands — but only
|
||||||
|
// it: nothing else can have changed until the turn commits.
|
||||||
|
refresh.canvas()
|
||||||
|
},
|
||||||
|
onDone: (turn: AgentTurn) => {
|
||||||
|
setPending(null)
|
||||||
|
refresh.everything()
|
||||||
|
if (turn.truncated) {
|
||||||
|
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onWarning: setWarning,
|
||||||
|
onError: (message) => {
|
||||||
|
setPending(null)
|
||||||
|
setError(message)
|
||||||
|
// Something may still have landed before it failed.
|
||||||
|
refresh.everything()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = history.data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-fg">Assistant</h2>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
clear.mutate(undefined, {
|
||||||
|
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={clear.isPending}
|
||||||
|
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
Start over
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canEdit && (
|
||||||
|
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">
|
||||||
|
You can only view this garden, so the assistant can't change anything in it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
|
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
||||||
|
{/* A failed load rendering as an empty thread would look like the
|
||||||
|
conversation had been lost, which is a much worse thing to believe. */}
|
||||||
|
{history.isError && (
|
||||||
|
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{history.isSuccess && messages.length === 0 && !pending && (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
|
||||||
|
north half of the west bed with beans”, “what's in here?”. Everything it does lands as one change you
|
||||||
|
can undo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((m) => (
|
||||||
|
<Bubble key={m.id} role={m.role} body={m.body}>
|
||||||
|
{/* Undo on the turn itself, so the common case never involves
|
||||||
|
opening the History panel. Same hook #49 uses, not a second
|
||||||
|
implementation. */}
|
||||||
|
{m.role === 'assistant' && m.changeSetId != null && canEdit && (
|
||||||
|
<UndoButton changeSet={{ id: m.changeSetId }} undo={undo} className="mt-1 items-start" />
|
||||||
|
)}
|
||||||
|
</Bubble>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<>
|
||||||
|
<Bubble role="user" body={pending.message} />
|
||||||
|
<div className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
||||||
|
<ol className="flex flex-col gap-0.5">
|
||||||
|
{pending.steps.map((s) => (
|
||||||
|
<li key={s.index} className="text-xs text-muted">
|
||||||
|
{describeStep(s)}…
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
||||||
|
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warning && <Alert tone="info">{warning}</Alert>}
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div ref={bottom} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-2">
|
||||||
|
<TextArea
|
||||||
|
label="Message"
|
||||||
|
name="agentMessage"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Change the garlic bed to cucumbers this year"
|
||||||
|
value={input}
|
||||||
|
disabled={!!pending}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter sends, Shift+Enter breaks the line — the convention every
|
||||||
|
// chat box uses, and typing a newline by accident mid-thought is a
|
||||||
|
// worse failure than the reverse.
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{pending && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
abort.current?.abort()
|
||||||
|
setPending(null)
|
||||||
|
refresh.everything()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button className="px-3 py-1.5 text-sm" disabled={!!pending || input.trim() === ''} onClick={send}>
|
||||||
|
{pending ? 'Working…' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({
|
||||||
|
role,
|
||||||
|
body,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
body: string
|
||||||
|
children?: ReactNode
|
||||||
|
}) {
|
||||||
|
const mine = role === 'user'
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
|
||||||
|
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import type { ChangeSet, UndoOutcome, useUndo } from '@/lib/history'
|
import type { UndoOutcome, UndoTarget, useUndo } from '@/lib/history'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo, plus what happened. Paired with useUndo so the history list and the
|
* Undo, plus what happened. Paired with useUndo so the history list and the
|
||||||
@@ -14,7 +14,7 @@ export function UndoButton({
|
|||||||
className,
|
className,
|
||||||
label = 'Undo',
|
label = 'Undo',
|
||||||
}: {
|
}: {
|
||||||
changeSet: ChangeSet
|
changeSet: UndoTarget
|
||||||
undo: ReturnType<typeof useUndo>
|
undo: ReturnType<typeof useUndo>
|
||||||
className?: string
|
className?: string
|
||||||
label?: string
|
label?: string
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { describeStep } from './agent'
|
||||||
|
|
||||||
|
describe('describeStep', () => {
|
||||||
|
// Raw tool names tell you the agent is busy; these tell you what it's busy
|
||||||
|
// DOING, which is the difference between the panel feeling alive and hung.
|
||||||
|
it("names tools in the app's own vocabulary", () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['clear_object'] })).toBe('Clearing a bed')
|
||||||
|
expect(describeStep({ index: 1, tools: ['find_plant'] })).toBe('Looking up a plant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses repeats within one step', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['fill_region', 'fill_region'] })).toBe('Filling a bed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins distinct tools', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['clear_object', 'fill_region'] })).toBe('Clearing a bed, Filling a bed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says something for a step with no tool calls', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: [] })).toBe('Thinking')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A tool added server-side before the client knows about it should degrade to
|
||||||
|
// something readable rather than showing snake_case at the user.
|
||||||
|
it('falls back readably for an unknown tool', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['prune_orchard'] })).toBe('prune orchard')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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) })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,3 +113,29 @@ describe('totalChanges', () => {
|
|||||||
).toBe(13)
|
).toBe(13)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('describeUndo with an unknown total', () => {
|
||||||
|
// The chat panel offers Undo on a turn knowing only its change set id — the
|
||||||
|
// agent's reply carries the id, not the tally. Inventing a denominator to
|
||||||
|
// fill the sentence would be worse than not having one.
|
||||||
|
it("doesn't invent a denominator it was never given", () => {
|
||||||
|
const out = describeUndo(
|
||||||
|
{ id: 5 },
|
||||||
|
{
|
||||||
|
changeSet: changeSet({ id: 6, counts: [{ entityType: 'planting', op: 'delete', n: 2 }] }),
|
||||||
|
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(out.tone).toBe('partial')
|
||||||
|
expect(out.message).toBe('Partly undone — “North Bed” was edited since, so it was left alone.')
|
||||||
|
expect(out.message).not.toMatch(/\d+ of \d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still reports a clean undo the same way', () => {
|
||||||
|
const out = describeUndo(
|
||||||
|
{ id: 5 },
|
||||||
|
{ changeSet: changeSet({ id: 6, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [] },
|
||||||
|
)
|
||||||
|
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+23
-6
@@ -113,9 +113,10 @@ export function useRevertChangeSet(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many rows a change set touched, for "3 changes" in the list. */
|
/** How many rows a change set touched, for "3 changes" in the list. Undefined
|
||||||
export function totalChanges(cs: ChangeSet): number {
|
* counts total zero, which callers read as "no denominator to quote". */
|
||||||
return cs.counts.reduce((sum, c) => sum + c.n, 0)
|
export function totalChanges(cs: { counts?: ChangeCount[] }): number {
|
||||||
|
return (cs.counts ?? []).reduce((sum, c) => sum + c.n, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
|
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
|
||||||
@@ -171,7 +172,7 @@ export function useUndo(gardenId: number) {
|
|||||||
const revert = useRevertChangeSet(gardenId)
|
const revert = useRevertChangeSet(gardenId)
|
||||||
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
|
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
|
||||||
|
|
||||||
const undo = (cs: ChangeSet) => {
|
const undo = (cs: UndoTarget) => {
|
||||||
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
|
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
|
||||||
revert.mutate(cs.id, {
|
revert.mutate(cs.id, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -197,6 +198,19 @@ export function useUndo(gardenId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What undo needs to know about a change set.
|
||||||
|
*
|
||||||
|
* `counts` is optional because the chat panel offers Undo on a turn knowing only
|
||||||
|
* its change set id — the agent's reply carries the id, not the tally. Fabricating
|
||||||
|
* counts to satisfy a type would produce a confidently wrong "1 of 1 changes
|
||||||
|
* undone"; leaving them out lets describeUndo say what it actually knows.
|
||||||
|
*/
|
||||||
|
export interface UndoTarget {
|
||||||
|
id: number
|
||||||
|
counts?: ChangeCount[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface UndoOutcome {
|
export interface UndoOutcome {
|
||||||
tone: 'pending' | 'ok' | 'partial' | 'error'
|
tone: 'pending' | 'ok' | 'partial' | 'error'
|
||||||
message: string
|
message: string
|
||||||
@@ -208,7 +222,7 @@ export interface UndoOutcome {
|
|||||||
* useful thing to report: a bare failure would be a lie about the two that did
|
* useful thing to report: a bare failure would be a lie about the two that did
|
||||||
* apply, and a bare success would hide the one that didn't.
|
* apply, and a bare success would hide the one that didn't.
|
||||||
*/
|
*/
|
||||||
export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutcome {
|
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
|
||||||
const skipped = result.conflicts.map(describeConflict).join('; ')
|
const skipped = result.conflicts.map(describeConflict).join('; ')
|
||||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||||
if (result.conflicts.length === 0) {
|
if (result.conflicts.length === 0) {
|
||||||
@@ -221,6 +235,9 @@ export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutco
|
|||||||
if (applied === 0) {
|
if (applied === 0) {
|
||||||
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
||||||
}
|
}
|
||||||
|
// Only claim a denominator when we have one. "2 of 3" from a caller that
|
||||||
|
// never knew the total would be a number invented to fill a sentence.
|
||||||
const total = totalChanges(target)
|
const total = totalChanges(target)
|
||||||
return { tone: 'partial', message: `${applied} of ${total} changes undone — ${skipped}.` }
|
const scale = total > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
|
||||||
|
return { tone: 'partial', message: `${scale} — ${skipped}.` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||||
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
||||||
import { HistoryPanel } from '@/editor/HistoryPanel'
|
import { HistoryPanel } from '@/editor/HistoryPanel'
|
||||||
|
import { ChatPanel } from '@/editor/ChatPanel'
|
||||||
import { JournalPanel } from '@/editor/JournalPanel'
|
import { JournalPanel } from '@/editor/JournalPanel'
|
||||||
import { Inspector } from '@/editor/Inspector'
|
import { Inspector } from '@/editor/Inspector'
|
||||||
import { PlopInspector } from '@/editor/PlopInspector'
|
import { PlopInspector } from '@/editor/PlopInspector'
|
||||||
@@ -30,6 +31,7 @@ import {
|
|||||||
} from '@/lib/objects'
|
} from '@/lib/objects'
|
||||||
import { toEditorPlanting } from '@/lib/plantings'
|
import { toEditorPlanting } from '@/lib/plantings'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import { useCapabilities } from '@/lib/agent'
|
||||||
import { useJournalCounts } from '@/lib/journal'
|
import { useJournalCounts } from '@/lib/journal'
|
||||||
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { useSeedTray } from '@/lib/seedTray'
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
@@ -64,6 +66,7 @@ export function GardenEditorPage() {
|
|||||||
const journalObjectId = useEditorStore((s) => s.journalObjectId)
|
const journalObjectId = useEditorStore((s) => s.journalObjectId)
|
||||||
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
|
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
|
||||||
const journalCounts = useJournalCounts(gid)
|
const journalCounts = useJournalCounts(gid)
|
||||||
|
const capabilities = useCapabilities()
|
||||||
const journalTotal = useMemo(
|
const journalTotal = useMemo(
|
||||||
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
|
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
|
||||||
[journalCounts.data],
|
[journalCounts.data],
|
||||||
@@ -385,6 +388,16 @@ export function GardenEditorPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Only when the instance actually has the assistant configured. A tab that
|
||||||
|
// opens onto an apology is worse than no tab.
|
||||||
|
if (capabilities.data?.agent) {
|
||||||
|
railTabs.push({
|
||||||
|
id: 'chat',
|
||||||
|
label: 'Assistant',
|
||||||
|
render: () => <ChatPanel gardenId={gid} canEdit={canEdit} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
||||||
<div className="shrink-0 md:w-40">
|
<div className="shrink-0 md:w-40">
|
||||||
@@ -422,6 +435,15 @@ export function GardenEditorPage() {
|
|||||||
>
|
>
|
||||||
History
|
History
|
||||||
</Button>
|
</Button>
|
||||||
|
{capabilities.data?.agent && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-1 w-full text-sm"
|
||||||
|
onClick={() => setRailTab(railTab === 'chat' ? null : 'chat')}
|
||||||
|
>
|
||||||
|
💬 Assistant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user