ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel contract only ever hands a component its panelId) or usePanelChannel-as-selection: - Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names since both panels render on the same route at once - Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes; Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel images render through GET /files/:filename with a ?token= query param appended client-side (lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this: a browser cannot set an Authorization header on an <img src>. Providers is not here — see PLUGIN.md for why it became a platform settings screen instead. assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see assets/PLACEHOLDER.md.
131 lines
4.7 KiB
TypeScript
131 lines
4.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}`),
|
|
|
|
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),
|
|
deleteCollection: (id: string) => client.delete<{ data: unknown }>(`collections/${id}`),
|
|
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>;
|