refactor(providers): a provider is a vendor and a key, and the table is gone
The dropdown was listing "connections" — a row per adapter + model + key — which made choosing a model an act of configuration, put a key icon and a bin inside a menu item, and showed five accounts nobody had created. A provider is now a vendor Officer curates. Every one is listed, set up or not, because a vendor you have no key for is exactly what you need to see in order to add one. The model is chosen per generation, from that provider's entry in the catalogue, beside it. With the model off the row, all that remained of a "provider" was the key for OpenAI — and Officer already stores keys: one active value per purpose, 0600 inside a 0700 directory, outside the backup tarball, created on first use so nothing needs provisioning. So `atelier_providers` is dropped, along with `generations.provider_id` that pointed into it. `provider` and `model` stay as text on each generation, which were always the historical fact rather than a pointer to configuration. `getSecret`, never `getKey`: this is a foreign credential, and getKey mints a random value for a purpose it cannot find — which for someone else's API key means authenticating with something invented. Resolution follows: a request names a model, and the catalogue says whose it is, rather than a database being asked which stored row served it. A model nobody in the catalogue claims is still accepted — the catalogue is advisory, and a model released today should work before we ship a file that mentions it. Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key that came from the environment is refused rather than faked — it belongs to the install, not to this screen.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -11,17 +12,17 @@ import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerate
|
||||
|
||||
export const Generate = () => {
|
||||
const providers = useProviders();
|
||||
const manager = useGenerateManager(providers.providers);
|
||||
// The model lives here, between the two: chosen in the bar above, used by the form below.
|
||||
const [model, setModel] = useState('');
|
||||
const manager = useGenerateManager(providers.providers, model);
|
||||
|
||||
return (
|
||||
<div className="h-full space-y-6 overflow-y-auto p-4">
|
||||
{/* Which provider, then what to make with it — one panel, in that order. */}
|
||||
<ProvidersBar manager={providers} />
|
||||
{/* Which provider and model, then what to make with it — one panel, in that order. */}
|
||||
<ProvidersBar manager={providers} model={model} onModelChange={setModel} />
|
||||
|
||||
{providers.providers.length > 0 && manager.usableProviders.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No provider is ready. Set an API key from the dropdown above.
|
||||
</p>
|
||||
{!providers.loading && manager.usableProviders.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No provider has a key yet — add one above.</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -36,21 +37,6 @@ export const Generate = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Model" id="atelier-model">
|
||||
<Select value={manager.model} onValueChange={manager.setModel}>
|
||||
<SelectTrigger id="atelier-model" className="w-full">
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manager.models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Count" id="atelier-n">
|
||||
<Input
|
||||
id="atelier-n"
|
||||
|
||||
+151
-215
@@ -1,13 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check, KeyRound, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { KeyRound, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -23,265 +16,208 @@ import type { ProviderStatus } from '../lib/api';
|
||||
import type { ProvidersManager } from './useProviders';
|
||||
|
||||
/**
|
||||
* Providers, as one line at the top of the Generate panel.
|
||||
* Provider and model, at the top of Generate.
|
||||
*
|
||||
* It was a whole panel — a card per provider, each with its own key field and three buttons, filling
|
||||
* a quarter of the screen for something set once and then ignored. What it actually has to answer is
|
||||
* "which one is this generation going to use", and that is a dropdown.
|
||||
* 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.
|
||||
*
|
||||
* Adding one opens a dialog rather than expanding the menu: it is three fields, one of them a secret,
|
||||
* and a menu that turns into a form cannot be dismissed by clicking away without losing what was typed.
|
||||
* 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 }: { manager: ProvidersManager }) => {
|
||||
const [dialog, setDialog] = useState<null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus }>(null);
|
||||
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);
|
||||
|
||||
const active =
|
||||
manager.providers.find((row) => row.is_default && row.configured && row.enabled) ??
|
||||
manager.providers.find((row) => row.configured && row.enabled) ??
|
||||
manager.providers[0];
|
||||
// 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="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="min-w-0 flex-1 justify-start">
|
||||
<span className="truncate">{active ? label(active) : 'No provider'}</span>
|
||||
{active && !active.configured && <span className="ml-auto shrink-0 text-xs text-destructive">no key</span>}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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.configured && <span className="text-xs text-destructive">no key</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
{manager.providers.length === 0 && (
|
||||
<DropdownMenuItem disabled>No providers configured</DropdownMenuItem>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{manager.providers.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
// Choosing one makes it the default, which is what "use this" means to the server: a
|
||||
// request names a model, and the default provider is the row that answers for it.
|
||||
onSelect={() => void manager.makeDefault(provider.id)}
|
||||
className="gap-2"
|
||||
>
|
||||
{provider.id === active?.id ? <Check className="size-3.5 shrink-0" /> : <span className="w-3.5" />}
|
||||
<span className="min-w-0 flex-1 truncate">{label(provider)}</span>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
|
||||
{!provider.configured && (
|
||||
<button
|
||||
type="button"
|
||||
title="Set the API key"
|
||||
className="shrink-0 rounded p-1 text-destructive hover:bg-destructive/10"
|
||||
onClick={(ev) => {
|
||||
// The row's own onSelect would close the menu and make this the default instead.
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setDialog({ mode: 'key', provider });
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5" />
|
||||
</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
|
||||
type="button"
|
||||
title="Delete this provider"
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
void manager.remove(provider.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={manager.refreshing}
|
||||
onClick={() => void manager.refreshCatalogue()}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
All providers
|
||||
</Button>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => setDialog({ mode: 'add' })} className="gap-2">
|
||||
<Plus className="size-3.5" />
|
||||
Add provider
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{manager.error && <span className="text-xs text-destructive">{manager.error}</span>}
|
||||
</div>
|
||||
|
||||
{manager.error && <span className="shrink-0 text-xs text-destructive">{manager.error}</span>}
|
||||
|
||||
<ProviderDialog state={dialog} manager={manager} onClose={() => setDialog(null)} />
|
||||
<KeyDialog provider={keyFor} manager={manager} onClose={() => setKeyFor(null)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const label = (provider: ProviderStatus) => `${provider.adapter} · ${provider.model}`;
|
||||
|
||||
/** "never refreshed" is a fact worth showing: it means these models are exactly what the plugin shipped. */
|
||||
/** "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);
|
||||
if (days <= 0) return 'refreshed today';
|
||||
return `${days}d old`;
|
||||
return days <= 0 ? 'updated today' : `${days}d old`;
|
||||
};
|
||||
|
||||
type DialogState = null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus };
|
||||
|
||||
/** Add a provider, or set the key on one that has none. Same dialog, since the key field is shared. */
|
||||
const ProviderDialog = ({
|
||||
state,
|
||||
const KeyDialog = ({
|
||||
provider,
|
||||
manager,
|
||||
onClose,
|
||||
}: {
|
||||
state: DialogState;
|
||||
provider: ProviderStatus | null;
|
||||
manager: ProvidersManager;
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const [adapter, setAdapter] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
|
||||
// Seeded when the dialog opens, not on every render: the adapter list arrives asynchronously, and
|
||||
// resetting on each render would fight the user's own choice as soon as it did.
|
||||
useEffect(() => {
|
||||
if (state?.mode !== 'add') return;
|
||||
const first = manager.adapters[0];
|
||||
setAdapter(first?.name ?? '');
|
||||
setModel(first?.default_model ?? '');
|
||||
setApiKey('');
|
||||
}, [state, manager.adapters]);
|
||||
}, [provider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state?.mode === 'key') setApiKey('');
|
||||
}, [state]);
|
||||
const fromEnvironment = provider?.key_source === 'environment';
|
||||
|
||||
const submit = async () => {
|
||||
if (!state) return;
|
||||
const ok =
|
||||
state.mode === 'add'
|
||||
? await manager.add(adapter, model, apiKey)
|
||||
: apiKey.trim()
|
||||
? await manager.setKey(state.provider.id, apiKey)
|
||||
: false;
|
||||
if (ok) onClose();
|
||||
const save = async () => {
|
||||
if (!provider || !apiKey.trim()) return;
|
||||
if (await manager.setKey(provider.name, apiKey)) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={!!state} onOpenChange={(next) => !next && onClose()}>
|
||||
<Dialog open={!!provider} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{state?.mode === 'key' ? 'Set API key' : 'Add a provider'}</DialogTitle>
|
||||
<DialogTitle>{provider?.label} API key</DialogTitle>
|
||||
<DialogDescription>
|
||||
{state?.mode === 'key'
|
||||
? `${label(state.provider)} — the key is stored encrypted and never sent back.`
|
||||
: 'An adapter and a model. The same adapter can back several providers — two keys, two accounts.'}
|
||||
{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 submit();
|
||||
void save();
|
||||
}}
|
||||
>
|
||||
{state?.mode === 'add' && (
|
||||
<>
|
||||
{/*
|
||||
The provider list is the CURATED FILE — the providers Officer decided are worth
|
||||
offering — and the models under it are that file's current answer for the one chosen.
|
||||
Neither is read from the vendor here: the file is the offer, and refreshing it is a
|
||||
deliberate act with its own button rather than something that happens on a timer.
|
||||
*/}
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-xs text-muted-foreground">Provider</span>
|
||||
<Select
|
||||
value={adapter}
|
||||
onValueChange={(value) => {
|
||||
setAdapter(value);
|
||||
setModel(manager.adapterFor(value)?.default_model ?? '');
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manager.adapters.map((entry) => (
|
||||
<SelectItem key={entry.name} value={entry.name}>
|
||||
{entry.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="sk-…"
|
||||
value={apiKey}
|
||||
onChange={(ev) => setApiKey(ev.target.value)}
|
||||
/>
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
Model
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
<span className="text-muted-foreground/60">{freshness(manager.adapterFor(adapter)?.updated_at)}</span>
|
||||
{/* This provider's models, from the vendor, now. Slow — it is a live fetch. */}
|
||||
<button
|
||||
type="button"
|
||||
title={`Refresh ${manager.adapterFor(adapter)?.label ?? 'this provider'}'s models and prices`}
|
||||
disabled={manager.refreshing || !manager.adapterFor(adapter)?.refreshable}
|
||||
onClick={() => void manager.refreshCatalogue(adapter)}
|
||||
className="rounded p-1 hover:bg-accent disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={manager.refreshing ? 'size-3 animate-spin' : 'size-3'} />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(manager.adapterFor(adapter)?.model_details ?? []).map((entry) => (
|
||||
<SelectItem key={entry.id} value={entry.id}>
|
||||
{entry.label ?? entry.id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Label>
|
||||
|
||||
<button
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{provider?.configured && !fromEnvironment ? (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={manager.refreshing}
|
||||
onClick={() => void manager.refreshCatalogue()}
|
||||
className="justify-self-start text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
||||
variant="ghost"
|
||||
className="text-destructive"
|
||||
disabled={manager.busy}
|
||||
onClick={() => void manager.clearKey(provider.name).then((ok) => ok && onClose())}
|
||||
>
|
||||
{manager.refreshing ? 'Refreshing…' : 'Refresh all providers'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Label className="grid gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
API key {state?.mode === 'add' && <span className="text-muted-foreground/60">— optional</span>}
|
||||
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>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="sk-…"
|
||||
value={apiKey}
|
||||
onChange={(ev) => setApiKey(ev.target.value)}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{state?.mode === 'add'
|
||||
? 'Leave empty if the key is already in the environment for this adapter.'
|
||||
: 'Stored encrypted, so this field always starts empty.'}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={manager.busy || (state?.mode === 'add' ? !adapter || !model : !apiKey.trim())}
|
||||
>
|
||||
{state?.mode === 'key' ? 'Save key' : 'Add'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -8,15 +8,16 @@ export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
|
||||
export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
|
||||
|
||||
/**
|
||||
* The form. Providers are passed IN rather than fetched here — `useProviders` owns them, because the
|
||||
* dropdown above this form and the model list inside it have to be the same list. They were two
|
||||
* fetches, and adding a provider left the form showing the old models until the page was reloaded.
|
||||
* The form.
|
||||
*
|
||||
* The MODEL is owned above, by the provider/model pair at the top of the panel — it is a choice about
|
||||
* what to generate with, made in the same place the provider is chosen, and duplicating it here gave
|
||||
* two answers to one question. Everything else on the form is this hook's.
|
||||
*/
|
||||
export const useGenerateManager = (providers: ProviderStatus[]) => {
|
||||
export const useGenerateManager = (providers: ProviderStatus[], model: string) => {
|
||||
const client = useAtelierClient();
|
||||
const [, bumpRefresh] = useAtelierRefresh();
|
||||
|
||||
const [model, setModel] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [n, setN] = useState(1);
|
||||
const [size, setSize] = useState<string>('auto');
|
||||
@@ -27,22 +28,9 @@ export const useGenerateManager = (providers: ProviderStatus[]) => {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Follow the provider list: pick the default's model, and only overwrite a choice the user made if
|
||||
// the provider behind it has gone away.
|
||||
useEffect(() => {
|
||||
const usable = providers.filter((row) => row.configured && row.enabled);
|
||||
if (usable.length === 0) return;
|
||||
if (model && usable.some((row) => row.model === model)) return;
|
||||
setModel((usable.find((row) => row.is_default) ?? usable[0]!).model);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [providers]);
|
||||
|
||||
// Derived in the hook, not in the component.
|
||||
const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
|
||||
// One model per configured provider, deduped: two providers on the same model is legitimate (two
|
||||
// keys, two accounts) and the request picks between them by model alone.
|
||||
const models = [...new Set(usableProviders.map((provider) => provider.model))];
|
||||
const ready = Boolean(prompt.trim()) && Boolean(model) && !running;
|
||||
// A provider we hold a key for and have code for. Both are required and neither implies the other.
|
||||
const usableProviders = providers.filter((provider) => provider.configured && provider.usable);
|
||||
const ready = Boolean(prompt.trim()) && Boolean(model) && usableProviders.length > 0 && !running;
|
||||
|
||||
async function generate() {
|
||||
if (!ready) return;
|
||||
@@ -82,9 +70,6 @@ export const useGenerateManager = (providers: ProviderStatus[]) => {
|
||||
return {
|
||||
providers,
|
||||
usableProviders,
|
||||
models,
|
||||
model,
|
||||
setModel,
|
||||
prompt,
|
||||
setPrompt,
|
||||
n,
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtelierClient } from '../lib/api';
|
||||
import type { Adapter, ProviderStatus } from '../lib/api';
|
||||
import type { ProviderStatus } from '../lib/api';
|
||||
|
||||
/**
|
||||
* Every provider, and the verbs that change them.
|
||||
* The providers Officer curates, and which of them we hold a key for.
|
||||
*
|
||||
* Owned here rather than inside useGenerateManager because two things need the same list and it must
|
||||
* not exist twice: the dropdown at the top of the panel, and the form below it whose model choices are
|
||||
* derived from whichever providers actually have a key. Adding one used to mean reloading the page
|
||||
* before the form noticed.
|
||||
* One list, one call. There used to be two — the configured rows and the adapter catalogue — and they
|
||||
* were different shapes answering overlapping questions, which is most of why the old screen was
|
||||
* confusing. `/providers` now returns the curated list with each entry's key status and its models
|
||||
* attached, so "what can I use" and "what have I set up" are one answer.
|
||||
*/
|
||||
export const useProviders = () => {
|
||||
const client = useAtelierClient();
|
||||
const [providers, setProviders] = useState<ProviderStatus[]>([]);
|
||||
const [adapters, setAdapters] = useState<Adapter[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const [rows, catalogue] = await Promise.all([client.listProviders(), client.listAdapters()]);
|
||||
setProviders(rows);
|
||||
setAdapters(catalogue);
|
||||
setProviders(await client.listProviders());
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
@@ -52,37 +49,21 @@ export const useProviders = () => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create, then set the key if one was typed.
|
||||
* Re-read a provider's models from the vendor, or every provider when omitted.
|
||||
*
|
||||
* Two calls because they are two things on the server: a provider row is public configuration, a key
|
||||
* is an encrypted secret that is never sent back. A provider added without a key is legitimate — the
|
||||
* key may live in the environment — so the second call is skipped rather than required.
|
||||
* Its own busy flag, separate from `busy`: this is a live fetch to a third party and takes seconds,
|
||||
* where setting a key is a local write. One flag would grey out the screen for a network round trip.
|
||||
*/
|
||||
const add = async (adapter: string, model: string, apiKey?: string) =>
|
||||
guard(async () => {
|
||||
const rows = await client.createProvider(adapter, model);
|
||||
const created = rows.find((row) => row.adapter === adapter && row.model === model);
|
||||
if (!apiKey?.trim() || !created) return rows;
|
||||
return client.updateProvider(created.id, { apiKey: apiKey.trim() });
|
||||
});
|
||||
|
||||
/**
|
||||
* Re-read the catalogue from the vendor, then re-read our copy of it.
|
||||
*
|
||||
* Separate busy flag from `busy`: this one is a live fetch against OpenAI and takes seconds, where
|
||||
* everything else here is a local database write. Sharing one flag would grey out the whole dialog
|
||||
* for the length of a network round trip to a third party.
|
||||
*/
|
||||
const refreshCatalogue = async (provider?: string) => {
|
||||
const refreshCatalogue = async (name?: string) => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await client.refreshCatalogue(provider);
|
||||
setAdapters(await client.listAdapters());
|
||||
const result = await client.refreshCatalogue(name);
|
||||
setProviders(result.providers);
|
||||
setError(null);
|
||||
return true;
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
return null;
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
@@ -90,20 +71,17 @@ export const useProviders = () => {
|
||||
|
||||
return {
|
||||
providers,
|
||||
adapters,
|
||||
loading,
|
||||
busy,
|
||||
refreshing,
|
||||
refreshCatalogue,
|
||||
error,
|
||||
refresh,
|
||||
add,
|
||||
setKey: (id: string, apiKey: string) => guard(() => client.updateProvider(id, { apiKey: apiKey.trim() })),
|
||||
/** The one a generation will use when the request names only a model. */
|
||||
makeDefault: (id: string) => guard(() => client.updateProvider(id, { isDefault: true, enabled: true })),
|
||||
remove: (id: string) => guard(() => client.deleteProvider(id)),
|
||||
modelsFor: (adapter: string) => adapters.find((entry) => entry.name === adapter)?.models ?? [],
|
||||
adapterFor: (adapter: string) => adapters.find((entry) => entry.name === adapter),
|
||||
refreshCatalogue,
|
||||
setKey: (name: string, apiKey: string) => guard(() => client.setProviderKey(name, apiKey)),
|
||||
clearKey: (name: string) => guard(() => client.clearProviderKey(name)),
|
||||
byName: (name: string) => providers.find((provider) => provider.name === name),
|
||||
/** The one a generation would use if you pressed Generate now: your choice, else the first with a key. */
|
||||
firstUsable: () => providers.find((provider) => provider.configured && provider.usable),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user