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 += ``;
+ 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 += `
`;
+ html += `${match.confidence}%`;
+ html += `${match.filename || match.path?.split(/[/\\]/).pop()}`;
+ html += ``;
+ html += `
`;
+ }
+ 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 += `
`;
- 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 += `
`;
- html += `${match.confidence}%`;
- html += `${match.filename || match.path?.split(/[/\\]/).pop()}`;
- html += ``;
- html += `
`;
- }
- 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.
`;