sd-server (stable-diffusion.cpp, Vulkan) serving Qwen-Image 2.1 on this machine. It already speaks OpenAI's image API, which is the vocabulary `ImageRequest` is written in, so there is no translation layer — only the parts that are true of a local GPU and not of a hosted one. ── keyless is a category, not an exception ── `credentials.ts` decided a provider was usable by asking whether a key existed, so a service that takes none read as unconfigured forever. The fix is an adapter declaring `keyless`, not a dummy secret: a dummy would record a credential that does not exist and make the secret store lie about what is configured, and that store is the one place meant to be the truth about them. A keyless adapter never reaches `keyFor` at all. That function's own comment says why it uses `getSecret` rather than `getKey` — so a missing FOREIGN credential is never minted — and a provider with no credential by design has no business in that path. whisper, OCR and TTS already run on this machine, so this will not be the last. ── measured, not assumed ── The integration doc said "tens of seconds, not two". The server's own log says 512x512 completes in ~185s and 1024x1024 samples at ~41.4s/step over 20 steps, about 14 minutes. And it serialises strictly: a second caller waits for the first to finish entirely, so a queued 512 can wait 17 minutes before a byte comes back. So: 512 is the default and the substitute for `auto`, 1024 is offered with the cost in its label, and `timeout: false` is a requirement rather than caution — every default anyone would reach for is far under 17 minutes. The caller's `signal` still aborts, because a user cancelling is not a client giving up. ── the model id moved, because only it could ── `GET /v1/models` returns `sd-cpp-local`, hardcoded in sd-server with no override. The catalogue now agrees with the server and carries "Qwen-Image 2.1 (local)" in the label, so requests name a model the server knows while the UI still says something meaningful. ── not shipped: edit() ── Same GPU, same queue. It doubles the ways generation can be blocked while the generate path is unproven in anger, and `ImageAdapter` makes absence the way to say so — the route answers 400 instead of the queue finding out late. Verified: /v1/models answers, provider statuses report qwen-local as configured/keyless/usable, and the shipped entry survives the merge with the stored catalogue.json. NOT verified end to end — a generation is occupying the GPU and I left it alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
187 lines
7.6 KiB
TypeScript
187 lines
7.6 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 provider: a vendor Officer curates, and whether we hold a key for it.
|
|
*
|
|
* Every provider in the catalogue is listed, set up or not — a vendor you have no key for is exactly
|
|
* what you need to see in order to add one. There is no "configured provider" as a separate thing;
|
|
* `configured` is a fact about the key, and the model is chosen per generation.
|
|
*/
|
|
export type ProviderStatus = {
|
|
name: string;
|
|
label: string;
|
|
configured: boolean;
|
|
/**
|
|
* This provider authenticates with nothing — a service of ours rather than a vendor's. It is
|
|
* `configured` by existing, so anywhere that offers to add or change a key must skip it.
|
|
*/
|
|
keyless: boolean;
|
|
key_source: 'store' | 'environment' | 'keyless' | null;
|
|
/** Null for a keyless provider: naming a variable nobody can usefully set would be a wrong hint. */
|
|
env_var: string | null;
|
|
/** False when the catalogue lists a vendor this build has no adapter for. */
|
|
usable: boolean;
|
|
supports_edit: boolean;
|
|
refreshable: boolean;
|
|
default_model: string | null;
|
|
models: string[];
|
|
model_details: CatalogueModel[];
|
|
updated_at: string | null;
|
|
};
|
|
|
|
/** 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';
|
|
};
|
|
|
|
/** 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),
|
|
|
|
/** Save a provider's key. Goes to the platform's secret store; nothing ever reads it back. */
|
|
setProviderKey: (name: string, apiKey: string) =>
|
|
client.put<{ data: ProviderStatus[] }>(`providers/${name}/key`, { apiKey }).then((r) => r.data),
|
|
/** Forget a stored key. Refused for a key that came from the environment — that one is the install's. */
|
|
clearProviderKey: (name: string) =>
|
|
client.delete<{ data: ProviderStatus[] }>(`providers/${name}/key`).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; providers: ProviderStatus[]; changed: boolean } }>(
|
|
`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>;
|