From 6d0b2743a5cc5bd4e2a6613bbafce176af90cb1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 04:02:29 +0000 Subject: [PATCH] settings: Atelier providers under Integrations, per plugin decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit decision 2 of the atelier extraction: providers (which adapter, which model, whose key) becomes an Officer settings screen rather than a panel, since it is owner-level configuration in the same shape as the Apify token beside it. Talks to /api/atelier/providers over HTTP through useClient() — no import from plugins/atelier/ in either direction. recorded in BLOCKERS.md: this goes against IntegrationsSettings' own comment from the email extraction five days earlier ("a plugin contributes panels, not settings sections"). Followed the pre-made decision anyway per the kickoff prompt's own instructions, and wrote down the contradiction rather than picking one silently. --- .../IntegrationsSettings/AtelierProviders.tsx | 238 ++++++++++++++++++ .../Settings/IntegrationsSettings/index.tsx | 10 +- 2 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/AtelierProviders.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/AtelierProviders.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/AtelierProviders.tsx new file mode 100644 index 00000000..12bcc597 --- /dev/null +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/AtelierProviders.tsx @@ -0,0 +1,238 @@ +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([]); + 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 && ( + + )} + +
+
+ +
+ +
+ 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/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx index b4249bce..bd0e959d 100644 --- a/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Settings/IntegrationsSettings/index.tsx @@ -1,6 +1,6 @@ import { useMemo } from 'react'; import { useNavigate, useParams } from 'react-router'; -import { Puzzle, KeyRound, Globe, Wrench, CalendarDays } from 'lucide-react'; +import { Puzzle, KeyRound, Globe, Wrench, CalendarDays, Palette } from 'lucide-react'; import type { LayoutNode, PanelComponents } from 'officerdev'; import { WorkspaceLayout } from 'officerdev'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -10,6 +10,7 @@ import { GoogleOAuthConfig } from './GoogleOAuthConfig'; // import { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now import { BrowserRelay } from './BrowserRelay'; import { ApifyConfig } from './ApifyConfig'; +import { AtelierProviders } from './AtelierProviders'; import { DavAppPasswords } from './DavAppPasswords'; import { ApiKeys } from './ApiKeys'; @@ -30,6 +31,13 @@ const enterpriseSections: SettingsSection[] = [ description: 'API token for web scraping actors', content: , }, + { + key: 'atelier', + icon: Palette, + title: 'Atelier', + description: 'Image generation providers — adapter, model and API key', + content: , + }, ]; const personalSections: SettingsSection[] = [