diff --git a/web/css/linker-main.css b/web/css/linker-main.css index 59476c5..fd8098f 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -764,6 +764,74 @@ margin-top: 8px; display: none; } +.ml-search-progress-list { + display: grid; + gap: 6px; + margin: 8px 0; +} +.ml-search-progress-item { + padding: 6px 8px; + background: rgba(255,255,255,0.035); + border: 1px solid var(--ml-border); + border-radius: 8px; +} +.ml-search-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 5px; + font-size: 11px; + color: var(--ml-text-muted); +} +.ml-search-progress-head span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ml-search-progress-head span:first-child { + color: var(--ml-text); + font-weight: 600; +} +.ml-search-progress-head span:last-child { + flex: 0 1 auto; + max-width: 55%; + text-align: right; +} +.ml-search-progress-bar { + height: 5px; + overflow: hidden; + background: rgba(255,255,255,0.08); + border-radius: 5px; +} +.ml-search-progress-fill { + height: 100%; + background: var(--ml-accent); + border-radius: inherit; + transition: width 0.2s ease, background 0.2s ease; +} +.ml-search-progress-fill-running { + width: 42% !important; + animation: ml-search-progress-slide 1.05s ease-in-out infinite; +} +.ml-search-progress-found .ml-search-progress-fill { + background: #72d38a; +} +.ml-search-progress-none .ml-search-progress-fill { + background: rgba(255,255,255,0.32); +} +.ml-search-progress-error .ml-search-progress-fill { + background: #ff8a80; +} +@keyframes ml-search-progress-slide { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(240%); + } +} .ml-combo-section { position: relative; margin-top: 8px; diff --git a/web/linker.js b/web/linker.js index 0fe7395..9d94b4a 100644 --- a/web/linker.js +++ b/web/linker.js @@ -106,10 +106,14 @@ class LinkerManagerDialog extends ComfyDialog { popular: null, model_list: null, huggingface: null, - civitai: null + civitai: null, + lora_manager_archive: null }, lastAttemptSources: [], - lastAttemptFound: null + lastAttemptFound: null, + lastAttemptError: null, + sourceProgress: {}, + activeSearchRunId: null }); } return this.searchResultCache.get(key); @@ -222,6 +226,69 @@ class LinkerManagerDialog extends ComfyDialog { return labels[source] || source; } + getSearchSourcesForSelection(selectedSource, missing = {}) { + if (selectedSource !== 'all') { + return this.isSourceAvailable(selectedSource) ? [selectedSource] : []; + } + + const sources = ['local', 'huggingface', 'civitai']; + if (!missing.is_urn && this.isSourceAvailable('lora_manager_archive')) { + sources.push('lora_manager_archive'); + } + return sources; + } + + setSourceProgress(state, source, patch = {}) { + state.sourceProgress = { + ...(state.sourceProgress || {}), + [source]: { + ...(state.sourceProgress?.[source] || {}), + ...patch + } + }; + } + + hasActiveSearchProgress(state = {}) { + return Object.values(state.sourceProgress || {}).some(progress => ( + progress?.status === 'pending' || progress?.status === 'running' + )); + } + + renderSearchProgress(state = {}) { + const progressEntries = Object.entries(state.sourceProgress || {}); + if (!progressEntries.length) return ''; + + const statusLabels = { + pending: 'Queued', + running: 'Searching...', + found: 'Found', + none: 'No match', + error: 'Error' + }; + + let html = '
'; + for (const [source, progress] of progressEntries) { + const status = progress?.status || 'pending'; + const label = this.getSearchSourceLabel(source); + const statusLabel = progress?.message || statusLabels[status] || status; + const percent = status === 'pending' ? 0 : (status === 'running' ? 55 : 100); + const safePercent = Math.max(0, Math.min(100, percent)); + html += ` +
+
+ ${this.escapeHtml(label)} + ${this.escapeHtml(statusLabel)} +
+
+
+
+
+ `; + } + html += '
'; + return html; + } + /** * Update source selector buttons and helper text for one card */ @@ -4527,6 +4594,7 @@ class LinkerManagerDialog extends ComfyDialog { const state = this.getSearchState(missing); const selectedSource = state.selectedSource || 'all'; const selectedSourceLabel = this.getSearchSourceLabel(selectedSource); + const sourceIds = this.getSearchSourcesForSelection(selectedSource, missing); // For URNs, use the CivitAI model name for searching instead of the URN itself // and pass the URN type as category (CivitAI expects specific type names) @@ -4558,24 +4626,41 @@ class LinkerManagerDialog extends ComfyDialog { const resultsId = `search-results-${missing.node_id}-${missing.widget_index}`; const resultsDiv = this.contentElement?.querySelector(`#${resultsId}`); const searchBtn = this.contentElement?.querySelector(`#search-${missing.node_id}-${missing.widget_index}`); + let searchRunId = null; try { + if (!sourceIds.length) { + throw new Error(`${selectedSourceLabel} is not available in this install`); + } + + searchRunId = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + state.activeSearchRunId = searchRunId; + state.lastAttemptSources = sourceIds; + state.lastAttemptFound = null; + state.lastAttemptError = null; + state.sourceProgress = {}; + for (const source of sourceIds) { + this.setSourceProgress(state, source, { + status: 'running', + message: 'Searching...' + }); + } + if (searchBtn) { searchBtn.disabled = true; searchBtn.innerHTML = `${this.getSearchIconHtml()} Searching ${selectedSourceLabel}...`; } if (resultsDiv) { resultsDiv.style.display = 'block'; - resultsDiv.innerHTML = `Searching ${selectedSourceLabel}...`; + this.displaySearchResults(missing, state, resultsDiv); } // For URNs, include model_id and version_id for direct download const tokens = this.getStoredTokens(); - const searchData = { + const baseSearchData = { filename, category, is_urn: isUrn, - sources: [selectedSource], civitai_session_token: tokens.civitai_session_token, civitai_candidate_limit: tokens.civitai_candidate_limit, civitai_use_trpc_search: tokens.civitai_use_trpc_search, @@ -4587,46 +4672,110 @@ class LinkerManagerDialog extends ComfyDialog { hf_use_brave_fallback: tokens.hf_use_brave_fallback }; if (isUrn && missing.urn) { - searchData.model_id = missing.urn.model_id; - searchData.version_id = missing.urn.version_id; + baseSearchData.model_id = missing.urn.model_id; + baseSearchData.version_id = missing.urn.version_id; } - - console.log('Model Linker: Search request:', JSON.stringify(searchData)); - const response = await api.fetchApi('/model_linker/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(searchData) + const attemptedSources = new Set(); + let anyFound = false; + let hadError = false; + + const searchPromises = sourceIds.map(async (source) => { + const searchData = { + ...baseSearchData, + sources: [source] + }; + + try { + console.log('Model Linker: Search request:', JSON.stringify(searchData)); + + const response = await api.fetchApi('/model_linker/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(searchData) + }); + + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + + const data = await response.json(); + console.log('Model Linker: Search response:', JSON.stringify(data)); + + if (state.activeSearchRunId !== searchRunId) { + 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 found = this.hasSearchResults(data); + anyFound = anyFound || found; + state.results = this.mergeSearchResults(state.results, data); + state.lastAttemptSources = Array.from(attemptedSources); + state.lastAttemptFound = anyFound; + this.setSourceProgress(state, source, { + status: found ? 'found' : 'none', + message: found ? 'Found' : 'No match' + }); + + 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 + }; + } + + this.displaySearchResults(missing, state, resultsDiv); + this.applySearchResultSuggestion(missing); + return { source, data }; + } catch (error) { + console.error(`Model Linker: Search error for ${source}:`, error); + if (state.activeSearchRunId !== searchRunId) { + return { source, stale: true, error }; + } + + hadError = true; + attemptedSources.add(source); + state.lastAttemptSources = Array.from(attemptedSources); + state.lastAttemptFound = anyFound; + this.setSourceProgress(state, source, { + status: 'error', + message: error.message || 'Error' + }); + this.displaySearchResults(missing, state, resultsDiv); + return { source, error }; + } }); - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); - } + await Promise.all(searchPromises); - const data = await response.json(); - console.log('Model Linker: Search response:', JSON.stringify(data)); - state.results = this.mergeSearchResults(state.results, data); - state.lastAttemptSources = Array.isArray(data.searched_sources) ? data.searched_sources : [selectedSource]; - state.lastAttemptFound = this.hasSearchResults(data); - 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 (state.activeSearchRunId === searchRunId) { + state.activeSearchRunId = null; + state.lastAttemptSources = attemptedSources.size ? Array.from(attemptedSources) : sourceIds; + state.lastAttemptFound = anyFound; + state.lastAttemptError = hadError && !anyFound + ? 'Search finished with errors. Check source statuses above.' + : null; + this.displaySearchResults(missing, state, resultsDiv); } - this.displaySearchResults(missing, state, resultsDiv); - this.applySearchResultSuggestion(missing); } catch (error) { console.error('Model Linker: Search error:', error); + state.lastAttemptError = error.message; if (resultsDiv) { resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error'); } } finally { + if (!searchRunId || state.activeSearchRunId === searchRunId) { + state.activeSearchRunId = null; + } if (searchBtn) { searchBtn.disabled = false; searchBtn.innerHTML = `${this.getSearchIconHtml()} Search Again`; @@ -4763,19 +4912,33 @@ class LinkerManagerDialog extends ComfyDialog { const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null; const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null; const hasResults = popular || modelListResult || hfResult || civitaiResult || loraManagerArchiveResult; + const progressHtml = this.renderSearchProgress(state); + const hasActiveProgress = this.hasActiveSearchProgress(state); if (!hasResults) { + if (hasActiveProgress) { + container.innerHTML = progressHtml; + return; + } + + if (state?.lastAttemptError) { + container.innerHTML = `${progressHtml}${this.renderStatusMessage(state.lastAttemptError, 'error')}`; + return; + } + const searchedLabel = (state?.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', '); - container.innerHTML = this.renderStatusMessage( + container.innerHTML = `${progressHtml}${this.renderStatusMessage( searchedLabel ? `No matches found in ${searchedLabel}.` : 'No matches found online for this model.', 'warning' - ); + )}`; return; } - let html = '
'; + let html = `${progressHtml}
`; - if (state?.lastAttemptFound === false) { + if (!hasActiveProgress && state?.lastAttemptError) { + html += this.renderStatusMessage(state.lastAttemptError, 'error'); + } else if (!hasActiveProgress && state?.lastAttemptFound === false) { const searchedLabel = (state.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', '); html += this.renderStatusMessage(`No new matches found in ${searchedLabel}. Existing results are kept below.`, 'warning'); }