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:
Dariusz L
2026-04-24 21:42:29 +02:00
parent ca233f6a99
commit 5323ee91da
3 changed files with 304 additions and 107 deletions
+72 -1
View File
@@ -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"
),
+55
View File
@@ -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
View File
@@ -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>`;