The daily job is gone. A refresh only matters at one moment — you are picking a provider and want to know its models are current — and at that moment a button answers the question, where a nightly pass can only have already answered it, invisibly, or not. It also bought a standing obligation: something hitting a vendor from every install forever whether anyone opens Atelier or not. So the add dialog is now the curated file made visible. Provider comes from the catalogue, models come from that provider's entry in it, and two buttons re-read it from the vendor: one for the provider in front of you, one for all of them. `POST /providers/catalogue/refresh?provider=` is the only trigger there is. The dialog also says how old the answer is — "as shipped" until a refresh has ever run, which is worth showing, because it means the models listed are exactly what the plugin was released with. A provider with no refresher answers 400 rather than a silent success: from a button, "nothing happened" and "nothing could happen" look identical.
187 lines
7.2 KiB
TypeScript
187 lines
7.2 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;
|
|
/** Whether the sidecar has a refresher for this provider — i.e. whether the refresh button does anything. */
|
|
refreshable: 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),
|
|
|
|
/**
|
|
* Re-read one provider's models and prices from the vendor, or every provider when omitted.
|
|
*
|
|
* The only thing that updates the catalogue — there is no schedule. It runs a live fetch, so it is
|
|
* as slow as the vendor is and the caller should show that it is working.
|
|
*/
|
|
refreshCatalogue: (provider?: string) =>
|
|
client
|
|
.post<{ data: { updated_at: string | null; models: number } }>(
|
|
`providers/catalogue/refresh${provider ? `?provider=${encodeURIComponent(provider)}` : ''}`,
|
|
{},
|
|
)
|
|
.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>;
|