Add local match search and open-folder features
Expose local model matching and folder-opening functionality. Adds search_local_matches to core/linker.py to run local filename/path similarity matching with deduplication and category prioritization. Registers two new HTTP endpoints in __init__.py: /model_linker/local-matches (POST) to search local files and /model_linker/open-containing-folder (POST) to open the OS file explorer at a model's path. Updates web/linker.js to add context-menu item, UI rendering and wiring for local matches (including confidence filtering, expandable alternatives, and button handlers), client-side openContainingFolder call, and triggers to refresh URN local matches. Also surfaces civitai version_name in model results. These changes enable searching and linking local model files from the UI and opening their containing folders.
This commit is contained in:
+72
-1
@@ -68,7 +68,11 @@ class ModelLinkerExtension:
|
||||
|
||||
# Import linker modules
|
||||
try:
|
||||
from .core.linker import analyze_and_find_matches, apply_resolution
|
||||
from .core.linker import (
|
||||
analyze_and_find_matches,
|
||||
apply_resolution,
|
||||
search_local_matches,
|
||||
)
|
||||
from .core.scanner import get_model_files
|
||||
except ImportError as e:
|
||||
self.logger.error(f"Model Linker: Could not import core modules: {e}")
|
||||
@@ -285,6 +289,70 @@ class ModelLinkerExtension:
|
||||
{"error": str(e), "success": False}, status=500
|
||||
)
|
||||
|
||||
@routes.post("/model_linker/local-matches")
|
||||
async def local_matches(request):
|
||||
"""Search local model files by filename/path."""
|
||||
try:
|
||||
data = await request.json()
|
||||
filename = data.get("filename", "")
|
||||
category = data.get("category", "")
|
||||
|
||||
if not filename:
|
||||
return web.json_response(
|
||||
{"error": "filename is required"}, status=400
|
||||
)
|
||||
|
||||
matches = search_local_matches(
|
||||
filename,
|
||||
category=category or None,
|
||||
similarity_threshold=0.0,
|
||||
max_matches_per_model=10,
|
||||
)
|
||||
return web.json_response({"matches": matches})
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Model Linker local-matches error: {e}", exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@routes.post("/model_linker/open-containing-folder")
|
||||
async def open_containing_folder(request):
|
||||
"""Open Explorer at the folder containing the selected model."""
|
||||
try:
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
data = await request.json()
|
||||
target_path = data.get("path", "")
|
||||
|
||||
if not target_path:
|
||||
return web.json_response(
|
||||
{"error": "path is required"}, status=400
|
||||
)
|
||||
|
||||
normalized_path = os.path.normpath(target_path)
|
||||
if not os.path.exists(normalized_path):
|
||||
return web.json_response(
|
||||
{"error": "path does not exist"}, status=404
|
||||
)
|
||||
|
||||
if os.path.isfile(normalized_path):
|
||||
absolute_path = os.path.abspath(normalized_path)
|
||||
subprocess.Popen(
|
||||
["explorer.exe", "/select,", absolute_path],
|
||||
shell=False,
|
||||
)
|
||||
else:
|
||||
os.startfile(normalized_path)
|
||||
|
||||
return web.json_response({"success": True})
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Model Linker open-containing-folder error: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
@routes.get("/model_linker/models")
|
||||
async def get_models(request):
|
||||
"""Get list of all available models."""
|
||||
@@ -744,6 +812,9 @@ class ModelLinkerExtension:
|
||||
results["civitai"] = {
|
||||
"source": "civitai",
|
||||
"name": model_info.get("model_name"),
|
||||
"version_name": model_info.get(
|
||||
"version_name"
|
||||
),
|
||||
"filename": model_info.get(
|
||||
"expected_filename"
|
||||
),
|
||||
|
||||
@@ -30,6 +30,61 @@ URL_PATTERN = re.compile(r'(https?://(?:huggingface\.co|civitai\.com)[^\s"\'<>\)
|
||||
MODEL_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".onnx", ".gguf")
|
||||
|
||||
|
||||
def search_local_matches(
|
||||
target_for_matching: str,
|
||||
category: Optional[str] = None,
|
||||
similarity_threshold: float = 0.0,
|
||||
max_matches_per_model: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Search local model files using the same matcher as workflow analysis.
|
||||
|
||||
Args:
|
||||
target_for_matching: Filename/path to match against local files
|
||||
category: Optional category hint to prioritize/filter candidates
|
||||
similarity_threshold: Minimum similarity score (0.0 to 1.0)
|
||||
max_matches_per_model: Maximum number of matches to return
|
||||
|
||||
Returns:
|
||||
Deduplicated list of local matches sorted by similarity
|
||||
"""
|
||||
available_models = get_model_files()
|
||||
|
||||
candidates = available_models
|
||||
if category and category != "unknown":
|
||||
candidates = [m for m in available_models if m.get("category") == category]
|
||||
candidates.extend(
|
||||
[m for m in available_models if m.get("category") != category]
|
||||
)
|
||||
|
||||
matches = find_matches(
|
||||
target_for_matching,
|
||||
candidates,
|
||||
threshold=similarity_threshold,
|
||||
max_results=max_matches_per_model,
|
||||
)
|
||||
|
||||
seen_absolute_paths = {}
|
||||
deduplicated_matches = []
|
||||
for match in matches:
|
||||
model_dict = match["model"]
|
||||
absolute_path = model_dict.get("path", "")
|
||||
if absolute_path:
|
||||
absolute_path = os.path.normpath(absolute_path)
|
||||
|
||||
if absolute_path not in seen_absolute_paths:
|
||||
seen_absolute_paths[absolute_path] = match
|
||||
deduplicated_matches.append(match)
|
||||
else:
|
||||
existing_match = seen_absolute_paths[absolute_path]
|
||||
if match["confidence"] > existing_match["confidence"]:
|
||||
idx = deduplicated_matches.index(existing_match)
|
||||
deduplicated_matches[idx] = match
|
||||
seen_absolute_paths[absolute_path] = match
|
||||
|
||||
return deduplicated_matches
|
||||
|
||||
|
||||
def extract_workflow_urls(workflow_json: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Extract model URLs from workflow JSON.
|
||||
|
||||
+177
-106
@@ -67,6 +67,14 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
}, [
|
||||
$el("span.ml-context-menu-item-icon", { textContent: "🌐" }),
|
||||
$el("span", { textContent: "Open in CivitAI" })
|
||||
]),
|
||||
$el("div.ml-context-menu-divider"),
|
||||
$el("div.ml-context-menu-item", {
|
||||
onclick: () => this.handleContextMenuAction('openFolder'),
|
||||
style: { cursor: "pointer" }
|
||||
}, [
|
||||
$el("span.ml-context-menu-item-icon", { textContent: "📁" }),
|
||||
$el("span", { textContent: "Open Containing Folder" })
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -250,6 +258,144 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
return html;
|
||||
}
|
||||
|
||||
renderLocalMatchesContent(missing, missingIndex = 0) {
|
||||
const allMatches = missing.matches || [];
|
||||
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
|
||||
const hasMatches = filteredMatches.length > 0;
|
||||
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
|
||||
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||||
|
||||
let html = '';
|
||||
|
||||
if (hasMatches) {
|
||||
const matchesToShow = perfectMatches.length > 0
|
||||
? perfectMatches
|
||||
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
|
||||
|
||||
const sortedMatches = matchesToShow.sort((a, b) => {
|
||||
if (a.confidence === 100 && b.confidence !== 100) return -1;
|
||||
if (a.confidence !== 100 && b.confidence === 100) return 1;
|
||||
return b.confidence - a.confidence;
|
||||
});
|
||||
|
||||
for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) {
|
||||
const match = sortedMatches[matchIndex];
|
||||
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
|
||||
const matchPath = match.model?.relative_path || match.filename || '';
|
||||
const formattedPath = this.formatPath(matchPath, 45);
|
||||
const isBestMatch = matchIndex === 0 && match.confidence >= 95;
|
||||
const modelData = encodeURIComponent(JSON.stringify(match.model || {}));
|
||||
|
||||
html += `<div class="ml-match-row ${isBestMatch ? 'ml-best-match' : ''}" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">`;
|
||||
html += this.getConfidenceBadge(match.confidence);
|
||||
html += `<span class="ml-match-filename" title="${formattedPath.full}">${formattedPath.display}</span>`;
|
||||
html += `<button id="${buttonId}" class="ml-btn ${isBestMatch ? 'ml-btn-primary' : 'ml-btn-secondary'} ml-btn-sm">`;
|
||||
html += `<span class="ml-btn-icon">🔗</span> Link`;
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
if (perfectMatches.length > 0 && otherMatches.length > 0) {
|
||||
const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
|
||||
html += `<div class="ml-no-matches" style="cursor: pointer; color: var(--ml-accent-blue);" onclick="document.getElementById('${matchId}').style.display = document.getElementById('${matchId}').style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent === '${otherMatches.length} other matches below 100%' ? 'Hide alternatives' : '${otherMatches.length} other matches below 100%'">${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} below 100%</div>`;
|
||||
html += `<div id="${matchId}" style="display: none; flex-direction: column; gap: 4px; margin-top: 8px;">`;
|
||||
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
|
||||
const match = otherMatches[mIdx];
|
||||
const confClass = match.confidence >= 70 ? 'ml-badge-medium' : 'ml-badge-low';
|
||||
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
|
||||
const modelData = encodeURIComponent(JSON.stringify(match.model || {}));
|
||||
html += `<div class="ml-match-row" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">`;
|
||||
html += `<span class="ml-badge ${confClass}">${match.confidence}%</span>`;
|
||||
html += `<span class="ml-match-filename" title="${match.path || match.filename}" style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${match.filename || match.path?.split(/[/\\]/).pop()}</span>`;
|
||||
html += `<button id="${altBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm">🔗 Link</button>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
} else if (missing.is_urn && !missing.civitai_info) {
|
||||
html += `<div class="ml-no-matches">Waiting for CivitAI filename to search local models...</div>`;
|
||||
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
|
||||
html += `<div class="ml-no-matches">No matches above 70% confidence</div>`;
|
||||
} else {
|
||||
html += `<div class="ml-no-matches">No local matches found</div>`;
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
wireLocalMatchButtons(container, missing, missingIndex = 0) {
|
||||
const allMatches = missing.matches || [];
|
||||
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
|
||||
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
|
||||
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||||
const matchesToShow = perfectMatches.length > 0
|
||||
? perfectMatches
|
||||
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
|
||||
|
||||
const sortedMatches = matchesToShow.sort((a, b) => {
|
||||
if (a.confidence === 100 && b.confidence !== 100) return -1;
|
||||
if (a.confidence !== 100 && b.confidence === 100) return 1;
|
||||
return b.confidence - a.confidence;
|
||||
});
|
||||
|
||||
sortedMatches.forEach((match, matchIndex) => {
|
||||
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
|
||||
const resolveButton = container.querySelector(`#${buttonId}`);
|
||||
if (resolveButton) {
|
||||
resolveButton.onclick = null;
|
||||
resolveButton.addEventListener('click', () => {
|
||||
this.queueResolution(missing, match.model);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (otherMatches && otherMatches.length > 0) {
|
||||
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
|
||||
const match = otherMatches[mIdx];
|
||||
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
|
||||
const altBtn = container.querySelector(`#${altBtnId}`);
|
||||
if (altBtn) {
|
||||
altBtn.addEventListener('click', () => {
|
||||
this.queueResolution(missing, match.model);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) return;
|
||||
|
||||
container.innerHTML = `<div class="ml-no-matches">Searching local matches for "${missing.civitai_info.expected_filename}"...</div>`;
|
||||
|
||||
try {
|
||||
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,
|
||||
category: missing.category || ''
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Local match search failed: ${response.status}`);
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('Model Linker: URN local match refresh error:', error);
|
||||
container.innerHTML = `<div class="ml-no-matches">Failed to refresh local matches.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click outside context menu to hide it
|
||||
*/
|
||||
@@ -306,10 +452,35 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
|
||||
if (action === 'civitai') {
|
||||
this.openInCivitAI(model);
|
||||
} else if (action === 'openFolder') {
|
||||
this.openContainingFolder(model);
|
||||
} else if (action === 'showInfo') {
|
||||
this.showModelInfo(model);
|
||||
}
|
||||
}
|
||||
|
||||
async openContainingFolder(model) {
|
||||
const path = model?.path || model?.resolved_path || '';
|
||||
if (!path) {
|
||||
this.showNotification('No local file path available', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.fetchApi('/model_linker/open-containing-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Open folder failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Model Linker: Open folder error:', error);
|
||||
this.showNotification('Failed to open containing folder', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open model in CivitAI
|
||||
@@ -3355,6 +3526,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
});
|
||||
|
||||
for (let mi = 0; mi < sortedMissingModels.length; mi++) {
|
||||
sortedMissingModels[mi].__displayIndex = mi;
|
||||
html += this.renderMissingModel(sortedMissingModels[mi], mi);
|
||||
}
|
||||
|
||||
@@ -3364,59 +3536,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
// Attach event listeners for resolve buttons (use sorted order)
|
||||
// Note: We need to match the exact same logic as renderMissingModel to find which buttons were rendered
|
||||
sortedMissingModels.forEach((missing, missingIndex) => {
|
||||
const allMatches = missing.matches || [];
|
||||
|
||||
// Filter out matches below 70% confidence threshold
|
||||
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
|
||||
|
||||
// Filter to only 100% matches if available, otherwise use filtered matches (>=70%)
|
||||
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
|
||||
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
|
||||
|
||||
// Match the same logic as renderMissingModel
|
||||
const matchesToShow = perfectMatches.length > 0
|
||||
? perfectMatches
|
||||
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
|
||||
|
||||
// Sort: 100% matches first, then by confidence descending (same as renderMissingModel)
|
||||
const sortedMatches = matchesToShow.sort((a, b) => {
|
||||
if (a.confidence === 100 && b.confidence !== 100) return -1;
|
||||
if (a.confidence !== 100 && b.confidence === 100) return 1;
|
||||
return b.confidence - a.confidence;
|
||||
});
|
||||
|
||||
sortedMatches.forEach((match, matchIndex) => {
|
||||
// Attach listener to all matches (all now have resolve buttons)
|
||||
// Use unique ID that includes missingIndex to avoid conflicts between different missing models with same node_id
|
||||
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
|
||||
const resolveButton = container.querySelector(`#${buttonId}`);
|
||||
console.log('Attaching listener for', buttonId, 'missingIndex:', missingIndex);
|
||||
if (resolveButton) {
|
||||
// Remove any existing listeners to prevent duplicates
|
||||
resolveButton.onclick = null;
|
||||
resolveButton.addEventListener('click', () => {
|
||||
console.log('>>> Queue button clicked:', buttonId, missing.original_path, '->', match.model?.filename);
|
||||
// Queue the selection instead of directly applying
|
||||
this.queueResolution(missing, match.model);
|
||||
});
|
||||
} else {
|
||||
console.warn('Button not found:', buttonId);
|
||||
}
|
||||
});
|
||||
|
||||
// Attach alternative (other matches below 100%) button listeners
|
||||
if (otherMatches && otherMatches.length > 0) {
|
||||
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
|
||||
const match = otherMatches[mIdx];
|
||||
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
|
||||
const altBtn = container.querySelector(`#${altBtnId}`);
|
||||
if (altBtn) {
|
||||
altBtn.addEventListener('click', () => {
|
||||
this.queueResolution(missing, match.model);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.wireLocalMatchButtons(container, missing, missingIndex);
|
||||
|
||||
// Attach download button listener
|
||||
const downloadBtnId = `download-${missing.node_id}-${missing.widget_index}`;
|
||||
@@ -3635,59 +3755,9 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
// LEFT COLUMN: Local Matches
|
||||
html += `<div class="ml-column">`;
|
||||
html += `<div class="ml-column-header">Local Matches</div>`;
|
||||
|
||||
if (hasMatches) {
|
||||
// If we have 100% matches, only show those. Otherwise, show other matches sorted by confidence
|
||||
const matchesToShow = perfectMatches.length > 0
|
||||
? perfectMatches
|
||||
: otherMatches.sort((a, b) => b.confidence - a.confidence).slice(0, 5);
|
||||
|
||||
// Sort: 100% matches first, then by confidence descending
|
||||
const sortedMatches = matchesToShow.sort((a, b) => {
|
||||
if (a.confidence === 100 && b.confidence !== 100) return -1;
|
||||
if (a.confidence !== 100 && b.confidence === 100) return 1;
|
||||
return b.confidence - a.confidence;
|
||||
});
|
||||
|
||||
for (let matchIndex = 0; matchIndex < sortedMatches.length; matchIndex++) {
|
||||
const match = sortedMatches[matchIndex];
|
||||
const buttonId = `resolve-${missingIndex}-${missing.node_id}-${missing.widget_index}-${matchIndex}`;
|
||||
const matchPath = match.model?.relative_path || match.filename || '';
|
||||
const formattedPath = this.formatPath(matchPath, 45);
|
||||
const isBestMatch = matchIndex === 0 && match.confidence >= 95;
|
||||
|
||||
html += `<div class="ml-match-row ${isBestMatch ? 'ml-best-match' : ''}">`;
|
||||
html += this.getConfidenceBadge(match.confidence);
|
||||
html += `<span class="ml-match-filename" title="${formattedPath.full}">${formattedPath.display}</span>`;
|
||||
html += `<button id="${buttonId}" class="ml-btn ${isBestMatch ? 'ml-btn-primary' : 'ml-btn-secondary'} ml-btn-sm">`;
|
||||
html += `<span class="ml-btn-icon">🔗</span> Link`;
|
||||
html += `</button>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
|
||||
// Add note if only showing 100% matches - make it expandable
|
||||
if (perfectMatches.length > 0 && otherMatches.length > 0) {
|
||||
const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
|
||||
html += `<div class="ml-no-matches" style="cursor: pointer; color: var(--ml-accent-blue);" onclick="document.getElementById('${matchId}').style.display = document.getElementById('${matchId}').style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent === '${otherMatches.length} other matches below 100%' ? 'Hide alternatives' : '${otherMatches.length} other matches below 100%'">${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} below 100%</div>`;
|
||||
// Hidden container with other matches
|
||||
html += `<div id="${matchId}" style="display: none; flex-direction: column; gap: 4px; margin-top: 8px;">`;
|
||||
for (let mIdx = 0; mIdx < otherMatches.length; mIdx++) {
|
||||
const match = otherMatches[mIdx];
|
||||
const confClass = match.confidence >= 70 ? 'ml-badge-medium' : 'ml-badge-low';
|
||||
const altBtnId = `resolve-alt-${missingIndex}-${missing.node_id}-${missing.widget_index}-${mIdx}`;
|
||||
html += `<div class="ml-match-row">`;
|
||||
html += `<span class="ml-badge ${confClass}">${match.confidence}%</span>`;
|
||||
html += `<span class="ml-match-filename" title="${match.path || match.filename}" style="flex: 1; overflow: hidden; text-overflow: ellipsis;">${match.filename || match.path?.split(/[/\\]/).pop()}</span>`;
|
||||
html += `<button id="${altBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm">🔗 Link</button>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
} else if (allMatches.length > 0 && filteredMatches.length === 0) {
|
||||
html += `<div class="ml-no-matches">No matches above 70% confidence</div>`;
|
||||
} else {
|
||||
html += `<div class="ml-no-matches">No local matches found</div>`;
|
||||
}
|
||||
html += `<div id="local-matches-body-${missing.node_id}-${missing.widget_index}">`;
|
||||
html += this.renderLocalMatchesContent(missing, missingIndex);
|
||||
html += `</div>`;
|
||||
|
||||
// Add all-models search picker - combo-style dropdown
|
||||
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
|
||||
@@ -4615,6 +4685,7 @@ class LinkerManagerDialog extends ComfyDialog {
|
||||
this.downloadModel(missing);
|
||||
});
|
||||
}
|
||||
this.refreshUrnLocalMatches(missing);
|
||||
}
|
||||
} else if (downloadEl) {
|
||||
downloadEl.innerHTML = `<div class="ml-download-info">Unable to resolve direct download for this URN.</div>`;
|
||||
|
||||
Reference in New Issue
Block a user