Files
atelier/web/Generate/ProvidersBar.tsx
T
pastilhasandClaude Opus 5 d7f3223b6a qwen-local: the first provider that runs on our own GPU
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
2026-09-21 13:00:05 +01:00

237 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { KeyRound, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { ProviderStatus } from '../lib/api';
import type { ProvidersManager } from './useProviders';
/**
* Provider and model, at the top of Generate.
*
* Two dropdowns and nothing else. The provider list is the curated file — one entry per vendor,
* whether or not you have a key — and the models under it are that provider's entry in the same file.
*
* What this replaced was a menu of "connections": a row per adapter+model+key, which made choosing a
* model an act of configuration, put a key icon and a delete button inside a menu item, and listed
* five accounts nobody had created. A provider is a vendor; the key is a property of it; the model is
* a choice you make each time.
*/
export const ProvidersBar = ({
manager,
model,
onModelChange,
}: {
manager: ProvidersManager;
model: string;
onModelChange: (model: string) => void;
}) => {
const [selected, setSelected] = useState('');
const [keyFor, setKeyFor] = useState<ProviderStatus | null>(null);
// Default to a provider we can actually use, once the list arrives.
useEffect(() => {
if (selected && manager.byName(selected)) return;
const next = manager.firstUsable() ?? manager.providers[0];
if (next) setSelected(next.name);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [manager.providers]);
const provider = manager.byName(selected);
const models = provider?.model_details ?? [];
// Keep the model honest: when the provider changes, or a refresh retires what was chosen, fall back
// to that provider's default rather than leaving a model the vendor no longer lists selected.
useEffect(() => {
if (!provider) return;
if (model && models.some((entry) => entry.id === model)) return;
onModelChange(provider.default_model ?? models[0]?.id ?? '');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider?.name, provider?.model_details]);
return (
<div className="grid gap-3">
<div className="grid grid-cols-2 gap-3">
<Label className="grid gap-2">
<span className="text-xs text-muted-foreground">Provider</span>
<Select value={selected} onValueChange={setSelected}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{manager.providers.map((entry) => (
<SelectItem key={entry.name} value={entry.name}>
<span className="flex w-full items-center gap-2">
{entry.label}
{/* Said here rather than discovered at Generate: a vendor with no key is listed
precisely so you can add one, so the state has to be visible in the list. */}
{entry.keyless ? (
<span className="text-xs text-muted-foreground">local</span>
) : !entry.configured ? (
<span className="text-xs text-destructive">no key</span>
) : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Label className="grid gap-2">
<span className="flex items-center gap-2 text-xs text-muted-foreground">
Model
<span className="ml-auto text-muted-foreground/60">{freshness(provider?.updated_at)}</span>
</span>
<Select value={model} onValueChange={onModelChange} disabled={models.length === 0}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
{models.map((entry) => (
<SelectItem key={entry.id} value={entry.id}>
{entry.label ?? entry.id}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Hidden rather than disabled for a keyless provider. A greyed-out "Add key" invites the
question of what would enable it, and the answer is nothing — there is no key to add. */}
{provider?.keyless ? (
<span className="text-xs text-muted-foreground">Runs on this machine — no key needed</span>
) : (
<Button size="sm" variant="outline" onClick={() => provider && setKeyFor(provider)} disabled={!provider}>
<KeyRound className="mr-1 size-3.5" />
{provider?.configured ? 'Change key' : 'Add key'}
</Button>
)}
<Button
size="sm"
variant="ghost"
disabled={manager.refreshing || !provider?.refreshable}
onClick={() => void manager.refreshCatalogue(selected)}
title={`Re-read ${provider?.label ?? 'this provider'}'s models from the vendor`}
>
<RefreshCw className={manager.refreshing ? 'mr-1 size-3.5 animate-spin' : 'mr-1 size-3.5'} />
Update models
</Button>
<Button
size="sm"
variant="ghost"
disabled={manager.refreshing}
onClick={() => void manager.refreshCatalogue()}
className="text-muted-foreground"
>
All providers
</Button>
{manager.error && <span className="text-xs text-destructive">{manager.error}</span>}
</div>
<KeyDialog provider={keyFor} manager={manager} onClose={() => setKeyFor(null)} />
</div>
);
};
/** "as shipped" is a fact worth showing: these models are exactly what the plugin was released with. */
const freshness = (updatedAt?: string | null): string => {
if (!updatedAt) return 'as shipped';
const days = Math.floor((Date.now() - new Date(updatedAt).getTime()) / 86_400_000);
return days <= 0 ? 'updated today' : `${days}d old`;
};
const KeyDialog = ({
provider,
manager,
onClose,
}: {
provider: ProviderStatus | null;
manager: ProvidersManager;
onClose: () => void;
}) => {
const [apiKey, setApiKey] = useState('');
useEffect(() => {
setApiKey('');
}, [provider]);
const fromEnvironment = provider?.key_source === 'environment';
const save = async () => {
if (!provider || !apiKey.trim()) return;
if (await manager.setKey(provider.name, apiKey)) onClose();
};
return (
<Dialog open={!!provider} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{provider?.label} API key</DialogTitle>
<DialogDescription>
{fromEnvironment
? `Currently taken from ${provider?.env_var} in the environment. A key saved here takes precedence.`
: 'Stored in Officer’s secret store, encrypted at rest and never sent back — so this field always starts empty.'}
</DialogDescription>
</DialogHeader>
{/* A form, so Enter saves. Pasting a key and pressing Enter is the strongest habit there is in
a password field, and outside a form it silently does nothing. */}
<form
className="grid gap-4"
onSubmit={(ev) => {
ev.preventDefault();
void save();
}}
>
<Input
autoFocus
type="password"
autoComplete="off"
placeholder="sk-…"
value={apiKey}
onChange={(ev) => setApiKey(ev.target.value)}
/>
<DialogFooter className="gap-2 sm:justify-between">
{provider?.configured && !fromEnvironment ? (
<Button
type="button"
variant="ghost"
className="text-destructive"
disabled={manager.busy}
onClick={() => void manager.clearKey(provider.name).then((ok) => ok && onClose())}
>
Forget key
</Button>
) : (
<span />
)}
<span className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={manager.busy || !apiKey.trim()}>
Save
</Button>
</span>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};