Compare commits
6 Commits
d3be31d038
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e6b4dc7fa | |||
| d5bbc14c8d | |||
| 4ef2f1ba2b | |||
| 600f574b5c | |||
| 5af05b2141 | |||
| 54f13c1097 |
@@ -0,0 +1,3 @@
|
||||
BASE_URL=https://api.openai.com/v1
|
||||
API_KEY=sk-...
|
||||
MODEL=gpt-image-2
|
||||
@@ -1,7 +1,9 @@
|
||||
# AGENTS.md
|
||||
|
||||
Bun + TypeScript single-file server that exposes an OpenAI-compatible image
|
||||
generation endpoint and serves a small vanilla HTML/JS playground.
|
||||
Bun + Hono server that proxies an OpenAI-compatible image endpoint and serves
|
||||
a small vanilla TS playground. SSE end-to-end: streams gpt-image partial
|
||||
previews through, with keepalive comments that survive Cloudflare's 120s
|
||||
proxy-read timeout.
|
||||
|
||||
## Runtime
|
||||
|
||||
@@ -10,6 +12,26 @@ generation endpoint and serves a small vanilla HTML/JS playground.
|
||||
`dotenv` — Bun loads `.env` automatically.
|
||||
- Bun version baseline: `1.3.13` (per `README.md`).
|
||||
|
||||
## Config
|
||||
|
||||
Required env vars (validated at startup via `requireEnv`; the process exits
|
||||
if any is missing):
|
||||
|
||||
| Var | Example | Purpose |
|
||||
|---|---|---|
|
||||
| `BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible base URL |
|
||||
| `API_KEY` | `sk-…` | Bearer token sent to upstream |
|
||||
| `MODEL` | `gpt-image-2` | Model name forwarded to upstream |
|
||||
|
||||
`.env.example` is the source of truth for variable names. The real `.env`
|
||||
is gitignored. Restart the server after changing env vars — they are read
|
||||
once at module load.
|
||||
|
||||
These secrets stay **server-side**. The browser only sends `prompt`,
|
||||
`size`, and `referenceImages`. Combined with the `0.0.0.0` bind, this
|
||||
means anyone reachable on the network can spend your upstream quota —
|
||||
bind to `127.0.0.1` or put auth in front if that matters.
|
||||
|
||||
## Commands
|
||||
|
||||
- Install: `bun install`
|
||||
@@ -19,24 +41,81 @@ generation endpoint and serves a small vanilla HTML/JS playground.
|
||||
`noEmit: true`, so plain `bunx tsc` works too).
|
||||
- Tests / lint / formatter: none configured. If adding tests, use `bun test`.
|
||||
|
||||
The server binds `0.0.0.0` (see `index.ts:6`), so it is reachable from other
|
||||
hosts on the network when running locally — be mindful when entering API keys.
|
||||
The server binds `0.0.0.0` (see `index.ts`), so it is reachable from other
|
||||
hosts on the network — be mindful when entering API keys.
|
||||
|
||||
Bun's dev server auto-serves `/.well-known/appspecific/com.chrome.devtools.json`
|
||||
advertising the project root to Chrome DevTools' "Automatic Workspace Folders".
|
||||
Sandboxed browsers (Flatpak/Snap) reject the path with
|
||||
`Unable to add filesystem: <illegal path>`. Disabled via
|
||||
`development.chromeDevToolsAutomaticWorkspaceFolders: false`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `index.ts` — the entire backend. One `Bun.serve` instance with:
|
||||
- `/` serves `index.html` via Bun's HTML import (`import index from "./index.html"`).
|
||||
- `POST /api/generate` accepts `{ baseURL, apiKey, model, prompt, size }`,
|
||||
builds an OpenAI-compatible provider with `@ai-sdk/openai-compatible`, and
|
||||
calls `generateImage` from `ai`. Images come back as base64 and are
|
||||
returned as `data:` URLs in `{ images: string[] }`.
|
||||
- `index.html` — self-contained UI: inline CSS, plain DOM JS, no build step.
|
||||
Settings (baseURL, apiKey, model, size, prompt) persist in `localStorage`
|
||||
under the `aip:<field>` prefix. There is no React code despite
|
||||
`react` / `react-dom` / `@types/react*` being in `package.json` — treat
|
||||
those deps as latent. Do not invent a React frontend unless asked.
|
||||
- No router, no DB, no auth. API key is supplied per-request by the browser
|
||||
and never stored server-side.
|
||||
Three files do everything:
|
||||
|
||||
- `index.ts` — Hono app mounted under `Bun.serve` (`fetch: app.fetch`).
|
||||
- `routes: { "/": index }` serves `index.html` via Bun's HTML bundler;
|
||||
everything else falls through to Hono.
|
||||
- `idleTimeout: 255` (max) — `Bun.serve`'s 10s default kills SSE
|
||||
connections before the first keepalive can fire. The symptom is an
|
||||
empty EventStream in DevTools and `request timed out after 10 seconds`
|
||||
in the log.
|
||||
- `POST /api/generate` uses `streamSSE` from `hono/streaming`. Accepts
|
||||
`{ prompt, size, referenceImages? }` — `BASE_URL`, `API_KEY`, and
|
||||
`MODEL` come from env, not the request. Emits:
|
||||
- `event: partial` — `{ image: dataUrl, index }` for each
|
||||
`image_generation.partial_image` / `image_edit.partial_image`.
|
||||
- `event: final` — `{ image: dataUrl }` for `*.completed`.
|
||||
- `event: done` — empty payload, sent before stream ends.
|
||||
- `event: error` — `{ message }` for any failure.
|
||||
- First write is `: connected\n\n` so the browser/EventStream tab
|
||||
becomes responsive immediately; then a `: keepalive\n\n` raw comment
|
||||
every 15s.
|
||||
- Upstream dispatch:
|
||||
- `referenceImages` present → `POST {baseURL}/images/edits` as
|
||||
`multipart/form-data` (blobs decoded from data URLs via
|
||||
`decodeDataUrl` → `Uint8Array<ArrayBuffer>`). Single reference uses
|
||||
field name `image`; **two or more references use `image[]`** to
|
||||
match OpenAI's documented array syntax — strict gateways reject
|
||||
repeated `image` parts with a `duplicate_parameter` 400.
|
||||
- Otherwise → `POST {baseURL}/images/generations` as JSON.
|
||||
- Always sends `stream: true, partial_images: 2` first. On a 400 that
|
||||
mentions `stream` or `partial_images` (see
|
||||
`isStreamingUnsupportedError`), retries once with `stream: false`
|
||||
and replays the JSON response as a single `final` event via
|
||||
`forwardUpstreamJSON`. Any other 4xx/5xx becomes an `error` event.
|
||||
- `AbortController` wired to `stream.onAbort()` and threaded as `signal`
|
||||
into every upstream `fetch`. The `catch` branch is suppressed when
|
||||
`signal.aborted` so closed tabs don't spam the log.
|
||||
- Targets the **gpt-image series only** (gpt-image-2 default). Do not
|
||||
reintroduce DALL·E-only fields like `response_format` — gpt-image
|
||||
always returns `b64_json`.
|
||||
- `gpt-image-2` size constraints (per the OpenAI cookbook): both edges
|
||||
multiple of 16, max edge **< 3840**, long/short ≤ 3:1, total pixels
|
||||
in 655,360–8,294,400, `auto` not supported. Exact 16:9 requires
|
||||
`16k × 9k` with `k` a multiple of 16 (so `1280×720`, `1536×864`,
|
||||
`2048×1152`, `2560×1440`, …). Sizes above 2560×1440 are
|
||||
experimental — the popular 4K target `3840×2160` violates the
|
||||
`< 3840` rule, round down to `3824×2144` if you need it. Common
|
||||
misses: `1920×1080` is **not valid** (1080 % 16 ≠ 0).
|
||||
- `client.ts` — browser entry, loaded via `<script type="module"
|
||||
src="./client.ts">` in `index.html`. Bun's bundler resolves the import,
|
||||
inlines `@microsoft/fetch-event-source`, and serves the bundle from
|
||||
`/_bun/client/index-*.js`. **Inline `<script type="module">` blocks are
|
||||
not bundled by Bun** — any client JS that imports from `node_modules`
|
||||
must live in a separate file.
|
||||
- Uses `fetchEventSource` instead of hand-rolled `fetch` +
|
||||
`ReadableStream` SSE parsing. It supports POST + body, custom headers,
|
||||
`signal`, and the `onopen` / `onmessage` / `onerror` callbacks.
|
||||
- On `done`, the client calls `abort.abort()` to terminate the
|
||||
`fetchEventSource` loop cleanly — otherwise it would retry forever.
|
||||
- Text fields (`size`, `prompt`) persist in `localStorage` under the
|
||||
`aip:<field>` prefix. Reference images stay in-memory only.
|
||||
- `index.html` — markup + inline CSS only. No JS lives here.
|
||||
|
||||
No router, no DB, no auth, no AI SDK. API key is supplied per-request by the
|
||||
browser and never stored server-side.
|
||||
|
||||
## TypeScript conventions
|
||||
|
||||
@@ -47,15 +126,31 @@ hosts on the network when running locally — be mindful when entering API keys.
|
||||
- `verbatimModuleSyntax` + `moduleDetection: "force"` — use `import type` for
|
||||
type-only imports; every file is a module.
|
||||
- `allowImportingTsExtensions` is on; `.ts` extensions in imports are fine.
|
||||
- `jsx: "react-jsx"` is set but unused (see frontend note above).
|
||||
- `lib: ["ESNext", "DOM", "DOM.Iterable"]` — DOM globals are in scope for
|
||||
`client.ts`. The server file uses Bun globals from `@types/bun`; the
|
||||
overlap (`fetch`, `Response`, `Blob`, `FormData`) resolves to Web
|
||||
standards, which is what we want.
|
||||
- TS 5.7+ split `Uint8Array` into `Uint8Array<ArrayBuffer>` vs
|
||||
`Uint8Array<SharedArrayBuffer>`. DOM's `Blob`/`BufferSource` requires the
|
||||
former. Allocate via `new Uint8Array(new ArrayBuffer(n))` (see
|
||||
`decodeDataUrl`) rather than `new Uint8Array(n)` — the latter widens to
|
||||
`ArrayBufferLike` and fails to satisfy `BlobPart`.
|
||||
|
||||
## When extending the API
|
||||
|
||||
- Add new routes inside the `routes` object in `index.ts`; keep the
|
||||
`{ POST: async (req) => … }` shape used by `/api/generate`.
|
||||
- Return JSON with `Response.json(...)`. Validate the request body shape
|
||||
explicitly — the existing handler asserts required fields and returns 400
|
||||
before calling the model.
|
||||
- The AI SDK image type is loose; the current handler casts to
|
||||
`{ mediaType?: string; base64?: string }`. Mirror that pattern rather than
|
||||
trusting field presence.
|
||||
- Routes live on the Hono `app`. For long-running upstream calls, mirror
|
||||
the existing pattern:
|
||||
- `return streamSSE(c, async (stream) => { … })`
|
||||
- `stream.onAbort(() => abortController.abort())` at the top
|
||||
- `await stream.write(": connected\n\n")` to flush headers immediately
|
||||
- `setInterval(() => stream.write(": keepalive\n\n").catch(() => {}),
|
||||
15_000)` and `clearInterval` in `finally`
|
||||
- `stream.writeSSE({ event, data: JSON.stringify(payload) })` for
|
||||
application events
|
||||
- Catch errors and check `signal.aborted` before emitting `error` —
|
||||
otherwise every closed tab logs noise.
|
||||
- Send the optimistic request to upstream first; detect the specific 400
|
||||
via `isStreamingUnsupportedError` and retry with a degraded body rather
|
||||
than feature-detecting up front.
|
||||
- Decode incoming data URLs with `decodeDataUrl` and pass the typed
|
||||
`Uint8Array<ArrayBuffer>` directly as a `Blob` part in `FormData`.
|
||||
@@ -5,73 +5,28 @@
|
||||
"": {
|
||||
"name": "ai-playground",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@ai-sdk/react": "^3.0.186",
|
||||
"ai": "^6.0.184",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"hono": "^4.12.19",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.115", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-xonmGfN9pt54WdKqMzWe68BRYS3rsYvraBzioyA0gfNcecHs8Ir5qk/X8grJSyZ95hghjWiOphrK6bAc11E6SA=="],
|
||||
|
||||
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/react": ["@ai-sdk/react@3.0.186", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.184", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-fy8wuy8pBghYD1ECw/M5vAsGsZp2D3y/oSTp1iOlAnJqRXzvz4rWLBz1n+rjL+aHZNgJK3kR3NHlnifoKYERfA=="],
|
||||
|
||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
"@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.0", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
|
||||
|
||||
"ai": ["ai@6.0.184", "", { "dependencies": { "@ai-sdk/gateway": "3.0.115", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-j//zHkKvj5ra27l8izHco8cj1g1Pr7vx1ZK+hrzrkHvndgIRmdfZKOb6+RAPpvbk42qGIsuYvlYbGlVAu3erNQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
|
||||
|
||||
"throttleit": ["throttleit@2.1.0", "", {}, "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw=="],
|
||||
"hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||
|
||||
const byId = <T extends HTMLElement>(id: string) =>
|
||||
document.getElementById(id) as T;
|
||||
const select = (id: string) => byId<HTMLSelectElement>(id);
|
||||
const textarea = (id: string) => byId<HTMLTextAreaElement>(id);
|
||||
|
||||
const persistedFields = ["size", "prompt"] as const;
|
||||
for (const f of persistedFields) {
|
||||
const el = byId<HTMLSelectElement | HTMLTextAreaElement>(f);
|
||||
const saved = localStorage.getItem("aip:" + f);
|
||||
if (saved) el.value = saved;
|
||||
const save = () => localStorage.setItem("aip:" + f, el.value);
|
||||
el.addEventListener("input", save);
|
||||
el.addEventListener("change", save);
|
||||
}
|
||||
|
||||
const refImages: string[] = [];
|
||||
const refPreview = byId<HTMLDivElement>("refPreview");
|
||||
|
||||
function renderRefPreview() {
|
||||
refPreview.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);
|
||||
refPreview.appendChild(thumb);
|
||||
});
|
||||
}
|
||||
|
||||
byId<HTMLInputElement>("refImages").addEventListener("change", async (e) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const files = Array.from(el.files ?? []);
|
||||
for (const file of files) {
|
||||
if (!file.type.startsWith("image/")) continue;
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const r = new FileReader();
|
||||
r.onload = () => resolve(r.result as string);
|
||||
r.onerror = () => reject(r.error);
|
||||
r.readAsDataURL(file);
|
||||
});
|
||||
refImages.push(dataUrl);
|
||||
}
|
||||
renderRefPreview();
|
||||
el.value = "";
|
||||
});
|
||||
|
||||
class FatalError extends Error {}
|
||||
|
||||
byId<HTMLButtonElement>("generate").addEventListener("click", async () => {
|
||||
const btn = byId<HTMLButtonElement>("generate");
|
||||
const status = byId<HTMLSpanElement>("status");
|
||||
const result = byId<HTMLDivElement>("result");
|
||||
|
||||
const body = {
|
||||
size: select("size").value,
|
||||
prompt: textarea("prompt").value.trim(),
|
||||
referenceImages: refImages,
|
||||
};
|
||||
|
||||
if (!body.prompt) {
|
||||
status.textContent = "Please enter a prompt.";
|
||||
status.className = "status error";
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
status.className = "status";
|
||||
status.textContent = "Generating...";
|
||||
result.innerHTML = "";
|
||||
|
||||
const preview = document.createElement("img");
|
||||
preview.alt = body.prompt;
|
||||
let appended = false;
|
||||
const showPreview = (src: string) => {
|
||||
preview.src = src;
|
||||
if (!appended) {
|
||||
result.appendChild(preview);
|
||||
appended = true;
|
||||
}
|
||||
};
|
||||
|
||||
const abort = new AbortController();
|
||||
try {
|
||||
await fetchEventSource("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: abort.signal,
|
||||
openWhenHidden: true,
|
||||
async onopen(res) {
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (res.ok && ct.includes("event-stream")) return;
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new FatalError(data.error ?? "HTTP " + res.status);
|
||||
},
|
||||
onmessage(ev) {
|
||||
if (!ev.data) return;
|
||||
const payload = JSON.parse(ev.data) as {
|
||||
image?: string;
|
||||
index?: number;
|
||||
message?: string;
|
||||
};
|
||||
if (ev.event === "partial" && payload.image) {
|
||||
showPreview(payload.image);
|
||||
status.textContent =
|
||||
"Receiving preview " + ((payload.index ?? 0) + 1) + "...";
|
||||
} else if (ev.event === "final" && payload.image) {
|
||||
showPreview(payload.image);
|
||||
status.textContent = "Done.";
|
||||
} else if (ev.event === "error") {
|
||||
throw new FatalError(payload.message ?? "Unknown error");
|
||||
} else if (ev.event === "done") {
|
||||
abort.abort();
|
||||
}
|
||||
},
|
||||
onerror(err) {
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (!abort.signal.aborted) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
status.textContent = "Error: " + message;
|
||||
status.className = "status error";
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
+20
-141
@@ -196,31 +196,22 @@
|
||||
</p>
|
||||
|
||||
<div class="panel">
|
||||
<div class="grid">
|
||||
<div class="row">
|
||||
<label for="baseURL">Base URL</label>
|
||||
<input
|
||||
id="baseURL"
|
||||
type="text"
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="apiKey">API Key</label>
|
||||
<input id="apiKey" type="password" placeholder="sk-..." />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="model">Model</label>
|
||||
<input id="model" type="text" placeholder="gpt-image-2" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="size">Size</label>
|
||||
<select id="size">
|
||||
<option value="1024x1024">1024x1024 (square)</option>
|
||||
<option value="1536x1024">1536x1024 (landscape)</option>
|
||||
<option value="1024x1536">1024x1536 (portrait)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="size">Size</label>
|
||||
<select id="size">
|
||||
<option value="1024x1024">1024x1024 (1:1 square)</option>
|
||||
<option value="1536x1024">1536x1024 (3:2 landscape)</option>
|
||||
<option value="1024x1536">1024x1536 (2:3 portrait)</option>
|
||||
<option value="1536x864">1536x864 (16:9 landscape)</option>
|
||||
<option value="864x1536">864x1536 (9:16 portrait)</option>
|
||||
<option value="2048x1152">2048x1152 (16:9 2K landscape)</option>
|
||||
<option value="1152x2048">1152x2048 (9:16 2K portrait)</option>
|
||||
<option value="2560x1440">2560x1440 (16:9 QHD — experimental boundary)</option>
|
||||
</select>
|
||||
<small class="hint">
|
||||
Sizes above 2560x1440 are experimental for <code>gpt-image-2</code>.
|
||||
Both edges must be a multiple of 16; max edge < 3840.
|
||||
</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="prompt">Prompt</label>
|
||||
@@ -243,127 +234,15 @@
|
||||
<span id="status" class="status"></span>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Settings are stored in your browser's localStorage</summary>
|
||||
Base URL, API Key, model and size are saved locally. They are sent to
|
||||
the local Bun server only when you click Generate.
|
||||
<summary>Size and prompt are saved to your browser's localStorage</summary>
|
||||
Base URL, API key and model live in the server's <code>.env</code> —
|
||||
they are never sent from the browser.
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="result" class="result"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fields = ["baseURL", "apiKey", "model", "size", "prompt"];
|
||||
|
||||
for (const f of fields) {
|
||||
const saved = localStorage.getItem("aip:" + f);
|
||||
if (saved) $(f).value = saved;
|
||||
$(f).addEventListener("input", () => {
|
||||
localStorage.setItem("aip:" + f, $(f).value);
|
||||
});
|
||||
$(f).addEventListener("change", () => {
|
||||
localStorage.setItem("aip:" + f, $(f).value);
|
||||
});
|
||||
}
|
||||
|
||||
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");
|
||||
const result = $("result");
|
||||
|
||||
const body = {
|
||||
baseURL: $("baseURL").value.trim(),
|
||||
apiKey: $("apiKey").value.trim(),
|
||||
model: $("model").value.trim(),
|
||||
size: $("size").value,
|
||||
prompt: $("prompt").value.trim(),
|
||||
referenceImages: refImages,
|
||||
};
|
||||
|
||||
if (!body.baseURL || !body.apiKey || !body.model || !body.prompt) {
|
||||
status.textContent = "Please fill in Base URL, API Key, Model and Prompt.";
|
||||
status.className = "status error";
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
status.className = "status";
|
||||
status.textContent = "Generating...";
|
||||
result.innerHTML = "";
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
||||
|
||||
if (!data.images?.length) {
|
||||
status.textContent = "No images returned.";
|
||||
status.className = "status error";
|
||||
return;
|
||||
}
|
||||
|
||||
for (const src of data.images) {
|
||||
const img = document.createElement("img");
|
||||
img.src = src;
|
||||
img.alt = body.prompt;
|
||||
result.appendChild(img);
|
||||
}
|
||||
status.textContent = "Done.";
|
||||
} catch (err) {
|
||||
status.textContent = "Error: " + (err.message || String(err));
|
||||
status.className = "status error";
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="./client.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,128 +1,271 @@
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import { generateImage } from "ai";
|
||||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import type { SSEStreamingApi } from "hono/streaming";
|
||||
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;
|
||||
type GenerateRequest = {
|
||||
prompt?: string;
|
||||
size?: Size;
|
||||
referenceImages?: string[];
|
||||
};
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) {
|
||||
throw new Error(`Missing required env: ${name} (see .env.example).`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
const BASE_URL = requireEnv("BASE_URL");
|
||||
const API_KEY = requireEnv("API_KEY");
|
||||
const MODEL = requireEnv("MODEL");
|
||||
|
||||
function decodeDataUrl(
|
||||
dataUrl: string,
|
||||
): { bytes: Uint8Array<ArrayBuffer>; mime: string } | null {
|
||||
const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) return null;
|
||||
const mime = match[1]!;
|
||||
const binary = atob(match[2]!);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return { bytes, mime };
|
||||
}
|
||||
|
||||
async function callUpstream(args: {
|
||||
prompt: string;
|
||||
size: Size;
|
||||
referenceImages: string[];
|
||||
}): Promise<string[]> {
|
||||
const form = new FormData();
|
||||
form.append("model", model);
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
stream: boolean;
|
||||
signal?: AbortSignal;
|
||||
}): Promise<Response> {
|
||||
const { prompt, size, referenceImages, stream, signal } = args;
|
||||
const isEdit = referenceImages.length > 0;
|
||||
const url = `${BASE_URL.replace(/\/+$/, "")}/images/${isEdit ? "edits" : "generations"}`;
|
||||
|
||||
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}`);
|
||||
if (isEdit) {
|
||||
const form = new FormData();
|
||||
form.append("model", MODEL);
|
||||
form.append("prompt", prompt);
|
||||
form.append("size", size);
|
||||
if (stream) {
|
||||
form.append("stream", "true");
|
||||
form.append("partial_images", "2");
|
||||
}
|
||||
const imageField = referenceImages.length > 1 ? "image[]" : "image";
|
||||
for (let i = 0; i < referenceImages.length; i++) {
|
||||
const dataUrl = referenceImages[i];
|
||||
if (!dataUrl) continue;
|
||||
const decoded = decodeDataUrl(dataUrl);
|
||||
if (!decoded) continue;
|
||||
const ext = decoded.mime.split("/")[1] ?? "png";
|
||||
form.append(
|
||||
imageField,
|
||||
new Blob([decoded.bytes], { type: decoded.mime }),
|
||||
`ref-${i}.${ext}`,
|
||||
);
|
||||
}
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${API_KEY}` },
|
||||
body: form,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
const url = `${baseURL.replace(/\/+$/, "")}/images/edits`;
|
||||
const res = await fetch(url, {
|
||||
const body: Record<string, unknown> = { model: MODEL, prompt, size };
|
||||
if (stream) {
|
||||
body.stream = true;
|
||||
body.partial_images = 2;
|
||||
}
|
||||
return fetch(url, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
body: form,
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`Upstream ${res.status}: ${text || res.statusText}`);
|
||||
function parseSSEBlock(raw: string): { event: string; data: string } | null {
|
||||
let eventName = "message";
|
||||
const dataLines: string[] = [];
|
||||
for (const line of raw.split("\n")) {
|
||||
if (line.startsWith(":")) continue;
|
||||
if (line.startsWith("event:")) eventName = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
||||
}
|
||||
if (dataLines.length === 0) return null;
|
||||
return { event: eventName, data: dataLines.join("\n") };
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
async function emitUpstreamBlock(
|
||||
raw: string,
|
||||
stream: SSEStreamingApi,
|
||||
): Promise<void> {
|
||||
const block = parseSSEBlock(raw);
|
||||
if (!block || block.data === "[DONE]") return;
|
||||
let parsed: {
|
||||
type?: string;
|
||||
b64_json?: string;
|
||||
partial_image_index?: number;
|
||||
};
|
||||
try {
|
||||
parsed = JSON.parse(block.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const type = parsed.type ?? block.event;
|
||||
const b64 = parsed.b64_json;
|
||||
if (!b64) return;
|
||||
if (type.endsWith(".partial_image")) {
|
||||
await stream.writeSSE({
|
||||
event: "partial",
|
||||
data: JSON.stringify({
|
||||
image: `data:image/png;base64,${b64}`,
|
||||
index: parsed.partial_image_index ?? 0,
|
||||
}),
|
||||
});
|
||||
} else if (type.endsWith(".completed")) {
|
||||
await stream.writeSSE({
|
||||
event: "final",
|
||||
data: JSON.stringify({ image: `data:image/png;base64,${b64}` }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function forwardUpstreamSSE(
|
||||
upstream: Response,
|
||||
stream: SSEStreamingApi,
|
||||
): Promise<void> {
|
||||
if (!upstream.body) throw new Error("Upstream returned no body");
|
||||
const reader = upstream.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buffer.indexOf("\n\n")) !== -1) {
|
||||
await emitUpstreamBlock(buffer.slice(0, idx), stream);
|
||||
buffer = buffer.slice(idx + 2);
|
||||
}
|
||||
}
|
||||
if (buffer.trim().length > 0) await emitUpstreamBlock(buffer, stream);
|
||||
}
|
||||
|
||||
async function forwardUpstreamJSON(
|
||||
upstream: Response,
|
||||
stream: SSEStreamingApi,
|
||||
): Promise<void> {
|
||||
const data = (await upstream.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);
|
||||
for (const item of data.data ?? []) {
|
||||
if (!item.b64_json) continue;
|
||||
await stream.writeSSE({
|
||||
event: "final",
|
||||
data: JSON.stringify({
|
||||
image: `data:image/png;base64,${item.b64_json}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function isStreamingUnsupportedError(errText: string): boolean {
|
||||
return /\b(stream|partial_images)\b/i.test(errText);
|
||||
}
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.post("/api/generate", async (c) => {
|
||||
const body = (await c.req.json()) as GenerateRequest;
|
||||
const { prompt, size, referenceImages } = body;
|
||||
if (!prompt) {
|
||||
return c.json({ error: "prompt is required" }, 400);
|
||||
}
|
||||
const refs = Array.isArray(referenceImages) ? referenceImages : [];
|
||||
const args = {
|
||||
prompt,
|
||||
size: size ?? ("1024x1024" as Size),
|
||||
referenceImages: refs,
|
||||
};
|
||||
|
||||
return streamSSE(c, async (stream) => {
|
||||
const abort = new AbortController();
|
||||
stream.onAbort(() => abort.abort());
|
||||
|
||||
await stream.write(": connected\n\n");
|
||||
const keepalive = setInterval(() => {
|
||||
stream.write(": keepalive\n\n").catch(() => {});
|
||||
}, 15_000);
|
||||
|
||||
try {
|
||||
let upstream = await callUpstream({
|
||||
...args,
|
||||
stream: true,
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
if (!upstream.ok && upstream.status === 400) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
if (isStreamingUnsupportedError(errText)) {
|
||||
upstream = await callUpstream({
|
||||
...args,
|
||||
stream: false,
|
||||
signal: abort.signal,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Upstream 400: ${errText || upstream.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Upstream ${upstream.status}: ${errText || upstream.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("event-stream")) {
|
||||
await forwardUpstreamSSE(upstream, stream);
|
||||
} else {
|
||||
await forwardUpstreamJSON(upstream, stream);
|
||||
}
|
||||
|
||||
await stream.writeSSE({ event: "done", data: "" });
|
||||
} catch (err) {
|
||||
if (abort.signal.aborted) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[generate] error:", err);
|
||||
await stream.writeSSE({
|
||||
event: "error",
|
||||
data: JSON.stringify({ message }),
|
||||
});
|
||||
} finally {
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: "0.0.0.0",
|
||||
idleTimeout: 255,
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/generate": {
|
||||
POST: async (req) => {
|
||||
try {
|
||||
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(
|
||||
{ error: "baseURL, apiKey, model, prompt are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
baseURL,
|
||||
});
|
||||
|
||||
const { images } = await generateImage({
|
||||
model: provider.imageModel(model),
|
||||
prompt,
|
||||
size: size || "1024x1024",
|
||||
});
|
||||
|
||||
const out = images.map((img) => {
|
||||
const mediaType = (img as { mediaType?: string }).mediaType ?? "image/png";
|
||||
const base64 = (img as { base64?: string }).base64;
|
||||
return base64 ? `data:${mediaType};base64,${base64}` : null;
|
||||
}).filter(Boolean);
|
||||
|
||||
return Response.json({ images: out });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[generate] error:", err);
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
fetch: app.fetch,
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
chromeDevToolsAutomaticWorkspaceFolders: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+2
-7
@@ -9,15 +9,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.14",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@ai-sdk/react": "^3.0.186",
|
||||
"ai": "^6.0.184",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
"@microsoft/fetch-event-source": "^2.0.1",
|
||||
"hono": "^4.12.19"
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user