diff --git a/web/css/linker-main.css b/web/css/linker-main.css index 4f87227..1b2355d 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -222,6 +222,307 @@ color: var(--ml-text-muted); } +.ml-missing-browser { + display: grid; + grid-template-columns: minmax(360px, 1.1fr) minmax(320px, 0.9fr); + gap: 10px; + height: 100%; + min-height: 0; + overflow: hidden; + border: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.015); +} +.ml-missing-list-pane, +.ml-missing-detail-pane { + min-width: 0; + min-height: 0; + overflow: hidden; +} +.ml-missing-list-pane { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + border-right: 1px solid rgba(255,255,255,0.08); + background: rgba(20,20,20,0.92); +} +.ml-missing-list-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 14px; + border-bottom: 1px solid rgba(255,255,255,0.07); +} +.ml-missing-list-title { + font-size: 14px; + font-weight: 750; + color: var(--ml-text); +} +.ml-missing-list-meta { + margin-top: 3px; + font-size: 11px; + color: var(--ml-text-muted); +} +.ml-missing-list-stats { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + flex-wrap: wrap; +} +.ml-missing-stat { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 2px 7px; + border-radius: 999px; + background: rgba(255,255,255,0.055); + border: 1px solid rgba(255,255,255,0.08); + color: var(--ml-text-muted); + font-size: 10px; + font-weight: 700; + white-space: nowrap; +} +.ml-missing-stat-exact { + color: #72d38a; +} +.ml-missing-stat-partial { + color: #f1cf64; +} +.ml-missing-list-head, +#model-linker-modal .ml-missing-list-row { + display: grid; + grid-template-columns: 18px minmax(220px, 1.7fr) minmax(66px, 112px) minmax(150px, 1fr) 106px; + align-items: center; + gap: 8px; +} +.ml-missing-list-head { + padding: 8px 10px 8px 6px; + border-bottom: 1px solid rgba(255,255,255,0.08); + background: rgba(255,255,255,0.035); + color: var(--ml-text-muted); + font-size: 10px; + font-weight: 700; +} +.ml-missing-list { + min-height: 0; + overflow-y: auto; + scrollbar-width: auto; +} +#model-linker-modal .ml-missing-list-row { + width: 100%; + min-height: 50px; + padding: 7px 10px 7px 6px; + border: 0; + border-bottom: 1px solid rgba(255,255,255,0.055); + border-radius: 0; + background: transparent; + color: var(--ml-text); + cursor: pointer; + font-family: inherit; + font-size: 11px; + text-align: left; + transition: background 0.14s ease, box-shadow 0.14s ease; +} +#model-linker-modal .ml-missing-list-row:hover { + background: rgba(255,255,255,0.04); +} +#model-linker-modal .ml-missing-list-row.is-selected { + background: rgba(114,211,138,0.09); + box-shadow: inset 3px 0 0 rgba(114,211,138,0.92); +} +.ml-missing-row-index { + display: block; + width: 100%; + color: rgba(255,255,255,0.55); + font-size: 10px; + font-weight: 700; + text-align: center; + justify-self: stretch; +} +.ml-missing-row-model, +.ml-missing-row-match { + min-width: 0; + display: grid; + gap: 2px; +} +.ml-missing-row-model { + grid-template-columns: minmax(0, 1fr); + align-items: center; +} +.ml-missing-row-name, +.ml-missing-row-node, +.ml-missing-row-match span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ml-missing-row-name { + color: var(--ml-text); + font-size: 12px; + font-weight: 700; +} +.ml-missing-row-node { + grid-column: 1; + color: var(--ml-text-muted); + font-size: 10px; +} +.ml-missing-row-type { + justify-self: start; + max-width: 100%; + min-width: 0; + padding: 2px 7px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 999px; + background: rgba(255,255,255,0.065); + color: #d4d4d4; + font-size: 9px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.02em; +} +.ml-missing-row-match { + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + column-gap: 6px; + row-gap: 2px; + color: var(--ml-text-muted); +} +.ml-missing-row-match strong { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(255,255,255,0.065); + font-size: 10px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} +.ml-missing-row-match span { + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; + word-break: normal; + line-height: 1.25; +} +.ml-missing-row-match-exact strong { + color: #72d38a; + background: rgba(114,211,138,0.12); +} +.ml-missing-row-match-partial strong { + color: #f1cf64; + background: rgba(241,207,100,0.13); +} +.ml-missing-row-match-none strong { + color: rgba(255,255,255,0.42); +} +.ml-missing-row-sources { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 5px; + min-width: 0; +} +.ml-missing-source-dot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 999px; + background: rgba(255,255,255,0.035); + border: 1px solid rgba(255,255,255,0.12); + color: rgba(255,255,255,0.42); + font-size: 9px; + font-weight: 800; +} +.ml-missing-source-icon { + display: block; + width: 12px; + height: 12px; + flex: 0 0 12px; +} +.ml-missing-source-found { + color: #72d38a; + border-color: rgba(114,211,138,0.55); + background: rgba(114,211,138,0.11); +} +.ml-missing-source-none { + color: rgba(255,255,255,0.48); +} +.ml-missing-source-running, +.ml-missing-source-pending { + color: #8fd0ff; + border-color: rgba(143,208,255,0.55); + background: rgba(143,208,255,0.1); +} +.ml-missing-source-error { + color: #ff8a80; + border-color: rgba(255,138,128,0.55); + background: rgba(255,138,128,0.1); +} +.ml-missing-detail-pane { + overflow-y: auto; + background: rgba(18,18,18,0.9); +} +.ml-missing-detail-pane .ml-card { + margin: 0; + padding: 14px; + background: transparent; + box-shadow: none; +} +.ml-missing-detail-pane .ml-card:hover { + box-shadow: none; +} +.ml-missing-detail-pane .ml-columns { + grid-template-columns: minmax(0, 1fr); +} +.ml-missing-detail-pane .ml-column { + border-radius: 8px; +} + +@media (max-width: 980px) { + .ml-missing-browser { + grid-template-columns: 1fr; + overflow-y: auto; + } + .ml-missing-list-pane { + max-height: 46vh; + border-right: 0; + border-bottom: 1px solid rgba(255,255,255,0.08); + } + .ml-missing-detail-pane { + overflow: visible; + } +} + +@media (max-width: 760px) { + .ml-missing-list-head { + display: none; + } + #model-linker-modal .ml-missing-list-row { + grid-template-columns: 18px minmax(0, 1fr); + gap: 7px; + } + .ml-missing-row-index { + grid-row: 1 / span 4; + align-self: start; + margin-top: 1px; + text-align: center; + } + .ml-missing-row-model, + .ml-missing-row-type, + .ml-missing-row-match, + .ml-missing-row-sources { + grid-column: 2; + } +} + /* Card Styles */ .ml-card { background: var(--ml-card-bg); diff --git a/web/linker.js b/web/linker.js index b6fba0f..b7fc985 100644 --- a/web/linker.js +++ b/web/linker.js @@ -28,6 +28,7 @@ class LinkerManagerDialog extends ComfyDialog { this.searchProgressTimers = new Map(); this.cachedAnalysisData = null; this.cachedWorkflowSignature = null; + this.selectedMissingModelKey = null; this.boundHandleOutsideClick = this.handleOutsideClick.bind(this); this.activeTabStorageKey = 'model_linker_active_tab'; this.activeTab = this.restoreActiveTab(); // Default tab @@ -471,6 +472,7 @@ class LinkerManagerDialog extends ComfyDialog { huggingface: 'huggingface', civitai: 'civitai', 'lora-archive': 'archive', + lora_manager_archive: 'archive', 'lora-manager-archive': 'archive', local: 'database', 'workflow-url': 'link', @@ -3924,7 +3926,297 @@ class LinkerManagerDialog extends ComfyDialog { } } } - + + getMissingModelKey(missing = {}) { + const nodeId = missing.node_id ?? ''; + const widgetIndex = missing.widget_index ?? ''; + const subgraphId = missing.subgraph_id || ''; + const scope = missing.is_top_level !== false ? 'T' : 'F'; + return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`; + } + + getMissingFilename(missing = {}) { + return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model'; + } + + getBestLocalMatch(missing = {}, minConfidence = 0) { + const matches = Array.isArray(missing.matches) ? missing.matches : []; + return matches + .filter(match => Number(match.confidence) >= minConfidence) + .sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null; + } + + getMissingModelSummaryStats(missingModels = []) { + return missingModels.reduce((stats, missing) => { + const best = this.getBestLocalMatch(missing, 70); + if (best?.confidence === 100) { + stats.exact += 1; + } else if (best) { + stats.partial += 1; + } else { + stats.none += 1; + } + return stats; + }, { exact: 0, partial: 0, none: 0 }); + } + + getMissingSourceStatus(missing = {}, source = '') { + const state = this.searchResultCache.get(this.getMissingSearchKey(missing)); + const progress = state?.sourceProgress?.[source]; + if (progress?.status) return progress.status; + + const results = state?.results || {}; + const resultKeys = { + local: 'model_list', + huggingface: 'huggingface', + civitai: 'civitai', + lora_manager_archive: 'lora_manager_archive' + }; + if (resultKeys[source] && results[resultKeys[source]]) { + return 'found'; + } + + const downloadSource = missing.download_source || {}; + const mappedDownloadSource = { + model_list: 'local', + popular: 'local', + workflow: 'civitai' + }[downloadSource.source] || downloadSource.source; + if (mappedDownloadSource === source && downloadSource.url) { + return 'found'; + } + + return 'idle'; + } + + renderMissingSourcesSummary(missing = {}) { + const sourceItems = [ + { source: 'local' }, + { source: 'huggingface' }, + { source: 'civitai' } + ]; + if (this.isSourceAvailable('lora_manager_archive')) { + sourceItems.push({ source: 'lora_manager_archive' }); + } + + return sourceItems.map(item => { + const status = this.getMissingSourceStatus(missing, item.source); + const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, ''); + const label = this.getSearchSourceLabel(item.source); + const statusLabels = { + pending: 'Queued', + running: 'Searching', + found: 'Found', + none: 'No match', + error: 'Error', + idle: 'Not searched' + }; + const title = `${label}: ${statusLabels[status] || status}`; + const iconName = this.getSearchSourceIconName(item.source); + const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon'); + return `${iconHtml}`; + }).join(''); + } + + hasRenderableSearchState(state = {}) { + return Boolean( + (state.lastAttemptSources || []).length + || Object.keys(state.sourceProgress || {}).length + || this.hasSearchResults(state.results || {}) + || state.lastAttemptError + ); + } + + renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) { + const stats = this.getMissingModelSummaryStats(missingModels); + const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey); + const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null; + const activeHint = activeCount > 0 + ? `${activeCount} downloading` + : (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online'); + + let html = ` +
${this.escapeHtml(label)}`;
+ html += `${label}`;
- html += `