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:
@@ -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.`;
|
||||
}
|
||||
Reference in New Issue
Block a user