600f574b5c
Server (index.ts): - migrate to Hono streamSSE, mounted under Bun.serve fetch handler - idleTimeout: 255 fixes the silent Bun 10s timeout that killed SSE responses before the first keepalive could fire (root cause of the empty EventStream tab) - stream.onAbort wires an AbortController into upstream fetch signal - 15s : keepalive raw SSE comments for Cloudflare 120s headroom - decodeDataUrl returns Uint8Array<ArrayBuffer> for DOM Blob types - chromeDevToolsAutomaticWorkspaceFolders: false silences the 'Unable to add filesystem' warning in sandboxed browsers Client (client.ts new): - extracted from inline <script> — Bun only bundles external script src, not inline module imports, so node_modules bare specifiers must live in their own file - @microsoft/fetch-event-source replaces hand-rolled fetch + ReadableStream parsing; supports POST + body + signal natively - client aborts the loop on event:done so fetchEventSource doesn't retry Build: - drop unused react/react-dom/@types/react* deps (KISS) - add 'DOM', 'DOM.Iterable' to tsconfig lib for client.ts
148 lines
4.3 KiB
TypeScript
148 lines
4.3 KiB
TypeScript
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
|
|
|
const byId = <T extends HTMLElement>(id: string) =>
|
|
document.getElementById(id) as T;
|
|
const input = (id: string) => byId<HTMLInputElement>(id);
|
|
const select = (id: string) => byId<HTMLSelectElement>(id);
|
|
const textarea = (id: string) => byId<HTMLTextAreaElement>(id);
|
|
|
|
const persistedFields = ["baseURL", "apiKey", "model", "size", "prompt"] as const;
|
|
for (const f of persistedFields) {
|
|
const el = byId<HTMLInputElement | 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);
|
|
});
|
|
}
|
|
|
|
input("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 = {
|
|
baseURL: input("baseURL").value.trim(),
|
|
apiKey: input("apiKey").value.trim(),
|
|
model: input("model").value.trim(),
|
|
size: select("size").value,
|
|
prompt: textarea("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 = "";
|
|
|
|
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;
|
|
}
|
|
});
|