settings: Atelier providers under Integrations, per plugin decision
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.
This commit is contained in:
+238
@@ -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<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>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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 disabled={busyHere || !keyDrafts[provider.id]?.trim()} onClick={() => saveKey(provider.id)}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<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.`;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
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 type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from 'officerdev';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
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 { GoogleAccount } from './GoogleAccount'; // hidden — email uses IMAP app password now
|
||||||
import { BrowserRelay } from './BrowserRelay';
|
import { BrowserRelay } from './BrowserRelay';
|
||||||
import { ApifyConfig } from './ApifyConfig';
|
import { ApifyConfig } from './ApifyConfig';
|
||||||
|
import { AtelierProviders } from './AtelierProviders';
|
||||||
import { DavAppPasswords } from './DavAppPasswords';
|
import { DavAppPasswords } from './DavAppPasswords';
|
||||||
import { ApiKeys } from './ApiKeys';
|
import { ApiKeys } from './ApiKeys';
|
||||||
|
|
||||||
@@ -30,6 +31,13 @@ const enterpriseSections: SettingsSection[] = [
|
|||||||
description: 'API token for web scraping actors',
|
description: 'API token for web scraping actors',
|
||||||
content: <ApifyConfig />,
|
content: <ApifyConfig />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'atelier',
|
||||||
|
icon: Palette,
|
||||||
|
title: 'Atelier',
|
||||||
|
description: 'Image generation providers — adapter, model and API key',
|
||||||
|
content: <AtelierProviders />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const personalSections: SettingsSection[] = [
|
const personalSections: SettingsSection[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user