Files
DemonGatanjieu-Anomalous_Mo…/web/main.js
T
2026-06-10 00:42:00 +08:00

2608 lines
117 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.
import { app } from "../../scripts/app.js";
const i18n = {
zh: {
title: '📦 Anomalous 模型浏览器',
scan: '🔄 扫描目录并同步 C 站数据',
scanTitle: '扫描目录',
scanConfirm: '注意:\n扫描过程将会自动比对 Civitai 并重命名本地模型,同时清理损坏文件。\n确定要开始扫描吗?',
scanning: '扫描中...',
scanBg: '🚀 扫描后台启动!',
scanDone: '完成',
scanCompleteMsg: '✅ 扫描完成!\n\n⚠️ 提示:由于部分模型已被重命名或去重,请点击 ComfyUI 的 [Refresh] 按钮以同步最新的模型列表。',
eco: '🔋 视频悬浮播放 (节能模式)',
autoPlay: '🎬 自动播放视频封面 (高能耗)',
togglePlayTitle: '切换播放模式',
clean: '🧹 清理无效的 .info 残留文件',
cleanTitle: '清理重复 Info',
cleanConfirm: '将扫描并删除所有无用的 .civitai.info 残留文件。是否继续?',
cleaning: '清理中',
cleanDone: '✅ 清理完毕!删除了',
files: '个文件。',
cleanFail: '❌ 清理失败: ',
cleanErr: '❌ 清理出错: ',
folders: '📂 文件夹',
collapseAll: '➖ 收起全部',
expandAll: '➕ 展开全部',
noModels: '此文件夹中没有找到模型。',
noPreview: '暂无预览图',
clickScan: '点击扫描',
back: '⬅️ 返回网格',
delModel: '🗑️ 删除',
delConfirm: '确定彻底删除',
delConfirm2: '及其所有关联配置、预览图和缓存文件吗?此操作不可逆!',
delSuccess: '✅ 删除成功!\n',
delNote: '\n\n⚠️ 提示:请重启 ComfyUI,否则由于缓存继续使用会报错',
delFail: '❌ 删除失败: ',
copyAll: '📋 复制全部',
copied: '✅ 已复制!',
clickToCopy: '点击复制: ',
compatibleModels: '🔗 兼容模型',
loadingCompatible: '加载中...',
backToPrev: '🔙 返回上一层',
applyToCanvas: '➕ 插入节点',
applySuccess: '✅ 已添加至工作流',
help: '帮助',
helpTitle: '📖 使用说明',
helpContent: `
<div style="line-height: 1.6; font-size: 0.95em; color: #eee; padding: 10px;">
<p><strong>1. 🔑 API Key (Civitai)</strong>: 在顶部输入你的 API Key,可用于扫描时获取限制级 (NSFW) 模型的封面和信息。</p>
<p><strong>2. 🔄 扫描 (Scan)</strong>: 自动扫描当前选中的文件夹,从 C 站匹配并下载封面图,同时规范化重命名你的本地模型文件。</p>
<p><strong>3. 🧹 清理 (Clean Info)</strong>: 全局深度清理:自动扫描并删除所有因为模型被删而遗留的无效 .civitai.info 文件。</p>
<p><strong>4. 🔋 节能 / 🎬 自动播放</strong>: 切换视频封面的播放行为。“节能”模式下,鼠标悬浮在卡片上时才会播放预览视频。</p>
<p><strong>5. 🗂️ 详情与删除</strong>: 点击任何模型卡片,可查看介绍并<strong>一键复制触发词</strong>。也可以在此处彻底删除模型及关联配置(删除后须重启 ComfyUI 引擎生效)。</p>
<p><strong>6. 📂 目录折叠</strong>: 点击左侧边栏的“收起/展开全部”快速管理网格视图。</p>
<p><strong>7. 🔗 兼容模型匹配</strong>: 点开大模型或 Lora 详细页,系统将自动基于 Base Model 架构,双向匹配并展示关联模型。</p>
<p><strong>8. ➕ 一键投放节点</strong>: 在网格卡片右上角或详细页顶部,点击【➕】按钮,可将当前模型节点直接贴在鼠标上,并无缝插入工作流画布!</p>
<p><strong>9. 📑 笔记本 (Notebook)</strong>: 提供强大的工作流草稿本功能。你可以创建不同的笔记本,选择基础架构 (Base Model),系统会自动为你筛选出对应的纯净主模型和 Lora 画廊供你点选。同时内置了极具高级感的双语提示词对照编辑器,支持标签化悬停高亮与快捷复制,并且支持将整个配置一键发送到画布!</p>
<p><strong>10. 🖼️ 图库 (Gallery)</strong>: 同步浏览 ComfyUI output 文件夹的所有生成历史。支持无限懒加载,点击全屏预览,设有极度安全的防误触删除遮罩。更可以直接将任意图片拖拽到画布上瞬间还原内嵌的工作流!</p>
<p><strong>11. 🪄 自动修复飘红节点 (Auto-Fix)</strong>: 设置面板中的“一键修复工作流飘红”功能或缺模型弹窗中的一键按钮,可以帮您瞬间自动替换画布上所有的报错模型。<strong>⚠️ 必须知晓:</strong>系统依赖模型哈希(Hash)进行精准匹配,因此只有在使用本插件之后生成/保存的工作流或图片,才能实现 100% 精准全自动修复。对于历史遗留的旧工作流,由于缺失哈希信息,无法自动修复,敬请手动重选。</p>
</div>`,
notebooks: '笔记本',
notebookTitle: '笔记本管理',
createNotebook: '➕ 新建',
saveNotebook: '💾 保存',
deleteNotebook: '🗑️ 删除',
renameNotebook: '✏️ 重命名',
baseModel: 'Base Model (基础模型)',
mainModel: 'Main Model (主模型)',
sendToCanvas: '🚀 发送到画布',
translate: '翻译',
newNotebookName: '新笔记本名称',
noBaseModel: '请先选择 Base Model',
closeHelp: '关闭说明',
delSure: '⚠️ 确定吗?',
dockTitle: '侧边停靠',
replaceAll: '全部替换',
editRaw: '📝 纯文本/粘贴',
findPlaceholder: '查找...',
replacePlaceholder: '替换为...',
models: '模型',
gallery: '图库',
apiKeyConfig: '🔑 Civitai API 密钥配置 (用于下载封面)',
apiKeyPrompt: '请输入你的 Civitai API Key:',
settingsBtn: '设置',
closeSettings: '✖ 关闭设置面板',
},
en: {
title: '📦 Anomalous Model Browser',
scan: '🔄 Scan folder and sync Civitai data',
scanTitle: 'Scan Folder',
scanConfirm: 'Notice:\nThe scan process will automatically compare with Civitai and rename your local files.\nStart scan?',
scanning: 'Scanning...',
scanBg: '🚀 Scan started in background!',
scanDone: 'Done',
scanCompleteMsg: '✅ Scan Complete!\n\n⚠️ Note: Please click [Refresh] to sync the model list since files were renamed.',
eco: '🔋 Eco Mode (Play video on hover)',
autoPlay: '🎬 AutoPlay Videos (High perf cost)',
togglePlayTitle: 'Toggle Play Mode',
clean: '🧹 Clean orphaned .info files',
cleanTitle: 'Clean Duplicate Info',
cleanConfirm: 'This will globally scan and delete all redundant .civitai.info files. Continue?',
cleaning: 'Cleaning',
cleanDone: '✅ Clean Complete! Deleted',
files: 'files.',
cleanFail: '❌ Failed: ',
cleanErr: '❌ Error: ',
folders: '📂 Folders',
collapseAll: '➖ Collapse All',
expandAll: '➕ Expand All',
noModels: 'No models found in this folder.',
noPreview: 'No preview available',
clickScan: 'Click Scan',
back: '⬅️ Back to grid',
delModel: '🗑️ Delete',
delConfirm: 'Are you sure you want to permanently delete',
delConfirm2: ' and all its associated configs, previews, and cache files? This action is irreversible!',
delSuccess: '✅ Delete Complete!\n',
delNote: '\n\n⚠️ Note: Please restart ComfyUI, otherwise continuing to use it due to cache will cause errors.',
delFail: '❌ Delete Failed: ',
copyAll: '📋 Copy All',
copied: '✅ Copied!',
clickToCopy: 'Click to copy: ',
compatibleModels: '🔗 Compatible Models',
loadingCompatible: 'Loading...',
backToPrev: '🔙 Back to prev',
applyToCanvas: '➕ Add Node',
applySuccess: '✅ Added to workflow',
help: 'Help',
helpTitle: '📖 User Manual',
helpContent: `
<div style="line-height: 1.6; font-size: 0.95em; color: #eee; padding: 10px;">
<p><strong>1. 🔑 API Key (Civitai)</strong>: Enter your key at the top to fetch metadata and previews for NSFW models during scanning.</p>
<p><strong>2. 🔄 Scan</strong>: Automatically scans the current folder, fetches Civitai metadata, downloads previews, and standardizes model filenames.</p>
<p><strong>3. 🧹 Clean Info</strong>: Globally scans and deletes orphaned .civitai.info files to free up disk space.</p>
<p><strong>4. 🔋 Eco / 🎬 AutoPlay</strong>: Toggle video playback modes. Eco mode plays videos only when hovering over a model card.</p>
<p><strong>5. 🗂️ Details & Delete</strong>: Click a model card to view details and <strong>1-click copy trained words</strong>. You can also permanently delete the model here (requires ComfyUI restart).</p>
<p><strong>6. 📂 Folder Toggle</strong>: Use the Collapse/Expand All button in the sidebar to manage your view.</p>
<p><strong>7. 🔗 Compatible Model Matching</strong>: Open a Checkpoint or Lora detail page, and the system will automatically bi-directionally match and display compatible models based on the Base Model architecture.</p>
<p><strong>8. ➕ One-Click Add Node</strong>: Click the 【➕】 button on a grid card or detail page to instantly attach the model node to your cursor and drop it seamlessly onto your workflow canvas!</p>
<p><strong>9. 📑 Notebook System</strong>: A powerful drafting workspace. Create notebooks, select a Base Model, and precisely match compatible Main Models and Loras. Includes a premium dual-pane bilingual prompt editor with hover-sync, tag splitting, and 1-click deployment to canvas.</p>
<p><strong>10. 🖼️ Gallery System</strong>: Natively browse your ComfyUI output history. Features infinite lazy loading, fullscreen immersive previews, and a foolproof overlay for safe deletion. You can even drag and drop any image directly onto your canvas to instantly import its workflow!</p>
<p><strong>11. 🪄 Auto-Fix Missing Models</strong>: The "Fix Models" button in Settings or the One-Click button in the missing-models popup will instantly repair all red/missing model nodes on your canvas. <strong>⚠️ Important:</strong> The system relies on model hashes for 100% accurate matching. Therefore, ONLY workflows or images generated/saved AFTER installing this plugin can be fully auto-repaired. For older historical workflows lacking hash data, auto-fix is not possible and you must manually re-select the models.</p>
</div>`,
notebooks: 'Notebooks',
notebookTitle: 'Notebook Manager',
createNotebook: '➕ New',
saveNotebook: '💾 Save',
deleteNotebook: '🗑️ Delete',
renameNotebook: '✏️ Rename',
baseModel: 'Base Model',
mainModel: 'Main Model',
sendToCanvas: '🚀 Send to Canvas',
translate: 'Translate',
newNotebookName: 'New Notebook Name',
noBaseModel: 'Select a Base Model first',
closeHelp: 'Close Manual',
delSure: '⚠️ Sure?',
dockTitle: 'Dock to Left',
replaceAll: 'Replace All',
editRaw: '📝 Edit Raw / Paste',
findPlaceholder: 'Find...',
replacePlaceholder: 'Replace with...',
models: 'Models',
gallery: 'Gallery',
apiKeyConfig: '🔑 Civitai API Key Config (For downloading previews)',
apiKeyPrompt: 'Enter your Civitai API Key:',
settingsBtn: 'Settings',
closeSettings: '✖ Close Settings',
}
};
let currentLang = localStorage.getItem('anomalous_lang') || 'zh';
const t = (key) => i18n[currentLang][key] || key;
class AnomalousBrowser {
constructor() {
this.modal = null;
this.sidebar = null;
this.grid = null;
this.detailPanel = null;
this.currentType = 'loras';
this.currentPathIdx = 0;
this.currentSubfolder = '/';
this.foldersData = null;
this.expandedFolders = new Set(['/', 'checkpoints', 'loras', 'unet', 'diffusion_models']);
this.energySaving = localStorage.getItem('anomalous_energy_saving') === 'true';
this.createDOM();
}
createDOM() {
this.modal = document.createElement('div');
this.modal.id = 'anomalous-modal';
const container = document.createElement('div');
container.id = 'anomalous-container';
const updateLangClass = () => {
if (currentLang === 'en') container.classList.add('anomalous-lang-en');
else container.classList.remove('anomalous-lang-en');
};
updateLangClass();
// Sidebar
this.sidebarWrapper = document.createElement('div');
this.sidebarWrapper.id = 'anomalous-sidebar-wrapper';
this.sidebar = document.createElement('div');
this.sidebar.id = 'anomalous-sidebar';
this.sidebarWrapper.appendChild(this.sidebar);
// Content Area
const content = document.createElement('div');
content.id = 'anomalous-content';
const header = document.createElement('div');
header.id = 'anomalous-header';
let isDragging = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.id === 'anomalous-close') return;
isDragging = true;
const rect = container.getBoundingClientRect();
dragOffsetX = e.clientX - rect.left;
dragOffsetY = e.clientY - rect.top;
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
let newX = e.clientX - dragOffsetX;
let newY = e.clientY - dragOffsetY;
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX + container.offsetWidth > window.innerWidth) newX = window.innerWidth - container.offsetWidth;
if (newY + container.offsetHeight > window.innerHeight) newY = window.innerHeight - container.offsetHeight;
container.style.left = newX + 'px';
container.style.top = newY + 'px';
container.style.transform = 'none';
});
window.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
localStorage.setItem('anomalous_pos_x', container.style.left);
localStorage.setItem('anomalous_pos_y', container.style.top);
}
});
const savedX = localStorage.getItem('anomalous_pos_x');
const savedY = localStorage.getItem('anomalous_pos_y');
if (savedX && savedY) {
container.style.left = savedX;
container.style.top = savedY;
}
const spacer = document.createElement('div');
spacer.style.flex = '1 1 auto';
const leftGroup = document.createElement('div');
leftGroup.className = 'anomalous-header-group';
const menuBtn = document.createElement('button');
menuBtn.innerHTML = '☰';
menuBtn.title = 'Toggle Sidebar';
menuBtn.onclick = () => {
if (this.sidebarWrapper.style.display === 'none') {
this.sidebarWrapper.style.display = 'flex';
container.classList.remove('anomalous-sidebar-closed');
} else {
this.sidebarWrapper.style.display = 'none';
container.classList.add('anomalous-sidebar-closed');
container.style.width = '';
container.style.height = '';
localStorage.removeItem('anomalous_width');
localStorage.removeItem('anomalous_height');
}
};
const hideAllPanels = () => {
this.grid.style.display = 'none';
this.detailPanel.style.display = 'none';
if (this.galleryPanel) this.galleryPanel.style.display = 'none';
if (this.currentDetailObserver) {
this.currentDetailObserver.disconnect();
this.currentDetailObserver = null;
}
};
const showSidebar = () => {
if (window.innerWidth <= 900) {
// If it was explicitly closed by the user, don't force it open, but since we are switching tabs we should probably show it
this.sidebarWrapper.style.display = 'flex';
container.classList.remove('anomalous-sidebar-closed');
} else {
this.sidebarWrapper.style.display = 'flex';
container.classList.remove('anomalous-sidebar-closed');
}
};
const modelsBtn = document.createElement('button');
modelsBtn.id = 'anomalous-models-btn';
modelsBtn.innerHTML = `🏠 <span class="anomalous-btn-text">${t('models')}</span>`;
modelsBtn.onclick = () => {
hideAllPanels();
showSidebar();
this.grid.style.display = 'grid';
if (this.detailPanel.innerHTML !== '') {
this.detailPanel.innerHTML = '';
this.currentDetailModel = null;
this.historyStack = [];
}
};
const galleryBtn = document.createElement('button');
galleryBtn.innerHTML = `🖼️ <span class="anomalous-btn-text">${t('gallery') || '图库'}</span>`;
galleryBtn.onclick = () => {
hideAllPanels();
this.sidebarWrapper.style.display = 'none';
container.classList.add('anomalous-sidebar-closed');
this.galleryPanel.style.display = 'flex';
if (!this.galleryLoaded) {
this.loadGalleryImages(1, true);
}
};
const rightGroup = document.createElement('div');
rightGroup.className = 'anomalous-header-group';
const dockBtn = document.createElement('button');
dockBtn.innerHTML = '◧';
dockBtn.title = t('dockTitle');
dockBtn.onclick = () => {
container.classList.toggle('anomalous-docked');
if (container.classList.contains('anomalous-docked')) {
localStorage.setItem('anomalous_docked', 'true');
} else {
localStorage.setItem('anomalous_docked', 'false');
}
};
if (localStorage.getItem('anomalous_docked') === 'true') {
container.classList.add('anomalous-docked');
}
const helpBtn = document.createElement('button');
helpBtn.id = 'anomalous-help-btn';
helpBtn.title = t('helpTitle');
helpBtn.innerHTML = `❓ <span class="anomalous-btn-text">${t('help')}</span>`;
helpBtn.onclick = () => this.showHelp();
const nbBtn = document.createElement('button');
nbBtn.id = 'anomalous-notebook-btn';
nbBtn.title = t('notebookTitle');
nbBtn.innerHTML = `📑 <span class="anomalous-btn-text">${t('notebooks')}</span>`;
nbBtn.onclick = () => {
this.nbPanel.style.display = 'flex';
this.showNotebooks();
};
leftGroup.appendChild(menuBtn);
leftGroup.appendChild(modelsBtn);
leftGroup.appendChild(galleryBtn);
leftGroup.appendChild(nbBtn);
const apiKeyBtn = document.createElement('button');
apiKeyBtn.id = 'anomalous-api-btn';
apiKeyBtn.innerHTML = `<span class="anomalous-btn-text">${t('apiKeyConfig')}</span>`;
apiKeyBtn.onclick = async () => {
const current = localStorage.getItem('anomalous_api_key') || '';
const val = prompt(t('apiKeyPrompt'), current);
if (val !== null) {
localStorage.setItem('anomalous_api_key', val.trim());
try {
await fetch('/anomalous/save_config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ api_key: val.trim() })
});
} catch (e) { }
}
};
const scanBtn = document.createElement('button');
scanBtn.id = 'anomalous-scan-btn';
scanBtn.title = t('scanTitle');
scanBtn.innerHTML = `<span class="anomalous-btn-text">${t('scan')}</span>`;
scanBtn.onclick = async () => {
if (!confirm(t('scanConfirm'))) return;
scanBtn.innerHTML = `⏳ <span class="anomalous-btn-text">${t('scanning')}</span>`;
scanBtn.disabled = true;
try {
const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder });
const res = await fetch('/anomalous/scan?' + params.toString(), { method: 'POST' });
const data = await res.json();
if (data.status === 'ok') {
alert(t('scanBg'));
const poll = setInterval(async () => {
try {
const sr = await fetch('/anomalous/scan_status?' + params.toString());
const sd = await sr.json();
if (!sd.scanning) {
clearInterval(poll);
scanBtn.innerHTML = `✅ <span class="anomalous-btn-text">${t('scanDone')}</span>`;
let msg = '';
if (sd.result) {
if (currentLang === 'zh') {
msg = `✅ 扫描与刮削已结束!\n成功处理:${sd.result.success} 个\n处理失败:${sd.result.fail} 个\n\n正在为您自动点亮画布上所有因此次更名而飘红的节点...`;
} else {
msg = `✅ Scan & Scraping Complete!\nSuccess: ${sd.result.success}\nFailed: ${sd.result.fail}\n\nAuto-fixing any red nodes on your canvas caused by renaming...`;
}
} else {
if (currentLang === 'zh') {
msg = `✅ 扫描完成!\n\n正在为您自动点亮画布上飘红的节点...`;
} else {
msg = `✅ Scan Complete!\n\nAuto-fixing any red nodes on your canvas...`;
}
}
alert(msg);
if (window.anomalous_resolve_all_missing_nodes) {
await window.anomalous_resolve_all_missing_nodes(true);
}
this.loadModels();
setTimeout(() => { scanBtn.innerHTML = `<span class="anomalous-btn-text">${t('scan')}</span>`; scanBtn.disabled = false; }, 2000);
}
} catch (e) { clearInterval(poll); scanBtn.disabled = false; }
}, 3000);
} else {
scanBtn.disabled = false;
}
} catch (e) { scanBtn.disabled = false; }
};
const energyBtn = document.createElement('button');
energyBtn.id = 'anomalous-energy-btn';
energyBtn.title = t('togglePlayTitle');
const renderEnergyBtn = () => {
energyBtn.innerHTML = this.energySaving
? `<span class="anomalous-btn-text">${t('eco')}</span>`
: `<span class="anomalous-btn-text">${t('autoPlay')}</span>`;
};
renderEnergyBtn();
energyBtn.onclick = () => {
this.energySaving = !this.energySaving;
localStorage.setItem('anomalous_energy_saving', this.energySaving);
renderEnergyBtn();
this.loadModels();
};
const closeBtn = document.createElement('div');
closeBtn.id = 'anomalous-close';
closeBtn.innerHTML = '&times;';
closeBtn.onclick = () => this.close();
const cleanBtn = document.createElement('button');
cleanBtn.id = 'anomalous-clean-btn';
cleanBtn.title = t('cleanTitle');
cleanBtn.innerHTML = `<span class="anomalous-btn-text">${t('clean')}</span>`;
cleanBtn.onclick = async () => {
if (!confirm(t('cleanConfirm'))) return;
cleanBtn.innerHTML = `⏳ <span class="anomalous-btn-text">${t('cleaning')}</span>`;
cleanBtn.disabled = true;
try {
const res = await fetch('/anomalous/clean_civitai_info', { method: 'POST' });
const data = await res.json();
if (data.status === 'success') {
alert(`${t('cleanDone')} ${data.count} ${t('files')}`);
} else {
alert(t('cleanFail') + data.message);
}
} catch (e) { alert(t('cleanErr') + e.message); }
cleanBtn.innerHTML = `<span class="anomalous-btn-text">${t('clean')}</span>`;
cleanBtn.disabled = false;
};
const settingsBtn = document.createElement('button');
settingsBtn.id = 'anomalous-settings-btn';
settingsBtn.innerHTML = `⚙️ <span class="anomalous-btn-text">${t('settingsBtn')}</span>`;
settingsBtn.onclick = () => {
if (this.settingsPanel.style.display === 'flex') {
this.settingsPanel.style.display = 'none';
} else {
this.settingsPanel.style.display = 'flex';
}
};
const fixBtn = document.createElement('button');
fixBtn.id = 'anomalous-fix-models-btn';
fixBtn.title = '一键修复工作流飘红 (Fix Models)';
fixBtn.innerHTML = `🪄 <span class="anomalous-btn-text">一键修复当前工作流飘红</span>`;
fixBtn.style.color = '#ffc107';
fixBtn.style.border = '1px solid #ffc107';
fixBtn.onclick = async () => {
if (window.anomalous_resolve_all_missing_nodes) {
await window.anomalous_resolve_all_missing_nodes(true);
this.settingsPanel.style.display = 'none';
} else {
alert("修复模块尚未加载!");
}
};
rightGroup.appendChild(dockBtn);
rightGroup.appendChild(settingsBtn);
rightGroup.appendChild(closeBtn);
header.appendChild(leftGroup);
header.appendChild(spacer);
header.appendChild(rightGroup);
this.settingsPanel = document.createElement('div');
this.settingsPanel.id = 'anomalous-settings-panel';
const settingsBox = document.createElement('div');
settingsBox.id = 'anomalous-settings-box';
const langBtn = document.createElement('button');
langBtn.className = 'anomalous-lang-btn';
langBtn.innerHTML = currentLang === 'zh' ? '🌐 Language: EN' : '🌐 语言: 中文';
langBtn.onclick = () => {
currentLang = currentLang === 'zh' ? 'en' : 'zh';
localStorage.setItem('anomalous_lang', currentLang);
langBtn.innerHTML = currentLang === 'zh' ? '🌐 Language: EN' : '🌐 语言: 中文';
updateLangClass();
modelsBtn.innerHTML = `🏠 <span class="anomalous-btn-text">${t('models')}</span>`;
galleryBtn.innerHTML = `🖼️ <span class="anomalous-btn-text">${t('gallery')}</span>`;
scanBtn.title = t('scanTitle');
scanBtn.innerHTML = `<span class="anomalous-btn-text">${t('scan')}</span>`;
cleanBtn.title = t('cleanTitle');
cleanBtn.innerHTML = `<span class="anomalous-btn-text">${t('clean')}</span>`;
helpBtn.title = t('helpTitle');
helpBtn.innerHTML = `❓ <span class="anomalous-btn-text">${t('help')}</span>`;
nbBtn.title = t('notebookTitle');
nbBtn.innerHTML = `📑 <span class="anomalous-btn-text">${t('notebooks')}</span>`;
apiKeyBtn.innerHTML = `<span class="anomalous-btn-text">${t('apiKeyConfig')}</span>`;
settingsBtn.innerHTML = `⚙️ <span class="anomalous-btn-text">${t('settingsBtn')}</span>`;
renderEnergyBtn();
this.renderSidebar();
this.loadModels();
if (this.detailPanel.style.display !== 'none' && this.currentDetailModel) {
this.showDetail(this.currentDetailModel);
}
if (this.nbEditor && this.nbEditor.innerHTML !== '') {
this.renderNotebookEditor();
this.refreshNotebooks();
}
};
const settingsCloseBtn = document.createElement('button');
settingsCloseBtn.style.background = 'linear-gradient(135deg, #cc3333 0%, #aa2222 100%)';
settingsCloseBtn.style.color = '#fff';
settingsCloseBtn.style.fontWeight = 'bold';
settingsCloseBtn.style.marginTop = '15px';
settingsCloseBtn.onclick = () => { this.settingsPanel.style.display = 'none'; };
const updateSettingsCloseText = () => {
settingsCloseBtn.innerHTML = t('closeSettings');
};
updateSettingsCloseText();
langBtn.addEventListener('click', updateSettingsCloseText);
settingsBox.appendChild(langBtn);
settingsBox.appendChild(apiKeyBtn);
settingsBox.appendChild(fixBtn);
settingsBox.appendChild(scanBtn);
settingsBox.appendChild(cleanBtn);
settingsBox.appendChild(energyBtn);
settingsBox.appendChild(helpBtn);
settingsBox.appendChild(settingsCloseBtn);
this.settingsPanel.appendChild(settingsBox);
this.settingsPanel.onclick = (e) => {
if (e.target === this.settingsPanel) this.settingsPanel.style.display = 'none';
};
this.grid = document.createElement('div');
this.grid.id = 'anomalous-grid';
this.detailPanel = document.createElement('div');
this.detailPanel.id = 'anomalous-detail';
this.detailPanel.style.display = 'none';
this.galleryPanel = document.createElement('div');
this.galleryPanel.id = 'anomalous-gallery-panel';
this.galleryGrid = document.createElement('div');
this.galleryGrid.className = 'anomalous-gallery-grid';
this.galleryPanel.appendChild(this.galleryGrid);
this.gallerySentinel = document.createElement('div');
this.gallerySentinel.className = 'anomalous-gallery-sentinel';
this.galleryGrid.appendChild(this.gallerySentinel);
this.galleryCurrentPage = 1;
this.galleryLoaded = false;
this.galleryLoading = false;
this.galleryHasMore = true;
this.galleryObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !this.galleryLoading && this.galleryHasMore) {
this.loadGalleryImages(this.galleryCurrentPage + 1);
}
}, { root: this.galleryGrid, rootMargin: '100px' });
this.galleryObserver.observe(this.gallerySentinel);
this.nbPanel = document.createElement('div');
this.nbPanel.className = 'anomalous-nb-modal';
this.nbPanel.style.display = 'none';
this.nbPanel.onclick = (e) => {
if (e.target === this.nbPanel) this.nbPanel.style.display = 'none';
};
content.appendChild(header);
content.appendChild(this.grid);
content.appendChild(this.detailPanel);
content.appendChild(this.galleryPanel);
container.appendChild(this.sidebarWrapper);
container.appendChild(content);
container.appendChild(this.settingsPanel);
container.appendChild(this.nbPanel);
this.modal.appendChild(container);
// Resize handle
const resizeHandle = document.createElement('div');
resizeHandle.className = 'anomalous-resize-handle';
let isResizing = false;
resizeHandle.onmousedown = (e) => {
e.preventDefault();
e.stopPropagation();
isResizing = true;
};
window.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const rect = container.getBoundingClientRect();
let newWidth = e.clientX - rect.left;
let newHeight = e.clientY - rect.top;
if (newWidth < 600) newWidth = 600;
if (newHeight < 400) newHeight = 400;
container.style.width = newWidth + 'px';
container.style.height = newHeight + 'px';
});
window.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
localStorage.setItem('anomalous_width', container.style.width);
localStorage.setItem('anomalous_height', container.style.height);
}
});
const savedW = localStorage.getItem('anomalous_width');
const savedH = localStorage.getItem('anomalous_height');
if (savedW) container.style.width = savedW;
if (savedH) container.style.height = savedH;
container.appendChild(resizeHandle);
document.body.appendChild(this.modal);
}
showHelp() {
if (this.helpModal) {
this.helpModal.remove();
}
this.helpModal = document.createElement('div');
this.helpModal.style.position = 'absolute';
this.helpModal.style.top = '0';
this.helpModal.style.left = '0';
this.helpModal.style.width = '100%';
this.helpModal.style.height = '100%';
this.helpModal.style.background = 'rgba(0,0,0,0.85)';
this.helpModal.style.zIndex = '9999';
this.helpModal.style.display = 'flex';
this.helpModal.style.alignItems = 'center';
this.helpModal.style.justifyContent = 'center';
const box = document.createElement('div');
box.style.background = 'var(--bg-color, #222)';
box.style.border = '1px solid var(--border-color, #444)';
box.style.borderRadius = '8px';
box.style.width = '550px';
box.style.maxWidth = '90%';
box.style.boxShadow = '0 10px 40px rgba(0,0,0,0.8)';
box.style.display = 'flex';
box.style.flexDirection = 'column';
box.style.maxHeight = '90vh';
const header = document.createElement('div');
header.style.padding = '15px 20px';
header.style.borderBottom = '1px solid #444';
header.style.background = '#333';
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.justifyContent = 'space-between';
const title = document.createElement('h2');
title.innerHTML = t('helpTitle');
title.style.margin = '0';
title.style.color = '#fff';
title.style.fontSize = '1.2em';
const closeX = document.createElement('div');
closeX.innerHTML = '&times;';
closeX.style.position = 'absolute';
closeX.style.top = '10px';
closeX.style.right = '15px';
closeX.style.fontSize = '1.8em';
closeX.style.cursor = 'pointer';
closeX.style.color = '#ff4444';
closeX.onclick = () => this.helpModal.remove();
header.appendChild(title);
header.appendChild(closeX);
const body = document.createElement('div');
body.style.padding = '20px';
body.innerHTML = t('helpContent');
body.style.overflowY = 'auto';
body.style.flex = '1';
const footer = document.createElement('div');
footer.style.padding = '15px';
footer.style.borderTop = '1px solid #444';
footer.style.display = 'flex';
footer.style.justifyContent = 'flex-end';
const closeBtn = document.createElement('button');
closeBtn.innerHTML = t('closeHelp');
closeBtn.style.padding = '8px 16px';
closeBtn.style.background = '#444';
closeBtn.style.color = '#fff';
closeBtn.style.border = 'none';
closeBtn.style.borderRadius = '4px';
closeBtn.style.cursor = 'pointer';
closeBtn.onclick = () => this.helpModal.remove();
footer.appendChild(closeBtn);
box.appendChild(header);
box.appendChild(body);
box.appendChild(footer);
this.helpModal.appendChild(box);
document.getElementById('anomalous-container').appendChild(this.helpModal);
}
async loadFolders() {
try {
const res = await fetch('/anomalous/folders');
const data = await res.json();
this.foldersData = data.folders || [];
if (!this.firstLoadDone && this.foldersData.length > 0) {
this.firstLoadDone = true;
let found = false;
for (const typeGroup of this.foldersData) {
const sortedPaths = Object.keys(typeGroup.folders).sort();
for (const path of sortedPaths) {
if (typeGroup.folders[path].model_count > 0) {
this.currentType = typeGroup.type;
this.currentPathIdx = typeGroup.path_idx;
this.currentSubfolder = path;
found = true;
break;
}
}
if (found) break;
}
}
// Auto expand all
(this.foldersData || []).forEach(typeGroup => {
this.expandedFolders.add(typeGroup.type);
Object.keys(typeGroup.folders).forEach(path => {
this.expandedFolders.add(typeGroup.type + path);
});
});
this.renderSidebar();
this.loadModels();
} catch (e) { }
}
renderSidebar() {
this.sidebar.innerHTML = '';
const brandBar = document.createElement('div');
brandBar.style.padding = '12px 15px 0 15px';
brandBar.style.display = 'flex';
brandBar.style.alignItems = 'center';
const badge = document.createElement('div');
badge.style.background = 'linear-gradient(135deg, #444, #222)';
badge.style.color = '#ccc';
badge.style.fontSize = '0.7em';
badge.style.padding = '4px 8px';
badge.style.borderRadius = '6px';
badge.style.letterSpacing = '1px';
badge.style.border = '1px solid #555';
badge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)';
badge.style.textTransform = 'uppercase';
badge.style.fontWeight = 'bold';
badge.innerHTML = 'Anomalous Browser';
brandBar.appendChild(badge);
const topBar = document.createElement('div');
topBar.style.display = 'flex';
topBar.style.justifyContent = 'space-between';
topBar.style.alignItems = 'center';
topBar.style.padding = '10px 15px 15px 15px';
const title = document.createElement('h3');
title.innerHTML = t('folders');
title.style.color = '#fff';
title.style.margin = '0';
const isAllCollapsed = this.expandedFolders.size === 0;
const collapseAllBtn = document.createElement('button');
collapseAllBtn.innerHTML = isAllCollapsed ? t('expandAll') : t('collapseAll');
collapseAllBtn.style.padding = '4px 8px';
collapseAllBtn.style.background = '#444';
collapseAllBtn.style.color = '#fff';
collapseAllBtn.style.border = 'none';
collapseAllBtn.style.borderRadius = '4px';
collapseAllBtn.style.cursor = 'pointer';
collapseAllBtn.onclick = () => {
if (isAllCollapsed) {
(this.foldersData || []).forEach(typeGroup => {
this.expandedFolders.add(typeGroup.type);
Object.keys(typeGroup.folders).forEach(path => {
this.expandedFolders.add(typeGroup.type + path);
});
});
} else {
this.expandedFolders.clear();
}
this.renderSidebar();
};
topBar.appendChild(title);
topBar.appendChild(collapseAllBtn);
this.sidebar.appendChild(brandBar);
this.sidebar.appendChild(topBar);
(this.foldersData || []).forEach(typeGroup => {
const header = document.createElement('div');
header.className = 'anomalous-type-header';
header.style.display = 'flex';
header.style.justifyContent = 'space-between';
header.style.cursor = 'pointer';
const isTypeExpanded = this.expandedFolders.has(typeGroup.type);
header.innerHTML = `<span>${typeGroup.label}</span> <span>${isTypeExpanded ? '▼' : '▶'}</span>`;
header.onclick = () => {
if (isTypeExpanded) this.expandedFolders.delete(typeGroup.type);
else this.expandedFolders.add(typeGroup.type);
this.renderSidebar();
};
this.sidebar.appendChild(header);
if (!isTypeExpanded) return;
const sortedPaths = Object.keys(typeGroup.folders).sort();
sortedPaths.forEach(path => {
const info = typeGroup.folders[path];
const parts = path.split('/').filter(p => p);
const parentPath = parts.length > 1 ? '/' + parts.slice(0, -1).join('/') : '/';
if (path !== '/' && parentPath !== '/') {
let parentId = typeGroup.type + parentPath;
if (!this.expandedFolders.has(parentId)) return;
}
const hasChildren = sortedPaths.some(p => p !== path && p.startsWith(path === '/' ? '/' : path + '/'));
const item = document.createElement('div');
item.className = 'anomalous-folder-item';
const depth = path === '/' ? 0 : parts.length;
item.style.paddingLeft = (15 + depth * 15) + 'px';
const myId = typeGroup.type + path;
const isExpanded = this.expandedFolders.has(myId);
let toggleIcon = '';
if (hasChildren) {
toggleIcon = `<span class="anomalous-folder-toggle" style="margin-right: 5px; width: 15px; display: inline-block;">${isExpanded ? '▼' : '▶'}</span>`;
} else {
toggleIcon = `<span style="margin-right: 5px; width: 15px; display: inline-block;"></span>`;
}
item.innerHTML = `${toggleIcon}📁 ${info.name} <span style="opacity:0.5; font-size:0.9em;">(${info.model_count})</span>`;
if (this.currentType === typeGroup.type && this.currentPathIdx === typeGroup.path_idx && this.currentSubfolder === path) {
item.classList.add('active');
}
item.onclick = (e) => {
if (e.target.classList.contains('anomalous-folder-toggle')) {
if (isExpanded) this.expandedFolders.delete(myId);
else this.expandedFolders.add(myId);
this.renderSidebar();
return;
}
this.currentType = typeGroup.type;
this.currentPathIdx = typeGroup.path_idx;
this.currentSubfolder = path;
this.detailPanel.style.display = 'none';
this.grid.style.display = 'grid';
this.renderSidebar();
this.loadModels();
};
this.sidebar.appendChild(item);
});
});
}
async loadModels() {
try {
const params = new URLSearchParams({ type: this.currentType, path_idx: this.currentPathIdx, subfolder: this.currentSubfolder });
const res = await fetch('/anomalous/models?' + params.toString());
const data = await res.json();
if (window.anomalous_update_hash_cache && data.models) {
window.anomalous_update_hash_cache(data.models);
}
this.grid.innerHTML = '';
if (!data.models || data.models.length === 0) {
this.grid.innerHTML = `<div style="color:white; padding:20px;">${t('noModels')}</div>`;
return;
}
data.models.forEach(model => {
const card = document.createElement('div');
card.className = 'anomalous-card';
if (model.preview_url) {
const isVideo = model.preview_url.toLowerCase().endsWith('.mp4') || model.preview_url.toLowerCase().endsWith('.webm');
if (isVideo) {
const video = document.createElement('video');
video.src = model.preview_url;
video.muted = true; video.loop = true; video.playsInline = true;
if (this.energySaving) {
video.pause();
card.addEventListener('mouseenter', () => video.play().catch(e => { }));
card.addEventListener('mouseleave', () => { video.pause(); video.currentTime = 0; });
} else {
video.autoplay = true;
}
card.appendChild(video);
} else {
const img = document.createElement('img');
img.src = model.preview_url;
card.appendChild(img);
}
} else {
const ph = document.createElement('div');
ph.className = 'anomalous-card-placeholder';
ph.innerHTML = `<div style="text-align:center;color:#666;margin-top:80px;">${t('noPreview')}</div><div style="font-size:0.8em;text-align:center;opacity:0.5;margin-top:5px">${t('clickScan')}</div>`;
card.appendChild(ph);
}
const title = document.createElement('div');
if (model.metadata && model.metadata.baseModel) {
const badge = document.createElement('div');
badge.innerText = model.metadata.baseModel;
badge.style.position = 'absolute';
badge.style.top = '6px';
badge.style.left = '6px';
badge.style.background = 'rgba(0,0,0,0.85)';
badge.style.color = '#00ffcc';
badge.style.padding = '3px 6px';
badge.style.borderRadius = '4px';
badge.style.fontSize = '0.75em';
badge.style.fontWeight = 'bold';
badge.style.border = '1px solid rgba(0,255,204,0.3)';
badge.style.pointerEvents = 'none';
badge.style.zIndex = '10';
card.appendChild(badge);
}
title.className = 'anomalous-card-title';
title.innerText = model.filename;
card.appendChild(title);
card.onclick = () => { this.historyStack = []; this.currentDetailModel = model; this.showDetail(model); };
const applyBtn = document.createElement('button');
applyBtn.innerHTML = '➕';
applyBtn.title = t('applyToCanvas');
applyBtn.style.position = 'absolute';
applyBtn.style.top = '6px';
applyBtn.style.right = '6px';
applyBtn.style.background = 'rgba(0,0,0,0.7)';
applyBtn.style.color = '#fff';
applyBtn.style.border = '1px solid rgba(255,255,255,0.2)';
applyBtn.style.borderRadius = '4px';
applyBtn.style.cursor = 'pointer';
applyBtn.style.padding = '4px 6px';
applyBtn.style.zIndex = '20';
applyBtn.style.fontSize = '1em';
applyBtn.style.display = 'none';
card.addEventListener('mouseenter', () => applyBtn.style.display = 'block');
card.addEventListener('mouseleave', () => applyBtn.style.display = 'none');
applyBtn.onclick = (e) => {
e.stopPropagation();
this.applyModelToCanvas(this.currentType, this.currentSubfolder, model);
};
card.appendChild(applyBtn);
this.grid.appendChild(card);
});
} catch (e) { }
}
applyModelToCanvas(type, subfolder, model) {
const nodeTypeMap = {
'checkpoints': 'CheckpointLoaderSimple',
'loras': 'LoraLoader',
'unet': 'UNETLoader',
'diffusion_models': 'UNETLoader'
};
const nodeType = nodeTypeMap[type];
if (!nodeType) {
alert('Unsupported model type for auto-apply.');
return;
}
const node = LiteGraph.createNode(nodeType);
if (!node) {
alert('Failed to create node: ' + nodeType);
return;
}
if (app.canvas && app.canvas.graph_mouse) {
node.pos = [
app.canvas.graph_mouse[0] || (window.innerWidth / 2),
app.canvas.graph_mouse[1] || (window.innerHeight / 2)
];
} else {
node.pos = [window.innerWidth / 2, window.innerHeight / 2];
}
app.graph.add(node);
const sub = subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const relPath = sub ? `${sub}/${model.filename}` : model.filename;
this.setWidgetValuePath(node, relPath);
this.close();
// 粘到鼠标上的逻辑
let isSticking = true;
const stickHandler = (e) => {
if (!isSticking || !app.canvas) return;
const canvas = app.canvas;
// LiteGraph内置了坐标转换,它会完美处理缩放和偏移带来的坐标偏移问题
let canvasX, canvasY;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
canvasX = pos[0];
canvasY = pos[1];
} else {
// 如果API不可用,使用标准降级计算
const rect = canvas.canvas.getBoundingClientRect();
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
}
node.pos = [canvasX - node.size[0] / 2, canvasY - 20];
canvas.setDirty(true, true);
};
const dropHandler = (e) => {
isSticking = false;
window.removeEventListener('mousemove', stickHandler, true);
window.removeEventListener('pointerdown', dropHandler, true);
window.removeEventListener('mousedown', dropHandler, true);
window.removeEventListener('click', dropHandler, true);
};
window.addEventListener('mousemove', stickHandler, true);
setTimeout(() => {
window.addEventListener('pointerdown', dropHandler, true);
window.addEventListener('mousedown', dropHandler, true);
window.addEventListener('click', dropHandler, true);
}, 100);
}
showDetail(model) {
if (this.currentDetailObserver) {
this.currentDetailObserver.disconnect();
this.currentDetailObserver = null;
}
this.grid.style.display = 'none';
this.detailPanel.style.display = 'flex';
this.detailPanel.innerHTML = '';
const header = document.createElement('div');
header.style.width = '100%';
header.style.padding = '8px 15px';
header.style.background = 'var(--comfy-menu-bg, #333)';
header.style.borderBottom = '1px solid var(--border-color, #444)';
header.style.display = 'flex';
header.style.alignItems = 'center';
header.style.boxSizing = 'border-box';
const backBtn = document.createElement('button');
backBtn.innerHTML = this.historyStack.length > 0 ? t('backToPrev') : t('back');
backBtn.style.padding = '6px 12px';
backBtn.style.background = '#444';
backBtn.style.color = '#fff';
backBtn.style.border = 'none';
backBtn.style.borderRadius = '4px';
backBtn.style.cursor = 'pointer';
backBtn.onclick = () => {
if (this.currentDetailObserver) {
this.currentDetailObserver.disconnect();
this.currentDetailObserver = null;
}
if (this.historyStack.length > 0) {
const prev = this.historyStack.pop();
this.currentType = prev.type;
this.currentPathIdx = prev.pathIdx;
this.currentSubfolder = prev.subfolder;
this.currentDetailModel = prev.model;
this.renderSidebar();
this.showDetail(prev.model);
} else {
this.detailPanel.style.display = 'none';
this.detailPanel.innerHTML = '';
this.grid.style.display = 'grid';
}
};
const title = document.createElement('h2');
title.innerHTML = model.filename;
title.style.margin = '0 20px 0 20px';
title.style.color = '#fff';
title.style.fontSize = '1.2em';
// 强制单行并溢出显示省略号
title.style.whiteSpace = 'nowrap';
title.style.overflow = 'hidden';
title.style.textOverflow = 'ellipsis';
title.style.flex = '1'; // 撑开剩余空间,把右侧按钮挤到最右边
const delBtn = document.createElement('button');
delBtn.innerHTML = t('delModel');
delBtn.style.padding = '6px 12px';
delBtn.style.background = '#ff4444';
delBtn.style.color = '#fff';
delBtn.style.border = 'none';
delBtn.style.borderRadius = '4px';
delBtn.style.cursor = 'pointer';
delBtn.style.marginLeft = 'auto'; // push to the right
delBtn.style.whiteSpace = 'nowrap';
delBtn.onclick = async () => {
if (!confirm(`${t('delConfirm')} ${model.filename} ${t('delConfirm2')}`)) return;
try {
const res = await fetch('/anomalous/delete_model', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type: this.currentType,
path_idx: this.currentPathIdx,
subfolder: this.currentSubfolder,
filename: model.filename
})
});
const data = await res.json();
if (data.status === 'success') {
if (this.currentDetailObserver) {
this.currentDetailObserver.disconnect();
this.currentDetailObserver = null;
}
alert(t('delSuccess') + data.deleted.join('\n') + t('delNote'));
this.detailPanel.style.display = 'none';
this.detailPanel.innerHTML = '';
this.grid.style.display = 'grid';
this.loadModels(); // refresh grid
} else {
alert(t('delFail') + data.message);
}
} catch (e) {
alert(t('delFail') + e.message);
}
};
const jumpBtn = document.createElement('button');
jumpBtn.innerHTML = '⬇️';
jumpBtn.title = t('jumpToBottom') || 'Jump to bottom';
jumpBtn.style.padding = '6px 12px';
jumpBtn.style.background = '#444';
jumpBtn.style.color = '#fff';
jumpBtn.style.border = 'none';
jumpBtn.style.borderRadius = '4px';
jumpBtn.style.cursor = 'pointer';
jumpBtn.style.marginLeft = '10px';
jumpBtn.onclick = () => {
// Find rightPanel which is created later, so we bind it dynamically
const rp = this.detailPanel.querySelector('.anomalous-split-right');
if (rp) rp.scrollTo({ top: rp.scrollHeight, behavior: 'smooth' });
};
header.appendChild(backBtn);
header.appendChild(title);
header.appendChild(delBtn);
header.appendChild(jumpBtn);
const applyDetailBtn = document.createElement('button');
applyDetailBtn.innerHTML = t('applyToCanvas') || 'Apply to Canvas';
applyDetailBtn.style.padding = '6px 12px';
applyDetailBtn.style.background = '#007bff';
applyDetailBtn.style.color = '#fff';
applyDetailBtn.style.border = 'none';
applyDetailBtn.style.borderRadius = '4px';
applyDetailBtn.style.cursor = 'pointer';
applyDetailBtn.style.marginLeft = '10px';
applyDetailBtn.style.fontWeight = 'bold';
applyDetailBtn.onclick = () => {
this.applyModelToCanvas(this.currentType, this.currentSubfolder, model);
};
header.appendChild(applyDetailBtn);
const splitContainer = document.createElement('div');
splitContainer.className = 'anomalous-split-container';
const leftPanel = document.createElement('div');
leftPanel.className = 'anomalous-split-left';
let isMediaRendered = null;
const renderMedia = (shouldRender) => {
if (shouldRender === isMediaRendered) return;
isMediaRendered = shouldRender;
leftPanel.innerHTML = '';
if (!shouldRender) return;
if (model.preview_url) {
const isVideo = model.preview_url.toLowerCase().endsWith('.mp4') || model.preview_url.toLowerCase().endsWith('.webm');
if (isVideo) {
const video = document.createElement('video');
video.src = model.preview_url;
video.controls = true;
video.autoplay = true;
video.loop = true;
video.style.width = '100%';
video.style.height = '100%';
video.style.objectFit = 'contain';
leftPanel.appendChild(video);
} else {
const img = document.createElement('img');
img.src = model.preview_url;
img.style.width = '100%';
img.style.height = '100%';
img.style.objectFit = 'contain';
leftPanel.appendChild(img);
}
} else {
leftPanel.innerHTML = `<div style="color:#aaa; text-align:center; margin-top:50px;">${t('noPreview')}</div>`;
}
};
const containerEl = document.getElementById('anomalous-container');
this.currentDetailObserver = new ResizeObserver(entries => {
for (let entry of entries) {
renderMedia(entry.contentRect.width >= 750);
}
});
this.currentDetailObserver.observe(containerEl);
const resizer = document.createElement('div');
resizer.className = 'anomalous-resizer';
let isResizing = false;
resizer.addEventListener('mousedown', (e) => {
isResizing = true;
document.body.style.cursor = 'col-resize';
e.preventDefault();
});
window.addEventListener('mousemove', (e) => {
if (!isResizing) return;
const containerRect = splitContainer.getBoundingClientRect();
let newWidth = ((e.clientX - containerRect.left) / containerRect.width) * 100;
if (newWidth < 20) newWidth = 20;
if (newWidth > 80) newWidth = 80;
leftPanel.style.width = newWidth + '%';
rightPanel.style.width = (100 - newWidth) + '%';
});
window.addEventListener('mouseup', () => {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
}
});
const rightPanel = document.createElement('div');
rightPanel.className = 'anomalous-split-right';
rightPanel.style.display = 'flex';
rightPanel.style.flexDirection = 'column';
rightPanel.style.height = '100%';
rightPanel.style.boxSizing = 'border-box';
rightPanel.style.overflow = 'auto';
rightPanel.style.padding = '15px';
rightPanel.style.color = '#eee';
const m = model.metadata || {};
// 1. Top Bar (Title + Size + Model + Button) in a single compact row if possible
const topRow = document.createElement('div');
topRow.style.flexShrink = '0';
topRow.style.display = 'flex';
topRow.style.flexWrap = 'wrap';
topRow.style.alignItems = 'center';
topRow.style.gap = '10px';
topRow.style.paddingBottom = '10px';
topRow.style.marginBottom = '10px';
topRow.style.borderBottom = '1px solid #444';
const titleEl = document.createElement('h3');
titleEl.style.margin = '0';
titleEl.style.fontSize = '1.3em';
titleEl.style.marginRight = '10px';
titleEl.innerText = m.name || model.filename;
topRow.appendChild(titleEl);
const metaSpan = document.createElement('span');
metaSpan.style.fontSize = '0.9em';
metaSpan.style.color = '#aaa';
metaSpan.innerHTML = `<strong>Size:</strong> ${model.size_mb} MB` + (m.baseModel ? ` <strong style="margin-left:10px;">Base:</strong> ${m.baseModel}` : '');
topRow.appendChild(metaSpan);
if (m.civitai_url) {
const cBtn = document.createElement('a');
cBtn.href = m.civitai_url;
cBtn.target = '_blank';
cBtn.innerHTML = '🌐 Civitai';
cBtn.style.marginLeft = 'auto';
cBtn.style.padding = '4px 8px';
cBtn.style.background = '#1a73e8';
cBtn.style.color = '#fff';
cBtn.style.textDecoration = 'none';
cBtn.style.borderRadius = '4px';
cBtn.style.fontSize = '0.85em';
cBtn.style.fontWeight = 'bold';
topRow.appendChild(cBtn);
}
rightPanel.appendChild(topRow);
// 2. Trained Words Section
if (m.trainedWords && m.trainedWords.length > 0) {
const twCont = document.createElement('div');
twCont.style.flexShrink = '0';
twCont.style.marginBottom = '10px';
const twHeader = document.createElement('div');
twHeader.style.display = 'flex';
twHeader.style.alignItems = 'center';
twHeader.style.marginBottom = '5px';
const twLabel = document.createElement('strong');
twLabel.innerText = 'Trained Words:';
twHeader.appendChild(twLabel);
const copyAll = document.createElement('button');
copyAll.innerText = t('copyAll');
copyAll.style.marginLeft = '10px';
copyAll.style.padding = '2px 6px';
copyAll.style.background = '#444';
copyAll.style.color = '#fff';
copyAll.style.border = 'none';
copyAll.style.borderRadius = '3px';
copyAll.style.cursor = 'pointer';
copyAll.style.fontSize = '0.8em';
copyAll.onclick = () => {
const allWords = m.trainedWords.join(', ');
navigator.clipboard.writeText(allWords).then(() => {
const old = copyAll.innerText;
copyAll.innerText = t('copied');
setTimeout(() => { copyAll.innerText = old; }, 1500);
});
};
twHeader.appendChild(copyAll);
twCont.appendChild(twHeader);
const tagsCont = document.createElement('div');
tagsCont.style.display = 'flex';
tagsCont.style.flexWrap = 'wrap';
tagsCont.style.gap = '4px';
m.trainedWords.forEach(w => {
const tag = document.createElement('span');
tag.innerText = w;
tag.style.background = '#333';
tag.style.padding = '2px 6px';
tag.style.borderRadius = '4px';
tag.style.fontSize = '0.85em';
tag.style.cursor = 'pointer';
tag.style.border = '1px solid #555';
tag.title = t('clickToCopy') + w;
tag.onclick = () => {
navigator.clipboard.writeText(w).then(() => {
tag.style.background = '#28a745';
setTimeout(() => { tag.style.background = '#333'; }, 500);
});
};
tagsCont.appendChild(tag);
});
twCont.appendChild(tagsCont);
rightPanel.appendChild(twCont);
}
// 3. Description Section (Expands to fill remaining height)
if (m.description) {
const descCont = document.createElement('div');
descCont.style.flex = 'none';
descCont.style.display = 'flex';
descCont.style.flexDirection = 'column';
// important for flex scroll
const descLabel = document.createElement('strong');
descLabel.innerText = 'Description:';
descLabel.style.marginBottom = '5px';
descCont.appendChild(descLabel);
const descText = document.createElement('div');
descText.style.flex = 'none';
descText.style.background = '#222';
descText.style.padding = '10px';
descText.style.borderRadius = '6px';
descText.style.border = '1px solid #333';
descText.style.fontSize = '0.95em';
descText.style.lineHeight = '1.4';
descText.innerHTML = m.description;
descCont.appendChild(descText);
rightPanel.appendChild(descCont);
}
// 4. Notes Section
if (m.notes) {
const notesCont = document.createElement('div');
notesCont.style.flexShrink = '0';
notesCont.style.marginTop = '10px';
const notesLabel = document.createElement('strong');
notesLabel.innerText = 'Notes:';
notesCont.appendChild(notesLabel);
const notesText = document.createElement('div');
notesText.style.background = '#332b00';
notesText.style.padding = '8px';
notesText.style.borderRadius = '6px';
notesText.style.marginTop = '5px';
notesText.style.border = '1px solid #554400';
notesText.style.fontSize = '0.9em';
notesText.innerHTML = m.notes;
notesCont.appendChild(notesText);
rightPanel.appendChild(notesCont);
}
// --- Compatible Models Section ---
if (m.baseModel) {
const compSec = document.createElement('div');
compSec.className = 'anomalous-compatible-section';
const compTitle = document.createElement('div');
compTitle.className = 'anomalous-compatible-title';
compTitle.innerHTML = `${t('compatibleModels') || '🔗 Compatible'} <span style="font-size:0.8em; opacity:0.6;">(${m.baseModel})</span>`;
const compList = document.createElement('div');
compList.className = 'anomalous-compatible-list';
compList.innerHTML = `<span style="color:#888;">${t('loadingCompatible') || 'Loading...'}</span>`;
compSec.appendChild(compTitle);
compSec.appendChild(compList);
rightPanel.appendChild(compSec);
const targetType = this.currentType === 'loras' ? 'checkpoints,unet,diffusion_models' : 'loras';
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(m.baseModel)}&target_type=${encodeURIComponent(targetType)}`)
.then(r => r.json())
.then(d => {
compList.innerHTML = '';
if (window.anomalous_update_hash_cache && d.models) {
window.anomalous_update_hash_cache(d.models);
}
if (!d.models || d.models.length === 0) {
compList.innerHTML = `<span style="color:#888;">No compatible models found.</span>`;
return;
}
d.models.forEach(m_comp => {
const mItem = document.createElement('div');
mItem.className = 'anomalous-compatible-item';
mItem.title = m_comp.filename;
let thumb = '';
if (m_comp.preview_url) {
const isVid = m_comp.preview_url.toLowerCase().endsWith('.mp4') || m_comp.preview_url.toLowerCase().endsWith('.webm');
if (isVid) thumb = `<video src="${m_comp.preview_url}" muted loop playsinline></video>`;
else thumb = `<img src="${m_comp.preview_url}" />`;
} else {
thumb = `<div style="width:30px; height:30px; background:#222; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:10px; color:#555;">?</div>`;
}
mItem.innerHTML = `${thumb}<div class="anomalous-compatible-item-name">${m_comp.filename}</div>`;
if (m_comp.preview_url && (m_comp.preview_url.toLowerCase().endsWith('.mp4') || m_comp.preview_url.toLowerCase().endsWith('.webm'))) {
mItem.onmouseenter = () => { const v = mItem.querySelector('video'); if (v) v.play().catch(e => { }); };
mItem.onmouseleave = () => { const v = mItem.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
mItem.onclick = () => {
this.historyStack.push({
type: this.currentType,
pathIdx: this.currentPathIdx,
subfolder: this.currentSubfolder,
model: this.currentDetailModel
});
this.currentType = m_comp.type;
this.currentPathIdx = m_comp.path_idx;
this.currentSubfolder = m_comp.subfolder;
this.currentDetailModel = m_comp;
this.renderSidebar();
this.showDetail(m_comp);
};
compList.appendChild(mItem);
});
})
.catch(e => {
compList.innerHTML = `<span style="color:#ff4444;">Failed to load.</span>`;
});
}
// ---------------------------------
splitContainer.appendChild(leftPanel);
splitContainer.appendChild(resizer);
splitContainer.appendChild(rightPanel);
this.detailPanel.appendChild(header);
this.detailPanel.appendChild(splitContainer);
}
show() {
this.modal.classList.add('visible');
if (!this.foldersData) {
this.loadFolders();
} else {
this.loadModels();
}
}
close() { this.modal.classList.remove('visible'); }
async showNotebooks() {
if (this.nbInitialized) {
this.refreshNotebooks(true);
return;
}
this.nbInitialized = true;
this.nbPanel.innerHTML = '';
const nbContainer = document.createElement('div');
nbContainer.className = 'anomalous-nb-container';
const nbHeader = document.createElement('div');
nbHeader.className = 'anomalous-nb-header';
nbHeader.innerHTML = `<h2>${t('notebookTitle')}</h2>`;
const closeNb = document.createElement('span');
closeNb.className = 'anomalous-nb-close';
closeNb.innerHTML = '&times;';
closeNb.onclick = () => { this.nbPanel.style.display = 'none'; };
nbHeader.appendChild(closeNb);
const body = document.createElement('div');
body.className = 'anomalous-nb-body';
// Sidebar for notebooks list
const sidebar = document.createElement('div');
sidebar.className = 'anomalous-nb-sidebar';
const nbList = document.createElement('div');
nbList.className = 'anomalous-nb-list';
const btnRow = document.createElement('div');
btnRow.style.padding = '10px';
btnRow.style.display = 'flex';
btnRow.style.gap = '5px';
const createBtn = document.createElement('button');
createBtn.innerHTML = `+ <span class="anomalous-nb-create-text">${t('createNotebook')}</span>`;
createBtn.className = 'anomalous-btn-primary';
const createInput = document.createElement('input');
createInput.type = 'text';
createInput.placeholder = t('newNotebookName');
createInput.style.display = 'none';
createInput.style.flex = '1';
createInput.style.padding = '4px';
createInput.style.background = '#222';
createInput.style.color = '#fff';
createInput.style.border = '1px solid #555';
createInput.style.borderRadius = '4px';
createBtn.onclick = () => {
if (createInput.style.display === 'none') {
createInput.style.display = 'block';
createBtn.innerHTML = '✓';
createInput.focus();
} else {
const name = createInput.value.trim();
if (name) {
this.currentNotebook = { filename: name + '.json', name: name, data: { baseModel: '', mainModel: null, loras: [], promptEn: '', promptZh: '' } };
this.saveCurrentNotebook();
this.renderNotebookEditor();
createInput.value = '';
}
createInput.style.display = 'none';
createBtn.innerHTML = `+ <span class="anomalous-nb-create-text">${t('createNotebook')}</span>`;
}
};
btnRow.appendChild(createInput);
btnRow.appendChild(createBtn);
sidebar.appendChild(btnRow);
sidebar.appendChild(nbList);
// Editor area
this.nbEditor = document.createElement('div');
this.nbEditor.className = 'anomalous-nb-editor';
body.appendChild(sidebar);
body.appendChild(this.nbEditor);
nbContainer.appendChild(nbHeader);
nbContainer.appendChild(body);
this.nbPanel.appendChild(nbContainer);
this.nbListEl = nbList;
this.refreshNotebooks(true);
}
async translateText(text) {
if (!text || !text.trim()) return "";
try {
const res = await fetch('/anomalous/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text, target_lang: currentLang === 'zh' ? 'zh-CN' : 'en' })
});
const data = await res.json();
return data.translated || text;
} catch (e) { return text; }
}
async loadGalleryImages(page = 1, reset = false) {
if (this.galleryLoading) return;
this.galleryLoading = true;
this.gallerySentinel.innerHTML = '加载中... / Loading...';
try {
const res = await fetch(`/anomalous/gallery_images?page=${page}&limit=50`);
const data = await res.json();
if (reset) {
// Clear existing cards
const cards = this.galleryGrid.querySelectorAll('.anomalous-gallery-card');
cards.forEach(c => c.remove());
this.galleryLoaded = true;
}
if (data.images && data.images.length > 0) {
data.images.forEach(imgData => {
const card = document.createElement('div');
card.className = 'anomalous-gallery-card';
const q_sub = encodeURIComponent(imgData.subfolder);
const q_file = encodeURIComponent(imgData.filename);
const imgUrl = `/view?filename=${q_file}&subfolder=${q_sub}&type=output`;
const img = document.createElement('img');
img.src = imgUrl;
img.loading = 'lazy';
img.draggable = true;
// Drag and drop support for ComfyUI
img.addEventListener('dragstart', (e) => {
const fullUrl = new URL(imgUrl, window.location.href).href;
e.dataTransfer.setData('text/uri-list', fullUrl);
e.dataTransfer.setData('text/plain', fullUrl);
});
// Click to view
img.onclick = () => this.showGalleryViewer(imgUrl);
const delBtn = document.createElement('button');
delBtn.className = 'anomalous-gallery-delete';
delBtn.innerHTML = '🗑️';
delBtn.title = 'Delete';
delBtn.onclick = (e) => {
e.stopPropagation();
const overlay = document.createElement('div');
overlay.style.position = 'absolute';
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.background = 'rgba(0,0,0,0.85)';
overlay.style.display = 'flex';
overlay.style.flexDirection = 'column';
overlay.style.alignItems = 'center';
overlay.style.justifyContent = 'center';
overlay.style.gap = '15px';
overlay.style.zIndex = '10';
const msg = document.createElement('div');
msg.innerHTML = '彻底删除这张图片?<br><span style="font-size:0.8em;color:#aaa">Delete permanently?</span>';
msg.style.color = '#fff';
msg.style.fontWeight = 'bold';
msg.style.textAlign = 'center';
const btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.gap = '12px';
const confirmBtn = document.createElement('button');
confirmBtn.innerHTML = '🗑️ 删除 (Delete)';
confirmBtn.style.background = '#dc3545';
confirmBtn.style.color = '#fff';
confirmBtn.style.border = 'none';
confirmBtn.style.padding = '10px 16px';
confirmBtn.style.borderRadius = '6px';
confirmBtn.style.cursor = 'pointer';
confirmBtn.style.fontWeight = 'bold';
confirmBtn.style.transition = 'background 0.2s';
confirmBtn.onmouseover = () => confirmBtn.style.background = '#ff0000';
confirmBtn.onmouseout = () => confirmBtn.style.background = '#dc3545';
const cancelBtn = document.createElement('button');
cancelBtn.innerHTML = '取消 (Cancel)';
cancelBtn.style.background = '#444';
cancelBtn.style.color = '#fff';
cancelBtn.style.border = 'none';
cancelBtn.style.padding = '10px 16px';
cancelBtn.style.borderRadius = '6px';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.transition = 'background 0.2s';
cancelBtn.onmouseover = () => cancelBtn.style.background = '#666';
cancelBtn.onmouseout = () => cancelBtn.style.background = '#444';
cancelBtn.onclick = (ce) => {
ce.stopPropagation();
overlay.remove();
};
confirmBtn.onclick = async (ce) => {
ce.stopPropagation();
confirmBtn.innerHTML = 'Deleting...';
confirmBtn.disabled = true;
try {
const dr = await fetch('/anomalous/delete_gallery_image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: imgData.filename, subfolder: imgData.subfolder })
});
const dd = await dr.json();
if (dd.status === 'success') {
card.remove();
} else {
alert('删除失败 / Delete failed: ' + dd.message);
overlay.remove();
}
} catch (err) {
alert('Error: ' + err);
overlay.remove();
}
};
btnRow.appendChild(cancelBtn);
btnRow.appendChild(confirmBtn);
overlay.appendChild(msg);
overlay.appendChild(btnRow);
card.appendChild(overlay);
};
card.appendChild(img);
card.appendChild(delBtn);
this.galleryGrid.insertBefore(card, this.gallerySentinel);
});
this.galleryCurrentPage = page;
this.galleryHasMore = page < data.pages;
if (!this.galleryHasMore) {
this.gallerySentinel.innerHTML = '没有更多图片了 / No more images';
} else {
this.gallerySentinel.innerHTML = '向下滚动加载更多 / Scroll for more';
}
} else {
this.galleryHasMore = false;
this.gallerySentinel.innerHTML = reset ? '图库为空 / Gallery is empty' : '没有更多图片了 / No more images';
}
} catch (e) {
console.error('Failed to load gallery images', e);
this.gallerySentinel.innerHTML = '加载失败 / Load failed';
}
this.galleryLoading = false;
}
showGalleryViewer(src) {
let viewer = document.getElementById('anomalous-gallery-viewer');
if (!viewer) {
viewer = document.createElement('div');
viewer.id = 'anomalous-gallery-viewer';
viewer.className = 'anomalous-gallery-viewer';
const closeBtn = document.createElement('div');
closeBtn.className = 'anomalous-gallery-viewer-close';
closeBtn.innerHTML = '&times;';
closeBtn.onclick = () => { viewer.style.display = 'none'; };
const img = document.createElement('img');
img.id = 'anomalous-gallery-viewer-img';
viewer.appendChild(img);
viewer.appendChild(closeBtn);
viewer.onclick = (e) => {
if (e.target === viewer) viewer.style.display = 'none';
};
document.body.appendChild(viewer);
}
const img = document.getElementById('anomalous-gallery-viewer-img');
img.src = src;
viewer.style.display = 'flex';
}
async refreshNotebooks(autoOpenFirst = false) {
try {
const res = await fetch('/anomalous/notebooks');
const data = await res.json();
this.nbListEl.innerHTML = '';
if (data.notebooks && data.notebooks.length > 0) {
if (autoOpenFirst) {
if (!this.currentNotebook) {
this.currentNotebook = data.notebooks[0];
}
if (this.currentNotebook) {
this.renderNotebookEditor();
}
}
data.notebooks.forEach(nb => {
const item = document.createElement('div');
item.className = 'anomalous-nb-item';
if (this.currentNotebook && this.currentNotebook.filename === nb.filename) {
item.classList.add('active');
}
item.innerHTML = `<span class="anomalous-nb-item-icon">📄&nbsp;</span><span class="anomalous-nb-item-text">${nb.name}</span>`;
item.onclick = () => {
this.currentNotebook = nb;
this.renderNotebookEditor();
this.refreshNotebooks();
};
this.nbListEl.appendChild(item);
});
}
} catch (e) { }
}
async saveCurrentNotebook() {
if (!this.currentNotebook) return;
try {
await fetch('/anomalous/save_notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.currentNotebook)
});
this.refreshNotebooks();
} catch (e) { }
}
async deleteCurrentNotebook(skipConfirm = false) {
if (!this.currentNotebook) return;
if (!skipConfirm && !confirm(t('deleteNotebook') + ' ?')) return;
try {
await fetch('/anomalous/delete_notebook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: this.currentNotebook.filename })
});
this.currentNotebook = null;
this.nbEditor.innerHTML = '';
this.refreshNotebooks();
} catch (e) { }
}
renderNotebookEditor() {
this.nbEditor.innerHTML = '';
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
if (!data.loras) data.loras = [];
// Toolbar
const tb = document.createElement('div');
tb.className = 'anomalous-nb-toolbar';
const titleArea = document.createElement('h3');
titleArea.innerHTML = this.currentNotebook.name;
titleArea.style.margin = '0';
const rightBtns = document.createElement('div');
const saveBtn = document.createElement('button');
saveBtn.innerHTML = t('saveNotebook');
saveBtn.className = 'anomalous-btn-primary';
saveBtn.onclick = async () => {
const orig = saveBtn.innerHTML;
saveBtn.innerHTML = '⏳...';
await this.saveCurrentNotebook();
saveBtn.innerHTML = '✅';
saveBtn.style.background = '#2e8b57';
setTimeout(() => {
saveBtn.innerHTML = orig;
saveBtn.style.background = '';
}, 1500);
};
let delTimer = null;
const delContainer = document.createElement('span');
delContainer.style.display = 'inline-flex';
delContainer.style.alignItems = 'center';
const delBtn = document.createElement('button');
delBtn.innerHTML = t('deleteNotebook');
delBtn.className = 'anomalous-btn-danger';
const cancelDelBtn = document.createElement('button');
cancelDelBtn.innerHTML = '✕';
cancelDelBtn.className = 'anomalous-btn-danger';
cancelDelBtn.style.display = 'none';
cancelDelBtn.style.background = '#555';
cancelDelBtn.style.marginLeft = '2px';
cancelDelBtn.style.padding = '6px 8px';
delContainer.appendChild(delBtn);
delContainer.appendChild(cancelDelBtn);
const resetDel = () => {
clearTimeout(delTimer);
delBtn.innerHTML = t('deleteNotebook');
delBtn.style.background = '';
cancelDelBtn.style.display = 'none';
};
delBtn.onclick = () => {
if (delBtn.innerHTML === t('deleteNotebook')) {
delBtn.innerHTML = t('delSure');
delBtn.style.background = '#800';
cancelDelBtn.style.display = 'block';
delTimer = setTimeout(resetDel, 4000);
} else {
resetDel();
this.deleteCurrentNotebook(true);
}
};
cancelDelBtn.onclick = resetDel;
const sendBtn = document.createElement('button');
sendBtn.innerHTML = t('sendToCanvas');
sendBtn.className = 'anomalous-btn-success';
sendBtn.onclick = () => this.sendNotebookToCanvas();
rightBtns.appendChild(saveBtn);
rightBtns.appendChild(sendBtn);
rightBtns.appendChild(delContainer);
tb.appendChild(titleArea);
tb.appendChild(rightBtns);
// Settings / Models
const modelSection = document.createElement('div');
modelSection.className = 'anomalous-nb-section';
// Base Model
const baseRow = document.createElement('div');
baseRow.className = 'anomalous-nb-row';
baseRow.innerHTML = `<strong>${t('baseModel')}</strong>`;
const baseSelect = document.createElement('select');
baseSelect.className = 'anomalous-nb-select';
const buildSelect = (bases) => {
baseSelect.innerHTML = '';
bases.forEach(b => {
const opt = document.createElement('option');
opt.value = b; opt.text = b;
if (data.baseModel === b) opt.selected = true;
baseSelect.appendChild(opt);
});
if (!data.baseModel && bases.length > 0) data.baseModel = bases[0];
};
if (this.baseModelsCache) {
buildSelect(this.baseModelsCache);
} else {
const tempBases = ['SD 1.5', 'SD 2.1', 'SDXL', 'SD 3.0', 'SD 3.5', 'Flux.1', 'Pony', 'HunyuanVideo', 'LTX-Video', 'OmniGen'];
buildSelect(tempBases);
fetch('/anomalous/base_models').then(r => r.json()).then(d => {
if (d.base_models && d.base_models.length > 0) {
this.baseModelsCache = d.base_models;
buildSelect(this.baseModelsCache);
}
}).catch(e => { });
}
if (!data.baseModel) data.baseModel = 'SDXL';
baseSelect.onchange = () => {
data.baseModel = baseSelect.value;
data.mainModel = null;
data.loras = [];
this.saveCurrentNotebook();
this.renderNotebookEditor();
};
baseRow.appendChild(baseSelect);
// Main Model (Card Selection)
const mainBox = document.createElement('div');
mainBox.className = 'anomalous-nb-gallery-box';
const mainRow = document.createElement('div');
mainRow.className = 'anomalous-nb-row';
mainRow.innerHTML = `<strong>${t('mainModel')}</strong>`;
const mainGallery = document.createElement('div');
mainGallery.className = 'anomalous-nb-gallery-wrap';
mainBox.appendChild(mainRow);
mainBox.appendChild(mainGallery);
// Loras (Card Selection)
const loraBox = document.createElement('div');
loraBox.className = 'anomalous-nb-gallery-box';
const loraRow = document.createElement('div');
loraRow.className = 'anomalous-nb-row';
loraRow.innerHTML = `<strong>Loras</strong>`;
const loraGallery = document.createElement('div');
loraGallery.className = 'anomalous-nb-gallery-wrap';
loraBox.appendChild(loraRow);
loraBox.appendChild(loraGallery);
modelSection.appendChild(baseRow);
modelSection.appendChild(mainBox);
modelSection.appendChild(loraBox);
// Prompt Section
const promptSec = document.createElement('div');
promptSec.className = 'anomalous-nb-section';
// Toolbar
const pToolbar = document.createElement('div');
pToolbar.style.display = 'flex';
pToolbar.style.gap = '10px';
pToolbar.style.marginBottom = '10px';
pToolbar.style.alignItems = 'center';
const langSelect = document.createElement('select');
langSelect.className = 'anomalous-nb-select';
const langs = [
{ v: 'zh-CN', l: '🇨🇳 中文 (zh-CN)' }, { v: 'en', l: '🇬🇧 English (en)' },
{ v: 'ja', l: '🇯🇵 日本语 (ja)' }, { v: 'ko', l: '🇰🇷 한국어 (ko)' },
{ v: 'fr', l: '🇫🇷 Français (fr)' }, { v: 'de', l: '🇩🇪 Deutsch (de)' },
{ v: 'es', l: '🇪🇸 Español (es)' }, { v: 'ru', l: '🇷🇺 Русский (ru)' }
];
langs.forEach(lg => {
const opt = document.createElement('option');
opt.value = lg.v; opt.text = lg.l;
if ((data.targetLang || 'zh-CN') === lg.v) opt.selected = true;
langSelect.appendChild(opt);
});
langSelect.onchange = () => {
data.targetLang = langSelect.value;
data.translations = {}; // Clear translation cache on lang change
this.saveCurrentNotebook();
updateVisualTags();
};
const findInput = document.createElement('input');
findInput.className = 'anomalous-nb-select';
findInput.placeholder = t('findPlaceholder');
findInput.style.flex = '1';
const replaceInput = document.createElement('input');
replaceInput.className = 'anomalous-nb-select';
replaceInput.placeholder = t('replacePlaceholder');
replaceInput.style.flex = '1';
const replaceBtn = document.createElement('button');
replaceBtn.className = 'anomalous-btn-primary';
replaceBtn.innerHTML = t('replaceAll');
pToolbar.appendChild(langSelect);
pToolbar.appendChild(findInput);
pToolbar.appendChild(replaceInput);
pToolbar.appendChild(replaceBtn);
// Raw Input Toggle
const toggleRow = document.createElement('div');
toggleRow.style.display = 'flex';
toggleRow.style.justifyContent = 'space-between';
toggleRow.style.marginBottom = '5px';
toggleRow.innerHTML = `<strong>Prompt Editor</strong>`;
const rawBtn = document.createElement('button');
rawBtn.className = 'anomalous-btn-primary';
rawBtn.innerHTML = t('editRaw');
toggleRow.appendChild(rawBtn);
// Raw Textarea
const rawArea = document.createElement('textarea');
rawArea.className = 'anomalous-nb-textarea';
rawArea.value = data.promptEn || '';
rawArea.style.display = 'none';
rawArea.style.height = '150px';
// Visual Dual Pane
const dualPane = document.createElement('div');
dualPane.className = 'anomalous-nb-dual-pane';
if (!data.translations) data.translations = {};
const updateVisualTags = () => {
dualPane.innerHTML = '';
const txt = rawArea.value;
data.promptEn = txt;
this.saveCurrentNotebook();
if (!txt.trim()) return;
const tags = txt.split(',').map(s => s.trim()).filter(s => s);
tags.forEach((tag, idx) => {
const tagRow = document.createElement('div');
tagRow.className = 'anomalous-nb-tag-row';
const tagL = document.createElement('div');
tagL.className = 'anomalous-nb-visual-tag';
tagL.style.flex = '1';
tagL.style.justifyContent = 'space-between';
const txtL = document.createElement('span');
txtL.innerText = tag;
const copyL = document.createElement('span');
copyL.className = 'anomalous-nb-copy-btn';
copyL.innerHTML = '📋';
copyL.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(tag).then(() => { copyL.innerHTML = '✅'; setTimeout(() => copyL.innerHTML = '📋', 1000); });
};
tagL.appendChild(txtL);
tagL.appendChild(copyL);
const tagR = document.createElement('div');
tagR.className = 'anomalous-nb-visual-tag';
tagR.style.flex = '1';
tagR.style.justifyContent = 'space-between';
const transTxt = data.translations[tag] ? data.translations[tag] : '...';
const txtR = document.createElement('span');
txtR.innerText = transTxt;
const copyR = document.createElement('span');
copyR.className = 'anomalous-nb-copy-btn';
copyR.innerHTML = '📋';
copyR.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(txtR.innerText).then(() => { copyR.innerHTML = '✅'; setTimeout(() => copyR.innerHTML = '📋', 1000); });
};
tagR.appendChild(txtR);
tagR.appendChild(copyR);
tagL.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); };
tagL.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); };
tagR.onmouseenter = () => { tagL.classList.add('hover'); tagR.classList.add('hover'); };
tagR.onmouseleave = () => { tagL.classList.remove('hover'); tagR.classList.remove('hover'); };
tagL.onclick = () => {
const inp = document.createElement('input');
inp.value = tag; inp.className = 'anomalous-nb-tag-edit';
tagL.innerHTML = ''; tagL.appendChild(inp); inp.focus();
const finish = () => {
tags[idx] = inp.value.trim();
rawArea.value = tags.join(', ');
updateVisualTags();
};
inp.onblur = finish;
inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
};
tagR.onclick = () => {
const inp = document.createElement('input');
inp.value = data.translations[tag] || ''; inp.className = 'anomalous-nb-tag-edit';
tagR.innerHTML = ''; tagR.appendChild(inp); inp.focus();
const finish = () => {
data.translations[tag] = inp.value.trim();
this.saveCurrentNotebook();
updateVisualTags();
};
inp.onblur = finish;
inp.onkeydown = (e) => { if (e.key === 'Enter') inp.blur(); };
};
tagRow.appendChild(tagL);
tagRow.appendChild(tagR);
dualPane.appendChild(tagRow);
if (!data.translations[tag]) {
fetch('/anomalous/translate', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: tag, target_lang: data.targetLang || 'zh-CN' })
}).then(r => r.json()).then(d => {
if (d.translated) {
data.translations[tag] = d.translated;
txtR.innerText = d.translated;
this.saveCurrentNotebook();
}
}).catch(() => { });
}
});
};
rawBtn.onclick = () => {
if (rawArea.style.display === 'none') {
rawArea.style.display = 'block';
dualPane.style.display = 'none';
rawBtn.innerHTML = '👁️ Done Editing';
pToolbar.style.display = 'none';
} else {
rawArea.style.display = 'none';
dualPane.style.display = 'flex';
rawBtn.innerHTML = '📝 Edit Raw / Paste';
pToolbar.style.display = 'flex';
updateVisualTags();
}
};
replaceBtn.onclick = () => {
const findStr = findInput.value;
const repStr = replaceInput.value;
if (!findStr) return;
const newTxt = rawArea.value.split(findStr).join(repStr);
rawArea.value = newTxt;
updateVisualTags();
};
rawArea.oninput = () => {
clearTimeout(this.pTimeout);
this.pTimeout = setTimeout(() => { data.promptEn = rawArea.value; this.saveCurrentNotebook(); }, 500);
};
updateVisualTags();
promptSec.appendChild(pToolbar);
promptSec.appendChild(toggleRow);
promptSec.appendChild(rawArea);
promptSec.appendChild(dualPane);
this.nbEditor.appendChild(tb);
this.nbEditor.appendChild(modelSection);
this.nbEditor.appendChild(promptSec);
// Fetch compatible models and fill galleries
this.fillNotebookGalleries(data.baseModel, mainGallery, loraGallery, data);
}
fillNotebookGalleries(baseModel, mainGallery, loraGallery, data) {
if (!baseModel) return;
const buildThumbHtml = (m) => {
let thumb = '';
if (m.preview_url) {
const isVid = m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm');
if (isVid) thumb = `<video src="${m.preview_url}" muted loop playsinline></video>`;
else thumb = `<img src="${m.preview_url}" />`;
} else {
thumb = `<div style="width:30px; height:30px; background:#222; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:10px; color:#555;">?</div>`;
}
return thumb;
};
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=checkpoints,unet,diffusion_models`)
.then(r => r.json()).then(d => {
const buildMainDOM = (models) => {
mainGallery.innerHTML = '';
if (!models || !models.length) {
mainGallery.innerHTML = '<span style="color:#666;">No compatible main models found.</span>';
} else {
models.forEach(m => {
const isSelected = (data.mainModel && data.mainModel.filename === m.filename);
const card = document.createElement('div');
card.className = 'anomalous-nb-minicheck ' + (isSelected ? 'selected' : '');
card.innerHTML = `${buildThumbHtml(m)}<div class="anomalous-nb-minicheck-name" title="${m.filename}">${m.filename}</div>`;
if (m.preview_url && (m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm'))) {
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e => { }); };
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
card.onclick = () => {
data.mainModel = m;
this.saveCurrentNotebook();
buildMainDOM(models); // re-render just the main gallery
};
mainGallery.appendChild(card);
});
}
};
buildMainDOM(d.models || []);
});
fetch(`/anomalous/compatible_models?base_model=${encodeURIComponent(baseModel)}&target_type=loras`)
.then(r => r.json()).then(d => {
const buildLoraDOM = (models) => {
loraGallery.innerHTML = '';
if (!models || !models.length) {
loraGallery.innerHTML = '<span style="color:#666;">No compatible Loras found.</span>';
} else {
models.forEach(m => {
const loraIndex = data.loras.findIndex(l => l.filename === m.filename);
const isSelected = loraIndex !== -1;
const card = document.createElement('div');
card.className = 'anomalous-nb-minilora ' + (isSelected ? 'selected' : '');
card.style.position = 'relative'; // for badge positioning
let badgeHtml = '';
if (isSelected) {
badgeHtml = `<div style="position:absolute; top:-5px; right:-5px; background:#00ffcc; color:#000; border-radius:50%; width:20px; height:20px; font-size:12px; display:flex; align-items:center; justify-content:center; font-weight:bold; z-index:10; box-shadow: 0 2px 4px rgba(0,0,0,0.5);">${loraIndex + 1}</div>`;
}
card.innerHTML = `${badgeHtml}${buildThumbHtml(m)}<div class="anomalous-nb-minilora-name" title="${m.filename}">${m.filename}</div>`;
if (m.preview_url && (m.preview_url.toLowerCase().endsWith('.mp4') || m.preview_url.toLowerCase().endsWith('.webm'))) {
card.onmouseenter = () => { const v = card.querySelector('video'); if (v) v.play().catch(e => { }); };
card.onmouseleave = () => { const v = card.querySelector('video'); if (v) { v.pause(); v.currentTime = 0; } };
}
card.onclick = () => {
if (isSelected) {
data.loras = data.loras.filter(l => l.filename !== m.filename);
} else {
data.loras.push(m);
}
this.saveCurrentNotebook();
buildLoraDOM(models); // re-render just the lora gallery
};
loraGallery.appendChild(card);
});
}
};
buildLoraDOM(d.models || []);
});
}
sendNotebookToCanvas() {
if (!this.currentNotebook) return;
const data = this.currentNotebook.data || {};
if (!data.mainModel) {
alert("Please select a Main Model first.");
return;
}
const groupNodes = [];
const isUnet = data.mainModel.type === 'unet' || data.mainModel.type === 'diffusion_models';
const ckptNode = LiteGraph.createNode(isUnet ? "UNETLoader" : "CheckpointLoaderSimple");
app.graph.add(ckptNode);
groupNodes.push({ node: ckptNode, relX: 0, relY: 0 });
const sub = data.mainModel.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const relPath = sub ? `${sub}/${data.mainModel.filename}` : data.mainModel.filename;
this.setWidgetValuePath(ckptNode, relPath);
let lastNode = ckptNode;
let lastModelSlot = isUnet ? 0 : 0;
let lastClipSlot = isUnet ? null : 1;
let relX = 350;
let relY = 0;
data.loras.forEach((lora, idx) => {
const loraNode = LiteGraph.createNode("LoraLoader");
app.graph.add(loraNode);
groupNodes.push({ node: loraNode, relX: relX, relY: relY });
const lsub = lora.subfolder.replace(/^\/+/, '').replace(/\/+$/, '');
const lrelPath = lsub ? `${lsub}/${lora.filename}` : lora.filename;
this.setWidgetValuePath(loraNode, lrelPath);
lastNode.connect(lastModelSlot, loraNode, 0);
if (lastClipSlot !== null) lastNode.connect(lastClipSlot, loraNode, 1);
lastNode = loraNode;
lastModelSlot = 0;
lastClipSlot = 1;
relX += 350;
});
if (data.promptEn) {
const posNode = LiteGraph.createNode("CLIPTextEncode");
posNode.title = "CLIP Text Encode (Positive)";
app.graph.add(posNode);
groupNodes.push({ node: posNode, relX: relX, relY: 0 });
if (posNode.widgets && posNode.widgets.length > 0) {
const tw = posNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = data.promptEn;
}
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, posNode, 0);
}
const negNode = LiteGraph.createNode("CLIPTextEncode");
negNode.title = "CLIP Text Encode (Negative)";
app.graph.add(negNode);
groupNodes.push({ node: negNode, relX: relX, relY: 250 });
if (negNode.widgets && negNode.widgets.length > 0) {
const tw = negNode.widgets.find(w => w.name === 'text' || w.type === 'customtext');
if (tw) tw.value = "text, watermark, ugly, bad anatomy";
}
if (lastClipSlot !== null) {
lastNode.connect(lastClipSlot, negNode, 0);
}
}
this.close();
// Magnetic Sticking Logic
let isSticking = true;
const stickHandler = (e) => {
if (!isSticking || !app.canvas) return;
const canvas = app.canvas;
let canvasX, canvasY;
if (canvas.convertEventToCanvasOffset) {
const pos = canvas.convertEventToCanvasOffset(e);
canvasX = pos[0];
canvasY = pos[1];
} else {
const rect = canvas.canvas.getBoundingClientRect();
canvasX = (e.clientX - rect.left - canvas.ds.offset[0]) / canvas.ds.scale;
canvasY = (e.clientY - rect.top - canvas.ds.offset[1]) / canvas.ds.scale;
}
groupNodes.forEach(item => {
item.node.pos = [canvasX - (item.node.size[0] / 2) + item.relX, canvasY - 20 + item.relY];
});
canvas.setDirty(true, true);
};
const dropHandler = (e) => {
isSticking = false;
window.removeEventListener('mousemove', stickHandler, true);
window.removeEventListener('pointerdown', dropHandler, true);
window.removeEventListener('mousedown', dropHandler, true);
window.removeEventListener('click', dropHandler, true);
};
window.addEventListener('mousemove', stickHandler, true);
setTimeout(() => {
window.addEventListener('pointerdown', dropHandler, true);
window.addEventListener('mousedown', dropHandler, true);
window.addEventListener('click', dropHandler, true);
}, 100);
}
setWidgetValuePath(node, relPath) {
if (!node.widgets || node.widgets.length === 0) return;
const w = node.widgets.find(wg => wg.type === 'combo');
const targetWidget = w || node.widgets[0];
if (!targetWidget) return;
if (targetWidget.options && targetWidget.options.values) {
const normalizedTarget = relPath.replace(/\\/g, '/');
const match = targetWidget.options.values.find(v => {
return String(v).replace(/\\/g, '/') === normalizedTarget;
});
if (match) {
targetWidget.value = match;
return;
}
}
targetWidget.value = relPath;
}
}
app.registerExtension({
name: 'Anomalous.ModelBrowser',
async setup() {
const cssUrl = '/extensions/Anomalous_Model_Browser/styles.css?v=' + Date.now();
if (!document.querySelector(`link[href^="/extensions/Anomalous_Model_Browser/styles.css"]`)) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = cssUrl;
document.head.appendChild(link);
}
const browser = new AnomalousBrowser();
const btn = document.createElement('button');
btn.id = 'anomalous-trigger-btn';
btn.innerHTML = '📦';
btn.title = 'Open Anomalous Model Browser';
let isDragging = false;
let startX, startY, initialX, initialY;
btn.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX; startY = e.clientY;
const rect = btn.getBoundingClientRect();
initialX = rect.left; initialY = rect.top;
btn.style.transition = 'none';
});
window.addEventListener('mousemove', (e) => {
if (!isDragging) return;
e.preventDefault();
let newX = initialX + (e.clientX - startX);
let newY = initialY + (e.clientY - startY);
if (newX < 0) newX = 0;
if (newY < 0) newY = 0;
if (newX > window.innerWidth - 60) newX = window.innerWidth - 60;
if (newY > window.innerHeight - 60) newY = window.innerHeight - 60;
btn.style.left = newX + 'px';
btn.style.top = newY + 'px';
btn.style.right = 'auto'; btn.style.bottom = 'auto';
});
window.addEventListener('mouseup', (e) => {
if (isDragging) {
isDragging = false;
btn.style.transition = 'transform 0.15s, box-shadow 0.15s';
localStorage.setItem('anomalous_btn_x', btn.style.left);
localStorage.setItem('anomalous_btn_y', btn.style.top);
if (Math.abs(e.clientX - startX) < 5 && Math.abs(e.clientY - startY) < 5) browser.show();
}
});
const savedX = localStorage.getItem('anomalous_btn_x');
const savedY = localStorage.getItem('anomalous_btn_y');
const updateBtnBounds = () => {
let numX = parseInt(btn.style.left || savedX);
let numY = parseInt(btn.style.top || savedY);
if (isNaN(numX)) numX = window.innerWidth - 90;
if (isNaN(numY)) numY = window.innerHeight - 90;
if (numX < 0) numX = 0;
if (numY < 0) numY = 0;
if (numX > window.innerWidth - 60) numX = window.innerWidth - 60;
if (numY > window.innerHeight - 60) numY = window.innerHeight - 60;
btn.style.left = numX + 'px';
btn.style.top = numY + 'px';
};
if (savedX && savedY && savedX !== 'NaN' && savedY !== 'NaN') {
btn.style.right = 'auto';
btn.style.bottom = 'auto';
btn.style.left = savedX;
btn.style.top = savedY;
}
// Always trigger an update slightly after load to ensure it's in bounds
setTimeout(updateBtnBounds, 200);
window.addEventListener('resize', () => {
if (btn.style.left) updateBtnBounds();
});
document.body.appendChild(btn);
}
});