Compare commits
8 Commits
19347e0d80
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e6b4dc7fa | |||
| d5bbc14c8d | |||
| 4ef2f1ba2b | |||
| 600f574b5c | |||
| 5af05b2141 | |||
| 54f13c1097 | |||
| d3be31d038 | |||
| 0123661faf |
@@ -0,0 +1,3 @@
|
|||||||
|
BASE_URL=https://api.openai.com/v1
|
||||||
|
API_KEY=sk-...
|
||||||
|
MODEL=gpt-image-2
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
- Bun, not Node. See `CLAUDE.md` for the full Bun-vs-Node cheatsheet
|
||||||
|
(prefer `Bun.serve`, `Bun.file`, `bun:test`, `Bun.sql`, etc.). Do not add
|
||||||
|
`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`
|
||||||
|
- Dev (HMR): `bun run dev` → `bun --hot ./index.ts`
|
||||||
|
- Start: `bun run start` → `bun ./index.ts`
|
||||||
|
- Typecheck: no script defined. Use `bunx tsc --noEmit` (tsconfig already sets
|
||||||
|
`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`), 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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
`tsconfig.json` is strict with bundler-mode resolution:
|
||||||
|
- `strict`, `noUncheckedIndexedAccess`, `noImplicitOverride`,
|
||||||
|
`noFallthroughCasesInSwitch` are on — array/object index access is
|
||||||
|
`T | undefined` and must be narrowed.
|
||||||
|
- `verbatimModuleSyntax` + `moduleDetection: "force"` — use `import type` for
|
||||||
|
type-only imports; every file is a module.
|
||||||
|
- `allowImportingTsExtensions` is on; `.ts` extensions in imports are fine.
|
||||||
|
- `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
|
||||||
|
|
||||||
|
- 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",
|
"name": "ai-playground",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@ai-sdk/react": "^3.0.186",
|
"hono": "^4.12.19",
|
||||||
"ai": "^6.0.184",
|
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="],
|
||||||
|
|
||||||
"@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=="],
|
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
|
"@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/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=="],
|
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
"hono": ["hono@4.12.19", "", {}, "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ=="],
|
||||||
|
|
||||||
"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=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"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=="],
|
"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;
|
||||||
|
}
|
||||||
|
});
|
||||||
+82
-99
@@ -113,6 +113,59 @@
|
|||||||
.status.error {
|
.status.error {
|
||||||
color: var(--danger);
|
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 {
|
.result {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -143,33 +196,22 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="grid">
|
<div class="row">
|
||||||
<div class="row">
|
<label for="size">Size</label>
|
||||||
<label for="baseURL">Base URL</label>
|
<select id="size">
|
||||||
<input
|
<option value="1024x1024">1024x1024 (1:1 square)</option>
|
||||||
id="baseURL"
|
<option value="1536x1024">1536x1024 (3:2 landscape)</option>
|
||||||
type="text"
|
<option value="1024x1536">1024x1536 (2:3 portrait)</option>
|
||||||
placeholder="https://api.openai.com/v1"
|
<option value="1536x864">1536x864 (16:9 landscape)</option>
|
||||||
/>
|
<option value="864x1536">864x1536 (9:16 portrait)</option>
|
||||||
</div>
|
<option value="2048x1152">2048x1152 (16:9 2K landscape)</option>
|
||||||
<div class="row">
|
<option value="1152x2048">1152x2048 (9:16 2K portrait)</option>
|
||||||
<label for="apiKey">API Key</label>
|
<option value="2560x1440">2560x1440 (16:9 QHD — experimental boundary)</option>
|
||||||
<input id="apiKey" type="password" placeholder="sk-..." />
|
</select>
|
||||||
</div>
|
<small class="hint">
|
||||||
<div class="row">
|
Sizes above 2560x1440 are experimental for <code>gpt-image-2</code>.
|
||||||
<label for="model">Model</label>
|
Both edges must be a multiple of 16; max edge < 3840.
|
||||||
<input id="model" type="text" placeholder="dall-e-3 / gpt-image-1 / flux-..." />
|
</small>
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label for="size">Size</label>
|
|
||||||
<select id="size">
|
|
||||||
<option value="1024x1024">1024x1024</option>
|
|
||||||
<option value="512x512">512x512</option>
|
|
||||||
<option value="768x768">768x768</option>
|
|
||||||
<option value="1024x1792">1024x1792 (portrait)</option>
|
|
||||||
<option value="1792x1024">1792x1024 (landscape)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="prompt">Prompt</label>
|
<label for="prompt">Prompt</label>
|
||||||
@@ -178,88 +220,29 @@
|
|||||||
placeholder="A futuristic cityscape at sunset, cinematic lighting"
|
placeholder="A futuristic cityscape at sunset, cinematic lighting"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<label for="refImages">Reference images (optional)</label>
|
||||||
|
<input id="refImages" type="file" accept="image/*" multiple />
|
||||||
|
<div id="refPreview" class="ref-preview"></div>
|
||||||
|
<small class="hint">
|
||||||
|
Provide one or more references to keep style consistent. When set,
|
||||||
|
the request is sent to <code>/v1/images/edits</code> (gpt-image series only).
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button id="generate">Generate</button>
|
<button id="generate">Generate</button>
|
||||||
<span id="status" class="status"></span>
|
<span id="status" class="status"></span>
|
||||||
</div>
|
</div>
|
||||||
<details>
|
<details>
|
||||||
<summary>Settings are stored in your browser's localStorage</summary>
|
<summary>Size and prompt are saved to your browser's localStorage</summary>
|
||||||
Base URL, API Key, model and size are saved locally. They are sent to
|
Base URL, API key and model live in the server's <code>.env</code> —
|
||||||
the local Bun server only when you click Generate.
|
they are never sent from the browser.
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="result" class="result"></div>
|
<div id="result" class="result"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script type="module" src="./client.ts"></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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$("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(),
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,59 +1,271 @@
|
|||||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
import { Hono } from "hono";
|
||||||
import { generateImage } from "ai";
|
import { streamSSE } from "hono/streaming";
|
||||||
|
import type { SSEStreamingApi } from "hono/streaming";
|
||||||
import index from "./index.html";
|
import index from "./index.html";
|
||||||
|
|
||||||
|
type Size = `${number}x${number}`;
|
||||||
|
|
||||||
|
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[];
|
||||||
|
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"}`;
|
||||||
|
|
||||||
|
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 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 ${API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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") };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }>;
|
||||||
|
};
|
||||||
|
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({
|
const server = Bun.serve({
|
||||||
hostname: "0.0.0.0",
|
hostname: "0.0.0.0",
|
||||||
|
idleTimeout: 255,
|
||||||
routes: {
|
routes: {
|
||||||
"/": index,
|
"/": index,
|
||||||
"/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}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!baseURL || !apiKey || !model || !prompt) {
|
|
||||||
return Response.json(
|
|
||||||
{ error: "baseURL, apiKey, model, prompt are required" },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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: {
|
development: {
|
||||||
hmr: true,
|
hmr: true,
|
||||||
console: true,
|
console: true,
|
||||||
|
chromeDevToolsAutomaticWorkspaceFolders: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-7
@@ -9,15 +9,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.14",
|
"@types/bun": "^1.3.14",
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
"@microsoft/fetch-event-source": "^2.0.1",
|
||||||
"@ai-sdk/react": "^3.0.186",
|
"hono": "^4.12.19"
|
||||||
"ai": "^6.0.184",
|
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user