atelier providers move into the plugin, where the rule already said

`IntegrationsSettings/index.tsx` has carried the rule since email was extracted
on 2026-08-18: "A plugin contributes panels, not settings sections, so it now
lives inside the email workspace itself."

the atelier extraction brief said the opposite, and the brief was mine and
wrong. sonnet found the contradiction, recorded it in BLOCKERS.md under a
heading that says "against a direct precedent", and followed the instruction
anyway — which is what it was told to do. so the wrong call got implemented
carefully rather than caught.

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 in the
settings screen for someone to delete by hand. neither is true of a panel.

the section, its import and its now-unused icon are gone from here. the screen
is `plugins/atelier/web/Providers/` and a fourth panel.
This commit is contained in:
2026-08-20 17:58:06 +00:00
parent 329c47c499
commit 7b975784b8
2 changed files with 1 additions and 9 deletions
+248
View File
@@ -0,0 +1,248 @@
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 own Providers screen — moved to Settings rather than a panel, per
// plugins/atelier/PLUGIN.md: image-generation providers are a global, owner-level configuration
// (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 AtelierProviders = () => {
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.`;
}