web: three panels — Generate, Gallery, Collections — and a layout
ported from the standalone app's hash-tab UI. The panels do not coordinate through props (the panel contract only ever hands a component its panelId) or usePanelChannel-as-selection: - Gallery's lightbox is ?selected=<id>, Collections' detail is ?collection=<id> — different names since both panels render on the same route at once - Generate bumps useAtelierRefresh (added to officerdev/src/channels.ts) when a job finishes; Gallery subscribes to it — a genuine signal, not a selection, so it stays a channel images render through GET /files/:filename with a ?token= query param appended client-side (lib/api.ts's fileUrl), the same escape hatch FileViewer and the music plugin use for exactly this: a browser cannot set an Authorization header on an <img src>. Providers is not here — see PLUGIN.md for why it became a platform settings screen instead. assets/icon.png and assets/splash.png are [placeholder] flat swatches, not real artwork — see assets/PLACEHOLDER.md.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
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 { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerateManager';
|
||||
|
||||
export const Generate = () => {
|
||||
const manager = useGenerateManager();
|
||||
|
||||
if (manager.providers.length > 0 && manager.usableProviders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-sm text-muted-foreground">
|
||||
No provider is ready. Add an API key in Settings → Integrations → Atelier first.
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4">
|
||||
<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="Model" id="atelier-model">
|
||||
<Select value={manager.model} onValueChange={manager.setModel}>
|
||||
<SelectTrigger id="atelier-model" className="w-full">
|
||||
<SelectValue placeholder="Choose" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{manager.models.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<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>
|
||||
);
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtelierRefresh } from 'officerdev';
|
||||
import { useAtelierClient } from '../lib/api';
|
||||
import type { JobResponse, ProviderStatus } from '../lib/api';
|
||||
|
||||
export const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
|
||||
export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
|
||||
export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
|
||||
|
||||
export const useGenerateManager = () => {
|
||||
const client = useAtelierClient();
|
||||
const [, bumpRefresh] = useAtelierRefresh();
|
||||
|
||||
const [providers, setProviders] = useState<ProviderStatus[]>([]);
|
||||
const [model, setModel] = useState('');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [n, setN] = useState(1);
|
||||
const [size, setSize] = useState<string>('auto');
|
||||
const [quality, setQuality] = useState<string>('auto');
|
||||
const [background, setBackground] = useState<string>('auto');
|
||||
|
||||
const [job, setJob] = useState<JobResponse | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
client
|
||||
.listProviders()
|
||||
.then((rows) => {
|
||||
setProviders(rows);
|
||||
const usable =
|
||||
rows.find((row) => row.is_default && row.configured && row.enabled) ??
|
||||
rows.find((row) => row.configured && row.enabled);
|
||||
if (usable) setModel(usable.model);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message));
|
||||
// Loaded once on mount — the providers screen (Settings → Integrations → Atelier) 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
|
||||
}, []);
|
||||
|
||||
// Derived in the hook, not in the component.
|
||||
const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
|
||||
// One model per configured provider, deduped: two providers on the same model is legitimate (two
|
||||
// keys, two accounts) and the request picks between them by model alone.
|
||||
const models = [...new Set(usableProviders.map((provider) => provider.model))];
|
||||
const ready = Boolean(prompt.trim()) && Boolean(model) && !running;
|
||||
|
||||
async function generate() {
|
||||
if (!ready) return;
|
||||
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setJob(null);
|
||||
|
||||
try {
|
||||
// Only what the user actually chose. `auto` is the provider's own default, so sending it would
|
||||
// be inventing a parameter they did not set.
|
||||
const body: Record<string, unknown> = { prompt: prompt.trim(), model, n };
|
||||
if (size !== 'auto') body.size = size;
|
||||
if (quality !== 'auto') body.quality = quality;
|
||||
if (background !== 'auto') body.background = background;
|
||||
|
||||
const { id } = await client.startGeneration(body);
|
||||
|
||||
// Long-poll in a loop: each call waits up to 30s server-side, so this is one request per half
|
||||
// minute rather than a busy poll, and it survives a generation that takes longer.
|
||||
for (;;) {
|
||||
const result = await client.awaitGeneration(id, 30);
|
||||
if (result.atelier && ['succeeded', 'failed', 'partial'].includes(result.atelier.status)) {
|
||||
setJob(result);
|
||||
bumpRefresh();
|
||||
return;
|
||||
}
|
||||
setJob(result);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
providers,
|
||||
usableProviders,
|
||||
models,
|
||||
model,
|
||||
setModel,
|
||||
prompt,
|
||||
setPrompt,
|
||||
n,
|
||||
setN,
|
||||
size,
|
||||
setSize,
|
||||
quality,
|
||||
setQuality,
|
||||
background,
|
||||
setBackground,
|
||||
job,
|
||||
running,
|
||||
error,
|
||||
ready,
|
||||
generate,
|
||||
};
|
||||
};
|
||||
|
||||
export type GenerateManager = ReturnType<typeof useGenerateManager>;
|
||||
Reference in New Issue
Block a user