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
237 lines
8.7 KiB
TypeScript
237 lines
8.7 KiB
TypeScript
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>
|
||
);
|
||
};
|