Files
chorus/portal/web/static/app.js
T

538 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
"use strict";
const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content || "";
const show = element => element?.classList.remove("hidden");
const hide = element => element?.classList.add("hidden");
const maxImages = () => Number(document.querySelector('meta[name="max-images"]')?.content || 8);
const maxPromptBytes = () => Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000);
let toastTimer;
let selectedImages = [];
let dragIndex = null;
let historyLoading = false;
let historyObserver;
let legacyTextRetry = false;
function toast(message) {
const element = document.querySelector("#toast");
const text = document.querySelector("#toast-text");
if (!element || !text) return;
window.clearTimeout(toastTimer);
text.textContent = message;
show(element);
toastTimer = window.setTimeout(() => hide(element), 3200);
}
function renderIcons(root = document) {
window.renderChorusIcons?.(root);
}
function initializeAlpine() {
window.Alpine.data("composer", () => ({
mode: "edit",
generatePressed: "false",
editPressed: "true",
imageClass: "",
setGenerate() {
this.mode = "generate";
this.generatePressed = "true";
this.editPressed = "false";
this.imageClass = "hidden";
document.querySelector("#generation-mode").value = "generate";
handleModeChanged("generate");
},
setEdit() {
this.mode = "edit";
this.generatePressed = "false";
this.editPressed = "true";
this.imageClass = "";
document.querySelector("#generation-mode").value = "edit";
handleModeChanged("edit");
}
}));
}
function handleModeChanged(mode) {
legacyTextRetry = false;
const label = document.querySelector("#generate-button span");
if (label) label.textContent = mode === "generate" ? "开始文生图" : "开始图片编辑";
hide(document.querySelector("#upload-error"));
hide(document.querySelector("#role-rule-error"));
updatePromptPreview();
}
function setMode(mode) {
document.querySelector(`[data-mode="${mode}"]`)?.click();
}
function clientID() {
const value = crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `img-${value}`.slice(0, 64);
}
function iconButton(name, label, action, imageID, disabled = false) {
const button = document.createElement("button");
button.className = "order-button";
if (action === "remove") button.classList.add("remove-image");
button.type = "button";
button.dataset.action = action;
button.dataset.imageId = imageID;
button.disabled = disabled;
button.setAttribute("aria-label", label);
button.title = label;
const icon = document.createElement("i");
icon.className = "icon";
icon.dataset.lucide = name;
button.append(icon);
return button;
}
function roleButton(image, role, label, iconName) {
const button = document.createElement("button");
button.className = "role-button";
button.type = "button";
button.dataset.action = "role";
button.dataset.role = role;
button.dataset.imageId = image.id;
button.setAttribute("aria-pressed", String(image.role === role));
const icon = document.createElement("i");
icon.className = "icon";
icon.dataset.lucide = iconName;
button.append(icon, document.createTextNode(label));
return button;
}
function releaseImage(image) {
if (image.previewURL) URL.revokeObjectURL(image.previewURL);
}
function renderImages() {
const list = document.querySelector("#image-list");
if (!list) return;
list.replaceChildren();
selectedImages.forEach((image, index) => {
const card = document.createElement("article");
card.className = "image-editor";
card.draggable = true;
card.dataset.imageId = image.id;
card.setAttribute("aria-label", `第 ${index + 1} 张输入图片:${image.file.name}`);
const preview = document.createElement("div");
preview.className = "image-thumb";
const picture = document.createElement("img");
picture.src = image.previewURL;
picture.alt = `第 ${index + 1} 张输入图片预览`;
picture.width = 96;
picture.height = 96;
const position = document.createElement("span");
position.className = "image-index";
position.textContent = String(index + 1);
preview.append(picture, position);
const main = document.createElement("div");
main.className = "image-editor-main";
const titleRow = document.createElement("div");
titleRow.className = "image-title-row";
const title = document.createElement("span");
title.className = "image-title";
title.title = image.file.name;
title.textContent = image.file.name;
const hint = document.createElement("span");
hint.className = "drag-hint";
hint.textContent = "可拖动";
titleRow.append(title, hint);
const roles = document.createElement("div");
roles.className = "role-group";
roles.setAttribute("role", "group");
roles.setAttribute("aria-label", `${image.file.name} 的角色`);
roles.append(roleButton(image, "primary", "主体图", "image"), roleButton(image, "reference", "参考图", "file-text"));
const noteLabel = document.createElement("label");
noteLabel.className = "sr-only";
noteLabel.htmlFor = `note-${image.id}`;
noteLabel.textContent = `${image.file.name} 的备注`;
const note = document.createElement("input");
note.className = "input image-note";
note.id = `note-${image.id}`;
note.dataset.imageId = image.id;
note.maxLength = 500;
note.value = image.note;
note.placeholder = "填写图片备注,最多 500 字符";
main.append(titleRow, roles, noteLabel, note);
const controls = document.createElement("div");
controls.className = "order-controls";
controls.append(
iconButton("arrow-up", `上移第 ${index + 1} 张图`, "up", image.id, index === 0),
iconButton("arrow-down", `下移第 ${index + 1} 张图`, "down", image.id, index === selectedImages.length - 1),
iconButton("x", `移除第 ${index + 1} 张图`, "remove", image.id)
);
card.append(preview, main, controls);
list.append(card);
});
const count = document.querySelector("#image-count");
if (count) count.textContent = `${selectedImages.length} / ${maxImages()}`;
renderIcons(list);
updatePromptPreview();
}
function validateFiles(files) {
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
if (files.length > maxImages()) return `一次最多添加 ${maxImages()} 张图片。`;
const invalid = files.find(file => !allowed.has(file.type));
return invalid ? `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。` : "";
}
function ensurePrimary() {
if (selectedImages.length && !selectedImages.some(image => image.role === "primary")) selectedImages[0].role = "primary";
}
function updatePromptPreview() {
const prompt = document.querySelector("#prompt")?.value.trim() || "";
const rule = document.querySelector("#role-rule")?.value.trim() || "";
const mode = document.querySelector("#generation-mode")?.value || "edit";
const ruleSummary = document.querySelector("#rule-summary");
const imageSummary = document.querySelector("#image-summary");
const preview = document.querySelector("#prompt-preview-text");
if (!ruleSummary || !imageSummary || !preview) return;
if (mode === "generate") {
ruleSummary.textContent = "文生图不使用图片角色规则。";
imageSummary.textContent = "文生图不包含输入图片。";
preview.textContent = `管理端默认模板 → ${prompt || "(尚未填写生成要求)"}。`;
return;
}
ruleSummary.textContent = rule ? "非空,将覆盖默认图片角色规则。" : "留空,当前只使用管理端默认规则。";
const primaryCount = selectedImages.filter(image => image.role === "primary").length;
imageSummary.textContent = selectedImages.length ? `按当前顺序附加:${primaryCount} 张主体图,${selectedImages.length - primaryCount} 张参考图。` : "尚未添加输入图片。";
const images = selectedImages.map((image, index) => `第 ${index + 1} 张为${image.role === "primary" ? "主体图" : "参考图"}${image.note ? `,备注:${image.note}` : ""}`).join(";");
preview.textContent = `管理端默认规则 → ${rule || "(未填写用户规则)"} → ${images || "尚未添加输入图片"}。`;
}
function moveImage(id, direction) {
const index = selectedImages.findIndex(image => image.id === id);
const target = direction === "up" ? index - 1 : index + 1;
if (index < 0 || target < 0 || target >= selectedImages.length) return;
[selectedImages[index], selectedImages[target]] = [selectedImages[target], selectedImages[index]];
renderImages();
document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-action="${direction}"]`)?.focus();
toast(direction === "up" ? "图片已上移" : "图片已下移");
}
function setImageRole(id, role) {
const image = selectedImages.find(item => item.id === id);
if (!image) return;
if (role === "primary") {
selectedImages.forEach(item => { item.role = item.id === id ? "primary" : "reference"; });
} else if (image.role === "primary") {
const replacement = selectedImages.find(item => item.id !== id);
if (!replacement) { toast("图片编辑必须保留一张主体图"); return; }
replacement.role = "primary";
image.role = "reference";
}
renderImages();
document.querySelector(`[data-image-id="${CSS.escape(id)}"] [data-role="${role}"]`)?.focus();
toast("图片角色已更新");
}
async function submitGeneration(event) {
event.preventDefault();
const prompt = document.querySelector("#prompt");
const promptError = document.querySelector("#prompt-error");
const uploadError = document.querySelector("#upload-error");
const ruleError = document.querySelector("#role-rule-error");
const button = document.querySelector("#generate-button");
const mode = document.querySelector("#generation-mode").value;
const promptBytes = new TextEncoder().encode(prompt.value).length;
hide(promptError); hide(uploadError); hide(ruleError);
if (!prompt.value.trim() || promptBytes > maxPromptBytes()) {
promptError.textContent = promptBytes > maxPromptBytes() ? `生成要求不能超过 ${maxPromptBytes()} 字节。` : "请输入生成要求。";
show(promptError); prompt.focus(); return;
}
const roleRule = document.querySelector("#role-rule").value;
if (mode === "edit") {
const fileError = selectedImages.length === 0 ? "图片编辑至少需要 1 张图片。" : validateFiles(selectedImages.map(image => image.file));
if (fileError) { uploadError.textContent = fileError; show(uploadError); document.querySelector("#image-upload").focus(); return; }
if (selectedImages.filter(image => image.role === "primary").length !== 1) { uploadError.textContent = "图片编辑必须且只能设置 1 张主体图。"; show(uploadError); document.querySelector("#image-upload").focus(); return; }
if (new TextEncoder().encode(roleRule).length > maxPromptBytes()) { ruleError.textContent = `图片角色规则不能超过 ${maxPromptBytes()} 字节。`; show(ruleError); document.querySelector("#role-rule").focus(); return; }
}
button.disabled = true;
button.querySelector("span").textContent = "正在提交";
const idempotencyKey = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let endpoint = "/api/generations/image";
let headers = { "X-CSRF-Token": csrfToken() };
let body;
if (legacyTextRetry) {
endpoint = "/api/generations/text";
headers["Content-Type"] = "application/json";
body = JSON.stringify({ idempotency_key: idempotencyKey, prompt: prompt.value });
} else {
body = new FormData();
body.set("idempotency_key", idempotencyKey);
body.set("prompt", prompt.value);
const editing = mode === "edit";
body.set("metadata", JSON.stringify({ capability: editing ? "image_edit" : "image_generate", role_rule: editing ? roleRule : "", images: editing ? selectedImages.map((image, position) => ({ client_id: image.id, position, role: image.role, note: image.note })) : [] }));
if (editing) selectedImages.forEach(image => body.append(`files[${image.id}]`, image.file, image.file.name));
}
try {
const response = await fetch(endpoint, { method: "POST", headers, body });
const payload = await response.json().catch(() => ({}));
if (response.status === 401) { showAuthExpired(); return; }
if (!response.ok) {
const error = payload.error || {};
const imageErrorCodes = new Set(["invalid_metadata", "invalid_capability", "invalid_client_id", "invalid_primary_count"]);
if (error.file || String(error.code || "").includes("image") || imageErrorCodes.has(error.code)) {
uploadError.textContent = error.file ? `${error.file.name}:图片未通过校验,请检查格式和大小。` : (error.message || "图片信息不符合要求,请检查角色和顺序。");
show(uploadError); document.querySelector("#image-upload").focus();
} else if (error.code === "invalid_role_rule") {
ruleError.textContent = "图片角色规则不符合要求。"; show(ruleError); document.querySelector("#role-rule").focus();
} else {
promptError.textContent = error.message || "提交失败,请检查输入后重试。"; show(promptError); prompt.focus();
}
return;
}
window.location.assign(`/generations/${payload.id}`);
} catch (_) {
toast("提交失败,请检查网络后重试");
} finally {
button.disabled = false;
button.querySelector("span").textContent = mode === "generate" ? "开始文生图" : "开始图片编辑";
}
}
async function retryGeneration(button) {
const section = button.closest("#result-section");
const data = section?.querySelector(".retry-data");
const prompt = document.querySelector("#prompt");
if (!data || !prompt) return;
prompt.value = data.dataset.prompt || "";
prompt.dispatchEvent(new Event("input"));
selectedImages.forEach(releaseImage);
selectedImages = [];
const inputs = Array.from(section.querySelectorAll(".retry-input"));
if (data.dataset.kind === "text") { setMode("generate"); legacyTextRetry = true; } else { setMode(inputs.length ? "edit" : "generate"); }
document.querySelector("#role-rule").value = data.dataset.roleRule || "";
if (inputs.length) {
button.disabled = true;
try {
for (const input of inputs) {
const response = await fetch(input.dataset.inputUrl);
if (!response.ok) throw new Error("input fetch failed");
const blob = await response.blob();
const file = new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type });
selectedImages.push({ id: clientID(), file, role: input.dataset.inputRole || "reference", note: input.dataset.inputNote || "", previewURL: URL.createObjectURL(file) });
}
ensurePrimary(); renderImages();
} catch (_) {
selectedImages.forEach(releaseImage); selectedImages = []; renderImages(); toast("原图读取失败,请重新选择图片");
} finally { button.disabled = false; }
}
updatePromptPreview();
document.querySelector("#composer-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
prompt.focus();
}
function showAuthExpired() {
show(document.querySelector("#auth-modal"));
document.querySelector("#relogin-button")?.focus();
}
function showPollingError(element) {
if (!element || element.querySelector(".polling-error")) return;
const notice = document.createElement("div");
notice.className = "notice polling-error";
notice.setAttribute("role", "alert");
notice.textContent = "状态更新失败。当前内容仍然保留,正在等待网络恢复。";
element.querySelector(".result-body")?.prepend(notice);
}
function statusText(status) {
return { pending: "排队中", running: "生成中", succeeded: "已完成", failed: "失败" }[status] || "未知";
}
function relativeTime(value) {
const elapsed = Math.max(0, Date.now() - new Date(value).getTime());
if (elapsed < 60000) return "刚刚";
if (elapsed < 3600000) return `${Math.floor(elapsed / 60000)} 分钟前`;
if (elapsed < 86400000) return `${Math.floor(elapsed / 3600000)} 小时前`;
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).format(new Date(value));
}
function historyItem(row) {
const item = document.createElement("li");
item.dataset.historySearch = `${row.user_prompt || ""} ${row.kind || ""}`;
const link = document.createElement("a");
link.className = "history-item";
link.href = `/generations/${row.id}`;
const iconWrap = document.createElement("span");
iconWrap.className = "history-icon";
const icon = document.createElement("i");
icon.className = "icon";
icon.dataset.lucide = row.kind === "image" ? "image" : "file-text";
iconWrap.append(icon);
const copy = document.createElement("span");
copy.className = "history-copy";
const title = document.createElement("span");
title.className = "history-title";
title.textContent = row.user_prompt || `任务 #${row.id}`;
title.title = title.textContent;
const meta = document.createElement("span");
meta.className = "history-meta";
const dot = document.createElement("span");
dot.className = `status-dot ${row.status === "succeeded" ? "success" : row.status || ""}`;
meta.append(dot, document.createTextNode(`${statusText(row.status)} · ${row.kind === "image" ? "图片" : "文本"} · ${relativeTime(row.created_at)}`));
copy.append(title, meta);
link.append(iconWrap, copy);
item.append(link);
return item;
}
function applyHistorySearch() {
const query = document.querySelector("#history-search")?.value.trim().toLowerCase() || "";
document.querySelectorAll("[data-history-search]").forEach(item => item.classList.toggle("hidden", !item.dataset.historySearch.toLowerCase().includes(query)));
}
async function loadHistory() {
const controls = document.querySelector("#history-controls");
const button = document.querySelector("#load-more-history");
const list = document.querySelector("#history-list");
if (!controls || !button || !list || historyLoading || controls.dataset.hasMore !== "true") return;
historyLoading = true;
hide(document.querySelector("#history-error"));
button.disabled = true;
button.querySelector("span").textContent = "正在加载";
try {
const response = await fetch(`/api/generations?limit=${encodeURIComponent(controls.dataset.limit || "20")}&cursor=${encodeURIComponent(controls.dataset.cursor || "")}`, { headers: { "Accept": "application/json" } });
if (response.status === 401) { showAuthExpired(); return; }
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.error?.code || "history_failed");
const sentinel = document.querySelector("#history-sentinel");
(payload.items || []).forEach(row => list.insertBefore(historyItem(row), sentinel));
controls.dataset.cursor = payload.next_cursor || "";
controls.dataset.hasMore = String(Boolean(payload.has_more));
if (payload.has_more) show(button); else { hide(button); show(document.querySelector("#history-end")); }
applyHistorySearch(); renderIcons(list);
} catch (_) {
show(document.querySelector("#history-error")); show(button);
} finally {
historyLoading = false;
button.disabled = false;
button.querySelector("span").textContent = "加载更多历史";
}
}
function initializeHistory() {
const list = document.querySelector("#history-list");
const sentinel = document.querySelector("#history-sentinel");
document.querySelector("#history-search")?.addEventListener("input", applyHistorySearch);
document.querySelector("#load-more-history")?.addEventListener("click", loadHistory);
if (!list || !sentinel || typeof IntersectionObserver === "undefined") return;
historyObserver = new IntersectionObserver(entries => {
if (entries.some(entry => entry.isIntersecting)) loadHistory();
}, { root: list, rootMargin: "0px 0px 80px 0px" });
historyObserver.observe(sentinel);
}
function initializeLogin() {
const form = document.querySelector("#login-form");
const toggle = document.querySelector("#toggle-password");
toggle?.addEventListener("click", () => {
const input = document.querySelector("#password");
const showing = input.type === "text";
input.type = showing ? "password" : "text";
toggle.setAttribute("aria-label", showing ? "显示密码" : "隐藏密码");
toggle.setAttribute("title", showing ? "显示密码" : "隐藏密码");
toggle.querySelector("svg")?.remove(); toggle.querySelector("i")?.remove();
const icon = document.createElement("i"); icon.className = "icon"; icon.dataset.lucide = showing ? "eye" : "eye-off"; toggle.append(icon); renderIcons(toggle);
});
form?.addEventListener("submit", async event => {
event.preventDefault();
const email = document.querySelector("#email"); const password = document.querySelector("#password");
const error = document.querySelector("#login-error"); const emailError = document.querySelector("#email-error");
hide(error); hide(emailError);
if (!email.validity.valid) { show(emailError); email.focus(); return; }
if (!password.value) { show(error); password.focus(); return; }
const button = document.querySelector("#login-button"); button.disabled = true; button.querySelector("span").textContent = "登录中";
try {
const response = await fetch("/api/session/login", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken() }, body: JSON.stringify({ email: email.value, password: password.value }) });
if (!response.ok) { show(error); password.focus(); return; }
const requested = document.body.dataset.returnTo || "/";
window.location.assign(requested.startsWith("/") && !requested.startsWith("//") ? requested : "/");
} catch (_) { error.textContent = "登录请求失败,请检查网络后重试。"; show(error); }
finally { button.disabled = false; button.querySelector("span").textContent = "登录"; }
});
}
function initializeApp() {
document.querySelector("#prompt")?.addEventListener("input", event => {
document.querySelector("#prompt-count").textContent = String(new TextEncoder().encode(event.target.value).length);
hide(document.querySelector("#prompt-error")); updatePromptPreview();
});
document.querySelector("#role-rule")?.addEventListener("input", () => { hide(document.querySelector("#role-rule-error")); updatePromptPreview(); });
document.querySelector("#image-upload")?.addEventListener("change", event => {
const added = Array.from(event.target.files || []);
const error = validateFiles(selectedImages.map(image => image.file).concat(added));
const output = document.querySelector("#upload-error");
if (error) { output.textContent = error; show(output); event.target.value = ""; event.target.focus(); return; }
hide(output);
added.forEach(file => selectedImages.push({ id: clientID(), file, role: selectedImages.length ? "reference" : "primary", note: "", previewURL: URL.createObjectURL(file) }));
event.target.value = ""; ensurePrimary(); renderImages();
});
const imageList = document.querySelector("#image-list");
imageList?.addEventListener("input", event => {
const input = event.target.closest(".image-note"); if (!input) return;
const image = selectedImages.find(item => item.id === input.dataset.imageId); if (image) image.note = input.value;
updatePromptPreview();
});
imageList?.addEventListener("click", event => {
const button = event.target.closest("button[data-action]"); if (!button) return;
const id = button.dataset.imageId;
if (button.dataset.action === "role") { setImageRole(id, button.dataset.role); return; }
if (button.dataset.action === "up" || button.dataset.action === "down") { moveImage(id, button.dataset.action); return; }
if (button.dataset.action === "remove") {
if (selectedImages.length === 1) { toast("图片编辑至少保留一张图片"); return; }
const index = selectedImages.findIndex(image => image.id === id); if (index < 0) return;
releaseImage(selectedImages[index]); selectedImages.splice(index, 1); ensurePrimary(); renderImages(); toast("图片已移除");
}
});
imageList?.addEventListener("dragstart", event => {
const card = event.target.closest(".image-editor"); if (!card) return;
dragIndex = selectedImages.findIndex(image => image.id === card.dataset.imageId); card.classList.add("dragging"); event.dataTransfer.effectAllowed = "move";
});
imageList?.addEventListener("dragover", event => { const card = event.target.closest(".image-editor"); if (card) { event.preventDefault(); card.classList.add("drag-over"); } });
imageList?.addEventListener("dragleave", event => event.target.closest(".image-editor")?.classList.remove("drag-over"));
imageList?.addEventListener("drop", event => {
const card = event.target.closest(".image-editor"); if (!card || dragIndex === null) return;
event.preventDefault(); const target = selectedImages.findIndex(image => image.id === card.dataset.imageId);
if (target >= 0 && target !== dragIndex) { const [moved] = selectedImages.splice(dragIndex, 1); selectedImages.splice(target, 0, moved); renderImages(); toast("图片顺序已调整"); }
dragIndex = null;
});
imageList?.addEventListener("dragend", () => { dragIndex = null; imageList.querySelectorAll(".image-editor").forEach(card => card.classList.remove("dragging", "drag-over")); });
document.querySelector("#composer-form")?.addEventListener("submit", submitGeneration);
initializeHistory();
document.querySelector("#logout-button")?.addEventListener("click", async () => { await fetch("/api/session/logout", { method: "POST", headers: { "X-CSRF-Token": csrfToken() } }).catch(() => {}); window.location.assign("/login"); });
document.addEventListener("click", async event => {
const retry = event.target.closest(".retry-button"); if (retry) { await retryGeneration(retry); return; }
const copy = event.target.closest(".copy-button"); if (copy) { const text = copy.closest("#result-section")?.querySelector(".text-result")?.innerText || ""; try { await navigator.clipboard.writeText(text); toast("文本已复制"); } catch (_) { toast("浏览器未允许复制,请手动选择文本"); } }
});
document.querySelector("#dismiss-auth")?.addEventListener("click", () => hide(document.querySelector("#auth-modal")));
document.querySelector("#relogin-button")?.addEventListener("click", () => window.location.assign(`/login?return_to=${encodeURIComponent(window.location.pathname)}`));
document.addEventListener("keydown", event => { if (event.key === "Escape") { hide(document.querySelector("#auth-modal")); document.querySelector("#main-content")?.focus(); } });
document.body.addEventListener("htmx:afterSwap", event => renderIcons(event.detail.target));
const handlePollingError = event => {
const status = event.detail.xhr?.status || 0; const element = event.detail.elt;
if (status === 401) { showAuthExpired(); return; }
showPollingError(element); window.setTimeout(() => window.htmx?.trigger(element, "retryPoll"), 3000);
};
document.body.addEventListener("htmx:responseError", handlePollingError);
document.body.addEventListener("htmx:sendError", handlePollingError);
updatePromptPreview();
}
document.addEventListener("alpine:init", initializeAlpine);
document.addEventListener("DOMContentLoaded", () => {
if (document.body.dataset.page === "login") initializeLogin();
if (document.body.dataset.page === "app") initializeApp();
renderIcons();
});
window.addEventListener("beforeunload", () => { selectedImages.forEach(releaseImage); historyObserver?.disconnect(); });
})();