The dropdown was listing "connections" — a row per adapter + model + key — which made choosing a model an act of configuration, put a key icon and a bin inside a menu item, and showed five accounts nobody had created. A provider is now a vendor Officer curates. Every one is listed, set up or not, because a vendor you have no key for is exactly what you need to see in order to add one. The model is chosen per generation, from that provider's entry in the catalogue, beside it. With the model off the row, all that remained of a "provider" was the key for OpenAI — and Officer already stores keys: one active value per purpose, 0600 inside a 0700 directory, outside the backup tarball, created on first use so nothing needs provisioning. So `atelier_providers` is dropped, along with `generations.provider_id` that pointed into it. `provider` and `model` stay as text on each generation, which were always the historical fact rather than a pointer to configuration. `getSecret`, never `getKey`: this is a foreign credential, and getKey mints a random value for a purpose it cannot find — which for someone else's API key means authenticating with something invented. Resolution follows: a request names a model, and the catalogue says whose it is, rather than a database being asked which stored row served it. A model nobody in the catalogue claims is still accepted — the catalogue is advisory, and a model released today should work before we ship a file that mentions it. Two routes replace five: PUT and DELETE on /providers/:name/key. Deleting a key that came from the environment is refused rather than faked — it belongs to the install, not to this screen.
134 lines
4.6 KiB
TypeScript
134 lines
4.6 KiB
TypeScript
import { useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
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';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { fileUrl } from '../lib/api';
|
|
import { ProvidersBar } from './ProvidersBar';
|
|
import { useProviders } from './useProviders';
|
|
import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerateManager';
|
|
|
|
export const Generate = () => {
|
|
const providers = useProviders();
|
|
// The model lives here, between the two: chosen in the bar above, used by the form below.
|
|
const [model, setModel] = useState('');
|
|
const manager = useGenerateManager(providers.providers, model);
|
|
|
|
return (
|
|
<div className="h-full space-y-6 overflow-y-auto p-4">
|
|
{/* Which provider and model, then what to make with it — one panel, in that order. */}
|
|
<ProvidersBar manager={providers} model={model} onModelChange={setModel} />
|
|
|
|
{!providers.loading && manager.usableProviders.length === 0 && (
|
|
<p className="text-sm text-muted-foreground">No provider has a key yet — add one above.</p>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="atelier-prompt">Prompt</Label>
|
|
<Textarea
|
|
id="atelier-prompt"
|
|
rows={4}
|
|
placeholder="a voxel duck in a beret, painting at an easel"
|
|
value={manager.prompt}
|
|
onChange={(ev) => manager.setPrompt(ev.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<Field label="Count" id="atelier-n">
|
|
<Input
|
|
id="atelier-n"
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
value={manager.n}
|
|
onChange={(ev) => manager.setN(Math.min(10, Math.max(1, Number(ev.target.value) || 1)))}
|
|
/>
|
|
</Field>
|
|
|
|
<Choice label="Size" id="atelier-size" value={manager.size} onChange={manager.setSize} options={SIZES} />
|
|
<Choice
|
|
label="Quality"
|
|
id="atelier-quality"
|
|
value={manager.quality}
|
|
onChange={manager.setQuality}
|
|
options={QUALITIES}
|
|
/>
|
|
<Choice
|
|
label="Background"
|
|
id="atelier-background"
|
|
value={manager.background}
|
|
onChange={manager.setBackground}
|
|
options={BACKGROUNDS}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<Button disabled={!manager.ready} onClick={manager.generate}>
|
|
{manager.running ? 'Generating…' : 'Generate'}
|
|
</Button>
|
|
{manager.job?.atelier && manager.running && (
|
|
<span className="text-sm text-muted-foreground">{manager.job.atelier.status}…</span>
|
|
)}
|
|
</div>
|
|
|
|
{manager.error && <p className="text-sm text-destructive">{manager.error}</p>}
|
|
|
|
{manager.job?.data && manager.job.data.length > 0 && (
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
{manager.job.data.map((image) => (
|
|
<figure key={image.url} className="space-y-2">
|
|
<img src={fileUrl(image.url) ?? undefined} alt={manager.prompt} className="w-full rounded-lg border border-border" />
|
|
<figcaption className="truncate text-xs text-muted-foreground">{image.revised_prompt ?? ''}</figcaption>
|
|
</figure>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{manager.job?.atelier?.images.some((image) => image.error) && (
|
|
<ul className="space-y-1 text-sm text-destructive">
|
|
{manager.job.atelier.images
|
|
.filter((image) => image.error)
|
|
.map((image) => (
|
|
<li key={image.id}>{image.error}</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Field = ({ label, id, children }: { label: string; id: string; children: ReactNode }) => (
|
|
<div className="space-y-2">
|
|
<Label htmlFor={id}>{label}</Label>
|
|
{children}
|
|
</div>
|
|
);
|
|
|
|
type ChoiceProps = {
|
|
label: string;
|
|
id: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
options: readonly string[];
|
|
};
|
|
|
|
const Choice = ({ label, id, value, onChange, options }: ChoiceProps) => (
|
|
<Field label={label} id={id}>
|
|
<Select value={value} onValueChange={onChange}>
|
|
<SelectTrigger id={id} className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{options.map((option) => (
|
|
<SelectItem key={option} value={option}>
|
|
{option}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
);
|