From 2726541ede0e72b86df43855dd56afe3b408d287 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 17:58:38 +0000 Subject: [PATCH] providers is a panel, not a section in officer's settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- web/Generate/Generate.tsx | 2 +- web/Generate/useGenerateManager.ts | 2 +- web/Providers/Providers.tsx | 256 +++++++++++++++++++++++++++++ web/Providers/index.ts | 1 + web/layout.ts | 24 ++- web/panels.ts | 6 +- 6 files changed, 285 insertions(+), 6 deletions(-) create mode 100644 web/Providers/Providers.tsx create mode 100644 web/Providers/index.ts diff --git a/web/Generate/Generate.tsx b/web/Generate/Generate.tsx index a6a51ab..bbc1d44 100644 --- a/web/Generate/Generate.tsx +++ b/web/Generate/Generate.tsx @@ -15,7 +15,7 @@ export const Generate = () => { return ( - 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. ); diff --git a/web/Generate/useGenerateManager.ts b/web/Generate/useGenerateManager.ts index 58842a5..3de48eb 100644 --- a/web/Generate/useGenerateManager.ts +++ b/web/Generate/useGenerateManager.ts @@ -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 }, []); diff --git a/web/Providers/Providers.tsx b/web/Providers/Providers.tsx new file mode 100644 index 0000000..ea4fbdb --- /dev/null +++ b/web/Providers/Providers.tsx @@ -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([]); + const [adapters, setAdapters] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(null); + const [keyDrafts, setKeyDrafts] = useState>({}); + 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) => + 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 ( +
+
+

Add a provider

+

+ An adapter and a model. The same adapter can back several providers. +

+
+ + + + + +
+
+ + {providers.length === 0 ? ( +

No providers yet. Add one above.

+ ) : ( + providers.map((provider) => { + const busyHere = busy === provider.id; + return ( +
+
+
+

+ {provider.name} + {provider.is_default && ( + + default + + )} +

+

{describe(provider)}

+
+
+ {!provider.is_default && ( + + )} + +
+
+ +
+ + {/* 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. */} +
{ + ev.preventDefault(); + void saveKey(provider.id); + }} + > + setKeyDrafts((current) => ({ ...current, [provider.id]: ev.target.value }))} + /> + +
+

+ Stored encrypted. The server never sends a key back, so this field always starts empty. +

+
+ + +
+ ); + }) + )} +
+ ); +}; + +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.`; +} diff --git a/web/Providers/index.ts b/web/Providers/index.ts new file mode 100644 index 0000000..4c75a95 --- /dev/null +++ b/web/Providers/index.ts @@ -0,0 +1 @@ +export { Providers } from './Providers'; diff --git a/web/layout.ts b/web/layout.ts index 6c876d6..de8f50b 100644 --- a/web/layout.ts +++ b/web/layout.ts @@ -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, + }, ], }; diff --git a/web/panels.ts b/web/panels.ts index 7eb3438..27b3bd9 100644 --- a/web/panels.ts +++ b/web/panels.ts @@ -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 }, ];