It described a separate atelier Postgres database, providers as a section in Officer's settings, and a BLOCKERS.md that was never committed here — none of which is true. It is now what the code does, and why: the three words that are not interchangeable, where keys live and why getSecret rather than getKey, the catalogue's four rules, why refresh is a button, what a refresh can and cannot learn, and the two ceilings the 240s poll sits under. Written so that someone arriving cold can carry on from here.
189 lines
10 KiB
Markdown
189 lines
10 KiB
Markdown
# Atelier — how it is built, and why
|
|
|
|
Current as of **2026-08-20**. Rewritten that evening, because the previous version described an
|
|
extraction rather than the thing that now exists and was wrong in three ways that mattered: it claimed a
|
|
separate `atelier` Postgres database, providers as a screen in Officer's settings, and a `BLOCKERS.md`
|
|
that was never committed to this repo.
|
|
|
|
**Code is the truth.** Where this file and the code disagree, fix this file.
|
|
|
|
---
|
|
|
|
## What it is
|
|
|
|
Image generation as an Officer plugin: a prompt goes in, a job is queued, images come out and stay —
|
|
with a gallery, collections, and an OpenAI-compatible API an agent can poll rather than block on.
|
|
|
|
```
|
|
manifest.ts the plugin's declaration; `sharp` is declared here, shared across plugins
|
|
api/router.ts a thin auth-gated proxy. No Atelier knowledge, and it must never grow any
|
|
sidecar/ the whole contract: catalogue, providers, queue, database
|
|
web/ three panels — Collections, Generate, Gallery
|
|
db/schema.ts found by the platform's schema generator; re-exports what lives in sidecar/db
|
|
```
|
|
|
|
---
|
|
|
|
## Three words that are not interchangeable
|
|
|
|
Getting these confused is what produced the UI we threw away on 2026-08-20, so they are fixed:
|
|
|
|
| | |
|
|
|---|---|
|
|
| **provider** | A vendor. OpenAI. Curated by us, listed whether you have a key or not. |
|
|
| **adapter** | Code that speaks a vendor's HTTP API. Written once, only developers say it. |
|
|
| **catalogue** | The curated file: which providers exist, their models, and what each model can do. |
|
|
|
|
There is deliberately **no fourth concept**. There used to be — a "provider" was a database row of
|
|
adapter + model + key, and choosing a model was therefore an act of configuration. That produced five
|
|
rows nobody had created (a migration minted one per model from a hard-coded list), a key icon and a
|
|
delete button inside a dropdown item, and no way to answer "which vendors could I use" without having
|
|
already set one up.
|
|
|
|
**A provider is a vendor with a key or without one. The model is chosen per generation.**
|
|
|
|
---
|
|
|
|
## Where things live
|
|
|
|
**Tables — `officer_db`, `atelier_` prefixed, like every other plugin.** Two of them:
|
|
`atelier_generations`, `atelier_collections` (+ `atelier_collection_items`). The extraction originally
|
|
kept a separate `atelier` database; that was reversed the same week. A plugin that behaves differently
|
|
from every other plugin is a plugin nobody can reason about.
|
|
|
|
**There is no providers table.** With the model off the row, all that was left of one was *the key for
|
|
OpenAI* — and Officer already stores keys.
|
|
|
|
**Keys — the platform secret store**, `secrets/officer-keys.db`, purpose `atelier-<provider>-key`. One
|
|
active value per purpose, `0600` inside a `0700` directory, deliberately outside `data/` so it does not
|
|
travel in the same tarball as a database dump. Purposes are created on first use, so nothing is
|
|
provisioned in advance and no plugin can read another's.
|
|
|
|
Read with **`getSecret`, never `getKey`**: `getKey` mints a random value for a purpose it cannot find,
|
|
which is right for our own encryption keys and catastrophic for a foreign credential — it means
|
|
authenticating with something invented. Falls back to `OPENAI_API_KEY` in the environment, which is how
|
|
an install supplies a key without a UI.
|
|
|
|
**Images — `$OFFICER_ROOT/data/atelier/images/`,** named by generation id, so a row and its file find
|
|
each other with no index between them. Not in the plugin directory: that is a git clone an update
|
|
replaces, and artefacts kept there vanish on upgrade with nothing logging it.
|
|
|
|
**Catalogue — two files.** `sidecar/catalogue/shipped.ts` is the curated baseline, in git, reviewed;
|
|
`$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh writes. The second merges over the first,
|
|
and a failed refresh therefore degrades to what we shipped rather than to nothing.
|
|
|
|
---
|
|
|
|
## The catalogue's four rules
|
|
|
|
1. **Advisory, never authoritative.** It shapes what the UI *offers*; it never decides what the server
|
|
*accepts*. `validate.ts` deliberately refuses to enforce per-model rules — a stale copy of them would
|
|
reject valid combinations, and the vendor should be the one to answer. A model the catalogue has
|
|
never heard of still generates.
|
|
2. **Allowed to be incomplete.** A field a refresh cannot establish stays absent, and absence degrades:
|
|
no price means a null cost, which is honest. An invented price sums into a total that is wrong.
|
|
3. **Fields merge, not objects.** `GET /v1/models` knows ids and nothing else, so an entry built from it
|
|
would otherwise wipe the sizes, qualities and prices that were curated by hand.
|
|
4. **Retire, never delete.** A model the vendor stopped listing is marked `deprecated`: hidden from the
|
|
dropdown, still resolving everywhere else. The gallery labels historical rows from this list, and 88
|
|
images name the model that made them.
|
|
|
|
### Refresh is a button
|
|
|
|
There was a daily cron and it was removed the day it was written. A refresh matters at exactly one
|
|
moment — you are choosing a provider and want to know its models are current — and a button answers that
|
|
question, where a nightly pass can only have already answered it, invisibly, or not. A timer also buys a
|
|
standing obligation to hit a vendor from every install forever, whether anyone opens Atelier or not.
|
|
|
|
`POST /providers/catalogue/refresh?provider=openai`, or without the parameter for all of them. A
|
|
provider with no refresher answers 400 rather than a silent success: from a button, "nothing happened"
|
|
and "nothing could happen" look identical.
|
|
|
|
### What a refresh can and cannot learn
|
|
|
|
`GET /v1/models` is authoritative and thin — ids, an owner, a timestamp. Which is why image models are
|
|
matched by pattern (`/^(gpt-image|dall-e)/`) rather than a list: `gpt-image-2` appeared this way, on the
|
|
day it shipped, with no release of this plugin.
|
|
|
|
**There is no pricing endpoint, on any plan.** Prices come off an HTML page, and `openai-refresh.ts`
|
|
parses it in a way that gives up rather than guesses — a guess gets written to disk and then believed.
|
|
As of 2026-08-20 it gives up: the page renders its prices client-side, and it says so in the log. **The
|
|
replacement is not a better regex** — it is handing the page to a model and asking for structured
|
|
output, which Officer can already do locally through its own Claude sidecar. That is the next piece of
|
|
work here.
|
|
|
|
---
|
|
|
|
## The panels
|
|
|
|
**Collections** (left, top) — a filterable list, one row per collection: icon, name, count, rename and
|
|
delete on hover. Create is a dialog. Deleting asks the one question that cannot be undone — keep the
|
|
images, or delete them too — unchecked by default, so the recoverable answer is what you get by pressing
|
|
Enter without reading. That needed `DELETE /collections/:id?images=delete`, which reads the members
|
|
*before* deleting the collection, since dropping it takes the memberships with it.
|
|
|
|
**Generate** (left, bottom) — provider dropdown (the catalogue, one entry per vendor, "no key" shown
|
|
inline), model dropdown (that provider's models), then Add/Change key, Update models, All providers, and
|
|
the form.
|
|
|
|
**Gallery** (right) — every image, or one collection's images when `?collection=` is set. The collection
|
|
view lives here rather than in the Collections panel: that panel is a narrow selector, and rendering a
|
|
grid inside it replaced the list you were selecting from with the thing you selected.
|
|
|
|
The panels coordinate through the URL and one refresh channel, never props: `?selected=` is the
|
|
lightbox, `?collection=` is what the gallery shows. Generate bumps `useAtelierRefresh` when a job
|
|
finishes; Gallery subscribes.
|
|
|
|
**Removing a panel key rewrites saved layouts** that name it — see `dashboards/queries.ts`, which is a
|
|
bug that cost an evening on offscale. The install flow clears this plugin's screen rows, so a panel
|
|
removed between versions is safe **as long as the plugin is reinstalled rather than merely pulled**.
|
|
|
|
---
|
|
|
|
## The job model
|
|
|
|
Generation answers **202 with a job id**, never the image. `n: 10` is ten HTTP requests and holding a
|
|
connection open for the length of them ties up a socket for minutes; the callers are agents that can
|
|
poll. `GET /images/generations/:id?wait=` long-polls up to **240s**, which is bounded by two ceilings
|
|
above it — Bun's `idleTimeout` at 255 (256 is rejected outright) and nginx at 320. The innermost has to
|
|
be the one that fires, or the caller gets a dropped connection instead of a status.
|
|
|
|
Each image is its own row with its own cost and duration, so one failure out of four does not discard
|
|
the three that worked.
|
|
|
|
The same model is what video will need when it arrives: a two-minute render is exactly the case 202 was
|
|
built for. Video needs a longer poll ceiling, per-second pricing (the catalogue's `Pricing` is already a
|
|
shape per unit for this reason), and duration/fps on the row — `has_alpha`/`opaque` go null for it, the
|
|
way pricing already goes null when we do not know it.
|
|
|
|
---
|
|
|
|
## Serving images
|
|
|
|
`GET /files/:filename`, authenticated. The standalone service served them from an unauthenticated
|
|
`PUBLIC_URL` route; inside Officer that is not an option. A browser cannot put an `Authorization` header
|
|
on an `<img src>`, so the JSON API returns a relative `/files/<name>` and the frontend appends its own
|
|
`?token=` — the same escape hatch `FileViewer/file-types.ts` and the music plugin already use.
|
|
|
|
---
|
|
|
|
## Verification
|
|
|
|
- `bunx tsgo` — clean, apart from `Cannot find module 'sharp'` in a checkout where the plugin's declared
|
|
dependency has not been installed. That is expected outside a real install.
|
|
- `bun test plugins/atelier` — 32 pass. `catalogue/merge.test.ts` covers the four merge rules above,
|
|
which are the part where a mistake silently rewrites curated data.
|
|
- The web bundle builds with the platform; the panels are typechecked against the real `@/components/ui`.
|
|
|
|
---
|
|
|
|
## Open
|
|
|
|
1. **The pricing extractor** — see above. Everything else in a refresh works; this is the one gap, and
|
|
it fails loudly rather than silently.
|
|
2. **Video.** Adapter, a longer poll, per-second pricing, duration/fps on the row.
|
|
3. **`bun db:push`** is required to drop `atelier_providers` and `generations.provider_id` on an install
|
|
that predates 2026-08-20.
|
|
4. **The 88 historical images** were generated by the standalone service and predate all of this. Their
|
|
prompts are the source for `OfficerIcons/ICON_GENERATION_RECIPE.md`.
|