diff --git a/web/css/linker-main.css b/web/css/linker-main.css index fd8098f..77d4636 100644 --- a/web/css/linker-main.css +++ b/web/css/linker-main.css @@ -19,24 +19,6 @@ color: var(--ml-text-muted); } -.ml-status-card-vertical { - flex-direction: column; - align-items: flex-start; -} - -.ml-action-row { - margin-top: var(--ml-spacing-8); - display: flex; - gap: var(--ml-spacing-8); -} - -.ml-search-results-stack { - margin-top: var(--ml-spacing-8); - display: flex; - flex-direction: column; - gap: var(--ml-spacing-8); -} - .ml-stack-md { display: flex; flex-direction: column; @@ -764,6 +746,118 @@ margin-top: 8px; display: none; } +.ml-search-results-table-wrap { + margin-top: 8px; + overflow-x: auto; + background: rgba(255,255,255,0.025); + border: 1px solid var(--ml-border); + border-radius: 8px; +} +.ml-search-results-table { + width: 100%; + min-width: 620px; + border-collapse: collapse; + table-layout: fixed; + font-size: 11px; +} +.ml-search-results-table th, +.ml-search-results-table td { + padding: 7px 10px; + border-bottom: 1px solid rgba(255,255,255,0.065); + vertical-align: middle; +} +.ml-search-results-table th { + color: var(--ml-text-muted); + font-size: 10px; + font-weight: 700; + text-align: left; + background: rgba(255,255,255,0.035); +} +.ml-search-results-table tr:last-child td { + border-bottom: 0; +} +.ml-search-col-source { + width: 124px; +} +.ml-search-col-match { + width: 78px; +} +.ml-search-col-size { + width: 92px; +} +.ml-search-col-actions { + width: 86px; +} +.ml-search-source-pill { + display: inline-flex; + max-width: 100%; + min-height: 18px; + align-items: center; + padding: 2px 7px; + overflow: hidden; + color: var(--ml-text); + background: rgba(255,255,255,0.1); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 999px; + font-size: 9px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} +.ml-search-result-model { + display: grid; + min-width: 0; + gap: 2px; +} +.ml-search-result-model span, +.ml-search-result-model small { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ml-search-result-model span { + color: var(--ml-text); + font-weight: 650; +} +.ml-search-result-model small, +.ml-search-size, +.ml-search-result-empty { + color: var(--ml-text-muted); + font-size: 10px; +} +.ml-search-match { + font-weight: 750; +} +.ml-search-match-strong { + color: #72d38a; +} +.ml-search-match-medium { + color: #f1cf64; +} +.ml-search-match-weak { + color: #8fd0ff; +} +.ml-search-match-neutral { + color: var(--ml-text-muted); +} +.ml-search-result-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} +.ml-search-result-action-btn { + width: 24px; + min-width: 24px; + min-height: 24px; + padding: 0; + border-radius: 7px; +} +.ml-search-result-action-btn svg { + width: 13px; + height: 13px; + display: block; +} .ml-search-progress-list { display: grid; gap: 6px; diff --git a/web/linker.js b/web/linker.js index 9d94b4a..8edd365 100644 --- a/web/linker.js +++ b/web/linker.js @@ -363,53 +363,119 @@ class LinkerManagerDialog extends ComfyDialog { return html; } - formatSearchResultMeta(result = {}, options = {}) { - const filename = options.filename || result.filename || ''; - const secondaryText = options.secondaryText || ''; - const confidenceValue = result.confidence; - const confidenceDisplay = typeof confidenceValue === 'number' - ? `${Math.round(confidenceValue)}% match` - : ''; - const sizeDisplay = result.size - ? (typeof result.size === 'number' ? this.formatBytes(result.size) : result.size) - : ''; - - let html = `
`; - if (filename) { - html += `${filename}`; - } - if (confidenceDisplay) { - html += ` ${confidenceDisplay}`; - } - if (sizeDisplay) { - html += ` [${sizeDisplay}]`; - } - if (secondaryText) { - html += ` ${secondaryText}`; - } - html += `
`; - return html; + formatSearchResultSize(result = {}) { + if (result.size === 0) return '0 B'; + if (!result.size) return ''; + return typeof result.size === 'number' ? this.formatBytes(result.size) : String(result.size); } - renderOnlineSearchResultCard({ - statusClass, - title, - result, - filename, - secondaryText = '', - actionHtml = '', - topLineHtml = '' - }) { - let html = `
`; - html += `${title}`; - if (topLineHtml) { - html += topLineHtml; + getSearchResultMatchDisplay(result = {}, fallbackLabel = 'Match', fallbackClass = 'neutral') { + const matchType = String(result.match_type || '').toLowerCase(); + if (matchType === 'exact') { + return { label: 'Exact', className: 'strong' }; } - html += this.formatSearchResultMeta(result, { filename, secondaryText }); - if (actionHtml) { - html += `
${actionHtml}
`; + + const confidence = Number(result.confidence); + if (Number.isFinite(confidence) && confidence > 0) { + const className = confidence >= 95 ? 'strong' : (confidence >= 70 ? 'medium' : 'weak'); + return { label: `${Math.round(confidence)}%`, className }; } - html += `
`; + + if (matchType === 'partial') { + return { label: 'Partial', className: 'medium' }; + } + if (matchType === 'fuzzy' || matchType === 'similar') { + return { label: matchType === 'fuzzy' ? 'Fuzzy' : 'Similar', className: 'medium' }; + } + + return { label: fallbackLabel, className: fallbackClass }; + } + + renderSearchResultsTable(rows = []) { + if (!rows.length) return ''; + + let html = ` +
+ + + + + + + + + + + + + + + + + + + `; + + for (const row of rows) { + const sourceKey = String(row.sourceKey || '').replace(/[^a-z0-9_-]/gi, ''); + const sourceLabel = this.escapeHtml(row.sourceLabel || row.sourceKey || 'Source'); + const model = this.escapeHtml(row.model || row.filename || 'Model'); + const secondary = row.secondary ? this.escapeHtml(row.secondary) : ''; + const filename = row.filename && row.filename !== row.model ? this.escapeHtml(row.filename) : ''; + const match = row.match || { label: 'Match', className: 'neutral' }; + const matchClass = String(match.className || 'neutral').replace(/[^a-z0-9_-]/gi, ''); + const size = this.escapeHtml(row.size || '-'); + const downloadUrl = row.downloadUrl || ''; + const openUrl = row.openUrl || ''; + const downloadFilename = row.downloadFilename || row.filename || row.model || 'model'; + const category = row.category || ''; + + let actions = ''; + if (downloadUrl) { + actions += ` + + `; + } + if (openUrl) { + actions += ` + ${getSvgIcon('externalLink')} + `; + } + if (!actions) { + actions = '-'; + } + + html += ` + + + + + + + + `; + } + + html += ` + +
SourceModelMatchSizeActions
${sourceLabel} +
+ ${model} + ${secondary || filename ? `${secondary || filename}` : ''} +
+
${this.escapeHtml(match.label)}${size}
${actions}
+
+ `; return html; } @@ -4934,99 +5000,104 @@ class LinkerManagerDialog extends ComfyDialog { return; } - let html = `${progressHtml}
`; + const rows = []; + let statusHtml = ''; if (!hasActiveProgress && state?.lastAttemptError) { - html += this.renderStatusMessage(state.lastAttemptError, 'error'); + statusHtml += 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'); + statusHtml += this.renderStatusMessage(`No new matches found in ${searchedLabel}. Existing results are kept below.`, 'warning'); } - // Popular models result (highest priority) if (popular) { const popularFilename = popular.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const actionHtml = ``; - html += this.renderOnlineSearchResultCard({ - statusClass: 'ml-status-success', - title: 'Found in Popular Models', - result: popular, + rows.push({ + sourceKey: 'popular', + sourceLabel: 'Popular', + model: popular.name || popularFilename, filename: popularFilename, - secondaryText: popular.name || '', - actionHtml + secondary: popular.name && popular.name !== popularFilename ? popularFilename : '', + match: this.getSearchResultMatchDisplay(popular, 'Known', 'strong'), + size: this.formatSearchResultSize(popular), + downloadUrl: popular.url, + downloadFilename: popularFilename, + category: popular.directory || missing.category, + openUrl: this.getModelCardUrl(popular.url) }); } - // Model list result (ComfyUI Manager database with fuzzy matching) if (modelListResult && modelListResult.url) { - const matchType = modelListResult.match_type === 'exact' ? 'Exact match' : 'Similar model'; - const statusClass = modelListResult.match_type === 'exact' ? 'ml-status-success' : 'ml-status-info'; - const actionHtml = ``; - html += this.renderOnlineSearchResultCard({ - statusClass, - title: `${matchType} in Model Database`, - result: modelListResult, + rows.push({ + sourceKey: 'model-list', + sourceLabel: 'Local Database', + model: modelListResult.name || modelListResult.filename, filename: modelListResult.filename, - secondaryText: modelListResult.name || '', - actionHtml + secondary: modelListResult.name && modelListResult.name !== modelListResult.filename ? modelListResult.filename : '', + match: this.getSearchResultMatchDisplay(modelListResult), + size: this.formatSearchResultSize(modelListResult), + downloadUrl: modelListResult.url, + downloadFilename: modelListResult.filename, + category: modelListResult.directory || missing.category, + openUrl: this.getModelCardUrl(modelListResult.url) }); } - // HuggingFace result if (hfResult && hfResult.url) { const hfRepo = hfResult.repo_id || hfResult.repo || ''; const hfModelUrl = hfRepo ? `https://huggingface.co/${hfRepo}` : this.getModelCardUrl(hfResult.url); - let actionHtml = ``; - if (hfModelUrl) { - actionHtml += `Open on HuggingFace`; - } - html += this.renderOnlineSearchResultCard({ - statusClass: 'ml-status-info', - title: 'Found on HuggingFace', - result: hfResult, + rows.push({ + sourceKey: 'huggingface', + sourceLabel: 'HuggingFace', + model: hfRepo || hfResult.filename, filename: hfResult.filename, - secondaryText: hfRepo, - actionHtml + secondary: hfResult.path && hfResult.path !== hfResult.filename ? hfResult.path : '', + match: this.getSearchResultMatchDisplay(hfResult), + size: this.formatSearchResultSize(hfResult), + downloadUrl: hfResult.url, + downloadFilename: hfResult.filename, + category: missing.category, + openUrl: hfModelUrl }); } - // LoRA Manager archive result if (loraManagerArchiveResult && loraManagerArchiveResult.url) { const archiveFilename = loraManagerArchiveResult.filename || missing.original_path?.split('/').pop()?.split('\\').pop() || ''; - const actionHtml = loraManagerArchiveResult.download_url - ? `` - : ''; - html += this.renderOnlineSearchResultCard({ - statusClass: 'ml-status-info', - title: 'Found in LoRA Manager Archive', - result: loraManagerArchiveResult, + rows.push({ + sourceKey: 'lora-archive', + sourceLabel: 'LoRA Archive', + model: loraManagerArchiveResult.name || loraManagerArchiveResult.version_name || archiveFilename, filename: archiveFilename, - secondaryText: loraManagerArchiveResult.version_name || loraManagerArchiveResult.name || '', - actionHtml + secondary: loraManagerArchiveResult.version_name || '', + match: this.getSearchResultMatchDisplay(loraManagerArchiveResult), + size: this.formatSearchResultSize(loraManagerArchiveResult), + downloadUrl: loraManagerArchiveResult.download_url || '', + downloadFilename: archiveFilename, + category: missing.category, + openUrl: loraManagerArchiveResult.url }); } - // CivitAI result if (civitaiResult && civitaiResult.download_url) { const modelUrl = civitaiResult.url || (civitaiResult.model_id ? `https://civitai.com/models/${civitaiResult.model_id}${civitaiResult.version_id ? `?modelVersionId=${civitaiResult.version_id}` : ''}` : ''); - // Use expected_filename from URN resolution, or fallback to civitaiResult filename const downloadFilename = missing.civitai_info?.expected_filename || civitaiResult.filename || civitaiResult.name; - // Build display name with version if available - const modelName = missing.civitai_info?.version_name ? `${missing.civitai_info.model_name} v${missing.civitai_info.version_name}` : (civitaiResult.name || 'Model'); - const topLineHtml = `
${civitaiResult.type || ''}
`; - const actionHtml = ``; - html += this.renderOnlineSearchResultCard({ - statusClass: 'ml-status-warning', - title: 'Found on CivitAI', - result: civitaiResult, + const modelName = missing.civitai_info?.version_name ? `${missing.civitai_info.model_name} v${missing.civitai_info.version_name}` : (civitaiResult.name || downloadFilename || 'Model'); + rows.push({ + sourceKey: 'civitai', + sourceLabel: 'CivitAI', + model: modelName, filename: downloadFilename, - secondaryText: '', - topLineHtml, - actionHtml + secondary: civitaiResult.type || civitaiResult.base_model || '', + match: this.getSearchResultMatchDisplay(civitaiResult), + size: this.formatSearchResultSize(civitaiResult), + downloadUrl: civitaiResult.download_url, + downloadFilename, + category: missing.category, + openUrl: modelUrl }); } - html += '
'; + const html = `${progressHtml}${statusHtml}${this.renderSearchResultsTable(rows)}`; container.innerHTML = html; // Attach download listeners diff --git a/web/utils/icon_utils.js b/web/utils/icon_utils.js index c74095f..17985c6 100644 --- a/web/utils/icon_utils.js +++ b/web/utils/icon_utils.js @@ -4,6 +4,8 @@ import { app } from "../../../scripts/app.js"; const SVG_ICONS = { search: ``, locate: ``, + download: ``, + externalLink: ``, eye: ``, eyeOff: ``, civitai: ``