The daily job is gone. A refresh only matters at one moment — you are picking a provider and want to know its models are current — and at that moment a button answers the question, where a nightly pass can only have already answered it, invisibly, or not. It also bought a standing obligation: something hitting a vendor from every install forever whether anyone opens Atelier or not. So the add dialog is now the curated file made visible. Provider comes from the catalogue, models come from that provider's entry in it, and two buttons re-read it from the vendor: one for the provider in front of you, one for all of them. `POST /providers/catalogue/refresh?provider=` is the only trigger there is. The dialog also says how old the answer is — "as shipped" until a refresh has ever run, which is worth showing, because it means the models listed are exactly what the plugin was released with. A provider with no refresher answers 400 rather than a silent success: from a button, "nothing happened" and "nothing could happen" look identical.
291 lines
12 KiB
TypeScript
291 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { Check, KeyRound, Plus, RefreshCw, Trash2 } 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';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import type { ProviderStatus } from '../lib/api';
|
|
import type { ProvidersManager } from './useProviders';
|
|
|
|
/**
|
|
* Providers, as one line at the top of the Generate panel.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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.
|
|
*/
|
|
export const ProvidersBar = ({ manager }: { manager: ProvidersManager }) => {
|
|
const [dialog, setDialog] = useState<null | { mode: 'add' } | { mode: 'key'; provider: ProviderStatus }>(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];
|
|
|
|
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>
|
|
|
|
<DropdownMenuContent align="start" className="w-72">
|
|
{manager.providers.length === 0 && (
|
|
<DropdownMenuItem disabled>No providers configured</DropdownMenuItem>
|
|
)}
|
|
|
|
{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>
|
|
|
|
{!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
|
|
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>
|
|
))}
|
|
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onSelect={() => setDialog({ mode: 'add' })} className="gap-2">
|
|
<Plus className="size-3.5" />
|
|
Add provider
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{manager.error && <span className="shrink-0 text-xs text-destructive">{manager.error}</span>}
|
|
|
|
<ProviderDialog state={dialog} manager={manager} onClose={() => setDialog(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. */
|
|
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`;
|
|
};
|
|
|
|
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,
|
|
manager,
|
|
onClose,
|
|
}: {
|
|
state: DialogState;
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (state?.mode === 'key') setApiKey('');
|
|
}, [state]);
|
|
|
|
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();
|
|
};
|
|
|
|
return (
|
|
<Dialog open={!!state} onOpenChange={(next) => !next && onClose()}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{state?.mode === 'key' ? 'Set API key' : 'Add a provider'}</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.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form
|
|
className="grid gap-4"
|
|
onSubmit={(ev) => {
|
|
ev.preventDefault();
|
|
void submit();
|
|
}}
|
|
>
|
|
{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>
|
|
|
|
<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
|
|
type="button"
|
|
disabled={manager.refreshing}
|
|
onClick={() => void manager.refreshCatalogue()}
|
|
className="justify-self-start text-xs text-muted-foreground hover:text-foreground disabled:opacity-40"
|
|
>
|
|
{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>}
|
|
</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>
|
|
</Dialog>
|
|
);
|
|
};
|