The five connections in the database were never configured by anyone — a migration minted one per model from the adapter's own list, so the table held five decisions nobody made. What that list actually is, is a CATALOGUE: what you can choose from, which is not the same kind of thing as what you chose. So it is a file. `sidecar/catalogue/shipped.ts` is the curated baseline, in git, reviewed; `$OFFICER_ROOT/data/atelier/catalogue.json` is what a refresh writes, and it wins where it has something to say. Data dir, not the plugin dir — a plugin directory is a clone an update replaces, which is the rule env.ts already states for images. Refresh runs in the sidecar, daily, and only on boot when the file is actually stale — this process has restarted twenty times in an afternoon and should not hit OpenAI twenty times for it. Two sources, unequal, and the code says so. `GET /v1/models` is authoritative and thin: ids and nothing else, so a model matching /^(gpt-image|dall-e)/ appears the day it ships without a release. Prices come off an HTML page because there is no pricing endpoint for any plan, and that parser is written to give up rather than guess — a guess gets written to disk and then believed. When it gives up it says so in the log; the durable replacement is handing the page to a model for structured output, which Officer can already do locally. Nothing a refresh cannot establish overwrites a curated value: fields merge one by one, not objects wholesale, so an id-only listing cannot wipe the sizes and prices that were written by hand. Retiring is decided by the merge, which can tell a complete listing from a request that failed halfway — and it marks `deprecated` rather than deleting, because the gallery labels 88 historical rows from this list. Per-model sizes, qualities and transparency now travel with each entry, which is what will let the form stop offering dall-e-2 a transparent background it cannot do. The catalogue is advisory throughout: validate.ts still refuses to enforce per-model rules, exactly as before.
171 lines
6.5 KiB
TypeScript
171 lines
6.5 KiB
TypeScript
import { getHeaders, useClient } from 'hooks/useClient';
|
|
|
|
// Every call the panels make to this plugin's own sidecar, through `/api/atelier`.
|
|
//
|
|
// `useClient('/api/atelier')` gives typed verbs for JSON; `fileUrl` below is the one thing a client
|
|
// call cannot do — put a bearer token on an `<img src>`.
|
|
|
|
const ATELIER_API = '/api/atelier';
|
|
|
|
/** A configured connection: adapter + model + key. Several may share one adapter. */
|
|
export type ProviderStatus = {
|
|
id: string;
|
|
name: string;
|
|
adapter: string;
|
|
model: string;
|
|
enabled: boolean;
|
|
is_default: boolean;
|
|
configured: boolean;
|
|
key_source: 'database' | 'environment' | null;
|
|
env_var: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
/** One model in the catalogue — what it makes, what it accepts, what it costs. */
|
|
export type CatalogueModel = {
|
|
id: string;
|
|
label?: string;
|
|
kind: 'image' | 'video';
|
|
sizes?: string[];
|
|
qualities?: string[];
|
|
transparency?: boolean;
|
|
edit?: boolean;
|
|
pricing?: Record<string, unknown>;
|
|
status?: 'current' | 'deprecated';
|
|
source?: 'shipped' | 'api' | 'scraped';
|
|
};
|
|
|
|
/**
|
|
* What a new connection can be built on: a catalogue provider that has an adapter behind it.
|
|
*
|
|
* `models` is the flat list of ids; `model_details` carries the per-model facts, which is what lets a
|
|
* form offer dall-e-2's three sizes rather than gpt-image-1's four. The catalogue is refreshed daily
|
|
* in the sidecar — `updated_at` says when, and is null while only the shipped file has been read.
|
|
*/
|
|
export type Adapter = {
|
|
name: string;
|
|
label: string;
|
|
models: string[];
|
|
model_details: CatalogueModel[];
|
|
default_model: string;
|
|
env_var: string;
|
|
supports_edit: boolean;
|
|
updated_at: string | null;
|
|
};
|
|
|
|
/** Snake case, matching the server — one spelling per field across every endpoint. */
|
|
export type GenerationRow = {
|
|
id: string;
|
|
request_id: string;
|
|
index: number;
|
|
operation: 'generate' | 'edit';
|
|
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
|
provider: string;
|
|
model: string;
|
|
prompt: string;
|
|
options: Record<string, unknown> | null;
|
|
input_images: string[] | null;
|
|
mask_image: string | null;
|
|
image_path: string | null;
|
|
url: string | null;
|
|
width: number | null;
|
|
height: number | null;
|
|
has_alpha: boolean | null;
|
|
opaque: boolean | null;
|
|
cost_usd: string | null;
|
|
duration_ms: number | null;
|
|
error: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export type JobResponse = {
|
|
id?: string;
|
|
status?: string;
|
|
created?: number;
|
|
data?: { url: string; revised_prompt?: string; b64_json?: string }[];
|
|
atelier?: {
|
|
id: string;
|
|
status: 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
|
|
cost_usd: string | null;
|
|
duration_ms: number;
|
|
images: { id: string; status: string; url: string | null; error: string | null }[];
|
|
};
|
|
error?: { message: string; param?: string | null };
|
|
};
|
|
|
|
export type Collection = {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
export type CollectionSummary = Collection & { item_count: number; cover_image_path: string | null; cover_url: string | null };
|
|
export type CollectionDetail = Collection & { images: GenerationRow[] };
|
|
|
|
/**
|
|
* `row.url` (e.g. `/files/<name>`) with this session's bearer token appended, for an `<img src>`.
|
|
*
|
|
* Browsers cannot set an `Authorization` header on an `<img>` request, so the token travels in the
|
|
* query string instead — the same escape hatch `FileViewer/file-types.ts` and the music plugin's
|
|
* `MusicPlayerHost` already use for exactly this reason (`permission-gate.ts` and `user-middleware.ts`
|
|
* both read `?token=` as well as the header).
|
|
*/
|
|
export function fileUrl(path: string | null): string | null {
|
|
if (!path) return null;
|
|
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
|
return `${ATELIER_API}${path}?token=${encodeURIComponent(token)}`;
|
|
}
|
|
|
|
export function useAtelierClient() {
|
|
const client = useClient(ATELIER_API);
|
|
|
|
return {
|
|
...client,
|
|
|
|
listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data),
|
|
listAdapters: () => client.get<{ data: Adapter[] }>('providers/adapters').then((r) => r.data),
|
|
|
|
listGenerations: (limit = 60) => client.get<{ data: GenerationRow[] }>(`images?limit=${limit}`).then((r) => r.data),
|
|
|
|
startGeneration: (body: Record<string, unknown>) => client.post<{ id: string }>('images/generations', body),
|
|
|
|
/** Long-polls up to `waitSeconds` server-side, so the panel does not run its own retry loop. */
|
|
awaitGeneration: (id: string, waitSeconds = 30) => client.get<JobResponse>(`images/generations/${id}?wait=${waitSeconds}`),
|
|
|
|
/** A provider row is adapter + model; the key is set separately, and never comes back. */
|
|
createProvider: (adapter: string, model: string) =>
|
|
client.post<{ data: ProviderStatus[] }>('providers', { adapter, model }).then((r) => r.data),
|
|
updateProvider: (id: string, values: Record<string, unknown>) =>
|
|
client.patch<{ data: ProviderStatus[] }>(`providers/${id}`, values).then((r) => r.data),
|
|
deleteProvider: (id: string) => client.delete<{ data: ProviderStatus[] }>(`providers/${id}`).then((r) => r.data),
|
|
|
|
listCollections: () => client.get<{ data: CollectionSummary[] }>('collections').then((r) => r.data),
|
|
getCollection: (id: string) => client.get<{ data: CollectionDetail }>(`collections/${id}`).then((r) => r.data),
|
|
createCollection: (name: string) =>
|
|
client.post<{ data: Collection }>('collections', { name }).then((r) => r.data),
|
|
renameCollection: (id: string, name: string) =>
|
|
client.patch<{ data: Collection }>(`collections/${id}`, { name }).then((r) => r.data),
|
|
/**
|
|
* `deleteImages` is the difference between filing something wrongly and regretting the whole
|
|
* afternoon — the server keeps the images unless it is asked, by name, not to.
|
|
*/
|
|
deleteCollection: (id: string, deleteImages = false) =>
|
|
client
|
|
.delete<{ data: { id: string; deleted: boolean; deletedImages: number } }>(
|
|
`collections/${id}${deleteImages ? '?images=delete' : ''}`,
|
|
)
|
|
.then((r) => r.data),
|
|
addToCollection: (id: string, generationIds: string[]) =>
|
|
client
|
|
.post<{ data: { added: number; images: GenerationRow[] } }>(`collections/${id}/items`, { generationIds })
|
|
.then((r) => r.data),
|
|
removeFromCollection: (id: string, generationId: string) =>
|
|
client.delete<{ data: { images: GenerationRow[] } }>(`collections/${id}/items/${generationId}`).then((r) => r.data),
|
|
};
|
|
}
|
|
|
|
export type UseAtelierClientType = ReturnType<typeof useAtelierClient>;
|