4071 lines
179 KiB
JavaScript
4071 lines
179 KiB
JavaScript
import { createModuleLogger } from "../../log_system/log_funcs.js";
|
|
import { getSvgIcon } from "../../utils/icon_utils.js";
|
|
import { getModelCardUrl, parseHuggingFaceFileUrl } from "../utils/url_utils.js";
|
|
import { getCivitaiModelUrl } from "../globals.js";
|
|
import { normalizeSha256 } from "../utils/hash_utils.js";
|
|
import { normalizePathIdentity } from "../utils/html_utils.js";
|
|
import { bindEventOnce, syncElementAttributes } from "../utils/dom_patch_utils.js";
|
|
import { bindDownloadActionHandlers } from "../utils/download_action_handlers.js";
|
|
import { matchesLocalModelDownload } from "../utils/local_match_utils.js";
|
|
import { matchesSearchText } from "../utils/search_utils.js";
|
|
import { normalizeSourceKey } from "../utils/source_labels.js";
|
|
import { playDownloadSuccessSound } from "../../utils/download_sound.js";
|
|
const log = createModuleLogger('resolve_download_methods');
|
|
|
|
export const resolveDownloadMethods = {
|
|
|
|
getExactLocalMatchSelections(missingModels = []) {
|
|
const selections = [];
|
|
const seenKeys = new Set();
|
|
|
|
for (const missing of Array.isArray(missingModels) ? missingModels : []) {
|
|
const match = this.getBestLocalMatch?.(missing, 100)
|
|
|| (missing.matches || []).find(item => Number(item.confidence || 0) >= 100);
|
|
if (!match?.model) continue;
|
|
|
|
const selection = this.buildResolutionSelection(missing, match.model);
|
|
const key = this.getResolutionQueueKey(selection);
|
|
if (!key || seenKeys.has(key)) continue;
|
|
|
|
seenKeys.add(key);
|
|
selections.push(selection);
|
|
}
|
|
|
|
return selections;
|
|
},
|
|
|
|
/**
|
|
* Auto-resolve all 100% confidence matches using the same apply path as queued selections.
|
|
* @returns {object|null} The updated workflow if successful, null otherwise
|
|
*/
|
|
async autoResolve100Percent() {
|
|
try {
|
|
const workflow = this.getCurrentWorkflow();
|
|
if (!workflow) {
|
|
this.showNotification('No workflow loaded', 'error');
|
|
return null;
|
|
}
|
|
|
|
const workflowSignature = this.getWorkflowSignature?.(workflow);
|
|
const canUseCurrentAnalysis = Boolean(
|
|
this.isVisible?.()
|
|
&& workflowSignature
|
|
&& this.activeWorkflowSignature === workflowSignature
|
|
&& this.cachedWorkflowSignature === workflowSignature
|
|
);
|
|
let missingModels = canUseCurrentAnalysis && Array.isArray(this.missingModels)
|
|
? this.missingModels
|
|
: [];
|
|
let selections = this.getExactLocalMatchSelections(missingModels);
|
|
|
|
if (
|
|
canUseCurrentAnalysis
|
|
&& !selections.length
|
|
&& !missingModels.length
|
|
&& Array.isArray(this.cachedAnalysisData?.missing_models)
|
|
) {
|
|
missingModels = this.cachedAnalysisData.missing_models;
|
|
selections = this.getExactLocalMatchSelections(missingModels);
|
|
}
|
|
|
|
if (!selections.length && !missingModels.length) {
|
|
const analyzeData = await this.fetchJson('/model_resolver/analyze', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ workflow })
|
|
}, 'Analyze workflow');
|
|
this.applyResolvedSelectionAliasesToAnalysisData?.(analyzeData);
|
|
this.syncWorkflowScopedQueue?.(workflow);
|
|
if (workflowSignature) {
|
|
this.cachedWorkflowSignature = workflowSignature;
|
|
}
|
|
this.cachedAnalysisData = this.cloneAnalysisData?.(analyzeData) || analyzeData;
|
|
missingModels = Array.isArray(analyzeData.missing_models) ? analyzeData.missing_models : [];
|
|
this.missingModels = missingModels;
|
|
selections = this.getExactLocalMatchSelections(missingModels);
|
|
}
|
|
|
|
if (!selections.length) {
|
|
this.showNotification('No 100% confidence matches found to auto-resolve.', 'error');
|
|
return null;
|
|
}
|
|
|
|
this.closeFooterMenus?.();
|
|
return await this.applyPendingResolutionList(selections, { clearAll: false });
|
|
} catch (error) {
|
|
console.error('Model Resolver: Error auto-resolving:', error);
|
|
this.showNotification('Error auto-resolving: ' + error.message, 'error');
|
|
return null;
|
|
}
|
|
},
|
|
|
|
|
|
getBestDownloadSourceForMissing(missing) {
|
|
if (!missing) return null;
|
|
|
|
if (missing.download_source?.url) {
|
|
return {
|
|
...missing.download_source,
|
|
directory: missing.download_source.directory || missing.download_source.category || missing.category || 'checkpoints'
|
|
};
|
|
}
|
|
|
|
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
|
|
const results = state?.results || {};
|
|
const filename = this.getFilenameFromPath(missing.original_path);
|
|
const first = (value) => Array.isArray(value) ? value[0] : value;
|
|
const candidates = [
|
|
{
|
|
source: 'popular',
|
|
result: first(results.popular),
|
|
urlKey: 'url',
|
|
filenameKey: 'filename',
|
|
categoryKey: 'directory'
|
|
},
|
|
{
|
|
source: 'model_list',
|
|
result: first(results.model_list),
|
|
urlKey: 'url',
|
|
filenameKey: 'filename',
|
|
categoryKey: 'directory'
|
|
},
|
|
{
|
|
source: 'huggingface',
|
|
result: first(results.huggingface),
|
|
urlKey: 'download_url',
|
|
fallbackUrlKey: 'url',
|
|
filenameKey: 'filename'
|
|
},
|
|
{
|
|
source: 'civarchive',
|
|
result: first(results.civarchive),
|
|
urlKey: 'download_url',
|
|
filenameKey: 'filename'
|
|
},
|
|
{
|
|
source: 'lora_manager_archive',
|
|
result: first(results.lora_manager_archive),
|
|
urlKey: 'download_url',
|
|
filenameKey: 'filename'
|
|
},
|
|
{
|
|
source: 'civitai',
|
|
result: first(results.civitai),
|
|
urlKey: 'download_url',
|
|
filenameKey: 'filename'
|
|
}
|
|
];
|
|
|
|
for (const candidate of candidates) {
|
|
const result = candidate.result;
|
|
const url = result?.[candidate.urlKey]
|
|
|| (candidate.fallbackUrlKey ? result?.[candidate.fallbackUrlKey] : '');
|
|
if (!url) continue;
|
|
const rawModelUrl = result.model_url || result.url || url;
|
|
|
|
return {
|
|
source: candidate.source,
|
|
url,
|
|
filename: result[candidate.filenameKey] || missing.civitai_info?.expected_filename || filename,
|
|
directory: result[candidate.categoryKey] || result.directory || result.category || missing.category || 'checkpoints',
|
|
model_url: getModelCardUrl(rawModelUrl) || rawModelUrl,
|
|
name: result.name || result.repo_id || result.repo || result.filename || filename,
|
|
size: result.size,
|
|
type: result.type || missing.category
|
|
};
|
|
}
|
|
|
|
return null;
|
|
},
|
|
|
|
getBatchSearchTargets(mode) {
|
|
const missingModels = this.missingModels || [];
|
|
if (mode === 'selected') {
|
|
return this.getSelectedMissingModels();
|
|
}
|
|
if (mode === 'unsearched') {
|
|
return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing)));
|
|
}
|
|
return missingModels;
|
|
},
|
|
|
|
async searchMissingBatch(mode = 'selected', source = 'all', { forceSearch = false } = {}) {
|
|
if (this.batchSearchRunning) {
|
|
this.stopBatchSearch();
|
|
return;
|
|
}
|
|
|
|
const targets = this.getBatchSearchTargets(mode);
|
|
if (!targets.length) {
|
|
this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to search.', 'info');
|
|
this.closeFooterMenus();
|
|
return;
|
|
}
|
|
|
|
this.batchSearchRunning = true;
|
|
this.batchSearchCancelRequested = false;
|
|
this.updateBatchFooterButtons();
|
|
this.closeFooterMenus();
|
|
this.showNotification(`Searching ${targets.length} missing model${targets.length > 1 ? 's' : ''}...`, 'info');
|
|
|
|
const batchWorkflowKey = this.getWorkflowScopedQueueKey();
|
|
let completed = 0;
|
|
let failed = 0;
|
|
let cancelled;
|
|
let batchError;
|
|
let hasBatchError = false;
|
|
const sourceTails = new Map();
|
|
const scheduleSource = (sourceId, task) => {
|
|
const previous = sourceTails.get(sourceId) || Promise.resolve();
|
|
const next = previous.then(() => this.batchSearchCancelRequested
|
|
? { source: sourceId, cancelled: true }
|
|
: task());
|
|
// One failed request must not block the remaining models.
|
|
sourceTails.set(sourceId, next.catch(() => {}));
|
|
return next;
|
|
};
|
|
try {
|
|
const outcomes = await Promise.allSettled(targets.map(async missing => {
|
|
if (this.batchSearchCancelRequested) return;
|
|
const state = this.getSearchStateForWorkflow(batchWorkflowKey, missing);
|
|
state.selectedSource = source || 'all';
|
|
try {
|
|
await this.searchOnline(missing, {
|
|
workflowKey: batchWorkflowKey,
|
|
forceSearch,
|
|
scheduleSource
|
|
});
|
|
} catch (error) {
|
|
failed += 1;
|
|
console.error('Model Resolver: batch search item failed:', error);
|
|
}
|
|
if (!this.batchSearchCancelRequested) completed += 1;
|
|
this.updateBatchFooterButtons();
|
|
}));
|
|
const rejected = outcomes.find(outcome => outcome.status === 'rejected');
|
|
if (rejected) throw rejected.reason;
|
|
} catch (error) {
|
|
batchError = error;
|
|
hasBatchError = true;
|
|
} finally {
|
|
cancelled = this.batchSearchCancelRequested;
|
|
this.batchSearchRunning = false;
|
|
this.batchSearchCancelRequested = false;
|
|
this.persistSearchStateForActiveWorkflow();
|
|
this.updateBatchFooterButtons();
|
|
}
|
|
|
|
const suffix = failed ? `, ${failed} failed` : '';
|
|
if (cancelled) {
|
|
this.showNotification(`Stopped search after ${completed} of ${targets.length} model${targets.length === 1 ? '' : 's'}${suffix}.`, 'info');
|
|
return;
|
|
}
|
|
if (hasBatchError) {
|
|
throw batchError;
|
|
}
|
|
this.showNotification(`Finished search for ${completed} model${completed === 1 ? '' : 's'}${suffix}.`, failed ? 'info' : 'success');
|
|
},
|
|
|
|
stopBatchSearch() {
|
|
if (!this.batchSearchRunning) {
|
|
this.closeFooterMenus();
|
|
return;
|
|
}
|
|
|
|
this.batchSearchCancelRequested = true;
|
|
for (const job of Array.from(this.backgroundSearchJobs?.values?.() || [])) {
|
|
this.cancelBackgroundSearchJob?.(job);
|
|
}
|
|
this.closeFooterMenus();
|
|
this.updateBatchFooterButtons();
|
|
this.showNotification('Stopping active search...', 'info');
|
|
},
|
|
|
|
async downloadMissingBatch(mode = 'selected') {
|
|
const targets = mode === 'selected'
|
|
? this.getSelectedMissingModels()
|
|
: (this.missingModels || []);
|
|
|
|
if (!targets.length) {
|
|
this.showNotification(mode === 'selected' ? 'No missing models selected.' : 'No missing models to download.', 'info');
|
|
this.closeFooterMenus();
|
|
return;
|
|
}
|
|
|
|
const toDownload = [];
|
|
for (const missing of targets) {
|
|
if (this.getBestLocalMatch(missing, 100)) continue;
|
|
const source = this.getBestDownloadSourceForMissing(missing);
|
|
if (!source?.url) continue;
|
|
missing.download_source = source;
|
|
toDownload.push(missing);
|
|
}
|
|
|
|
if (!toDownload.length) {
|
|
this.showNotification('No selected models have downloadable sources.', 'info');
|
|
this.closeFooterMenus();
|
|
return;
|
|
}
|
|
|
|
this.closeFooterMenus();
|
|
this.showNotification(`Starting ${toDownload.length} download${toDownload.length > 1 ? 's' : ''}...`, 'info');
|
|
for (const missing of toDownload) {
|
|
await this.downloadModel(missing);
|
|
}
|
|
this.updateDownloadAllButtonState();
|
|
this.updateBatchFooterButtons();
|
|
},
|
|
|
|
queueExactLocalMatchesBatch(mode = 'selected') {
|
|
const selected = this.getSelectedMissingModels();
|
|
const targets = mode === 'selected' && selected.length ? selected : (this.missingModels || []);
|
|
let queued = 0;
|
|
|
|
for (const missing of targets) {
|
|
const match = this.getBestLocalMatch(missing, 100);
|
|
if (!match?.model) continue;
|
|
this.queueResolution(missing, match.model);
|
|
queued += 1;
|
|
}
|
|
|
|
this.closeFooterMenus();
|
|
if (!queued) {
|
|
this.showNotification('No exact local matches found to queue.', 'info');
|
|
return;
|
|
}
|
|
this.showNotification(`Queued ${queued} exact local match${queued === 1 ? '' : 'es'}.`, 'success');
|
|
this.updateBatchFooterButtons();
|
|
},
|
|
|
|
/**
|
|
* Refresh local matches after a download completes.
|
|
*/
|
|
getDownloadedLocalMatchTarget(missing = {}, downloadedFilename = '') {
|
|
return downloadedFilename
|
|
|| missing.civitai_info?.expected_filename
|
|
|| missing.download_source?.filename
|
|
|| this.getFilenameFromPath(missing.original_path);
|
|
},
|
|
|
|
getDownloadFolderContext(progress = {}, info = {}) {
|
|
const directory = progress?.directory || info?.downloadDirectory || '';
|
|
const filePath = progress?.path || info?.downloadPath || '';
|
|
const targetPath = directory || filePath;
|
|
if (!targetPath) return null;
|
|
|
|
const missing = info?.missing || progress?.missing || null;
|
|
const missingKey = missing ? this.getMissingModelKey?.(missing) : (info?.missing_key || progress?.missing_key || '');
|
|
const missingSearchKey = missing ? this.getMissingSearchKey?.(missing) : (info?.missing_search_key || progress?.missing_search_key || '');
|
|
|
|
return {
|
|
context_scope: 'download_folder',
|
|
name: progress?.filename || info?.filename || 'Download',
|
|
path: targetPath,
|
|
resolved_path: targetPath,
|
|
open_path: directory || filePath,
|
|
folder_path: directory || targetPath,
|
|
download_directory: directory || '',
|
|
download_path: filePath || '',
|
|
category: info?.category || progress?.category || '',
|
|
missing_key: missingKey || '',
|
|
missing_search_key: missingSearchKey || ''
|
|
};
|
|
},
|
|
|
|
rememberDownloadLocation(info, progress = {}) {
|
|
if (!info || !progress) return;
|
|
if (progress.path) info.downloadPath = progress.path;
|
|
if (progress.directory) info.downloadDirectory = progress.directory;
|
|
if (progress.filename) info.filename = progress.filename;
|
|
},
|
|
|
|
rememberDownloadSubfolderAfterCompletion(info = {}, progress = {}) {
|
|
const category = info?.category || progress?.category || '';
|
|
const subfolder = info?.subfolder || progress?.subfolder || '';
|
|
if (!category || !subfolder) return;
|
|
|
|
this.rememberDownloadedSubfolder?.(
|
|
category,
|
|
subfolder,
|
|
info?.baseDirectory || progress?.base_directory || progress?.baseDirectory || ''
|
|
);
|
|
},
|
|
|
|
renderDownloadStatusMessage(message, type, progress = {}, info = {}) {
|
|
const contextMenuModel = this.getDownloadFolderContext(progress, info);
|
|
return this.renderStatusMessage(message, type, {
|
|
contextMenuModel,
|
|
contextMenuTooltip: 'Right-click to open download folder'
|
|
});
|
|
},
|
|
|
|
getDownloadDomKey(missing = {}) {
|
|
return this.getMissingModelDomKey?.(missing)
|
|
|| [
|
|
missing.node_id ?? '',
|
|
missing.widget_index ?? '',
|
|
missing.subgraph_id || '',
|
|
missing.category || '',
|
|
missing.original_path || missing.filename || ''
|
|
].join(':').replace(/[^A-Za-z0-9_-]+/g, '-');
|
|
},
|
|
|
|
getDownloadProgressElementId(missing = {}) {
|
|
return `download-progress-${this.getDownloadDomKey(missing)}`;
|
|
},
|
|
|
|
getDownloadButtonElementId(missing = {}) {
|
|
return `download-${this.getDownloadDomKey(missing)}`;
|
|
},
|
|
|
|
getDownloadProgressStore() {
|
|
if (!(this.downloadProgressByMissingKey instanceof Map)) {
|
|
this.downloadProgressByMissingKey = new Map();
|
|
}
|
|
return this.downloadProgressByMissingKey;
|
|
},
|
|
|
|
getDownloadWorkflowScopeIdentity(context = {}) {
|
|
if (typeof context === 'string') return context.trim();
|
|
const workflowKey = String(context.workflowKey || context.workflow_key || '').trim();
|
|
return String(
|
|
context.workflowTabId
|
|
|| context.workflow_tab_id
|
|
|| context.workflowId
|
|
|| context.workflow_id
|
|
|| context.workflowRouteKey
|
|
|| context.workflow_route_key
|
|
|| workflowKey.split('\n')[0]
|
|
|| ''
|
|
).trim();
|
|
},
|
|
|
|
getCurrentDownloadWorkflowScopeIdentity() {
|
|
const context = this.getActiveWorkflowTabContext?.() || {};
|
|
return this.getDownloadWorkflowScopeIdentity({
|
|
...context,
|
|
workflowKey: this.getWorkflowScopedQueueKey?.() || '',
|
|
workflowRouteKey: context.workflowRouteKey || this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || '',
|
|
workflowId: context.workflowId || this.getActiveWorkflowId?.() || ''
|
|
});
|
|
},
|
|
|
|
isDownloadInCurrentWorkflowScope(info = {}, currentScope = null) {
|
|
const downloadScope = this.getDownloadWorkflowScopeIdentity(info);
|
|
const scope = currentScope || this.getCurrentDownloadWorkflowScopeIdentity();
|
|
return !downloadScope || !scope || downloadScope === scope;
|
|
},
|
|
|
|
getDownloadMissingIdentity(missing) {
|
|
// Search keys can intentionally be shared by related model references.
|
|
// Download UI state belongs to the concrete workflow model slot, so use
|
|
// the full model identity first to prevent unrelated downloads from
|
|
// being rendered in the same progress container.
|
|
return this.getMissingModelKey?.(missing)
|
|
|| this.getMissingSearchKey?.(missing)
|
|
|| '';
|
|
},
|
|
|
|
getDownloadStateKey(missing, context = null) {
|
|
const missingKey = this.getDownloadMissingIdentity(missing);
|
|
const workflowScope = (context && this.getDownloadWorkflowScopeIdentity(context))
|
|
|| this.getCurrentDownloadWorkflowScopeIdentity();
|
|
return workflowScope ? `${workflowScope}::${missingKey}` : missingKey;
|
|
},
|
|
|
|
rememberDownloadSnapshotForMissing(missing, snapshot = {}) {
|
|
if (!missing) return null;
|
|
|
|
const key = this.getDownloadStateKey(missing, snapshot);
|
|
const store = this.getDownloadProgressStore();
|
|
const previous = store.get(key) || {};
|
|
const progress = {
|
|
...(previous.progress || {}),
|
|
...(snapshot.progress || {})
|
|
};
|
|
const next = {
|
|
...previous,
|
|
...snapshot,
|
|
missing,
|
|
progress,
|
|
updatedAt: Date.now()
|
|
};
|
|
|
|
store.set(key, next);
|
|
return next;
|
|
},
|
|
|
|
cloneLocalMatches(matches = []) {
|
|
if (!Array.isArray(matches)) return [];
|
|
try {
|
|
return JSON.parse(JSON.stringify(matches));
|
|
} catch (error) {
|
|
console.warn('Model Resolver: failed to clone local matches', error);
|
|
return matches.map(match => ({ ...match }));
|
|
}
|
|
},
|
|
|
|
getLocalMatchRefreshKey(missing = {}) {
|
|
return String(
|
|
this.getMissingModelKey?.(missing)
|
|
|| this.getMissingSearchKey?.(missing)
|
|
|| missing.original_path
|
|
|| missing.name
|
|
|| ''
|
|
);
|
|
},
|
|
|
|
beginLocalMatchRefresh(missing = {}) {
|
|
if (!(this.localMatchRefreshTokens instanceof Map)) {
|
|
this.localMatchRefreshTokens = new Map();
|
|
}
|
|
const key = this.getLocalMatchRefreshKey(missing);
|
|
const token = Symbol(key || 'local-match-refresh');
|
|
this.localMatchRefreshTokens.set(key, token);
|
|
return { key, token };
|
|
},
|
|
|
|
isCurrentLocalMatchRefresh(key, token) {
|
|
return this.localMatchRefreshTokens?.get(key) === token;
|
|
},
|
|
|
|
finishLocalMatchRefresh(key, token) {
|
|
if (this.isCurrentLocalMatchRefresh(key, token)) {
|
|
this.localMatchRefreshTokens.delete(key);
|
|
}
|
|
},
|
|
|
|
getLocalMatchIdentity(match = {}) {
|
|
return this.getLocalMatchIdentityKeys(match)[0] || '';
|
|
},
|
|
|
|
normalizeLocalMatchPathIdentity(value = '') {
|
|
return normalizePathIdentity(value);
|
|
},
|
|
|
|
getLocalMatchAbsolutePathIdentity(match = {}) {
|
|
const model = match.model || {};
|
|
return this.normalizeLocalMatchPathIdentity(
|
|
model.path
|
|
|| model.resolved_path
|
|
|| match.path
|
|
|| match.resolved_path
|
|
|| ''
|
|
);
|
|
},
|
|
|
|
getLocalMatchRelativePathIdentity(match = {}) {
|
|
const model = match.model || {};
|
|
const category = String(model.category || match.category || '').trim().toLowerCase();
|
|
const relativePath = this.normalizeLocalMatchPathIdentity(model.relative_path || match.relative_path || '');
|
|
return relativePath ? `${category}::${relativePath}` : '';
|
|
},
|
|
|
|
getLocalMatchIdentityKeys(match = {}) {
|
|
const model = match.model || {};
|
|
const normalizeText = (value = '') => String(value || '').trim().toLowerCase();
|
|
const category = normalizeText(model.category || match.category || '');
|
|
const filename = normalizeText(model.filename || match.filename || '');
|
|
const keys = [];
|
|
const seen = new Set();
|
|
const addKey = (kind, ...parts) => {
|
|
const value = [kind, ...parts].map(part => String(part || '').trim()).join('::');
|
|
if (!value.replace(/:/g, '') || seen.has(value)) return;
|
|
seen.add(value);
|
|
keys.push(value);
|
|
};
|
|
|
|
const absolutePath = this.getLocalMatchAbsolutePathIdentity(match);
|
|
const relativePath = this.normalizeLocalMatchPathIdentity(model.relative_path || match.relative_path || '');
|
|
|
|
if (absolutePath) addKey('path', absolutePath);
|
|
if (relativePath) addKey('relative', category, relativePath);
|
|
if (filename) addKey('filename', category, filename);
|
|
|
|
return keys;
|
|
},
|
|
|
|
canMergeLocalMatches(previous = {}, next = {}, matchedKey = '') {
|
|
if (!String(matchedKey || '').startsWith('filename::')) return true;
|
|
|
|
const previousAbsolute = this.getLocalMatchAbsolutePathIdentity(previous);
|
|
const nextAbsolute = this.getLocalMatchAbsolutePathIdentity(next);
|
|
if (previousAbsolute && nextAbsolute && previousAbsolute !== nextAbsolute) {
|
|
return false;
|
|
}
|
|
|
|
const previousRelative = this.getLocalMatchRelativePathIdentity(previous);
|
|
const nextRelative = this.getLocalMatchRelativePathIdentity(next);
|
|
if (previousRelative && nextRelative && previousRelative !== nextRelative) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
isHashLocalMatch(match = {}) {
|
|
return Boolean(match?.hash_match || match?.match_type === 'hash');
|
|
},
|
|
|
|
shouldReplaceLocalMatch(previous = {}, next = {}) {
|
|
const previousModel = previous.model || {};
|
|
const previousIsDownloading = Boolean(
|
|
previous.is_downloading
|
|
|| previous.downloading
|
|
|| previousModel.is_downloading
|
|
|| previousModel.downloading
|
|
|| this.getActiveDownloadInfoForLocalMatch?.(previous)
|
|
);
|
|
if (previousIsDownloading) {
|
|
const previousAbsolute = this.getLocalMatchAbsolutePathIdentity(previous);
|
|
const nextAbsolute = this.getLocalMatchAbsolutePathIdentity(next);
|
|
const previousRelative = this.getLocalMatchRelativePathIdentity(previous);
|
|
const nextRelative = this.getLocalMatchRelativePathIdentity(next);
|
|
const sameAbsolute = Boolean(
|
|
previousAbsolute && nextAbsolute && previousAbsolute === nextAbsolute
|
|
);
|
|
const sameRelative = Boolean(
|
|
previousRelative && nextRelative && previousRelative === nextRelative
|
|
);
|
|
|
|
// A late hash lookup may contain only category + filename. That is
|
|
// not enough evidence to replace a path-specific match belonging to
|
|
// an active download when another file with the same name exists.
|
|
if (!sameAbsolute && !sameRelative) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const previousConfidence = Number(previous.confidence || 0);
|
|
const nextConfidence = Number(next.confidence || 0);
|
|
if (nextConfidence !== previousConfidence) {
|
|
return nextConfidence > previousConfidence;
|
|
}
|
|
if (this.isHashLocalMatch(next) !== this.isHashLocalMatch(previous)) {
|
|
return this.isHashLocalMatch(next);
|
|
}
|
|
return false;
|
|
},
|
|
|
|
mergeLocalMatches(existingMatches = [], restoredMatches = []) {
|
|
const merged = [];
|
|
const byKey = new Map();
|
|
const keysByMatch = new Map();
|
|
|
|
const rememberMatch = (match, keys) => {
|
|
keysByMatch.set(match, keys);
|
|
keys.forEach(key => byKey.set(key, match));
|
|
};
|
|
|
|
const addMatch = (match) => {
|
|
if (!match || typeof match !== 'object') return;
|
|
const keys = this.getLocalMatchIdentityKeys(match);
|
|
if (!keys.length) return;
|
|
|
|
const previousEntry = keys
|
|
.map(key => ({ key, match: byKey.get(key) }))
|
|
.find(entry => entry.match && this.canMergeLocalMatches(entry.match, match, entry.key));
|
|
const previous = previousEntry?.match;
|
|
if (!previous) {
|
|
merged.push(match);
|
|
rememberMatch(match, keys);
|
|
return;
|
|
}
|
|
|
|
if (this.shouldReplaceLocalMatch(previous, match)) {
|
|
const index = merged.indexOf(previous);
|
|
if (index !== -1) {
|
|
merged[index] = match;
|
|
}
|
|
const previousKeys = keysByMatch.get(previous) || [];
|
|
const combinedKeys = Array.from(new Set([...previousKeys, ...keys]));
|
|
keysByMatch.delete(previous);
|
|
rememberMatch(match, combinedKeys);
|
|
} else {
|
|
const previousKeys = keysByMatch.get(previous) || [];
|
|
rememberMatch(previous, Array.from(new Set([...previousKeys, ...keys])));
|
|
}
|
|
};
|
|
|
|
existingMatches.forEach(addMatch);
|
|
restoredMatches.forEach(addMatch);
|
|
|
|
return merged.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0));
|
|
},
|
|
|
|
applyLocalHashMatchesFromSearchResponse(missing, data = {}, { workflowKey = this.getWorkflowScopedQueueKey?.() } = {}) {
|
|
const hashMatches = Array.isArray(data?.local_hash_matches)
|
|
? data.local_hash_matches
|
|
: [];
|
|
if (!missing || !hashMatches.length) return [];
|
|
|
|
const missingKey = this.getMissingModelKey(missing);
|
|
const currentMissing = (this.missingModels || [])
|
|
.find(item => this.getMissingModelKey(item) === missingKey)
|
|
|| missing;
|
|
const currentMatches = Array.isArray(currentMissing.matches)
|
|
? currentMissing.matches
|
|
: (Array.isArray(missing.matches) ? missing.matches : []);
|
|
const mergedMatches = this.mergeLocalMatches(currentMatches, hashMatches);
|
|
|
|
currentMissing.matches = mergedMatches;
|
|
missing.matches = mergedMatches;
|
|
this.persistLocalMatchesInAnalysisCache(currentMissing, hashMatches);
|
|
|
|
const state = workflowKey
|
|
? this.getSearchStateForWorkflow?.(workflowKey, currentMissing)
|
|
: this.searchResultCache?.get(this.getMissingSearchKey(currentMissing));
|
|
if (state) {
|
|
state.results = {
|
|
...(state.results || {}),
|
|
local_hash_matches: this.mergeLocalMatches(
|
|
state.results?.local_hash_matches || [],
|
|
hashMatches
|
|
)
|
|
};
|
|
if (workflowKey) {
|
|
this.persistSearchStateForWorkflow?.(workflowKey, currentMissing, state);
|
|
}
|
|
}
|
|
|
|
this.refreshLocalMatchesUiForMissing?.(currentMissing);
|
|
this.refreshSearchUiForMissing?.(currentMissing, state || null, { workflowKey });
|
|
return hashMatches;
|
|
},
|
|
|
|
getExistingLocalHashMatchesForSha(missing = {}, sha256 = '', state = null) {
|
|
const hash = normalizeSha256(sha256)
|
|
|| String(sha256 || '').trim().toLowerCase();
|
|
if (!hash) return [];
|
|
|
|
const results = state?.results || {};
|
|
const matches = [
|
|
...(Array.isArray(results.local_hash_matches) ? results.local_hash_matches : []),
|
|
...(Array.isArray(missing.matches) ? missing.matches : [])
|
|
];
|
|
const seen = new Set();
|
|
const found = [];
|
|
matches.forEach(match => {
|
|
if (!match || typeof match !== 'object') return;
|
|
if (!(match.hash_match || match.match_type === 'hash' || match.hash_lookup_source)) return;
|
|
const matchHash = normalizeSha256(this.getLocalMatchHash?.(match) || match.sha256 || match.hash)
|
|
|| String(this.getLocalMatchHash?.(match) || match.sha256 || match.hash || '').trim().toLowerCase();
|
|
if (matchHash !== hash) return;
|
|
const identity = this.getLocalMatchIdentity?.(match) || JSON.stringify(match);
|
|
if (seen.has(identity)) return;
|
|
seen.add(identity);
|
|
found.push(match);
|
|
});
|
|
return found;
|
|
},
|
|
|
|
async syncRemoteHashMatchesForResult(missing = {}, result = {}, options = {}) {
|
|
if (!missing || !result || typeof result !== 'object') return [];
|
|
|
|
const sha256 = this.getSearchResultSha256?.(result) || '';
|
|
if (!sha256) return [];
|
|
|
|
const workflowKey = options.workflowKey || this.getWorkflowScopedQueueKey?.();
|
|
const state = options.state
|
|
|| (workflowKey ? this.getSearchStateForWorkflow?.(workflowKey, missing) : null)
|
|
|| this.searchResultCache?.get?.(this.getMissingSearchKey?.(missing))
|
|
|| this.getSearchState?.(missing)
|
|
|| null;
|
|
const existingMatches = this.getExistingLocalHashMatchesForSha(missing, sha256, state);
|
|
if (existingMatches.length) {
|
|
this.refreshSearchUiForMissing?.(missing, state || null, { workflowKey });
|
|
return existingMatches;
|
|
}
|
|
|
|
const sourceKey = normalizeSourceKey(
|
|
options.sourceKey
|
|
|| result.sourceKey
|
|
|| result.source
|
|
|| result.details_source
|
|
|| 'download_source'
|
|
);
|
|
const category = options.category
|
|
|| result.category
|
|
|| result.directory
|
|
|| missing.category
|
|
|| '';
|
|
const filename = result.filename
|
|
|| result.downloadFilename
|
|
|| result.path
|
|
|| missing.original_path
|
|
|| '';
|
|
|
|
try {
|
|
const data = await this.fetchJson('/model_resolver/local-matches-by-hash', {
|
|
method: 'POST',
|
|
silent: true,
|
|
body: JSON.stringify({
|
|
sha256,
|
|
category,
|
|
source: sourceKey,
|
|
filename,
|
|
max_matches: 20
|
|
})
|
|
}, 'Find local models by hash');
|
|
|
|
return this.applyLocalHashMatchesFromSearchResponse?.(missing, data, { workflowKey }) || [];
|
|
} catch (error) {
|
|
console.warn('Model Resolver: local hash match sync failed:', error);
|
|
return [];
|
|
}
|
|
},
|
|
|
|
restoreSearchLocalHashMatchesForMissing(missing) {
|
|
if (!missing) return missing;
|
|
|
|
const state = this.searchResultCache?.get(this.getMissingSearchKey(missing));
|
|
const hashMatches = Array.isArray(state?.results?.local_hash_matches)
|
|
? state.results.local_hash_matches
|
|
: [];
|
|
if (!hashMatches.length) return missing;
|
|
|
|
missing.matches = this.mergeLocalMatches(
|
|
Array.isArray(missing.matches) ? missing.matches : [],
|
|
this.cloneLocalMatches(hashMatches)
|
|
);
|
|
return missing;
|
|
},
|
|
|
|
rememberDownloadedLocalMatchesForMissing(missing, matches = [], metadata = {}) {
|
|
if (!missing || !Array.isArray(matches)) return null;
|
|
|
|
const snapshot = this.rememberDownloadSnapshotForMissing(missing, {
|
|
localMatches: this.cloneLocalMatches(matches),
|
|
localMatchesMetadata: {
|
|
...metadata,
|
|
updatedAt: Date.now()
|
|
}
|
|
});
|
|
|
|
this.persistLocalMatchesInAnalysisCache(missing, matches);
|
|
return snapshot;
|
|
},
|
|
|
|
restoreDownloadedLocalMatchesForMissing(missing, workflowScope = null) {
|
|
if (!missing) return missing;
|
|
|
|
const snapshot = this.getDownloadProgressStore().get(
|
|
this.getDownloadStateKey(missing, workflowScope || null)
|
|
);
|
|
const storedMatches = Array.isArray(snapshot?.localMatches) ? snapshot.localMatches : [];
|
|
if (!storedMatches.length) return missing;
|
|
|
|
const currentMatches = Array.isArray(missing.matches) ? missing.matches : [];
|
|
missing.matches = this.mergeLocalMatches(currentMatches, this.cloneLocalMatches(storedMatches));
|
|
return missing;
|
|
},
|
|
|
|
preserveActiveDownloadLocalMatches(missing, nextMatches = [], workflowScope = null) {
|
|
if (!missing) return Array.isArray(nextMatches) ? nextMatches : [];
|
|
|
|
const activeEntries = this.getActiveDownloadEntriesForMissing?.(missing) || [];
|
|
const candidates = [
|
|
...(Array.isArray(missing.matches) ? missing.matches : []),
|
|
...activeEntries.flatMap(({ info }) => (
|
|
Array.isArray(info?.missing?.matches) ? info.missing.matches : []
|
|
))
|
|
];
|
|
const downloadingMatches = [];
|
|
for (const match of candidates) {
|
|
if (!match || typeof match !== 'object') continue;
|
|
const activeDownload = this.getActiveDownloadInfoForLocalMatch?.(match);
|
|
if (!activeDownload) continue;
|
|
|
|
downloadingMatches.push(match);
|
|
|
|
// A full missing-model refresh creates new model objects, while the
|
|
// progress poller still holds the object captured when the download
|
|
// started. Rebind it by the verified download/path match so the next
|
|
// polling tick cannot repaint Local Matches from stale state.
|
|
const activeEntry = this.activeDownloads?.[activeDownload.download_id];
|
|
if (
|
|
activeEntry
|
|
&& (!this.isDownloadInCurrentWorkflowScope || this.isDownloadInCurrentWorkflowScope(activeEntry, workflowScope))
|
|
&& activeEntry.missing !== missing
|
|
) {
|
|
activeEntry.missing = missing;
|
|
}
|
|
}
|
|
if (!downloadingMatches.length) {
|
|
return Array.isArray(nextMatches) ? nextMatches : [];
|
|
}
|
|
|
|
return this.mergeLocalMatches(
|
|
Array.isArray(nextMatches) ? nextMatches : [],
|
|
this.cloneLocalMatches(downloadingMatches)
|
|
);
|
|
},
|
|
|
|
isLocalMatchForDownloadTarget(match = {}, info = {}, progress = {}) {
|
|
return matchesLocalModelDownload(match, { info, progress });
|
|
},
|
|
|
|
removeCancelledDownloadLocalMatches(info = {}, progress = {}) {
|
|
if (!info) return [];
|
|
const shouldRemove = (match) => this.isLocalMatchForDownloadTarget(match, info, progress);
|
|
const cleanMatches = (matches) => (
|
|
Array.isArray(matches) ? matches.filter(match => !shouldRemove(match)) : []
|
|
);
|
|
const cleanMissing = (missing) => {
|
|
if (!missing || !Array.isArray(missing.matches)) return;
|
|
missing.matches = cleanMatches(missing.matches);
|
|
};
|
|
const cleanAnalysis = (data) => {
|
|
for (const key of ['missing_models', 'resolved_models']) {
|
|
(Array.isArray(data?.[key]) ? data[key] : []).forEach(cleanMissing);
|
|
}
|
|
};
|
|
|
|
cleanMissing(info.missing);
|
|
(Array.isArray(this.missingModels) ? this.missingModels : []).forEach(cleanMissing);
|
|
cleanAnalysis(this.cachedAnalysisData);
|
|
for (const cached of this.workflowAnalysisCaches?.values?.() || []) {
|
|
cleanAnalysis(cached?.data);
|
|
}
|
|
for (const snapshot of this.getDownloadProgressStore?.().values?.() || []) {
|
|
if (Array.isArray(snapshot?.localMatches)) {
|
|
snapshot.localMatches = cleanMatches(snapshot.localMatches);
|
|
}
|
|
}
|
|
|
|
return Array.isArray(info.missing?.matches) ? info.missing.matches : [];
|
|
},
|
|
|
|
finalizeCancelledDownloadFrontend(downloadId, info = {}, progress = {}) {
|
|
if (!downloadId) return;
|
|
|
|
this.clearPendingDownloadStatus?.(info);
|
|
this.removeCancelledDownloadLocalMatches?.(info, progress);
|
|
|
|
const store = this.getDownloadProgressStore?.();
|
|
const toCancelledSnapshot = (snapshot = {}) => ({
|
|
...snapshot,
|
|
downloadId,
|
|
missing: snapshot.missing || info.missing || null,
|
|
progress: {
|
|
...(snapshot.progress || {}),
|
|
...progress,
|
|
status: 'cancelled',
|
|
speed: 0
|
|
},
|
|
status: 'cancelled',
|
|
message: 'Download cancelled - incomplete file removed',
|
|
type: 'warning',
|
|
isActive: false,
|
|
updatedAt: Date.now()
|
|
});
|
|
let cancelledSnapshot = toCancelledSnapshot(info.statusSnapshot || {});
|
|
if (store instanceof Map) {
|
|
for (const [key, snapshot] of store.entries()) {
|
|
if (String(snapshot?.downloadId || '') === String(downloadId)) {
|
|
const terminalSnapshot = toCancelledSnapshot(snapshot);
|
|
store.set(key, terminalSnapshot);
|
|
cancelledSnapshot = terminalSnapshot;
|
|
}
|
|
}
|
|
}
|
|
|
|
delete this.activeDownloads?.[downloadId];
|
|
this.removeActiveDownloadRecovery?.(downloadId);
|
|
|
|
const elements = this.resolveDownloadUiElements?.(info) || {};
|
|
this.renderDownloadSnapshot?.(downloadId, cancelledSnapshot, elements);
|
|
this.refreshLocalMatchesUiForMissing?.(info.missing);
|
|
this.updateDownloadAllButtonState?.();
|
|
this.updateQueuePanel?.();
|
|
},
|
|
|
|
persistLocalMatchesInAnalysisCache(missing, matches = []) {
|
|
if (!missing || !Array.isArray(matches)) return;
|
|
|
|
const missingKey = this.getMissingModelKey(missing);
|
|
const patchAnalysisData = (data) => {
|
|
const patchList = (list) => {
|
|
if (!Array.isArray(list)) return;
|
|
|
|
for (const item of list) {
|
|
if (this.getMissingModelKey(item) !== missingKey) continue;
|
|
item.matches = this.mergeLocalMatches(item.matches || [], matches);
|
|
}
|
|
};
|
|
|
|
patchList(data?.missing_models);
|
|
patchList(data?.resolved_models);
|
|
};
|
|
|
|
patchAnalysisData(this.cachedAnalysisData);
|
|
const workflowKey = this.getWorkflowScopedQueueKey?.();
|
|
const cached = workflowKey ? this.workflowAnalysisCaches?.get(workflowKey) : null;
|
|
if (cached?.data) {
|
|
patchAnalysisData(cached.data);
|
|
}
|
|
},
|
|
|
|
rememberDownloadUiState(downloadId, info, progress = {}, options = {}) {
|
|
if (!info?.missing) return null;
|
|
|
|
this.rememberDownloadLocation(info, progress);
|
|
const status = options.status || progress.status || info.lastStatus || 'starting';
|
|
const snapshot = this.rememberDownloadSnapshotForMissing(info.missing, {
|
|
downloadId,
|
|
category: info.category || '',
|
|
subfolder: info.subfolder || '',
|
|
filename: progress.filename || info.filename || '',
|
|
downloadPath: progress.path || info.downloadPath || '',
|
|
downloadDirectory: progress.directory || info.downloadDirectory || '',
|
|
baseDirectory: info.baseDirectory || '',
|
|
sourceUrl: info.sourceUrl || '',
|
|
workflowKey: info.workflowKey || '',
|
|
workflowId: info.workflowId || '',
|
|
workflowRouteKey: info.workflowRouteKey || '',
|
|
workflowLabel: info.workflowLabel || this.getWorkflowLabelFromRouteKey?.(info.workflowRouteKey || '') || '',
|
|
workflowSignature: info.workflowSignature || '',
|
|
workflowTabId: info.workflowTabId || '',
|
|
workflowTabName: info.workflowTabName || '',
|
|
workflowTabAriaControls: info.workflowTabAriaControls || '',
|
|
workflowTabText: info.workflowTabText || '',
|
|
progress: {
|
|
...progress,
|
|
status,
|
|
filename: progress.filename || info.filename || '',
|
|
path: progress.path || info.downloadPath || '',
|
|
directory: progress.directory || info.downloadDirectory || '',
|
|
download_backend: progress.download_backend || info.downloadBackend || ''
|
|
},
|
|
status,
|
|
message: options.message || '',
|
|
type: options.type || '',
|
|
isActive: options.isActive ?? Boolean(this.activeDownloads?.[downloadId])
|
|
});
|
|
|
|
info.lastProgress = snapshot.progress;
|
|
info.lastStatus = status;
|
|
info.statusSnapshot = snapshot;
|
|
return snapshot;
|
|
},
|
|
|
|
getActiveDownloadEntryForMissing(missing) {
|
|
if (!missing) return null;
|
|
const missingKey = this.getDownloadMissingIdentity(missing);
|
|
|
|
for (const [downloadId, info] of Object.entries(this.activeDownloads || {})) {
|
|
if (
|
|
info?.missing
|
|
&& this.isDownloadInCurrentWorkflowScope(info)
|
|
&& this.getDownloadMissingIdentity(info.missing) === missingKey
|
|
) {
|
|
return { downloadId, info };
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
|
|
getActiveDownloadEntriesForMissing(missing) {
|
|
if (!missing) return [];
|
|
const missingKey = this.getDownloadMissingIdentity(missing);
|
|
return Object.entries(this.activeDownloads || {})
|
|
.filter(([, info]) => (
|
|
info?.missing
|
|
&& this.isDownloadInCurrentWorkflowScope(info)
|
|
&& this.getDownloadMissingIdentity(info.missing) === missingKey
|
|
))
|
|
.map(([downloadId, info]) => ({ downloadId, info }));
|
|
},
|
|
|
|
getDownloadSnapshotForMissing(missing) {
|
|
const active = this.getActiveDownloadEntryForMissing(missing);
|
|
if (active) {
|
|
return this.rememberDownloadUiState(
|
|
active.downloadId,
|
|
active.info,
|
|
active.info.lastProgress || { status: active.info.lastStatus || 'starting', progress: 0 },
|
|
{ isActive: true }
|
|
);
|
|
}
|
|
return this.getDownloadProgressStore().get(this.getDownloadStateKey(missing)) || null;
|
|
},
|
|
|
|
resolveDownloadUiElements(target = {}) {
|
|
const missing = target?.missing;
|
|
if (!missing) return { progressDiv: null, downloadBtn: null };
|
|
|
|
const progressId = this.getDownloadProgressElementId(missing);
|
|
const buttonId = this.getDownloadButtonElementId(missing);
|
|
const connectedProgressDiv = target.progressDiv?.isConnected ? target.progressDiv : null;
|
|
const connectedDownloadBtn = target.downloadBtn?.isConnected ? target.downloadBtn : null;
|
|
const progressDiv = this.contentElement?.querySelector(`#${progressId}`) || connectedProgressDiv;
|
|
let downloadBtn = this.contentElement?.querySelector(`#${buttonId}`) || connectedDownloadBtn;
|
|
|
|
if (!downloadBtn && target.sourceUrl && this.contentElement) {
|
|
downloadBtn = Array.from(this.contentElement.querySelectorAll('.search-download-btn')).find(btn => (
|
|
btn.dataset.url === target.sourceUrl
|
|
&& (!target.filename || btn.dataset.filename === target.filename)
|
|
)) || null;
|
|
}
|
|
|
|
if ('progressDiv' in target) target.progressDiv = progressDiv;
|
|
if ('downloadBtn' in target) target.downloadBtn = downloadBtn;
|
|
return { progressDiv, downloadBtn };
|
|
},
|
|
|
|
attachDownloadActionHandlers(progressDiv, downloadId) {
|
|
if (!progressDiv) return;
|
|
const getDownloadContext = (button) => {
|
|
const targetDownloadId = button?.dataset?.downloadId || downloadId;
|
|
const info = this.activeDownloads?.[targetDownloadId];
|
|
if (!targetDownloadId || !info) return null;
|
|
const progress = typeof this.applyPendingDownloadStatus === 'function'
|
|
? this.applyPendingDownloadStatus(info, info.lastProgress || {})
|
|
: (info.lastProgress || {});
|
|
const workflowLabel = this.getDownloadWorkflowLabel?.(info) || 'Current workflow';
|
|
return this.getDownloadQueueContext?.(progress, info, workflowLabel, targetDownloadId)
|
|
|| this.getDownloadFolderContext?.(progress, info)
|
|
|| null;
|
|
};
|
|
bindDownloadActionHandlers(progressDiv, {
|
|
fallbackDownloadId: downloadId,
|
|
selectors: {
|
|
cancel: '.cancel-download-btn, .cancel-download-btn-pending, .mr-download-queue-cancel',
|
|
pause: '.pause-download-btn, .mr-download-queue-pause',
|
|
resume: '.resume-download-btn, .mr-download-queue-resume',
|
|
openFolder: '.mr-download-queue-open-folder',
|
|
switchWorkflow: '.mr-download-queue-switch-workflow',
|
|
more: '.mr-download-queue-more',
|
|
},
|
|
getContext: getDownloadContext,
|
|
onCancel: targetDownloadId => this.cancelDownload(targetDownloadId),
|
|
onPause: targetDownloadId => this.pauseDownload(targetDownloadId),
|
|
onResume: targetDownloadId => this.resumeDownload(targetDownloadId),
|
|
onOpenFolder: context => this.openContainingFolder?.(context),
|
|
onSwitchWorkflow: context => this.switchToDownloadWorkflow?.(context),
|
|
onMore: (event, button) => {
|
|
const item = button.closest('.mr-download-queue-item');
|
|
if (item) window.MLOpenContextMenu?.(event, item);
|
|
},
|
|
});
|
|
},
|
|
|
|
renderDownloadSnapshotMarkup(downloadId, snapshot) {
|
|
const progress = snapshot.progress || {};
|
|
const status = snapshot.status || progress.status || '';
|
|
const isActive = snapshot.isActive || Boolean(downloadId && this.activeDownloads?.[downloadId]);
|
|
const shouldRenderProgress = this.isDownloadProgressStatus(status, isActive);
|
|
|
|
if (shouldRenderProgress) {
|
|
const info = {
|
|
...snapshot,
|
|
missing: snapshot.missing || {},
|
|
lastProgress: progress,
|
|
lastStatus: status,
|
|
filename: progress.filename || snapshot.filename || 'Download',
|
|
category: snapshot.category || progress.category || '',
|
|
downloadBackend: progress.download_backend || snapshot.downloadBackend || snapshot.download_backend || '',
|
|
downloadDirectory: progress.directory || snapshot.downloadDirectory || '',
|
|
downloadPath: progress.path || snapshot.downloadPath || '',
|
|
workflowLabel: snapshot.workflowLabel || snapshot.workflow_label || 'Current workflow'
|
|
};
|
|
return this.renderQueueDownloadsHtml([{ downloadId, info }], {
|
|
wrap: false,
|
|
showWorkflowAction: false,
|
|
showWorkflowDetails: false
|
|
});
|
|
}
|
|
|
|
if (status === 'cancelled') {
|
|
return this.renderStatusMessage('Download cancelled - incomplete file removed', 'warning');
|
|
}
|
|
if (status === 'cancelling') {
|
|
return this.renderDownloadStatusMessage(snapshot.message || 'Cancelling download...', snapshot.type || 'info', progress, snapshot);
|
|
}
|
|
if (status === 'error' || status === 'refresh_error') {
|
|
return this.renderDownloadStatusMessage(
|
|
snapshot.message || progress.error || 'Download failed',
|
|
snapshot.type || (status === 'refresh_error' ? 'warning' : 'error'),
|
|
progress,
|
|
snapshot
|
|
);
|
|
}
|
|
if (status === 'completed_checking') {
|
|
return this.renderDownloadStatusMessage(snapshot.message || 'Download complete. Checking local matches...', snapshot.type || 'success', progress, snapshot);
|
|
}
|
|
if (status === 'completed') {
|
|
return this.renderDownloadStatusMessage(snapshot.message || 'Download complete. Local matches updated.', snapshot.type || 'success', progress, snapshot);
|
|
}
|
|
return '';
|
|
},
|
|
|
|
buildActiveDownloadSnapshot(downloadId, info = {}) {
|
|
const progress = this.applyPendingDownloadStatus?.(
|
|
info,
|
|
info.lastProgress || { status: info.lastStatus || 'starting', progress: 0 }
|
|
) || info.lastProgress || {};
|
|
return {
|
|
...(info.statusSnapshot || {}),
|
|
downloadId,
|
|
missing: info.missing,
|
|
category: info.category || '',
|
|
filename: progress.filename || info.filename || '',
|
|
downloadPath: progress.path || info.downloadPath || '',
|
|
downloadDirectory: progress.directory || info.downloadDirectory || '',
|
|
progress,
|
|
status: progress.status || info.lastStatus || 'starting',
|
|
isActive: true
|
|
};
|
|
},
|
|
|
|
renderDownloadProgressGroupItem(downloadId, snapshot) {
|
|
return this.renderDownloadSnapshotMarkup(downloadId, snapshot);
|
|
},
|
|
|
|
patchDownloadProgressContent(progressDiv, html) {
|
|
if (!progressDiv) return;
|
|
|
|
const nextHtml = String(html || '');
|
|
if (
|
|
typeof progressDiv.cloneNode !== 'function'
|
|
|| typeof this.patchDownloadsPanelElement !== 'function'
|
|
) {
|
|
if (progressDiv.innerHTML !== nextHtml) {
|
|
progressDiv.innerHTML = nextHtml;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const nextProgressDiv = progressDiv.cloneNode(false);
|
|
nextProgressDiv.innerHTML = nextHtml;
|
|
this.patchDownloadsPanelElement(progressDiv, nextProgressDiv);
|
|
},
|
|
|
|
renderDownloadProgressGroupForMissing(missing, progressDiv, { includeDownloadId = '', includeSnapshot = null } = {}) {
|
|
if (!missing || !progressDiv) return false;
|
|
|
|
const activeEntries = this.getActiveDownloadEntriesForMissing(missing);
|
|
const renderedIds = new Set();
|
|
const items = activeEntries.map(({ downloadId, info }) => {
|
|
renderedIds.add(String(downloadId));
|
|
return this.renderDownloadProgressGroupItem(downloadId, this.buildActiveDownloadSnapshot(downloadId, info));
|
|
});
|
|
|
|
if (includeSnapshot && (!includeDownloadId || !renderedIds.has(String(includeDownloadId)))) {
|
|
items.push(this.renderDownloadProgressGroupItem(includeDownloadId, includeSnapshot));
|
|
}
|
|
|
|
if (items.length <= 1) return false;
|
|
|
|
progressDiv.classList.remove('mr-is-hidden');
|
|
progressDiv.classList.add('mr-is-visible');
|
|
this.patchDownloadProgressContent(
|
|
progressDiv,
|
|
`<div class="mr-download-progress-list">${items.join('')}</div>`
|
|
);
|
|
this.attachDownloadActionHandlers(progressDiv, includeDownloadId);
|
|
return true;
|
|
},
|
|
|
|
ensureSearchDownloadButtonIcon(downloadBtn) {
|
|
if (
|
|
!downloadBtn?.classList?.contains('search-download-btn')
|
|
|| downloadBtn.querySelector?.('svg')
|
|
) {
|
|
return;
|
|
}
|
|
downloadBtn.innerHTML = getSvgIcon('download');
|
|
},
|
|
|
|
updateDownloadButtonForSnapshot(downloadBtn, progress, status, shouldRenderProgress) {
|
|
if (!downloadBtn) return;
|
|
|
|
if (shouldRenderProgress) {
|
|
const displayProgress = this.getDownloadDisplayProgress(progress);
|
|
const percent = displayProgress.percent;
|
|
downloadBtn.disabled = true;
|
|
downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
|
const percentLabel = Number.isFinite(percent) && percent > 0
|
|
? `${this.formatDownloadPercent?.(percent) ?? Math.round(percent)}%`
|
|
: '';
|
|
const label = displayProgress.isFinalizing
|
|
? 'Finalizing download...'
|
|
: (status === 'paused'
|
|
? `Paused ${percentLabel}`.trim()
|
|
: (percentLabel ? `Downloading ${percentLabel}` : 'Starting download...'));
|
|
if (downloadBtn.classList.contains('search-download-btn')) {
|
|
this.ensureSearchDownloadButtonIcon(downloadBtn);
|
|
downloadBtn.setAttribute('data-tooltip', label);
|
|
downloadBtn.setAttribute('aria-label', label);
|
|
} else {
|
|
downloadBtn.textContent = displayProgress.isFinalizing
|
|
? 'Finalizing...'
|
|
: (status === 'paused'
|
|
? 'Paused'
|
|
: (Number.isFinite(percent) && percent > 0 ? `${Math.round(percent)}%` : 'Starting...'));
|
|
}
|
|
} else if (status === 'cancelling') {
|
|
downloadBtn.disabled = true;
|
|
downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
|
if (downloadBtn.classList.contains('search-download-btn')) {
|
|
this.ensureSearchDownloadButtonIcon(downloadBtn);
|
|
downloadBtn.setAttribute('data-tooltip', 'Cancelling download...');
|
|
downloadBtn.setAttribute('aria-label', 'Cancelling download');
|
|
} else {
|
|
downloadBtn.textContent = 'Cancelling...';
|
|
}
|
|
} else if (status === 'error' || status === 'refresh_error') {
|
|
downloadBtn.disabled = false;
|
|
downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
|
if (downloadBtn.classList.contains('search-download-btn')) {
|
|
this.ensureSearchDownloadButtonIcon(downloadBtn);
|
|
downloadBtn.setAttribute('data-tooltip', 'Retry download');
|
|
downloadBtn.setAttribute('aria-label', 'Retry download');
|
|
} else {
|
|
downloadBtn.textContent = 'Retry';
|
|
}
|
|
} else {
|
|
this.restoreDownloadButtonReadyState(downloadBtn);
|
|
}
|
|
},
|
|
|
|
renderDownloadSnapshot(downloadId, snapshot, elements = {}) {
|
|
if (!snapshot) return;
|
|
|
|
const { progressDiv, downloadBtn } = elements.progressDiv || elements.downloadBtn
|
|
? elements
|
|
: this.resolveDownloadUiElements(snapshot);
|
|
const progress = snapshot.progress || {};
|
|
const status = snapshot.status || progress.status || '';
|
|
const isActive = snapshot.isActive || Boolean(downloadId && this.activeDownloads?.[downloadId]);
|
|
const shouldRenderProgress = this.isDownloadProgressStatus(status, isActive);
|
|
const missingForGroup = snapshot.missing || this.activeDownloads?.[downloadId]?.missing || null;
|
|
|
|
if (
|
|
progressDiv &&
|
|
this.renderDownloadProgressGroupForMissing(missingForGroup, progressDiv, {
|
|
includeDownloadId: downloadId,
|
|
includeSnapshot: snapshot
|
|
})
|
|
) {
|
|
this.updateDownloadButtonForSnapshot(downloadBtn, progress, status, shouldRenderProgress);
|
|
return;
|
|
}
|
|
|
|
if (progressDiv) {
|
|
progressDiv.classList.remove('mr-is-hidden');
|
|
progressDiv.classList.add('mr-is-visible');
|
|
this.patchDownloadProgressContent(
|
|
progressDiv,
|
|
this.renderDownloadSnapshotMarkup(downloadId, snapshot)
|
|
);
|
|
this.attachDownloadActionHandlers(progressDiv, downloadId);
|
|
}
|
|
|
|
this.updateDownloadButtonForSnapshot(downloadBtn, progress, status, shouldRenderProgress);
|
|
},
|
|
|
|
restoreDownloadProgressForMissing(missing) {
|
|
const snapshot = this.getDownloadSnapshotForMissing(missing);
|
|
if (!snapshot) return;
|
|
this.renderDownloadSnapshot(snapshot.downloadId, snapshot);
|
|
},
|
|
|
|
patchLocalMatchesContainer(container, renderedHtml = '') {
|
|
if (!container) return;
|
|
const documentRef = container.ownerDocument || globalThis.document;
|
|
if (!documentRef?.createElement) {
|
|
container.innerHTML = renderedHtml;
|
|
return;
|
|
}
|
|
|
|
const template = documentRef.createElement('template');
|
|
template.innerHTML = renderedHtml || '';
|
|
const nextRows = Array.from(template.content.querySelectorAll('.mr-match-row[data-local-match-key]'));
|
|
const currentRows = Array.from(container.querySelectorAll('.mr-match-row[data-local-match-key]'));
|
|
|
|
if (
|
|
!nextRows.length
|
|
|| !currentRows.length
|
|
|| currentRows.some(row => !row.dataset.localMatchKey)
|
|
) {
|
|
if (container.innerHTML !== renderedHtml) {
|
|
container.innerHTML = renderedHtml;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const currentGroups = Array.from(container.querySelectorAll('.mr-stack-sm'));
|
|
const currentGroupsById = new Map(currentGroups.map(group => [group.id, group]));
|
|
const nextGroups = Array.from(template.content.querySelectorAll('.mr-stack-sm'));
|
|
const nextGroupsById = new Map();
|
|
const retainedGroups = new Set();
|
|
|
|
for (const nextGroup of nextGroups) {
|
|
const groupId = nextGroup.id || '';
|
|
let currentGroup = currentGroupsById.get(groupId);
|
|
if (!currentGroup) {
|
|
currentGroup = nextGroup.cloneNode(true);
|
|
currentGroup.querySelectorAll('.mr-match-row').forEach(row => row.remove());
|
|
}
|
|
nextGroupsById.set(groupId, currentGroup);
|
|
retainedGroups.add(currentGroup);
|
|
}
|
|
|
|
const currentRowsByKey = new Map(
|
|
currentRows.map(row => [row.dataset.localMatchKey, row])
|
|
);
|
|
const retainedRows = new Set();
|
|
const nextRowsByKey = new Map();
|
|
|
|
for (const nextRow of nextRows) {
|
|
const key = nextRow.dataset.localMatchKey;
|
|
let currentRow = currentRowsByKey.get(key);
|
|
if (!currentRow) {
|
|
currentRow = nextRow.cloneNode(true);
|
|
} else {
|
|
this.syncSearchElementAttributes(currentRow, nextRow);
|
|
if (currentRow.innerHTML !== nextRow.innerHTML) {
|
|
currentRow.innerHTML = nextRow.innerHTML;
|
|
}
|
|
}
|
|
|
|
const nextGroup = nextRow.closest('.mr-stack-sm');
|
|
const targetParent = nextGroup
|
|
? nextGroupsById.get(nextGroup.id || '')
|
|
: container;
|
|
if (!targetParent) return;
|
|
targetParent.appendChild(currentRow);
|
|
retainedRows.add(currentRow);
|
|
nextRowsByKey.set(key, currentRow);
|
|
}
|
|
|
|
for (const currentRow of currentRows) {
|
|
if (!retainedRows.has(currentRow)) currentRow.remove();
|
|
}
|
|
for (const currentGroup of currentGroups) {
|
|
if (!retainedGroups.has(currentGroup)) currentGroup.remove();
|
|
}
|
|
|
|
const nextToggle = template.content.querySelector('.mr-local-alternatives-toggle');
|
|
const currentToggle = container.querySelector('.mr-local-alternatives-toggle');
|
|
let nextDomToggle = null;
|
|
if (nextToggle) {
|
|
nextDomToggle = currentToggle || nextToggle.cloneNode(true);
|
|
if (currentToggle) {
|
|
this.syncSearchElementAttributes(currentToggle, nextToggle);
|
|
}
|
|
} else if (currentToggle) {
|
|
currentToggle.remove();
|
|
}
|
|
|
|
const retainedChildren = new Set();
|
|
const nextDomChildren = [];
|
|
for (const nextChild of Array.from(template.content.children)) {
|
|
let nextDomChild = null;
|
|
if (nextChild.classList.contains('mr-match-row')) {
|
|
nextDomChild = nextRowsByKey.get(nextChild.dataset.localMatchKey);
|
|
} else if (nextChild.classList.contains('mr-local-alternatives-toggle')) {
|
|
nextDomChild = nextDomToggle;
|
|
} else if (nextChild.classList.contains('mr-stack-sm')) {
|
|
nextDomChild = nextGroupsById.get(nextChild.id || '');
|
|
}
|
|
if (!nextDomChild) continue;
|
|
retainedChildren.add(nextDomChild);
|
|
nextDomChildren.push(nextDomChild);
|
|
}
|
|
|
|
Array.from(container.children).forEach(child => {
|
|
if (!retainedChildren.has(child)) child.remove();
|
|
});
|
|
nextDomChildren.forEach(child => container.appendChild(child));
|
|
},
|
|
|
|
async refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, { progressDiv = null, category = '', downloadPath = '', downloadDirectory = '', workflowScope = null } = {}) {
|
|
if (!missing) return [];
|
|
workflowScope ??= this.getCurrentDownloadWorkflowScopeIdentity();
|
|
if (workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return null;
|
|
|
|
const targetFilename = this.getDownloadedLocalMatchTarget(missing, downloadedFilename);
|
|
if (!targetFilename) return [];
|
|
|
|
const missingKey = this.getMissingModelKey(missing);
|
|
const refreshToken = this.beginLocalMatchRefresh(missing);
|
|
|
|
try {
|
|
if (progressDiv) {
|
|
progressDiv.innerHTML = this.renderDownloadStatusMessage(
|
|
`Checking local matches for ${targetFilename}...`,
|
|
'info',
|
|
{ filename: targetFilename, path: downloadPath, directory: downloadDirectory },
|
|
{ category, downloadPath, downloadDirectory, filename: targetFilename }
|
|
);
|
|
}
|
|
const body = this.contentElement?.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`);
|
|
if (body) {
|
|
this.patchLocalMatchesContainer(
|
|
body,
|
|
`<div class="mr-no-matches">Checking local matches for ${this.escapeHtml(targetFilename)}...</div>`
|
|
);
|
|
}
|
|
|
|
const data = await this.fetchLocalMatches(targetFilename, category || missing.category || '', true);
|
|
if (workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return null;
|
|
// Linking can replace the model list while the scan is in flight.
|
|
const currentMissing = (this.missingModels || []).find(item => this.getMissingModelKey(item) === missingKey) || missing;
|
|
if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) {
|
|
return null;
|
|
}
|
|
|
|
const matches = Array.isArray(data.matches) ? data.matches : [];
|
|
currentMissing.matches = matches;
|
|
missing.matches = matches;
|
|
this.rememberDownloadedLocalMatchesForMissing(currentMissing, matches, {
|
|
targetFilename,
|
|
category: category || missing.category || '',
|
|
downloadPath,
|
|
downloadDirectory
|
|
});
|
|
this.allModels = null;
|
|
this.invalidateLoadedModelsCacheForActiveWorkflow?.();
|
|
this.refreshLocalMatchesUiForMissing(currentMissing);
|
|
return matches;
|
|
} finally {
|
|
this.finishLocalMatchRefresh(refreshToken.key, refreshToken.token);
|
|
}
|
|
},
|
|
|
|
refreshLocalMatchesUiForMissing(missing) {
|
|
if (!missing || !this.contentElement) return;
|
|
|
|
const body = this.contentElement.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`);
|
|
const displayIndex = Number.isFinite(missing.__displayIndex) ? missing.__displayIndex : 0;
|
|
if (body) {
|
|
this.patchLocalMatchesContainer(
|
|
body,
|
|
this.renderLocalMatchesContent(missing, displayIndex)
|
|
);
|
|
}
|
|
|
|
this.refreshMissingListRow(missing);
|
|
this.updateBatchFooterButtons?.();
|
|
},
|
|
|
|
getLocalMatchRefreshTarget(missing = {}) {
|
|
return missing.civitai_info?.expected_filename
|
|
|| missing.expected_filename
|
|
|| missing.download_source?.filename
|
|
|| missing.original_path
|
|
|| missing.name
|
|
|| '';
|
|
},
|
|
|
|
async refreshLocalMatchesForMissing(missing, { button = null } = {}) {
|
|
if (!missing || button?.disabled) return [];
|
|
|
|
const targetFilename = String(this.getLocalMatchRefreshTarget(missing) || '');
|
|
if (!targetFilename) {
|
|
this.showNotification('No model filename available for local refresh.', 'warning');
|
|
return [];
|
|
}
|
|
|
|
const minRefreshFeedback = new Promise(resolve => setTimeout(resolve, 420));
|
|
const refreshAnimation = this.startRefreshButtonAnimation?.(button);
|
|
const body = this.contentElement?.querySelector(`#local-matches-body-${this.getMissingModelDomKey(missing)}`);
|
|
const displayName = this.getFilenameFromPath(targetFilename) || targetFilename;
|
|
const missingKey = this.getMissingModelKey(missing);
|
|
const currentMissing = (this.missingModels || []).find(item => this.getMissingModelKey(item) === missingKey) || missing;
|
|
const previousMatches = this.cloneLocalMatches?.(currentMissing.matches || missing.matches || []) || [];
|
|
const refreshToken = this.beginLocalMatchRefresh(missing);
|
|
|
|
try {
|
|
if (button) {
|
|
button.disabled = true;
|
|
button.classList.add('mr-btn-is-disabled', 'mr-is-refreshing');
|
|
}
|
|
if (body) {
|
|
this.patchLocalMatchesContainer(
|
|
body,
|
|
`<div class="mr-no-matches">Refreshing local matches for ${this.escapeHtml(displayName)}...</div>`
|
|
);
|
|
}
|
|
|
|
const data = await this.fetchLocalMatches(targetFilename, missing.category || '', true);
|
|
if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) {
|
|
return currentMissing.matches || [];
|
|
}
|
|
const refreshedMatches = Array.isArray(data.matches) ? data.matches : [];
|
|
const matches = this.preserveActiveDownloadLocalMatches?.(
|
|
currentMissing,
|
|
refreshedMatches
|
|
) || refreshedMatches;
|
|
currentMissing.matches = matches;
|
|
missing.matches = matches;
|
|
if (currentMissing.is_urn) {
|
|
currentMissing.__urnLocalMatchesFilename = targetFilename;
|
|
missing.__urnLocalMatchesFilename = targetFilename;
|
|
}
|
|
|
|
this.rememberDownloadedLocalMatchesForMissing(currentMissing, matches, {
|
|
targetFilename,
|
|
category: missing.category || '',
|
|
manualRefresh: true
|
|
});
|
|
this.allModels = null;
|
|
this.invalidateLoadedModelsCacheForActiveWorkflow?.();
|
|
await minRefreshFeedback;
|
|
if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) {
|
|
return currentMissing.matches || [];
|
|
}
|
|
this.refreshLocalMatchesUiForMissing(currentMissing);
|
|
|
|
return matches;
|
|
} catch (error) {
|
|
if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) {
|
|
return currentMissing.matches || [];
|
|
}
|
|
await minRefreshFeedback;
|
|
if (!this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) {
|
|
return currentMissing.matches || [];
|
|
}
|
|
console.error('Model Resolver: local match refresh failed:', error);
|
|
currentMissing.matches = previousMatches;
|
|
missing.matches = previousMatches;
|
|
this.refreshLocalMatchesUiForMissing(currentMissing);
|
|
this.showNotification(`Local match refresh failed: ${error.message}`, 'error');
|
|
return [];
|
|
} finally {
|
|
refreshAnimation?.cancel?.();
|
|
if (button) {
|
|
if (this.isCurrentLocalMatchRefresh(refreshToken.key, refreshToken.token)) {
|
|
button.disabled = false;
|
|
button.classList.remove('mr-btn-is-disabled', 'mr-is-refreshing');
|
|
}
|
|
}
|
|
this.finishLocalMatchRefresh(refreshToken.key, refreshToken.token);
|
|
}
|
|
},
|
|
|
|
async refreshAfterDownload(missing, downloadedFilename, { progressDiv = null, downloadBtn = null, category = '', downloadPath = '', downloadDirectory = '', alreadyExists = false, workflowScope = null } = {}) {
|
|
workflowScope ??= this.getCurrentDownloadWorkflowScopeIdentity();
|
|
try {
|
|
const matches = await this.refreshLocalMatchesForDownloadedMissing(missing, downloadedFilename, {
|
|
progressDiv,
|
|
category,
|
|
downloadPath,
|
|
downloadDirectory,
|
|
workflowScope
|
|
});
|
|
if (matches === null || workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return;
|
|
const downloadedLower = String(downloadedFilename || '').toLowerCase();
|
|
const perfectMatch = matches.find(match => {
|
|
const matchFilename = match.filename || match.model?.filename || '';
|
|
return match.confidence === 100
|
|
|| (downloadedLower && matchFilename.toLowerCase() === downloadedLower);
|
|
});
|
|
const message = perfectMatch?.model
|
|
? (alreadyExists ? 'This model is already downloaded. Exact local match is ready.' : 'Download complete. Exact local match is ready.')
|
|
: (alreadyExists ? 'This model is already downloaded. Local matches updated.' : 'Download complete. Local matches updated.');
|
|
const snapshot = this.rememberDownloadSnapshotForMissing(missing, {
|
|
downloadId: null,
|
|
category,
|
|
filename: downloadedFilename,
|
|
downloadPath,
|
|
downloadDirectory,
|
|
progress: {
|
|
status: 'completed',
|
|
filename: downloadedFilename,
|
|
path: downloadPath,
|
|
directory: downloadDirectory,
|
|
already_exists: alreadyExists
|
|
},
|
|
status: 'completed',
|
|
message,
|
|
type: 'success',
|
|
isActive: false
|
|
});
|
|
|
|
this.renderDownloadSnapshot(
|
|
null,
|
|
snapshot,
|
|
progressDiv || downloadBtn ? { progressDiv, downloadBtn } : {}
|
|
);
|
|
|
|
} catch (error) {
|
|
if (workflowScope !== this.getCurrentDownloadWorkflowScopeIdentity()) return;
|
|
console.error('Model Resolver: Error refreshing after download:', error);
|
|
const snapshot = this.rememberDownloadSnapshotForMissing(missing, {
|
|
downloadId: null,
|
|
category,
|
|
filename: downloadedFilename,
|
|
downloadPath,
|
|
downloadDirectory,
|
|
progress: {
|
|
status: 'refresh_error',
|
|
filename: downloadedFilename,
|
|
path: downloadPath,
|
|
directory: downloadDirectory
|
|
},
|
|
status: 'refresh_error',
|
|
message: alreadyExists
|
|
? `Already downloaded, but local re-check failed: ${error.message}`
|
|
: `Downloaded, but local re-check failed: ${error.message}`,
|
|
type: 'warning',
|
|
isActive: false
|
|
});
|
|
this.renderDownloadSnapshot(
|
|
null,
|
|
snapshot,
|
|
progressDiv || downloadBtn ? { progressDiv, downloadBtn } : {}
|
|
);
|
|
if (downloadBtn) {
|
|
downloadBtn.disabled = false;
|
|
downloadBtn.textContent = 'Link manually';
|
|
}
|
|
this.showNotification(
|
|
`${alreadyExists ? 'Already downloaded' : 'Downloaded'}, but local re-check failed: ${error.message}`,
|
|
'warning',
|
|
{
|
|
contextMenuModel: this.getDownloadFolderContext(
|
|
{ filename: downloadedFilename, path: downloadPath, directory: downloadDirectory },
|
|
{ category, downloadPath, downloadDirectory, filename: downloadedFilename }
|
|
)
|
|
}
|
|
);
|
|
}
|
|
},
|
|
|
|
buildRecoveredDownloadMissing(downloadId, progress = {}, recovery = {}) {
|
|
const filename = progress.filename || recovery.filename || 'Download';
|
|
const url = progress.url || recovery.sourceUrl || '';
|
|
const category = progress.category || recovery.category || '';
|
|
return {
|
|
missing_key: `download:${downloadId}`,
|
|
original_path: filename,
|
|
filename,
|
|
category,
|
|
node_type: 'Backend download',
|
|
download_source: {
|
|
source: 'backend',
|
|
url,
|
|
filename,
|
|
directory: category,
|
|
category
|
|
}
|
|
};
|
|
},
|
|
|
|
rebindActiveDownloadMissingModels() {
|
|
const candidates = [];
|
|
const seen = new Set();
|
|
const addCandidates = (models) => {
|
|
for (const model of Array.isArray(models) ? models : []) {
|
|
if (!model || seen.has(model)) continue;
|
|
seen.add(model);
|
|
candidates.push(model);
|
|
}
|
|
};
|
|
addCandidates(this.missingModels);
|
|
addCandidates(this.cachedAnalysisData?.missing_models);
|
|
for (const cached of this.workflowAnalysisCaches?.values?.() || []) {
|
|
addCandidates(cached?.data?.missing_models);
|
|
}
|
|
if (!candidates.length) return;
|
|
|
|
const normalizeUrl = (value = '') => String(value || '')
|
|
.trim()
|
|
.replace(/\/blob\//g, '/resolve/')
|
|
.replace(/[?#].*$/, '')
|
|
.toLowerCase();
|
|
const getFilename = (value = '') => String(value || '')
|
|
.split(/[\\/]+/)
|
|
.pop()
|
|
.trim()
|
|
.toLowerCase();
|
|
|
|
for (const info of Object.values(this.activeDownloads || {})) {
|
|
if (!info?.missing) continue;
|
|
|
|
const missingKey = this.getMissingModelKey?.(info.missing) || '';
|
|
let replacement = candidates.find(model => (
|
|
missingKey && this.getMissingModelKey?.(model) === missingKey
|
|
));
|
|
|
|
if (!replacement) {
|
|
const progress = info.lastProgress || {};
|
|
const downloadUrl = normalizeUrl(progress.url || info.sourceUrl);
|
|
const filename = getFilename(progress.filename || info.filename);
|
|
const urlMatches = downloadUrl
|
|
? candidates.filter(model => normalizeUrl(
|
|
model.download_source?.url
|
|
|| model.download_source?.download_url
|
|
|| model.url
|
|
) === downloadUrl)
|
|
: [];
|
|
replacement = urlMatches.length === 1 ? urlMatches[0] : null;
|
|
if (!replacement && filename) {
|
|
const filenameMatches = candidates.filter(model => (
|
|
getFilename(model.download_source?.filename)
|
|
|| getFilename(model.original_path)
|
|
) === filename);
|
|
replacement = filenameMatches.length === 1 ? filenameMatches[0] : null;
|
|
}
|
|
}
|
|
|
|
if (replacement) info.missing = replacement;
|
|
}
|
|
},
|
|
|
|
async restoreActiveDownloadsFromBackend() {
|
|
if (this._activeDownloadsRestorePromise) {
|
|
return this._activeDownloadsRestorePromise;
|
|
}
|
|
|
|
this._activeDownloadsRestorePromise = (async () => {
|
|
let allProgress;
|
|
try {
|
|
allProgress = await this.fetchJson(
|
|
'/model_resolver/progress',
|
|
{ silent: true },
|
|
'Get active downloads'
|
|
);
|
|
} catch (error) {
|
|
console.warn('Model Resolver: failed to restore active downloads:', error);
|
|
return 0;
|
|
}
|
|
|
|
const terminalStatuses = new Set([
|
|
'completed',
|
|
'completed_checking',
|
|
'cancelled',
|
|
'error',
|
|
'refresh_error'
|
|
]);
|
|
const recovery = this.loadActiveDownloadRecovery?.() || {};
|
|
const activeIds = new Set();
|
|
let restored = 0;
|
|
|
|
for (const [downloadId, rawProgress] of Object.entries(allProgress || {})) {
|
|
const progress = rawProgress && typeof rawProgress === 'object'
|
|
? rawProgress
|
|
: {};
|
|
const status = String(progress.status || '').toLowerCase();
|
|
if (terminalStatuses.has(status) || !this.isDownloadProgressStatus(status, true)) {
|
|
continue;
|
|
}
|
|
|
|
activeIds.add(String(downloadId));
|
|
const stored = recovery[downloadId] && typeof recovery[downloadId] === 'object'
|
|
? recovery[downloadId]
|
|
: {};
|
|
const existing = this.activeDownloads?.[downloadId];
|
|
if (existing) {
|
|
existing.lastProgress = progress;
|
|
existing.lastStatus = status || existing.lastStatus || 'starting';
|
|
this.rememberDownloadUiState?.(downloadId, existing, progress, { isActive: true });
|
|
continue;
|
|
}
|
|
|
|
const info = {
|
|
...stored,
|
|
missing: stored.missing && typeof stored.missing === 'object'
|
|
? stored.missing
|
|
: this.buildRecoveredDownloadMissing(downloadId, progress, stored),
|
|
progressDiv: null,
|
|
downloadBtn: null,
|
|
category: progress.category || stored.category || '',
|
|
subfolder: stored.subfolder || '',
|
|
filename: progress.filename || stored.filename || 'Download',
|
|
downloadPath: progress.path || stored.downloadPath || '',
|
|
downloadDirectory: progress.directory || stored.downloadDirectory || '',
|
|
baseDirectory: stored.baseDirectory || '',
|
|
sourceUrl: progress.url || stored.sourceUrl || '',
|
|
downloadBackend: progress.download_backend || stored.downloadBackend || '',
|
|
lastProgress: progress,
|
|
lastStatus: status || 'starting'
|
|
};
|
|
this.activeDownloads[downloadId] = info;
|
|
this.rememberDownloadUiState?.(downloadId, info, progress, { isActive: true });
|
|
this.persistActiveDownloadRecovery?.(downloadId, info);
|
|
this.pollDownloadProgress(downloadId);
|
|
restored += 1;
|
|
}
|
|
|
|
for (const downloadId of Object.keys(recovery)) {
|
|
if (!activeIds.has(String(downloadId))) {
|
|
this.removeActiveDownloadRecovery?.(downloadId);
|
|
}
|
|
}
|
|
|
|
this.rebindActiveDownloadMissingModels();
|
|
this.updateDownloadAllButtonState?.();
|
|
this.updateQueuePanel?.({ force: true });
|
|
return restored;
|
|
})();
|
|
|
|
try {
|
|
return await this._activeDownloadsRestorePromise;
|
|
} finally {
|
|
this._activeDownloadsRestorePromise = null;
|
|
}
|
|
},
|
|
|
|
async executeDownloadRequest(missing, {
|
|
url,
|
|
filename,
|
|
category,
|
|
subfolder = '',
|
|
baseDirectory = '',
|
|
pathMetadata = null,
|
|
downloadMetadata = null,
|
|
btn = null
|
|
}) {
|
|
const progressId = this.getDownloadProgressElementId(missing);
|
|
const progressDiv = this.contentElement?.querySelector(`#${progressId}`);
|
|
const tokens = this.getStoredTokens();
|
|
const workflowContext = this.getActiveWorkflowTabContext?.() || {};
|
|
const workflowKey = this.getWorkflowScopedQueueKey?.() || '';
|
|
const workflowRouteKey = workflowContext.workflowRouteKey || this.getActiveWorkflowRouteKey?.() || this.activeWorkflowRouteKey || '';
|
|
const workflowLabel = workflowContext.workflowLabel || this.getActiveWorkflowDownloadLabel?.() || 'Current workflow';
|
|
const workflowId = workflowContext.workflowId || this.getActiveWorkflowId?.() || '';
|
|
const workflowSignature = workflowContext.workflowSignature || this.activeWorkflowSignature || '';
|
|
const workflowTabId = workflowContext.workflowTabId || '';
|
|
const workflowTabName = workflowContext.workflowTabName || '';
|
|
const workflowTabAriaControls = workflowContext.workflowTabAriaControls || '';
|
|
const workflowTabText = workflowContext.workflowTabText || '';
|
|
|
|
try {
|
|
const startingSnapshot = this.rememberDownloadSnapshotForMissing(missing, {
|
|
downloadId: null,
|
|
category,
|
|
subfolder,
|
|
filename,
|
|
downloadPath: '',
|
|
downloadDirectory: '',
|
|
baseDirectory,
|
|
sourceUrl: url,
|
|
workflowKey,
|
|
workflowRouteKey,
|
|
workflowLabel,
|
|
workflowSignature,
|
|
workflowId,
|
|
workflowTabId,
|
|
workflowTabName,
|
|
workflowTabAriaControls,
|
|
workflowTabText,
|
|
progress: { status: 'starting', progress: 0, filename },
|
|
status: 'starting',
|
|
isActive: true
|
|
});
|
|
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
|
if (btn.classList.contains('search-download-btn')) {
|
|
btn.innerHTML = getSvgIcon('download');
|
|
btn.setAttribute('data-tooltip', 'Starting download...');
|
|
btn.setAttribute('aria-label', 'Starting download');
|
|
} else {
|
|
btn.textContent = 'Starting...';
|
|
}
|
|
}
|
|
|
|
if (progressDiv) {
|
|
this.renderDownloadSnapshot(null, startingSnapshot, { progressDiv, downloadBtn: btn });
|
|
}
|
|
|
|
// Start download
|
|
const data = await this.fetchJson('/model_resolver/download', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
url,
|
|
filename,
|
|
category,
|
|
subfolder,
|
|
base_directory: baseDirectory,
|
|
path_metadata: pathMetadata,
|
|
download_metadata: downloadMetadata,
|
|
hf_token: tokens.hf_token,
|
|
civitai_key: tokens.civitai_key,
|
|
civitai_session_token: tokens.civitai_session_token
|
|
})
|
|
}, 'Start download');
|
|
|
|
if (!data.success) {
|
|
throw new Error(data.error || 'Download failed');
|
|
}
|
|
|
|
const downloadId = data.download_id;
|
|
this.activeDownloads[downloadId] = {
|
|
missing,
|
|
progressDiv,
|
|
downloadBtn: btn,
|
|
category,
|
|
subfolder,
|
|
filename,
|
|
downloadPath: data.path || '',
|
|
downloadDirectory: data.directory || '',
|
|
baseDirectory,
|
|
sourceUrl: url,
|
|
workflowKey,
|
|
workflowRouteKey,
|
|
workflowLabel,
|
|
workflowSignature,
|
|
workflowId,
|
|
workflowTabId,
|
|
workflowTabName,
|
|
workflowTabAriaControls,
|
|
workflowTabText,
|
|
downloadBackend: data.download_backend || ''
|
|
};
|
|
this.persistActiveDownloadRecovery?.(downloadId, this.activeDownloads[downloadId]);
|
|
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
this.activeDownloads[downloadId],
|
|
{
|
|
status: 'starting',
|
|
progress: 0,
|
|
filename,
|
|
path: data.path || '',
|
|
directory: data.directory || '',
|
|
download_backend: data.download_backend || ''
|
|
},
|
|
{ isActive: true }
|
|
);
|
|
|
|
this.updateDownloadAllButtonState();
|
|
this.updateQueuePanel?.();
|
|
this.renderDownloadSnapshot(downloadId, snapshot);
|
|
|
|
this.pollDownloadProgress(downloadId);
|
|
|
|
} catch (error) {
|
|
console.error('Model Resolver: Download error:', error);
|
|
const snapshot = this.rememberDownloadSnapshotForMissing(missing, {
|
|
downloadId: null,
|
|
category,
|
|
filename,
|
|
downloadPath: '',
|
|
downloadDirectory: '',
|
|
baseDirectory,
|
|
sourceUrl: url,
|
|
workflowKey,
|
|
workflowRouteKey,
|
|
workflowLabel,
|
|
workflowSignature,
|
|
workflowId,
|
|
workflowTabId,
|
|
workflowTabName,
|
|
workflowTabAriaControls,
|
|
workflowTabText,
|
|
progress: { status: 'error', filename, error: error.message },
|
|
status: 'error',
|
|
message: error.message,
|
|
type: 'error',
|
|
isActive: false
|
|
});
|
|
|
|
if (progressDiv) {
|
|
this.renderDownloadSnapshot(null, snapshot, { progressDiv, downloadBtn: btn });
|
|
}
|
|
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
|
if (btn.classList.contains('search-download-btn')) {
|
|
btn.innerHTML = getSvgIcon('download');
|
|
btn.setAttribute('data-tooltip', 'Retry download');
|
|
btn.setAttribute('aria-label', 'Retry download');
|
|
} else {
|
|
btn.innerHTML = '<span class="mr-btn-icon">☁</span> Retry';
|
|
}
|
|
}
|
|
|
|
this.updateQueuePanel?.();
|
|
this.showNotification('Download failed: ' + error.message, 'error');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Download a model from a known source
|
|
*/
|
|
async downloadModel(missing) {
|
|
const source = missing.download_source;
|
|
if (!source || !source.url) {
|
|
this.showNotification('No download URL available', 'error');
|
|
return;
|
|
}
|
|
|
|
const originalFilename = this.getFilenameFromPath(missing.original_path) || 'model.safetensors';
|
|
const filename = source.filename || originalFilename;
|
|
const targetSelection = this.getDownloadTargetSelection(missing, source.directory || missing.category || 'checkpoints');
|
|
const downloadBtn = this.contentElement?.querySelector(`#${this.getDownloadButtonElementId(missing)}`);
|
|
|
|
const pathMetadata = this.getDownloadPathMetadata(missing, source);
|
|
const downloadMetadata = this.getDownloadMetadata(missing, source, {
|
|
filename,
|
|
category: targetSelection.category,
|
|
url: source.url,
|
|
pathMetadata
|
|
});
|
|
|
|
await this.executeDownloadRequest(missing, {
|
|
url: source.url,
|
|
filename,
|
|
category: targetSelection.category,
|
|
subfolder: targetSelection.subfolder,
|
|
baseDirectory: targetSelection.baseDirectory || '',
|
|
pathMetadata,
|
|
downloadMetadata,
|
|
btn: downloadBtn
|
|
});
|
|
},
|
|
|
|
setPendingDownloadStatus(info, status, durationMs = 8000) {
|
|
if (!info) return;
|
|
info.pendingDownloadStatus = status;
|
|
info.pendingDownloadStatusStartedAt = Date.now();
|
|
info.pendingDownloadStatusUntil = Date.now() + durationMs;
|
|
},
|
|
|
|
clearPendingDownloadStatus(info) {
|
|
if (!info) return;
|
|
delete info.pendingDownloadStatus;
|
|
delete info.pendingDownloadStatusStartedAt;
|
|
delete info.pendingDownloadStatusUntil;
|
|
},
|
|
|
|
applyPendingDownloadStatus(info, progress = {}) {
|
|
if (!info?.pendingDownloadStatus) return progress;
|
|
const desiredStatus = info.pendingDownloadStatus;
|
|
const currentStatus = progress.status || '';
|
|
const terminalStatuses = new Set(['completed', 'completed_checking', 'error', 'cancelled', 'cancelling']);
|
|
|
|
if (terminalStatuses.has(currentStatus)) {
|
|
this.clearPendingDownloadStatus(info);
|
|
return progress;
|
|
}
|
|
|
|
if (
|
|
currentStatus === desiredStatus &&
|
|
Date.now() - Number(info.pendingDownloadStatusStartedAt || 0) > 1200
|
|
) {
|
|
this.clearPendingDownloadStatus(info);
|
|
return progress;
|
|
}
|
|
|
|
if (Date.now() > Number(info.pendingDownloadStatusUntil || 0)) {
|
|
this.clearPendingDownloadStatus(info);
|
|
return progress;
|
|
}
|
|
|
|
const canHoldDesiredStatus = (
|
|
desiredStatus === 'downloading' && currentStatus === 'paused'
|
|
) || (
|
|
desiredStatus === 'paused' && (currentStatus === 'downloading' || currentStatus === 'starting')
|
|
) || (
|
|
desiredStatus === 'cancelling' && ['starting', 'downloading', 'paused'].includes(currentStatus)
|
|
);
|
|
if (!canHoldDesiredStatus) return progress;
|
|
|
|
return {
|
|
...progress,
|
|
backend_status: currentStatus,
|
|
status: desiredStatus,
|
|
speed: desiredStatus === 'paused' || desiredStatus === 'cancelling' ? 0 : progress.speed
|
|
};
|
|
},
|
|
|
|
async pollDownloadProgress(downloadId) {
|
|
const info = this.activeDownloads[downloadId];
|
|
if (!info) return;
|
|
|
|
try {
|
|
let progress = await this.fetchJson(`/model_resolver/progress/${downloadId}`, { silent: true }, 'Get download progress');
|
|
// Cancel may have completed while this request was in flight. Never
|
|
// let a stale progress response recreate a globally finalized bar.
|
|
if (this.activeDownloads?.[downloadId] !== info) return;
|
|
progress = this.applyPendingDownloadStatus(info, progress);
|
|
const snapshot = this.rememberDownloadUiState(downloadId, info, progress, { isActive: true });
|
|
const { progressDiv, downloadBtn } = this.resolveDownloadUiElements(info);
|
|
const { missing, category } = info;
|
|
const downloadFolderContext = this.getDownloadFolderContext(progress, info);
|
|
|
|
if (progress.status === 'downloading' || progress.status === 'starting' || progress.status === 'paused') {
|
|
this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn });
|
|
if (typeof this.requestQueuePanelUpdate === 'function') {
|
|
this.requestQueuePanelUpdate();
|
|
} else {
|
|
this.updateQueuePanel?.();
|
|
}
|
|
|
|
// Continue polling
|
|
const pollDelay = this.getDownloadProgressPollDelay(progress, info);
|
|
setTimeout(() => this.pollDownloadProgress(downloadId), pollDelay);
|
|
|
|
} else if (progress.status === 'cancelling') {
|
|
this.renderDownloadSnapshot(downloadId, snapshot, { progressDiv, downloadBtn });
|
|
this.updateQueuePanel?.();
|
|
setTimeout(() => this.pollDownloadProgress(downloadId), 500);
|
|
|
|
} else if (progress.status === 'completed') {
|
|
const alreadyExists = Boolean(progress.already_exists);
|
|
const completedSnapshot = this.rememberDownloadUiState(downloadId, info, progress, {
|
|
status: 'completed_checking',
|
|
message: alreadyExists
|
|
? (progress.message || 'This model is already downloaded. Checking local matches...')
|
|
: 'Download complete. Checking local matches...',
|
|
type: 'success',
|
|
isActive: false
|
|
});
|
|
this.renderDownloadSnapshot(downloadId, completedSnapshot, { progressDiv, downloadBtn });
|
|
this.rememberDownloadSubfolderAfterCompletion(info, progress);
|
|
this.rememberCompletedDownloadHistory?.(downloadId, info, progress);
|
|
delete this.activeDownloads[downloadId];
|
|
this.removeActiveDownloadRecovery?.(downloadId);
|
|
this.updateDownloadAllButtonState();
|
|
this.updateQueuePanel?.();
|
|
this.refreshLocalMatchesUiForMissing?.(missing);
|
|
if (!alreadyExists) playDownloadSuccessSound();
|
|
this.showNotification(alreadyExists
|
|
? `Already downloaded: ${progress.filename}`
|
|
: `Downloaded: ${progress.filename}`, 'success', {
|
|
contextMenuModel: downloadFolderContext
|
|
});
|
|
|
|
// Refresh local matches only for this downloaded missing model.
|
|
// Small delay to ensure file system is updated.
|
|
setTimeout(async () => {
|
|
const currentElements = this.resolveDownloadUiElements(info);
|
|
await this.refreshAfterDownload(missing, progress.filename, {
|
|
progressDiv: currentElements.progressDiv,
|
|
downloadBtn: currentElements.downloadBtn,
|
|
category,
|
|
downloadPath: progress.path || info.downloadPath || '',
|
|
downloadDirectory: progress.directory || info.downloadDirectory || '',
|
|
workflowScope: this.getDownloadWorkflowScopeIdentity(info),
|
|
alreadyExists
|
|
});
|
|
}, 500);
|
|
|
|
} else if (progress.status === 'error') {
|
|
const errorSnapshot = this.rememberDownloadUiState(downloadId, info, progress, {
|
|
status: 'error',
|
|
message: progress.error || 'Download failed',
|
|
type: 'error',
|
|
isActive: false
|
|
});
|
|
this.renderDownloadSnapshot(downloadId, errorSnapshot, { progressDiv, downloadBtn });
|
|
delete this.activeDownloads[downloadId];
|
|
this.removeActiveDownloadRecovery?.(downloadId);
|
|
this.updateDownloadAllButtonState();
|
|
this.updateQueuePanel?.();
|
|
this.refreshLocalMatchesUiForMissing?.(missing);
|
|
|
|
} else if (progress.status === 'cancelled') {
|
|
this.finalizeCancelledDownloadFrontend?.(downloadId, info, progress);
|
|
this.showNotification('Download cancelled', 'info');
|
|
|
|
} else {
|
|
// Unknown status, keep polling
|
|
setTimeout(() => this.pollDownloadProgress(downloadId), 500);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Model Resolver: Progress poll error:', error);
|
|
const info = this.activeDownloads[downloadId];
|
|
// Update UI to show error state instead of just disappearing
|
|
if (info) {
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
{ ...(info.lastProgress || {}), status: 'error' },
|
|
{
|
|
status: 'error',
|
|
message: 'Connection lost - download may have failed',
|
|
type: 'error',
|
|
isActive: false
|
|
}
|
|
);
|
|
this.renderDownloadSnapshot(downloadId, snapshot);
|
|
}
|
|
delete this.activeDownloads[downloadId];
|
|
this.removeActiveDownloadRecovery?.(downloadId);
|
|
this.updateDownloadAllButtonState();
|
|
this.updateQueuePanel?.();
|
|
}
|
|
},
|
|
|
|
restoreDownloadButtonReadyState(downloadBtn) {
|
|
if (!downloadBtn) return;
|
|
downloadBtn.disabled = false;
|
|
downloadBtn.classList.remove('mr-is-success-action', 'mr-btn-primary');
|
|
if (downloadBtn.classList.contains('search-download-btn')) {
|
|
downloadBtn.innerHTML = getSvgIcon('download');
|
|
downloadBtn.setAttribute('data-tooltip', 'Download');
|
|
downloadBtn.setAttribute('aria-label', 'Download');
|
|
return;
|
|
}
|
|
downloadBtn.innerHTML = '<span class="mr-btn-icon">☁</span> Download';
|
|
},
|
|
|
|
/**
|
|
* Cancel an active download
|
|
*/
|
|
cancelDownload(downloadId) {
|
|
const info = this.activeDownloads[downloadId];
|
|
if (info) {
|
|
this.setPendingDownloadStatus(info, 'cancelling', 30000);
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
{ ...(info.lastProgress || {}), status: 'cancelling' },
|
|
{
|
|
status: 'cancelling',
|
|
message: 'Cancelling download...',
|
|
type: 'info',
|
|
isActive: true
|
|
}
|
|
);
|
|
const elements = this.resolveDownloadUiElements(info);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
|
this.updateQueuePanel?.();
|
|
}
|
|
|
|
this.fetchJson(`/model_resolver/cancel/${downloadId}`, {
|
|
method: 'POST'
|
|
}, 'Cancel download').then(() => {
|
|
// The request only acknowledges cancellation. Progress polling
|
|
// releases the file and enables retry when the worker has stopped.
|
|
}).catch((error) => {
|
|
this.clearPendingDownloadStatus(info);
|
|
console.error('Model Resolver: Cancel error:', error);
|
|
this.showNotification('Failed to cancel download', 'error');
|
|
});
|
|
},
|
|
|
|
pauseDownload(downloadId) {
|
|
const info = this.activeDownloads[downloadId];
|
|
const currentStatus = String(
|
|
info?.pendingDownloadStatus
|
|
|| info?.lastStatus
|
|
|| info?.lastProgress?.status
|
|
|| ''
|
|
).toLowerCase();
|
|
if (currentStatus === 'cancelling' || currentStatus === 'cancelled') {
|
|
if (info) {
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
{ ...(info.lastProgress || {}), status: 'cancelling', speed: 0 },
|
|
{
|
|
status: 'cancelling',
|
|
message: 'Cancelling download...',
|
|
type: 'info',
|
|
isActive: true
|
|
}
|
|
);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, this.resolveDownloadUiElements(info));
|
|
}
|
|
return;
|
|
}
|
|
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
|
|
if (info) {
|
|
this.setPendingDownloadStatus(info, 'paused');
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
{ ...(info.lastProgress || {}), status: 'paused', speed: 0 },
|
|
{
|
|
status: 'paused',
|
|
message: 'Download paused.',
|
|
type: 'info',
|
|
isActive: true
|
|
}
|
|
);
|
|
const elements = this.resolveDownloadUiElements(info);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
|
this.updateQueuePanel?.();
|
|
}
|
|
|
|
this.fetchJson(`/model_resolver/pause/${downloadId}`, {
|
|
method: 'POST'
|
|
}, 'Pause download').then(() => {
|
|
this.showNotification('Download paused', 'info');
|
|
}).catch((error) => {
|
|
console.error('Model Resolver: Pause error:', error);
|
|
const cancellationInProgress = /being cancelled|cancell?ing/i.test(String(error?.message || ''));
|
|
if (info && cancellationInProgress) {
|
|
this.setPendingDownloadStatus(info, 'cancelling', 30000);
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
{ ...(info.lastProgress || {}), status: 'cancelling', speed: 0 },
|
|
{
|
|
status: 'cancelling',
|
|
message: 'Cancelling download...',
|
|
type: 'info',
|
|
isActive: true
|
|
}
|
|
);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, this.resolveDownloadUiElements(info));
|
|
this.updateQueuePanel?.();
|
|
return;
|
|
}
|
|
if (info && previousProgress) {
|
|
this.clearPendingDownloadStatus(info);
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
previousProgress,
|
|
{
|
|
status: previousProgress.status || 'downloading',
|
|
type: 'error',
|
|
isActive: true
|
|
}
|
|
);
|
|
const elements = this.resolveDownloadUiElements(info);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
|
this.updateQueuePanel?.();
|
|
}
|
|
this.showNotification(error.message || 'Failed to pause download', 'error');
|
|
});
|
|
},
|
|
|
|
resumeDownload(downloadId) {
|
|
const info = this.activeDownloads[downloadId];
|
|
const previousProgress = info?.lastProgress ? { ...info.lastProgress } : null;
|
|
if (info) {
|
|
this.setPendingDownloadStatus(info, 'downloading');
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
{ ...(info.lastProgress || {}), status: 'downloading' },
|
|
{
|
|
status: 'downloading',
|
|
message: 'Download resumed.',
|
|
type: 'info',
|
|
isActive: true
|
|
}
|
|
);
|
|
const elements = this.resolveDownloadUiElements(info);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
|
this.updateQueuePanel?.();
|
|
window.setTimeout(() => this.pollDownloadProgress(downloadId), 250);
|
|
}
|
|
|
|
this.fetchJson(`/model_resolver/resume/${downloadId}`, {
|
|
method: 'POST'
|
|
}, 'Resume download').then(() => {
|
|
this.showNotification('Download resumed', 'success');
|
|
}).catch((error) => {
|
|
console.error('Model Resolver: Resume error:', error);
|
|
if (info && previousProgress) {
|
|
this.clearPendingDownloadStatus(info);
|
|
const snapshot = this.rememberDownloadUiState(
|
|
downloadId,
|
|
info,
|
|
previousProgress,
|
|
{
|
|
status: previousProgress.status || 'paused',
|
|
type: 'error',
|
|
isActive: true
|
|
}
|
|
);
|
|
const elements = this.resolveDownloadUiElements(info);
|
|
this.renderDownloadSnapshot(downloadId, snapshot, elements);
|
|
this.updateQueuePanel?.();
|
|
}
|
|
this.showNotification(error.message || 'Failed to resume download', 'error');
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Add a user-provided provider URL to the cached search results.
|
|
*/
|
|
async addCustomUrlResult(missing, inputEl, addBtn, { workflowKey = this.getWorkflowScopedQueueKey() } = {}) {
|
|
const url = String(inputEl?.value || '').trim();
|
|
if (!url) {
|
|
this.showNotification?.('Paste a URL first.', 'warning');
|
|
inputEl?.focus?.();
|
|
return;
|
|
}
|
|
|
|
const state = this.getSearchStateForWorkflow(workflowKey, missing);
|
|
const tokens = this.getStoredTokens();
|
|
const originalButtonHtml = addBtn?.innerHTML || '';
|
|
if (addBtn) {
|
|
addBtn.disabled = true;
|
|
addBtn.innerHTML = this.renderLinkNameActionContent?.('link', 'Adding')
|
|
|| this.renderCustomUrlButtonContent?.('Adding')
|
|
|| 'Adding';
|
|
}
|
|
if (inputEl) {
|
|
inputEl.disabled = true;
|
|
}
|
|
|
|
try {
|
|
const filename = this.getFilenameFromPath(missing.original_path);
|
|
const data = await this.fetchJson('/model_resolver/custom-url', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
url,
|
|
filename,
|
|
original_path: missing.original_path || '',
|
|
category: missing.category || this.getNodeTypeDownloadCategory?.(missing.node_type) || '',
|
|
civitai_key: tokens.civitai_key,
|
|
hf_token: tokens.hf_token
|
|
}),
|
|
silent: true
|
|
}, 'Add custom URL');
|
|
|
|
const results = (Array.isArray(data?.custom) ? data.custom : [data?.result])
|
|
.filter(result => result && typeof result === 'object');
|
|
if (!results.length) {
|
|
throw new Error('The URL did not resolve to a downloadable model.');
|
|
}
|
|
|
|
const searchedAt = new Date().toISOString();
|
|
const customResults = results.map(result => (
|
|
this.withSearchResultTimestamp?.(result, result.searched_at || searchedAt) || result
|
|
));
|
|
const customResult = customResults[0];
|
|
const currentResults = state.results || this.createEmptySearchState().results;
|
|
const existingCustom = Array.isArray(currentResults.custom) ? currentResults.custom : [];
|
|
const nextCustom = [...existingCustom];
|
|
for (const item of customResults) {
|
|
const signature = this.getSearchResultSignature(item) || item.version_url || item.download_url || url;
|
|
const existingIndex = nextCustom.findIndex(existing => (
|
|
(this.getSearchResultSignature(existing) || existing.version_url || existing.download_url || '') === signature
|
|
));
|
|
if (existingIndex >= 0) nextCustom[existingIndex] = item;
|
|
else nextCustom.push(item);
|
|
}
|
|
const localHashMatches = this.mergeLocalMatches
|
|
? this.mergeLocalMatches(
|
|
Array.isArray(currentResults.local_hash_matches) ? currentResults.local_hash_matches : [],
|
|
Array.isArray(data.local_hash_matches) ? data.local_hash_matches : []
|
|
)
|
|
: [
|
|
...(Array.isArray(currentResults.local_hash_matches) ? currentResults.local_hash_matches : []),
|
|
...(Array.isArray(data.local_hash_matches) ? data.local_hash_matches : [])
|
|
];
|
|
|
|
state.results = {
|
|
...currentResults,
|
|
custom: nextCustom,
|
|
local_hash_matches: localHashMatches
|
|
};
|
|
if (state.removedSearchResultKeys?.length) {
|
|
const restoredKeys = new Set(customResults.map(item => {
|
|
const customRow = this.getCustomUrlResultTableRow(missing, item);
|
|
if (!customRow) return '';
|
|
const row = this.prepareSearchResultRow(missing, customRow);
|
|
return `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
|
|
}));
|
|
state.removedSearchResultKeys = state.removedSearchResultKeys.filter(key => !restoredKeys.has(key));
|
|
}
|
|
state.lastAttemptSources = Array.from(new Set([
|
|
...(Array.isArray(state.lastAttemptSources) ? state.lastAttemptSources : []),
|
|
'custom'
|
|
]));
|
|
state.lastAttemptFound = true;
|
|
state.lastAttemptError = null;
|
|
|
|
if (customResult.source === 'civitai' || customResult.source === 'civarchive') {
|
|
missing.civitai_search_result = {
|
|
base_model: customResult.base_model,
|
|
tags: customResult.tags || [],
|
|
trained_words: customResult.trained_words || [],
|
|
filename: customResult.filename,
|
|
name: customResult.name,
|
|
type: customResult.type
|
|
};
|
|
}
|
|
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
this.applySearchResultSuggestion?.(missing);
|
|
if (inputEl) inputEl.value = '';
|
|
const unresolvedCount = data?.unresolved_version_ids?.length || 0;
|
|
this.showNotification?.(
|
|
unresolvedCount
|
|
? `Added ${customResults.length} models. ${unresolvedCount} linked versions could not be resolved.`
|
|
: customResults.length > 1 ? `Added ${customResults.length} models.` : 'Link added.',
|
|
unresolvedCount ? 'warning' : 'success'
|
|
);
|
|
} catch (error) {
|
|
console.error('Model Resolver: custom URL add failed:', error);
|
|
this.showNotification?.(error.message || 'Failed to add link.', 'error');
|
|
} finally {
|
|
if (addBtn) {
|
|
addBtn.disabled = false;
|
|
addBtn.innerHTML = originalButtonHtml
|
|
|| this.renderLinkNameActionContent?.('link', 'Add')
|
|
|| this.renderCustomUrlButtonContent?.('Add')
|
|
|| 'Add';
|
|
}
|
|
if (inputEl) {
|
|
inputEl.disabled = false;
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Search online for a model
|
|
*/
|
|
async searchOnline(missing, { workflowKey = this.getWorkflowScopedQueueKey(), forceSearch = false, source = null, searchQuery = '', scheduleSource = null } = {}) {
|
|
const providedSearchQuery = String(searchQuery || '').trim();
|
|
const searchSha256 = normalizeSha256(providedSearchQuery);
|
|
const state = this.getSearchStateForWorkflow(workflowKey, missing);
|
|
const requestedSearchQuery = searchSha256 ? '' : providedSearchQuery;
|
|
let filename = requestedSearchQuery || this.getFilenameFromPath(missing.original_path);
|
|
let category = missing.category || this.getNodeTypeDownloadCategory?.(missing.node_type) || '';
|
|
if (providedSearchQuery) {
|
|
state.inputMode = 'name';
|
|
state.manualSearchQuery = providedSearchQuery;
|
|
state.lastSearchMode = searchSha256 ? 'sha256' : 'name';
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
}
|
|
const missingSearchKey = this.getMissingSearchKey(missing);
|
|
const backgroundJobKey = this.getBackgroundSearchJobKey(workflowKey, missingSearchKey);
|
|
const activeJob = this.backgroundSearchJobs?.get(backgroundJobKey);
|
|
if (activeJob?.promise) {
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
return activeJob.promise;
|
|
}
|
|
const requestedSource = String(source || '').trim();
|
|
let selectedSource = requestedSource || state.selectedSource || 'all';
|
|
if (!requestedSource && selectedSource !== 'all' && !this.isSearchSourceUsable(selectedSource)) {
|
|
selectedSource = 'all';
|
|
state.selectedSource = 'all';
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
}
|
|
const selectedSourceLabel = this.getSearchSourceLabel(selectedSource);
|
|
const sourceIds = requestedSource
|
|
? (selectedSource === 'all' ? [] : [selectedSource])
|
|
: this.getSearchSourcesForSelection(selectedSource, missing);
|
|
const baseModelContext = this.getSearchBaseModelContext(missing);
|
|
const selectedBaseModelAtStart = state.selectedBaseModel || this.getDefaultSearchBaseModel?.() || 'auto';
|
|
|
|
// For URNs, use the resolved file/model name for searching instead of the URN itself
|
|
// and pass the URN type as category (CivitAI expects specific type names)
|
|
if (missing.is_urn && !requestedSearchQuery && !searchSha256) {
|
|
const urnSearchName = missing.civitai_info?.expected_filename
|
|
|| missing.download_source?.filename
|
|
|| missing.civitai_info?.model_name;
|
|
if (urnSearchName) {
|
|
filename = urnSearchName;
|
|
}
|
|
// Pass URN type directly - CivitAPI expects types like 'Upscaler', 'Checkpoint'
|
|
const urnType = missing.urn_type || '';
|
|
if (urnType) {
|
|
// Map URN types to CivitAI type names
|
|
const typeMap = {
|
|
'checkpoint': 'Checkpoint',
|
|
'lora': 'LORA',
|
|
'vae': 'VAE',
|
|
'upscaler': 'Upscaler',
|
|
'upscale_model': 'Upscaler',
|
|
'embedding': 'TextualInversion',
|
|
'controlnet': 'Controlnet'
|
|
};
|
|
const civitaiType = typeMap[urnType.toLowerCase()];
|
|
if (civitaiType) {
|
|
category = civitaiType;
|
|
}
|
|
}
|
|
}
|
|
|
|
const isUrn = Boolean(missing.is_urn && !requestedSearchQuery && !searchSha256);
|
|
const resultsId = `search-results-${this.getMissingModelDomKey(missing)}`;
|
|
const canUpdateCurrentWorkflow = workflowKey === this.getWorkflowScopedQueueKey()
|
|
&& (!this.selectedMissingModelKey || this.getMissingModelKey(missing) === this.selectedMissingModelKey);
|
|
const resultsDiv = canUpdateCurrentWorkflow
|
|
? this.contentElement?.querySelector(`#${resultsId}`)
|
|
: null;
|
|
const searchBtn = canUpdateCurrentWorkflow
|
|
? this.contentElement?.querySelector(`#search-${this.getMissingModelDomKey(missing)}`)
|
|
: null;
|
|
let searchRunId = null;
|
|
let completedSearchRun = false;
|
|
const isCurrentSearchRun = () => (
|
|
!searchRunId
|
|
|| this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)
|
|
);
|
|
|
|
try {
|
|
if (!sourceIds.length) {
|
|
throw new Error(`${selectedSourceLabel} is not available in this install`);
|
|
}
|
|
|
|
this.clearSearchProgressTimers(state.activeSearchRunId);
|
|
searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const backgroundJob = {
|
|
workflowKey,
|
|
missingSearchKey,
|
|
runId: searchRunId,
|
|
missing,
|
|
startedAt: Date.now(),
|
|
sourceControllers: new Map(),
|
|
sourceProgressIds: new Map(),
|
|
cancelledSources: new Set(),
|
|
promise: null
|
|
};
|
|
this.backgroundSearchJobs.set(backgroundJobKey, backgroundJob);
|
|
state.activeSearchRunId = searchRunId;
|
|
state.explicitSearchSources = Array.from(new Set([
|
|
...(Array.isArray(state.explicitSearchSources) ? state.explicitSearchSources : []),
|
|
...sourceIds
|
|
]));
|
|
state.lastAttemptSources = sourceIds;
|
|
state.lastAttemptFound = null;
|
|
state.lastAttemptError = null;
|
|
state.lastAttemptBaseModelContext = baseModelContext;
|
|
if (forceSearch) {
|
|
state.results = this.clearSearchResultsForSources?.(state.results || {}, sourceIds)
|
|
|| this.createEmptySearchState().results;
|
|
}
|
|
state.sourceProgress = {};
|
|
for (const source of sourceIds) {
|
|
this.setSourceProgress(state, source, {
|
|
status: scheduleSource ? 'pending' : 'running',
|
|
percent: scheduleSource ? 0 : 6,
|
|
message: scheduleSource ? 'Queued' : 'Searching...',
|
|
startedAt: scheduleSource ? null : Date.now(),
|
|
estimateMs: this.getSearchSourceEstimateMs(source, isUrn)
|
|
}, missing, { workflowKey });
|
|
}
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
|
|
if (searchBtn) {
|
|
searchBtn.disabled = true;
|
|
searchBtn.innerHTML = this.renderSearchButtonContent(`Searching ${selectedSourceLabel}...`);
|
|
}
|
|
if (canUpdateCurrentWorkflow) this.syncLinkNameActionUi?.(missing, this.contentElement, state);
|
|
if (resultsDiv) {
|
|
resultsDiv.classList.remove('mr-is-hidden');
|
|
resultsDiv.classList.add('mr-is-visible');
|
|
this.displaySearchResults(missing, state, resultsDiv);
|
|
}
|
|
|
|
|
|
// For URNs, include model_id and version_id for direct download
|
|
const tokens = this.getStoredTokens();
|
|
const baseSearchData = {
|
|
filename,
|
|
sha256: searchSha256,
|
|
search_mode: searchSha256 ? 'sha256' : 'name',
|
|
category,
|
|
is_urn: isUrn,
|
|
base_model_context: baseModelContext,
|
|
civitai_key: tokens.civitai_key,
|
|
civitai_session_token: tokens.civitai_session_token,
|
|
civitai_candidate_limit: tokens.civitai_candidate_limit,
|
|
civitai_use_trpc_search: tokens.civitai_use_trpc_search,
|
|
civitai_use_api_search: tokens.civitai_use_api_search,
|
|
civitai_use_html_fallback: tokens.civitai_use_html_fallback,
|
|
hf_token: tokens.hf_token,
|
|
brave_search_api_key: tokens.brave_search_api_key,
|
|
hf_use_api_search: tokens.hf_use_api_search,
|
|
hf_use_comfy_org_fallback: tokens.hf_use_comfy_org_fallback,
|
|
hf_use_brave_fallback: tokens.hf_use_brave_fallback,
|
|
force_search: Boolean(forceSearch)
|
|
};
|
|
if (isUrn && missing.urn) {
|
|
baseSearchData.model_id = missing.urn.model_id;
|
|
baseSearchData.version_id = missing.urn.version_id;
|
|
}
|
|
|
|
const attemptedSources = new Set();
|
|
const sourceErrorMessages = new Set();
|
|
const notifiedSourceErrors = new Set();
|
|
let anyFound = false;
|
|
let hadError = false;
|
|
|
|
const runSource = async (source) => {
|
|
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)
|
|
|| this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) {
|
|
return { source, cancelled: true };
|
|
}
|
|
this.setSourceProgress(state, source, {
|
|
status: 'running',
|
|
percent: 6,
|
|
message: 'Searching...',
|
|
startedAt: Date.now()
|
|
}, missing, { workflowKey });
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
this.startEstimatedSearchProgress(state, missing, resultsDiv, source, searchRunId, { workflowKey });
|
|
const sourceIsUrn = isUrn && source !== 'lora_manager_archive';
|
|
const progressId = `${searchRunId}-${source}-${Math.random().toString(36).slice(2, 8)}`;
|
|
const searchData = {
|
|
...baseSearchData,
|
|
is_urn: sourceIsUrn,
|
|
sources: [source],
|
|
progress_id: progressId,
|
|
progress_source: source
|
|
};
|
|
const sourceController = typeof AbortController !== 'undefined'
|
|
? new AbortController()
|
|
: null;
|
|
const currentJob = this.backgroundSearchJobs.get(backgroundJobKey);
|
|
if (sourceController && currentJob?.runId === searchRunId) {
|
|
currentJob.sourceControllers?.set(source, sourceController);
|
|
}
|
|
if (currentJob?.runId === searchRunId) {
|
|
currentJob.sourceProgressIds?.set(source, progressId);
|
|
}
|
|
this.startBackendSearchProgressPolling?.(state, missing, source, searchRunId, progressId, { workflowKey });
|
|
|
|
try {
|
|
if (this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) {
|
|
return { source, cancelled: true };
|
|
}
|
|
|
|
log.debug('Model Resolver: Search request:', JSON.stringify(searchData));
|
|
|
|
const data = await this.fetchJson('/model_resolver/search', {
|
|
method: 'POST',
|
|
body: JSON.stringify(searchData),
|
|
signal: sourceController?.signal,
|
|
silent: true
|
|
}, 'Search source');
|
|
log.debug('Model Resolver: Search response:', JSON.stringify(data));
|
|
|
|
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) {
|
|
return { source, stale: true };
|
|
}
|
|
if (this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source)) {
|
|
return { source, cancelled: true };
|
|
}
|
|
const currentSelectedBaseModel = state.selectedBaseModel || this.getDefaultSearchBaseModel?.() || 'auto';
|
|
if (currentSelectedBaseModel !== selectedBaseModelAtStart) {
|
|
return { source, stale: true };
|
|
}
|
|
|
|
const responseSources = Array.isArray(data.searched_sources) && data.searched_sources.length
|
|
? data.searched_sources
|
|
: [source];
|
|
responseSources.forEach(responseSource => attemptedSources.add(responseSource));
|
|
const sourceErrors = data.source_errors && typeof data.source_errors === 'object'
|
|
? data.source_errors
|
|
: {};
|
|
const sourceStatuses = data.source_status && typeof data.source_status === 'object'
|
|
? data.source_status
|
|
: {};
|
|
const sourceError = sourceErrors[source]
|
|
|| responseSources.map(responseSource => sourceErrors[responseSource]).find(Boolean)
|
|
|| null;
|
|
const sourceStatus = sourceStatuses[source]
|
|
|| responseSources.map(responseSource => sourceStatuses[responseSource]).find(Boolean)
|
|
|| null;
|
|
const hasSourceError = Boolean(sourceError || sourceStatus?.state);
|
|
const sourceErrorMessage = sourceError
|
|
? (this.getSearchSourceErrorMessage?.(source, sourceError, sourceStatus) || sourceError)
|
|
: (sourceStatus
|
|
? (this.getSearchSourceErrorMessage?.(source, '', sourceStatus) || sourceStatus.message)
|
|
: null);
|
|
const sourceErrorTooltip = sourceError
|
|
? (this.getSearchSourceErrorTooltip?.(source, sourceError, sourceStatus) || sourceErrorMessage)
|
|
: (sourceStatus
|
|
? (this.getSearchSourceErrorTooltip?.(source, '', sourceStatus) || sourceErrorMessage)
|
|
: null);
|
|
if (sourceErrorMessage) sourceErrorMessages.add(sourceErrorMessage);
|
|
if (sourceErrorMessage && !notifiedSourceErrors.has(source)) {
|
|
this.showNotification?.(
|
|
sourceErrorMessage,
|
|
sourceStatus?.retryable ? 'warning' : 'error'
|
|
);
|
|
notifiedSourceErrors.add(source);
|
|
}
|
|
|
|
const found = this.hasSearchResults(data);
|
|
const foundViaAnyModel = this.isAnyModelSearchResult?.(data[source]);
|
|
const foundMessage = foundViaAnyModel ? 'Found (Any Model)' : 'Found';
|
|
anyFound = anyFound || found;
|
|
hadError = hadError || hasSourceError;
|
|
state.results = this.mergeSearchResults(state.results, data, {
|
|
searchedAt: new Date().toISOString(),
|
|
forceRefresh: Boolean(forceSearch)
|
|
});
|
|
this.applyLocalHashMatchesFromSearchResponse(missing, data, { workflowKey });
|
|
state.lastAttemptSources = Array.from(attemptedSources);
|
|
state.lastAttemptFound = anyFound;
|
|
this.clearSearchProgressTimer(searchRunId, source);
|
|
this.clearBackendSearchProgressTimer?.(searchRunId, source);
|
|
this.setSourceProgress(state, source, {
|
|
status: hasSourceError ? 'error' : (found ? 'found' : 'none'),
|
|
percent: 100,
|
|
message: sourceErrorMessage || (found ? foundMessage : 'No match'),
|
|
error: sourceErrorTooltip,
|
|
providerState: sourceStatus?.state || null,
|
|
providerCode: sourceStatus?.code || null,
|
|
providerMessage: sourceStatus?.message || null,
|
|
providerHttpStatus: sourceStatus?.http_status || null,
|
|
retryable: Boolean(sourceStatus?.retryable)
|
|
}, missing, { workflowKey });
|
|
|
|
if (data.civitai) {
|
|
missing.civitai_search_result = {
|
|
base_model: data.civitai.base_model,
|
|
tags: data.civitai.tags || [],
|
|
trained_words: data.civitai.trained_words || [],
|
|
filename: data.civitai.filename,
|
|
name: data.civitai.name,
|
|
type: data.civitai.type
|
|
};
|
|
}
|
|
if (data.civarchive) {
|
|
missing.civitai_search_result = {
|
|
base_model: data.civarchive.base_model,
|
|
tags: data.civarchive.tags || [],
|
|
trained_words: data.civarchive.trained_words || [],
|
|
filename: data.civarchive.filename,
|
|
name: data.civarchive.name,
|
|
type: data.civarchive.type
|
|
};
|
|
}
|
|
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
this.applySearchResultSuggestion(missing);
|
|
return { source, data };
|
|
} catch (error) {
|
|
const wasCancelled = error?.name === 'AbortError'
|
|
|| this.isSearchSourceCancelled?.(workflowKey, missingSearchKey, searchRunId, source);
|
|
if (wasCancelled) {
|
|
this.clearSearchProgressTimer(searchRunId, source);
|
|
this.clearBackendSearchProgressTimer?.(searchRunId, source);
|
|
return { source, cancelled: true };
|
|
}
|
|
|
|
console.error(`Model Resolver: Search error for ${source}:`, error);
|
|
if (!this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) {
|
|
return { source, stale: true, error };
|
|
}
|
|
|
|
hadError = true;
|
|
attemptedSources.add(source);
|
|
const sourceErrorMessage = this.getSearchSourceErrorMessage?.(source, error.message)
|
|
|| error.message
|
|
|| 'Error';
|
|
const sourceErrorTooltip = this.getSearchSourceErrorTooltip?.(source, error.message)
|
|
|| sourceErrorMessage;
|
|
const retryable = Boolean(this.isSearchSourceRetryable?.(source, error.message));
|
|
sourceErrorMessages.add(sourceErrorMessage);
|
|
if (!notifiedSourceErrors.has(source)) {
|
|
this.showNotification?.(sourceErrorMessage, 'error');
|
|
notifiedSourceErrors.add(source);
|
|
}
|
|
state.lastAttemptSources = Array.from(attemptedSources);
|
|
state.lastAttemptFound = anyFound;
|
|
this.clearSearchProgressTimer(searchRunId, source);
|
|
this.clearBackendSearchProgressTimer?.(searchRunId, source);
|
|
this.setSourceProgress(state, source, {
|
|
status: 'error',
|
|
percent: 100,
|
|
message: sourceErrorMessage,
|
|
error: sourceErrorTooltip,
|
|
retryable
|
|
}, missing, { workflowKey });
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
return { source, error };
|
|
} finally {
|
|
const cleanupJob = this.backgroundSearchJobs.get(backgroundJobKey);
|
|
if (cleanupJob?.runId === searchRunId) {
|
|
cleanupJob.sourceControllers?.delete(source);
|
|
cleanupJob.sourceProgressIds?.delete(source);
|
|
}
|
|
}
|
|
};
|
|
const searchPromises = sourceIds.map(source => (
|
|
scheduleSource
|
|
? scheduleSource(source, () => runSource(source))
|
|
: runSource(source)
|
|
));
|
|
|
|
const currentJob = this.backgroundSearchJobs.get(backgroundJobKey);
|
|
if (currentJob?.runId === searchRunId) {
|
|
currentJob.promise = Promise.all(searchPromises);
|
|
}
|
|
await Promise.all(searchPromises);
|
|
this.clearSearchProgressTimers(searchRunId);
|
|
this.clearBackendSearchProgressTimers?.(searchRunId);
|
|
|
|
if (this.isBackgroundSearchRunActive(workflowKey, missingSearchKey, searchRunId)) {
|
|
for (const source of sourceIds) {
|
|
const progress = state.sourceProgress?.[source];
|
|
if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue;
|
|
attemptedSources.add(source);
|
|
this.setSourceProgress(state, source, {
|
|
status: 'none',
|
|
percent: 100,
|
|
message: 'No match',
|
|
error: null
|
|
}, missing, { workflowKey });
|
|
}
|
|
state.activeSearchRunId = null;
|
|
state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds;
|
|
state.lastAttemptFound = anyFound;
|
|
state.lastAttemptError = hadError && !anyFound
|
|
? (sourceErrorMessages.size === 1
|
|
? Array.from(sourceErrorMessages)[0]
|
|
: 'Search finished with errors. Check source statuses above.')
|
|
: null;
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
completedSearchRun = true;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Model Resolver: Search error:', error);
|
|
this.clearSearchProgressTimers(searchRunId);
|
|
this.clearBackendSearchProgressTimers?.(searchRunId);
|
|
if (searchRunId && !isCurrentSearchRun()) return;
|
|
state.lastAttemptError = error.message;
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
if (resultsDiv && resultsDiv.isConnected !== false) {
|
|
this.patchSearchResultsContainer?.(
|
|
resultsDiv,
|
|
this.renderStatusMessage(`Search failed: ${error.message}`, 'error')
|
|
);
|
|
}
|
|
} finally {
|
|
const currentSearchRun = isCurrentSearchRun();
|
|
if (!searchRunId || currentSearchRun) {
|
|
this.clearSearchProgressTimers(searchRunId);
|
|
this.clearBackendSearchProgressTimers?.(searchRunId);
|
|
state.activeSearchRunId = null;
|
|
}
|
|
const currentJob = this.backgroundSearchJobs?.get(backgroundJobKey);
|
|
if (currentJob?.runId === searchRunId) {
|
|
this.backgroundSearchJobs.delete(backgroundJobKey);
|
|
}
|
|
for (const [jobKey, job] of Array.from(this.backgroundSearchJobs?.entries?.() || [])) {
|
|
if (job?.runId === searchRunId && job?.missingSearchKey === missingSearchKey) {
|
|
this.backgroundSearchJobs.delete(jobKey);
|
|
}
|
|
}
|
|
if (!completedSearchRun && currentSearchRun) {
|
|
this.settleInactiveSearchProgress?.(missing, state, {
|
|
workflowKey,
|
|
message: state.lastAttemptError || 'Search interrupted',
|
|
persist: false,
|
|
refresh: false
|
|
});
|
|
}
|
|
if (currentSearchRun && searchBtn && searchBtn.isConnected !== false) {
|
|
searchBtn.disabled = false;
|
|
searchBtn.innerHTML = this.renderSearchButtonContent('Search Again');
|
|
}
|
|
if (currentSearchRun) {
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
this.refreshSearchUiForMissing(missing, state, { workflowKey });
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Resolve URN asynchronously - fetch CivitAI info and update UI
|
|
*/
|
|
async resolveUrnAsync(modelId, versionId, loadingElementId, modelUrl) {
|
|
log.debug('resolveUrnAsync called:', modelId, versionId);
|
|
if (!modelId || !versionId) {
|
|
log.debug('resolveUrnAsync: missing modelId or versionId');
|
|
return;
|
|
}
|
|
|
|
if (!(this.urnResolveUiTokens instanceof Map)) {
|
|
this.urnResolveUiTokens = new Map();
|
|
}
|
|
const uiTokenKey = String(loadingElementId || `urn:${modelId}:${versionId}`);
|
|
const uiToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
this.urnResolveUiTokens.set(uiTokenKey, uiToken);
|
|
const isCurrentUrnUi = () => this.urnResolveUiTokens.get(uiTokenKey) === uiToken;
|
|
|
|
try {
|
|
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
|
|
const missing = this.missingModels.find(m =>
|
|
`urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId
|
|
);
|
|
|
|
let data = null;
|
|
if (missing) {
|
|
data = await this.resolveUrnDataForMissing(missing);
|
|
} else {
|
|
const tokens = this.getStoredTokens();
|
|
const payload = {
|
|
filename: `${modelId}_${versionId}`,
|
|
category: '',
|
|
is_urn: true,
|
|
sources: ['civitai'],
|
|
model_id: modelId,
|
|
version_id: versionId,
|
|
civitai_candidate_limit: tokens.civitai_candidate_limit
|
|
};
|
|
log.debug('resolveUrnAsync payload:', JSON.stringify(payload));
|
|
|
|
data = await this.fetchJson('/model_resolver/search', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
silent: true
|
|
}, 'Resolve URN');
|
|
log.debug('resolveUrnAsync search completed');
|
|
}
|
|
|
|
if (data && isCurrentUrnUi()) {
|
|
const loadingEl = document.getElementById(loadingElementId);
|
|
if (loadingEl && data.civitai) {
|
|
loadingEl.classList.remove('mr-is-muted', 'mr-is-error');
|
|
const civitai = data.civitai;
|
|
const labelHtml = this.renderVersionedModelNameHtml(civitai.name, civitai.version_name)
|
|
|| this.escapeHtml(civitai.filename || 'Model');
|
|
const url = modelUrl || getCivitaiModelUrl(modelId, versionId);
|
|
loadingEl.innerHTML = `<a href="${url}" target="_blank" class="mr-inline-civitai-link">${labelHtml}</a>`;
|
|
} else if (loadingEl) {
|
|
loadingEl.textContent = 'Not found';
|
|
loadingEl.classList.remove('mr-is-error');
|
|
loadingEl.classList.add('mr-is-muted');
|
|
}
|
|
|
|
const downloadEl = document.getElementById(downloadContainerId);
|
|
if (downloadEl && data.civitai?.download_url) {
|
|
if (missing) {
|
|
this.applyCivitaiUrnResult(missing, data.civitai);
|
|
this.refreshSearchBaseModelLabels?.();
|
|
downloadEl.innerHTML = this.renderKnownDownloadPanel(missing, missing.download_source);
|
|
this.wireDownloadSearchPanel(downloadEl, missing);
|
|
this.refreshUrnLocalMatches(missing);
|
|
}
|
|
} else if (downloadEl) {
|
|
downloadEl.innerHTML = `<div class="mr-download-info">Unable to resolve direct download for this URN.</div>`;
|
|
}
|
|
} else if (data) {
|
|
return;
|
|
} else {
|
|
const loadingEl = document.getElementById(loadingElementId);
|
|
if (loadingEl) {
|
|
loadingEl.textContent = 'Error';
|
|
loadingEl.classList.remove('mr-is-muted');
|
|
loadingEl.classList.add('mr-is-error');
|
|
}
|
|
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
|
|
const downloadEl = document.getElementById(downloadContainerId);
|
|
if (downloadEl) {
|
|
downloadEl.innerHTML = `<div class="mr-download-info">Failed to resolve URN download.</div>`;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Model Resolver: URN resolve error:', error);
|
|
if (!isCurrentUrnUi()) return;
|
|
const loadingEl = document.getElementById(loadingElementId);
|
|
if (loadingEl) {
|
|
loadingEl.textContent = 'Error';
|
|
loadingEl.classList.remove('mr-is-muted');
|
|
loadingEl.classList.add('mr-is-error');
|
|
}
|
|
const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
|
|
const downloadEl = document.getElementById(downloadContainerId);
|
|
if (downloadEl) {
|
|
downloadEl.innerHTML = `<div class="mr-download-info">Failed to resolve URN download.</div>`;
|
|
}
|
|
} finally {
|
|
if (isCurrentUrnUi()) {
|
|
this.urnResolveUiTokens.delete(uiTokenKey);
|
|
}
|
|
}
|
|
},
|
|
|
|
wireSearchDownloadButtons(container, missing) {
|
|
if (!container) return;
|
|
|
|
this.wireSearchHashMatchHighlights?.(container);
|
|
bindEventOnce(container, 'click', (event) => {
|
|
const btn = event.target?.closest?.(
|
|
'.search-download-btn, .search-open-page-btn, .search-show-details-btn, .search-toggle-remove-btn, .search-remove-result-btn'
|
|
);
|
|
if (!btn || !container.contains(btn)) return;
|
|
|
|
if (btn.matches('.search-toggle-remove-btn, .search-remove-result-btn')) {
|
|
const workflowKey = this.getWorkflowScopedQueueKey();
|
|
const state = this.getSearchStateForWorkflow(workflowKey, missing);
|
|
if (btn.classList.contains('search-toggle-remove-btn')) {
|
|
state.showSearchResultRemoval = !state.showSearchResultRemoval;
|
|
} else {
|
|
if (!state.showSearchResultRemoval) return;
|
|
const key = btn.closest('tr')?.dataset.searchResultKey;
|
|
if (!key) return;
|
|
state.removedSearchResultKeys = Array.from(new Set([
|
|
...(state.removedSearchResultKeys || []), decodeURIComponent(key)
|
|
]));
|
|
}
|
|
this.persistSearchStateForWorkflow(workflowKey, missing, state);
|
|
const resultsContainer = btn.closest('.mr-search-results-table-wrap')?.parentElement;
|
|
this.displaySearchResults(missing, state, resultsContainer);
|
|
return;
|
|
}
|
|
|
|
if (btn.classList.contains('search-download-btn')) {
|
|
const url = btn.dataset.url;
|
|
const filename = btn.dataset.filename;
|
|
const category = btn.dataset.category;
|
|
let pathMetadata;
|
|
try {
|
|
pathMetadata = btn.dataset.pathMetadata
|
|
? JSON.parse(decodeURIComponent(btn.dataset.pathMetadata))
|
|
: null;
|
|
} catch (_error) {
|
|
pathMetadata = null;
|
|
}
|
|
let downloadMetadata;
|
|
try {
|
|
downloadMetadata = btn.dataset.downloadMetadata
|
|
? JSON.parse(decodeURIComponent(btn.dataset.downloadMetadata))
|
|
: null;
|
|
} catch (_error) {
|
|
downloadMetadata = null;
|
|
}
|
|
this.downloadFromSearch(missing, url, filename, category, btn, pathMetadata, downloadMetadata);
|
|
return;
|
|
}
|
|
|
|
if (btn.classList.contains('search-open-page-btn')) {
|
|
const url = btn.dataset.url;
|
|
if (!url) return;
|
|
|
|
const opened = window.open(url, '_blank', 'noopener,noreferrer');
|
|
if (opened) {
|
|
opened.opener = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const model = JSON.parse(decodeURIComponent(btn.dataset.model || ''));
|
|
this.showSourceModelDetails(model);
|
|
} catch (error) {
|
|
console.error('Model Resolver: failed to open model details:', error);
|
|
this.showNotification?.('Failed to open model details.', 'error');
|
|
}
|
|
}, 'search-result-actions');
|
|
},
|
|
|
|
wireDownloadSearchPanel(container, missing) {
|
|
if (!container) return;
|
|
|
|
const searchResultsId = `search-results-${this.getMissingModelDomKey(missing)}`;
|
|
const searchResultsDiv = container.querySelector(`#${searchResultsId}`);
|
|
if (searchResultsDiv) {
|
|
this.wireSearchDownloadButtons(searchResultsDiv, missing);
|
|
}
|
|
|
|
const searchBtn = container.querySelector(`#search-${this.getMissingModelDomKey(missing)}`);
|
|
if (searchBtn && searchBtn.dataset.mlSearchBound !== 'true') {
|
|
searchBtn.dataset.mlSearchBound = 'true';
|
|
searchBtn.addEventListener('click', () => {
|
|
this.searchOnline(missing, {
|
|
forceSearch: this.hasSearchAttemptForMissing?.(missing) || this.hasSearchResultsForMissing(missing)
|
|
});
|
|
});
|
|
}
|
|
|
|
const customUrlInput = container.querySelector(`#custom-url-${missing.node_id}-${missing.widget_index}`);
|
|
const linkNameControl = container.querySelector(`#link-name-control-${missing.node_id}-${missing.widget_index}`);
|
|
const searchActionBtn = container.querySelector(`#link-name-action-${missing.node_id}-${missing.widget_index}`);
|
|
const workflowKey = this.getWorkflowScopedQueueKey();
|
|
const state = this.getSearchStateForWorkflow?.(workflowKey, missing) || this.getSearchState?.(missing);
|
|
const persistInputState = () => {
|
|
if (!state) return;
|
|
this.persistSearchStateForWorkflow?.(workflowKey, missing, state);
|
|
};
|
|
const syncInputControl = () => this.syncLinkNameActionUi?.(missing, container, state);
|
|
|
|
if (customUrlInput && linkNameControl && searchActionBtn) {
|
|
const modeInput = linkNameControl.querySelector('.mr-link-name-toggle-input');
|
|
const setInputMode = (mode, { focus = false } = {}) => {
|
|
const nextMode = mode === 'name' ? 'name' : 'link';
|
|
const currentMode = linkNameControl.dataset.mode === 'name' ? 'name' : 'link';
|
|
if (currentMode === 'name' && state) {
|
|
state.manualSearchQuery = String(customUrlInput.value || '').trim();
|
|
}
|
|
linkNameControl.dataset.mode = nextMode;
|
|
if (state) state.inputMode = nextMode;
|
|
customUrlInput.value = nextMode === 'name'
|
|
? String(state?.manualSearchQuery || '')
|
|
: '';
|
|
persistInputState();
|
|
syncInputControl();
|
|
if (focus) customUrlInput.focus();
|
|
};
|
|
|
|
if (modeInput && modeInput.dataset.mlInputModeBound !== 'true') {
|
|
modeInput.dataset.mlInputModeBound = 'true';
|
|
modeInput.addEventListener('change', () => {
|
|
setInputMode(modeInput.checked ? 'link' : 'name', { focus: true });
|
|
});
|
|
}
|
|
|
|
if (searchActionBtn.dataset.mlLinkNameActionBound !== 'true') {
|
|
searchActionBtn.dataset.mlLinkNameActionBound = 'true';
|
|
const submitAction = () => {
|
|
const mode = linkNameControl.dataset.mode === 'name' ? 'name' : 'link';
|
|
if (mode === 'name') {
|
|
const query = String(customUrlInput.value || '').trim();
|
|
if (!query) {
|
|
this.showNotification?.('Enter a model name first.', 'warning');
|
|
customUrlInput.focus();
|
|
return;
|
|
}
|
|
if (state) {
|
|
state.inputMode = 'name';
|
|
state.manualSearchQuery = query;
|
|
}
|
|
persistInputState();
|
|
this.searchOnline(missing, {
|
|
searchQuery: query,
|
|
forceSearch: this.hasSearchAttemptForMissing?.(missing) || this.hasSearchResultsForMissing(missing)
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (state) {
|
|
state.inputMode = 'link';
|
|
state.manualSearchQuery = '';
|
|
}
|
|
persistInputState();
|
|
this.addCustomUrlResult(missing, customUrlInput, searchActionBtn);
|
|
};
|
|
searchActionBtn.addEventListener('click', submitAction);
|
|
customUrlInput.addEventListener('input', () => {
|
|
if (state && linkNameControl.dataset.mode === 'name') {
|
|
state.manualSearchQuery = String(customUrlInput.value || '');
|
|
}
|
|
});
|
|
customUrlInput.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Enter') return;
|
|
event.preventDefault();
|
|
submitAction();
|
|
});
|
|
}
|
|
syncInputControl();
|
|
}
|
|
|
|
const sourceSelect = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
|
|
const sourceList = container.querySelector(`#search-source-list-${missing.node_id}-${missing.widget_index}`);
|
|
if (sourceSelect && sourceList && sourceSelect.dataset.mlSearchSourceBound !== 'true') {
|
|
sourceSelect.dataset.mlSearchSourceBound = 'true';
|
|
|
|
const renderSourceOptions = () => {
|
|
const options = this.getSearchSourceOptions();
|
|
sourceList.innerHTML = options
|
|
.map(option => `<div class="mr-download-target-option" data-value="${encodeURIComponent(option.value)}" data-label="${encodeURIComponent(option.label)}">${this.escapeHtml(option.label)}</div>`)
|
|
.join('');
|
|
this.showDropdownList?.(sourceList, sourceSelect);
|
|
sourceList.querySelectorAll('.mr-download-target-option').forEach(optionEl => {
|
|
optionEl.addEventListener('mousedown', (event) => {
|
|
event.preventDefault();
|
|
const value = decodeURIComponent(optionEl.dataset.value || '');
|
|
const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || '');
|
|
this.setDropdownValue(sourceSelect, value, label);
|
|
this.setSearchSource(missing, value, container);
|
|
this.hideDropdownList(sourceList);
|
|
});
|
|
});
|
|
};
|
|
|
|
this.enableWheelScrollChaining(sourceList);
|
|
this.bindDropdownOutsideDismiss?.(sourceList, [sourceSelect]);
|
|
sourceSelect.addEventListener('focus', () => renderSourceOptions());
|
|
sourceSelect.addEventListener('click', () => renderSourceOptions());
|
|
sourceSelect.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
renderSourceOptions();
|
|
}
|
|
});
|
|
this.syncSearchSourceUi(missing, container);
|
|
}
|
|
|
|
const baseSelect = container.querySelector(`#search-base-select-${missing.node_id}-${missing.widget_index}`);
|
|
const baseList = container.querySelector(`#search-base-list-${missing.node_id}-${missing.widget_index}`);
|
|
if (baseSelect && baseList && baseSelect.dataset.mlSearchBaseBound !== 'true') {
|
|
baseSelect.dataset.mlSearchBaseBound = 'true';
|
|
|
|
const renderBaseOptions = (filterText = '') => {
|
|
const filter = String(filterText || '').trim().toLowerCase();
|
|
const options = this.getKnownBaseModelOptions()
|
|
.map(option => {
|
|
const label = option.value === 'auto'
|
|
? this.getSearchBaseModelLabel('auto', missing)
|
|
: option.label;
|
|
return { ...option, label };
|
|
})
|
|
.filter(option => matchesSearchText(`${option.value} ${option.label}`, filter));
|
|
baseList.innerHTML = options
|
|
.map(option => {
|
|
const label = option.label;
|
|
return `<div class="mr-download-target-option" data-value="${encodeURIComponent(option.value)}" data-label="${encodeURIComponent(label)}">${this.escapeHtml(label)}</div>`;
|
|
})
|
|
.join('');
|
|
if (options.length) {
|
|
this.showDropdownList?.(baseList, baseSelect);
|
|
} else {
|
|
this.hideDropdownList(baseList);
|
|
}
|
|
baseList.querySelectorAll('.mr-download-target-option').forEach(optionEl => {
|
|
optionEl.addEventListener('mousedown', (event) => {
|
|
event.preventDefault();
|
|
const value = decodeURIComponent(optionEl.dataset.value || '');
|
|
const label = decodeURIComponent(optionEl.dataset.label || optionEl.dataset.value || '');
|
|
this.setDropdownValue(baseSelect, value, label);
|
|
this.setSearchBaseModel(missing, value, container);
|
|
this.hideDropdownList(baseList);
|
|
});
|
|
});
|
|
};
|
|
|
|
this.enableWheelScrollChaining(baseList);
|
|
this.bindDropdownOutsideDismiss?.(baseList, [baseSelect], () => {
|
|
const value = this.getDropdownValue(baseSelect);
|
|
this.setDropdownValue(baseSelect, value, this.getSearchBaseModelLabel(value, missing));
|
|
this.hideDropdownList(baseList);
|
|
});
|
|
baseSelect.addEventListener('focus', () => renderBaseOptions(''));
|
|
baseSelect.addEventListener('click', () => renderBaseOptions(''));
|
|
baseSelect.addEventListener('input', () => {
|
|
const value = this.getSearchBaseModelInputValue(baseSelect.value, missing);
|
|
baseSelect.dataset.value = value;
|
|
this.setSearchBaseModel(missing, value, container, { sync: false });
|
|
renderBaseOptions(baseSelect.value);
|
|
});
|
|
baseSelect.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Enter' || event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
renderBaseOptions(baseSelect.value);
|
|
}
|
|
});
|
|
baseSelect.addEventListener('blur', () => {
|
|
const value = this.getDropdownValue(baseSelect);
|
|
this.setDropdownValue(baseSelect, value, this.getSearchBaseModelLabel(value, missing));
|
|
});
|
|
this.syncSearchSourceUi(missing, container);
|
|
}
|
|
|
|
this.wireDownloadTargetAutocomplete(container, missing);
|
|
},
|
|
|
|
syncSearchElementAttributes(current, next) {
|
|
return syncElementAttributes(current, next);
|
|
},
|
|
|
|
patchSearchProgressActions(currentItem, nextItem) {
|
|
const currentHost = currentItem.querySelector('.mr-search-progress-head') || currentItem;
|
|
const nextHost = nextItem.querySelector('.mr-search-progress-head') || nextItem;
|
|
const getActionKey = (button) => {
|
|
if (button.classList.contains('mr-search-progress-cancel')) return 'cancel';
|
|
if (button.classList.contains('mr-search-progress-retry')) return 'retry';
|
|
return '';
|
|
};
|
|
const currentButtons = Array.from(currentHost.children)
|
|
.filter(child => child.tagName === 'BUTTON')
|
|
.reduce((buttons, button) => {
|
|
const key = getActionKey(button);
|
|
if (key) buttons.set(key, button);
|
|
return buttons;
|
|
}, new Map());
|
|
const nextButtons = Array.from(nextHost.children)
|
|
.filter(child => child.tagName === 'BUTTON');
|
|
const retainedButtons = new Set();
|
|
|
|
for (const nextButton of nextButtons) {
|
|
const key = getActionKey(nextButton);
|
|
let currentButton = key ? currentButtons.get(key) : null;
|
|
if (!currentButton) {
|
|
currentButton = nextButton.cloneNode(true);
|
|
} else {
|
|
this.syncSearchElementAttributes(currentButton, nextButton);
|
|
if (currentButton.innerHTML !== nextButton.innerHTML) {
|
|
currentButton.innerHTML = nextButton.innerHTML;
|
|
}
|
|
}
|
|
retainedButtons.add(currentButton);
|
|
currentHost.appendChild(currentButton);
|
|
}
|
|
|
|
for (const currentButton of currentButtons.values()) {
|
|
if (!retainedButtons.has(currentButton)) {
|
|
currentButton.remove();
|
|
}
|
|
}
|
|
},
|
|
|
|
patchSearchProgressItem(current, next) {
|
|
const currentHead = current.querySelector('.mr-search-progress-head');
|
|
const nextHead = next.querySelector('.mr-search-progress-head');
|
|
const currentBar = current.querySelector('.mr-search-progress-bar');
|
|
const nextBar = next.querySelector('.mr-search-progress-bar');
|
|
if (
|
|
Boolean(currentHead) !== Boolean(nextHead)
|
|
|| Boolean(currentBar) !== Boolean(nextBar)
|
|
) {
|
|
return next.cloneNode(true);
|
|
}
|
|
|
|
const currentSource = current.querySelector('.mr-search-progress-source');
|
|
const nextSource = next.querySelector('.mr-search-progress-source');
|
|
const currentStatus = current.querySelector('.mr-search-progress-status');
|
|
const nextStatus = next.querySelector('.mr-search-progress-status');
|
|
if (!currentSource || !nextSource || !currentStatus || !nextStatus) {
|
|
return next.cloneNode(true);
|
|
}
|
|
|
|
this.syncSearchElementAttributes(current, next);
|
|
if (currentSource.textContent !== nextSource.textContent) {
|
|
currentSource.textContent = nextSource.textContent;
|
|
}
|
|
if (currentStatus.textContent !== nextStatus.textContent) {
|
|
currentStatus.textContent = nextStatus.textContent;
|
|
}
|
|
|
|
if (currentBar && nextBar) {
|
|
this.syncSearchElementAttributes(currentBar, nextBar);
|
|
const currentFill = currentBar.querySelector('.mr-search-progress-fill');
|
|
const nextFill = nextBar.querySelector('.mr-search-progress-fill');
|
|
if (!currentFill || !nextFill) return next.cloneNode(true);
|
|
this.syncSearchElementAttributes(currentFill, nextFill);
|
|
}
|
|
|
|
this.patchSearchProgressActions(current, next);
|
|
return current;
|
|
},
|
|
|
|
patchSearchProgressElement(current, next) {
|
|
const currentIsCompact = current.classList.contains('mr-search-progress-list-compact');
|
|
const nextIsCompact = next.classList.contains('mr-search-progress-list-compact');
|
|
if (currentIsCompact !== nextIsCompact) {
|
|
return next.cloneNode(true);
|
|
}
|
|
|
|
const currentItems = Array.from(current.children)
|
|
.filter(child => child.classList.contains('mr-search-progress-item'));
|
|
const nextItems = Array.from(next.children)
|
|
.filter(child => child.classList.contains('mr-search-progress-item'));
|
|
if (currentItems.some(item => !item.dataset.searchProgressSource)) {
|
|
return next.cloneNode(true);
|
|
}
|
|
|
|
const currentBySource = new Map(
|
|
currentItems.map(item => [item.dataset.searchProgressSource, item])
|
|
);
|
|
const retainedItems = new Set();
|
|
const nextDomItems = [];
|
|
|
|
this.syncSearchElementAttributes(current, next);
|
|
for (const nextItem of nextItems) {
|
|
const source = nextItem.dataset.searchProgressSource;
|
|
let currentItem = currentBySource.get(source);
|
|
if (!currentItem) {
|
|
currentItem = nextItem.cloneNode(true);
|
|
} else {
|
|
currentItem = this.patchSearchProgressItem(currentItem, nextItem);
|
|
}
|
|
retainedItems.add(currentItem);
|
|
nextDomItems.push(currentItem);
|
|
}
|
|
|
|
for (const currentItem of currentItems) {
|
|
if (!retainedItems.has(currentItem)) {
|
|
currentItem.remove();
|
|
}
|
|
}
|
|
nextDomItems.forEach(item => current.appendChild(item));
|
|
return current;
|
|
},
|
|
|
|
patchSearchResultsTable(current, next) {
|
|
const currentTable = current.querySelector('.mr-search-results-table');
|
|
const nextTable = next.querySelector('.mr-search-results-table');
|
|
const currentBody = currentTable?.querySelector('tbody');
|
|
const nextBody = nextTable?.querySelector('tbody');
|
|
if (!currentTable || !nextTable || !currentBody || !nextBody) {
|
|
return next.cloneNode(true);
|
|
}
|
|
|
|
const currentRows = Array.from(currentBody.children)
|
|
.filter(child => child.tagName === 'TR');
|
|
const nextRows = Array.from(nextBody.children)
|
|
.filter(child => child.tagName === 'TR');
|
|
if (
|
|
currentRows.some(row => !row.dataset.searchResultKey)
|
|
|| nextRows.some(row => !row.dataset.searchResultKey)
|
|
) {
|
|
return next.cloneNode(true);
|
|
}
|
|
|
|
const currentByKey = new Map(
|
|
currentRows.map(row => [row.dataset.searchResultKey, row])
|
|
);
|
|
const retainedRows = new Set();
|
|
const nextDomRows = [];
|
|
|
|
this.syncSearchElementAttributes(current, next);
|
|
this.syncSearchElementAttributes(currentTable, nextTable);
|
|
const currentHead = currentTable.querySelector('thead');
|
|
const nextHead = nextTable.querySelector('thead');
|
|
if (currentHead && nextHead && currentHead.innerHTML !== nextHead.innerHTML) {
|
|
currentHead.innerHTML = nextHead.innerHTML;
|
|
}
|
|
for (const nextRow of nextRows) {
|
|
const key = nextRow.dataset.searchResultKey;
|
|
let currentRow = currentByKey.get(key);
|
|
if (!currentRow) {
|
|
currentRow = nextRow.cloneNode(true);
|
|
} else {
|
|
this.syncSearchElementAttributes(currentRow, nextRow);
|
|
if (currentRow.innerHTML !== nextRow.innerHTML) {
|
|
currentRow.innerHTML = nextRow.innerHTML;
|
|
}
|
|
}
|
|
retainedRows.add(currentRow);
|
|
nextDomRows.push(currentRow);
|
|
}
|
|
|
|
for (const currentRow of currentRows) {
|
|
if (!retainedRows.has(currentRow)) {
|
|
currentRow.remove();
|
|
}
|
|
}
|
|
nextDomRows.forEach(row => currentBody.appendChild(row));
|
|
return current;
|
|
},
|
|
|
|
getSearchResultsPatchKind(element) {
|
|
if (element.classList.contains('mr-search-progress-list')) return 'progress';
|
|
if (element.classList.contains('mr-search-results-table-wrap')) return 'table';
|
|
if (element.classList.contains('mr-status')) return 'status';
|
|
return 'other';
|
|
},
|
|
|
|
patchSearchResultsContainer(container, renderedHtml = '') {
|
|
if (!container) return;
|
|
const documentRef = container.ownerDocument || globalThis.document;
|
|
if (!documentRef?.createElement) {
|
|
container.innerHTML = renderedHtml;
|
|
return;
|
|
}
|
|
|
|
const template = documentRef.createElement('template');
|
|
template.innerHTML = renderedHtml || '';
|
|
const nextChildren = Array.from(template.content.children);
|
|
const currentChildren = Array.from(container.children);
|
|
|
|
if (!nextChildren.length) {
|
|
currentChildren.forEach(child => child.remove());
|
|
Array.from(container.childNodes)
|
|
.filter(node => node.nodeType === 3)
|
|
.forEach(node => node.remove());
|
|
return;
|
|
}
|
|
if (!currentChildren.length) {
|
|
container.innerHTML = renderedHtml;
|
|
return;
|
|
}
|
|
|
|
Array.from(container.childNodes)
|
|
.filter(node => node.nodeType === 3 && !node.textContent.trim())
|
|
.forEach(node => node.remove());
|
|
|
|
const currentByKind = new Map();
|
|
for (const child of currentChildren) {
|
|
const kind = this.getSearchResultsPatchKind(child);
|
|
if (!currentByKind.has(kind)) currentByKind.set(kind, child);
|
|
}
|
|
|
|
const retainedChildren = new Set();
|
|
const nextDomChildren = [];
|
|
for (const nextChild of nextChildren) {
|
|
const kind = this.getSearchResultsPatchKind(nextChild);
|
|
const currentChild = currentByKind.get(kind);
|
|
let nextDomChild;
|
|
if (!currentChild) {
|
|
nextDomChild = nextChild.cloneNode(true);
|
|
} else if (kind === 'progress') {
|
|
nextDomChild = this.patchSearchProgressElement(currentChild, nextChild);
|
|
} else if (kind === 'table') {
|
|
nextDomChild = this.patchSearchResultsTable(currentChild, nextChild);
|
|
} else if (kind === 'status') {
|
|
nextDomChild = currentChild.outerHTML === nextChild.outerHTML
|
|
? currentChild
|
|
: nextChild.cloneNode(true);
|
|
} else {
|
|
nextDomChild = nextChild.cloneNode(true);
|
|
}
|
|
retainedChildren.add(nextDomChild);
|
|
nextDomChildren.push(nextDomChild);
|
|
}
|
|
|
|
for (const child of currentChildren) {
|
|
if (!retainedChildren.has(child)) child.remove();
|
|
}
|
|
nextDomChildren.forEach(child => container.appendChild(child));
|
|
},
|
|
|
|
/**
|
|
* Display search results
|
|
*/
|
|
displaySearchResults(missing, state, container) {
|
|
if (!container) return;
|
|
|
|
const results = state?.results || {};
|
|
const popular = results.popular;
|
|
const modelListResult = results.model_list;
|
|
const hfResult = results.huggingface ? (Array.isArray(results.huggingface) ? results.huggingface[0] : results.huggingface) : null;
|
|
const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null;
|
|
const civarchiveResult = results.civarchive ? (Array.isArray(results.civarchive) ? results.civarchive[0] : results.civarchive) : null;
|
|
const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null;
|
|
const customResults = Array.isArray(results.custom)
|
|
? results.custom.filter(result => result && typeof result === 'object')
|
|
: [];
|
|
const localHashMatches = [
|
|
...(Array.isArray(results.local_hash_matches) ? results.local_hash_matches : []),
|
|
...(Array.isArray(missing.matches) ? missing.matches.filter(match => match?.hash_lookup_source) : [])
|
|
];
|
|
const hashLabelMap = this.getHashMatchLabelMap?.(missing, results) || null;
|
|
const knownDownloadRow = this.shouldDisplayKnownDownloadSource(missing, missing.download_source, state)
|
|
? this.getDownloadSourceTableRow(missing, missing.download_source, hashLabelMap)
|
|
: null;
|
|
const hasResults = knownDownloadRow || popular || modelListResult || hfResult || civitaiResult || civarchiveResult || loraManagerArchiveResult || customResults.length;
|
|
const progressHtml = this.renderSearchProgress(state);
|
|
const hasActiveProgress = this.hasActiveSearchProgress(state);
|
|
|
|
if (!hasResults) {
|
|
if (hasActiveProgress) {
|
|
this.patchSearchResultsContainer(container, progressHtml);
|
|
this.wireSearchProgressRetryButtons?.(container, missing, state);
|
|
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
|
return;
|
|
}
|
|
|
|
if (state?.lastAttemptError) {
|
|
this.patchSearchResultsContainer(
|
|
container,
|
|
`${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`
|
|
);
|
|
this.wireSearchProgressRetryButtons?.(container, missing, state);
|
|
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
|
return;
|
|
}
|
|
|
|
this.patchSearchResultsContainer(
|
|
container,
|
|
progressHtml || this.renderStatusMessage(
|
|
'No matches found online for this model.',
|
|
'warning'
|
|
)
|
|
);
|
|
this.wireSearchProgressRetryButtons?.(container, missing, state);
|
|
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
|
return;
|
|
}
|
|
|
|
const rows = [];
|
|
const rowKeys = new Set();
|
|
const removedRowKeys = new Set(state?.removedSearchResultKeys || []);
|
|
const addRow = (row) => {
|
|
if (!row) return;
|
|
row = this.prepareSearchResultRow(missing, row);
|
|
const rowKey = `${row.sourceKey}:${row.downloadUrl || row.openUrl || `${row.model}:${row.filename}`}`;
|
|
if (removedRowKeys.has(rowKey)) return;
|
|
if (rowKeys.has(rowKey)) return;
|
|
rowKeys.add(rowKey);
|
|
rows.push({ ...row, __searchResultKey: rowKey });
|
|
};
|
|
const addArchiveRow = (result, {
|
|
sourceKey,
|
|
sourceLabel,
|
|
fallbackCategory,
|
|
hashSourceKey = sourceKey,
|
|
detailsSource = sourceKey,
|
|
detailsSourceOverride = '',
|
|
secondaryParts = [],
|
|
openUrl = '',
|
|
} = {}) => {
|
|
if (!result?.download_url) return;
|
|
|
|
const filename = result.filename || this.getFilenameFromPath(missing.original_path);
|
|
const model = result.name || filename || 'Model';
|
|
const categoryFallback = this.getMissingDownloadCategory?.(missing, fallbackCategory)
|
|
|| fallbackCategory;
|
|
const category = this.getSourceResultDownloadCategory?.(
|
|
result,
|
|
categoryFallback,
|
|
missing
|
|
) || categoryFallback;
|
|
const secondary = [
|
|
model && model !== filename ? filename : '',
|
|
...secondaryParts,
|
|
].filter(Boolean).join(' / ');
|
|
|
|
addRow(this.buildSearchResultRow(missing, {
|
|
result,
|
|
sourceKey,
|
|
sourceLabel,
|
|
model,
|
|
version: result.version_name || '',
|
|
filename,
|
|
secondary,
|
|
downloadUrl: result.download_url,
|
|
downloadFilename: filename,
|
|
category,
|
|
openUrl,
|
|
hashSourceKey,
|
|
hashLabelMap,
|
|
localHashMatches,
|
|
detailsContext: {
|
|
...result,
|
|
...(detailsSourceOverride ? { source: detailsSourceOverride } : {}),
|
|
details_source: detailsSource,
|
|
missing_key: this.getMissingModelKey(missing),
|
|
category,
|
|
},
|
|
}));
|
|
};
|
|
|
|
let statusHtml = '';
|
|
if (!hasActiveProgress && state?.lastAttemptError) {
|
|
statusHtml += this.renderStatusMessage(state.lastAttemptError, 'error');
|
|
}
|
|
|
|
addRow(knownDownloadRow);
|
|
|
|
if (popular) {
|
|
const popularFilename = popular.filename || this.getFilenameFromPath(missing.original_path);
|
|
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
|
|
const popularSize = popular.size
|
|
|| (
|
|
modelListResult
|
|
&& modelListResult.filename
|
|
&& modelListResult.filename.toLowerCase() === popularFilename.toLowerCase()
|
|
? modelListResult.size
|
|
: ''
|
|
);
|
|
addRow(this.buildSearchResultRow(missing, {
|
|
result: popular,
|
|
sourceKey: 'popular',
|
|
sourceLabel: 'Popular',
|
|
model: popular.name || popularFilename,
|
|
filename: popularFilename,
|
|
secondary: popular.name && popular.name !== popularFilename ? popularFilename : '',
|
|
sizeResult: { ...popular, size: popularSize },
|
|
downloadUrl: popular.url,
|
|
downloadFilename: popularFilename,
|
|
category: popular.directory || missingCategory,
|
|
openUrl: getModelCardUrl(popular.url),
|
|
hashLabelMap,
|
|
localHashMatches,
|
|
fallbackLabel: 'Known',
|
|
fallbackClass: 'strong',
|
|
}));
|
|
}
|
|
|
|
if (modelListResult && modelListResult.url) {
|
|
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
|
|
const huggingFaceFile = parseHuggingFaceFileUrl(modelListResult.url);
|
|
const modelListDetailsContext = huggingFaceFile
|
|
? {
|
|
...modelListResult,
|
|
source: modelListResult.source || 'model_list',
|
|
details_source: 'huggingface',
|
|
model_id: huggingFaceFile.repo,
|
|
repo_id: huggingFaceFile.repo,
|
|
version_id: huggingFaceFile.revision,
|
|
branch: huggingFaceFile.revision,
|
|
path: huggingFaceFile.path,
|
|
filename: modelListResult.filename || huggingFaceFile.filename,
|
|
missing_key: this.getMissingModelKey(missing),
|
|
category: modelListResult.directory || missingCategory
|
|
}
|
|
: null;
|
|
addRow(this.buildSearchResultRow(missing, {
|
|
result: modelListResult,
|
|
sourceKey: 'model-list',
|
|
sourceLabel: 'Local Database',
|
|
model: modelListResult.name || modelListResult.filename,
|
|
filename: modelListResult.filename,
|
|
secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '',
|
|
downloadUrl: modelListResult.url,
|
|
downloadFilename: modelListResult.filename,
|
|
category: modelListResult.directory || missingCategory,
|
|
openUrl: getModelCardUrl(modelListResult.url),
|
|
hashLabelMap,
|
|
localHashMatches,
|
|
detailsContext: modelListDetailsContext,
|
|
}));
|
|
}
|
|
|
|
const hfDownloadUrl = hfResult?.download_url || hfResult?.url || '';
|
|
if (hfResult && hfDownloadUrl) {
|
|
const hfRepo = hfResult.repo_id || hfResult.repo || '';
|
|
const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : getModelCardUrl(hfResult.url);
|
|
const missingCategory = this.getMissingDownloadCategory?.(missing, 'checkpoints') || missing.category || 'checkpoints';
|
|
addRow(this.buildSearchResultRow(missing, {
|
|
result: hfResult,
|
|
sourceKey: 'huggingface',
|
|
sourceLabel: 'HuggingFace',
|
|
model: hfRepo || hfResult.filename,
|
|
filename: hfResult.filename,
|
|
secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '',
|
|
downloadUrl: hfDownloadUrl,
|
|
downloadFilename: hfResult.filename,
|
|
category: missingCategory,
|
|
openUrl: hfModelUrl,
|
|
hashLabelMap,
|
|
localHashMatches,
|
|
detailsContext: {
|
|
...hfResult,
|
|
name: hfRepo || hfResult.filename,
|
|
filename: hfResult.filename,
|
|
details_source: 'huggingface',
|
|
missing_key: this.getMissingModelKey(missing),
|
|
category: missingCategory
|
|
}
|
|
}));
|
|
}
|
|
|
|
if (civarchiveResult && civarchiveResult.download_url) {
|
|
addArchiveRow(civarchiveResult, {
|
|
sourceKey: 'civarchive',
|
|
sourceLabel: 'CivArchive',
|
|
fallbackCategory: 'checkpoints',
|
|
secondaryParts: [
|
|
civarchiveResult.platform || '',
|
|
civarchiveResult.base_model || '',
|
|
],
|
|
openUrl: civarchiveResult.url,
|
|
});
|
|
}
|
|
|
|
if (loraManagerArchiveResult && loraManagerArchiveResult.download_url) {
|
|
addArchiveRow(loraManagerArchiveResult, {
|
|
sourceKey: 'lora-archive',
|
|
sourceLabel: 'LoRA Archive',
|
|
fallbackCategory: 'loras',
|
|
hashSourceKey: 'lora_manager_archive',
|
|
detailsSource: 'lora_manager_archive',
|
|
detailsSourceOverride: 'lora_manager_archive',
|
|
openUrl: loraManagerArchiveResult.url || getModelCardUrl(loraManagerArchiveResult.download_url),
|
|
});
|
|
}
|
|
|
|
if (civitaiResult && civitaiResult.download_url) {
|
|
const modelUrl = civitaiResult.url || getCivitaiModelUrl(civitaiResult.model_id, civitaiResult.version_id);
|
|
const downloadFilename = civitaiResult.filename || missing.civitai_info?.expected_filename || civitaiResult.name;
|
|
const modelName = civitaiResult.name || missing.civitai_info?.model_name || downloadFilename || 'Model';
|
|
const civitaiCategory = this.getSourceResultDownloadCategory?.(
|
|
civitaiResult,
|
|
this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints',
|
|
missing
|
|
) || this.getMissingDownloadCategory?.(missing, 'checkpoints') || 'checkpoints';
|
|
const civitaiSecondary = [
|
|
civitaiResult.type || '',
|
|
civitaiResult.base_model || missing.civitai_info?.base_model || ''
|
|
].filter(Boolean).join(' / ');
|
|
addRow(this.buildSearchResultRow(missing, {
|
|
result: civitaiResult,
|
|
sourceKey: 'civitai',
|
|
sourceLabel: 'CivitAI',
|
|
model: modelName,
|
|
version: civitaiResult.version_name || missing.civitai_info?.version_name || '',
|
|
filename: downloadFilename,
|
|
secondary: civitaiSecondary,
|
|
downloadUrl: civitaiResult.download_url,
|
|
downloadFilename,
|
|
category: civitaiCategory,
|
|
openUrl: modelUrl,
|
|
hashLabelMap,
|
|
localHashMatches,
|
|
detailsContext: {
|
|
...civitaiResult,
|
|
name: modelName,
|
|
filename: downloadFilename,
|
|
details_source: 'civitai',
|
|
missing_key: this.getMissingModelKey(missing),
|
|
category: civitaiCategory
|
|
}
|
|
}));
|
|
}
|
|
|
|
customResults.forEach((customResult) => {
|
|
addRow(this.getCustomUrlResultTableRow(
|
|
missing,
|
|
customResult,
|
|
hashLabelMap,
|
|
localHashMatches
|
|
));
|
|
});
|
|
|
|
const tableHtml = rows.length
|
|
? this.renderSearchResultsTable(rows, { removalEnabled: true, showRemoval: Boolean(state?.showSearchResultRemoval) })
|
|
: this.renderStatusMessage('No model results remain in this table.', 'info');
|
|
const html = `${progressHtml}${statusHtml}${tableHtml}`;
|
|
this.patchSearchResultsContainer(container, html);
|
|
|
|
this.wireSearchProgressCancelButtons?.(container, missing, state);
|
|
this.wireSearchProgressRetryButtons?.(container, missing, state);
|
|
this.wireSearchDownloadButtons(container, missing);
|
|
},
|
|
|
|
/**
|
|
* Download from search results
|
|
*/
|
|
async downloadFromSearch(missing, url, filename, category, btn, pathMetadata = null, downloadMetadata = null) {
|
|
const targetSelection = this.getDownloadTargetSelection(missing, category || missing.category || 'checkpoints');
|
|
|
|
const resolvedPathMetadata = pathMetadata || this.getDownloadPathMetadata(missing, {
|
|
filename,
|
|
category: targetSelection.category
|
|
});
|
|
const resolvedDownloadMetadata = downloadMetadata && typeof downloadMetadata === 'object'
|
|
? { ...downloadMetadata }
|
|
: this.getDownloadMetadata(missing, {
|
|
filename,
|
|
category: targetSelection.category,
|
|
url,
|
|
download_url: url
|
|
}, {
|
|
filename,
|
|
category: targetSelection.category,
|
|
url,
|
|
pathMetadata: resolvedPathMetadata
|
|
});
|
|
resolvedDownloadMetadata.filename = filename;
|
|
resolvedDownloadMetadata.category = targetSelection.category;
|
|
resolvedDownloadMetadata.download_url = url;
|
|
resolvedDownloadMetadata.source_url = url;
|
|
resolvedDownloadMetadata.path_metadata = resolvedPathMetadata;
|
|
|
|
await this.executeDownloadRequest(missing, {
|
|
url,
|
|
filename,
|
|
category: targetSelection.category,
|
|
subfolder: targetSelection.subfolder,
|
|
baseDirectory: targetSelection.baseDirectory || '',
|
|
pathMetadata: resolvedPathMetadata,
|
|
downloadMetadata: resolvedDownloadMetadata,
|
|
btn
|
|
});
|
|
},
|
|
|
|
async fetchLocalMatches(filename, category = '', forceRescan = false) {
|
|
return await this.fetchJson('/model_resolver/local-matches', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
filename,
|
|
category: category || '',
|
|
force_rescan: Boolean(forceRescan)
|
|
})
|
|
}, 'Fetch local matches');
|
|
}
|
|
};
|