web: three panels — Generate, Gallery, Collections — and a layout
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.
This commit is contained in:
+130
@@ -0,0 +1,130 @@
|
||||
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>;
|
||||
Reference in New Issue
Block a user