// ==========================================================================
// 📝 TODO (Architecture / Enhancements)
// ==========================================================================
// 1. [Prompt Presets] 实现基于 ID 的 Prompt 远程动态加载
// - 方案:将超长 Prompt 抽离为集中的 JSON 文件(如: prompt-assets/presets.json)。
// - 逻辑:图库 (Gallery) 跳转时仅传递 ?id=xxx,前端 (Studio) 接收到 ID 后,跨域 fetch 该 JSON 并自动填充 Prompt 输入框。
// - 优点:彻底解决 URL 长度限制,实现前后端数据彻底解耦,方便统一维护。
// commit@70f742a386b9cbbfd6df774b19d727485ef5446f
// ==========================================================================
// 🛡️ Dynamic API Base URL
// ==========================================================================
const hostname = window.location.hostname;
let API_BASE_URL = "";
// 1. 如果在 GUI 预览环境(哪怕带 .static 后缀)
if (hostname.includes("universal-lora-studio-gui")) {
API_BASE_URL = "https://waveshaker-universal-lora-studio-test.hf.space";
}
// 2. 如果在本地开发环境
else if (hostname === "localhost" || hostname === "127.0.0.1") {
API_BASE_URL = "https://waveshaker-universal-lora-studio-test.hf.space";
}
// 3. 如果在测试环境(test Space)
else if (hostname.includes("universal-lora-studio-test")) {
API_BASE_URL = ""; // 同源,走相对路径
}
// 4. 如果在线上环境(prod Space)
else if (hostname.includes("universal-lora-studio.hf.space") || hostname.includes("universal-lora-studio.static")) {
API_BASE_URL = ""; // 同源,走相对路径
}
// ==========================================================================
// 🔐 ZeroGPU Token Handshake
// ==========================================================================
const ZEROGPU_HEADERS_MSG = "supports-zerogpu-headers";
// 监听 HF 父窗口发来的握手信号
window.addEventListener("message", (event) => {
if (event.data === ZEROGPU_HEADERS_MSG) {
window.supports_zerogpu_headers = true;
}
});
// 向父窗口声明支持,触发后续的 token 索要流程
(function announceZeroGpuSupport() {
const hostname = window.location.hostname;
if (!hostname.endsWith(".hf.space") && !hostname.includes(".dev.")) {
return; // 非 HF 环境,不执行
}
const origin = hostname.includes(".dev.")
? `https://moon-${hostname.split(".")[1]}.dev.spaces.huggingface.tech`
: "https://huggingface.co";
try {
window.parent.postMessage(ZEROGPU_HEADERS_MSG, origin);
} catch (e) {
console.warn("ZeroGPU handshake failed:", e);
}
})();
// 向父窗口索要 x-ip-token(带 2 秒超时兜底)
async function requestZeroGpuToken() {
if (!window.supports_zerogpu_headers) return null;
const hostname = window.location.hostname;
if (!hostname.endsWith(".hf.space") && !hostname.includes(".dev.")) return null;
const origin = hostname.includes(".dev.")
? `https://moon-${hostname.split(".")[1]}.dev.spaces.huggingface.tech`
: "https://huggingface.co";
try {
return await new Promise((resolve) => {
const channel = new MessageChannel();
const timeout = setTimeout(() => {
channel.port1.close();
resolve(null);
}, 2000);
channel.port1.onmessage = ({ data }) => {
clearTimeout(timeout);
resolve(data || null);
};
window.parent.postMessage("zerogpu-headers", origin, [channel.port2]);
});
} catch {
return null;
}
}
// ==========================================================================
// 🛡️ API Layer
// ==========================================================================
const BackendAPI = {
// 1. 心跳检测
checkHealth: async function() {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const res = await fetch(`${API_BASE_URL}/api/health`, {
signal: controller.signal,
cache: 'no-store'
});
clearTimeout(timeoutId);
if (res.ok) {
const data = await res.json();
return data.status === "ok";
}
return false;
} catch (error) {
return false;
}
},
// 2. 获取全局配置 (Sampler/Scheduler & Root Mapping)
getConfig: async function() {
try {
const res = await fetch(`${API_BASE_URL}/api/config`, {
method: 'GET',
cache: 'no-store'
});
if (!res.ok) throw new Error("HTTP error " + res.status);
return await res.json();
} catch (error) {
console.warn("⚠️ Failed to fetch config", error);
return null;
}
},
// 3. 获取 Base Model 列表
getModels: async function() {
const res = await fetch(`${API_BASE_URL}/api/models`, {
method: 'GET',
cache: 'no-store'
});
if (!res.ok) throw new Error("HTTP error " + res.status);
return await res.json();
},
// 4. 获取角色 LoRA 图库数据
getLoras: async function() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
try {
const res = await fetch(`${API_BASE_URL}/api/loras`, {
method: 'GET',
cache: 'no-store',
signal: controller.signal
});
clearTimeout(timeoutId);
if (!res.ok) throw new Error("HTTP error " + res.status);
return await res.json();
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
},
// 5. 提交生成任务
submitGenerate: async function(payload) {
const headers = { 'Content-Type': 'application/json' };
const tokenInfo = await requestZeroGpuToken();
if (tokenInfo) {
if (typeof tokenInfo === 'object') {
Object.assign(headers, tokenInfo);
} else if (typeof tokenInfo === 'string') {
headers['x-ip-token'] = tokenInfo;
}
}
const res = await fetch(`${API_BASE_URL}/api/generate`, {
method: 'POST',
cache: 'no-store',
headers: headers,
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
const data = await res.json();
if (data.status === "error") throw new Error(data.message);
return data.task_id;
},
// 6. 轮询任务状态
pollStatus: async function(taskId, callbacks, retries = 0) {
const MAX_RETRIES = 150;
try {
const res = await fetch(`${API_BASE_URL}/api/status/${taskId}`, {
cache: 'no-store'
});
const data = await res.json();
if (data.status === "processing" || data.status === "queued") {
if (callbacks.onProcessing) {
callbacks.onProcessing(data.message || "Processing...");
}
if (retries >= MAX_RETRIES) {
const timeoutMsg = `Timeout: Backend exceeded ${MAX_RETRIES * 500 / 1000}s. Please try again.`;
if (callbacks.onError) callbacks.onError(timeoutMsg);
return;
}
setTimeout(() => this.pollStatus(taskId, callbacks, retries + 1), 100);
}
else if (data.status === "success") {
// 如果后端返回的是绝对路径或 data: 开头,直接使用;否则拼接 API_BASE_URL
let finalImageUrl = data.image;
if (finalImageUrl && !finalImageUrl.startsWith('data:') && !finalImageUrl.startsWith('http')) {
finalImageUrl = `${API_BASE_URL}${finalImageUrl}`;
}
if (callbacks.onSuccess) callbacks.onSuccess(finalImageUrl, data.seed, data.metrics, data.filename);
}
else if (data.status === "error") {
if (callbacks.onError) callbacks.onError(data.message);
}
else if (data.status === "not_found") {
if (callbacks.onError) callbacks.onError("Task not found (backend may have restarted).");
}
} catch (error) {
if (callbacks.onError) callbacks.onError(error.message);
}
},
};
// ==========================================================================
// 🧠 Global State & Utilities
// ==========================================================================
let generateStartTime = 0;
let currentStageStartTime = 0;
let currentStageName = "";
let isInitializing = false;
let shouldAnimateNextRender = false;
let globalModelData = {};
let currentGalleryData = [];
let renderedCount = 0;
const BATCH_SIZE = 20;
let selectedLora = "";
let backendState = { isReady: false, isChecking: false, healthInterval: null };
const ANIM_CONFIG = {
startY: 24,
stiffness: 280,
damping: 0.7,
staggerDelay: 30,
};
function toUnicodeBold(str) {
return str.split('').map(char => {
const code = char.charCodeAt(0);
if (code >= 65 && code <= 90) return String.fromCodePoint(0x1D5D4 + (code - 65));
if (code >= 97 && code <= 122) return String.fromCodePoint(0x1D5EE + (code - 97));
if (code >= 48 && code <= 57) return String.fromCodePoint(0x1D7EC + (code - 48));
return char;
}).join('');
}
function bindSlider(sliderId, inputId) {
const slider = document.getElementById(sliderId);
const input = document.getElementById(inputId);
slider.addEventListener('input', (e) => { input.value = e.target.value; });
input.addEventListener('input', (e) => {
let val = parseFloat(e.target.value);
if(val < parseFloat(slider.min)) val = slider.min;
if(val > parseFloat(slider.max)) val = slider.max;
slider.value = val;
});
}
bindSlider('width-slider', 'width-input');
bindSlider('height-slider', 'height-input');
bindSlider('steps-slider', 'steps-input');
bindSlider('cfg-slider', 'cfg-input');
const RATIO_PRESETS = {
"1:1": { w: 1024, h: 1024 },
"3:4": { w: 888, h: 1184 },
"9:16": { w: 768, h: 1368 },
"4:3": { w: 1184, h: 888 },
"16:9": { w: 1368, h: 768 }
};
document.querySelectorAll('.ratio-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const ratio = btn.dataset.ratio;
const preset = RATIO_PRESETS[ratio];
if (preset) {
document.getElementById('width-slider').value = preset.w;
document.getElementById('width-input').value = preset.w;
document.getElementById('height-slider').value = preset.h;
document.getElementById('height-input').value = preset.h;
}
});
});
function clearActiveRatio() {
document.querySelectorAll('.ratio-btn').forEach(b => b.classList.remove('active'));
}
document.getElementById('width-slider').addEventListener('input', clearActiveRatio);
document.getElementById('width-input').addEventListener('input', clearActiveRatio);
document.getElementById('height-slider').addEventListener('input', clearActiveRatio);
document.getElementById('height-input').addEventListener('input', clearActiveRatio);
function setUIState(isGenerating) {
const btn = document.getElementById('generate-btn');
if (!backendState.isReady) {
btn.disabled = true;
btn.innerText = "⏳ Waiting for Backend...";
} else if (isGenerating) {
btn.disabled = true;
btn.innerText = "⏳ Generating...";
} else {
btn.disabled = false;
btn.innerText = "Generate";
}
}
// 获取 Checkbox 元素
const autoScrollToggle = document.getElementById('auto-scroll-toggle');
function smartScroll() {
// 只有用户勾选了“自动滚动”,才强制贴底
if (autoScrollToggle && autoScrollToggle.checked) {
const logContent = document.getElementById('log-content');
// 用 requestAnimationFrame 确保在 DOM 高度更新后再执行滚动
requestAnimationFrame(() => {
logContent.scrollTop = logContent.scrollHeight;
});
}
}
function addLog(message, type = "normal") {
const logContent = document.getElementById('log-content');
const newLine = document.createElement('div');
newLine.className = `log-line ${type === "normal" ? "" : type}`;
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
newLine.innerText = `[${time}] ${message}`;
logContent.appendChild(newLine);
if (logContent.childElementCount > 100) {
logContent.firstElementChild.remove();
}
// 插入后调用智能滚动
smartScroll();
}
// ==========================================================================
// 💓 Health Check & Initialization
// ==========================================================================
async function performHealthCheck() {
if (backendState.isChecking) return;
backendState.isChecking = true;
const dot = document.getElementById('backend-status-dot');
const wrapper = document.getElementById('backend-status-wrapper');
if (!backendState.isReady) {
setUIState(false);
dot.className = "status-dot loading";
wrapper.title = "Connecting to backend...";
}
const isHealthy = await BackendAPI.checkHealth();
if (isHealthy) {
if (!backendState.isReady) {
backendState.isReady = true;
setUIState(false);
dot.className = "status-dot online";
wrapper.title = "Backend online, ready to generate!";
await loadInitialData();
}
} else {
backendState.isReady = false;
setUIState(false);
dot.className = "status-dot offline";
wrapper.title = "Backend offline, trying to reconnect...";
}
backendState.isChecking = false;
}
function startHealthPolling() {
if (backendState.healthInterval) return;
backendState.healthInterval = setInterval(async () => {
if (backendState.isReady) {
clearInterval(backendState.healthInterval);
backendState.healthInterval = null;
return;
}
await performHealthCheck();
}, 3000);
}
async function loadInitialData() {
if (isInitializing) return;
isInitializing = true;
console.log("🟢 [Init] 开始初始化...");
document.getElementById('unet-select').disabled = true;
const config = await BackendAPI.getConfig();
if (!config) console.error("❌ 获取配置失败,请检查后端 API 是否正常");
const finalSamplers = config?.samplers || ["euler", "euler_ancestral", "euler_a", "dpmpp_2m", "dpmpp_2m_sde", "dpmpp_sde", "heun", "lms", "lcm"];
const finalSchedulers = config?.schedulers || ["simple", "beta", "normal", "karras", "exponential", "sgm_uniform"];
const rootMapping = config?.root_mapping || {};
if (config && config.default_prompt) {
document.getElementById('prompt').value = config.default_prompt;
}
document.getElementById('sampler').innerHTML = finalSamplers.map(s => ``).join('');
document.getElementById('scheduler').innerHTML = finalSchedulers.map(s => ``).join('');
const selectEl = document.getElementById('unet-select');
try {
const data = await BackendAPI.getModels();
console.log("🟢 [Init] Models 数据已拉取");
if (data.models) {
globalModelData = data.models;
selectEl.innerHTML = "";
Object.keys(data.models).forEach(modelName => {
const opt = document.createElement("option");
opt.value = modelName;
if (modelName.includes(" | ")) {
const parts = modelName.split(" | ");
const enginePart = toUnicodeBold(parts[0]);
const modelPart = parts[1];
opt.textContent = `${enginePart} | ${modelPart}`;
} else {
opt.textContent = toUnicodeBold(modelName);
}
selectEl.appendChild(opt);
});
let modelParam = null;
let loraParam = null;
if (window.location.hash) {
const hashStr = window.location.hash.substring(1);
const hashParams = new URLSearchParams(hashStr);
modelParam = hashParams.get('model');
loraParam = hashParams.get('lora');
}
if (!modelParam) {
const urlParams = new URLSearchParams(window.location.search);
modelParam = urlParams.get('model');
loraParam = urlParams.get('lora');
}
let targetModel = Object.keys(data.models)[0];
if (modelParam) {
const targetEngine = rootMapping[modelParam] || modelParam;
console.log(`🔗 URL解析: ${modelParam} -> ${targetEngine}`);
for (const key of Object.keys(data.models)) {
const modelEngine = data.models[key].engine;
if (modelEngine === targetEngine) {
targetModel = key;
console.log(`✅ 成功匹配到模型: ${key} (Engine: ${modelEngine})`);
break;
}
}
}
console.log(`🟢 [Init] 准备选中模型: ${targetModel}`);
selectEl.value = targetModel;
window._currentSelectedModel = targetModel;
window._lastUsedArchitecture = globalModelData[targetModel]?.engine || "unknown";
const modelDefaults = globalModelData[targetModel]?.defaults || {};
if (globalModelData[targetModel]?.default_prompt !== undefined) {
document.getElementById('prompt').value = globalModelData[targetModel].default_prompt;
}
if (modelDefaults.cfg !== undefined) {
document.getElementById('cfg-slider').value = modelDefaults.cfg;
document.getElementById('cfg-input').value = Number(modelDefaults.cfg).toFixed(1);
}
if (modelDefaults.steps !== undefined) {
document.getElementById('steps-slider').value = modelDefaults.steps;
document.getElementById('steps-input').value = modelDefaults.steps;
}
if (modelDefaults.sampler !== undefined) document.getElementById('sampler').value = modelDefaults.sampler;
if (modelDefaults.scheduler !== undefined) document.getElementById('scheduler').value = modelDefaults.scheduler;
if (modelDefaults.char_lora_strength !== undefined) {
document.getElementById('char-strength').value = Number(modelDefaults.char_lora_strength).toFixed(1);
}
selectedLora = "";
document.getElementById('selected-lora-name').innerText = "None";
document.getElementById('selected-lora-name').style.color = 'var(--text-muted)';
const bigImg = document.getElementById('selected-lora-big-img');
const placeholder = document.getElementById('preview-placeholder');
bigImg.src = "";
bigImg.style.display = 'none';
placeholder.style.display = 'flex';
document.querySelectorAll('.lora-card').forEach(c => c.classList.remove('selected'));
renderBaseLoras(targetModel);
window._isDataReady = true;
// 🌟 核心修改:如果 URL 没有指定 lora 参数,说明是普通进入,开启动画
const isPlainEntry = !loraParam;
await fetchAndRenderGallery(targetModel, isPlainEntry);
if (loraParam) {
if (!loraParam.endsWith(".safetensors")) {
console.warn(`⚠️ [Init] 忽略无效的 LoRA 参数(缺少 .safetensors 后缀): ${loraParam}`);
} else {
const targetIndex = currentGalleryData.findIndex(item => item.filename === loraParam);
if (targetIndex !== -1) {
const targetItem = currentGalleryData[targetIndex];
console.log(`🟢 [Init] 数据层成功找到 LoRA: ${loraParam},位于第 ${targetIndex + 1} 个`);
selectedLora = targetItem.filename;
const nameEl = document.getElementById('selected-lora-name');
nameEl.innerText = targetItem.title;
nameEl.style.color = 'var(--accent)';
const bigImg = document.getElementById('selected-lora-big-img');
const placeholder = document.getElementById('preview-placeholder');
const previewWrapper = document.querySelector('.preview-wrapper');
if (previewWrapper) previewWrapper.classList.add('shimmer-container');
bigImg.src = targetItem.webp;
bigImg.onload = () => {
bigImg.style.display = 'block';
placeholder.style.display = 'none';
if (previewWrapper) previewWrapper.classList.remove('shimmer-container');
bigImg.onload = null;
};
bigImg.onerror = () => {
if (previewWrapper) previewWrapper.classList.remove('shimmer-container');
bigImg.onerror = null;
};
if (targetIndex >= renderedCount) {
const requiredRenderCount = Math.ceil((targetIndex + 1) / BATCH_SIZE) * BATCH_SIZE;
const tempData = currentGalleryData.slice(renderedCount, requiredRenderCount);
const grid = document.getElementById('gallery-grid');
tempData.forEach(item => {
const card = document.createElement('div');
card.className = 'lora-card';
card.dataset.filename = item.filename;
card.innerHTML = `