736 lines
32 KiB
JavaScript
736 lines
32 KiB
JavaScript
/**
|
|
* ui_model_sources.js
|
|
* 模型来源统一中控中心 (Model Source Hub)
|
|
*
|
|
* 核心功能:
|
|
* 1. 查看/编辑当前工作流中用到的模型来源链接 (Workflow Scope)
|
|
* 2. 盘点本地全部模型库已识别/缺失信息状态 (Library Scope)
|
|
* 3. 一键在画布生成原生 ComfyUI Note 便签节点 (显式工作流节点)
|
|
* 4. 同步/保存来源元数据至工作流 JSON/PNG extra 字段 (隐式元数据)
|
|
* 5. 一键浏览器直达对应发布页 (Civitai / Hugging Face / Liblib / 网盘等)
|
|
* 6. 持久化自定义链接至本地模型 .civitai.info (全系统自动回退)
|
|
*/
|
|
|
|
import { app } from '../../../scripts/app.js';
|
|
import { translate as t } from './locales.js';
|
|
import { text, jsonResponse } from './ui_dom.js';
|
|
import { createViewScope } from './ui_lifecycle.js';
|
|
import { showWorkbenchToast } from './ui_prompt_toast.js';
|
|
|
|
let activeSourcesModalScope = null;
|
|
|
|
export function detectPlatform(url) {
|
|
if (!url || typeof url !== 'string') return null;
|
|
const clean = url.trim();
|
|
if (!clean) return null;
|
|
if (/civitai\.(com|red)/i.test(clean)) return { name: 'Civitai', color: '#38bdf8', bg: 'rgba(56,189,248,0.15)', border: 'rgba(56,189,248,0.4)' };
|
|
if (/huggingface\.co/i.test(clean)) return { name: 'HuggingFace', color: '#fbbf24', bg: 'rgba(251,191,36,0.15)', border: 'rgba(251,191,36,0.4)' };
|
|
if (/liblib/i.test(clean)) return { name: 'LiblibAI', color: '#ec4899', bg: 'rgba(236,72,153,0.15)', border: 'rgba(236,72,153,0.4)' };
|
|
if (/modelscope\.cn/i.test(clean)) return { name: 'ModelScope', color: '#a855f7', bg: 'rgba(168,85,247,0.15)', border: 'rgba(168,85,247,0.4)' };
|
|
if (/github\.com/i.test(clean)) return { name: 'GitHub', color: '#34d399', bg: 'rgba(52,211,153,0.15)', border: 'rgba(52,211,153,0.4)' };
|
|
if (/pan\.baidu|123pan|quark|lanzou/i.test(clean)) return { name: 'CloudNet', color: '#06b6d4', bg: 'rgba(6,182,212,0.15)', border: 'rgba(6,182,212,0.4)' };
|
|
return { name: 'Web Link', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)', border: 'rgba(148,163,184,0.3)' };
|
|
}
|
|
|
|
export function isModelFilename(val) {
|
|
return typeof val === 'string' && /\.(safetensors|ckpt|pt|bin|sft)$/i.test(val);
|
|
}
|
|
|
|
export function normalizeUrl(url) {
|
|
if (!url) return '';
|
|
let trimmed = url.trim();
|
|
if (!trimmed) return '';
|
|
if (!/^https?:\/\//i.test(trimmed)) {
|
|
trimmed = 'https://' + trimmed;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
/** 提取当前画布上所有正在使用的模型 */
|
|
export function collectWorkflowModels() {
|
|
const models = [];
|
|
const seen = new Set();
|
|
const liveNodes = app.graph?._nodes || [];
|
|
const savedSources = app.graph?.extra?.anomalous_model_sources || {};
|
|
const savedHashes = app.graph?.extra?.anomalous_hashes || {};
|
|
|
|
for (const node of liveNodes) {
|
|
if (!Array.isArray(node.widgets)) continue;
|
|
for (const w of node.widgets) {
|
|
const val = w.value;
|
|
if (!isModelFilename(val)) continue;
|
|
|
|
const dedupeKey = `${node.id}_${val}`;
|
|
if (seen.has(dedupeKey)) continue;
|
|
seen.add(dedupeKey);
|
|
|
|
const basename = val.split(/[/\\]/).pop();
|
|
const isMissing = Boolean(w.options?.values && !w.options.values.includes(val));
|
|
const hashObj = savedHashes[dedupeKey] || savedHashes[val] || window.anomalous_hash_cache?.[val] || window.anomalous_hash_cache?.[basename] || {};
|
|
const hash = typeof hashObj === 'string' ? hashObj : (hashObj.hash || '');
|
|
|
|
const existingSource = savedSources[val] || savedSources[basename] || savedSources[dedupeKey];
|
|
let url = (typeof existingSource === 'string' ? existingSource : existingSource?.url) || hashObj?.url || hashObj?.civitai_url || '';
|
|
|
|
models.push({
|
|
key: dedupeKey,
|
|
nodeId: node.id,
|
|
nodeTitle: node.title || node.type || `Node #${node.id}`,
|
|
nodeType: node.type,
|
|
filename: val,
|
|
basename,
|
|
isMissing,
|
|
hash,
|
|
url,
|
|
initialUrl: url,
|
|
platform: detectPlatform(url),
|
|
});
|
|
}
|
|
}
|
|
return models;
|
|
}
|
|
|
|
/** 异步盘点本地全部模型库 */
|
|
export async function fetchAllLibraryModels(signal = null) {
|
|
try {
|
|
const res = await fetch('/anomalous/all_scan_models?limit=0', { signal });
|
|
const payload = await jsonResponse(res, 'load library models');
|
|
const rawList = Array.isArray(payload.models) ? payload.models : [];
|
|
|
|
return rawList.map(m => {
|
|
const meta = m.metadata || {};
|
|
const url = meta.source_url || meta.civitai_url || '';
|
|
const relPath = m.subfolder ? `${m.subfolder}/${m.filename}` : m.filename;
|
|
return {
|
|
key: `lib_${m.type}_${m.path_idx}_${relPath}`,
|
|
type: m.type,
|
|
path_idx: m.path_idx,
|
|
subfolder: m.subfolder || '',
|
|
filename: m.filename,
|
|
basename: m.filename,
|
|
relPath,
|
|
size_mb: m.size_mb || 0,
|
|
hash: meta.hash || '',
|
|
civitai_url: meta.civitai_url || '',
|
|
source_url: meta.source_url || '',
|
|
url,
|
|
initialUrl: url,
|
|
platform: detectPlatform(url),
|
|
hasResolved: Boolean(url.trim()),
|
|
};
|
|
});
|
|
} catch (e) {
|
|
if (signal?.aborted) return [];
|
|
console.error('[Model Source Hub] Failed to fetch library models', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** 在 ComfyUI 画布生成原生 Note 便签节点 */
|
|
export function createCanvasNoteNode(models) {
|
|
if (!models.length) return false;
|
|
const creator = (typeof LiteGraph !== 'undefined' ? LiteGraph?.createNode : null) || globalThis.LiteGraph?.createNode || (typeof window !== 'undefined' ? window.LiteGraph?.createNode : null);
|
|
if (typeof creator !== 'function') return false;
|
|
|
|
const noteNode = creator('Note');
|
|
if (!noteNode) return false;
|
|
|
|
const now = new Date();
|
|
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
|
|
let textContent = `═══════════════════════════════════════════════════════════════\n`;
|
|
textContent += `📋 工作流模型来源与下载直达 (Workflow Models & Sources)\n`;
|
|
textContent += `═══════════════════════════════════════════════════════════════\n`;
|
|
textContent += `• 生成时间: ${timeStr}\n`;
|
|
textContent += `• 模型总数: ${models.length} 个\n`;
|
|
textContent += `───────────────────────────────────────────────────────────────\n\n`;
|
|
|
|
models.forEach((m, idx) => {
|
|
textContent += `[${idx + 1}] ${m.nodeTitle || m.nodeType || 'Model'}\n`;
|
|
textContent += ` 文件: ${m.filename}\n`;
|
|
if (m.url) {
|
|
textContent += ` 来源: ${m.url}\n`;
|
|
} else {
|
|
textContent += ` 来源: ⚠️ 未配置来源发布页\n`;
|
|
}
|
|
if (m.hash) {
|
|
textContent += ` 哈希: ${m.hash.slice(0, 16)}...\n`;
|
|
}
|
|
textContent += `\n`;
|
|
});
|
|
|
|
textContent += `───────────────────────────────────────────────────────────────\n`;
|
|
textContent += `由 Anomalous Model Browser 模型来源中控中心一键生成`;
|
|
|
|
noteNode.title = window.anomalous_browser_lang === 'zh' ? '📋 模型来源清单 (Sources)' : '📋 Model Sources';
|
|
if (Array.isArray(noteNode.widgets) && noteNode.widgets.length > 0) {
|
|
noteNode.widgets[0].value = textContent;
|
|
}
|
|
|
|
// 计算合适坐标(排在画布最上方或靠左侧)
|
|
let minX = Infinity;
|
|
let minY = Infinity;
|
|
for (const n of app.graph._nodes || []) {
|
|
if (n.pos) {
|
|
if (n.pos[0] < minX) minX = n.pos[0];
|
|
if (n.pos[1] < minY) minY = n.pos[1];
|
|
}
|
|
}
|
|
if (!Number.isFinite(minX)) { minX = 100; minY = 100; }
|
|
noteNode.pos = [minX, minY - 320];
|
|
noteNode.size = [480, 260];
|
|
|
|
app.graph.add(noteNode);
|
|
app.canvas?.setDirty(true, true);
|
|
return noteNode;
|
|
}
|
|
|
|
/** 同步来源至工作流元数据 (extra.anomalous_model_sources) */
|
|
export function syncWorkflowSources(models) {
|
|
if (!app.graph) return { count: 0, isUpdate: false };
|
|
app.graph.extra ||= {};
|
|
const existing = app.graph.extra.anomalous_model_sources || {};
|
|
const isUpdate = Object.keys(existing).length > 0;
|
|
|
|
const updatedMap = { ...existing };
|
|
let savedCount = 0;
|
|
|
|
for (const m of models) {
|
|
if (!m.url) continue;
|
|
const normalized = normalizeUrl(m.url);
|
|
updatedMap[m.filename] = {
|
|
name: m.filename,
|
|
url: normalized,
|
|
platform: detectPlatform(normalized)?.name || 'Custom',
|
|
nodeId: m.nodeId,
|
|
hash: m.hash || '',
|
|
updated_at: Date.now(),
|
|
};
|
|
m.url = normalized;
|
|
m.initialUrl = normalized;
|
|
m.platform = detectPlatform(normalized);
|
|
savedCount++;
|
|
}
|
|
|
|
app.graph.extra.anomalous_model_sources = updatedMap;
|
|
app.canvas?.setDirty(true, true);
|
|
return { count: savedCount, isUpdate };
|
|
}
|
|
|
|
/** 复制 Markdown 格式清单 */
|
|
export async function copySourcesSummary(models) {
|
|
if (!models.length) return;
|
|
let md = `### 📋 工作流模型下载来源清单 (Workflow Models & Sources)\n\n`;
|
|
models.forEach((m, idx) => {
|
|
const plat = m.platform ? `[${m.platform.name}]` : '';
|
|
md += `${idx + 1}. **${m.nodeTitle || m.nodeType}** \`${m.basename || m.filename}\`\n`;
|
|
if (m.url) {
|
|
md += ` - 来源链接: ${plat} ${m.url}\n`;
|
|
} else {
|
|
md += ` - 来源链接: ⚠️ 未指定\n`;
|
|
}
|
|
});
|
|
await navigator.clipboard.writeText(md);
|
|
}
|
|
|
|
/** 保存单个模型自定义链接到本地 .civitai.info */
|
|
export async function saveSingleModelToLocalSidecar(item, url) {
|
|
const cleanUrl = normalizeUrl(url);
|
|
const targetFilename = item.basename || (item.filename ? item.filename.split(/[/\\]/).pop() : '');
|
|
const body = {
|
|
filename: targetFilename,
|
|
type: item.type || 'checkpoints',
|
|
subfolder: item.subfolder || '/',
|
|
path_idx: item.path_idx || 0,
|
|
custom_source_url: cleanUrl,
|
|
};
|
|
const res = await fetch('/anomalous/update_metadata', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
return jsonResponse(res, 'save custom source url');
|
|
}
|
|
|
|
/** 一键打开外部链接 */
|
|
export function openExternalUrl(rawUrl) {
|
|
const normalized = normalizeUrl(rawUrl);
|
|
if (!normalized) return;
|
|
window.open(normalized, '_blank', 'noopener,noreferrer');
|
|
}
|
|
|
|
/** 异步解析工作流中模型的本地元数据与路径详情 */
|
|
export async function resolveWorkflowModelsMetadata(models, signal = null) {
|
|
if (!Array.isArray(models) || !models.length) return false;
|
|
const requestedPaths = models.map(m => m.filename);
|
|
try {
|
|
const res = await fetch('/anomalous/resolve_paths_to_previews', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths: requestedPaths }),
|
|
signal,
|
|
});
|
|
const data = await jsonResponse(res, 'resolve workflow models');
|
|
const modelsMap = data?.models || {};
|
|
let hasChanges = false;
|
|
|
|
for (const m of models) {
|
|
const info = modelsMap[m.filename] || modelsMap[m.basename];
|
|
if (info) {
|
|
m.type = info.type;
|
|
m.subfolder = info.subfolder;
|
|
m.path_idx = info.path_idx;
|
|
m.file_path = info.file_path;
|
|
if (!m.hash && info.metadata?.hash) {
|
|
m.hash = info.metadata.hash;
|
|
hasChanges = true;
|
|
}
|
|
const resolvedUrl = info.metadata?.source_url || info.metadata?.civitai_url || '';
|
|
if (!m.url && resolvedUrl) {
|
|
m.url = resolvedUrl;
|
|
m.initialUrl = resolvedUrl;
|
|
m.platform = detectPlatform(resolvedUrl);
|
|
hasChanges = true;
|
|
}
|
|
if (typeof window !== 'undefined' && window.anomalous_hash_cache) {
|
|
if (m.filename && info.metadata?.hash) {
|
|
window.anomalous_hash_cache[m.filename] = {
|
|
hash: info.metadata.hash,
|
|
url: resolvedUrl,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return hasChanges;
|
|
} catch (e) {
|
|
if (!signal?.aborted) {
|
|
console.warn('[Model Source Hub] Failed to resolve workflow model info', e);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// UI RENDERERS (Sub-divided into concise blocks <= 50 lines)
|
|
// -----------------------------------------------------------------------------
|
|
|
|
function renderModalHeader(headerEl, scope, state, onScopeChange, onClose) {
|
|
headerEl.className = 'anomalous-sources-header';
|
|
const topRow = text(headerEl, 'div', '', 'anomalous-sources-top-row');
|
|
|
|
const titleWrap = text(topRow, 'div', '', 'anomalous-sources-title-wrap');
|
|
text(titleWrap, 'h3', t('modelSourcesModalTitle'));
|
|
|
|
const closeBtn = text(topRow, 'button', '✕', 'anomalous-sources-close-btn');
|
|
closeBtn.title = t('close') || '关闭';
|
|
closeBtn.onclick = onClose;
|
|
|
|
// Scope Tabs (Active Workflow vs All Local Models)
|
|
const tabsRow = text(headerEl, 'div', '', 'anomalous-sources-scope-tabs');
|
|
|
|
const wfTab = text(tabsRow, 'button', '', `anomalous-sources-scope-tab${state.scope === 'workflow' ? ' is-active' : ''}`);
|
|
wfTab.innerHTML = `🎛️ ${t('modelSourcesScopeWorkflow')} <span class="anomalous-sources-badge-num">${state.workflowModels.length}</span>`;
|
|
wfTab.onclick = () => onScopeChange('workflow');
|
|
|
|
const libTab = text(tabsRow, 'button', '', `anomalous-sources-scope-tab${state.scope === 'library' ? ' is-active' : ''}`);
|
|
libTab.innerHTML = `📚 ${t('modelSourcesScopeLibrary')} <span class="anomalous-sources-badge-num">${state.libraryModels.length || '...'}</span>`;
|
|
libTab.onclick = () => onScopeChange('library');
|
|
}
|
|
|
|
function renderFilterAndSearch(filterBarEl, state, onFilterChange, onSearch) {
|
|
filterBarEl.className = 'anomalous-sources-filter-bar';
|
|
|
|
const pillsWrap = text(filterBarEl, 'div', '', 'anomalous-sources-pills');
|
|
const filters = [
|
|
{ id: 'all', label: t('modelSourcesFilterAll') },
|
|
{ id: 'resolved', label: t('modelSourcesFilterResolved') },
|
|
{ id: 'unresolved', label: t('modelSourcesFilterUnresolved') },
|
|
];
|
|
|
|
filters.forEach(f => {
|
|
const pill = text(pillsWrap, 'button', f.label, `anomalous-sources-pill${state.filter === f.id ? ' is-active' : ''}`);
|
|
pill.onclick = () => onFilterChange(f.id);
|
|
});
|
|
|
|
const searchInput = text(filterBarEl, 'input', '', 'anomalous-sources-search-input');
|
|
searchInput.placeholder = t('modelSourcesSearchPlaceholder');
|
|
searchInput.value = state.searchKeyword;
|
|
searchInput.oninput = () => onSearch(searchInput.value.trim().toLowerCase());
|
|
}
|
|
|
|
function renderModelTitle(infoCol, m) {
|
|
const titleRow = text(infoCol, 'div', '', 'anomalous-source-row-title');
|
|
text(titleRow, 'span', m.nodeTitle || m.type || 'Model', 'anomalous-source-node-tag');
|
|
text(titleRow, 'strong', m.basename || m.filename, 'anomalous-source-model-name');
|
|
|
|
if (m.isMissing) {
|
|
const missingBadge = text(titleRow, 'span', t('modelSourcesStatusMissingOnDisk'), 'anomalous-source-badge-danger');
|
|
missingBadge.title = '当前本地尚未下载安装此模型';
|
|
}
|
|
|
|
if (m.isEditing) {
|
|
const editBadge = text(titleRow, 'span', window.anomalous_browser_lang === 'zh' ? '✏️ 正在编辑' : '✏️ Editing', 'anomalous-source-edit-badge');
|
|
editBadge.title = window.anomalous_browser_lang === 'zh' ? '当前处于来源链接修改状态' : 'In editing mode';
|
|
}
|
|
|
|
if (m.hash) {
|
|
text(titleRow, 'code', `SHA: ${m.hash.slice(0, 10)}...`, 'anomalous-source-hash-chip');
|
|
}
|
|
}
|
|
|
|
function renderModelUrlInput(inputWrap, m, onRefresh, getActions) {
|
|
const isLocked = Boolean(m.url && !m.isEditing);
|
|
const inputClass = `anomalous-source-url-input${isLocked ? ' is-locked' : ''}${m.isEditing ? ' is-editing' : ''}`;
|
|
const urlInput = text(inputWrap, 'input', '', inputClass);
|
|
urlInput.placeholder = t('modelSourcesUrlPlaceholder');
|
|
urlInput.value = m.url || '';
|
|
urlInput.readOnly = isLocked;
|
|
|
|
const platBadge = text(inputWrap, 'span', m.platform?.name || '', 'anomalous-source-plat-badge');
|
|
if (m.platform) {
|
|
platBadge.style.color = m.platform.color;
|
|
platBadge.style.borderColor = m.platform.border;
|
|
platBadge.style.backgroundColor = m.platform.bg;
|
|
} else {
|
|
platBadge.style.display = 'none';
|
|
}
|
|
|
|
if (isLocked) {
|
|
urlInput.title = window.anomalous_browser_lang === 'zh'
|
|
? '当前为展示锁定态。双击或点击右侧【✏️ 修改】可解锁编辑'
|
|
: 'Protected link. Double-click or click [✏️ Edit] to modify';
|
|
urlInput.ondblclick = () => {
|
|
m.isEditing = true;
|
|
onRefresh();
|
|
};
|
|
} else {
|
|
urlInput.oninput = () => {
|
|
m.url = urlInput.value.trim();
|
|
m.platform = detectPlatform(m.url);
|
|
if (m.platform) {
|
|
platBadge.style.display = '';
|
|
platBadge.textContent = m.platform.name;
|
|
platBadge.style.color = m.platform.color;
|
|
platBadge.style.borderColor = m.platform.border;
|
|
platBadge.style.backgroundColor = m.platform.bg;
|
|
} else {
|
|
platBadge.style.display = 'none';
|
|
}
|
|
const { localBtn, jumpBtn } = getActions();
|
|
const isDirty = Boolean(m.url && m.url.trim() !== (m.initialUrl || '').trim());
|
|
if (localBtn) localBtn.style.display = (isDirty && !m.isMissing) ? '' : 'none';
|
|
if (jumpBtn) jumpBtn.disabled = !m.url;
|
|
};
|
|
urlInput.onkeydown = (e) => {
|
|
if (e.key === 'Escape') {
|
|
m.url = m.initialUrl;
|
|
m.platform = detectPlatform(m.url);
|
|
m.isEditing = false;
|
|
onRefresh();
|
|
} else if (e.key === 'Enter') {
|
|
m.isEditing = false;
|
|
onRefresh();
|
|
}
|
|
};
|
|
if (m.isEditing) {
|
|
setTimeout(() => {
|
|
urlInput.focus();
|
|
urlInput.select();
|
|
}, 0);
|
|
}
|
|
}
|
|
|
|
return urlInput;
|
|
}
|
|
|
|
function renderModelRowActions(actionsRow, m, state, urlInput, onRefresh, onAutoDetect, onSaveLocal) {
|
|
const isDirty = Boolean(m.url && m.url.trim() !== (m.initialUrl || '').trim());
|
|
|
|
const jumpBtn = text(actionsRow, 'button', t('modelSourcesJump'), 'anomalous-btn-primary anomalous-btn-sm anomalous-btn-jump');
|
|
jumpBtn.disabled = !m.url;
|
|
jumpBtn.title = window.anomalous_browser_lang === 'zh' ? '在浏览器新标签页中打开对应网址' : 'Open website in new tab';
|
|
jumpBtn.onclick = () => openExternalUrl(m.url);
|
|
|
|
const detectBtn = text(actionsRow, 'button', t('modelSourcesAutoDetect'), 'anomalous-btn-ghost anomalous-btn-sm');
|
|
detectBtn.title = window.anomalous_browser_lang === 'zh' ? '从本地元数据或 SHA256 自动解析来源' : 'Auto detect from hash or metadata';
|
|
detectBtn.onclick = () => onAutoDetect(m, urlInput);
|
|
|
|
let editBtn = null;
|
|
let cancelBtn = null;
|
|
|
|
if (m.isEditing) {
|
|
cancelBtn = text(actionsRow, 'button', window.anomalous_browser_lang === 'zh' ? '✕ 取消' : '✕ Cancel', 'anomalous-btn-ghost anomalous-btn-sm');
|
|
cancelBtn.title = window.anomalous_browser_lang === 'zh' ? '放弃本次修改并还原' : 'Cancel edits and revert';
|
|
cancelBtn.onclick = () => {
|
|
m.url = m.initialUrl;
|
|
m.platform = detectPlatform(m.url);
|
|
m.isEditing = false;
|
|
onRefresh();
|
|
};
|
|
} else if (m.url) {
|
|
editBtn = text(actionsRow, 'button', window.anomalous_browser_lang === 'zh' ? '✏️ 修改' : '✏️ Edit', 'anomalous-btn-ghost anomalous-btn-sm');
|
|
editBtn.title = window.anomalous_browser_lang === 'zh' ? '解锁输入框以修改来源网址' : 'Unlock to edit URL';
|
|
editBtn.onclick = () => {
|
|
m.isEditing = true;
|
|
onRefresh();
|
|
};
|
|
}
|
|
|
|
let localBtn = null;
|
|
if (!m.isMissing) {
|
|
localBtn = text(actionsRow, 'button', t('modelSourcesSaveLocal'), 'anomalous-btn-primary anomalous-btn-sm anomalous-btn-save-local');
|
|
localBtn.title = window.anomalous_browser_lang === 'zh' ? '检测到链接已修改,点击记入本地模型的 .civitai.info' : 'Modified link detected. Save to local .civitai.info';
|
|
localBtn.style.display = isDirty ? '' : 'none';
|
|
localBtn.onclick = () => onSaveLocal(m);
|
|
}
|
|
|
|
return { jumpBtn, detectBtn, editBtn, cancelBtn, localBtn };
|
|
}
|
|
|
|
function renderModelRow(listEl, m, state, onRefresh, onAutoDetect, onSaveLocal) {
|
|
const card = text(listEl, 'div', '', `anomalous-source-row-item${m.url ? ' has-url' : ' is-missing-url'}`);
|
|
|
|
const infoCol = text(card, 'div', '', 'anomalous-source-col-info');
|
|
renderModelTitle(infoCol, m);
|
|
|
|
const inputRow = text(card, 'div', '', 'anomalous-source-col-input-row');
|
|
const inputWrap = text(inputRow, 'div', '', 'anomalous-source-input-wrap');
|
|
const actionsRow = text(inputRow, 'div', '', 'anomalous-source-row-actions');
|
|
|
|
let actionRefs = {};
|
|
const urlInput = renderModelUrlInput(inputWrap, m, onRefresh, () => actionRefs);
|
|
actionRefs = renderModelRowActions(actionsRow, m, state, urlInput, onRefresh, onAutoDetect, onSaveLocal);
|
|
}
|
|
|
|
function renderFooterBar(footerEl, state, activeModels, onSaveWorkflow, onGenerateNote, onCopySummary) {
|
|
footerEl.className = 'anomalous-sources-footer';
|
|
footerEl.replaceChildren();
|
|
|
|
const statsEl = text(footerEl, 'div', '', 'anomalous-sources-footer-stats');
|
|
const withUrlCount = activeModels.filter(m => Boolean(m.url?.trim())).length;
|
|
statsEl.innerHTML = `${window.anomalous_browser_lang === 'zh' ? '已配置' : 'Configured'}: <strong>${withUrlCount} / ${activeModels.length}</strong>`;
|
|
|
|
const btnsWrap = text(footerEl, 'div', '', 'anomalous-sources-footer-actions');
|
|
|
|
const copyBtn = text(btnsWrap, 'button', t('modelSourcesCopySummary'), 'anomalous-btn-ghost anomalous-btn-sm');
|
|
copyBtn.onclick = onCopySummary;
|
|
|
|
if (state.scope === 'workflow') {
|
|
const noteBtn = text(btnsWrap, 'button', t('modelSourcesGenerateNoteNode'), 'anomalous-btn-ghost anomalous-btn-sm');
|
|
noteBtn.title = window.anomalous_browser_lang === 'zh' ? '在 ComfyUI 画布生成一个原生 Note 便签节点,任何人打开都能直接看到下载链接' : 'Generate native Note node with source links on canvas';
|
|
noteBtn.onclick = onGenerateNote;
|
|
|
|
// Dynamic Save vs Sync text
|
|
const hasExisting = Boolean(Object.keys(app.graph?.extra?.anomalous_model_sources || {}).length);
|
|
const saveLabel = hasExisting ? t('modelSourcesSyncToWorkflow') : t('modelSourcesSaveToWorkflow');
|
|
const saveBtn = text(btnsWrap, 'button', saveLabel, 'anomalous-btn-primary anomalous-btn-sm');
|
|
saveBtn.onclick = onSaveWorkflow;
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// MAIN MODAL OPENER
|
|
// -----------------------------------------------------------------------------
|
|
|
|
export function openModelSourcesModal(initialScope = 'workflow') {
|
|
activeSourcesModalScope?.dispose();
|
|
const scope = createViewScope();
|
|
activeSourcesModalScope = scope;
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'anomalous-model-sources-overlay';
|
|
scope.onDispose(() => {
|
|
overlay.remove();
|
|
if (activeSourcesModalScope === scope) activeSourcesModalScope = null;
|
|
});
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'anomalous-model-sources-modal';
|
|
overlay.appendChild(modal);
|
|
|
|
const state = {
|
|
scope: initialScope,
|
|
filter: 'all',
|
|
searchKeyword: '',
|
|
workflowModels: collectWorkflowModels(),
|
|
libraryModels: [],
|
|
isLoadingLibrary: false,
|
|
};
|
|
|
|
const headerEl = text(modal, 'header', '', '');
|
|
const filterBarEl = text(modal, 'div', '', '');
|
|
const bodyEl = text(modal, 'div', '', 'anomalous-sources-body');
|
|
const footerEl = text(modal, 'footer', '', '');
|
|
|
|
const refreshUi = () => {
|
|
headerEl.replaceChildren();
|
|
filterBarEl.replaceChildren();
|
|
bodyEl.replaceChildren();
|
|
|
|
renderModalHeader(headerEl, scope, state, (newScope) => {
|
|
state.scope = newScope;
|
|
if (newScope === 'library' && !state.libraryModels.length && !state.isLoadingLibrary) {
|
|
state.isLoadingLibrary = true;
|
|
refreshUi();
|
|
fetchAllLibraryModels(scope.signal).then(items => {
|
|
if (scope.signal.aborted) return;
|
|
state.libraryModels = items;
|
|
state.isLoadingLibrary = false;
|
|
refreshUi();
|
|
});
|
|
return;
|
|
}
|
|
refreshUi();
|
|
}, () => scope.dispose());
|
|
|
|
renderFilterAndSearch(filterBarEl, state, (newFilter) => {
|
|
state.filter = newFilter;
|
|
refreshUi();
|
|
}, (keyword) => {
|
|
state.searchKeyword = keyword;
|
|
refreshUi();
|
|
});
|
|
|
|
// Filter models
|
|
const rawList = state.scope === 'workflow' ? state.workflowModels : state.libraryModels;
|
|
const filtered = rawList.filter(m => {
|
|
if (state.filter === 'resolved' && !m.url) return false;
|
|
if (state.filter === 'unresolved' && m.url) return false;
|
|
if (state.searchKeyword) {
|
|
const title = `${m.nodeTitle || ''} ${m.filename || ''} ${m.basename || ''}`.toLowerCase();
|
|
if (!title.includes(state.searchKeyword)) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
if (state.isLoadingLibrary) {
|
|
const loadingBox = text(bodyEl, 'div', '', 'anomalous-sources-empty');
|
|
loadingBox.innerHTML = `<div>⏳ 正在全量盘点本地模型库...</div>`;
|
|
} else if (!filtered.length) {
|
|
const emptyBox = text(bodyEl, 'div', '', 'anomalous-sources-empty');
|
|
emptyBox.innerHTML = `
|
|
<div style="font-size:24px;margin-bottom:6px;">🔍</div>
|
|
<div>${t('modelSourcesNoModelsFound')}</div>
|
|
`;
|
|
} else {
|
|
filtered.forEach(m => {
|
|
renderModelRow(bodyEl, m, state,
|
|
() => refreshUi(),
|
|
async (item, inputEl) => {
|
|
inputEl.disabled = true;
|
|
try {
|
|
const detected = await autoDetectModelSource(item);
|
|
if (detected) {
|
|
item.url = detected;
|
|
item.initialUrl = item.initialUrl || detected;
|
|
item.platform = detectPlatform(detected);
|
|
inputEl.value = detected;
|
|
refreshUi();
|
|
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✓ 已识别模型来源!' : '✓ Model source detected!');
|
|
} else {
|
|
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '未能在本地或云端匹配到官方页面' : 'No online source matched');
|
|
}
|
|
} finally {
|
|
inputEl.disabled = false;
|
|
}
|
|
},
|
|
async (item) => {
|
|
try {
|
|
await saveSingleModelToLocalSidecar(item, item.url);
|
|
item.initialUrl = item.url;
|
|
item.isEditing = false;
|
|
refreshUi();
|
|
showWorkbenchToast(t('modelSourcesSavedLocal'));
|
|
} catch (e) {
|
|
showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '保存至本地失败' : 'Failed to save local');
|
|
}
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
renderFooterBar(footerEl, state, filtered,
|
|
() => {
|
|
const res = syncWorkflowSources(state.workflowModels);
|
|
showWorkbenchToast(res.isUpdate ? t('modelSourcesSyncedToWorkflow') : t('modelSourcesSavedToWorkflow'));
|
|
refreshUi();
|
|
},
|
|
() => {
|
|
const ok = createCanvasNoteNode(state.workflowModels);
|
|
if (ok) showWorkbenchToast(t('modelSourcesNoteCreated'));
|
|
},
|
|
async () => {
|
|
await copySourcesSummary(filtered);
|
|
showWorkbenchToast(t('modelSourcesCopied'));
|
|
}
|
|
);
|
|
};
|
|
|
|
refreshUi();
|
|
|
|
resolveWorkflowModelsMetadata(state.workflowModels, scope.signal).then(hasChanges => {
|
|
if (hasChanges && !scope.signal.aborted) {
|
|
refreshUi();
|
|
}
|
|
});
|
|
|
|
overlay.onclick = (e) => {
|
|
if (e.target === overlay) scope.dispose();
|
|
};
|
|
|
|
scope.listen(window, 'keydown', (e) => {
|
|
if (e.key === 'Escape') scope.dispose();
|
|
});
|
|
|
|
document.body.appendChild(overlay);
|
|
return () => scope.dispose();
|
|
}
|
|
|
|
/** 自动识别单条模型来源 */
|
|
export async function autoDetectModelSource(item) {
|
|
if (item.url) return item.url;
|
|
|
|
// 1. Priority: check local sidecar & metadata via backend resolver
|
|
try {
|
|
const queryPath = item.filename || item.basename;
|
|
const res = await fetch('/anomalous/resolve_paths_to_previews', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ paths: [queryPath, item.basename].filter(Boolean) }),
|
|
});
|
|
const data = await jsonResponse(res, 'detect model source local');
|
|
const resolved = data?.models?.[queryPath] || data?.models?.[item.basename];
|
|
if (resolved) {
|
|
item.type = resolved.type;
|
|
item.subfolder = resolved.subfolder;
|
|
item.path_idx = resolved.path_idx;
|
|
if (resolved.metadata?.hash) item.hash = resolved.metadata.hash;
|
|
const foundUrl = resolved.metadata?.source_url || resolved.metadata?.civitai_url;
|
|
if (foundUrl) return foundUrl;
|
|
}
|
|
} catch (e) {
|
|
// Backend lookup failed, proceed to cloud/search fallback
|
|
}
|
|
|
|
// 2. Cloud lookup if hash exists
|
|
if (item.hash) {
|
|
try {
|
|
const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${item.hash}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (data.modelId) {
|
|
const domain = (data.model?.nsfw || data.nsfwLevel > 1) ? 'civitai.red' : 'civitai.com';
|
|
return `https://${domain}/models/${data.modelId}${data.id ? '?modelVersionId=' + data.id : ''}`;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Network fallback
|
|
}
|
|
}
|
|
|
|
// 3. Search fallback
|
|
const query = encodeURIComponent((item.basename || item.filename).replace(/\.[^.]+$/, ''));
|
|
return `https://civitai.com/search/models?query=${query}`;
|
|
}
|