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

1753 lines
75 KiB
JavaScript

import { app } from "../../../../../scripts/app.js";
import { api } from "../../../../../scripts/api.js";
import { $el } from "../../../../../scripts/ui.js";
import { getSvgIcon } from "../../utils/icon_utils.js";
export const searchPanelMethods = {
/**
* Build stable cache key for a missing model entry
*/
getMissingSearchKey(missing) {
const nodeId = missing?.node_id ?? '';
const widgetIndex = missing?.widget_index ?? '';
const subgraphId = missing?.subgraph_id ?? '';
const category = missing?.category ?? '';
const modelPath = missing?.original_path || missing?.name || missing?.filename || missing?.urn_string || '';
return [nodeId, widgetIndex, subgraphId, category, modelPath].map(value => String(value)).join(':');
},
/**
* Get or initialize search state for a missing model entry
*/
getSearchState(missing) {
const key = this.getMissingSearchKey(missing);
if (!this.searchResultCache.has(key)) {
this.searchResultCache.set(key, this.createEmptySearchState());
}
return this.searchResultCache.get(key);
},
createEmptySearchState() {
return {
selectedSource: 'all',
selectedBaseModel: this.getDefaultSearchBaseModel(),
results: {
popular: null,
model_list: null,
huggingface: null,
civitai: null,
civarchive: null,
lora_manager_archive: null
},
lastAttemptSources: [],
lastAttemptBaseModelContext: '',
lastAttemptFound: null,
lastAttemptError: null,
sourceProgress: {},
activeSearchRunId: null
};
},
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' }
];
},
normalizeBaseModelToken(value = '') {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
},
resolveBaseModelAlias(value = '') {
const token = this.normalizeBaseModelToken(value);
if (!token) return '';
for (const entry of this.getBaseModelAliases()) {
const aliases = [entry.value, ...(entry.aliases || [])];
if (aliases.some(alias => this.normalizeBaseModelToken(alias) === token)) {
return entry.value;
}
}
for (const entry of this.getBaseModelAliases()) {
const aliases = [entry.value, ...(entry.aliases || [])];
if (aliases.some(alias => {
const aliasToken = this.normalizeBaseModelToken(alias);
return aliasToken && (aliasToken.includes(token) || token.includes(aliasToken));
})) {
return entry.value;
}
}
return '';
},
resolveBaseModelAliasFromPath(path = '') {
const text = String(path || '').trim();
if (!text) return '';
const parts = text
.split(/[\\/]+/)
.map(part => part.trim())
.filter(Boolean);
const directoryParts = parts.filter(part => !/\.(safetensors|ckpt|pt|pth|bin|gguf|onnx)$/i.test(part));
for (const part of directoryParts.slice(0, 3)) {
const exact = this.resolveBaseModelAlias(part);
if (exact) return exact;
}
return '';
},
getBaseModelAliases() {
const baseModelsList = this.baseModels?.base_models;
if (Array.isArray(baseModelsList) && baseModelsList.length > 0) {
return baseModelsList.map(m => ({ value: m.name, aliases: m.aliases || [] }));
}
// Fallback to hardcoded list
return [
{ value: 'Z-Image', aliases: ['zimage', 'z image', 'z-image', 'z_image', 'zImageTurbo', 'z image turbo'] },
{ value: 'Pony', aliases: ['pony', 'ponyxl', 'pony diffusion', 'pony realism'] },
{ value: 'Illustrious', aliases: ['illustrious', 'illustriousxl', 'illustrious xl'] },
{ value: 'SDXL 1.0', aliases: ['sdxl', 'sdxl10', 'sdxl 1.0', 'stable diffusion xl'] },
{ value: 'SD 1.5', aliases: ['sd15', 'sd 1.5', 'sd1.5', 'stable diffusion 1.5'] },
{ value: 'Flux.1 D', aliases: ['flux', 'flux1', 'flux.1', 'flux dev', 'flux.1 d', 'flux1d'] },
{ value: 'Flux.1 S', aliases: ['flux schnell', 'flux.1 s', 'flux1s'] },
{ value: 'Qwen Image', aliases: ['qwen image', 'qwenimage', 'qwen-image'] },
{ value: 'Hunyuan 1', aliases: ['hunyuan', 'hunyuan1'] },
{ value: 'WAN Video', aliases: ['wan', 'wan video', 'wanvideo'] },
{ value: 'NoobAI', aliases: ['noobai', 'noob ai'] },
{ value: 'HiDream', aliases: ['hidream', 'hi dream'] }
];
},
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 (/\.(safetensors|ckpt|pt|pth|bin|gguf|onnx)\b/i.test(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') {
if (value === 'auto') {
const detected = this.getDominantWorkflowBaseModel();
return detected ? `Auto (${detected})` : 'Auto';
}
const option = this.getKnownBaseModelOptions().find(item => item.value === value);
return option?.label || value || 'Auto';
},
getSearchBaseModelContext(missing = {}) {
const state = this.getSearchState(missing);
const selected = state.selectedBaseModel || this.getDefaultSearchBaseModel();
if (selected === 'none') return '';
if (selected === 'auto') return this.getDominantWorkflowBaseModel();
return selected;
},
getBackgroundSearchJobKey(workflowKey, missingSearchKey) {
return `${workflowKey || 'workflow'}\n${missingSearchKey || 'missing'}`;
},
hasBackgroundSearchJob(workflowKey, missingSearchKey, runId = null) {
const job = this.backgroundSearchJobs?.get(
this.getBackgroundSearchJobKey(workflowKey, missingSearchKey)
);
if (!job) return false;
return !runId || job.runId === runId;
},
isBackgroundSearchRunActive(workflowKey, missingSearchKey, runId) {
return this.hasBackgroundSearchJob(workflowKey, missingSearchKey, runId);
},
getWorkflowSearchCache(workflowKey, { create = false } = {}) {
if (!workflowKey) return null;
let cache = this.workflowSearchResultCaches.get(workflowKey);
if (!cache && create) {
cache = new Map();
this.workflowSearchResultCaches.set(workflowKey, cache);
}
return cache || null;
},
getSearchStateForWorkflow(workflowKey, missing) {
if (!workflowKey || workflowKey === this.getWorkflowScopedQueueKey()) {
return this.getSearchState(missing);
}
const missingSearchKey = this.getMissingSearchKey(missing);
const cache = this.getWorkflowSearchCache(workflowKey, { create: true });
if (!cache.has(missingSearchKey)) {
cache.set(missingSearchKey, this.createEmptySearchState());
}
return cache.get(missingSearchKey);
},
persistSearchStateForWorkflow(workflowKey, missing, state) {
const missingSearchKey = this.getMissingSearchKey(missing);
if (!workflowKey || !missingSearchKey || !state) return;
const cache = this.getWorkflowSearchCache(workflowKey, { create: true });
cache.set(
missingSearchKey,
this.cloneSearchState(state, {
preserveActive: this.hasBackgroundSearchJob(
workflowKey,
missingSearchKey,
state.activeSearchRunId
)
})
);
if (workflowKey === this.getWorkflowScopedQueueKey()) {
this.searchResultCache.set(missingSearchKey, state);
}
},
refreshSearchUiForMissing(missing, state = null, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
if (!missing) return;
if (workflowKey && workflowKey !== this.getWorkflowScopedQueueKey()) return;
const currentState = state || this.searchResultCache.get(this.getMissingSearchKey(missing));
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);
searchBtn.disabled = isRunning;
searchBtn.innerHTML = isRunning
? `${this.getSearchIconHtml()} Searching...`
: `${this.getSearchIconHtml()} Search Again`;
}
},
/**
* Merge new search results into cached per-source results.
* Empty normal responses do not delete previous results; forced refreshes do.
*/
mergeSearchResults(existingResults = {}, newResults = {}, { searchedAt = null, forceRefresh = false } = {}) {
const searchedSources = new Set(Array.isArray(newResults.searched_sources) ? newResults.searched_sources : []);
const sourceErrors = newResults.source_errors && typeof newResults.source_errors === 'object'
? newResults.source_errors
: {};
const pickResult = (source) => {
if (newResults[source]) {
const existingTimestamp = this.getSearchResultTimestamp(existingResults[source]);
const resultTimestamp = !forceRefresh && this.areSearchResultsSame(existingResults[source], newResults[source])
? existingTimestamp
: null;
return this.withSearchResultTimestamp(
newResults[source],
resultTimestamp || searchedAt
);
}
const sourceWasSearched = searchedSources.has(source)
|| (searchedSources.has('local') && (source === 'popular' || source === 'model_list'));
const sourceHadError = sourceErrors[source]
|| (sourceErrors.local && (source === 'popular' || source === 'model_list'));
if (forceRefresh && sourceWasSearched && !sourceHadError) {
return null;
}
return existingResults[source] || null;
};
return {
popular: pickResult('popular'),
model_list: pickResult('model_list'),
huggingface: pickResult('huggingface'),
civitai: pickResult('civitai'),
civarchive: pickResult('civarchive'),
lora_manager_archive: pickResult('lora_manager_archive')
};
},
getSearchResultSignature(result) {
if (Array.isArray(result)) {
return result.map(item => this.getSearchResultSignature(item)).join('|');
}
if (!result || typeof result !== 'object') return '';
return [
result.download_url || result.url || result.model_url || '',
result.filename || result.path || '',
result.repo_id || result.repo || '',
result.model_id || '',
result.version_id || '',
result.name || ''
].map(value => String(value || '').trim()).join('::');
},
areSearchResultsSame(previousResult, nextResult) {
const previousSignature = this.getSearchResultSignature(previousResult);
const nextSignature = this.getSearchResultSignature(nextResult);
return Boolean(previousSignature && nextSignature && previousSignature === nextSignature);
},
withSearchResultTimestamp(result, searchedAt = null) {
if (!result || !searchedAt) return result;
if (Array.isArray(result)) {
return result.map(item => this.withSearchResultTimestamp(item, searchedAt));
}
if (typeof result !== 'object') return result;
return {
...result,
searchedAt: result.searchedAt || result.searched_at || searchedAt
};
},
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>`;
},
showTooltip(target) {
if (!target || !this.tooltipElement) 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;
this.tooltipElement.textContent = text;
this.tooltipElement.style.display = 'block';
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`;
this.tooltipElement.setAttribute('data-visible', 'true');
},
hideTooltip() {
if (!this.tooltipElement) return;
this.tooltipElement.style.display = 'none';
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);
},
hasSearchResultsForMissing(missing = {}) {
if (missing?.download_source?.url) return true;
const state = this.searchResultCache?.get(this.getMissingSearchKey(missing));
return this.hasSearchResults(state?.results || {});
},
/**
* Convert source ids to readable labels
*/
getSearchSourceLabel(source) {
const labels = {
all: 'Everything',
local: 'Local Database',
huggingface: 'HuggingFace',
civitai: 'CivitAI',
civarchive: 'CivArchive',
lora_manager_archive: 'LoRA Manager Archive'
};
return labels[source] || source;
},
getSearchSourceDefinitions() {
return [
{
source: 'local',
storageKey: 'ModelResolver.searchSource.localEnabled',
tooltip: 'Searches bundled known-model data before online providers.'
},
{
source: 'huggingface',
storageKey: 'ModelResolver.searchSource.huggingFaceEnabled',
tooltip: 'Searches Hugging Face when Everything is selected.'
},
{
source: 'civitai',
storageKey: 'ModelResolver.searchSource.civitaiEnabled',
tooltip: 'Searches CivitAI when Everything is selected.'
},
{
source: 'civarchive',
storageKey: 'ModelResolver.searchSource.civArchiveEnabled',
tooltip: 'Searches CivArchive when Everything is selected.'
},
{
source: 'lora_manager_archive',
storageKey: 'ModelResolver.searchSource.loraManagerArchiveEnabled',
tooltip: 'Searches the local LoRA Manager archive when Everything is selected.'
}
];
},
getSearchSourceDefinition(source) {
return this.getSearchSourceDefinitions().find(def => def.source === source) || null;
},
isSearchSourceEnabled(source) {
if (!source || source === 'all') return true;
const definition = this.getSearchSourceDefinition(source);
if (!definition) return true;
return localStorage.getItem(definition.storageKey) !== 'false';
},
isSearchSourceUsable(source) {
return this.isSourceAvailable(source) && this.isSearchSourceEnabled(source);
},
getEnabledSearchSources() {
const sources = this.getSearchSourceDefinitions()
.filter(def => this.isSearchSourceUsable(def.source))
.map(def => def.source);
return sources.length ? sources : ['local'];
},
getSearchSourceEnabledMap() {
return this.getSearchSourceDefinitions().reduce((enabled, def) => {
enabled[def.source] = this.isSearchSourceEnabled(def.source);
return enabled;
}, {});
},
getSearchSourcesForSelection(selectedSource, missing = {}) {
if (selectedSource !== 'all') {
return this.isSearchSourceUsable(selectedSource) ? [selectedSource] : [];
}
return this.getEnabledSearchSources();
},
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);
}
}
},
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
);
},
reconnectActiveSearchProgress(missingModels = this.missingModels || []) {
for (const missing of missingModels || []) {
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const runId = state?.activeSearchRunId;
if (!runId) continue;
for (const [source, progress] of Object.entries(state.sourceProgress || {})) {
if (progress?.status !== 'running') continue;
const timerKey = this.getSearchProgressTimerKey(runId, source);
if (!this.searchProgressTimers.has(timerKey)) {
this.startEstimatedSearchProgress(state, missing, null, source, runId);
}
}
this.refreshSearchUiForMissing(missing, state);
}
},
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'
};
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}`;
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>
</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 percent = status === 'pending'
? 0
: (status === 'running' ? progress?.percent : 100);
const safePercent = Math.max(0, Math.min(100, Number(percent) || 0));
const statusLabel = status === 'running'
? `Searching... ${Math.round(safePercent)}%`
: (progress?.message || statusLabels[status] || status);
const title = progress?.error ? ` data-tooltip="${this.escapeHtml(`${label}: ${progress.error}`)}"` : '';
html += `
<div class="mr-search-progress-item mr-search-progress-${statusClass}"${title}>
<div class="mr-search-progress-head">
<span>${this.escapeHtml(label)}</span>
<span>${this.escapeHtml(statusLabel)}</span>
</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;
},
/**
* Update source selector buttons and helper text for one card
*/
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));
}
},
/**
* 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) {
const state = this.getSearchState(missing);
const nextBaseModel = baseModel || this.getDefaultSearchBaseModel();
const changed = (state.selectedBaseModel || this.getDefaultSearchBaseModel()) !== nextBaseModel;
if (changed) {
this.clearSearchProgressTimers(state.activeSearchRunId);
state.results = this.createEmptySearchState().results;
state.lastAttemptSources = [];
state.lastAttemptBaseModelContext = '';
state.lastAttemptFound = null;
state.lastAttemptError = null;
state.sourceProgress = {};
state.activeSearchRunId = null;
const resultsDiv = container?.querySelector?.(`#search-results-${missing.node_id}-${missing.widget_index}`);
if (resultsDiv) {
resultsDiv.classList.remove('mr-is-visible');
resultsDiv.classList.add('mr-is-hidden');
resultsDiv.innerHTML = '';
}
const searchBtn = container?.querySelector?.(`#search-${missing.node_id}-${missing.widget_index}`);
if (searchBtn) {
searchBtn.disabled = false;
searchBtn.innerHTML = `${this.getSearchIconHtml()} ${this.hasSearchResultsForMissing(missing) ? 'Search Again' : 'Search'}`;
}
}
state.selectedBaseModel = nextBaseModel;
this.persistSearchStateForActiveWorkflow();
this.syncSearchSourceUi(missing, container);
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') {
this.setDropdownValue(baseEl, value, this.getSearchBaseModelLabel(value));
}
});
},
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',
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 textWidth = (value, charPx = 6, minPx = 40, maxPx = 180) => {
const length = String(value || '').length;
return Math.max(minPx, Math.min(maxPx, Math.ceil(length * charPx)));
};
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, textWidth('x'.repeat(maxSourceLabel), 6, 34, 100) + 52);
const matchPx = textWidth('x'.repeat(maxMatchLabel), 7, 34, 76) + 22;
const sizePx = textWidth('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') {
const matchType = String(result.match_type || '').toLowerCase();
if (matchType === 'exact') {
return { label: 'Exact', 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 = {}) {
if (!downloadSource?.url) return null;
const originalFilename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
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 = this.getModelCardUrl(rawModelUrl) || rawModelUrl;
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 secondaryParts = [
sourceSecondary,
fullModelName && fullModelName !== downloadFilename ? downloadFilename : '',
baseModel
].filter(Boolean);
return {
sourceKey,
sourceLabel,
model: modelName,
version: modelParts.version,
filename: downloadFilename,
secondary: secondaryParts.join(' / '),
match: isFromWorkflow
? { label: 'Provided', className: 'strong' }
: this.getSearchResultMatchDisplay(downloadSource, 'Known', 'strong'),
size: this.formatSearchResultSize(downloadSource),
downloadUrl: downloadSource.url,
downloadFilename,
category: downloadSource.directory || downloadSource.category || missing.category || 'checkpoints',
openUrl: modelUrl,
searchedAt: this.getSearchResultTimestamp(downloadSource),
detailsContext: ['civitai', 'civarchive'].includes(String(source).toLowerCase())
? {
...downloadSource,
source,
details_source: String(source).toLowerCase(),
model_id: downloadSource.model_id,
version_id: downloadSource.version_id,
name: modelName,
filename: downloadFilename,
missing_key: this.getMissingModelKey(missing),
category: missing.category
}
: null
};
},
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 || missing.category || '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 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))
: '';
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)}">${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}" ${detailsData ? `data-model="${this.escapeHtml(detailsData)}" oncontextmenu="window.MLOpenContextMenu(event, this)"` : ''}>
<span>${modelHtml}</span>
${secondary || filename ? `<small>${secondary || filename}</small>` : ''}
</div>
</td>
<td><span class="mr-search-match mr-search-match-${matchClass}">${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 state = this.getSearchState(missing);
const selectedSource = state.selectedSource || 'all';
const selectedBaseModel = state.selectedBaseModel || this.getDefaultSearchBaseModel();
const buttonText = options.buttonText
|| (this.hasSearchResultsForMissing(missing) ? 'Search Again' : 'Search');
let html = `<div id="${searchSourcesId}" class="mr-search-source-bar">`;
html += `<button id="search-${missing.node_id}-${missing.widget_index}" class="mr-btn mr-btn-link">`;
html += `${this.getSearchIconHtml()} ${buttonText}`;
html += `</button>`;
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</label>`;
html += `<div class="mr-download-target-wrap">`;
html += `<input id="${searchBaseSelectId}" class="mr-download-target-input mr-search-base-select" type="text" readonly autocomplete="off" data-value="${this.escapeHtml(selectedBaseModel)}" value="${this.escapeHtml(this.getSearchBaseModelLabel(selectedBaseModel))}">`;
html += `<div id="${searchBaseListId}" class="mr-download-target-list mr-search-base-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
return html;
},
renderProgressWithAction({
percent = 0,
leftText = '',
rightText = '',
actionClass = '',
actionText = '',
actionDataAttr = '',
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 contextMenuData = contextMenuModel
? this.escapeHtml(encodeURIComponent(JSON.stringify(contextMenuModel)))
: '';
const contextMenuAttrs = contextMenuData
? ` data-model="${contextMenuData}" oncontextmenu="window.MLOpenContextMenu(event, this)" data-tooltip="${this.escapeHtml(contextMenuTooltip)}"`
: '';
const containerClass = contextMenuData
? 'mr-progress-container mr-download-folder-context'
: 'mr-progress-container';
const actionHtml = 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 = '') {
const resolvedPath = model.path || model.resolved_path || '';
const filename = model.filename || fallbackName || resolvedPath.split(/[\/\\]/).pop() || '';
return {
...model,
name: model.name || filename,
original_path: model.original_path || filename,
resolved_path: resolvedPath,
category: model.category || ''
};
},
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);
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 || '');
const modelData = encodeURIComponent(JSON.stringify(contextModel));
html += `<div class="mr-match-row ${isBestMatch ? 'mr-best-match' : ''}" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += `<span class="mr-match-status ${match.confidence === 100 ? 'mr-match-status-exact' : 'mr-match-status-partial'}">${match.confidence === 100 ? 'Exact' : 'Partial'}</span>`;
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})`;
html += `<button type="button" class="mr-local-alternatives-toggle" aria-expanded="true" onclick="window.MLToggleHidden('${matchId}', this, '${altLabel}', '${altLabel}')">`;
html += `<span class="mr-local-alternatives-label">${altLabel}</span>`;
html += `<span class="mr-local-alternatives-state">Hide</span>`;
html += `<span class="mr-local-alternatives-chevron" aria-hidden="true"></span>`;
html += `</button>`;
html += `<div id="${matchId}" class="mr-stack-sm">`;
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 || '');
const modelData = encodeURIComponent(JSON.stringify(contextModel));
const matchPath = match.model?.relative_path || match.model?.path || match.path || match.filename || '';
html += `<div class="mr-match-row" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">`;
html += this.getConfidenceBadge(match.confidence);
html += `<span class="mr-match-filename" data-tooltip="${this.escapeHtml(matchPath)}">${this.escapeHtml(matchPath)}</span>`;
html += `<span class="mr-match-status mr-match-status-partial">Partial</span>`;
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);
});
}
}
}
},
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 || `https://civitai.com/models/${ids.modelId}?modelVersionId=${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 response = await api.fetchApi('/model_resolver/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`URN resolve failed: ${response.status}`);
}
const data = await response.json();
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>
`;
},
refreshMissingListRowLocalMatch(missing = {}) {
if (!this.contentElement) 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 bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || '';
const matchDisplay = matchName || 'No local match';
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
const bestEl = row.querySelector('.mr-missing-row-best');
if (bestEl) {
bestEl.setAttribute('data-tooltip', matchDisplay);
bestEl.innerHTML = bestMatch
? this.escapeHtml(matchDisplay)
: '<span class="mr-missing-row-none">-- No local match</span>';
}
const matchEl = row.querySelector('.mr-missing-row-match');
if (matchEl) {
matchEl.classList.remove(
'mr-missing-row-match-exact',
'mr-missing-row-match-partial',
'mr-missing-row-match-none'
);
matchEl.classList.add(`mr-missing-row-match-${matchClass}`);
const valueEl = matchEl.querySelector('strong');
if (valueEl) {
valueEl.textContent = bestMatch
? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%`
: '--';
}
}
}
this.refreshMissingListStats();
},
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 response = await api.fetchApi('/model_resolver/local-matches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename,
category: missing.category || ''
})
});
if (!response.ok) {
throw new Error(`Local match search failed: ${response.status}`);
}
const data = await response.json();
missing.matches = Array.isArray(data.matches) ? data.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-${missing.node_id}-${missing.widget_index}`;
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.refreshMissingListRowLocalMatch(missing);
} 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);
});
}
};