diff --git a/__init__.py b/__init__.py
index 74ee0bf..7283148 100644
--- a/__init__.py
+++ b/__init__.py
@@ -824,6 +824,8 @@ class ModelLinkerExtension:
"model_id": model_id,
"version_id": version_id,
"size": primary_file.get("size"),
+ "base_model": model_info.get("base_model"),
+ "tags": model_info.get("tags", []),
}
results["found"] = True
elif category:
@@ -846,6 +848,8 @@ class ModelLinkerExtension:
),
"url": first_result.get("url"),
"size": first_result.get("size"),
+ "base_model": first_result.get("base_model"),
+ "tags": first_result.get("tags", []),
}
results["found"] = True
else:
@@ -1006,6 +1010,56 @@ class ModelLinkerExtension:
)
return web.json_response({"error": str(e)}, status=500)
+ @routes.get("/model_linker/subfolders/{category}")
+ async def get_subfolders(request):
+ """Get known subfolders for a category using ComfyUI folder_paths."""
+ try:
+ import folder_paths
+
+ raw_category = (request.match_info.get("category") or "").strip()
+ category_map = {
+ "checkpoint": "checkpoints",
+ "checkpoints": "checkpoints",
+ "lora": "loras",
+ "loras": "loras",
+ "vae": "vae",
+ "controlnet": "controlnet",
+ "clip": "clip",
+ "clip_vision": "clip_vision",
+ "upscaler": "upscale_models",
+ "upscale_models": "upscale_models",
+ "embeddings": "embeddings",
+ "embedding": "embeddings",
+ "diffusion_models": "diffusion_models",
+ "unet": "diffusion_models",
+ "text_encoders": "text_encoders",
+ "text_encoder": "text_encoders",
+ "ipadapter": "ipadapter",
+ "ip-adapter": "ipadapter",
+ "default": "upscale_models",
+ }
+ category = category_map.get(raw_category.lower(), raw_category.lower())
+
+ subfolders = set()
+ filenames = folder_paths.get_filename_list(category) or []
+ for rel_path in filenames:
+ if not isinstance(rel_path, str):
+ continue
+ parts = [p for p in rel_path.replace("/", "\\").split("\\") if p]
+ if len(parts) <= 1:
+ continue
+ current = ""
+ for part in parts[:-1]:
+ current = f"{current}\\{part}" if current else part
+ subfolders.add(current)
+
+ return web.json_response(sorted(subfolders))
+ except Exception as e:
+ self.logger.error(
+ f"Model Linker subfolders error: {e}", exc_info=True
+ )
+ return web.json_response({"error": str(e)}, status=500)
+
self.routes_setup = True
self.logger.info("Model Linker: API routes registered successfully")
return True
diff --git a/core/downloader.py b/core/downloader.py
index 89bae27..b2a07a9 100644
--- a/core/downloader.py
+++ b/core/downloader.py
@@ -96,11 +96,31 @@ def get_download_directory(category: str) -> Optional[str]:
folder_key = category_map.get(category.lower(), category.lower())
+ def _normalize(path_value: str) -> str:
+ return os.path.normcase(os.path.abspath(path_value))
+
+ def _is_within(path_value: str, root_value: str) -> bool:
+ try:
+ return os.path.commonpath([_normalize(path_value), _normalize(root_value)]) == _normalize(root_value)
+ except Exception:
+ return False
+
+ def _choose_preferred_path(paths: List[str]) -> Optional[str]:
+ if not paths:
+ return None
+
+ comfy_root = os.path.dirname(os.path.abspath(getattr(folder_paths, "__file__", "")))
+ if comfy_root:
+ redirected_paths = [path for path in paths if not _is_within(path, comfy_root)]
+ if redirected_paths:
+ return redirected_paths[0]
+
+ return paths[0]
+
try:
paths = folder_paths.get_folder_paths(folder_key)
if paths:
- # Return the first path (usually the main models directory)
- return paths[0]
+ return _choose_preferred_path(paths)
# If category not found, try to get any models directory as fallback
all_names = folder_paths.get_folder_names()
@@ -108,7 +128,7 @@ def get_download_directory(category: str) -> Optional[str]:
# Fall back to first available directory
fallback_paths = folder_paths.get_folder_paths(all_names[0])
if fallback_paths:
- return fallback_paths[0]
+ return _choose_preferred_path(fallback_paths)
except Exception as e:
log_debug(f"Could not get folder path for {folder_key}: {e}")
diff --git a/core/sources/civitai.py b/core/sources/civitai.py
index 803be5a..9679dd7 100644
--- a/core/sources/civitai.py
+++ b/core/sources/civitai.py
@@ -148,6 +148,8 @@ def search_civitai_for_file(
"url": f"https://civitai.com/models/{model_id}",
"download_url": download_url,
"size": file_info.get("sizeKB", 0) * 1024,
+ "base_model": version.get("baseModel"),
+ "tags": item.get("tags", []),
"match_type": "exact",
}
_search_cache[cache_key] = result
@@ -171,6 +173,8 @@ def search_civitai_for_file(
"url": f"https://civitai.com/models/{model_id}",
"download_url": download_url,
"size": file_info.get("sizeKB", 0) * 1024,
+ "base_model": version.get("baseModel"),
+ "tags": item.get("tags", []),
"match_type": "partial",
}
_search_cache[cache_key] = result
@@ -261,6 +265,8 @@ def search_civitai(
"url": f"https://civitai.com/models/{model_id}",
"download_url": get_civitai_download_url(version_id, api_key),
"downloads": model.get("stats", {}).get("downloadCount", 0),
+ "base_model": latest.get("baseModel"),
+ "tags": model.get("tags", []),
}
if primary_file:
@@ -389,6 +395,8 @@ def resolve_urn(
"model_name": data.get("name", "Unknown"),
"version_name": target_version.get("name", "Unknown"),
"expected_filename": primary_file.get("name", "Unknown"),
+ "base_model": target_version.get("baseModel"),
+ "tags": data.get("tags", []),
"files": [
{"name": f.get("name"), "size": f.get("sizeKB", 0) * 1024}
for f in files
diff --git a/web/linker.js b/web/linker.js
index 842a1f5..b9c9e6a 100644
--- a/web/linker.js
+++ b/web/linker.js
@@ -16,6 +16,8 @@ class LinkerManagerDialog extends ComfyDialog {
this.currentWorkflow = null;
this.missingModels = [];
this.allModels = null; // list of all available models for dropdown
+ this.downloadDirectories = null;
+ this.downloadSubfolders = new Map();
this.pendingResolutions = [];
this.pendingIndex = new Map(); // key -> index in pendingResolutions
this.activeDownloads = {}; // Track active downloads
@@ -254,6 +256,7 @@ class LinkerManagerDialog extends ComfyDialog {
html += `
Direct CivitAI download can require an API key for some models.`;
}
html += ``;
+ html += this.renderDownloadTargetControls(missing, downloadSource.directory || downloadSource.category || 'checkpoints');
html += ``;
return html;
}
@@ -1910,6 +1913,71 @@ class LinkerManagerDialog extends ComfyDialog {
font-size: 12px;
color: var(--ml-text-muted);
}
+ .ml-download-target {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid rgba(255,255,255,0.06);
+ display: grid;
+ gap: 8px;
+ }
+ .ml-download-target-row {
+ display: grid;
+ gap: 6px;
+ }
+ .ml-download-target-label {
+ font-size: 11px;
+ font-weight: 700;
+ color: var(--ml-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ }
+ .ml-download-target-input,
+ .ml-download-target-select {
+ width: 100%;
+ padding: 8px 10px;
+ background: rgba(255,255,255,0.04);
+ color: var(--ml-text);
+ border: 1px solid var(--ml-border);
+ border-radius: 10px;
+ font-size: 12px;
+ outline: none;
+ }
+ .ml-download-target-input:focus,
+ .ml-download-target-select:focus {
+ border-color: rgba(78,161,255,0.45);
+ box-shadow: 0 0 0 3px rgba(78,161,255,0.12);
+ }
+ .ml-download-target-wrap {
+ position: relative;
+ }
+ .ml-download-target-list {
+ position: absolute;
+ top: calc(100% + 4px);
+ left: 0;
+ right: 0;
+ max-height: 220px;
+ overflow: auto;
+ display: none;
+ z-index: 100000;
+ background: #22272e;
+ border: 1px solid var(--ml-border-strong);
+ border-radius: 12px;
+ box-shadow: var(--ml-shadow);
+ }
+ .ml-download-target-option {
+ padding: 8px 10px;
+ cursor: pointer;
+ border-bottom: 1px solid rgba(255,255,255,0.04);
+ color: var(--ml-text);
+ font-size: 12px;
+ }
+ .ml-download-target-option:last-child {
+ border-bottom: none;
+ }
+ .ml-download-target-option:hover,
+ .ml-download-target-option.is-highlighted {
+ background: rgba(78,161,255,0.16);
+ }
`;
document.head.appendChild(styles);
@@ -2329,6 +2397,230 @@ class LinkerManagerDialog extends ComfyDialog {
}
}
+ async ensureDownloadDirectoriesLoaded() {
+ if (this.downloadDirectories) return;
+ try {
+ const resp = await api.fetchApi('/model_linker/directories');
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const directories = await resp.json();
+ this.downloadDirectories = directories && typeof directories === 'object' ? directories : {};
+ } catch (e) {
+ console.warn('Model Linker: could not load download directories', e);
+ this.downloadDirectories = {};
+ }
+ }
+
+ getDownloadCategoryOptions(defaultCategory = 'checkpoints') {
+ const directories = this.downloadDirectories || {};
+ const keys = Object.keys(directories);
+ const preferred = defaultCategory || 'checkpoints';
+ const ordered = [
+ preferred,
+ ...keys.filter(key => key !== preferred)
+ ].filter((value, index, arr) => value && arr.indexOf(value) === index);
+
+ return ordered.length > 0 ? ordered : [preferred];
+ }
+
+ getAvailableSubfolders(category = '') {
+ return this.downloadSubfolders.get((category || '').toLowerCase()) || [];
+ }
+
+ normalizeFolderToken(value = '') {
+ return String(value || '')
+ .toLowerCase()
+ .replace(/[\/\\]+/g, ' ')
+ .replace(/[^a-z0-9]+/g, '');
+ }
+
+ getSuggestedCivitaiSubfolder(missing, category, folders = []) {
+ if ((category || '').toLowerCase() !== 'loras' || !folders.length) {
+ return '';
+ }
+
+ const civitaiData = {
+ ...(missing?.civitai_info || {}),
+ ...(missing?.download_source || {})
+ };
+ const baseModel = civitaiData.base_model || '';
+ const tags = Array.isArray(civitaiData.tags) ? civitaiData.tags.filter(Boolean) : [];
+ if (!baseModel) return '';
+
+ const priorityTags = [
+ 'concept',
+ 'style',
+ 'character',
+ 'clothing',
+ 'pose',
+ 'object',
+ 'vehicle',
+ 'artist',
+ 'celebrity'
+ ];
+ const normalizedBase = this.normalizeFolderToken(baseModel);
+ if (!normalizedBase) return '';
+
+ const folderEntries = folders.map(folder => {
+ const segments = String(folder || '').split(/[\/\\]/).filter(Boolean);
+ return {
+ value: folder,
+ segments,
+ normalizedSegments: segments.map(segment => this.normalizeFolderToken(segment))
+ };
+ });
+
+ const baseMatches = folderEntries.filter(entry => entry.normalizedSegments[0] === normalizedBase);
+ if (!baseMatches.length) return '';
+
+ const exactBase = baseMatches.find(entry => entry.segments.length === 1);
+ const orderedTags = [
+ ...priorityTags.filter(tag => tags.some(value => this.normalizeFolderToken(value) === this.normalizeFolderToken(tag))),
+ ...tags
+ ].filter((value, index, arr) => value && arr.findIndex(other => this.normalizeFolderToken(other) === this.normalizeFolderToken(value)) === index);
+
+ for (const tag of orderedTags) {
+ const normalizedTag = this.normalizeFolderToken(tag);
+ if (!normalizedTag) continue;
+ const match = baseMatches.find(entry => entry.normalizedSegments[1] === normalizedTag);
+ if (match) {
+ return match.value;
+ }
+ }
+
+ return exactBase?.value || '';
+ }
+
+ async applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl) {
+ if (!categoryEl || !subfolderEl || subfolderEl.value.trim()) return;
+
+ await this.ensureDownloadSubfoldersLoaded(categoryEl.value);
+ const folders = this.getAvailableSubfolders(categoryEl.value);
+ const suggestion = this.getSuggestedCivitaiSubfolder(missing, categoryEl.value, folders);
+ if (suggestion) {
+ subfolderEl.value = suggestion;
+ }
+ }
+
+ async ensureDownloadSubfoldersLoaded(category = '') {
+ const key = (category || '').trim().toLowerCase();
+ if (!key) return [];
+ if (this.downloadSubfolders.has(key)) {
+ return this.downloadSubfolders.get(key) || [];
+ }
+
+ try {
+ const resp = await api.fetchApi(`/model_linker/subfolders/${encodeURIComponent(key)}`);
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const subfolders = await resp.json();
+ const list = Array.isArray(subfolders) ? subfolders : [];
+ this.downloadSubfolders.set(key, list);
+ return list;
+ } catch (e) {
+ console.warn(`Model Linker: could not load subfolders for ${key}`, e);
+ this.downloadSubfolders.set(key, []);
+ return [];
+ }
+ }
+
+ renderDownloadTargetControls(missing, defaultCategory = 'checkpoints') {
+ const selectId = `download-category-${missing.node_id}-${missing.widget_index}`;
+ const subfolderId = `download-subfolder-${missing.node_id}-${missing.widget_index}`;
+ const subfolderListId = `download-subfolder-list-${missing.node_id}-${missing.widget_index}`;
+ const options = this.getDownloadCategoryOptions(defaultCategory)
+ .map(category => ``)
+ .join('');
+
+ let html = `