diff --git a/src/app/api/image-generation/route.ts b/src/app/api/image-generation/route.ts new file mode 100644 index 0000000..822bb3d --- /dev/null +++ b/src/app/api/image-generation/route.ts @@ -0,0 +1,74 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireActor, requireAdmin } from "@/lib/server/actor"; +import { configApiFetch } from "@/lib/server/evoscientistConfigClient"; +import { isCrossOrigin } from "@/lib/server/workspace"; +import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors"; +import type { + ImageGenerationSettings, + PutImageGenerationRequest, +} from "@/lib/imageGeneration"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + const actor = requireActor(request); + requireAdmin(actor); + const { body } = await configApiFetch( + actor, + "/api/image-generation" + ); + return NextResponse.json(body, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error); + } +} + +export async function PUT(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { code: "FORBIDDEN", message: "Cross-origin access is not allowed." }, + { status: 403, headers: NO_STORE } + ); + } + const actor = requireActor(request); + requireAdmin(actor); + const payload = (await request + .json() + .catch(() => null)) as PutImageGenerationRequest | null; + if ( + !payload || + typeof payload !== "object" || + !Array.isArray(payload.models) + ) { + return NextResponse.json( + { + code: "INVALID_REQUEST", + message: "default_model, timeout_seconds and models are required.", + }, + { status: 400, headers: NO_STORE } + ); + } + const body: PutImageGenerationRequest = { + default_model: payload.default_model, + timeout_seconds: payload.timeout_seconds, + models: payload.models, + }; + const { body: saved } = await configApiFetch( + actor, + "/api/image-generation", + { method: "PUT", body } + ); + return NextResponse.json(saved, { headers: NO_STORE }); + } catch (error) { + return routeErrorResponse(error); + } +} diff --git a/src/app/components/ImageModelsDialog.tsx b/src/app/components/ImageModelsDialog.tsx new file mode 100644 index 0000000..64016a1 --- /dev/null +++ b/src/app/components/ImageModelsDialog.tsx @@ -0,0 +1,44 @@ +"use client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ImageModelsEditor } from "@/app/components/ImageModelsEditor"; + +interface ImageModelsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ImageModelsDialog({ open, onOpenChange }: ImageModelsDialogProps) { + return ( + + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + onEscapeKeyDown={(event) => event.preventDefault()} + > + + Image models + + Manage dedicated image-generation models (stored in config.yaml). + Admin only. + + + {open && } + + + + + + ); +} diff --git a/src/app/components/ImageModelsEditor.tsx b/src/app/components/ImageModelsEditor.tsx new file mode 100644 index 0000000..2f59090 --- /dev/null +++ b/src/app/components/ImageModelsEditor.tsx @@ -0,0 +1,493 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Eye, EyeOff } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { + getImageGeneration, + newImageModelDraft, + putImageGeneration, + type ImageGenerationSettings, + type ImageModel, + type ImageModelStatus, + type PutImageGenerationRequest, +} from "@/lib/imageGeneration"; + +const inputClass = + "w-full rounded-md border border-border bg-background px-2 py-1 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"; + +const SIZE_OPTIONS = ["1024x1024", "1536x1024", "1024x1536"]; + +function Field({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +/** Editable model state: the masked GET shape plus the raw params JSON text. */ +type ModelDraft = ImageModelStatus & { paramsText: string }; + +interface DraftState { + default_model: string; + timeout_seconds: number; + models: ModelDraft[]; +} + +function toModelDraft(model: ImageModelStatus): ModelDraft { + return { + ...model, + paramsText: + model.params && Object.keys(model.params).length > 0 + ? JSON.stringify(model.params, null, 2) + : "", + }; +} + +function toDraftState(settings: ImageGenerationSettings): DraftState { + return { + default_model: settings.default_model, + timeout_seconds: settings.timeout_seconds, + models: settings.models.map(toModelDraft), + }; +} + +type ParsedParams = + | { ok: true; value: Record } + | { ok: false; message: string }; + +function parseParams(text: string): ParsedParams { + const trimmed = text.trim(); + if (!trimmed) return { ok: true, value: {} }; + try { + const parsed: unknown = JSON.parse(trimmed); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, message: "params must be a JSON object" }; + } + return { ok: true, value: parsed as Record }; + } catch { + return { ok: false, message: "invalid JSON" }; + } +} + +/** Build the PUT payload; returns null when any params text is invalid. */ +function buildPayload(draft: DraftState): PutImageGenerationRequest | null { + const models: ImageModel[] = []; + for (const model of draft.models) { + const params = parseParams(model.paramsText); + if (!params.ok) return null; + models.push({ + id: model.id, + name: model.name, + provider: model.provider, + api_key: model.api_key, + base_url: model.base_url, + supports_generation: model.supports_generation, + supports_edit: model.supports_edit, + default_size: model.default_size, + default_quality: model.default_quality, + params: params.value, + }); + } + return { + default_model: draft.default_model, + timeout_seconds: draft.timeout_seconds, + models, + }; +} + +function ImageModelCard({ + model, + onChange, + onRemove, +}: { + model: ModelDraft; + onChange: (next: ModelDraft) => void; + onRemove: () => void; +}) { + const [showKey, setShowKey] = useState(false); + const paramsResult = parseParams(model.paramsText); + const paramsError = paramsResult.ok ? null : paramsResult.message; + + return ( +
+
+ + {model.id || "(new model)"} + + + {model.provider} + + + + + +
+
+ + onChange({ ...model, id: event.target.value })} + /> + + + + onChange({ ...model, name: event.target.value }) + } + /> + + + + +
+ + + onChange({ ...model, base_url: event.target.value }) + } + /> + +
+ + + + + + onChange({ ...model, default_quality: event.target.value }) + } + /> + + +
+ + onChange({ ...model, api_key: event.target.value }) + } + /> + +
+
+
+ +