305 lines
14 KiB
JavaScript
305 lines
14 KiB
JavaScript
(() => {
|
|
"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");
|
|
let toastTimer;
|
|
let selectedFiles = [];
|
|
let previewURLs = [];
|
|
|
|
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: "image",
|
|
textPressed: "false",
|
|
imagePressed: "true",
|
|
imageClass: "",
|
|
setText() {
|
|
this.mode = "text";
|
|
this.textPressed = "true";
|
|
this.imagePressed = "false";
|
|
this.imageClass = "hidden";
|
|
document.querySelector("#generation-kind").value = "text";
|
|
},
|
|
setImage() {
|
|
this.mode = "image";
|
|
this.textPressed = "false";
|
|
this.imagePressed = "true";
|
|
this.imageClass = "";
|
|
document.querySelector("#generation-kind").value = "image";
|
|
}
|
|
}));
|
|
}
|
|
|
|
function renderUploads() {
|
|
const list = document.querySelector("#upload-list");
|
|
if (!list) return;
|
|
previewURLs.forEach(URL.revokeObjectURL);
|
|
previewURLs = [];
|
|
list.replaceChildren();
|
|
selectedFiles.forEach((file, index) => {
|
|
const row = document.createElement("div");
|
|
row.className = "upload-item";
|
|
const image = document.createElement("img");
|
|
image.className = "upload-thumb";
|
|
image.width = 48;
|
|
image.height = 48;
|
|
image.alt = index === 0 ? "主图缩略图" : `第 ${index + 1} 张参考图缩略图`;
|
|
image.src = URL.createObjectURL(file);
|
|
previewURLs.push(image.src);
|
|
const copy = document.createElement("span");
|
|
const name = document.createElement("span");
|
|
name.className = "upload-name";
|
|
name.textContent = file.name;
|
|
const role = document.createElement("span");
|
|
role.className = "upload-role";
|
|
role.textContent = index === 0 ? "主图 · 第 1 张" : `参考图 · 第 ${index + 1} 张`;
|
|
copy.append(name, role);
|
|
const remove = document.createElement("button");
|
|
remove.className = "icon-button remove-upload";
|
|
remove.type = "button";
|
|
remove.dataset.index = String(index);
|
|
remove.setAttribute("aria-label", `移除 ${file.name}`);
|
|
remove.title = "移除图片";
|
|
const icon = document.createElement("i");
|
|
icon.className = "icon";
|
|
icon.dataset.lucide = "x";
|
|
remove.append(icon);
|
|
row.append(image, copy, remove);
|
|
list.append(row);
|
|
});
|
|
renderIcons(list);
|
|
}
|
|
|
|
function validateFiles(files) {
|
|
const allowed = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
const maxImages = Number(document.querySelector('meta[name="max-images"]')?.content || 8);
|
|
if (files.length > maxImages) return `一次最多添加 ${maxImages} 张图片。`;
|
|
const invalid = files.find((file) => !allowed.has(file.type));
|
|
if (invalid) return `${invalid.name}:格式不支持,请改用 JPG、PNG 或 WebP。`;
|
|
return "";
|
|
}
|
|
|
|
function setMode(mode) {
|
|
document.querySelector(`[data-mode="${mode}"]`)?.click();
|
|
}
|
|
|
|
async function submitGeneration(event) {
|
|
event.preventDefault();
|
|
const prompt = document.querySelector("#prompt");
|
|
const promptError = document.querySelector("#prompt-error");
|
|
const uploadError = document.querySelector("#upload-error");
|
|
const button = document.querySelector("#generate-button");
|
|
const mode = document.querySelector("#generation-kind").value;
|
|
const promptBytes = new TextEncoder().encode(prompt.value).length;
|
|
const maxPromptBytes = Number(document.querySelector('meta[name="max-prompt-bytes"]')?.content || 8000);
|
|
hide(promptError);
|
|
hide(uploadError);
|
|
if (!prompt.value.trim() || promptBytes > maxPromptBytes) {
|
|
promptError.textContent = promptBytes > maxPromptBytes ? `生成要求不能超过 ${maxPromptBytes} 字节。` : "请输入生成要求。";
|
|
show(promptError);
|
|
prompt.focus();
|
|
return;
|
|
}
|
|
if (mode === "image") {
|
|
const fileError = selectedFiles.length === 0 ? "请至少添加 1 张图片。" : validateFiles(selectedFiles);
|
|
if (fileError) {
|
|
uploadError.textContent = fileError;
|
|
show(uploadError);
|
|
document.querySelector("#image-upload").focus();
|
|
return;
|
|
}
|
|
}
|
|
button.disabled = true;
|
|
button.querySelector("span").textContent = "提交中";
|
|
const idempotencyKey = crypto.randomUUID?.() || `web-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
let body;
|
|
let headers = { "X-CSRF-Token": csrfToken() };
|
|
let endpoint = "/api/generations/text";
|
|
if (mode === "text") {
|
|
headers["Content-Type"] = "application/json";
|
|
body = JSON.stringify({ idempotency_key: idempotencyKey, prompt: prompt.value });
|
|
} else {
|
|
endpoint = "/api/generations/image";
|
|
body = new FormData();
|
|
body.set("idempotency_key", idempotencyKey);
|
|
body.set("prompt", prompt.value);
|
|
selectedFiles.forEach((file) => body.append("images", file, 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 || {};
|
|
if (error.file) {
|
|
uploadError.textContent = `${error.file.name}:图片未通过校验,请检查格式和大小。`;
|
|
show(uploadError);
|
|
document.querySelector("#image-upload").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 = "开始生成";
|
|
}
|
|
}
|
|
|
|
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"));
|
|
setMode(data.dataset.kind || "text");
|
|
selectedFiles = [];
|
|
if (data.dataset.kind === "image") {
|
|
button.disabled = true;
|
|
try {
|
|
const inputs = Array.from(section.querySelectorAll(".retry-input"));
|
|
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();
|
|
selectedFiles.push(new File([blob], input.dataset.inputName || "image", { type: input.dataset.inputMime || blob.type }));
|
|
}
|
|
renderUploads();
|
|
} catch (_) {
|
|
toast("原图读取失败,请重新选择图片");
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
}
|
|
document.querySelector("#composer-title")?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
prompt.focus();
|
|
}
|
|
|
|
function showAuthExpired() {
|
|
const modal = document.querySelector("#auth-modal");
|
|
show(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 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"));
|
|
});
|
|
document.querySelector("#image-upload")?.addEventListener("change", (event) => {
|
|
const added = Array.from(event.target.files || []);
|
|
const candidate = selectedFiles.concat(added);
|
|
const error = validateFiles(candidate);
|
|
const output = document.querySelector("#upload-error");
|
|
if (error) { output.textContent=error;show(output);event.target.focus();return; }
|
|
hide(output);selectedFiles=candidate;event.target.value="";renderUploads();
|
|
});
|
|
document.querySelector("#upload-list")?.addEventListener("click", (event) => {
|
|
const button = event.target.closest(".remove-upload");
|
|
if (!button) return;
|
|
selectedFiles.splice(Number(button.dataset.index),1);renderUploads();toast("图片已移除");
|
|
});
|
|
document.querySelector("#composer-form")?.addEventListener("submit", submitGeneration);
|
|
document.querySelector("#history-search")?.addEventListener("input", (event) => {
|
|
const query=event.target.value.trim().toLowerCase();
|
|
document.querySelectorAll("[data-history-search]").forEach((item)=>item.classList.toggle("hidden",!item.dataset.historySearch.toLowerCase().includes(query)));
|
|
});
|
|
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);
|
|
}
|
|
|
|
document.addEventListener("alpine:init", initializeAlpine);
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
if (document.body.dataset.page === "login") initializeLogin();
|
|
if (document.body.dataset.page === "app") initializeApp();
|
|
renderIcons();
|
|
});
|
|
})();
|