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

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();
});
})();