From 8dbbc5439df05b5b501c715b930aba315c9f652b Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 06:43:22 +0000 Subject: [PATCH] Chat panel in the garden editor (#57) (#71) Co-authored-by: Steve Dudenhoeffer --- DESIGN.md | 3 +- README.md | 5 +- internal/api/api.go | 25 +++ web/src/editor/ChatPanel.tsx | 239 +++++++++++++++++++++++++++++ web/src/editor/UndoButton.tsx | 4 +- web/src/lib/agent.test.ts | 29 ++++ web/src/lib/agent.ts | 232 ++++++++++++++++++++++++++++ web/src/lib/history.test.ts | 26 ++++ web/src/lib/history.ts | 29 +++- web/src/pages/GardenEditorPage.tsx | 22 +++ 10 files changed, 604 insertions(+), 10 deletions(-) create mode 100644 web/src/editor/ChatPanel.tsx create mode 100644 web/src/lib/agent.test.ts create mode 100644 web/src/lib/agent.ts diff --git a/DESIGN.md b/DESIGN.md index ebb46f4..f217298 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 POST /agent/chat ← SSE: step events, then the finished turn (editor only) 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) ``` @@ -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`. - **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`. -- **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 diff --git a/README.md b/README.md index a4b959d..af9385e 100644 --- a/README.md +++ b/README.md @@ -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. +**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. 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_CLIENT_ID: ... # 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 volumes: pansy-data: diff --git a/internal/api/api.go b/internal/api/api.go index 021b5ea..8777f11 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -53,6 +53,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { // is set; see csrfGuard). v1.Use(h.csrfGuard()) 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); // /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 same shape as OIDC. An instance with no API key serves the app // 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() { runner, err := agent.NewRunner(svc, cfg) if err != nil { @@ -180,6 +195,16 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine { 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. func healthz(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) diff --git a/web/src/editor/ChatPanel.tsx b/web/src/editor/ChatPanel.tsx new file mode 100644 index 0000000..3a7b486 --- /dev/null +++ b/web/src/editor/ChatPanel.tsx @@ -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(null) + const [warning, setWarning] = useState(null) + const abort = useRef(null) + const bottom = useRef(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 ( +
+
+

Assistant

+ {messages.length > 0 && ( + + )} +
+ + {!canEdit && ( +

+ You can only view this garden, so the assistant can't change anything in it. +

+ )} + +
+ {history.isPending &&

Loading the conversation…

} + {/* 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 && ( + {errorMessage(history.error, "Couldn't load the conversation.")} + )} + + {history.isSuccess && messages.length === 0 && !pending && ( +

+ 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. +

+ )} + + {messages.map((m) => ( + + {/* 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 && ( + + )} + + ))} + + {pending && ( + <> + +
+
    + {pending.steps.map((s) => ( +
  1. + {describeStep(s)}… +
  2. + ))} +
+

+ + {pending.steps.length === 0 ? 'Thinking…' : 'Working…'} +

+
+ + )} + + {warning && {warning}} + {error && {error}} +
+
+ +
+