From d3be31d038013d833620b4d0d4004338e0e5d155 Mon Sep 17 00:00:00 2001 From: imbytecat Date: Mon, 18 May 2026 22:13:23 +0800 Subject: [PATCH] feat: support reference images via /images/edits --- index.html | 116 ++++++++++++++++++++++++++++++++++++++++++++++++++--- index.ts | 83 ++++++++++++++++++++++++++++++++++---- 2 files changed, 186 insertions(+), 13 deletions(-) diff --git a/index.html b/index.html index 94bd261..2cfadc6 100644 --- a/index.html +++ b/index.html @@ -113,6 +113,59 @@ .status.error { color: var(--danger); } + .ref-preview { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; + } + .ref-preview:empty { + display: none; + } + .ref-preview .thumb { + position: relative; + width: 64px; + height: 64px; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border); + } + .ref-preview .thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + .ref-preview .thumb .remove { + position: absolute; + top: 2px; + right: 2px; + width: 18px; + height: 18px; + padding: 0; + border: 0; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + color: white; + font-size: 12px; + line-height: 18px; + cursor: pointer; + font-weight: bold; + } + .hint { + display: block; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + } + .hint code { + background: rgba(255, 255, 255, 0.06); + padding: 1px 4px; + border-radius: 4px; + } + input[type="file"] { + padding: 8px; + } .result { margin-top: 24px; display: grid; @@ -158,16 +211,14 @@
- +
@@ -178,6 +229,15 @@ placeholder="A futuristic cityscape at sunset, cinematic lighting" > +
+ + +
+ + Provide one or more references to keep style consistent. When set, + the request is sent to /v1/images/edits (gpt-image series only). + +
@@ -207,6 +267,49 @@ }); } + const refImages = []; + + function renderRefPreview() { + const c = $("refPreview"); + c.innerHTML = ""; + refImages.forEach((src, i) => { + const thumb = document.createElement("div"); + thumb.className = "thumb"; + const img = document.createElement("img"); + img.src = src; + img.alt = "reference " + (i + 1); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "remove"; + btn.textContent = "\u00d7"; + btn.title = "Remove"; + btn.onclick = () => { + refImages.splice(i, 1); + renderRefPreview(); + }; + thumb.appendChild(img); + thumb.appendChild(btn); + c.appendChild(thumb); + }); + } + + $("refImages").addEventListener("change", async (e) => { + const input = e.target; + const files = Array.from(input.files || []); + for (const file of files) { + if (!file.type.startsWith("image/")) continue; + const dataUrl = await new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(r.result); + r.onerror = () => reject(r.error); + r.readAsDataURL(file); + }); + refImages.push(dataUrl); + } + renderRefPreview(); + input.value = ""; + }); + $("generate").addEventListener("click", async () => { const btn = $("generate"); const status = $("status"); @@ -218,6 +321,7 @@ model: $("model").value.trim(), size: $("size").value, prompt: $("prompt").value.trim(), + referenceImages: refImages, }; if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) { diff --git a/index.ts b/index.ts index 8e2b9f2..51b5cfe 100644 --- a/index.ts +++ b/index.ts @@ -2,6 +2,61 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { generateImage } from "ai"; import index from "./index.html"; +type Size = `${number}x${number}`; + +async function generateWithReference({ + baseURL, + apiKey, + model, + prompt, + size, + referenceImages, +}: { + baseURL: string; + apiKey: string; + model: string; + prompt: string; + size: Size; + referenceImages: string[]; +}): Promise { + const form = new FormData(); + form.append("model", model); + form.append("prompt", prompt); + form.append("size", size); + + for (let i = 0; i < referenceImages.length; i++) { + const dataUrl = referenceImages[i]; + if (!dataUrl) continue; + const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!match) continue; + const mime = match[1]!; + const b64 = match[2]!; + const bytes = Buffer.from(b64, "base64"); + const ext = mime.split("/")[1] ?? "png"; + form.append("image", new Blob([bytes], { type: mime }), `ref-${i}.${ext}`); + } + + const url = `${baseURL.replace(/\/+$/, "")}/images/edits`; + const res = await fetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: form, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Upstream ${res.status}: ${text || res.statusText}`); + } + + const data = (await res.json()) as { + data?: Array<{ b64_json?: string }>; + }; + + return (data.data ?? []) + .map((item) => (item.b64_json ? `data:image/png;base64,${item.b64_json}` : null)) + .filter((s): s is string => s !== null); +} + const server = Bun.serve({ hostname: "0.0.0.0", routes: { @@ -9,13 +64,15 @@ const server = Bun.serve({ "/api/generate": { POST: async (req) => { try { - const { baseURL, apiKey, model, prompt, size } = (await req.json()) as { - baseURL?: string; - apiKey?: string; - model?: string; - prompt?: string; - size?: `${number}x${number}`; - }; + const { baseURL, apiKey, model, prompt, size, referenceImages } = + (await req.json()) as { + baseURL?: string; + apiKey?: string; + model?: string; + prompt?: string; + size?: Size; + referenceImages?: string[]; + }; if (!baseURL || !apiKey || !model || !prompt) { return Response.json( @@ -24,6 +81,18 @@ const server = Bun.serve({ ); } + if (Array.isArray(referenceImages) && referenceImages.length > 0) { + const images = await generateWithReference({ + baseURL, + apiKey, + model, + prompt, + size: size ?? "1024x1024", + referenceImages, + }); + return Response.json({ images }); + } + const provider = createOpenAICompatible({ name: "custom", apiKey,