feat: stream gpt-image generation via SSE with keepalive

- /api/generate now responds with text/event-stream end-to-end
- forwards upstream image_generation.* / image_edit.* partial+completed events
- 20s keepalive comments survive Cloudflare's 120s proxy-read timeout
- falls back to non-streaming when upstream rejects stream/partial_images
- drops @ai-sdk/openai-compatible, @ai-sdk/react, ai (unused)
- frontend consumes SSE via fetch+ReadableStream, shows progressive preview
This commit is contained in:
2026-05-18 22:44:31 +08:00
parent 54f13c1097
commit 5af05b2141
5 changed files with 327 additions and 170 deletions
+56 -12
View File
@@ -335,31 +335,75 @@
status.textContent = "Generating...";
result.innerHTML = "";
let preview = null;
const ensurePreview = () => {
if (!preview) {
preview = document.createElement("img");
preview.alt = body.prompt;
result.appendChild(preview);
}
return preview;
};
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;
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("event-stream")) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || ("HTTP " + res.status));
}
if (!res.body) throw new Error("No response body");
for (const src of data.images) {
const img = document.createElement("img");
img.src = src;
img.alt = body.prompt;
result.appendChild(img);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
const handleBlock = (raw) => {
let event = "message";
const dataLines = [];
for (const line of raw.split("\n")) {
if (line.startsWith(":")) continue;
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
}
if (dataLines.length === 0) return;
let payload;
try {
payload = JSON.parse(dataLines.join("\n"));
} catch {
return;
}
if (event === "partial") {
ensurePreview().src = payload.image;
status.textContent = "Receiving preview " + ((payload.index ?? 0) + 1) + "...";
} else if (event === "final") {
ensurePreview().src = payload.image;
status.textContent = "Done.";
} else if (event === "error") {
throw new Error(payload.message || "Unknown error");
}
};
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
handleBlock(buffer.slice(0, idx));
buffer = buffer.slice(idx + 2);
}
}
status.textContent = "Done.";
if (buffer.trim()) handleBlock(buffer);
} catch (err) {
status.textContent = "Error: " + (err.message || String(err));
status.className = "status error";
if (preview && !preview.src) preview.remove();
} finally {
btn.disabled = false;
}