diff --git a/web/linker.js b/web/linker.js index b166bd1..0bf7a6d 100644 --- a/web/linker.js +++ b/web/linker.js @@ -26,6 +26,8 @@ class LinkerManagerDialog extends ComfyDialog { this.activeDownloads = {}; // Track active downloads this.searchResultCache = new Map(); this.searchProgressTimers = new Map(); + this.urnResolvePromises = new Map(); + this.urnLocalMatchPromises = new Map(); this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; this.selectedMissingModelKey = null; @@ -1047,21 +1049,189 @@ class LinkerManagerDialog extends ComfyDialog { } } - async refreshUrnLocalMatches(missing) { - if (!missing?.civitai_info?.expected_filename || !this.contentElement) return; + getUrnIds(missing = {}) { + return { + modelId: missing.urn_model_id || missing.urn?.model_id, + versionId: missing.urn_version_id || missing.urn?.version_id + }; + } - const bodyId = `local-matches-body-${missing.node_id}-${missing.widget_index}`; - const container = this.contentElement.querySelector(`#${bodyId}`); - if (!container) return; + getUrnResolveKey(missing = {}) { + const ids = this.getUrnIds(missing); + return `${ids.modelId || ''}:${ids.versionId || ''}:${this.getMissingModelKey(missing)}`; + } - container.innerHTML = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; + getCivitaiResultFromMissing(missing = {}) { + if (!missing.civitai_info?.expected_filename && !missing.download_source?.url) return null; + const ids = this.getUrnIds(missing); + return { + name: missing.civitai_info?.model_name || missing.download_source?.name, + version_name: missing.civitai_info?.version_name || missing.download_source?.version_name, + filename: missing.civitai_info?.expected_filename || missing.download_source?.filename, + download_url: missing.download_source?.url, + url: missing.download_source?.model_url, + type: missing.download_source?.type || missing.category, + size: missing.download_source?.size, + model_id: missing.download_source?.model_id || ids.modelId, + version_id: missing.download_source?.version_id || ids.versionId, + base_model: missing.civitai_info?.base_model || missing.download_source?.base_model, + tags: missing.civitai_info?.tags || missing.download_source?.tags || [] + }; + } - try { + applyCivitaiUrnResult(missing, civitai = {}) { + if (!missing || !civitai) return; + const ids = this.getUrnIds(missing); + missing.civitai_info = { + model_name: civitai.name, + version_name: civitai.version_name, + expected_filename: civitai.filename, + base_model: civitai.base_model, + tags: civitai.tags || [] + }; + missing.download_source = { + source: 'civitai', + url: civitai.download_url, + filename: civitai.filename, + name: civitai.name, + version_name: civitai.version_name, + type: civitai.type, + directory: missing.category || 'checkpoints', + match_type: 'exact', + size: civitai.size, + model_id: civitai.model_id || ids.modelId, + version_id: civitai.version_id || ids.versionId, + model_url: civitai.url || `https://civitai.com/models/${ids.modelId}?modelVersionId=${ids.versionId}`, + base_model: civitai.base_model, + tags: civitai.tags || [] + }; + } + + async resolveUrnDataForMissing(missing) { + if (!missing?.is_urn) return null; + + const existing = this.getCivitaiResultFromMissing(missing); + if (existing?.filename) { + return { civitai: existing }; + } + + const ids = this.getUrnIds(missing); + if (!ids.modelId || !ids.versionId) return null; + + const key = this.getUrnResolveKey(missing); + if (this.urnResolvePromises.has(key)) { + return this.urnResolvePromises.get(key); + } + + const promise = (async () => { + const tokens = this.getStoredTokens(); + const payload = { + filename: `${ids.modelId}_${ids.versionId}`, + category: '', + is_urn: true, + sources: ['civitai'], + model_id: ids.modelId, + version_id: ids.versionId, + civitai_candidate_limit: tokens.civitai_candidate_limit + }; + + const response = await api.fetchApi('/model_linker/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`URN resolve failed: ${response.status}`); + } + + const data = await response.json(); + if (data.civitai) { + this.applyCivitaiUrnResult(missing, data.civitai); + } + return data; + })().finally(() => { + this.urnResolvePromises.delete(key); + }); + + this.urnResolvePromises.set(key, promise); + return promise; + } + + refreshMissingListStats() { + if (!this.contentElement) return; + const statsEl = this.contentElement.querySelector('.ml-missing-list-stats'); + if (!statsEl) return; + + const stats = this.getMissingModelSummaryStats(this.missingModels || []); + statsEl.innerHTML = ` + ${stats.exact} exact + ${stats.partial} partial + ${stats.none} no match + `; + } + + refreshMissingListRowLocalMatch(missing = {}) { + if (!this.contentElement) return; + + const key = this.getMissingModelKey(missing); + const rows = this.contentElement.querySelectorAll('.ml-missing-list-row'); + for (const row of rows) { + if (row.getAttribute('data-missing-key') !== key) continue; + + const bestMatch = this.getBestLocalMatch(missing, 70); + const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0; + const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || ''; + const matchDisplay = matchName || 'No local match'; + const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none'); + + const bestEl = row.querySelector('.ml-missing-row-best'); + if (bestEl) { + bestEl.setAttribute('data-tooltip', matchDisplay); + bestEl.innerHTML = bestMatch + ? this.escapeHtml(matchDisplay) + : '-- No local match'; + } + + const matchEl = row.querySelector('.ml-missing-row-match'); + if (matchEl) { + matchEl.classList.remove( + 'ml-missing-row-match-exact', + 'ml-missing-row-match-partial', + 'ml-missing-row-match-none' + ); + matchEl.classList.add(`ml-missing-row-match-${matchClass}`); + const valueEl = matchEl.querySelector('strong'); + if (valueEl) { + valueEl.textContent = bestMatch + ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` + : '--'; + } + } + } + + this.refreshMissingListStats(); + } + + async fetchUrnLocalMatches(missing) { + if (!missing?.civitai_info?.expected_filename) return []; + + const filename = missing.civitai_info.expected_filename; + if (missing.__urnLocalMatchesFilename === filename && Array.isArray(missing.matches)) { + return missing.matches; + } + + const key = this.getUrnResolveKey(missing); + if (this.urnLocalMatchPromises.has(key)) { + return this.urnLocalMatchPromises.get(key); + } + + const promise = (async () => { const response = await api.fetchApi('/model_linker/local-matches', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - filename: missing.civitai_info.expected_filename, + filename, category: missing.category || '' }) }); @@ -1072,14 +1242,58 @@ class LinkerManagerDialog extends ComfyDialog { const data = await response.json(); missing.matches = Array.isArray(data.matches) ? data.matches : []; - container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0); - this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); + missing.__urnLocalMatchesFilename = filename; + return missing.matches; + })().finally(() => { + this.urnLocalMatchPromises.delete(key); + }); + + this.urnLocalMatchPromises.set(key, promise); + return promise; + } + + async refreshUrnLocalMatches(missing) { + if (!missing?.civitai_info?.expected_filename || !this.contentElement) return; + + const bodyId = `local-matches-body-${missing.node_id}-${missing.widget_index}`; + const container = this.contentElement.querySelector(`#${bodyId}`); + if (container) { + container.innerHTML = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; + } + + try { + await this.fetchUrnLocalMatches(missing); + if (container) { + container.innerHTML = this.renderLocalMatchesContent(missing, missing.__displayIndex || 0); + this.wireLocalMatchButtons(this.contentElement, missing, missing.__displayIndex || 0); + } + this.refreshMissingListRowLocalMatch(missing); } catch (error) { console.error('Model Linker: URN local match refresh error:', error); - container.innerHTML = `
Failed to refresh local matches.
`; + if (container) { + container.innerHTML = `
Failed to refresh local matches.
`; + } } } + scheduleInitialUrnLocalMatchRefresh(missingModels = []) { + missingModels.forEach((missing) => { + if (!missing?.is_urn || missing.__urnLocalRefreshQueued) return; + if (this.getBestLocalMatch(missing, 70)) return; + + missing.__urnLocalRefreshQueued = true; + setTimeout(async () => { + try { + await this.resolveUrnDataForMissing(missing); + await this.refreshUrnLocalMatches(missing); + } catch (error) { + missing.__urnLocalRefreshQueued = false; + console.error('Model Linker: initial URN local match refresh error:', error); + } + }, 0); + }); + } + /** * Handle click outside context menu to hide it */ @@ -5424,6 +5638,7 @@ class LinkerManagerDialog extends ComfyDialog { hasAny100Match ); this.wireMissingModelsBrowser(container, data, sortedMissingModels); + this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels); } renderMissingModel(missing, missingIndex = 0) { @@ -6449,28 +6664,41 @@ class LinkerManagerDialog extends ComfyDialog { } try { - 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 - }; - console.log('resolveUrnAsync payload:', JSON.stringify(payload)); - - const response = await api.fetchApi('/model_linker/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - console.log('resolveUrnAsync response status:', response.status); - - if (response.ok) { - const data = await response.json(); + 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 + }; + console.log('resolveUrnAsync payload:', JSON.stringify(payload)); + + const response = await api.fetchApi('/model_linker/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + console.log('resolveUrnAsync response status:', response.status); + if (!response.ok) { + throw new Error(`URN resolve failed: ${response.status}`); + } + data = await response.json(); + } + + if (data) { const loadingEl = document.getElementById(loadingElementId); if (loadingEl && data.civitai) { loadingEl.classList.remove('ml-is-muted', 'ml-is-error'); @@ -6485,36 +6713,10 @@ class LinkerManagerDialog extends ComfyDialog { loadingEl.classList.add('ml-is-muted'); } - const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-'); const downloadEl = document.getElementById(downloadContainerId); if (downloadEl && data.civitai?.download_url) { - const missing = this.missingModels.find(m => - `urn-download-${m.node_id}-${m.widget_index}` === downloadContainerId - ); if (missing) { - missing.civitai_info = { - model_name: data.civitai.name, - version_name: data.civitai.version_name, - expected_filename: data.civitai.filename, - base_model: data.civitai.base_model, - tags: data.civitai.tags || [] - }; - missing.download_source = { - source: 'civitai', - url: data.civitai.download_url, - filename: data.civitai.filename, - name: data.civitai.name, - version_name: data.civitai.version_name, - type: data.civitai.type, - directory: missing.category || 'checkpoints', - match_type: 'exact', - size: data.civitai.size, - model_id: data.civitai.model_id || modelId, - version_id: data.civitai.version_id || versionId, - model_url: data.civitai.url || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`, - base_model: data.civitai.base_model, - tags: data.civitai.tags || [] - }; + this.applyCivitaiUrnResult(missing, data.civitai); const downloadParent = downloadEl.parentElement; downloadEl.outerHTML = this.renderKnownDownloadPanel(missing, missing.download_source); this.wireDownloadSearchPanel(downloadParent || this.contentElement, missing);