Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa9288b4b | ||
|
|
6a4fd40bc3 | ||
|
|
1e2b763566 | ||
|
|
757ac7394d | ||
|
|
156b3fd14c | ||
|
|
e3d8e01e5b | ||
|
|
d82db48e4b | ||
|
|
20bf7ee03d | ||
|
|
cb594f34d8 | ||
|
|
74f6b9a876 |
@@ -12,6 +12,7 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
||||
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
|
||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here** — `OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither.
|
||||
- **Seed-packet capture (#81):** photograph a packet → a *vision* model (separate `vision_model` setting) reads it into structured fields via one-shot `majordomo.Generate[SeedPacket]` — NOT an agent loop, so the extraction can't touch the garden; it only reads a picture and returns data. The image is normalized to JPEG at the upload boundary (`internal/imagenorm`: decodes HEIC/webp/png/jpeg, since majordomo's stdlib media path can't do HEIC — the iPhone default). The hard part is **catalog matching, not OCR**: a wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates — it surfaces ranked candidates (`matchPlants`) and the user confirms, then `CreateFromPacket` makes the plant (new or existing) + the lot. Plants/lots aren't in the undo history (they're catalog/inventory), so there's no change set to wrap. The extractor is injectable on the service (`WithPacketExtractor`) so the whole path tests hermetically against majordomo's `fake` provider.
|
||||
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
|
||||
|
||||
## Domain model
|
||||
@@ -69,12 +70,14 @@ POST /objects/:id/fill ← hex-pack a region with one plant; region by co
|
||||
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||
POST /seed-lots/scan ← multipart image → a seed-packet proposal (reads only, no writes)
|
||||
POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot
|
||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||
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 RIGHT NOW (tracks the live agent, not just config)
|
||||
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off
|
||||
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model
|
||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
||||
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
||||
|
||||
@@ -70,8 +70,9 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
|
||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
|
||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
|
||||
| `PANSY_VISION_MODEL` | *(empty)* | Default model for **seed-packet capture** (photograph a packet → it fills in the plant + purchase). A *vision-capable* model (the chat model may not be). Empty = the feature isn't offered. Runs against the same `OLLAMA_CLOUD_API_KEY`, and is overridable in Settings. |
|
||||
|
||||
The model and enabled flag can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — the change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence for the model and enabled flag is **Settings value, if set → env var → built-in default**.
|
||||
The agent model + enabled flag, and the vision model, can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — an agent change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence is **Settings value, if set → env var → built-in default**.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ go 1.26.2
|
||||
require (
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/gen2brain/heic v0.7.1
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/samber/slog-gin v1.15.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
@@ -33,6 +35,7 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
@@ -51,14 +54,15 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
|
||||
@@ -26,12 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
|
||||
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
@@ -126,6 +130,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -144,11 +150,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -163,27 +171,27 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
|
||||
+17
-1
@@ -185,6 +185,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
seedLots.GET("/:id", h.getSeedLot)
|
||||
seedLots.PATCH("/:id", h.updateSeedLot)
|
||||
seedLots.DELETE("/:id", h.deleteSeedLot)
|
||||
// Seed-packet capture (#81): scan a photo into a proposal, then create the
|
||||
// plant + lot from the confirmed proposal. scan reads only.
|
||||
seedLots.POST("/scan", h.scanSeedPacket)
|
||||
seedLots.POST("/from-packet", h.createFromPacket)
|
||||
|
||||
// Public, unauthenticated read of a garden by its share token. Deliberately
|
||||
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
|
||||
@@ -204,7 +208,19 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
|
||||
// load) means this reflects a settings-driven swap on the very next poll.
|
||||
func (h *handlers) capabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil})
|
||||
// vision advertises whether seed-packet scanning (#81) can be offered — a
|
||||
// configured, resolvable vision model + a key. Read per-request so a settings
|
||||
// change is reflected on the next poll, same as agent.
|
||||
vision := false
|
||||
if vis, err := h.svc.EffectiveVision(c.Request.Context()); err != nil {
|
||||
// A read fault here means the DB is unhappy; report vision off (safe: the
|
||||
// UI just hides a button) but don't do it silently — the same best-effort
|
||||
// settings reads elsewhere log rather than swallow.
|
||||
slog.Error("api: could not resolve vision settings for capabilities", "error", err)
|
||||
} else {
|
||||
vision = vis.Ready()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil, "vision": vision})
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
|
||||
@@ -16,9 +16,12 @@ import (
|
||||
// the buyer — a lot is never shared along with a garden — so every handler here
|
||||
// scopes to the session actor with no garden in the picture.
|
||||
|
||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||
type seedLotCreateRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
// seedLotFields is the lot half of a create body — every field EXCEPT which plant
|
||||
// it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
|
||||
// confirm supplies none (the plant comes from its plantId/newPlant choice), so it
|
||||
// embeds these fields directly. Sharing one struct keeps the two request shapes —
|
||||
// and their validation — from drifting apart.
|
||||
type seedLotFields struct {
|
||||
Vendor string `json:"vendor"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
SKU string `json:"sku"`
|
||||
@@ -32,15 +35,28 @@ type seedLotCreateRequest struct {
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
||||
// toInput builds the service input with no plant attribution; callers that know
|
||||
// the plant (the create handler; the packet confirm) set PlantID afterwards.
|
||||
func (f seedLotFields) toInput() service.SeedLotInput {
|
||||
return service.SeedLotInput{
|
||||
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
|
||||
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
|
||||
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
|
||||
GerminationPct: r.GerminationPct, Notes: r.Notes,
|
||||
Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
|
||||
PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
|
||||
Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
// seedLotCreateRequest is the body for POST /seed-lots.
|
||||
type seedLotCreateRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
seedLotFields
|
||||
}
|
||||
|
||||
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
|
||||
in := r.seedLotFields.toInput()
|
||||
in.PlantID = r.PlantID
|
||||
return in
|
||||
}
|
||||
|
||||
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
|
||||
// optional, plus the required current version. The nullable columns are
|
||||
// json.RawMessage so an explicit null (clear it) is distinguishable from an
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Seed-packet capture (#81). Two steps, deliberately separate:
|
||||
// POST /seed-lots/scan multipart image → a proposal (reads only)
|
||||
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
|
||||
// The scan never writes; creation happens only from an explicit confirm, so a
|
||||
// misread can't add anything to the catalog on its own.
|
||||
|
||||
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
|
||||
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
|
||||
// a few MB, so this is generous.
|
||||
const scanUploadLimit = 30 << 20
|
||||
|
||||
// scanReadTimeout is how long we allow the image upload to take. The server's
|
||||
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
|
||||
// on a slow phone connection, so this endpoint extends it — the same
|
||||
// ResponseController mechanism the SSE path uses for writes (#78).
|
||||
const scanReadTimeout = 60 * time.Second
|
||||
|
||||
// scanWriteTimeout extends the write deadline for the same reason. The server's
|
||||
// absolute WriteTimeout (30s) is measured from the start of the request, but this
|
||||
// handler's response can't be written until AFTER a slow upload AND a live vision
|
||||
// call — together easily past 30s. Without this, a successful extraction's
|
||||
// response is silently dropped: the exact failure mode #78 fixed for SSE.
|
||||
const scanWriteTimeout = 120 * time.Second
|
||||
|
||||
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
|
||||
func (h *handlers) scanSeedPacket(c *gin.Context) {
|
||||
// Extend both deadlines for the (potentially large, potentially slow) upload
|
||||
// and the live vision call that follows. Best-effort: if the writer doesn't
|
||||
// support it, the server defaults apply.
|
||||
rc := http.NewResponseController(c.Writer)
|
||||
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
|
||||
// malformed request. Everything else here is a genuinely missing/garbled
|
||||
// multipart field.
|
||||
var tooBig *http.MaxBytesError
|
||||
if errors.As(err, &tooBig) {
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
return
|
||||
}
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
|
||||
return
|
||||
}
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
// Opening the parsed upload failed on our side, not the client's.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
|
||||
// everything downstream — including the vision model — only sees a format it
|
||||
// can read. This is where an iPhone HEIC becomes usable.
|
||||
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, imagenorm.ErrTooLarge):
|
||||
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
|
||||
case errors.Is(err, imagenorm.ErrUnsupported):
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
|
||||
default:
|
||||
// A read or re-encode fault is ours, not bad input.
|
||||
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
|
||||
if err != nil {
|
||||
// A missing vision model surfaces as ErrInvalidInput from the service; give
|
||||
// it a clearer message than the generic 400, since the UI shouldn't have
|
||||
// offered the button at all in that case.
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
|
||||
return
|
||||
}
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, prop)
|
||||
}
|
||||
|
||||
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
|
||||
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
|
||||
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
|
||||
// comes from the plantId/newPlant choice, not the lot body.
|
||||
type fromPacketRequest struct {
|
||||
PlantID *int64 `json:"plantId"`
|
||||
NewPlant *plantCreateRequest `json:"newPlant"`
|
||||
Lot seedLotFields `json:"lot"`
|
||||
}
|
||||
|
||||
// createFromPacket turns a confirmed proposal into a plant + lot.
|
||||
func (h *handlers) createFromPacket(c *gin.Context) {
|
||||
var req fromPacketRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
|
||||
return
|
||||
}
|
||||
|
||||
confirm := service.PacketConfirm{
|
||||
PlantID: req.PlantID,
|
||||
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
|
||||
}
|
||||
if req.NewPlant != nil {
|
||||
in := req.NewPlant.toInput()
|
||||
confirm.NewPlant = &in
|
||||
}
|
||||
|
||||
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, res)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// packetEngine builds an engine whose service reads seed packets via the given
|
||||
// canned extractor, so the scan endpoint can be tested without a live model.
|
||||
func packetEngine(t *testing.T, cfg *config.Config, extract func() (vision.SeedPacket, error)) *gin.Engine {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := db.Migrate(context.Background()); err != nil {
|
||||
t.Fatalf("Migrate: %v", err)
|
||||
}
|
||||
svc := service.New(db, cfg, service.WithPacketExtractor(
|
||||
func(context.Context, string, string, []byte) (vision.SeedPacket, error) { return extract() },
|
||||
))
|
||||
return New(cfg, svc)
|
||||
}
|
||||
|
||||
// visionCfg is a config with a vision model + key configured, so packet scanning
|
||||
// is available.
|
||||
func visionCfg() *config.Config {
|
||||
c := localCfg()
|
||||
c.Agent = config.AgentConfig{OllamaCloudAPIKey: "k", VisionModel: "ollama-cloud/vision:cloud"}
|
||||
return c
|
||||
}
|
||||
|
||||
// pngUpload builds a multipart body with a real PNG under the "image" field.
|
||||
func pngUpload(t *testing.T) (body *bytes.Buffer, contentType string) {
|
||||
t.Helper()
|
||||
var img bytes.Buffer
|
||||
if err := png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 32, 24))); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
part, err := w.CreateFormFile("image", "packet.png")
|
||||
if err != nil {
|
||||
t.Fatalf("form file: %v", err)
|
||||
}
|
||||
part.Write(img.Bytes())
|
||||
w.Close()
|
||||
return &buf, w.FormDataContentType()
|
||||
}
|
||||
|
||||
func doMultipart(t *testing.T, r *gin.Engine, path, contentType string, body *bytes.Buffer, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, path, body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
if cookie != nil {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestScanSeedPacketAPI: a multipart image → a proposal, end to end through the
|
||||
// router. The extractor is canned; imagenorm runs for real on the uploaded PNG.
|
||||
func TestScanSeedPacketAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||
return vision.SeedPacket{Species: "garlic", Variety: "Music", Category: "vegetable"}, nil
|
||||
})
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
// A matching plant so the proposal has a candidate.
|
||||
createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||
|
||||
body, ct := pngUpload(t)
|
||||
w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct, body, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("scan: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
res := decodeMap(t, w.Body.Bytes())
|
||||
pkt, _ := res["packet"].(map[string]any)
|
||||
if pkt["variety"] != "Music" {
|
||||
t.Errorf("packet variety = %v", pkt["variety"])
|
||||
}
|
||||
if cands, _ := res["candidates"].([]any); len(cands) == 0 {
|
||||
t.Error("expected a candidate match for Music Garlic")
|
||||
}
|
||||
if res["suggestedName"] != "Music" {
|
||||
t.Errorf("suggestedName = %v", res["suggestedName"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSeedPacketErrorsAPI: no image, unreadable bytes, and vision-not-
|
||||
// configured each get their own clear status.
|
||||
func TestScanSeedPacketErrorsAPI(t *testing.T) {
|
||||
// Vision configured, so we reach imagenorm / the extractor.
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
|
||||
return vision.SeedPacket{Variety: "X"}, nil
|
||||
})
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
// No file field → 400.
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", "multipart/form-data; boundary=x", bytes.NewBufferString(""), cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("no image = %d, want 400", w.Code)
|
||||
}
|
||||
// A file that isn't an image → 400 (imagenorm rejects it).
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, _ := mw.CreateFormFile("image", "notes.txt")
|
||||
part.Write([]byte("this is not an image"))
|
||||
mw.Close()
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-image = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Vision NOT configured → 503, even with a valid image.
|
||||
r2 := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie2 := registerAndCookie(t, r2, "[email protected]")
|
||||
body, ct := pngUpload(t)
|
||||
if w := doMultipart(t, r2, "/api/v1/seed-lots/scan", ct, body, cookie2); w.Code != http.StatusServiceUnavailable {
|
||||
t.Errorf("no vision model = %d, want 503", w.Code)
|
||||
}
|
||||
|
||||
// Unauthenticated → 401.
|
||||
b3, ct3 := pngUpload(t)
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct3, b3, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous scan = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanSeedPacketTooLargeAPI: a body over the multipart cap trips
|
||||
// MaxBytesReader, which must surface as 413 (too large), not 400 (malformed).
|
||||
func TestScanSeedPacketTooLargeAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
part, _ := mw.CreateFormFile("image", "big.png")
|
||||
// A hair over scanUploadLimit (30 MiB) so MaxBytesReader trips during parsing.
|
||||
part.Write(bytes.Repeat([]byte{0}, (30<<20)+1024))
|
||||
mw.Close()
|
||||
|
||||
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized upload = %d, want 413", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketAPI: confirm → plant + lot. No model involved, so the full
|
||||
// path runs through the router.
|
||||
func TestCreateFromPacketAPI(t *testing.T) {
|
||||
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
// New plant + lot.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"newPlant": map[string]any{"name": "Music Garlic", "category": "vegetable", "color": "#4a7c3f", "icon": "🧄", "spacingCm": 15},
|
||||
"lot": map[string]any{"vendor": "Johnny's", "quantity": 8, "unit": "bulbs"},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("new plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
res := decodeMap(t, w.Body.Bytes())
|
||||
if res["plantIsNew"] != true {
|
||||
t.Errorf("plantIsNew = %v, want true", res["plantIsNew"])
|
||||
}
|
||||
plantObj, _ := res["plant"].(map[string]any)
|
||||
plantID := int64(plantObj["id"].(float64))
|
||||
lotObj, _ := res["lot"].(map[string]any)
|
||||
if int64(lotObj["plantId"].(float64)) != plantID {
|
||||
t.Errorf("lot not attributed to the new plant")
|
||||
}
|
||||
|
||||
// Existing plant + lot.
|
||||
w = doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"plantId": plantID,
|
||||
"lot": map[string]any{"vendor": "Fedco", "quantity": 10, "unit": "bulbs"},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("existing plant confirm: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if decodeMap(t, w.Body.Bytes())["plantIsNew"] != false {
|
||||
t.Error("plantIsNew should be false for an existing plant")
|
||||
}
|
||||
|
||||
// Both plantId and newPlant → 400.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"plantId": plantID,
|
||||
"newPlant": map[string]any{"name": "X", "category": "vegetable", "color": "#4a7c3f", "icon": "🌱"},
|
||||
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("both plant choices = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Neither → 400.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
|
||||
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("neither plant choice = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// Unauthenticated → 401.
|
||||
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous confirm = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilitiesReportsVision: /capabilities advertises vision only when a
|
||||
// vision model is configured.
|
||||
func TestCapabilitiesReportsVision(t *testing.T) {
|
||||
on := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie := registerAndCookie(t, on, "[email protected]")
|
||||
w := doJSON(t, on, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||
if decodeMap(t, w.Body.Bytes())["vision"] != true {
|
||||
t.Errorf("vision should be true with a model configured: %s", w.Body.String())
|
||||
}
|
||||
|
||||
off := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
|
||||
cookie2 := registerAndCookie(t, off, "[email protected]")
|
||||
w = doJSON(t, off, http.MethodGet, "/api/v1/capabilities", nil, cookie2)
|
||||
if decodeMap(t, w.Body.Bytes())["vision"] != false {
|
||||
t.Errorf("vision should be false with no model: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -51,26 +51,33 @@ type effectiveView struct {
|
||||
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
|
||||
// which is exactly the case the UI needs to surface.
|
||||
AgentLive bool `json:"agentLive"`
|
||||
// VisionModel is the resolved seed-packet model (DB-over-env). VisionReady is
|
||||
// whether capture can actually be offered (a key and a model).
|
||||
VisionModel string `json:"visionModel"`
|
||||
VisionReady bool `json:"visionReady"`
|
||||
}
|
||||
|
||||
// settingsPayload builds the response, or an error. It does NOT swallow an
|
||||
// EffectiveAgent failure into a misleading empty "effective" view — an empty
|
||||
// EffectiveConfig failure into a misleading empty "effective" view — an empty
|
||||
// view would report no model and no key, which reads as "nothing configured"
|
||||
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row
|
||||
// rather than "we couldn't read it". Since EffectiveConfig re-reads the same row
|
||||
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
|
||||
// surfacing as a 500, not papering over.
|
||||
// surfacing as a 500, not papering over. It also resolves the agent and vision
|
||||
// views from ONE row read rather than fetching the single-row table twice.
|
||||
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
|
||||
eff, err := h.svc.EffectiveAgent(c.Request.Context())
|
||||
eff, vis, err := h.svc.EffectiveConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
return settingsResponse{}, err
|
||||
}
|
||||
return settingsResponse{
|
||||
Settings: st,
|
||||
Effective: effectiveView{
|
||||
Model: eff.Model,
|
||||
Enabled: eff.Enabled,
|
||||
HasApiKey: eff.APIKey != "",
|
||||
AgentLive: h.agent.get() != nil,
|
||||
Model: eff.Model,
|
||||
Enabled: eff.Enabled,
|
||||
HasApiKey: eff.APIKey != "",
|
||||
AgentLive: h.agent.get() != nil,
|
||||
VisionModel: vis.Model,
|
||||
VisionReady: vis.Ready(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -95,6 +102,7 @@ func (h *handlers) getSettings(c *gin.Context) {
|
||||
type settingsUpdateRequest struct {
|
||||
AgentModel string `json:"agentModel"`
|
||||
AgentEnabled json.RawMessage `json:"agentEnabled"`
|
||||
VisionModel string `json:"visionModel"`
|
||||
Version int64 `json:"version" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -117,6 +125,7 @@ func (h *handlers) updateSettings(c *gin.Context) {
|
||||
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
|
||||
AgentModel: req.AgentModel,
|
||||
AgentEnabled: enabled,
|
||||
VisionModel: req.VisionModel,
|
||||
Version: req.Version,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -74,6 +74,11 @@ type AgentConfig struct {
|
||||
// a key is present, so an instance with no key starts cleanly and simply
|
||||
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
||||
Enabled bool
|
||||
// VisionModel is the model that reads a photographed seed packet
|
||||
// (PANSY_VISION_MODEL). Empty by default: the seed-packet capture feature is
|
||||
// only offered when a vision-capable model is configured (in env or Settings)
|
||||
// and a key is present. Passed verbatim to majordomo.Parse, like Model.
|
||||
VisionModel string
|
||||
}
|
||||
|
||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||
@@ -120,6 +125,9 @@ func Load() *Config {
|
||||
// opt-in, and making people set a second flag to use what they just
|
||||
// configured is a papercut with no upside.
|
||||
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
||||
// No default vision model: unlike chat there's no obvious safe default,
|
||||
// and the feature stays off until an admin names one that can see.
|
||||
VisionModel: envStr("PANSY_VISION_MODEL", ""),
|
||||
}
|
||||
|
||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||
|
||||
@@ -253,9 +253,13 @@ type InstanceSettings struct {
|
||||
// majordomo.Parse, exactly like the env var it shadows.
|
||||
AgentModel string `json:"agentModel"`
|
||||
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
|
||||
AgentEnabled *bool `json:"agentEnabled"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
AgentEnabled *bool `json:"agentEnabled"`
|
||||
// VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that
|
||||
// reads a photographed seed packet (#81). Empty = feature off unless the env
|
||||
// var names one.
|
||||
VisionModel string `json:"visionModel"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
|
||||
// (and majordomo's vision path) can rely on, decoding the formats a phone
|
||||
// actually produces.
|
||||
//
|
||||
// # Why this exists
|
||||
//
|
||||
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
|
||||
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
|
||||
// first input is "a photo from my phone", so without this the feature fails on
|
||||
// the exact device that motivates it. Normalizing at the upload boundary means
|
||||
// everything downstream only ever sees JPEG.
|
||||
//
|
||||
// # The CGO constraint
|
||||
//
|
||||
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
|
||||
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
|
||||
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
|
||||
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
|
||||
//
|
||||
// # Import-driven registry
|
||||
//
|
||||
// Go's image decoders register via blank imports, and the failure mode is
|
||||
// backwards from intuition: forget "image/png" and PNG uploads fail with
|
||||
// "unknown format" while the exotic HEIC still works. So the blank imports below
|
||||
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
|
||||
// keep them so.
|
||||
package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
// Decoders, registered with image.Decode by side effect. All four matter:
|
||||
// jpeg/png are the common cases, heic is the iPhone default, webp is common
|
||||
// on the web. Dropping any one silently breaks that format's uploads.
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "github.com/gen2brain/heic"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
|
||||
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
|
||||
const (
|
||||
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
|
||||
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
|
||||
// left at 2048.
|
||||
DefaultMaxDim = 2048
|
||||
// DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25
|
||||
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
|
||||
// as an unbounded read. The re-encoded output is far smaller.
|
||||
DefaultMaxBytes = 25 << 20
|
||||
// maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so
|
||||
// a small file claiming enormous dimensions (a decompression bomb) is refused
|
||||
// before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any
|
||||
// current phone camera (a 48 MP sensor is 48 MP) while capping the
|
||||
// amplification a hostile header can force. maxDecodePixels and maxDimension
|
||||
// are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no
|
||||
// reason for a caller to raise them.
|
||||
maxDecodePixels = 50_000_000
|
||||
// maxDimension caps EACH side independently. It exists to make the pixel-count
|
||||
// check overflow-safe: without it, a header claiming ~2^32 on a side could
|
||||
// wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image
|
||||
// is 50k px on a side.
|
||||
maxDimension = 50_000
|
||||
// jpegQuality for the normalized output. 85 is visually clean and keeps the
|
||||
// file small; the model reads text off it, not fine gradients.
|
||||
jpegQuality = 85
|
||||
)
|
||||
|
||||
// Options tunes Normalize. The zero value uses the Default* constants.
|
||||
type Options struct {
|
||||
MaxDim int // longest edge; 0 → DefaultMaxDim
|
||||
MaxBytes int // input read cap; 0 → DefaultMaxBytes
|
||||
}
|
||||
|
||||
func (o Options) maxDim() int {
|
||||
if o.MaxDim > 0 {
|
||||
return o.MaxDim
|
||||
}
|
||||
return DefaultMaxDim
|
||||
}
|
||||
|
||||
func (o Options) maxBytes() int {
|
||||
if o.MaxBytes > 0 {
|
||||
return o.MaxBytes
|
||||
}
|
||||
return DefaultMaxBytes
|
||||
}
|
||||
|
||||
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
|
||||
// pixel count. ErrUnsupported means the bytes weren't a decodable image format —
|
||||
// or a decoder panicked on them (see the recover in Normalize).
|
||||
var (
|
||||
ErrTooLarge = errors.New("imagenorm: image too large")
|
||||
ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image")
|
||||
)
|
||||
|
||||
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||||
// downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded
|
||||
// as JPEG, plus the decoded format name (e.g. "heic") — handy for logging what a
|
||||
// phone actually sent. On any error the returned bytes are nil and format is "".
|
||||
//
|
||||
// Errors, by cause:
|
||||
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
|
||||
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
|
||||
// - bytes that aren't a decodable image, or a decoder that panics on them →
|
||||
// ErrUnsupported;
|
||||
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
|
||||
// sentinel), since those are the caller's stream/environment, not the image.
|
||||
//
|
||||
// It bounds work against a hostile upload three ways: the byte cap, the
|
||||
// pre-decode pixel/dimension check, and a recover around the third-party decoders
|
||||
// (a malformed HEIC/WebP shouldn't take the process down).
|
||||
//
|
||||
// Two known gaps, both deferred to the upload handler that wires this in (#81):
|
||||
// - EXIF ORIENTATION is not applied, so a portrait phone photo tagged
|
||||
// "rotate 90°" comes out sideways. That's best fixed and tested with a real
|
||||
// oriented photo end-to-end, which the library has no consumer for yet.
|
||||
// - There is no context: image.Decode is CPU-bound and not cancellable
|
||||
// mid-decode, so a caller that needs a hard deadline should run Normalize
|
||||
// under its own timeout. The size guards keep the work finite regardless.
|
||||
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||||
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||||
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
|
||||
byteCap := opts.maxBytes()
|
||||
limit := int64(byteCap) + 1
|
||||
if limit < 1 {
|
||||
limit = int64(DefaultMaxBytes) + 1
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r, limit))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
|
||||
}
|
||||
if len(raw) > byteCap {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
|
||||
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
|
||||
// before it allocates its bitmap. The per-side maxDimension check runs first
|
||||
// so the pixel-count multiply below can't overflow.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 ||
|
||||
cfg.Width > maxDimension || cfg.Height > maxDimension ||
|
||||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
|
||||
img, format, err := decodeSafely(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
img = downscale(img, opts.maxDim())
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
|
||||
}
|
||||
return buf.Bytes(), format, nil
|
||||
}
|
||||
|
||||
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
|
||||
// into ErrUnsupported. The recover matters because the image comes from an
|
||||
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
|
||||
// x/image/webp): a malformed file that panics one of them must fail this one
|
||||
// request, not crash the process.
|
||||
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
img, format, err = nil, "", ErrUnsupported
|
||||
}
|
||||
}()
|
||||
img, format, err = image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||||
// aspect ratio. An image already within bounds is returned unchanged (no
|
||||
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||||
// a sharp result on text, which is what a packet photo is mostly made of.
|
||||
func downscale(img image.Image, maxDim int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
longest := max(w, h)
|
||||
if longest <= maxDim || longest == 0 {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxDim) / float64(longest)
|
||||
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
|
||||
// encode; heic/webp come from testdata (Go has no encoder for them).
|
||||
func pngBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
|
||||
m.Pix[m.PixOffset(x, y)+3] = 255
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, m); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, m, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func readTestdata(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
|
||||
// accept must round-trip to a valid JPEG. It exists specifically to catch a
|
||||
// dropped blank import — the failure mode where the common format (PNG) breaks
|
||||
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
|
||||
func TestNormalizeAllFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input []byte
|
||||
wantFormat string
|
||||
}{
|
||||
{"png", pngBytes(t, 120, 90), "png"},
|
||||
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
|
||||
{"heic", readTestdata(t, "sample.heic"), "heic"},
|
||||
{"webp", readTestdata(t, "sample.webp"), "webp"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%s): %v", tc.name, err)
|
||||
}
|
||||
if format != tc.wantFormat {
|
||||
t.Errorf("format = %q, want %q", format, tc.wantFormat)
|
||||
}
|
||||
// The output must itself be a decodable JPEG.
|
||||
_, outFormat, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("output isn't a valid image: %v", err)
|
||||
}
|
||||
if outFormat != "jpeg" {
|
||||
t.Errorf("output format = %q, want jpeg", outFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
|
||||
// longest edge with aspect ratio preserved, and a small one is left alone.
|
||||
func TestNormalizeDownscales(t *testing.T) {
|
||||
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
|
||||
// long edge with aspect preserved. Derived from the constant, not hard-coded,
|
||||
// so the test tracks the default rather than silently asserting a magic number.
|
||||
longEdge := DefaultMaxDim * 2
|
||||
big := pngBytes(t, longEdge, longEdge/2)
|
||||
out, _, err := Normalize(bytes.NewReader(big), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize: %v", err)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode out: %v", err)
|
||||
}
|
||||
if cfg.Width != DefaultMaxDim {
|
||||
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
|
||||
}
|
||||
if cfg.Height != DefaultMaxDim/2 {
|
||||
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
|
||||
}
|
||||
|
||||
// A small image within bounds keeps its dimensions.
|
||||
small := pngBytes(t, 100, 80)
|
||||
out, _, err = Normalize(bytes.NewReader(small), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize small: %v", err)
|
||||
}
|
||||
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode small out: %v", err)
|
||||
}
|
||||
if cfg.Width != 100 || cfg.Height != 80 {
|
||||
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
|
||||
// refused without a full decode.
|
||||
func TestNormalizeRejectsOversizeInput(t *testing.T) {
|
||||
big := pngBytes(t, 500, 500)
|
||||
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
|
||||
if err != ErrTooLarge {
|
||||
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
|
||||
// both fail cleanly with ErrUnsupported, not a panic.
|
||||
func TestNormalizeRejectsGarbage(t *testing.T) {
|
||||
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
|
||||
if err != ErrUnsupported {
|
||||
t.Errorf("garbage err = %v, want ErrUnsupported", err)
|
||||
}
|
||||
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
|
||||
png := pngBytes(t, 100, 100)
|
||||
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
|
||||
if err != ErrUnsupported {
|
||||
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
|
||||
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
|
||||
// for image.DecodeConfig to report width/height without a real bitmap — exactly
|
||||
// what's needed to exercise the pre-decode size guard with a tiny input.
|
||||
func pngHeader(w, h uint32) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // colour type: RGBA
|
||||
// compression/filter/interlace already 0.
|
||||
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
|
||||
chunk := append([]byte("IHDR"), ihdr...)
|
||||
buf.Write(chunk)
|
||||
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
|
||||
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
|
||||
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
|
||||
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
|
||||
// the near-2^16-per-side case, that the count math doesn't overflow.
|
||||
func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h uint32
|
||||
}{
|
||||
{"huge single side", 60000, 10}, // > maxDimension on width
|
||||
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hdr := pngHeader(tc.w, tc.h)
|
||||
if len(hdr) > 100 {
|
||||
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
|
||||
}
|
||||
// Sanity: the header really does decode to those dimensions.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
|
||||
if err != nil {
|
||||
t.Fatalf("crafted PNG header didn't parse: %v", err)
|
||||
}
|
||||
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
|
||||
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
|
||||
}
|
||||
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
|
||||
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# imagenorm test fixtures
|
||||
|
||||
Go has no encoder for HEIC or WebP, so these small samples are committed rather
|
||||
than generated at test time. They are used by `TestNormalizeAllFormats` to prove
|
||||
every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank
|
||||
import — see the package doc).
|
||||
|
||||
- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS
|
||||
`sips -s format heic`. HEVC still-image profile.
|
||||
- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus
|
||||
(`Lib/test/test_email/data/python.webp`), verified to decode with
|
||||
`golang.org/x/image/webp` before committing. Used only as a decode fixture.
|
||||
|
||||
Neither contains anything meaningful; they exist purely to be decoded.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 432 B |
@@ -43,6 +43,7 @@ func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*doma
|
||||
type InstanceSettingsPatch struct {
|
||||
AgentModel string
|
||||
AgentEnabled *bool
|
||||
VisionModel string
|
||||
Version int64
|
||||
}
|
||||
|
||||
@@ -58,16 +59,20 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
|
||||
return nil, err
|
||||
}
|
||||
model := strings.TrimSpace(patch.AgentModel)
|
||||
// Validate a non-empty spec up front. An empty one is the "inherit env"
|
||||
vision := strings.TrimSpace(patch.VisionModel)
|
||||
// Validate non-empty specs up front. An empty one is the "inherit env"
|
||||
// sentinel and needs no check — the env value was validated at boot.
|
||||
if model != "" {
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
|
||||
return nil, domain.ErrInvalidInput
|
||||
for _, spec := range []string{model, vision} {
|
||||
if spec != "" {
|
||||
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
}
|
||||
}
|
||||
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||
AgentModel: model,
|
||||
AgentEnabled: patch.AgentEnabled,
|
||||
VisionModel: vision,
|
||||
Version: patch.Version,
|
||||
})
|
||||
}
|
||||
@@ -95,6 +100,13 @@ func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, err
|
||||
}
|
||||
return s.agentOver(st), nil
|
||||
}
|
||||
|
||||
// agentOver layers a settings row over the env-derived agent defaults. Split out
|
||||
// so EffectiveConfig can resolve agent AND vision from a single row read instead
|
||||
// of fetching the same one-row table twice.
|
||||
func (s *Service) agentOver(st *domain.InstanceSettings) EffectiveAgent {
|
||||
eff := EffectiveAgent{
|
||||
Model: s.cfg.Agent.Model,
|
||||
Enabled: s.cfg.Agent.Enabled,
|
||||
@@ -106,5 +118,51 @@ func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
||||
if st.AgentEnabled != nil {
|
||||
eff.Enabled = *st.AgentEnabled
|
||||
}
|
||||
return eff, nil
|
||||
return eff
|
||||
}
|
||||
|
||||
// EffectiveVision resolves the vision configuration in force for seed-packet
|
||||
// capture (#81): the model from DB-over-env, the key always from env.
|
||||
type EffectiveVision struct {
|
||||
Model string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
// Ready reports whether packet capture can be offered: a key and a vision model.
|
||||
// There's no separate enabled flag — configuring a vision model IS enabling it.
|
||||
func (e EffectiveVision) Ready() bool {
|
||||
return e.APIKey != "" && e.Model != ""
|
||||
}
|
||||
|
||||
// EffectiveVision reads the settings row and layers it over the environment.
|
||||
func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveVision{}, err
|
||||
}
|
||||
return s.visionOver(st), nil
|
||||
}
|
||||
|
||||
// visionOver layers a settings row over the env-derived vision defaults. See
|
||||
// agentOver for why this is split from EffectiveVision.
|
||||
func (s *Service) visionOver(st *domain.InstanceSettings) EffectiveVision {
|
||||
eff := EffectiveVision{
|
||||
Model: s.cfg.Agent.VisionModel,
|
||||
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
||||
}
|
||||
if st.VisionModel != "" {
|
||||
eff.Model = st.VisionModel
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
// EffectiveConfig resolves the agent AND vision configuration from ONE settings
|
||||
// read, for callers (the settings view) that need both — the single-row table
|
||||
// would otherwise be fetched twice for one response.
|
||||
func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, EffectiveVision, error) {
|
||||
st, err := s.store.GetInstanceSettings(ctx)
|
||||
if err != nil {
|
||||
return EffectiveAgent{}, EffectiveVision{}, err
|
||||
}
|
||||
return s.agentOver(st), s.visionOver(st), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// Seed-packet capture (#81): read a photographed packet, propose a plant + lot,
|
||||
// let the user confirm. The two halves are deliberately separate operations:
|
||||
// extraction only READS (a picture in, a proposal out — it can't touch the
|
||||
// garden), and creation happens later, from the confirmed proposal, so a
|
||||
// misread never writes anything on its own.
|
||||
|
||||
// PacketPlantMatch is a candidate existing plant the packet might be, with why it
|
||||
// matched, so the UI can pre-select the likely one and let the user override.
|
||||
type PacketPlantMatch struct {
|
||||
Plant domain.Plant `json:"plant"`
|
||||
// Reason is a short human tag: "exact name", "variety in name", "same species".
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// PacketProposal is what a scan returns: the fields read off the packet, plus
|
||||
// candidate existing plants it might already be. Nothing is created yet.
|
||||
type PacketProposal struct {
|
||||
Packet vision.SeedPacket `json:"packet"`
|
||||
// Candidates are existing plants the packet may match, best first. Empty means
|
||||
// "probably a new variety" — the UI then offers to create one.
|
||||
Candidates []PacketPlantMatch `json:"candidates"`
|
||||
// SuggestedName is the variety (or species) to prefill a new-plant name with.
|
||||
SuggestedName string `json:"suggestedName"`
|
||||
// SuggestedCategory is the packet's category if it's a valid one, for prefill.
|
||||
SuggestedCategory string `json:"suggestedCategory"`
|
||||
}
|
||||
|
||||
// ExtractSeedPacket reads a (JPEG) packet photo and proposes a plant + lot for
|
||||
// the actor to confirm. It needs a configured vision model; with none it returns
|
||||
// ErrInvalidInput (the API layer turns "not configured" into a clear message and
|
||||
// never offers the feature in the first place).
|
||||
//
|
||||
// The extraction runs as the actor only in the sense that the catalog match is
|
||||
// scoped to what they can see; the model call itself has no ACL — it just reads
|
||||
// a picture the actor uploaded.
|
||||
func (s *Service) ExtractSeedPacket(ctx context.Context, actorID int64, jpeg []byte) (*PacketProposal, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
vis, err := s.EffectiveVision(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !vis.Ready() {
|
||||
// No vision model configured — the feature isn't available.
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
packet, err := s.extractPacket(ctx, vis.APIKey, vis.Model, jpeg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PacketProposal{
|
||||
Packet: packet,
|
||||
Candidates: matchPlants(packet, plants),
|
||||
SuggestedName: suggestedName(packet),
|
||||
SuggestedCategory: validCategory(packet.Category),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// suggestedName is the variety if the packet named one, else the species — what
|
||||
// to prefill a new plant's name with. "Music" beats "garlic" when both are read.
|
||||
func suggestedName(p vision.SeedPacket) string {
|
||||
if v := strings.TrimSpace(p.Variety); v != "" {
|
||||
return v
|
||||
}
|
||||
return strings.TrimSpace(p.Species)
|
||||
}
|
||||
|
||||
// validCategory returns the packet's category if it's one pansy knows, else "".
|
||||
// It reuses plantCategories — the same set CreatePlant validates against — so a
|
||||
// new category can't be accepted by one path and rejected by the other.
|
||||
func validCategory(c string) string {
|
||||
if _, ok := plantCategories[c]; ok {
|
||||
return c
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// matchPlants ranks existing plants the packet might already be, best first.
|
||||
//
|
||||
// This is the crux of "create both, linked" (#81): getting it wrong makes a
|
||||
// duplicate catalog entry that then splits a variety's seed-lot history across
|
||||
// two rows. So it NEVER decides — it only surfaces candidates for the user to
|
||||
// confirm. Matching is deliberately conservative and name-based (no fuzzy
|
||||
// scoring that could confidently mis-rank): exact variety name, variety appearing
|
||||
// within a plant's name, then same species word. Case-insensitive.
|
||||
func matchPlants(p vision.SeedPacket, plants []domain.Plant) []PacketPlantMatch {
|
||||
variety := strings.ToLower(strings.TrimSpace(p.Variety))
|
||||
species := strings.ToLower(strings.TrimSpace(p.Species))
|
||||
|
||||
// rank: lower is better; keep only matched plants.
|
||||
type scored struct {
|
||||
match PacketPlantMatch
|
||||
rank int
|
||||
}
|
||||
var out []scored
|
||||
seen := map[int64]bool{}
|
||||
add := func(pl domain.Plant, rank int, reason string) {
|
||||
if seen[pl.ID] {
|
||||
return
|
||||
}
|
||||
seen[pl.ID] = true
|
||||
out = append(out, scored{PacketPlantMatch{Plant: pl, Reason: reason}, rank})
|
||||
}
|
||||
|
||||
for _, pl := range plants {
|
||||
name := strings.ToLower(pl.Name)
|
||||
switch {
|
||||
case variety != "" && name == variety:
|
||||
add(pl, 0, "exact name")
|
||||
case variety != "" && strings.Contains(name, variety):
|
||||
add(pl, 1, "variety in name")
|
||||
case species != "" && wordIn(name, species):
|
||||
add(pl, 2, "same species")
|
||||
}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].rank < out[j].rank })
|
||||
|
||||
matches := make([]PacketPlantMatch, len(out))
|
||||
for i, s := range out {
|
||||
matches[i] = s.match
|
||||
}
|
||||
return matches
|
||||
}
|
||||
|
||||
// wordIn reports whether word appears as a whole space-delimited token in name,
|
||||
// so "garlic" matches "German Garlic" but not "garlicky-thing".
|
||||
func wordIn(name, word string) bool {
|
||||
for _, tok := range strings.Fields(name) {
|
||||
if tok == word {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PacketConfirm is a user-confirmed proposal to turn into rows.
|
||||
//
|
||||
// Plant selection is explicit: either PlantID names an existing plant to attach
|
||||
// the lot to, or NewPlant carries the fields to create one. Exactly one — the
|
||||
// service refuses both or neither, so an ambiguous confirm can't silently pick.
|
||||
type PacketConfirm struct {
|
||||
// PlantID attaches the lot to an existing plant. Set this XOR NewPlant.
|
||||
PlantID *int64
|
||||
// NewPlant creates a variety. Set this XOR PlantID.
|
||||
NewPlant *PlantInput
|
||||
// Lot is the purchase to record against whichever plant results.
|
||||
Lot SeedLotInput
|
||||
}
|
||||
|
||||
// PacketResult is what a confirm produced.
|
||||
type PacketResult struct {
|
||||
Plant *domain.Plant `json:"plant"`
|
||||
Lot *domain.SeedLot `json:"lot"`
|
||||
PlantIsNew bool `json:"plantIsNew"`
|
||||
}
|
||||
|
||||
// CreateFromPacket turns a confirmed proposal into a plant (new or existing) plus
|
||||
// a seed lot attributed to it. Unlike garden edits these rows aren't in the undo
|
||||
// history — plants and lots are catalog/inventory, created directly — so there is
|
||||
// no change set to wrap; the two creations just happen in sequence.
|
||||
//
|
||||
// If a new plant is created but the lot then fails, the plant is rolled back so
|
||||
// the confirm is all-or-nothing. Otherwise a bad lot (say, an invalid unit) would
|
||||
// strand a half-made catalog entry the user never asked for on its own, and the
|
||||
// error return gives the HTTP caller no handle to it. A just-created plant has no
|
||||
// plantings or lots yet, so the delete is safe; if it somehow can't be undone we
|
||||
// log and still surface the original lot error, not the cleanup one.
|
||||
func (s *Service) CreateFromPacket(ctx context.Context, actorID int64, in PacketConfirm) (*PacketResult, error) {
|
||||
hasID, hasNew := in.PlantID != nil, in.NewPlant != nil
|
||||
if hasID == hasNew {
|
||||
return nil, domain.ErrInvalidInput // exactly one of existing / new
|
||||
}
|
||||
|
||||
res := &PacketResult{}
|
||||
if hasNew {
|
||||
plant, err := s.CreatePlant(ctx, actorID, *in.NewPlant)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Plant = plant
|
||||
res.PlantIsNew = true
|
||||
} else {
|
||||
// Attach to an existing plant the actor can see. visiblePlant enforces the
|
||||
// ACL (built-ins + their own); a plant they can't see is ErrNotFound.
|
||||
plant, err := s.visiblePlant(ctx, actorID, *in.PlantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Plant = plant
|
||||
}
|
||||
|
||||
lotIn := in.Lot
|
||||
lotIn.PlantID = res.Plant.ID
|
||||
lot, err := s.CreateSeedLot(ctx, actorID, lotIn)
|
||||
if err != nil {
|
||||
if res.PlantIsNew {
|
||||
// Roll back the plant we just made for this confirm; keep the lot error.
|
||||
if delErr := s.DeletePlant(ctx, actorID, res.Plant.ID); delErr != nil {
|
||||
slog.Error("service: could not roll back plant after packet lot failed",
|
||||
"error", delErr, "plant", res.Plant.ID)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
res.Lot = lot
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
func packet(species, variety, category string) vision.SeedPacket {
|
||||
return vision.SeedPacket{Species: species, Variety: variety, Category: category}
|
||||
}
|
||||
|
||||
func plant(id int64, name string) domain.Plant {
|
||||
return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable}
|
||||
}
|
||||
|
||||
// TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates,
|
||||
// best first, and never invents a match — the whole point, since a wrong auto-
|
||||
// match would fragment a variety's seed-lot history across duplicate rows.
|
||||
func TestMatchPlants(t *testing.T) {
|
||||
catalog := []domain.Plant{
|
||||
plant(1, "Garlic"),
|
||||
plant(2, "Music Garlic"),
|
||||
plant(3, "Cherokee Purple"),
|
||||
plant(4, "Basil"),
|
||||
}
|
||||
|
||||
t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) {
|
||||
got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog)
|
||||
if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" {
|
||||
t.Fatalf("want Cherokee Purple exact first, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) {
|
||||
// "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic"
|
||||
// shares the species word (rank 2).
|
||||
got := matchPlants(packet("garlic", "Music", "vegetable"), catalog)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
|
||||
}
|
||||
if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" {
|
||||
t.Errorf("first = %+v, want Music Garlic / variety in name", got[0])
|
||||
}
|
||||
if got[1].Plant.ID != 1 || got[1].Reason != "same species" {
|
||||
t.Errorf("second = %+v, want Garlic / same species", got[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("case-insensitive", func(t *testing.T) {
|
||||
got := matchPlants(packet("", "cherokee purple", ""), catalog)
|
||||
if len(got) == 0 || got[0].Plant.ID != 3 {
|
||||
t.Errorf("case-insensitive exact match failed: %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no match → empty (a new variety)", func(t *testing.T) {
|
||||
if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 {
|
||||
t.Errorf("want no candidates, got %+v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("species word boundary, not substring", func(t *testing.T) {
|
||||
// "garlic" should not match a hypothetical "garlicky" — wordIn is token-based.
|
||||
got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("substring shouldn't match on species: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// visionTestService builds a service with a configured vision model and a canned
|
||||
// extractor, so ExtractSeedPacket can run with no live model.
|
||||
func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) {
|
||||
t.Helper()
|
||||
cfg := openConfig()
|
||||
cfg.Agent.OllamaCloudAPIKey = "k"
|
||||
cfg.Agent.VisionModel = "ollama-cloud/vision:cloud"
|
||||
s := newTestService(t, cfg)
|
||||
s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) {
|
||||
return out, extractErr
|
||||
}
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
return s, owner
|
||||
}
|
||||
|
||||
// TestExtractSeedPacket exercises the orchestration: canned packet → proposal
|
||||
// with catalog candidates + prefill suggestions.
|
||||
func TestExtractSeedPacket(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil)
|
||||
// Seed a matching plant.
|
||||
if _, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||
Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||
}); err != nil {
|
||||
t.Fatalf("seed plant: %v", err)
|
||||
}
|
||||
|
||||
prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if prop.Packet.Variety != "Music" {
|
||||
t.Errorf("packet variety = %q", prop.Packet.Variety)
|
||||
}
|
||||
if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" {
|
||||
t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates)
|
||||
}
|
||||
if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable {
|
||||
t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the
|
||||
// feature is unavailable (ErrInvalidInput), and the extractor is never called.
|
||||
func TestExtractSeedPacketNeedsVisionModel(t *testing.T) {
|
||||
s := newTestService(t, openConfig()) // no vision model, no key
|
||||
called := false
|
||||
s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) {
|
||||
called = true
|
||||
return vision.SeedPacket{}, nil
|
||||
}
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("extractor was called despite no configured vision model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a
|
||||
// lot attributed to it, in one call.
|
||||
func TestCreateFromPacketNewPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
|
||||
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if !res.PlantIsNew || res.Plant.Name != "Music Garlic" {
|
||||
t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew)
|
||||
}
|
||||
if res.Lot == nil || res.Lot.PlantID != res.Plant.ID {
|
||||
t.Errorf("lot not attributed to the new plant: %+v", res.Lot)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to
|
||||
// the existing plant and creates nothing new.
|
||||
func TestCreateFromPacketExistingPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
existing, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||
Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed plant: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
PlantID: &existing.ID,
|
||||
Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
if res.PlantIsNew || res.Plant.ID != existing.ID {
|
||||
t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew)
|
||||
}
|
||||
if res.Lot.PlantID != existing.ID {
|
||||
t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketRollsBackNewPlantOnLotFailure: if the lot fails after a new
|
||||
// plant was created for the confirm, the plant is rolled back so a bad lot can't
|
||||
// strand a half-made catalog entry the user never asked for on its own.
|
||||
func TestCreateFromPacketRollsBackNewPlantOnLotFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
|
||||
before, err := s.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list before: %v", err)
|
||||
}
|
||||
|
||||
// A bogus unit makes CreateSeedLot fail AFTER the plant is created.
|
||||
_, err = s.CreateFromPacket(ctx, owner, PacketConfirm{
|
||||
NewPlant: &PlantInput{Name: "Rollback Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
|
||||
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: "furlongs"},
|
||||
})
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("err = %v, want ErrInvalidInput from the bad unit", err)
|
||||
}
|
||||
|
||||
after, err := s.ListPlants(ctx, owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list after: %v", err)
|
||||
}
|
||||
if len(after) != len(before) {
|
||||
t.Errorf("plant count %d → %d: the rolled-back plant was left behind", len(before), len(after))
|
||||
}
|
||||
for _, p := range after {
|
||||
if p.Name == "Rollback Garlic" {
|
||||
t.Errorf("plant %q survived a failed lot; rollback didn't fire", p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused,
|
||||
// so an ambiguous confirm can't silently pick.
|
||||
func TestCreateFromPacketExactlyOne(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
|
||||
id := int64(1)
|
||||
for _, in := range []PacketConfirm{
|
||||
{}, // neither
|
||||
{PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both
|
||||
} {
|
||||
if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
|
||||
)
|
||||
|
||||
// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy
|
||||
@@ -41,16 +43,35 @@ type Service struct {
|
||||
// produced by timingHash (fixed salt, no RNG) so it is always present — an
|
||||
// empty one would silently re-open account enumeration.
|
||||
dummyHash string
|
||||
// extractPacket reads a photographed seed packet (#81). Injectable so tests
|
||||
// can supply a canned packet instead of calling a live vision model — the
|
||||
// same reason `now` is injectable. Defaults to vision.Extract.
|
||||
extractPacket func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)
|
||||
}
|
||||
|
||||
// Option customizes a Service at construction. The only current use is injecting
|
||||
// a seed-packet extractor in tests so they don't call a live vision model.
|
||||
type Option func(*Service)
|
||||
|
||||
// WithPacketExtractor overrides how a photographed seed packet is read (#81).
|
||||
// Production uses vision.Extract; a test supplies a canned reader.
|
||||
func WithPacketExtractor(fn func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)) Option {
|
||||
return func(s *Service) { s.extractPacket = fn }
|
||||
}
|
||||
|
||||
// New constructs a Service.
|
||||
func New(st *store.DB, cfg *config.Config) *Service {
|
||||
return &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
func New(st *store.DB, cfg *config.Config, opts ...Option) *Service {
|
||||
s := &Service{
|
||||
store: st,
|
||||
cfg: cfg,
|
||||
now: time.Now,
|
||||
dummyHash: timingHash(),
|
||||
extractPacket: vision.Extract,
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// formatTime renders a time as pansy's canonical UTC string.
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// The instance_settings row is seeded by migration 0010 and there is exactly one
|
||||
// (CHECK id = 1), so reads never branch on existence and writes never insert.
|
||||
|
||||
const instanceSettingsColumns = `agent_model, agent_enabled, version, updated_at`
|
||||
const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
|
||||
|
||||
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
|
||||
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
|
||||
@@ -21,7 +21,7 @@ func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
|
||||
out domain.InstanceSettings
|
||||
enabled sql.NullInt64
|
||||
)
|
||||
if err := s.Scan(&out.AgentModel, &enabled, &out.Version, &out.UpdatedAt); err != nil {
|
||||
if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if enabled.Valid {
|
||||
@@ -60,12 +60,12 @@ func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSetti
|
||||
}
|
||||
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE instance_settings
|
||||
SET agent_model = ?, agent_enabled = ?,
|
||||
SET agent_model = ?, agent_enabled = ?, vision_model = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = 1 AND version = ?
|
||||
RETURNING `+instanceSettingsColumns,
|
||||
s.AgentModel, enabled, s.Version,
|
||||
s.AgentModel, enabled, s.VisionModel, s.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
current, gerr := d.GetInstanceSettings(ctx)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Vision model setting (#81): the model that reads a photographed seed packet.
|
||||
--
|
||||
-- Separate from agent_model because it's a different capability — extracting
|
||||
-- structured fields from an image needs a vision-capable model, which the chat
|
||||
-- model may not be. Same "inherit from env unless set" contract as agent_model:
|
||||
-- '' falls back to PANSY_VISION_MODEL, then the feature is simply not offered.
|
||||
--
|
||||
-- Like agent_model, the API KEY is NOT stored here — the vision model runs
|
||||
-- against the same OLLAMA_CLOUD_API_KEY from the environment.
|
||||
ALTER TABLE instance_settings ADD COLUMN vision_model TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,77 @@
|
||||
// Package vision reads a photographed seed packet into structured fields (#81).
|
||||
//
|
||||
// It is one-shot structured extraction, NOT an agent loop: majordomo.Generate[T]
|
||||
// derives a JSON schema from the SeedPacket struct, hands the image to a vision
|
||||
// model, and unmarshals the reply into SeedPacket. Because it can't call a tool,
|
||||
// it can't touch the garden — it only reads a picture and returns data, which the
|
||||
// service then turns into a plant + lot after the user confirms.
|
||||
package vision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||
)
|
||||
|
||||
// SeedPacket is what a vision model reads off a packet. The json/description/enum
|
||||
// tags drive the schema majordomo.Generate derives; pointer fields are nullable,
|
||||
// so a field the packet doesn't print comes back nil rather than a made-up zero.
|
||||
//
|
||||
// These are the packet's PRINTED facts. Mapping them onto a pansy Plant + SeedLot
|
||||
// (and deciding whether the variety is one already in the catalog) is the
|
||||
// service's job, not the model's.
|
||||
type SeedPacket struct {
|
||||
Species string `json:"species" description:"the plant species in plain words, e.g. tomato, garlic, basil"`
|
||||
Variety string `json:"variety" description:"the cultivar or variety name, e.g. Cherokee Purple; empty if the packet only names a species"`
|
||||
Category string `json:"category" enum:"vegetable,herb,flower,fruit,tree_shrub,cover" description:"the single best-fit category"`
|
||||
Vendor string `json:"vendor" description:"the seed company, e.g. Johnny's Selected Seeds"`
|
||||
SKU string `json:"sku" description:"the vendor's item/product number, if printed"`
|
||||
LotCode string `json:"lotCode" description:"the lot or batch code, if printed"`
|
||||
PackedForYear *int `json:"packedForYear" description:"the 'packed for' or 'sell by' year, if printed"`
|
||||
DaysToMaturity *int `json:"daysToMaturity" description:"days to maturity/harvest, if printed"`
|
||||
SpacingCM *float64 `json:"spacingCm" description:"recommended in-row spacing in CENTIMETERS; convert if the packet uses inches"`
|
||||
SeedCount *int `json:"seedCount" description:"approximate seed count in the packet, if printed"`
|
||||
}
|
||||
|
||||
// extractPrompt tells the model the conventions it can't guess: centimeters, and
|
||||
// that a missing field must be left empty rather than invented.
|
||||
const extractPrompt = `You are reading a photograph of a seed packet. Extract only what is actually printed on it.
|
||||
Rules:
|
||||
- Spacing must be in CENTIMETERS. If the packet gives inches, convert (1 in = 2.54 cm).
|
||||
- If a field is not printed on the packet, leave it empty or null. Do not guess or fill from general knowledge.
|
||||
- "variety" is the cultivar name (e.g. "Cherokee Purple"); "species" is the plain plant name (e.g. "tomato").`
|
||||
|
||||
// Extract runs one vision extraction: it resolves the model spec against pansy's
|
||||
// registry, sends the JPEG with the prompt, and returns the parsed SeedPacket.
|
||||
// The image bytes should already be normalized to JPEG (see internal/imagenorm).
|
||||
//
|
||||
// It makes a live model call, so callers give it a bounded context.
|
||||
func Extract(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (SeedPacket, error) {
|
||||
if len(jpeg) == 0 {
|
||||
return SeedPacket{}, fmt.Errorf("vision: empty image")
|
||||
}
|
||||
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||
if err != nil {
|
||||
return SeedPacket{}, err
|
||||
}
|
||||
return generate(ctx, model, jpeg)
|
||||
}
|
||||
|
||||
// generate makes the actual one-shot call against an already-resolved model. It
|
||||
// is split from Extract on purpose: the hermetic test drives THIS function with a
|
||||
// fake model, so the prompt, the derived schema and the image part it builds are
|
||||
// the real ones the live path uses — not a hand-copied double that could drift.
|
||||
func generate(ctx context.Context, model llm.Model, jpeg []byte) (SeedPacket, error) {
|
||||
return majordomo.Generate[SeedPacket](ctx, model, majordomo.Request{
|
||||
Messages: []majordomo.Message{
|
||||
majordomo.UserParts(
|
||||
majordomo.Text(extractPrompt),
|
||||
majordomo.Image("image/jpeg", jpeg),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||
)
|
||||
|
||||
// tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before
|
||||
// the provider, which checks the image's magic bytes, so a string literal won't
|
||||
// do — the bytes must actually be a JPEG.
|
||||
func tinyJPEG(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// TestExtractParsesModelJSON is the hermetic proof that the extraction path works
|
||||
// end to end without a live model: a fake vision model returns canned packet JSON
|
||||
// and Generate[SeedPacket] unmarshals it into the struct, image and schema
|
||||
// included.
|
||||
func TestExtractParsesModelJSON(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp") // default caps advertise structured output + images
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{
|
||||
"species": "garlic",
|
||||
"variety": "Music",
|
||||
"category": "vegetable",
|
||||
"vendor": "Johnny's",
|
||||
"sku": "2761",
|
||||
"lotCode": "L-42",
|
||||
"packedForYear": 2026,
|
||||
"daysToMaturity": 240,
|
||||
"spacingCm": 15,
|
||||
"seedCount": 8
|
||||
}`))
|
||||
|
||||
m, err := reg.Parse("fp/vision")
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" {
|
||||
t.Errorf("unexpected packet: %+v", got)
|
||||
}
|
||||
if got.SpacingCM == nil || *got.SpacingCM != 15 {
|
||||
t.Errorf("spacingCm = %v, want 15", got.SpacingCM)
|
||||
}
|
||||
if got.PackedForYear == nil || *got.PackedForYear != 2026 {
|
||||
t.Errorf("packedForYear = %v, want 2026", got.PackedForYear)
|
||||
}
|
||||
|
||||
// The image and the derived schema really reached the model.
|
||||
call := fp.Calls()[0]
|
||||
if call.Request.SchemaName != "seedpacket" {
|
||||
t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName)
|
||||
}
|
||||
var sawImage bool
|
||||
for _, p := range call.Request.Messages[0].Parts {
|
||||
if _, ok := p.(llm.ImagePart); ok {
|
||||
sawImage = true
|
||||
}
|
||||
}
|
||||
if !sawImage {
|
||||
t.Error("the image part didn't reach the model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes
|
||||
// back with nil pointers for the numbers, not invented zeros — the whole reason
|
||||
// the numeric fields are pointers.
|
||||
func TestExtractLeavesMissingFieldsNil(t *testing.T) {
|
||||
reg := majordomo.New(majordomo.WithoutEnvProviders())
|
||||
fp := fake.New("fp")
|
||||
reg.RegisterProvider(fp)
|
||||
fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`))
|
||||
m, _ := reg.Parse("fp/vision")
|
||||
|
||||
got, err := generate(context.Background(), m, tinyJPEG(t))
|
||||
if err != nil {
|
||||
t.Fatalf("extract: %v", err)
|
||||
}
|
||||
if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil {
|
||||
t.Errorf("missing numeric fields should be nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractRejectsEmptyImage: no bytes, no call.
|
||||
func TestExtractRejectsEmptyImage(t *testing.T) {
|
||||
if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil {
|
||||
t.Error("Extract accepted an empty image")
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,16 @@ interface ToastState {
|
||||
|
||||
let nextId = 1
|
||||
|
||||
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||
// happened — is always visible.
|
||||
const MAX_TOASTS = 4
|
||||
|
||||
export const useToastStore = create<ToastState>((set) => ({
|
||||
toasts: [],
|
||||
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
|
||||
push: (message, tone = 'info') =>
|
||||
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
|
||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||
}))
|
||||
|
||||
@@ -32,21 +39,34 @@ export const toast = {
|
||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||
function ToastItem({ item }: { item: Toast }) {
|
||||
const dismiss = useToastStore((s) => s.dismiss)
|
||||
const isError = item.tone === 'error'
|
||||
useEffect(() => {
|
||||
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||
if (isError) return
|
||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||
return () => clearTimeout(t)
|
||||
}, [item.id, dismiss])
|
||||
}, [item.id, dismiss, isError])
|
||||
return (
|
||||
<div
|
||||
role={item.tone === 'error' ? 'alert' : 'status'}
|
||||
role={isError ? 'alert' : 'status'}
|
||||
className={cn(
|
||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
item.tone === 'error'
|
||||
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||
isError
|
||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||
: 'border-border bg-surface text-fg',
|
||||
)}
|
||||
>
|
||||
{item.message}
|
||||
<span className="flex-1">{item.message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(item.id)}
|
||||
aria-label="Dismiss"
|
||||
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
import type { EditorObject } from './types'
|
||||
import { objectDisplayName } from './kinds'
|
||||
|
||||
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||
// don't drift from each other.
|
||||
const dateInputClass =
|
||||
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||
|
||||
/**
|
||||
* The garden's journal: write an entry, read the season back.
|
||||
*
|
||||
@@ -42,7 +47,15 @@ export function JournalPanel({
|
||||
scopeObjectId: number | null
|
||||
onScopeChange: (id: number | null) => void
|
||||
}) {
|
||||
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
|
||||
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||
// from/to; they just had no UI. Empty inputs don't filter.
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const filter = {
|
||||
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||
...(from ? { from } : {}),
|
||||
...(to ? { to } : {}),
|
||||
}
|
||||
const journal = useJournal(gardenId, filter)
|
||||
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||
@@ -70,6 +83,41 @@ export function JournalPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<label className="flex items-center gap-1">
|
||||
<span>From</span>
|
||||
<input
|
||||
type="date"
|
||||
value={from}
|
||||
max={to || undefined}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<span>To</span>
|
||||
<input
|
||||
type="date"
|
||||
value={to}
|
||||
min={from || undefined}
|
||||
onChange={(e) => setTo(e.target.value)}
|
||||
className={dateInputClass}
|
||||
/>
|
||||
</label>
|
||||
{(from || to) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setFrom('')
|
||||
setTo('')
|
||||
}}
|
||||
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<Composer
|
||||
gardenId={gardenId}
|
||||
|
||||
@@ -160,6 +160,15 @@ export async function streamChat(
|
||||
handlers.onError('Could not reach the server.')
|
||||
return
|
||||
}
|
||||
if (res.status === 401) {
|
||||
// Session expired mid-conversation (#85). Reporting this as "the assistant is
|
||||
// unavailable" would send the user chasing a config problem that isn't there.
|
||||
// Send them to sign in again, preserving where they were.
|
||||
handlers.onError('Your session has expired — please sign in again.')
|
||||
const back = encodeURIComponent(location.pathname + location.search)
|
||||
window.location.assign(`/login?redirect=${back}`)
|
||||
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
|
||||
|
||||
@@ -398,8 +398,11 @@ export function GardenEditorPage() {
|
||||
})
|
||||
}
|
||||
|
||||
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||||
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||||
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||
<div className="shrink-0 md:w-40">
|
||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function PublicGardenPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
||||
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||
{garden.name}
|
||||
|
||||
Reference in New Issue
Block a user