Files
atelier/web/Providers/Providers.tsx
T
pastilhas 4533ef03ec fix(web): make every panel scroll
Each panel's root was a plain div with padding, so anything past the panel's
height was simply not reachable — the gallery showed its first two rows of
sixty images, and the Providers panel cut off mid-card. The shell hands a
panel a fixed-height box and does not scroll it for you; plugins/music has
had `h-full overflow-y-auto` on every panel root since it was written.

Collections gets it in both places, the list and the detail view, which are
two separate returns in one file and would otherwise disagree.
2026-08-20 21:15:39 +01:00

259 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
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';
// Atelier's providers: which adapter, which model, whose key.
//
// A PANEL, in the plugin, not a section in Officer's settings. It was the latter for one day, because the
// extraction brief said so — and `IntegrationsSettings/index.tsx` already carried the opposite rule,
// written when email was extracted: "A plugin contributes panels, not settings sections, so it now lives
// inside the email workspace itself."
//
// The brief was wrong and the precedent was right. Settings in the platform mean the plugin is not
// self-contained: it cannot be configured on an older platform, and uninstalling leaves a dead section
// behind that someone has to delete by hand. Neither is true of a panel.
// (which adapter, which model, whose key), the same shape as the Apify token above rather than
// something worth a dedicated working panel. Talks to /api/atelier/providers over HTTP through
// useClient(), same as every other authenticated screen — this does not import anything from
// plugins/atelier/, so it stays on the right side of "a plugin importing platform code is fine; the
// reverse is not".
type ProviderStatus = {
id: string;
name: string;
adapter: string;
model: string;
enabled: boolean;
is_default: boolean;
configured: boolean;
key_source: 'database' | 'environment' | null;
env_var: string;
};
type Adapter = {
name: string;
label: string;
models: string[];
default_model: string;
env_var: string;
};
export const Providers = () => {
const client = useClient();
const [providers, setProviders] = useState<ProviderStatus[]>([]);
const [adapters, setAdapters] = useState<Adapter[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [keyDrafts, setKeyDrafts] = useState<Record<string, string>>({});
const [newAdapter, setNewAdapter] = useState('');
const [newModel, setNewModel] = useState('');
useEffect(() => {
Promise.all([
client.get<{ data: ProviderStatus[] }>('/atelier/providers').then((r) => r.data),
client.get<{ data: Adapter[] }>('/atelier/providers/adapters').then((r) => r.data),
])
.then(([rows, catalogue]) => {
setProviders(rows);
setAdapters(catalogue);
if (catalogue[0]) {
setNewAdapter(catalogue[0].name);
setNewModel(catalogue[0].default_model);
}
})
.catch(() => toast.error('Failed to load Atelier providers'))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const run = async (key: string, action: () => Promise<{ data: ProviderStatus[] }>) => {
setBusy(key);
try {
setProviders((await action()).data);
} catch {
toast.error('That did not work — check the server logs');
} finally {
setBusy(null);
}
};
const modelsFor = (adapterName: string) => adapters.find((a) => a.name === adapterName)?.models ?? [];
const patch = (id: string, values: Record<string, unknown>) =>
run(id, () => client.patch<{ data: ProviderStatus[] }>(`/atelier/providers/${id}`, values));
const saveKey = async (id: string) => {
const apiKey = keyDrafts[id]?.trim();
if (!apiKey) return;
await patch(id, { apiKey });
setKeyDrafts((current) => ({ ...current, [id]: '' }));
toast.success('Key saved');
};
const add = () =>
run('new', () =>
client.post<{ data: ProviderStatus[] }>('/atelier/providers', { adapter: newAdapter, model: newModel }),
);
const remove = (id: string) => run(id, () => client.delete<{ data: ProviderStatus[] }>(`/atelier/providers/${id}`));
if (loading) return null;
// `content-start`: a grid told to fill its height stretches its rows, which would pull the two cards
// tall and leave their text floating in the middle of them.
return (
<div className="grid h-full content-start gap-5 overflow-y-auto p-4">
<div className="grid gap-3 rounded-lg border border-duck-dark/10 p-4 dark:border-foreground/10">
<p className="text-sm font-medium text-duck-dark dark:text-foreground">Add a provider</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">
An adapter and a model. The same adapter can back several providers.
</p>
<div className="flex flex-wrap items-end gap-3">
<Label className="grid gap-2">
<span className="text-xs text-duck-dark/70 dark:text-foreground/70">Adapter</span>
<Select
value={newAdapter}
onValueChange={(value) => {
setNewAdapter(value);
setNewModel(adapters.find((a) => a.name === value)?.default_model ?? '');
}}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{adapters.map((adapter) => (
<SelectItem key={adapter.name} value={adapter.name}>
{adapter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Label className="grid gap-2">
<span className="text-xs text-duck-dark/70 dark:text-foreground/70">Model</span>
<Select value={newModel} onValueChange={setNewModel}>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
{modelsFor(newAdapter).map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
</Label>
<Button onClick={add} disabled={busy === 'new' || !newAdapter}>
Add
</Button>
</div>
</div>
{providers.length === 0 ? (
<p className="text-sm text-duck-dark/40 dark:text-foreground/40">No providers yet. Add one above.</p>
) : (
providers.map((provider) => {
const busyHere = busy === provider.id;
return (
<div
key={provider.id}
className="grid gap-3 rounded-lg border border-duck-dark/10 p-4 dark:border-foreground/10"
>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="flex items-center gap-2 text-sm font-medium text-duck-dark dark:text-foreground">
{provider.name}
{provider.is_default && (
<span className="rounded bg-duck-yellow/20 px-2 py-0.5 text-[10px] uppercase tracking-wider text-duck-teal">
default
</span>
)}
</p>
<p className="text-xs text-duck-dark/50 dark:text-foreground/50">{describe(provider)}</p>
</div>
<div className="flex gap-2">
{!provider.is_default && (
<Button
variant="ghost"
size="sm"
disabled={busyHere}
onClick={() => patch(provider.id, { isDefault: true })}
>
Make default
</Button>
)}
<Button
variant={provider.enabled ? 'secondary' : 'default'}
size="sm"
disabled={busyHere}
onClick={() => patch(provider.id, { enabled: !provider.enabled })}
>
{provider.enabled ? 'Enabled' : 'Disabled'}
</Button>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`${provider.id}-key`} className="text-xs text-duck-dark/70 dark:text-foreground/70">
API key
</Label>
{/* 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 — which reads as
"the key did not save". TODO.md tracks nineteen panels with this same defect; this is
not the twentieth. */}
<form
className="flex gap-2"
onSubmit={(ev) => {
ev.preventDefault();
void saveKey(provider.id);
}}
>
<Input
id={`${provider.id}-key`}
type="password"
autoComplete="off"
placeholder={provider.configured ? 'A key is set — type a new one to replace it' : 'sk-…'}
value={keyDrafts[provider.id] ?? ''}
onChange={(ev) => setKeyDrafts((current) => ({ ...current, [provider.id]: ev.target.value }))}
/>
<Button type="submit" disabled={busyHere || !keyDrafts[provider.id]?.trim()}>
Save
</Button>
</form>
<p className="text-xs text-duck-dark/40 dark:text-foreground/40">
Stored encrypted. The server never sends a key back, so this field always starts empty.
</p>
</div>
<Button
variant="ghost"
size="sm"
disabled={busyHere}
onClick={() => remove(provider.id)}
className="w-fit"
>
Delete provider
</Button>
</div>
);
})
)}
</div>
);
};
function describe(provider: ProviderStatus): string {
const base = `${provider.adapter} · ${provider.model}`;
if (!provider.configured) return `${base} — no key. Set one here, or as ${provider.env_var}.`;
if (provider.key_source === 'environment') return `${base} — key from ${provider.env_var}.`;
return `${base} — key stored and encrypted.`;
}