From 5323ee91dac345e10f78d53a94dc084a3447dea2 Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Fri, 24 Apr 2026 21:42:29 +0200 Subject: [PATCH] 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. --- __init__.py | 73 ++++++++++++- core/linker.py | 55 ++++++++++ web/linker.js | 283 +++++++++++++++++++++++++++++++------------------ 3 files changed, 304 insertions(+), 107 deletions(-) diff --git a/__init__.py b/__init__.py index 994fa25..74ee0bf 100644 --- a/__init__.py +++ b/__init__.py @@ -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" ), diff --git a/core/linker.py b/core/linker.py index 48fdf5b..4abb710 100644 --- a/core/linker.py +++ b/core/linker.py @@ -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. diff --git a/web/linker.js b/web/linker.js index 7b8176d..3b78c80 100644 --- a/web/linker.js +++ b/web/linker.js @@ -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 += `
`; + html += this.getConfidenceBadge(match.confidence); + html += `${formattedPath.display}`; + html += ``; + html += `
`; + } + + if (perfectMatches.length > 0 && otherMatches.length > 0) { + const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`; + html += `
${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} below 100%
`; + html += ``; + } + } else if (missing.is_urn && !missing.civitai_info) { + html += `
Waiting for CivitAI filename to search local models...
`; + } else if (allMatches.length > 0 && filteredMatches.length === 0) { + html += `
No matches above 70% confidence
`; + } else { + html += `
No local matches found
`; + } + + 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 = `
Searching local matches for "${missing.civitai_info.expected_filename}"...
`; + + 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 = `
Failed to refresh local matches.
`; + } + } + /** * 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 += `
`; html += `
Local Matches
`; - - 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 += `
`; - html += this.getConfidenceBadge(match.confidence); - html += `${formattedPath.display}`; - html += ``; - html += `
`; - } - - // 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 += `
${otherMatches.length} other match${otherMatches.length > 1 ? 'es' : ''} below 100%
`; - // Hidden container with other matches - html += ``; - } - } else if (allMatches.length > 0 && filteredMatches.length === 0) { - html += `
No matches above 70% confidence
`; - } else { - html += `
No local matches found
`; - } + html += `
`; + html += this.renderLocalMatchesContent(missing, missingIndex); + html += `
`; // 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 = `
Unable to resolve direct download for this URN.
`;