Spaces:
Runtime error
Runtime error
| import { FormEvent, useMemo, useRef, useState } from "react"; | |
| import { apiFetch } from "../api"; | |
| import { DownloadMediaButton } from "../components/DownloadMediaButton"; | |
| import { ElapsedTimer } from "../components/ElapsedTimer"; | |
| import { ImageSlot } from "../components/ImageSlot"; | |
| import { useGenerationOptions } from "../context/GenerationOptionsContext"; | |
| const FLASH25_ID = "gemini-2.5-flash-image"; | |
| const FLASH_ID = "gemini-3.1-flash-image-preview"; | |
| const PRO_ID = "gemini-3-pro-image-preview"; | |
| /** Order: older flash → Nano Banana 2 (default) → long-thinking → Pro */ | |
| type ImageGenVariant = "nb25-minimal" | "flash-minimal" | "flash-high" | "pro-high"; | |
| export function ImageGen() { | |
| const opts = useGenerationOptions(); | |
| const nano25Label = | |
| opts.image.models.find((m) => m.value === FLASH25_ID)?.label ?? "Nano Banana"; | |
| const flashLabel = | |
| opts.image.models.find((m) => m.value === FLASH_ID)?.label ?? "Nano Banana 2"; | |
| const proLabel = | |
| opts.image.models.find((m) => m.value === PRO_ID)?.label ?? "Nano Banana Pro"; | |
| const [prompt, setPrompt] = useState(""); | |
| const [variant, setVariant] = useState<ImageGenVariant>("flash-minimal"); | |
| const [aspect, setAspect] = useState(opts.image.aspect_ratios[0] ?? "1:1"); | |
| const [res, setRes] = useState(opts.image.resolutions[0] ?? "1K"); | |
| const [f0, setF0] = useState<File | null>(null); | |
| const [f1, setF1] = useState<File | null>(null); | |
| const [f2, setF2] = useState<File | null>(null); | |
| const [busy, setBusy] = useState(false); | |
| const [err, setErr] = useState<string | null>(null); | |
| const [out, setOut] = useState<string | null>(null); | |
| const formRef = useRef<HTMLFormElement>(null); | |
| const variantOptions = useMemo( | |
| () => [ | |
| { id: "nb25-minimal" as const, label: nano25Label }, | |
| { id: "flash-minimal" as const, label: flashLabel }, | |
| { id: "flash-high" as const, label: `${flashLabel}(长思考)` }, | |
| { id: "pro-high" as const, label: `${proLabel}(长思考)` }, | |
| ], | |
| [nano25Label, flashLabel, proLabel], | |
| ); | |
| async function onSubmit(e: FormEvent) { | |
| e.preventDefault(); | |
| setErr(null); | |
| setOut(null); | |
| if (!prompt.trim()) { | |
| setErr("请填写提示词。"); | |
| return; | |
| } | |
| let model: string; | |
| let thinking: "minimal" | "high"; | |
| switch (variant) { | |
| case "nb25-minimal": | |
| model = FLASH25_ID; | |
| thinking = "minimal"; | |
| break; | |
| case "flash-minimal": | |
| model = FLASH_ID; | |
| thinking = "minimal"; | |
| break; | |
| case "flash-high": | |
| model = FLASH_ID; | |
| thinking = "high"; | |
| break; | |
| case "pro-high": | |
| model = PRO_ID; | |
| thinking = "high"; | |
| break; | |
| } | |
| const fd = new FormData(); | |
| fd.append("prompt", prompt.trim()); | |
| fd.append("model", model); | |
| fd.append("aspect_ratio", aspect); | |
| fd.append("resolution", res); | |
| fd.append("thinking_level", thinking); | |
| if (f0) fd.append("image_0", f0); | |
| if (f1) fd.append("image_1", f1); | |
| if (f2) fd.append("image_2", f2); | |
| setBusy(true); | |
| try { | |
| const r = await apiFetch("/api/generate/image", { | |
| method: "POST", | |
| body: fd, | |
| }); | |
| const j = await r.json().catch(() => ({})); | |
| if (!r.ok) { | |
| throw new Error((j as { detail?: string }).detail || r.statusText); | |
| } | |
| const b64 = (j as { image_base64: string; mime_type: string }).image_base64; | |
| const mime = (j as { mime_type: string }).mime_type; | |
| setOut(`data:${mime};base64,${b64}`); | |
| } catch (e: unknown) { | |
| setErr(e instanceof Error ? e.message : "请求失败"); | |
| } finally { | |
| setBusy(false); | |
| } | |
| } | |
| return ( | |
| <div className="max-w-3xl"> | |
| <h1 className="font-display text-2xl font-semibold text-ink mb-2">图片编辑或生成</h1> | |
| <div className="mb-6 space-y-1.5"> | |
| <p className="text-mist text-sm leading-relaxed"> | |
| 填写提示词;参考图最多三张。再选比例与分辨率。 | |
| </p> | |
| <p className="text-xs text-mist leading-relaxed"> | |
| {nano25Label} 为稳定版;{flashLabel} 与 {proLabel} 为实验性,接口或效果可能变更。 | |
| </p> | |
| </div> | |
| <form ref={formRef} onSubmit={onSubmit} className="space-y-5"> | |
| <fieldset | |
| disabled={busy} | |
| className="border-0 p-0 m-0 min-w-0 space-y-5 disabled:pointer-events-none disabled:opacity-[0.92]" | |
| > | |
| <div> | |
| <label className="block text-sm font-semibold text-ink mb-1">模型</label> | |
| <select | |
| className="w-full max-w-md rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm" | |
| value={variant} | |
| onChange={(e) => setVariant(e.target.value as ImageGenVariant)} | |
| > | |
| {variantOptions.map((opt) => ( | |
| <option key={opt.id} value={opt.id}> | |
| {opt.label} | |
| </option> | |
| ))} | |
| </select> | |
| <p className="text-xs text-mist mt-1.5"> | |
| {nano25Label} 为上一代快速;{flashLabel} 为默认推荐;{flashLabel}(长思考)与 {proLabel}(长思考)为长思考模式,更慢、更细;{proLabel} 为 Pro 路线。 | |
| </p> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-semibold text-ink mb-1">提示词(必填)</label> | |
| <textarea | |
| className="w-full min-h-[120px] rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-clay/50" | |
| value={prompt} | |
| onChange={(e) => setPrompt(e.target.value)} | |
| placeholder="填写画面描述…" | |
| /> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> | |
| <div> | |
| <label className="block text-sm font-semibold text-ink mb-1">宽高比</label> | |
| <select | |
| className="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm" | |
| value={aspect} | |
| onChange={(e) => setAspect(e.target.value)} | |
| > | |
| {opts.image.aspect_ratios.map((a) => ( | |
| <option key={a} value={a}> | |
| {a} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-semibold text-ink mb-1">分辨率</label> | |
| <select | |
| className="w-full rounded-lg border border-slate-300 bg-white px-2 py-2 text-sm" | |
| value={res} | |
| onChange={(e) => setRes(e.target.value)} | |
| > | |
| {opts.image.resolutions.map((x) => ( | |
| <option key={x} value={x}> | |
| {x} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| <div> | |
| <p className="text-sm font-semibold text-ink mb-2">参考图(最多三张)</p> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-3"> | |
| <ImageSlot label="参考图 1" file={f0} onChange={setF0} /> | |
| <ImageSlot label="参考图 2" file={f1} onChange={setF1} /> | |
| <ImageSlot label="参考图 3" file={f2} onChange={setF2} /> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <button | |
| type="submit" | |
| className="rounded-lg bg-ink text-paper px-5 py-2 text-sm font-medium hover:bg-slate-800 disabled:opacity-40" | |
| > | |
| {busy ? "生成中…" : "生成图片"} | |
| </button> | |
| <ElapsedTimer active={busy} /> | |
| </div> | |
| </fieldset> | |
| {err && <p className="text-sm text-red-700">{err}</p>} | |
| </form> | |
| {out && ( | |
| <div className="mt-10 border-t border-slate-200 pt-8"> | |
| <h2 className="font-display text-xl font-semibold text-ink mb-3">生成结果</h2> | |
| <div className="mb-3 flex flex-wrap items-center gap-3"> | |
| <DownloadMediaButton dataUrl={out} filenameBase="image-gen" label="下载图片" /> | |
| <button | |
| type="button" | |
| onClick={() => formRef.current?.requestSubmit()} | |
| className="rounded-lg border border-slate-300 bg-white px-5 py-2 text-sm font-medium text-ink hover:bg-slate-50" | |
| > | |
| 重新生成 | |
| </button> | |
| </div> | |
| <img | |
| src={out} | |
| alt="生成结果" | |
| className="max-w-full rounded-xl border border-slate-200 shadow-lg" | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |