Files
Azornes-Comfyui-Model-Resolver/web/resolver/search/search_panel.js
T

2616 lines
115 KiB
JavaScript

import { getSvgIcon } from "../../utils/icon_utils.js";
import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js";
import { getCivitaiModelUrl } from "../globals.js";
import { safeStorage, normalizePathIdentity } from "../utils/html_utils.js";
const localStorage = safeStorage;
export const searchPanelMethods = {
isAutoFillBaseModelEnabled() {
return localStorage.getItem('ModelResolver.autoFillBaseModel') !== 'false';
},
getDefaultSearchBaseModel() {
return this.isAutoFillBaseModelEnabled() ? 'auto' : 'none';
},
getKnownBaseModelOptions() {
const baseModelsList = this.baseModels?.base_models;
if (Array.isArray(baseModelsList) && baseModelsList.length > 0) {
return [
{ value: 'auto', label: 'Auto' },
{ value: 'none', label: 'Any model' },
...baseModelsList.map(m => ({ value: m.name, label: m.name }))
];
}
// Fallback to hardcoded list
return [
{ value: 'auto', label: 'Auto' },
{ value: 'none', label: 'Any model' },
{ value: 'Z-Image', label: 'Z-Image' },
{ value: 'Pony', label: 'Pony' },
{ value: 'Illustrious', label: 'Illustrious' },
{ value: 'SDXL 1.0', label: 'SDXL 1.0' },
{ value: 'SD 1.5', label: 'SD 1.5' },
{ value: 'Flux.1 D', label: 'Flux.1 D' },
{ value: 'Flux.1 S', label: 'Flux.1 S' },
{ value: 'Qwen Image', label: 'Qwen Image' },
{ value: 'Hunyuan 1', label: 'Hunyuan 1' },
{ value: 'WAN Video', label: 'WAN Video' },
{ value: 'NoobAI', label: 'NoobAI' },
{ value: 'HiDream', label: 'HiDream' }
];
},
getMissingLocalBaseModel(missing = {}, minConfidence = 70) {
const bestMatch = this.getBestLocalMatch?.(missing, minConfidence);
const matchModel = bestMatch?.model || {};
const pathCandidates = [
matchModel.relative_path,
matchModel.path,
bestMatch?.path,
bestMatch?.filename
];
for (const path of pathCandidates) {
const canonical = this.resolveBaseModelAliasFromPath(path);
if (canonical) return canonical;
}
return '';
},
getMissingAutoBaseModel(missing = {}) {
return this.getMissingAutoBaseModelInfo(missing).value;
},
getBaseModelIndependentSearchType(missing = {}) {
const normalizeType = (value) => String(value || '')
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
const typeLabels = {
sam: 'SAM',
sams: 'SAM',
sam_model: 'SAM',
sam_models: 'SAM',
ultralytics: 'Ultralytics',
ultralytics_bbox: 'Ultralytics',
ultralytics_segm: 'Ultralytics',
yolo: 'Ultralytics'
};
const candidates = [
missing?.category,
missing?.model_type,
missing?.urn_type
];
for (const candidate of candidates) {
const label = typeLabels[normalizeType(candidate)];
if (label) return label;
}
const nodeType = normalizeType(missing?.node_type);
if (nodeType === 'samloader' || nodeType === 'sam_loader') return 'SAM';
if (nodeType === 'ultralyticsdetectorprovider' || nodeType === 'ultralytics_detector_provider') {
return 'Ultralytics';
}
return '';
},
getMissingAutoBaseModelInfo(missing = {}) {
const formatAutoMessage = (selected, reason, action = 'Choose a different option in the Model field.') => (
`Selected automatically: ${selected}\n\nWhy: ${reason}\n\nTo change: ${action}`
);
const independentType = this.getBaseModelIndependentSearchType(missing);
if (independentType) {
return {
value: '',
source: 'independent model type',
usesAnyModel: true,
message: formatAutoMessage(
'Any model',
`${independentType} models are not tied to one base model, so no model filter is needed.`
)
};
}
const searchSuggestion = this.getCachedSearchSuggestionData?.(missing) || {};
const directCandidates = [
{ value: missing?.download_source?.base_model, source: 'selected download source' },
{ value: missing?.download_source?.baseModel, source: 'selected download source' },
{ value: missing?.civitai_info?.base_model, source: 'model metadata' },
{ value: missing?.civitai_info?.baseModel, source: 'model metadata' }
];
for (const { value, source } of directCandidates) {
const canonical = this.resolveBaseModelAlias(value);
if (canonical) {
return {
value: canonical,
source,
message: formatAutoMessage(
canonical,
source === 'selected download source'
? `The download source you selected identifies this model as ${canonical}.`
: `The model information saved with this workflow identifies it as ${canonical}.`
)
};
}
}
const savedTarget = this.getSavedDownloadTargetSelection?.(missing) || {};
const explicitlySelectedSubfolder = savedTarget.subfolderTouched
? savedTarget.subfolder
: '';
const pathCandidates = [
{ value: this.getMissingLocalBaseModel(missing, 70), source: 'best local match' },
{ value: explicitlySelectedSubfolder, source: 'selected subfolder' }
];
for (const { value, source } of pathCandidates) {
const canonical = this.resolveBaseModelAliasFromPath(value)
|| this.resolveBaseModelAliasExact(value);
if (canonical) {
return {
value: canonical,
source,
message: formatAutoMessage(
canonical,
source === 'best local match'
? `A reliable local match is stored in a folder associated with ${canonical}.`
: `The subfolder you selected is associated with ${canonical}.`
)
};
}
}
const fallbackPathCandidates = [
{ value: missing?.original_path, source: 'missing model path' },
{ value: missing?.name, source: 'missing model name' },
{ value: searchSuggestion.path, source: 'cached search path' }
];
for (const { value, source } of fallbackPathCandidates) {
const canonical = this.resolveBaseModelAliasFromPath(value);
if (canonical) {
return {
value: canonical,
source,
message: formatAutoMessage(
canonical,
source === 'missing model path'
? `The model path saved in this workflow points to a folder associated with ${canonical}.`
: source === 'missing model name'
? `The model name saved in this workflow contains a name associated with ${canonical}.`
: `A previously found model path points to a folder associated with ${canonical}.`
)
};
}
}
const cachedCandidates = [
{ value: searchSuggestion.base_model, source: 'cached search result' },
{ value: searchSuggestion.baseModel, source: 'cached search result' }
];
for (const { value, source } of cachedCandidates) {
const canonical = this.resolveBaseModelAlias(value);
if (canonical) {
return {
value: canonical,
source,
message: formatAutoMessage(
canonical,
`A compatible search result identifies this model as ${canonical}.`
)
};
}
}
const workflowFallback = this.getDominantWorkflowBaseModel();
if (workflowFallback) {
return {
value: workflowFallback,
source: 'workflow fallback',
message: formatAutoMessage(
workflowFallback,
`This model could not be identified directly, so Model Resolver used the most common model type in this workflow.`,
'Choose another model here, or update the Base Models list in Options.'
)
};
}
return {
value: '',
source: 'none',
message: formatAutoMessage(
'No model filter',
'There is not enough reliable information to identify the model type.',
'Choose a model here, or update the Base Models list in Options.'
)
};
},
getSearchBaseModelTooltip(missing = {}) {
const state = this.getSearchState(missing);
const selected = state.selectedBaseModel || this.getDefaultSearchBaseModel();
if (selected === 'none') {
return 'Selected: Any model\n\nWhy: Searches will not be limited to one model type.\n\nTo change: Choose Auto or a specific model.';
}
if (selected && selected !== 'auto') {
return `Selected manually: ${selected}\n\nWhy: Searches are limited to this model type.\n\nTo change: Choose Auto, Any model, or another model.`;
}
return this.getMissingAutoBaseModelInfo(missing).message;
},
getWorkflowModelReferenceText() {
const workflow = this.getCurrentWorkflow?.();
if (!workflow) return '';
const values = [];
const visit = (value, key = '') => {
if (typeof value === 'string') {
if (/^urn:/i.test(value.trim())) {
return;
}
if (this.hasModelExtension(value) || /model|checkpoint|unet|diffusion/i.test(key)) {
values.push(value);
}
return;
}
if (Array.isArray(value)) {
value.forEach(item => visit(item, key));
return;
}
if (value && typeof value === 'object') {
Object.entries(value).forEach(([childKey, childValue]) => visit(childValue, childKey));
}
};
visit(workflow);
return values.join(' ');
},
getMissingBaseModelWeight(missing = {}) {
const category = String(missing.category || '').toLowerCase();
const nodeType = String(missing.node_type || '').toLowerCase();
if (
category.includes('checkpoint')
|| category.includes('diffusion')
|| category.includes('unet')
|| nodeType.includes('checkpoint')
|| nodeType.includes('unet')
) {
return 8;
}
if (category.includes('lora') || nodeType.includes('lora')) {
return 2;
}
return 1;
},
getResolvedWorkflowBaseModelScores() {
const primaryScores = new Map();
const secondaryScores = new Map();
for (const missing of this.missingModels || []) {
const baseModel = missing?.civitai_info?.base_model || '';
const bestMatch = this.getBestLocalMatch?.(missing, 95);
const matchPath = bestMatch?.model?.relative_path
|| bestMatch?.model?.path
|| bestMatch?.path
|| bestMatch?.filename
|| '';
const localBaseModel = this.resolveBaseModelAliasFromPath(matchPath);
const canonical = this.resolveBaseModelAlias(baseModel)
|| localBaseModel
|| this.resolveBaseModelAlias(missing?.download_source?.base_model || '');
if (!canonical) continue;
const confidence = Number(bestMatch?.confidence || 0);
const localMatchBoost = confidence >= 100 ? 1 : 0;
const weight = this.getMissingBaseModelWeight(missing) + localMatchBoost;
const scores = weight >= 8 ? primaryScores : secondaryScores;
scores.set(canonical, (scores.get(canonical) || 0) + weight);
}
return primaryScores.size ? primaryScores : secondaryScores;
},
getDominantWorkflowBaseModel() {
const resolvedScores = this.getResolvedWorkflowBaseModelScores();
if (resolvedScores.size) {
let bestResolved = null;
for (const [value, score] of resolvedScores.entries()) {
if (!bestResolved || score > bestResolved.score) {
bestResolved = { value, score };
}
}
if (bestResolved) return bestResolved.value;
}
const text = this.getWorkflowModelReferenceText();
if (!text) return '';
const normalizedText = this.normalizeBaseModelToken(text);
let best = null;
for (const entry of this.getBaseModelAliases()) {
const score = entry.aliases.reduce((count, alias) => {
const token = this.normalizeBaseModelToken(alias);
return token && normalizedText.includes(token) ? count + 1 : count;
}, 0);
if (score > 0 && (!best || score > best.score)) {
best = { value: entry.value, score };
}
}
return best?.value || '';
},
getSearchBaseModelLabel(value = 'auto', missing = {}) {
if (value === 'auto') {
const autoInfo = missing ? this.getMissingAutoBaseModelInfo(missing) : null;
if (autoInfo?.usesAnyModel) return 'Auto (Any model)';
const detected = autoInfo ? autoInfo.value : this.getDominantWorkflowBaseModel();
return detected ? `Auto (${detected})` : 'Auto';
}
const option = this.getKnownBaseModelOptions().find(item => item.value === value);
return option?.label || value || 'Auto';
},
getSearchBaseModelInputValue(text = '', missing = {}) {
const raw = String(text || '').trim();
if (!raw) return 'none';
const normalized = this.normalizeBaseModelToken(raw);
for (const option of this.getKnownBaseModelOptions()) {
const label = option.value === 'auto'
? this.getSearchBaseModelLabel('auto', missing)
: option.label;
const optionTokens = [
option.value,
label
].flatMap(value => [...this.getBaseModelTokenVariants(value)]);
if (optionTokens.some(token => token === normalized)) {
return option.value;
}
}
return this.resolveBaseModelAliasExact(raw) || raw;
},
getSearchBaseModelContext(missing = {}) {
const state = this.getSearchState(missing);
const selected = state.selectedBaseModel || this.getDefaultSearchBaseModel();
if (selected === 'none') return '';
if (selected === 'auto') return this.getMissingAutoBaseModel(missing);
return selected;
},
getSearchButtonLabelLines(text = '') {
const label = String(text || '').replace(/\s+/g, ' ').trim() || 'Search Online';
const normalized = label.toLowerCase();
if (normalized === 'search' || normalized === 'search online') {
return ['Online'];
}
if (normalized === 'search again') {
return ['Again'];
}
const searchingMatch = label.match(/^Searching(?:\s+(.+?))?\.{0,3}$/i);
if (searchingMatch) {
return ['Searching'];
}
return label.split(' ');
},
renderSearchButtonContent(text = '') {
const lines = this.getSearchButtonLabelLines(text);
const labelHtml = lines
.map(line => `<span class="mr-search-btn-line">${this.escapeHtml(line)}</span>`)
.join('');
return `${this.getSearchIconHtml()} <span class="mr-search-btn-text">${labelHtml}</span>`;
},
renderCustomUrlButtonContent(text = 'Add') {
return `<span class="mr-btn-icon" aria-hidden="true">${getSvgIcon('link')}</span><span>${this.escapeHtml(text)}</span>`;
},
refreshSearchUiForMissing(missing, state = null, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
if (!missing) return;
const activeWorkflowKey = this.getWorkflowScopedQueueKey();
const missingSearchKey = this.getMissingSearchKey(missing);
let currentState = state || this.searchResultCache.get(missingSearchKey);
if (workflowKey && workflowKey !== activeWorkflowKey) {
const activeState = this.searchResultCache.get(missingSearchKey);
const runId = currentState?.activeSearchRunId || activeState?.activeSearchRunId;
const mirrorsActiveSearch = Boolean(
activeState === currentState
|| (
runId
&& activeState?.activeSearchRunId === runId
&& this.hasBackgroundSearchJob(activeWorkflowKey, missingSearchKey, runId)
)
);
if (!mirrorsActiveSearch) return;
currentState = activeState || currentState;
}
this.refreshMissingSourcesSummary(missing);
this.updateBatchFooterButtons?.();
if (!this.contentElement || this.activeTab !== 'missing') return;
const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`;
const resultsDiv = this.contentElement.querySelector(`#${resultsId}`);
if (resultsDiv && currentState && this.hasRenderableSearchState(currentState)) {
resultsDiv.classList.remove('mr-is-hidden');
resultsDiv.classList.add('mr-is-visible');
this.displaySearchResults(missing, currentState, resultsDiv);
}
const searchBtn = this.contentElement.querySelector(`#search-${missing.node_id}-${missing.widget_index}`);
if (searchBtn && currentState) {
const isRunning = Boolean(currentState.activeSearchRunId);
const hasSearchAttempt = this.hasRenderableSearchState(currentState);
searchBtn.disabled = isRunning;
searchBtn.innerHTML = isRunning
? this.renderSearchButtonContent('Searching...')
: this.renderSearchButtonContent(hasSearchAttempt ? 'Search Again' : 'Search Online');
}
},
getSearchIconHtml() {
return `<span class="mr-btn-icon" aria-hidden="true">${getSvgIcon('search')}</span>`;
},
getLocateIconHtml() {
return `<span class="mr-node-chip-icon" aria-hidden="true">${getSvgIcon('locate')}</span>`;
},
async getTooltipPreviewMediaType(previewUrl) {
if (!previewUrl) return 'image';
let mediaType = 'image';
try {
const response = await fetch(previewUrl, {
method: 'HEAD',
cache: 'no-cache'
});
const contentType = String(response.headers.get('Content-Type') || '').toLowerCase();
if (response.ok && contentType.startsWith('video/')) {
mediaType = 'video';
}
} catch (error) {
console.debug('Model Resolver: Could not detect tooltip preview type:', error);
}
return mediaType;
},
async showTooltip(target) {
if (!target || !this.tooltipElement) return;
if (this.contextMenu?.style.display === 'block') {
this.hideTooltip();
return;
}
this.normalizeTooltipTarget(target);
if (target.matches?.('.mr-footer-menu-button[aria-expanded="true"]')) {
this.hideTooltip();
return;
}
const text = target.getAttribute('data-tooltip');
if (!text) return;
const imageUrl = target.getAttribute('data-tooltip-image');
this._tooltipTarget = target;
this.tooltipElement.replaceChildren();
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
if (imageUrl) {
const label = document.createElement('div');
label.className = 'mr-tooltip-label';
label.textContent = text;
this.tooltipElement.append(label);
this.tooltipElement.style.display = 'block';
this.positionTooltip(target);
this.tooltipElement.setAttribute('data-visible', 'true');
const mediaType = await this.getTooltipPreviewMediaType(imageUrl);
if (
this._tooltipTarget !== target
|| this.contextMenu?.style.display === 'block'
) {
return;
}
const media = document.createElement(mediaType === 'video' ? 'video' : 'img');
media.className = 'mr-tooltip-preview';
if (mediaType === 'video') {
media.autoplay = true;
media.loop = true;
media.muted = true;
media.playsInline = true;
media.preload = 'metadata';
} else {
media.alt = '';
media.decoding = 'async';
}
media.addEventListener(mediaType === 'video' ? 'loadeddata' : 'load', () => {
if (this._tooltipTarget === target) {
this.positionTooltip(target);
if (mediaType === 'video') {
media.play?.().catch(() => {});
}
}
});
media.addEventListener('error', () => {
if (this._tooltipTarget !== target) return;
media.remove();
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
this.positionTooltip(target);
});
media.src = imageUrl;
this.tooltipElement.prepend(media);
this.tooltipElement.classList.add('mr-global-tooltip-with-image');
} else {
this.tooltipElement.textContent = text;
}
this.tooltipElement.style.display = 'block';
this.positionTooltip(target);
this.tooltipElement.setAttribute('data-visible', 'true');
},
positionTooltip(target) {
if (!target || !this.tooltipElement || this._tooltipTarget !== target) return;
const rect = target.getBoundingClientRect();
const tooltipRect = this.tooltipElement.getBoundingClientRect();
const margin = 12;
const maxLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin);
let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
left = Math.min(Math.max(margin, left), maxLeft);
let top = rect.top - tooltipRect.height - 10;
if (top < margin) {
top = Math.min(window.innerHeight - tooltipRect.height - margin, rect.bottom + 10);
}
top = Math.max(margin, top);
this.tooltipElement.style.left = `${Math.round(left)}px`;
this.tooltipElement.style.top = `${Math.round(top)}px`;
},
hideTooltip() {
if (!this.tooltipElement) return;
this._tooltipTarget = null;
this.tooltipElement.style.display = 'none';
this.tooltipElement.classList.remove('mr-global-tooltip-with-image');
this.tooltipElement.replaceChildren();
this.tooltipElement.removeAttribute('data-visible');
},
normalizeTooltipTarget(target) {
if (!target || !target.getAttribute) return;
const title = target.getAttribute('title');
if (title && !target.getAttribute('data-tooltip')) {
target.setAttribute('data-tooltip', title);
}
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
if (target.classList?.contains('mr-tooltip-badge') && !target.hasAttribute('tabindex')) {
target.setAttribute('tabindex', '0');
}
},
setTooltip(target, text) {
if (!target || !text) return;
target.setAttribute('data-tooltip', text);
if (target.hasAttribute('title')) {
target.removeAttribute('title');
}
this.bindTooltips(target);
},
bindTooltips(container) {
if (!container) return;
const selector = '[data-tooltip], [title]';
const targets = [];
if (container.matches?.(selector)) {
targets.push(container);
}
if (container.querySelectorAll) {
targets.push(...container.querySelectorAll(selector));
}
targets.forEach((target) => {
this.normalizeTooltipTarget(target);
if (!target.dataset) return;
if (target.dataset.mlTooltipBound === '1') return;
target.dataset.mlTooltipBound = '1';
target.addEventListener('mouseenter', () => this.showTooltip(target));
target.addEventListener('focus', () => this.showTooltip(target));
target.addEventListener('mouseleave', () => this.hideTooltip());
target.addEventListener('blur', () => this.hideTooltip());
});
},
getValidTab(tab) {
return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing';
},
restoreActiveTab() {
try {
return this.getValidTab(localStorage.getItem(this.activeTabStorageKey));
} catch (error) {
console.warn('Model Resolver: Failed to restore active tab:', error);
return 'missing';
}
},
persistActiveTab(tab) {
try {
localStorage.setItem(this.activeTabStorageKey, this.getValidTab(tab));
} catch (error) {
console.warn('Model Resolver: Failed to persist active tab:', error);
}
},
/**
* Return true when at least one downloadable source was found
*/
hasSearchResults(data = {}) {
return !!(
data.popular
|| data.model_list
|| data.huggingface
|| data.civitai
|| data.civarchive
|| data.lora_manager_archive
|| (Array.isArray(data.custom) && data.custom.length)
);
},
isAnyModelSearchResult(result) {
if (Array.isArray(result)) {
return result.some(item => this.isAnyModelSearchResult(item));
}
return Boolean(result && typeof result === 'object' && (result.any_model_match || result.base_model_fallback));
},
hasSearchResultsForMissing(missing = {}) {
if (missing?.download_source?.url) return true;
const state = this.searchResultCache?.get(this.getMissingSearchKey(missing));
return this.hasSearchResults(state?.results || {});
},
hasSearchAttemptForMissing(missing = {}) {
if (missing?.download_source?.url) return true;
const state = this.searchResultCache?.get(this.getMissingSearchKey(missing));
return this.hasRenderableSearchState?.(state || {}) || this.hasSearchResults(state?.results || {});
},
setSourceProgress(state, source, patch = {}, missing = null, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
state.sourceProgress = {
...(state.sourceProgress || {}),
[source]: {
...(state.sourceProgress?.[source] || {}),
...patch
}
};
if (missing && workflowKey === this.getWorkflowScopedQueueKey()) {
this.refreshMissingSourcesSummary(missing);
}
},
refreshMissingSourcesSummary(missing = {}) {
if (!this.contentElement || !missing) return;
const key = this.getMissingModelKey(missing);
const rows = this.contentElement.querySelectorAll('.mr-missing-list-row');
for (const row of rows) {
if (row.getAttribute('data-missing-key') !== key) continue;
const sourcesEl = row.querySelector('.mr-missing-row-sources');
if (sourcesEl) {
sourcesEl.innerHTML = this.renderMissingSourcesSummary(missing);
}
}
},
getSearchSourceEstimateMs(source, isUrn = false) {
if (isUrn && source === 'civitai') return 5000;
if (isUrn && source === 'civarchive') return 6000;
const estimates = {
local: 1400,
lora_manager_archive: 3200,
civarchive: 10000,
huggingface: 18000,
civitai: 22000
};
return estimates[source] || 8000;
},
getEstimatedSearchProgressPercent(elapsedMs, estimateMs) {
const safeEstimate = Math.max(1000, Number(estimateMs) || 8000);
const elapsed = Math.max(0, Number(elapsedMs) || 0);
if (elapsed <= safeEstimate) {
const normalized = Math.min(1, elapsed / safeEstimate);
const eased = 1 - Math.pow(1 - normalized, 2.1);
return 6 + eased * 78;
}
const overtime = elapsed - safeEstimate;
const slowTail = 1 - Math.exp(-overtime / (safeEstimate * 1.6));
return Math.min(94, 84 + slowTail * 10);
},
getSearchProgressTimerKey(runId, source) {
return `${runId || 'search'}:${source}`;
},
clearSearchProgressTimer(runId, source) {
const key = this.getSearchProgressTimerKey(runId, source);
const timer = this.searchProgressTimers.get(key);
if (timer) {
clearInterval(timer);
this.searchProgressTimers.delete(key);
}
},
clearSearchProgressTimers(runId) {
if (!runId) return;
const prefix = `${runId}:`;
for (const [key, timer] of this.searchProgressTimers.entries()) {
if (key.startsWith(prefix)) {
clearInterval(timer);
this.searchProgressTimers.delete(key);
}
}
},
getBackendSearchProgressTimerKey(runId, source) {
return `${runId || 'search'}:${source}:backend`;
},
clearBackendSearchProgressTimer(runId, source) {
const key = this.getBackendSearchProgressTimerKey(runId, source);
const timer = this.backendSearchProgressTimers?.get(key);
if (timer) {
clearInterval(timer.interval);
this.backendSearchProgressTimers.delete(key);
}
},
clearBackendSearchProgressTimers(runId) {
if (!runId || !this.backendSearchProgressTimers) return;
const prefix = `${runId}:`;
for (const [key, timer] of this.backendSearchProgressTimers.entries()) {
if (key.startsWith(prefix)) {
clearInterval(timer.interval);
this.backendSearchProgressTimers.delete(key);
}
}
},
startBackendSearchProgressPolling(state, missing, source, runId, progressId, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
if (!progressId || !source || !runId) return;
if (!(this.backendSearchProgressTimers instanceof Map)) {
this.backendSearchProgressTimers = new Map();
}
this.clearBackendSearchProgressTimer(runId, source);
const key = this.getBackendSearchProgressTimerKey(runId, source);
const timerRecord = { interval: null, inFlight: false };
const tick = async () => {
const missingSearchKey = this.getMissingSearchKey(missing);
if (
state.activeSearchRunId !== runId
&& !this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, runId)
) {
this.clearBackendSearchProgressTimer(runId, source);
return;
}
const currentProgress = state.sourceProgress?.[source];
if (!currentProgress || (currentProgress.status !== 'pending' && currentProgress.status !== 'running')) {
this.clearBackendSearchProgressTimer(runId, source);
return;
}
if (timerRecord.inFlight) return;
timerRecord.inFlight = true;
try {
const progress = await this.fetchJson(`/model_resolver/search-progress/${encodeURIComponent(progressId)}`, {
silent: true
}, 'Poll search progress');
if (!progress) return;
if (!progress?.exists) return;
const latestProgress = state.sourceProgress?.[source];
if (!latestProgress || (latestProgress.status !== 'pending' && latestProgress.status !== 'running')) {
this.clearBackendSearchProgressTimer(runId, source);
return;
}
if (progress.status === 'error') {
this.setSourceProgress(state, source, {
status: 'error',
percent: 100,
message: progress.message || 'Error',
error: progress.message || 'Error',
backendStage: progress.stage || ''
}, missing, { workflowKey });
this.persistSearchStateForWorkflow(workflowKey, missing, state);
this.refreshSearchUiForMissing(missing, state, { workflowKey });
this.clearBackendSearchProgressTimer(runId, source);
return;
}
if (progress.status === 'completed') {
this.clearBackendSearchProgressTimer(runId, source);
return;
}
const nextPercent = Math.max(
Number(currentProgress.percent) || 0,
Number(progress.percent) || 0
);
this.setSourceProgress(state, source, {
status: 'running',
percent: nextPercent,
message: progress.message || currentProgress.message || 'Searching...',
backendStage: progress.stage || currentProgress.backendStage || ''
}, missing, { workflowKey });
this.persistSearchStateForWorkflow(workflowKey, missing, state);
this.refreshSearchUiForMissing(missing, state, { workflowKey });
} catch (_error) {
this.clearBackendSearchProgressTimer(runId, source);
} finally {
timerRecord.inFlight = false;
}
};
timerRecord.interval = setInterval(tick, 550);
this.backendSearchProgressTimers.set(key, timerRecord);
tick();
},
startEstimatedSearchProgress(state, missing, container, source, runId, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
this.clearSearchProgressTimer(runId, source);
const tick = () => {
const missingSearchKey = this.getMissingSearchKey(missing);
if (
state.activeSearchRunId !== runId
&& !this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, runId)
) {
this.clearSearchProgressTimer(runId, source);
return;
}
const progress = state.sourceProgress?.[source];
if (!progress || progress.status !== 'running') {
this.clearSearchProgressTimer(runId, source);
return;
}
const elapsedMs = Date.now() - (progress.startedAt || Date.now());
const percent = this.getEstimatedSearchProgressPercent(
elapsedMs,
progress.estimateMs
);
if (percent > (Number(progress.percent) || 0) + 0.2) {
this.setSourceProgress(state, source, { percent }, missing, { workflowKey });
this.persistSearchStateForWorkflow(workflowKey, missing, state);
this.refreshSearchUiForMissing(missing, state, { workflowKey });
}
};
tick();
const timer = setInterval(tick, 450);
this.searchProgressTimers.set(
this.getSearchProgressTimerKey(runId, source),
timer
);
},
settleInactiveSearchProgress(missing, state = null, {
workflowKey = this.getWorkflowScopedQueueKey(),
message = 'Search interrupted',
persist = true,
refresh = true
} = {}) {
if (!missing || !state) return false;
const runId = state.activeSearchRunId;
const missingSearchKey = this.getMissingSearchKey(missing);
const hasActiveJob = Boolean(
runId && this.hasBackgroundSearchJob(workflowKey, missingSearchKey, runId)
);
if (hasActiveJob) return false;
let changed = false;
let markedError = false;
let markedFound = false;
if (runId) {
this.clearSearchProgressTimers(runId);
state.activeSearchRunId = null;
changed = true;
}
for (const [source, progress] of Object.entries(state.sourceProgress || {})) {
if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue;
const resultStatus = this.getMissingSourceResultStatus?.(missing, source, state) || '';
const hasResult = resultStatus === 'exact'
|| resultStatus === 'partial'
|| resultStatus === 'found';
state.sourceProgress[source] = {
...progress,
status: hasResult ? 'found' : 'error',
percent: 100,
message: hasResult ? 'Found' : message,
error: hasResult ? null : (progress.error || message)
};
markedFound = markedFound || hasResult;
markedError = markedError || !hasResult;
changed = true;
}
if (!changed) return false;
if (markedFound) {
state.lastAttemptFound = true;
}
if (markedError && !state.lastAttemptError) {
state.lastAttemptError = message;
}
if (persist) {
this.persistSearchStateForWorkflow(workflowKey, missing, state);
}
if (refresh) {
this.refreshSearchUiForMissing(missing, state, { workflowKey });
}
return true;
},
syncSearchProgressAfterResume(missingModels = this.missingModels || [], { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
for (const missing of missingModels || []) {
const missingSearchKey = this.getMissingSearchKey(missing);
const state = workflowKey === this.getWorkflowScopedQueueKey()
? this.searchResultCache.get(missingSearchKey)
: this.getWorkflowSearchCache(workflowKey)?.get(missingSearchKey);
if (!state) continue;
if (this.settleInactiveSearchProgress(missing, state, { workflowKey })) {
continue;
}
const runId = state.activeSearchRunId;
if (!runId || !this.hasBackgroundSearchJob(workflowKey, missingSearchKey, runId)) continue;
const job = this.getBackgroundSearchJob(workflowKey, missingSearchKey);
for (const [source, progress] of Object.entries(state.sourceProgress || {})) {
if (progress?.status !== 'running') continue;
const progressId = job?.sourceProgressIds?.get?.(source);
if (progressId) {
this.startBackendSearchProgressPolling?.(state, missing, source, runId, progressId, { workflowKey });
}
this.startEstimatedSearchProgress(state, missing, null, source, runId, { workflowKey });
}
this.refreshSearchUiForMissing(missing, state, { workflowKey });
}
},
reconnectActiveSearchProgress(missingModels = this.missingModels || []) {
this.syncSearchProgressAfterResume(missingModels);
},
hasActiveSearchProgress(state = {}) {
return Object.values(state.sourceProgress || {}).some(progress => (
progress?.status === 'pending' || progress?.status === 'running'
));
},
renderSearchProgress(state = {}) {
const progressEntries = Object.entries(state.sourceProgress || {});
if (!progressEntries.length) return '';
const isCompact = !this.hasActiveSearchProgress(state);
const statusLabels = {
pending: 'Queued',
running: 'Searching...',
found: 'Found',
none: 'No match',
error: 'Error',
cancelled: 'Cancelled'
};
if (isCompact) {
let html = '<div class="mr-search-progress-list mr-search-progress-list-compact">';
for (const [source, progress] of progressEntries) {
const status = progress?.status || 'pending';
const statusClass = String(status).replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(source);
const statusLabel = progress?.message || statusLabels[status] || status;
const title = `${label}: ${progress?.error || statusLabel}`;
const retryButton = status === 'error' && progress?.retryable
? `<button type="button" class="mr-search-result-action-btn mr-search-progress-retry" data-source="${this.escapeHtml(source)}" data-tooltip="Retry ${this.escapeHtml(label)} search" aria-label="Retry ${this.escapeHtml(label)} search">${getSvgIcon('refreshCw')}</button>`
: '';
html += `
<div class="mr-search-progress-item mr-search-progress-${statusClass}" data-tooltip="${this.escapeHtml(title)}">
<span class="mr-search-progress-source">${this.escapeHtml(label)}</span>
<span class="mr-search-progress-status">${this.escapeHtml(statusLabel)}</span>
${retryButton}
</div>
`;
}
html += '</div>';
return html;
}
let html = '<div class="mr-search-progress-list">';
for (const [source, progress] of progressEntries) {
const status = progress?.status || 'pending';
const statusClass = String(status).replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(source);
const canCancel = state.activeSearchRunId && (status === 'pending' || status === 'running');
const percent = status === 'pending'
? 0
: (status === 'running' ? progress?.percent : 100);
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const runningMessage = progress?.message || 'Searching...';
const statusLabel = status === 'running'
? `${runningMessage} ${Math.round(safePercent)}%`
: (progress?.message || statusLabels[status] || status);
const title = progress?.error ? ` data-tooltip="${this.escapeHtml(`${label}: ${progress.error}`)}"` : '';
const cancelButton = canCancel
? `<button type="button" class="mr-search-result-action-btn mr-search-progress-cancel" data-source="${this.escapeHtml(source)}" data-run-id="${this.escapeHtml(state.activeSearchRunId)}" data-tooltip="Cancel ${this.escapeHtml(label)} search" aria-label="Cancel ${this.escapeHtml(label)} search">${getSvgIcon('x')}</button>`
: '';
const retryButton = status === 'error' && progress?.retryable
? `<button type="button" class="mr-search-result-action-btn mr-search-progress-retry" data-source="${this.escapeHtml(source)}" data-tooltip="Retry ${this.escapeHtml(label)} search" aria-label="Retry ${this.escapeHtml(label)} search">${getSvgIcon('refreshCw')}</button>`
: '';
html += `
<div class="mr-search-progress-item mr-search-progress-${statusClass}"${title}>
<div class="mr-search-progress-head">
<span class="mr-search-progress-source">${this.escapeHtml(label)}</span>
<span class="mr-search-progress-status">${this.escapeHtml(statusLabel)}</span>
${cancelButton}
${retryButton}
</div>
<div class="mr-search-progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${safePercent}">
<div class="mr-search-progress-fill" style="width: ${safePercent}%;"></div>
</div>
</div>
`;
}
html += '</div>';
return html;
},
wireSearchProgressRetryButtons(container, missing, state = null, options = {}) {
if (!container || !missing) return;
const workflowKey = options.workflowKey || this.getWorkflowScopedQueueKey();
container.querySelectorAll?.('.mr-search-progress-retry').forEach(button => {
if (button.dataset.mlRetrySearchBound === '1') return;
button.dataset.mlRetrySearchBound = '1';
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const source = button.dataset.source || '';
const progress = state?.sourceProgress?.[source];
if (!source || !progress?.retryable || state?.activeSearchRunId) return;
button.disabled = true;
this.searchOnline(missing, {
workflowKey,
source,
forceSearch: true
});
});
});
},
wireSearchProgressCancelButtons(container, missing, state = null, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
if (!container || !missing) return;
container.querySelectorAll?.('.mr-search-progress-cancel').forEach(button => {
if (button.dataset.mlCancelSearchBound === '1') return;
button.dataset.mlCancelSearchBound = '1';
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const source = button.dataset.source || '';
const runId = button.dataset.runId || state?.activeSearchRunId || '';
this.cancelSearchSource(missing, source, runId, { workflowKey });
});
});
},
cancelBackendSearchProgress(progressId = '') {
const id = String(progressId || '').trim();
if (!id) return;
this.fetchJson(`/model_resolver/search-cancel/${encodeURIComponent(id)}`, {
method: 'POST',
silent: true
}, 'Cancel search').catch(error => {
console.warn('Model Resolver: backend search cancel failed', error);
});
},
cancelSearchSource(missing, source, runId = '', { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
if (!missing || !source || !runId) return false;
const missingSearchKey = this.getMissingSearchKey(missing);
const state = this.getSearchStateForWorkflow(workflowKey, missing);
const job = this.getBackgroundSearchJob(workflowKey, missingSearchKey);
if (!state || state.activeSearchRunId !== runId || !job || job.runId !== runId) return false;
job.cancelledSources = job.cancelledSources || new Set();
job.cancelledSources.add(source);
const progressId = job.sourceProgressIds?.get(source) || '';
this.cancelBackendSearchProgress(progressId);
const controller = job.sourceControllers?.get(source);
if (controller && !controller.signal?.aborted) {
controller.abort();
}
this.clearSearchProgressTimer(runId, source);
this.setSourceProgress(state, source, {
status: 'cancelled',
percent: 100,
message: 'Cancelled',
error: null
}, missing, { workflowKey });
const hasRunningSources = Object.entries(state.sourceProgress || {}).some(([entrySource, progress]) => (
entrySource !== source
&& (progress?.status === 'pending' || progress?.status === 'running')
&& !job.cancelledSources?.has(entrySource)
));
if (!hasRunningSources) {
state.activeSearchRunId = null;
this.clearSearchProgressTimers(runId);
this.backgroundSearchJobs?.delete(this.getBackgroundSearchJobKey(workflowKey, missingSearchKey));
}
this.persistSearchStateForWorkflow(workflowKey, missing, state);
this.refreshSearchUiForMissing(missing, state, { workflowKey });
return true;
},
cancelBackgroundSearchJob(job = null) {
if (!job?.missing || !job.runId) return false;
const sources = new Set([
...Array.from(job.sourceControllers?.keys?.() || []),
...Array.from(job.sourceProgressIds?.keys?.() || []),
...Object.keys(this.getSearchStateForWorkflow(job.workflowKey, job.missing)?.sourceProgress || {})
]);
let cancelled = false;
for (const source of sources) {
cancelled = this.cancelSearchSource(job.missing, source, job.runId, {
workflowKey: job.workflowKey || this.getWorkflowScopedQueueKey()
}) || cancelled;
}
return cancelled;
},
/**
* Update source selector buttons and helper text for one card
*/
updateSearchBaseModelHelp(container, missing) {
if (!container || !missing) return;
const baseId = `search-base-select-${missing.node_id}-${missing.widget_index}`;
const labelEl = container.querySelector(`label[for="${baseId}"]`);
const helpEl = labelEl?.querySelector('.mr-search-model-help');
if (helpEl) {
helpEl.setAttribute('data-tooltip', this.getSearchBaseModelTooltip(missing));
}
},
syncSearchSourceUi(missing, container) {
if (!container) return;
const state = this.getSearchState(missing);
const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
if (selectEl) {
const options = this.getSearchSourceOptions();
if (!options.some(option => option.value === state.selectedSource)) {
state.selectedSource = 'all';
}
this.setDropdownValue(selectEl, state.selectedSource, this.getSearchSourceLabel(state.selectedSource));
}
const baseEl = container.querySelector(`#search-base-select-${missing.node_id}-${missing.widget_index}`);
if (baseEl) {
const options = this.getKnownBaseModelOptions();
if (!options.some(option => option.value === state.selectedBaseModel)) {
state.selectedBaseModel = this.getDefaultSearchBaseModel();
}
this.setDropdownValue(baseEl, state.selectedBaseModel, this.getSearchBaseModelLabel(state.selectedBaseModel, missing));
this.updateSearchBaseModelHelp(container, missing);
}
},
/**
* Set current search source for one card
*/
setSearchSource(missing, source, container) {
const state = this.getSearchState(missing);
state.selectedSource = source || 'all';
this.persistSearchStateForActiveWorkflow();
this.syncSearchSourceUi(missing, container);
},
setSearchBaseModel(missing, baseModel, container, options = {}) {
const { sync = true } = options || {};
const state = this.getSearchState(missing);
const nextBaseModel = baseModel || this.getDefaultSearchBaseModel();
state.selectedBaseModel = nextBaseModel;
this.persistSearchStateForActiveWorkflow();
if (sync) {
this.syncSearchSourceUi(missing, container);
} else {
this.updateSearchBaseModelHelp(container, missing);
}
this.refreshSearchUiForMissing?.(missing, state);
},
getDropdownValue(el) {
return el?.dataset?.value || el?.value || '';
},
setDropdownValue(el, value, label = value) {
if (!el) return;
el.dataset.value = value || '';
el.value = label || value || '';
},
refreshSearchBaseModelLabels(container = this.contentElement) {
if (!container) return;
container.querySelectorAll?.('.mr-search-base-select').forEach((baseEl) => {
const value = baseEl.dataset?.value || 'auto';
if (value === 'auto') {
const missingKey = baseEl.dataset?.missingSearchKey || '';
const missing = (this.missingModels || []).find(item => this.getMissingSearchKey(item) === missingKey) || {};
this.setDropdownValue(baseEl, value, this.getSearchBaseModelLabel(value, missing));
this.updateSearchBaseModelHelp(container, missing);
}
});
},
normalizeVersionName(versionName) {
return String(versionName || '').trim().replace(/^v{2,}(?=\d)/i, 'v');
},
getModelVersionParts(modelName, versionName) {
const name = String(modelName || '').trim();
const version = this.normalizeVersionName(versionName);
if (!version || version === name) {
return { name: name || version, version: '' };
}
if (name && name.toLowerCase().includes(version.toLowerCase())) {
return { name, version: '' };
}
return { name, version };
},
getVersionedModelName(modelName, versionName) {
const parts = this.getModelVersionParts(modelName, versionName);
if (!parts.version) return parts.name;
return parts.name ? `${parts.name} ${parts.version}` : parts.version;
},
renderVersionedModelNameHtml(modelName, versionName) {
const parts = this.getModelVersionParts(modelName, versionName);
const nameHtml = parts.name ? this.escapeHtml(parts.name) : '';
const versionHtml = parts.version
? `<em class="mr-model-version">${this.escapeHtml(parts.version)}</em>`
: '';
return [nameHtml, versionHtml].filter(Boolean).join(' ');
},
getSearchSourceIconName(sourceKey) {
const icons = {
popular: 'star',
'model-list': 'comfyui',
huggingface: 'huggingface',
civitai: 'civitai',
civarchive: 'civarchive',
'lora-archive': 'loraManager',
lora_manager_archive: 'loraManager',
'lora-manager-archive': 'loraManager',
custom: 'link',
local: 'comfyui',
'workflow-url': 'link',
workflow: 'link',
online: 'globe'
};
return icons[sourceKey] || 'globe';
},
getSearchResultTimestamp(result = {}) {
if (Array.isArray(result)) {
return this.getSearchResultTimestamp(result[0] || {});
}
return result?.searchedAt
|| result?.searched_at
|| result?.cachedAt
|| result?.cached_at
|| result?.updatedAt
|| result?.updated_at
|| '';
},
formatSearchResultTimestamp(value, { compact = false } = {}) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
const monthNames = compact
? ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const pad = (number) => String(number).padStart(2, '0');
const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${monthNames[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()} ${time}`;
},
renderSearchSourcePill(sourceKey, sourceLabel, searchedAt = '') {
const iconName = this.getSearchSourceIconName(sourceKey);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'mr-search-source-icon');
const sourceDate = this.formatSearchResultTimestamp(searchedAt, { compact: true });
const sourceDateTitle = this.formatSearchResultTimestamp(searchedAt);
return `
<div class="mr-search-source-cell">
<span class="mr-search-source-pill mr-search-source-${sourceKey}" data-tooltip="${this.escapeHtml(sourceLabel)}">
${iconHtml}
<span>${this.escapeHtml(sourceLabel)}</span>
</span>
${sourceDate ? `<small class="mr-search-source-date" data-tooltip="Result saved ${this.escapeHtml(sourceDateTitle)}">${this.escapeHtml(sourceDate)}</small>` : ''}
</div>
`;
},
getSearchResultsTableLayout(rows = []) {
const maxSourceLabel = rows.reduce(
(max, row) => Math.max(max, String(row.sourceLabel || row.sourceKey || '').length),
'Source'.length
);
const maxMatchLabel = rows.reduce(
(max, row) => Math.max(max, String(row.match?.label || '').length),
'Match'.length
);
const maxSizeLabel = rows.reduce(
(max, row) => Math.max(max, String(row.size || '-').length),
'Size'.length
);
const maxActions = rows.reduce((max, row) => {
const count = (row.detailsContext ? 1 : 0) + (row.downloadUrl ? 1 : 0) + (row.openUrl ? 1 : 0);
return Math.max(max, count);
}, 1);
const sourcePx = Math.max(118, this.estimateTextWidth('x'.repeat(maxSourceLabel), 6, 34, 100) + 52);
const matchPx = this.estimateTextWidth('x'.repeat(maxMatchLabel), 7, 34, 76) + 22;
const sizePx = this.estimateTextWidth('x'.repeat(maxSizeLabel), 6.5, 42, 88) + 30;
const actionsPx = Math.max(96, (maxActions * 28) + (Math.max(0, maxActions - 1) * 8) + 26);
const modelMinPx = 210;
const tableMinPx = Math.ceil(sourcePx + matchPx + sizePx + actionsPx + modelMinPx);
return { sourcePx, matchPx, sizePx, actionsPx, tableMinPx };
},
formatSearchResultSize(result = {}) {
if (result.size === 0) return '0 B';
if (!result.size) return '';
return typeof result.size === 'number' ? this.formatBytes(result.size) : String(result.size);
},
getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral', hashLabel = '') {
const matchType = String(result.match_type || '').toLowerCase();
const resolvedHashLabel = String(hashLabel || result.hash_match_label || result.hashLabel || '').trim();
if (resolvedHashLabel || result.hash_verified || result.hash_verified_sha256 || matchType === 'hash') {
return { label: resolvedHashLabel || 'Hash', className: 'hash' };
}
if (matchType === 'exact') {
return { label: 'Exact', className: 'strong' };
}
if (matchType === 'model_title') {
return { label: 'Title', className: 'strong' };
}
if (matchType === 'custom_url' || result.custom_url || result.url_source === 'custom') {
return { label: 'Provided', className: 'strong' };
}
const confidence = Number(result.confidence);
if (Number.isFinite(confidence) && confidence > 0) {
const className = confidence >= 95 ? 'strong' : (confidence >= 70 ? 'medium' : 'weak');
return { label: `${Math.round(confidence)}%`, className };
}
if (matchType === 'partial') {
return { label: 'Partial', className: 'medium' };
}
if (matchType === 'fuzzy' || matchType === 'similar') {
return { label: matchType === 'fuzzy' ? 'Fuzzy' : 'Similar', className: 'medium' };
}
return { label: fallbackLabel, className: fallbackClass };
},
getDownloadSourceTableRow(missing, downloadSource = {}, hashLabelMap = null) {
if (!downloadSource?.url) return null;
const originalFilename = this.getFilenameFromPath(missing.original_path);
const downloadFilename = downloadSource.filename || originalFilename || 'model';
const isFromWorkflow = downloadSource.url_source === 'workflow';
const source = downloadSource.source || (isFromWorkflow ? 'workflow' : 'online');
const sourceLabels = {
popular: 'Popular',
model_list: 'Local Database',
huggingface: 'HuggingFace',
civitai: 'CivitAI',
civarchive: 'CivArchive',
lora_manager_archive: 'LoRA Archive',
workflow: 'Workflow',
online: 'Online'
};
const sourceLabel = isFromWorkflow ? 'Workflow URL' : (sourceLabels[source] || source);
const sourceKey = isFromWorkflow
? 'workflow-url'
: String(source).replace(/_/g, '-');
const sourceSecondary = isFromWorkflow && sourceLabels[source] && source !== 'workflow'
? sourceLabels[source]
: '';
const rawModelUrl = downloadSource.model_url
|| downloadSource.workflow_model_url
|| downloadSource.url;
const modelUrl = getModelCardUrl(rawModelUrl) || rawModelUrl;
const normalizedSource = String(source).toLowerCase();
const huggingFaceFile = normalizedSource === 'huggingface'
? parseHuggingFaceFileUrl(
downloadSource.download_url || downloadSource.url || rawModelUrl
)
: null;
const versionName = downloadSource.version_name || missing.civitai_info?.version_name || '';
const modelParts = this.getModelVersionParts(
downloadSource.name || missing.civitai_info?.model_name || '',
versionName
);
const modelName = modelParts.name || downloadFilename;
const fullModelName = this.getVersionedModelName(modelName, modelParts.version);
const baseModel = downloadSource.base_model || missing.civitai_info?.base_model || '';
const rowCategory = this.getSourceResultDownloadCategory?.(
downloadSource,
downloadSource.directory || downloadSource.category || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints',
missing
) || downloadSource.directory || downloadSource.category || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints';
const secondaryParts = [
sourceSecondary,
fullModelName && fullModelName !== downloadFilename ? downloadFilename : '',
baseModel
].filter(Boolean);
const rowPathMetadata = this.getDownloadPathMetadata(missing, {
...downloadSource,
filename: downloadFilename,
model: modelName,
version: modelParts.version,
category: rowCategory
});
const rowDetailsContext = ['civitai', 'civarchive', 'huggingface'].includes(normalizedSource)
? {
...downloadSource,
source,
details_source: normalizedSource,
model_id: downloadSource.model_id || huggingFaceFile?.repo,
repo_id: downloadSource.repo_id || downloadSource.repo || huggingFaceFile?.repo,
version_id: downloadSource.version_id || huggingFaceFile?.revision,
branch: downloadSource.branch || huggingFaceFile?.revision || '',
path: downloadSource.path || huggingFaceFile?.path || '',
name: modelName,
filename: downloadFilename,
missing_key: this.getMissingModelKey(missing),
node_id: missing.node_id,
widget_index: missing.widget_index,
subgraph_id: missing.subgraph_id || '',
is_top_level: missing.is_top_level !== false,
category: rowCategory
}
: null;
const localHashMatches = Array.isArray(missing.matches)
? missing.matches.filter(match => match?.hash_lookup_source)
: [];
const localHashMatchIdentities = this.getLocalHashMatchIdentitiesForResult?.(
localHashMatches,
source,
downloadSource
) || [];
const resolvedHashLabelMap = hashLabelMap || this.getHashMatchLabelMap?.(missing) || null;
const hashMatchLabel = this.getHashMatchLabelForSearchResult?.(
downloadSource,
resolvedHashLabelMap,
localHashMatchIdentities
) || '';
return {
sourceKey,
sourceLabel,
model: modelName,
version: modelParts.version,
filename: downloadFilename,
secondary: secondaryParts.join(' / '),
size: this.formatSearchResultSize(downloadSource),
downloadUrl: downloadSource.url,
downloadFilename,
category: rowCategory,
openUrl: modelUrl,
searchedAt: this.getSearchResultTimestamp(downloadSource),
localHashMatchIdentities,
pathMetadata: rowPathMetadata,
downloadMetadata: this.getDownloadMetadata(missing, rowDetailsContext || {
...downloadSource,
filename: downloadFilename,
model: modelName,
version: modelParts.version,
category: rowCategory
}, {
filename: downloadFilename,
category: rowCategory,
url: downloadSource.url,
openUrl: modelUrl,
pathMetadata: rowPathMetadata
}),
detailsContext: rowDetailsContext,
match: isFromWorkflow
? { label: 'Provided', className: 'strong' }
: this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong', hashMatchLabel)
};
},
getCustomUrlResultTableRow(missing, result = {}, hashLabelMap = null, localHashMatches = []) {
if (!result || typeof result !== 'object') return null;
const source = String(result.source || result.details_source || 'custom').toLowerCase().replace(/-/g, '_');
const sourceLabels = {
huggingface: 'HuggingFace',
civitai: 'CivitAI',
civarchive: 'CivArchive',
custom: 'Custom URL'
};
const sourceKeys = {
huggingface: 'huggingface',
civitai: 'civitai',
civarchive: 'civarchive',
custom: 'custom'
};
const downloadUrl = result.download_url || result.downloadUrl || result.url || '';
if (!downloadUrl) return null;
const originalFilename = this.getFilenameFromPath(missing.original_path);
const filename = result.filename || this.getFilenameFromPath(result.path || downloadUrl) || originalFilename || 'model';
const modelName = result.name || result.model_name || result.repo_id || result.repo || filename;
const category = this.getSourceResultDownloadCategory?.(
result,
this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints',
missing
) || this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
const openUrl = source === 'huggingface'
? (result.page_url || result.version_url || getModelCardUrl(downloadUrl) || result.url)
: (result.version_url || result.url || result.platform_url || getModelCardUrl(downloadUrl));
const version = result.version_name || '';
const secondary = source === 'huggingface'
? [result.path && result.path !== filename ? result.path : ''].filter(Boolean).join(' / ')
: [
modelName && modelName !== filename ? filename : '',
result.type || '',
result.base_model || ''
].filter(Boolean).join(' / ');
const detailsContext = {
...result,
source,
details_source: source,
custom_url: true,
url_source: 'custom',
model_id: result.model_id || result.modelId,
version_id: result.version_id || result.versionId,
name: modelName,
filename,
missing_key: this.getMissingModelKey(missing),
category
};
const localHashMatchIdentities = this.getLocalHashMatchIdentitiesForResult?.(
localHashMatches,
source,
result
) || [];
const hashMatchLabel = this.getHashMatchLabelForSearchResult?.(
result,
hashLabelMap,
localHashMatchIdentities
) || '';
return {
sourceKey: sourceKeys[source] || 'custom',
sourceLabel: sourceLabels[source] || 'Custom URL',
model: modelName,
version,
filename,
secondary,
match: this.getSearchResultMatchDisplay(result, 'Provided', 'strong', hashMatchLabel),
size: this.formatSearchResultSize(result),
downloadUrl,
downloadFilename: filename,
category,
openUrl,
searchedAt: this.getSearchResultTimestamp(result),
localHashMatchIdentities,
detailsContext
};
},
renderKnownDownloadPanel(missing, downloadSource) {
let html = `<div class="mr-download-section">`;
html += this.renderSearchControls(missing, { buttonText: 'Search Again' });
html += this.renderDownloadTargetControls(
missing,
downloadSource.directory || downloadSource.category || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints'
);
html += `</div>`;
const downloadSourceRow = this.getDownloadSourceTableRow(missing, downloadSource);
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" class="mr-search-results mr-is-visible">`;
html += this.renderSearchResultsTable(downloadSourceRow ? [downloadSourceRow] : []);
html += `</div>`;
return html;
},
renderSearchResultsTable(rows = []) {
if (!rows.length) return '';
const layout = this.getSearchResultsTableLayout(rows);
const tableStyle = [
`--mr-source-col:${layout.sourcePx}px`,
`--mr-match-col:${layout.matchPx}px`,
`--mr-size-col:${layout.sizePx}px`,
`--mr-actions-col:${layout.actionsPx}px`,
`--mr-table-min:${layout.tableMinPx}px`
].join(';');
let html = `
<div class="mr-search-results-table-wrap">
<table class="mr-search-results-table" style="${tableStyle}">
<colgroup>
<col class="mr-search-col-source">
<col class="mr-search-col-model">
<col class="mr-search-col-match">
<col class="mr-search-col-size">
<col class="mr-search-col-actions">
</colgroup>
<thead>
<tr>
<th>Source</th>
<th>Model</th>
<th>Match</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
`;
for (const row of rows) {
const sourceKey = String(row.sourceKey || '').replace(/[^a-z0-9_-]/gi, '');
const sourceLabel = row.sourceLabel || row.sourceKey || 'Source';
const sourcePill = this.renderSearchSourcePill(sourceKey, sourceLabel, row.searchedAt || row.sourceDate || '');
const rawModel = row.model || row.filename || 'Model';
const rawVersion = row.version || '';
const model = this.escapeHtml(rawModel);
const modelTitle = this.escapeHtml(this.getVersionedModelName(rawModel, rawVersion) || rawModel);
const modelHtml = this.renderVersionedModelNameHtml(rawModel, rawVersion) || model;
const secondary = row.secondary ? this.escapeHtml(row.secondary) : '';
const filename = row.filename && row.filename !== row.model ? this.escapeHtml(row.filename) : '';
const match = row.match || { label: 'Match', className: 'neutral' };
const matchClass = String(match.className || 'neutral').replace(/[^a-z0-9_-]/gi, '');
const localHashMatchIdentities = Array.isArray(row.localHashMatchIdentities)
? row.localHashMatchIdentities.filter(Boolean)
: [];
const hashMatchTargetData = localHashMatchIdentities.length
? encodeURIComponent(JSON.stringify(localHashMatchIdentities))
: '';
const hashMatchTargetClass = hashMatchTargetData ? ' mr-search-match-has-local-target' : '';
const hashMatchTargetAttrs = hashMatchTargetData
? ` data-local-match-identities="${this.escapeHtml(hashMatchTargetData)}" tabindex="0"`
: '';
const size = this.escapeHtml(row.size || '-');
const downloadUrl = row.downloadUrl || '';
const openUrl = row.openUrl || '';
const downloadFilename = row.downloadFilename || row.filename || row.model || 'model';
const category = row.category || '';
const detailsContext = row.detailsContext && (row.detailsContext.model_id || row.detailsContext.modelId)
? {
...row.detailsContext,
model_id: row.detailsContext.model_id || row.detailsContext.modelId,
version_id: row.detailsContext.version_id || row.detailsContext.versionId,
name: row.detailsContext.name || rawModel,
filename: row.detailsContext.filename || downloadFilename,
category: row.detailsContext.category || category,
context_scope: 'download_table'
}
: null;
const detailsData = detailsContext
? encodeURIComponent(JSON.stringify(detailsContext))
: '';
const pathMetadata = row.pathMetadata
? encodeURIComponent(JSON.stringify(row.pathMetadata))
: '';
const downloadMetadata = row.downloadMetadata
? encodeURIComponent(JSON.stringify(row.downloadMetadata))
: '';
let actions = '';
if (detailsContext) {
actions += `
<button type="button"
class="search-show-details-btn mr-search-result-action-btn"
data-tooltip="Show more"
aria-label="Show more details"
data-model="${this.escapeHtml(detailsData)}">${getSvgIcon('moreCircle')}</button>
`;
}
if (downloadUrl) {
actions += `
<button type="button" class="search-download-btn mr-search-result-action-btn"
data-tooltip="Download"
aria-label="Download ${this.escapeHtml(downloadFilename)}"
data-url="${this.escapeHtml(downloadUrl)}"
data-filename="${this.escapeHtml(downloadFilename)}"
data-category="${this.escapeHtml(category)}"
data-path-metadata="${this.escapeHtml(pathMetadata)}"
data-download-metadata="${this.escapeHtml(downloadMetadata)}">${getSvgIcon('download')}</button>
`;
}
if (openUrl) {
actions += `
<button type="button"
class="search-open-page-btn mr-search-result-action-btn"
data-tooltip="Open model page"
aria-label="Open model page"
data-url="${this.escapeHtml(openUrl)}">${getSvgIcon('globe')}</button>
`;
}
if (!actions) {
actions = '<span class="mr-search-result-empty">-</span>';
}
html += `
<tr>
<td>${sourcePill}</td>
<td>
<div class="mr-search-result-model" data-tooltip="${modelTitle}"${this.getContextMenuAttrs(detailsContext)}>
<span>${modelHtml}</span>
${secondary || filename ? `<small>${secondary || filename}</small>` : ''}
</div>
</td>
<td><span class="mr-search-match mr-search-match-${matchClass}${hashMatchTargetClass}"${hashMatchTargetAttrs}>${this.escapeHtml(match.label)}</span></td>
<td class="mr-search-size">${size}</td>
<td><div class="mr-search-result-actions">${actions}</div></td>
</tr>
`;
}
html += `
</tbody>
</table>
</div>
`;
return html;
},
renderSearchControls(missing, options = {}) {
const searchSourcesId = `search-sources-${missing.node_id}-${missing.widget_index}`;
const searchSourceSelectId = `search-source-select-${missing.node_id}-${missing.widget_index}`;
const searchSourceListId = `search-source-list-${missing.node_id}-${missing.widget_index}`;
const searchBaseSelectId = `search-base-select-${missing.node_id}-${missing.widget_index}`;
const searchBaseListId = `search-base-list-${missing.node_id}-${missing.widget_index}`;
const customUrlAddId = `custom-url-add-${missing.node_id}-${missing.widget_index}`;
const customUrlInputId = `custom-url-${missing.node_id}-${missing.widget_index}`;
const state = this.getSearchState(missing);
const selectedSource = state.selectedSource || 'all';
const selectedBaseModel = state.selectedBaseModel || this.getDefaultSearchBaseModel();
const baseModelTooltip = this.getSearchBaseModelTooltip(missing);
const buttonText = options.buttonText
|| (this.hasRenderableSearchState(state) ? 'Search Again' : 'Search Online');
let html = `<div id="${searchSourcesId}" class="mr-search-source-bar">`;
html += `<div class="mr-search-source-picker mr-search-button-picker">`;
html += `<label class="mr-search-source-picker-label" for="search-${missing.node_id}-${missing.widget_index}">Search</label>`;
html += `<button id="search-${missing.node_id}-${missing.widget_index}" class="mr-btn mr-btn-link">`;
html += this.renderSearchButtonContent(buttonText);
html += `</button>`;
html += `</div>`;
html += `<div class="mr-search-source-picker">`;
html += `<label class="mr-search-source-picker-label" for="${searchSourceSelectId}">Source</label>`;
html += `<div class="mr-download-target-wrap">`;
html += `<input id="${searchSourceSelectId}" class="mr-download-target-input mr-search-source-select" type="text" readonly autocomplete="off" data-value="${this.escapeHtml(selectedSource)}" value="${this.escapeHtml(this.getSearchSourceLabel(selectedSource))}">`;
html += `<div id="${searchSourceListId}" class="mr-download-target-list mr-search-source-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `<div class="mr-search-source-picker mr-search-base-picker">`;
html += `<label class="mr-search-source-picker-label" for="${searchBaseSelectId}">Model <span class="mr-tooltip-badge mr-search-model-help" data-tooltip="${this.escapeHtml(baseModelTooltip)}" tabindex="0">?</span></label>`;
html += `<div class="mr-download-target-wrap">`;
html += `<input id="${searchBaseSelectId}" class="mr-download-target-input mr-search-base-select" type="text" autocomplete="off" data-value="${this.escapeHtml(selectedBaseModel)}" data-missing-search-key="${this.escapeHtml(this.getMissingSearchKey(missing))}" value="${this.escapeHtml(this.getSearchBaseModelLabel(selectedBaseModel, missing))}">`;
html += `<div id="${searchBaseListId}" class="mr-download-target-list mr-search-base-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
html += `<div class="mr-custom-url-bar">`;
html += `<div class="mr-search-source-picker mr-custom-url-button-picker">`;
html += `<label class="mr-search-source-picker-label" for="${customUrlAddId}">Link</label>`;
html += `<button id="${customUrlAddId}" class="mr-btn mr-btn-link mr-custom-url-add-btn" type="button">`;
html += this.renderCustomUrlButtonContent('Add');
html += `</button>`;
html += `</div>`;
html += `<div class="mr-search-source-picker mr-custom-url-input-picker">`;
html += `<label class="mr-search-source-picker-label" for="${customUrlInputId}">URL</label>`;
html += `<input id="${customUrlInputId}" class="mr-download-target-input mr-custom-url-input" type="text" autocomplete="off" spellcheck="false" placeholder="https://...">`;
html += `</div>`;
html += `</div>`;
return html;
},
renderProgressWithAction({
percent = 0,
leftText = '',
rightText = '',
actionClass = '',
actionText = '',
actionDataAttr = '',
actionsHtml = '',
contextMenuModel = null,
contextMenuTooltip = 'Right-click to open download folder'
} = {}) {
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const actionAttr = actionDataAttr ? ` ${actionDataAttr}` : '';
const contextMenuAttrs = this.getContextMenuAttrs(contextMenuModel, contextMenuTooltip);
const containerClass = contextMenuAttrs
? 'mr-progress-container mr-download-folder-context'
: 'mr-progress-container';
const actionHtml = actionsHtml || (actionClass || actionText
? `<button class="${actionClass}"${actionAttr}>${actionText}</button>`
: '');
return `
<div class="${containerClass}"${contextMenuAttrs}>
<div class="mr-progress-row">
<div class="mr-progress-bar mr-progress-bar-grow">
<div class="mr-progress-fill" style="width: ${safePercent}%;"></div>
</div>
${actionHtml}
</div>
<div class="mr-progress-text">
<span>${leftText}</span>
<span>${rightText}</span>
</div>
</div>
`;
},
buildContextMenuModelData(model = {}, fallbackName = '', extra = {}) {
const merged = {
...model,
...(extra && typeof extra === 'object' ? extra : {})
};
const resolvedPath = merged.path || merged.resolved_path || '';
const filename = merged.filename || fallbackName || resolvedPath.split(/[/\\]/).pop() || '';
return {
...merged,
name: merged.name || filename,
original_path: merged.original_path || filename,
resolved_path: resolvedPath,
category: merged.category || ''
};
},
getLocalMatchContextData(missing = {}, match = {}) {
const hash = this.getLocalMatchHash(match);
const metadataPath = match.metadata_path || match.model?.metadata_path || '';
const identity = this.getLocalMatchIdentity?.(match) || '';
return {
context_scope: 'local_match',
missing_key: this.getMissingModelKey?.(missing) || '',
missing_search_key: this.getMissingSearchKey?.(missing) || '',
local_match_identity: identity,
local_match_confidence: match.confidence || 0,
local_match_sha256: hash,
sha256: hash || match.model?.sha256 || '',
hash: hash || match.model?.hash || '',
hash_match: Boolean(match.hash_match || match.match_type === 'hash'),
metadata_path: metadataPath
};
},
renderLocalMatchStatus(match = {}, hashLabelMap = null) {
const confidence = Number(match.confidence || 0);
const isHashMatch = Boolean(match.hash_match || match.match_type === 'hash');
const model = match.model || {};
const hashes = model.hashes && typeof model.hashes === 'object' ? model.hashes : {};
const normalizedHash = String(
match.sha256
|| match.hash
|| model.sha256
|| model.hash
|| hashes.SHA256
|| hashes.sha256
|| ''
).trim().toLowerCase();
const hashLabel = normalizedHash && hashLabelMap?.get?.(normalizedHash)
? hashLabelMap.get(normalizedHash)
: '';
const label = isHashMatch ? (hashLabel || 'Hash') : (confidence === 100 ? 'Exact' : 'Partial');
const className = isHashMatch
? 'mr-match-status-hash'
: (confidence === 100 ? 'mr-match-status-exact' : 'mr-match-status-partial');
const attrs = isHashMatch ? ' tabindex="0"' : '';
return `<span class="mr-match-status ${className}"${attrs}>${label}</span>`;
},
getLocalMatchCategory(match = {}) {
const model = match.model && typeof match.model === 'object' ? match.model : {};
const candidates = [
model.category,
match.category,
model.directory,
match.directory
];
for (const candidate of candidates) {
const raw = String(candidate || '').trim();
if (!raw || /[/\\]/.test(raw) || /^[a-z]:/i.test(raw)) continue;
const normalized = this.normalizeDownloadCategory?.(raw) || raw;
if (normalized && normalized !== 'unknown') return normalized;
}
return '';
},
getLocalMatchBadFolderWarning(missing = {}, match = {}) {
const matchCategory = this.getLocalMatchCategory?.(match) || '';
if (!matchCategory) return null;
const supportedCategories = (this.getMissingSupportedDownloadCategories?.(missing) || [])
.map(category => this.normalizeDownloadCategory?.(category) || String(category || '').trim())
.filter(category => category && category !== 'unknown');
const supported = Array.from(new Set(supportedCategories));
if (!supported.length || supported.includes(matchCategory)) return null;
const matchLabel = this.getCategoryDisplayName?.(matchCategory) || matchCategory;
const supportedLabels = supported.map(category => this.getCategoryDisplayName?.(category) || category);
const supportedText = supportedLabels.length === 1
? supportedLabels[0]
: `one of: ${supportedLabels.join(', ')}`;
return {
matchCategory,
supportedCategories: supported,
tooltip: `This local file is in ${matchLabel}, but this node likely accepts ${supportedText}. Linking it may leave the model missing; move or copy the file to a supported model folder.`
};
},
renderLocalMatchBadFolderBadge(missing = {}, match = {}) {
const warning = this.getLocalMatchBadFolderWarning?.(missing, match);
if (!warning) return '';
return `<span class="mr-match-status mr-match-status-bad-folder" tabindex="0" data-tooltip="${this.escapeHtml(warning.tooltip)}">Bad folder</span>`;
},
getModelFileTypeInfo(value = '') {
const match = String(value || '').trim().match(/\.([a-z0-9]+)(?:$|[?#])/i);
if (!match) return null;
const extension = match[1].toLowerCase();
const labels = {
safetensors: 'Safetensors',
gguf: 'GGUF',
ckpt: 'Checkpoint',
pt: 'PyTorch',
pt2: 'PyTorch',
pth: 'PyTorch',
bin: 'Binary model',
onnx: 'ONNX',
pkl: 'Pickle',
sft: 'SFT'
};
if (!labels[extension]) return null;
return {
extension,
label: labels[extension],
display: `${labels[extension]} (.${extension})`
};
},
getMissingAcceptedModelFileTypes(missing = {}) {
const explicitExtensions = missing.accepted_extensions
|| missing.allowed_extensions
|| missing.accepted_model_extensions;
let acceptedValues = Array.isArray(explicitExtensions) ? explicitExtensions : [];
if (!acceptedValues.length) {
const widgetName = missing.widget_name
|| this.getComfyWidgetNameByIndex?.(missing.node_type || '', Number(missing.widget_index))
|| '';
acceptedValues = this.getCurrentComfyCatalogValues?.(
missing.node_type || '',
widgetName
) || [];
}
const byExtension = new Map();
for (const value of acceptedValues) {
const typeInfo = this.getModelFileTypeInfo?.(value);
if (typeInfo && !byExtension.has(typeInfo.extension)) {
byExtension.set(typeInfo.extension, typeInfo);
}
}
return Array.from(byExtension.values());
},
getLocalMatchBadTypeWarning(missing = {}, match = {}) {
const model = match.model && typeof match.model === 'object' ? match.model : {};
const matchValue = model.relative_path
|| model.path
|| match.relative_path
|| match.path
|| model.filename
|| match.filename
|| model.name
|| match.name
|| '';
const matchType = this.getModelFileTypeInfo?.(matchValue);
if (!matchType) return null;
const acceptedTypes = this.getMissingAcceptedModelFileTypes?.(missing) || [];
if (!acceptedTypes.length || acceptedTypes.some(type => type.extension === matchType.extension)) {
return null;
}
const acceptedText = acceptedTypes.length === 1
? acceptedTypes[0].display
: acceptedTypes.map(type => type.display).join(', ');
const nodeLabel = String(missing.node_title || missing.node_type || 'this node').trim();
return {
matchType,
acceptedTypes,
tooltip: `This file is ${matchType.display}, but ${nodeLabel} only lists ${acceptedText} files.\n\nLinking it may not work. Choose a supported file type or use a loader made for ${matchType.label} models.`
};
},
renderLocalMatchBadTypeBadge(missing = {}, match = {}) {
const warning = this.getLocalMatchBadTypeWarning?.(missing, match);
if (!warning) return '';
return `<span class="mr-match-status mr-match-status-bad-type" tabindex="0" data-tooltip="${this.escapeHtml(warning.tooltip)}">Bad type</span>`;
},
getActiveDownloadInfoForLocalMatch(match = {}) {
const model = match.model || {};
const normalizePath = (value = '') => normalizePathIdentity(value);
const joinPath = (...parts) => normalizePath(parts.filter(Boolean).join('/'));
const activeStatuses = new Set(['starting', 'downloading', 'paused', 'cancelling']);
const matchAbsolutePaths = [
model.path,
model.resolved_path,
match.path,
match.resolved_path
].map(normalizePath).filter(Boolean);
const matchRelativePaths = [
model.relative_path,
match.relative_path
].map(normalizePath).filter(Boolean);
const matchFilename = normalizePath(model.filename || match.filename || '');
for (const [downloadId, info] of Object.entries(this.activeDownloads || {})) {
const progress = info?.lastProgress || info?.statusSnapshot?.progress || {};
const status = String(progress.status || info?.lastStatus || info?.statusSnapshot?.status || 'starting').trim().toLowerCase();
if (!activeStatuses.has(status)) continue;
const filename = progress.filename || info?.filename || '';
const absoluteCandidates = [
progress.path,
info?.downloadPath,
info?.statusSnapshot?.downloadPath,
joinPath(progress.directory || '', filename),
joinPath(info?.downloadDirectory || '', filename),
joinPath(info?.statusSnapshot?.downloadDirectory || '', filename)
].map(normalizePath).filter(Boolean);
const relativeCandidates = [
info?.subfolder && filename ? joinPath(info.subfolder, filename) : '',
progress.relative_path,
info?.relativePath
].map(normalizePath).filter(Boolean);
const absoluteMatch = absoluteCandidates.some(activePath => (
matchAbsolutePaths.includes(activePath)
|| matchRelativePaths.some(relativePath => activePath.endsWith(`/${relativePath}`))
));
const relativeMatch = relativeCandidates.some(activeRelative => (
matchRelativePaths.includes(activeRelative)
|| matchAbsolutePaths.some(matchPath => matchPath.endsWith(`/${activeRelative}`))
));
if (!absoluteMatch && !relativeMatch) continue;
return {
download_id: downloadId,
download_status: status,
download_progress: progress.progress,
downloaded: progress.downloaded,
total_size: progress.total_size,
filename: filename || matchFilename,
is_downloading: true,
downloading: true
};
}
return null;
},
renderLocalMatchDownloadingBadge(match = {}) {
const model = match.model || {};
const activeDownloadInfo = this.getActiveDownloadInfoForLocalMatch?.(match) || null;
const isDownloading = Boolean(
match.is_downloading
|| match.downloading
|| model.is_downloading
|| model.downloading
|| activeDownloadInfo
);
if (!isDownloading) return '';
const status = String(activeDownloadInfo?.download_status || match.download_status || model.download_status || '').trim().toLowerCase();
const progress = Number(activeDownloadInfo?.download_progress ?? match.download_progress ?? model.download_progress);
const progressLabel = Number.isFinite(progress) && progress > 0 && progress < 100
? ` (${Math.round(progress)}%)`
: '';
const statusLabel = status === 'paused'
? 'paused'
: (status === 'starting' ? 'starting' : 'still downloading');
const tooltip = `The download for this local file is ${statusLabel}${progressLabel}. It may be incomplete until the download finishes.`;
return `<span class="mr-match-status mr-match-status-downloading" tabindex="0" data-tooltip="${this.escapeHtml(tooltip)}">Downloading</span>`;
},
renderLocalMatchStatusGroup(missing = {}, match = {}, hashLabelMap = null) {
const badTypeBadge = this.renderLocalMatchBadTypeBadge?.(missing, match) || '';
return `<span class="mr-match-status-group">${this.renderLocalMatchStatus(match, hashLabelMap)}${this.renderLocalMatchDownloadingBadge(match)}${this.renderLocalMatchBadFolderBadge(missing, match)}${badTypeBadge}</span>`;
},
areLocalMatchAlternativesCollapsed() {
if (typeof this.localMatchAlternativesCollapsed === 'boolean') {
return this.localMatchAlternativesCollapsed;
}
try {
this.localMatchAlternativesCollapsed = localStorage.getItem(this.localMatchAlternativesCollapsedStorageKey) === '1';
} catch (_e) {
this.localMatchAlternativesCollapsed = false;
}
return this.localMatchAlternativesCollapsed;
},
setLocalMatchAlternativesCollapsed(collapsed) {
this.localMatchAlternativesCollapsed = Boolean(collapsed);
try {
localStorage.setItem(
this.localMatchAlternativesCollapsedStorageKey,
this.localMatchAlternativesCollapsed ? '1' : '0'
);
} catch (_e) {}
},
renderLocalMatchesContent(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const hasMatches = filteredMatches.length > 0;
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
const hashLabelMap = this.getHashMatchLabelMap?.(missing) || null;
let html = '';
if (hasMatches) {
const matchesToShow = perfectMatches.length > 0
? perfectMatches
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
const sortedMatches = matchesToShow.sort((a, b) => {
if (a.confidence === 100 && b.confidence !== 100) return -1;
if (a.confidence !== 100 && b.confidence === 100) return 1;
return b.confidence - a.confidence;
});
for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) {
const match = sortedMatches[matchIndex];
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || '';
const isBestMatch = matchIndex === 0 && match.confidence >= 95;
const contextModel = this.buildContextMenuModelData(
match.model || {},
match.filename || '',
this.getLocalMatchContextData(missing, match)
);
const localMatchIdentity = this.getLocalMatchIdentity?.(match) || '';
const identityAttr = localMatchIdentity
? ` data-local-match-identity="${this.escapeHtml(localMatchIdentity)}"`
: '';
const rowClass = `mr-match-row${isBestMatch ? ' mr-best-match' : ''}`;
html += `<div class="${rowClass}"${identityAttr}${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename"${this.getModelPreviewTooltipAttrs(match.model || match, matchPath)}>${this.escapeHtml(matchPath)}</span>`;
html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap);
html += `<button id="${buttonId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">`;
html += getSvgIcon('link');
html += `</button>`;
html += `</div>`;
}
if (perfectMatches.length > 0 && otherMatches.length > 0) {
const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
const altLabel = `Alternatives (${otherMatches.length})`;
const alternativesCollapsed = this.areLocalMatchAlternativesCollapsed();
html += `<button type="button" class="mr-local-alternatives-toggle" data-ml-preference="local-match-alternatives" aria-expanded="${alternativesCollapsed ? 'false' : 'true'}" onclick="window.MLToggleHidden('${matchId}', this, '${altLabel}', '${altLabel}')">`;
html += `<span class="mr-local-alternatives-label">${altLabel}</span>`;
html += `<span class="mr-local-alternatives-state">${alternativesCollapsed ? 'Show' : 'Hide'}</span>`;
html += `<span class="mr-local-alternatives-chevron" aria-hidden="true"></span>`;
html += `</button>`;
html += `<div id="${matchId}" class="mr-stack-sm ${alternativesCollapsed ? 'mr-hidden' : ''}">`;
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
const match = otherMatches[mIdx];
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
const contextModel = this.buildContextMenuModelData(
match.model || {},
match.filename || '',
this.getLocalMatchContextData(missing, match)
);
const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || '';
const localMatchIdentity = this.getLocalMatchIdentity?.(match) || '';
const identityAttr = localMatchIdentity
? ` data-local-match-identity="${this.escapeHtml(localMatchIdentity)}"`
: '';
html += `<div class="mr-match-row"${identityAttr}${this.getContextMenuAttrs(contextModel)}>`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename"${this.getModelPreviewTooltipAttrs(match.model || match, matchPath)}>${this.escapeHtml(matchPath)}</span>`;
html += this.renderLocalMatchStatusGroup(missing, match, hashLabelMap);
html += `<button id="${altBtnId}" class="mr-btn mr-btn-secondary mr-btn-sm mr-btn-icon-only mr-local-link-btn" data-tooltip="Link this local match" aria-label="Link this local match">${getSvgIcon('link')}</button>`;
html += `</div>`;
}
html += `</div>`;
}
} else if (missing.is_urn && !missing.civitai_info) {
html += `<div class="mr-no-matches">Waiting for CivitAI filename to search local models...</div>`;
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
html += `<div class="mr-no-matches">No matches above 70% confidence</div>`;
} else {
html += `<div class="mr-no-matches">No local matches found</div>`;
}
return html;
},
wireLocalMatchButtons(container, missing, missingIndex = 0) {
const allMatches = missing.matches || [];
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
const matchesToShow = perfectMatches.length > 0
? perfectMatches
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
const sortedMatches = matchesToShow.sort((a, b) => {
if (a.confidence === 100 && b.confidence !== 100) return -1;
if (a.confidence !== 100 && b.confidence === 100) return 1;
return b.confidence - a.confidence;
});
sortedMatches.forEach((match, matchIndex) => {
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
const resolveButton = container.querySelector(`#${buttonId}`);
if (resolveButton) {
resolveButton.onclick = null;
resolveButton.addEventListener('click', () => {
this.queueResolution(missing, match.model);
});
}
});
if (otherMatches && otherMatches.length > 0) {
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
const match = otherMatches[mIdx];
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
const altBtn = container.querySelector(`#${altBtnId}`);
if (altBtn) {
altBtn.addEventListener('click', () => {
this.queueResolution(missing, match.model);
});
}
}
}
this.wireLocalHashMatchResultHighlights?.(container);
this.bindTooltips?.(container);
},
decodeLocalMatchIdentityList(value = '') {
try {
const decoded = decodeURIComponent(String(value || ''));
const parsed = JSON.parse(decoded);
return Array.isArray(parsed)
? parsed.map(item => String(item || '').trim()).filter(Boolean)
: [];
} catch (_error) {
return [];
}
},
setLocalHashMatchHighlight(container, identities = [], highlighted = false) {
if (!container || !Array.isArray(identities) || !identities.length) return;
const identitySet = new Set(identities);
const scope = container.closest?.('.mr-columns')
|| container.closest?.('.mr-missing-detail-pane')
|| this.contentElement
|| document;
scope.querySelectorAll('.mr-match-row[data-local-match-identity]').forEach(row => {
const identity = row.dataset.localMatchIdentity || '';
if (identitySet.has(identity)) {
row.classList.toggle('mr-local-match-hash-highlight', highlighted);
}
});
},
setSearchHashResultHighlight(container, identity = '', highlighted = false) {
const normalizedIdentity = String(identity || '').trim();
if (!container || !normalizedIdentity) return;
const scope = container.closest?.('.mr-columns')
|| container.closest?.('.mr-missing-detail-pane')
|| this.contentElement
|| document;
scope.querySelectorAll('.mr-search-match[data-local-match-identities]').forEach(badge => {
const identities = this.decodeLocalMatchIdentityList(badge.dataset.localMatchIdentities || '');
if (!identities.includes(normalizedIdentity)) return;
badge.classList.toggle('mr-search-match-linked-highlight', highlighted);
const row = badge.closest('tr');
if (row) {
row.classList.toggle('mr-search-result-hash-highlight', highlighted);
}
});
},
wireLocalHashMatchResultHighlights(container) {
if (!container) return;
container.querySelectorAll('.mr-match-row[data-local-match-identity] .mr-match-status-hash').forEach(badge => {
if (badge.dataset.searchResultHoverBound === 'true') return;
badge.dataset.searchResultHoverBound = 'true';
const getIdentity = () => badge.closest('.mr-match-row')?.dataset.localMatchIdentity || '';
const showHighlight = () => this.setSearchHashResultHighlight(container, getIdentity(), true);
const hideHighlight = () => this.setSearchHashResultHighlight(container, getIdentity(), false);
badge.addEventListener('mouseenter', showHighlight);
badge.addEventListener('mouseleave', hideHighlight);
badge.addEventListener('focus', showHighlight);
badge.addEventListener('blur', hideHighlight);
});
},
wireSearchHashMatchHighlights(container) {
if (!container) return;
container.querySelectorAll('.mr-search-match[data-local-match-identities]').forEach(badge => {
if (badge.dataset.localMatchHoverBound === 'true') return;
badge.dataset.localMatchHoverBound = 'true';
const getIdentities = () => this.decodeLocalMatchIdentityList(badge.dataset.localMatchIdentities || '');
const showHighlight = () => this.setLocalHashMatchHighlight(container, getIdentities(), true);
const hideHighlight = () => this.setLocalHashMatchHighlight(container, getIdentities(), false);
badge.addEventListener('mouseenter', showHighlight);
badge.addEventListener('mouseleave', hideHighlight);
badge.addEventListener('focus', showHighlight);
badge.addEventListener('blur', hideHighlight);
});
},
getUrnIds(missing = {}) {
return {
modelId: missing.urn_model_id || missing.urn?.model_id,
versionId: missing.urn_version_id || missing.urn?.version_id
};
},
getUrnResolveKey(missing = {}) {
const ids = this.getUrnIds(missing);
return `${ids.modelId || ''}:${ids.versionId || ''}:${this.getMissingModelKey(missing)}`;
},
getCivitaiResultFromMissing(missing = {}) {
if (!missing.civitai_info?.expected_filename && !missing.download_source?.url) return null;
const ids = this.getUrnIds(missing);
return {
name: missing.civitai_info?.model_name || missing.download_source?.name,
version_name: missing.civitai_info?.version_name || missing.download_source?.version_name,
filename: missing.civitai_info?.expected_filename || missing.download_source?.filename,
download_url: missing.download_source?.url,
url: missing.download_source?.model_url,
type: missing.download_source?.type || missing.category,
size: missing.download_source?.size,
model_id: missing.download_source?.model_id || ids.modelId,
version_id: missing.download_source?.version_id || ids.versionId,
base_model: missing.civitai_info?.base_model || missing.download_source?.base_model,
tags: missing.civitai_info?.tags || missing.download_source?.tags || [],
searchedAt: missing.download_source?.searchedAt || missing.download_source?.searched_at
};
},
applyCivitaiUrnResult(missing, civitai = {}) {
if (!missing || !civitai) return;
const ids = this.getUrnIds(missing);
const searchedAt = civitai.searchedAt || civitai.searched_at || new Date().toISOString();
missing.civitai_info = {
model_name: civitai.name,
version_name: civitai.version_name,
expected_filename: civitai.filename,
base_model: civitai.base_model,
tags: civitai.tags || []
};
missing.download_source = {
source: 'civitai',
url: civitai.download_url,
filename: civitai.filename,
name: civitai.name,
version_name: civitai.version_name,
type: civitai.type,
directory: missing.category || 'checkpoints',
match_type: 'exact',
size: civitai.size,
model_id: civitai.model_id || ids.modelId,
version_id: civitai.version_id || ids.versionId,
model_url: civitai.url || getCivitaiModelUrl(ids.modelId, ids.versionId),
base_model: civitai.base_model,
tags: civitai.tags || [],
searchedAt
};
},
async resolveUrnDataForMissing(missing) {
if (!missing?.is_urn) return null;
const existing = this.getCivitaiResultFromMissing(missing);
if (existing?.filename) {
return { civitai: existing };
}
const ids = this.getUrnIds(missing);
if (!ids.modelId || !ids.versionId) return null;
const key = this.getUrnResolveKey(missing);
if (this.urnResolvePromises.has(key)) {
return this.urnResolvePromises.get(key);
}
const promise = (async () => {
const tokens = this.getStoredTokens();
const payload = {
filename: `${ids.modelId}_${ids.versionId}`,
category: '',
is_urn: true,
sources: ['civitai'],
model_id: ids.modelId,
version_id: ids.versionId,
civitai_candidate_limit: tokens.civitai_candidate_limit
};
const data = await this.fetchJson('/model_resolver/search', {
method: 'POST',
body: JSON.stringify(payload)
}, 'Resolve URN');
if (data.civitai) {
this.applyCivitaiUrnResult(missing, data.civitai);
}
return data;
})().finally(() => {
this.urnResolvePromises.delete(key);
});
this.urnResolvePromises.set(key, promise);
return promise;
},
refreshMissingListStats() {
if (!this.contentElement) return;
const statsEl = this.contentElement.querySelector('.mr-missing-list-stats');
if (!statsEl) return;
const stats = this.getMissingModelSummaryStats(this.missingModels || []);
statsEl.innerHTML = `
<span class="mr-missing-stat mr-missing-stat-exact">${stats.exact} exact</span>
<span class="mr-missing-stat mr-missing-stat-partial">${stats.partial} partial</span>
<span class="mr-missing-stat mr-missing-stat-none">${stats.none} no match</span>
`;
},
async fetchUrnLocalMatches(missing) {
if (!missing?.civitai_info?.expected_filename) return [];
const filename = missing.civitai_info.expected_filename;
if (missing.__urnLocalMatchesFilename === filename && Array.isArray(missing.matches)) {
return missing.matches;
}
const key = this.getUrnResolveKey(missing);
if (this.urnLocalMatchPromises.has(key)) {
return this.urnLocalMatchPromises.get(key);
}
const promise = (async () => {
const data = await this.fetchLocalMatches(filename, missing.category || '', false);
const matches = Array.isArray(data.matches) ? data.matches : [];
missing.matches = this.preserveActiveDownloadLocalMatches?.(missing, matches) || matches;
missing.__urnLocalMatchesFilename = filename;
return missing.matches;
})().finally(() => {
this.urnLocalMatchPromises.delete(key);
});
this.urnLocalMatchPromises.set(key, promise);
return promise;
},
async refreshUrnLocalMatches(missing) {
if (!missing?.civitai_info?.expected_filename || !this.contentElement) return;
const bodyId = `local-matches-body-${this.getMissingModelDomKey(missing)}`;
const container = this.contentElement.querySelector(`#${bodyId}`);
if (container) {
container.innerHTML = `<div class="mr-no-matches">Searching local matches for "${missing.civitai_info.expected_filename}"...</div>`;
}
try {
await this.fetchUrnLocalMatches(missing);
if (container) {
container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0);
this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0);
}
this.refreshMissingListRow(missing, { refreshBaseModels: true });
} catch (error) {
console.error('Model Resolver: URN local match refresh error:', error);
if (container) {
container.innerHTML = `<div class="mr-no-matches">Failed to refresh local matches.</div>`;
}
}
},
scheduleInitialUrnLocalMatchRefresh(missingModels = [], container = null, data = null) {
const refreshTasks = [];
missingModels.forEach((missing) => {
if (!missing?.is_urn || missing.__urnLocalRefreshQueued) return;
if (this.getBestLocalMatch(missing, 70)) return;
missing.__urnLocalRefreshQueued = true;
const task = (async () => {
try {
await this.resolveUrnDataForMissing(missing);
await this.refreshUrnLocalMatches(missing);
} catch (error) {
missing.__urnLocalRefreshFailed = true;
console.error('Model Resolver: initial URN local match refresh error:', error);
}
})();
refreshTasks.push(task);
});
if (!refreshTasks.length || !container || !data) return;
Promise.allSettled(refreshTasks).then(() => {
if (!container.isConnected || this.activeTab !== 'missing') return;
this.displayMissingModels(container, data);
});
}
};