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.
This commit is contained in:
@@ -15,7 +15,7 @@ export const Generate = () => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
No provider is ready. Add an API key in Settings → Integrations → Atelier first.
|
||||
No provider is ready. Add an API key in the Providers panel first.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -34,7 +34,7 @@ export const useGenerateManager = () => {
|
||||
if (usable) setModel(usable.model);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message));
|
||||
// Loaded once on mount — the providers screen (Settings → Integrations → Atelier) is where they
|
||||
// Loaded once on mount — the Providers panel is where they
|
||||
// are managed, and this panel does not need to react to edits made there in real time.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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.`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { Providers } from './Providers';
|
||||
+21
-3
@@ -1,8 +1,15 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// Generate stays narrow and fixed — it is a form, not a working area. Gallery gets the most room: it
|
||||
// is the main "what did I make" view. Collections is the organising view, smallest of the three. Users
|
||||
// can resize or rearrange freely; this is only the starting point.
|
||||
// is the main "what did I make" view. The right column stacks the two organising views: Collections
|
||||
// above, Providers below.
|
||||
//
|
||||
// Providers is here rather than in Officer's settings because a plugin contributes panels, not settings
|
||||
// sections — the rule `IntegrationsSettings/index.tsx` states for email. It is placed smallest and last
|
||||
// because it is configured once and then ignored, but it has to be REACHABLE, and a plugin whose only
|
||||
// configuration screen lives in the platform is not self-contained.
|
||||
//
|
||||
// Users can resize or rearrange freely; this is only the starting point.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'atelier-root',
|
||||
@@ -10,6 +17,17 @@ export const defaultLayout: LayoutNode = {
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'atelier-generate', appType: 'atelier-generate' }, size: 26 },
|
||||
{ node: { type: 'panel', id: 'atelier-gallery', appType: 'atelier-gallery' }, size: 48 },
|
||||
{ node: { type: 'panel', id: 'atelier-collections', appType: 'atelier-collections' }, size: 26 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'atelier-side',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'atelier-collections', appType: 'atelier-collections' }, size: 60 },
|
||||
{ node: { type: 'panel', id: 'atelier-providers', appType: 'atelier-providers' }, size: 40 },
|
||||
],
|
||||
},
|
||||
size: 26,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+5
-1
@@ -1,8 +1,9 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Sparkles, GalleryHorizontalEnd, FolderOpen } from 'lucide-react';
|
||||
import { Sparkles, GalleryHorizontalEnd, FolderOpen, KeyRound } from 'lucide-react';
|
||||
import { Generate } from './Generate/Generate';
|
||||
import { Gallery } from './Gallery/Gallery';
|
||||
import { Collections } from './Collections/Collections';
|
||||
import { Providers } from './Providers';
|
||||
|
||||
// No component re-exports. `panels.ts` declares PANELS — the shell mounts them by key and there is no
|
||||
// way to export a component from a plugin.
|
||||
@@ -16,4 +17,7 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'atelier-generate', name: 'Generate', icon: Sparkles, component: Generate, availableOnPanel: false },
|
||||
{ key: 'atelier-gallery', name: 'Gallery', icon: GalleryHorizontalEnd, component: Gallery, availableOnPanel: false },
|
||||
{ key: 'atelier-collections', name: 'Collections', icon: FolderOpen, component: Collections, availableOnPanel: false },
|
||||
// Providers is a panel and not a section in Officer's settings — see Providers.tsx. A plugin that
|
||||
// cannot be configured without the platform shipping its settings screen is not self-contained.
|
||||
{ key: 'atelier-providers', name: 'Providers', icon: KeyRound, component: Providers, availableOnPanel: false },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user