Files
atelier/web/lib/api.ts
T
pastilhas 82ca696ccf refactor(web): two panels on the left, the work on the right
Four panels became three, and the arrangement carries the change: everything
that organises or configures is stacked in one narrow column, and the whole of
the rest is one surface for looking at images.

Collections is a LIST, not a grid of covers — it sits in a narrow column beside
the gallery, where two cards per row meant four collections visible. A row is a
name, a count, and rename/delete on hover. Create is a dialog rather than an
always-present input, and a filter field replaces it in that space, since
finding one is the frequent act and making one is not.

Selecting a collection now shows it in the GALLERY. It used to replace the list
inside its own panel, which meant the selector disappeared the moment it was
used, and both panels kept their own copy of one collection's images and
disagreed after any edit. `?collection=` is read by the gallery now; the
collections hook stopped fetching detail entirely.

Deleting asks the question that cannot be undone: keep the images, or delete
them too. That needed a server route — DELETE /collections/:id?images=delete —
which reads the members BEFORE the collection goes, since deleting it takes the
memberships with it. Unchecked by default: the recoverable answer is the one
you get by pressing Enter without reading.

Providers stopped being a panel. It was a card per provider, each with a key
field and three buttons, filling a quarter of the screen for something set once
— it is a dropdown at the top of Generate now, listing "adapter · model" with a
delete on each, and adding one opens a dialog. The form and the dropdown share
one provider list (useProviders); they were two fetches, so adding a provider
left the form showing the old models until a reload.
2026-08-20 21:34:34 +01:00

149 lines
5.7 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;
};
/** The code side — what a new provider can be built on. */
export type Adapter = {
name: string;
label: string;
models: string[];
default_model: string;
env_var: string;
supports_edit: boolean;
};
/** 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>;