Files
atelier/web/Providers/Providers.tsx
T
pastilhas 2726541ede providers is a panel, not a section in officer's settings
`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: a plugin contributes panels, not settings sections. the
extraction brief said the opposite and the brief was wrong.

settings living in the platform mean this plugin is not self-contained — it
cannot be configured on an older platform, and uninstalling would leave a dead
"Atelier" section in the settings screen for someone to delete by hand.

so: a fourth panel, `atelier-providers`, and the default layout stacks it under
Collections in the right column. smallest and last because it is configured once
and then ignored — but it has to be REACHABLE, which is the whole point.

Generate's empty state pointed at "Settings → Integrations → Atelier". it now
points at the Providers panel, which is where the key actually goes.
2026-08-20 17:58:38 +00:00

257 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;
return (
<div className="grid gap-5">
<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.`;
}