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:
+56
-12
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user