diff --git a/web/linker.js b/web/linker.js
index bcad3d0..c744e32 100644
--- a/web/linker.js
+++ b/web/linker.js
@@ -1,46 +1,46 @@
-/**
- * ComfyUI Model Linker Extension - Frontend
- *
- * Provides a menu button and dialog interface for relinking missing models in workflows.
- */
-
-// Import ComfyUI APIs
-// These paths are relative to the ComfyUI web directory
+/**
+ * ComfyUI Model Linker Extension - Frontend
+ *
+ * Provides a menu button and dialog interface for relinking missing models in workflows.
+ */
+
+// Import ComfyUI APIs
+// These paths are relative to the ComfyUI web directory
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
import { $el, ComfyDialog } from "../../../scripts/ui.js";
-import { loadStylesWhenNeeded } from "./utils/css-loader.js";
-import { getSvgIcon } from "./utils/IconUtils.js";
-
-class LinkerManagerDialog extends ComfyDialog {
- constructor() {
- super();
- this.currentWorkflow = null;
- this.missingModels = [];
- this.allModels = null; // list of all available models for dropdown
- this.downloadDirectories = null;
- this.capabilities = null;
- this.downloadSubfolders = new Map();
- this.pendingResolutions = [];
- this.pendingIndex = new Map(); // key -> index in pendingResolutions
- this.activeDownloads = {}; // Track active downloads
- this.searchResultCache = new Map();
- this.cachedAnalysisData = null;
- this.cachedWorkflowSignature = null;
- this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
- this.activeTabStorageKey = 'model_linker_active_tab';
- this.activeTab = this.restoreActiveTab(); // Default tab
- this.fullscreen = false;
- this._dragging = false;
- this._dragStart = null;
- this._analysisProgressToken = null;
- this._locateAnimationFrame = null;
-
+import { loadStylesWhenNeeded } from "./utils/css_loader.js";
+import { getSvgIcon } from "./utils/icon_utils.js";
+
+class LinkerManagerDialog extends ComfyDialog {
+ constructor() {
+ super();
+ this.currentWorkflow = null;
+ this.missingModels = [];
+ this.allModels = null; // list of all available models for dropdown
+ this.downloadDirectories = null;
+ this.capabilities = null;
+ this.downloadSubfolders = new Map();
+ this.pendingResolutions = [];
+ this.pendingIndex = new Map(); // key -> index in pendingResolutions
+ this.activeDownloads = {}; // Track active downloads
+ this.searchResultCache = new Map();
+ this.cachedAnalysisData = null;
+ this.cachedWorkflowSignature = null;
+ this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
+ this.activeTabStorageKey = 'model_linker_active_tab';
+ this.activeTab = this.restoreActiveTab(); // Default tab
+ this.fullscreen = false;
+ this._dragging = false;
+ this._dragStart = null;
+ this._analysisProgressToken = null;
+ this._locateAnimationFrame = null;
+
// Create backdrop overlay for click-outside-to-close
this.backdrop = $el("div.model-linker-backdrop", {
parent: document.body
});
-
+
// Create context menu for model chips
this.contextMenu = $el("div.ml-context-menu", {
parent: document.body
@@ -48,87 +48,87 @@ class LinkerManagerDialog extends ComfyDialog {
$el("div.ml-context-menu-item", {
onclick: () => this.handleContextMenuAction('showInfo')
}, [
- $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }),
- $el("span", { textContent: "Show Info" })
- ]),
+ $el("span.ml-context-menu-item-icon", { textContent: "ℹ" }),
+ $el("span", { textContent: "Show Info" })
+ ]),
$el("div.ml-context-menu-divider"),
$el("div.ml-context-menu-item", {
onclick: () => this.handleContextMenuAction('civitai')
}, [
- $el("span.ml-context-menu-item-icon", { textContent: "🌐" }),
- $el("span", { textContent: "Open in CivitAI" })
- ]),
+ $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')
}, [
- $el("span.ml-context-menu-item-icon", { textContent: "📁" }),
- $el("span", { textContent: "Open Containing Folder" })
- ])
- ]);
-
+ $el("span.ml-context-menu-item-icon", { textContent: "📁" }),
+ $el("span", { textContent: "Open Containing Folder" })
+ ])
+ ]);
+
this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body });
-
- // Selected model for context menu
- this._contextMenuModel = null;
-
- // Create dialog element using $el
+
+ // Selected model for context menu
+ this._contextMenuModel = null;
+
+ // Create dialog element using $el
this.element = $el("div.comfy-modal.model-linker-modal", {
id: "model-linker-modal",
parent: document.body
}, [
- this.createHeader(),
- this.createContent(),
- this.createFooter()
- ]);
-
- // Add click listener to hide context menu when clicking outside
- this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
- document.addEventListener('click', this.boundHandleContextMenuClick);
- }
-
- /**
- * Build stable cache key for a missing model entry
- */
- getMissingSearchKey(missing) {
- return `${missing.node_id}:${missing.widget_index}`;
- }
-
- /**
- * Get or initialize search state for a missing model entry
- */
- getSearchState(missing) {
- const key = this.getMissingSearchKey(missing);
- if (!this.searchResultCache.has(key)) {
- this.searchResultCache.set(key, {
- selectedSource: 'all',
- results: {
- popular: null,
- model_list: null,
- huggingface: null,
- civitai: null
- },
- lastAttemptSources: [],
- lastAttemptFound: null
- });
- }
- return this.searchResultCache.get(key);
- }
-
- /**
- * Merge new search results into cached per-source results.
- * Empty responses do not delete previously found results.
- */
- mergeSearchResults(existingResults = {}, newResults = {}) {
- return {
- popular: newResults.popular || existingResults.popular || null,
- model_list: newResults.model_list || existingResults.model_list || null,
- huggingface: newResults.huggingface || existingResults.huggingface || null,
- civitai: newResults.civitai || existingResults.civitai || null,
- lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null
- };
- }
-
+ this.createHeader(),
+ this.createContent(),
+ this.createFooter()
+ ]);
+
+ // Add click listener to hide context menu when clicking outside
+ this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
+ document.addEventListener('click', this.boundHandleContextMenuClick);
+ }
+
+ /**
+ * Build stable cache key for a missing model entry
+ */
+ getMissingSearchKey(missing) {
+ return `${missing.node_id}:${missing.widget_index}`;
+ }
+
+ /**
+ * Get or initialize search state for a missing model entry
+ */
+ getSearchState(missing) {
+ const key = this.getMissingSearchKey(missing);
+ if (!this.searchResultCache.has(key)) {
+ this.searchResultCache.set(key, {
+ selectedSource: 'all',
+ results: {
+ popular: null,
+ model_list: null,
+ huggingface: null,
+ civitai: null
+ },
+ lastAttemptSources: [],
+ lastAttemptFound: null
+ });
+ }
+ return this.searchResultCache.get(key);
+ }
+
+ /**
+ * Merge new search results into cached per-source results.
+ * Empty responses do not delete previously found results.
+ */
+ mergeSearchResults(existingResults = {}, newResults = {}) {
+ return {
+ popular: newResults.popular || existingResults.popular || null,
+ model_list: newResults.model_list || existingResults.model_list || null,
+ huggingface: newResults.huggingface || existingResults.huggingface || null,
+ civitai: newResults.civitai || existingResults.civitai || null,
+ lora_manager_archive: newResults.lora_manager_archive || existingResults.lora_manager_archive || null
+ };
+ }
+
getSearchIconHtml() {
return `${getSvgIcon('search')} `;
}
@@ -136,212 +136,212 @@ class LinkerManagerDialog extends ComfyDialog {
getLocateIconHtml() {
return `${getSvgIcon('locate')} `;
}
-
- showTooltip(target) {
- if (!target || !this.tooltipElement) return;
- const text = target.getAttribute('data-tooltip');
- if (!text) return;
-
- this.tooltipElement.textContent = text;
- this.tooltipElement.style.display = 'block';
-
- const rect = target.getBoundingClientRect();
- const tooltipRect = this.tooltipElement.getBoundingClientRect();
- const margin = 12;
- const maxLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin);
- let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
- left = Math.min(Math.max(margin, left), maxLeft);
-
- let top = rect.top - tooltipRect.height - 10;
- if (top < margin) {
- top = Math.min(window.innerHeight - tooltipRect.height - margin, rect.bottom + 10);
- }
- top = Math.max(margin, top);
-
- this.tooltipElement.style.left = `${Math.round(left)}px`;
- this.tooltipElement.style.top = `${Math.round(top)}px`;
- this.tooltipElement.setAttribute('data-visible', 'true');
- }
-
- hideTooltip() {
- if (!this.tooltipElement) return;
- this.tooltipElement.style.display = 'none';
- this.tooltipElement.removeAttribute('data-visible');
- }
-
- bindTooltips(container) {
- if (!container) return;
-
- container.querySelectorAll('.ml-tooltip-badge').forEach((badge) => {
- badge.addEventListener('mouseenter', () => this.showTooltip(badge));
- badge.addEventListener('focus', () => this.showTooltip(badge));
- badge.addEventListener('mouseleave', () => this.hideTooltip());
- badge.addEventListener('blur', () => this.hideTooltip());
- });
- }
-
- getValidTab(tab) {
- return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing';
- }
-
- restoreActiveTab() {
- try {
- return this.getValidTab(localStorage.getItem(this.activeTabStorageKey));
- } catch (error) {
- console.warn('Model Linker: Failed to restore active tab:', error);
- return 'missing';
- }
- }
-
- persistActiveTab(tab) {
- try {
- localStorage.setItem(this.activeTabStorageKey, this.getValidTab(tab));
- } catch (error) {
- console.warn('Model Linker: Failed to persist active tab:', error);
- }
- }
-
- /**
- * Return true when at least one downloadable source was found
- */
- hasSearchResults(data = {}) {
- return !!(data.popular || data.model_list || data.huggingface || data.civitai || data.lora_manager_archive);
- }
-
- /**
- * Convert source ids to readable labels
- */
- getSearchSourceLabel(source) {
- const labels = {
- all: 'Everything',
- local: 'Local Database',
- huggingface: 'HuggingFace',
- civitai: 'CivitAI',
- lora_manager_archive: 'LoRA Manager Archive'
- };
- return labels[source] || source;
- }
-
- /**
- * Update source selector buttons and helper text for one card
- */
- syncSearchSourceUi(missing, container) {
- if (!container) return;
-
- const state = this.getSearchState(missing);
- const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
- if (selectEl) {
- selectEl.value = state.selectedSource;
- }
- }
-
- /**
- * Set current search source for one card
- */
- setSearchSource(missing, source, container) {
- const state = this.getSearchState(missing);
- state.selectedSource = source || 'all';
- this.syncSearchSourceUi(missing, container);
- }
-
+
+ showTooltip(target) {
+ if (!target || !this.tooltipElement) return;
+ const text = target.getAttribute('data-tooltip');
+ if (!text) return;
+
+ this.tooltipElement.textContent = text;
+ this.tooltipElement.style.display = 'block';
+
+ const rect = target.getBoundingClientRect();
+ const tooltipRect = this.tooltipElement.getBoundingClientRect();
+ const margin = 12;
+ const maxLeft = Math.max(margin, window.innerWidth - tooltipRect.width - margin);
+ let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2);
+ left = Math.min(Math.max(margin, left), maxLeft);
+
+ let top = rect.top - tooltipRect.height - 10;
+ if (top < margin) {
+ top = Math.min(window.innerHeight - tooltipRect.height - margin, rect.bottom + 10);
+ }
+ top = Math.max(margin, top);
+
+ this.tooltipElement.style.left = `${Math.round(left)}px`;
+ this.tooltipElement.style.top = `${Math.round(top)}px`;
+ this.tooltipElement.setAttribute('data-visible', 'true');
+ }
+
+ hideTooltip() {
+ if (!this.tooltipElement) return;
+ this.tooltipElement.style.display = 'none';
+ this.tooltipElement.removeAttribute('data-visible');
+ }
+
+ bindTooltips(container) {
+ if (!container) return;
+
+ container.querySelectorAll('.ml-tooltip-badge').forEach((badge) => {
+ badge.addEventListener('mouseenter', () => this.showTooltip(badge));
+ badge.addEventListener('focus', () => this.showTooltip(badge));
+ badge.addEventListener('mouseleave', () => this.hideTooltip());
+ badge.addEventListener('blur', () => this.hideTooltip());
+ });
+ }
+
+ getValidTab(tab) {
+ return ['missing', 'loaded', 'options'].includes(tab) ? tab : 'missing';
+ }
+
+ restoreActiveTab() {
+ try {
+ return this.getValidTab(localStorage.getItem(this.activeTabStorageKey));
+ } catch (error) {
+ console.warn('Model Linker: Failed to restore active tab:', error);
+ return 'missing';
+ }
+ }
+
+ persistActiveTab(tab) {
+ try {
+ localStorage.setItem(this.activeTabStorageKey, this.getValidTab(tab));
+ } catch (error) {
+ console.warn('Model Linker: Failed to persist active tab:', error);
+ }
+ }
+
+ /**
+ * Return true when at least one downloadable source was found
+ */
+ hasSearchResults(data = {}) {
+ return !!(data.popular || data.model_list || data.huggingface || data.civitai || data.lora_manager_archive);
+ }
+
+ /**
+ * Convert source ids to readable labels
+ */
+ getSearchSourceLabel(source) {
+ const labels = {
+ all: 'Everything',
+ local: 'Local Database',
+ huggingface: 'HuggingFace',
+ civitai: 'CivitAI',
+ lora_manager_archive: 'LoRA Manager Archive'
+ };
+ return labels[source] || source;
+ }
+
+ /**
+ * Update source selector buttons and helper text for one card
+ */
+ syncSearchSourceUi(missing, container) {
+ if (!container) return;
+
+ const state = this.getSearchState(missing);
+ const selectEl = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
+ if (selectEl) {
+ selectEl.value = state.selectedSource;
+ }
+ }
+
+ /**
+ * Set current search source for one card
+ */
+ setSearchSource(missing, source, container) {
+ const state = this.getSearchState(missing);
+ state.selectedSource = source || 'all';
+ this.syncSearchSourceUi(missing, container);
+ }
+
renderDownloadSourceSection(missing, downloadSource) {
- const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
- const isFromWorkflow = downloadSource.url_source === 'workflow';
- const isCivitaiSource = downloadSource.source === 'civitai';
- const sourceLabels = {
- popular: 'Popular Models',
- model_list: 'Model Database',
- huggingface: 'HuggingFace',
- civitai: 'CivitAI',
- workflow: 'Workflow'
- };
- const sourceLabel = isFromWorkflow ? 'Workflow' : (sourceLabels[downloadSource.source] || 'Online');
- const downloadFilename = downloadSource.filename || filename;
- const formattedDownloadName = this.formatFilename(downloadFilename, 45);
- const confidenceDisplay = typeof downloadSource.confidence === 'number'
- ? `${Math.round(downloadSource.confidence)}% match`
- : '';
-
- let sizeDisplay = '';
- if (downloadSource.size) {
- sizeDisplay = typeof downloadSource.size === 'number'
- ? this.formatBytes(downloadSource.size)
- : downloadSource.size;
- }
-
- const modelCardUrl = downloadSource.model_url || this.getModelCardUrl(downloadSource.url);
-
- let html = `
`;
- html += this.getConfidenceBadge(match.confidence);
- html += `${formattedPath.display} `;
- html += ``;
- html += `🔗 Link`;
- html += ` `;
- html += `
`;
- }
-
- if (perfectMatches.length > 0 && otherMatches.length > 0) {
- const matchId = `more-matches-${missing.node_id}-${missing.widget_index}`;
+ const resolvedPath = model.path || model.resolved_path || '';
+ const filename = model.filename || fallbackName || resolvedPath.split(/[\/\\]/).pop() || '';
+ return {
+ ...model,
+ name: model.name || filename,
+ original_path: model.original_path || filename,
+ resolved_path: resolvedPath,
+ category: model.category || ''
+ };
+ }
+
+ 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 contextModel = this.buildContextMenuModelData(match.model || {}, match.filename || '');
+ const modelData = encodeURIComponent(JSON.stringify(contextModel));
+
+ html += ``;
+ html += this.getConfidenceBadge(match.confidence);
+ html += `${formattedPath.display} `;
+ html += ``;
+ html += `🔗 Link`;
+ 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++) {
@@ -461,711 +461,711 @@ class LinkerManagerDialog extends ComfyDialog {
html += `🔗 Link `;
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 += `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
- */
- handleContextMenuOutsideClick(e) {
- if (!this.contextMenu) return;
- if (this.contextMenu.style.display === 'none') return;
-
- // Check if click is outside the context menu
- if (!this.contextMenu.contains(e.target)) {
- this.hideContextMenu();
- }
- }
-
- /**
- * Show context menu at the specified position
- */
- showContextMenu(x, y, model) {
- if (!this.contextMenu) return;
-
- this._contextMenuModel = model;
-
- // Position the menu
- this.contextMenu.style.left = `${x}px`;
- this.contextMenu.style.top = `${y}px`;
- this.contextMenu.style.display = 'block';
-
- // Adjust position if menu would go off screen
- const rect = this.contextMenu.getBoundingClientRect();
- if (rect.right > window.innerWidth) {
- this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
- }
- if (rect.bottom > window.innerHeight) {
- this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`;
- }
- }
-
- /**
- * Hide context menu
- */
- hideContextMenu() {
- if (!this.contextMenu) return;
- this.contextMenu.style.display = 'none';
- this._contextMenuModel = null;
- }
-
- /**
- * Handle context menu item click
- */
- handleContextMenuAction(action) {
- const model = this._contextMenuModel;
- this.hideContextMenu();
-
- if (!model) return;
-
- 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
- */
- async openInCivitAI(model) {
- if (!model) return;
-
- const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
- if (!name) return;
-
- try {
- // Search CivitAI for this model using hash (pass resolved_path for hash lookup)
- const response = await api.fetchApi('/model_linker/civitai-search', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- filename: name,
- category: model.category,
- resolved_path: model.resolved_path || ''
- })
- });
-
- if (!response.ok) {
- this.showNotification('Nie znaleziono modelu na CivitAI', 'error');
- return;
- }
-
- const data = await response.json();
- if (data.url) {
- window.open(data.url, '_blank');
- } else {
- // Try direct search on CivitAI
- const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
- const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
- window.open(searchUrl, '_blank');
- }
- } catch (e) {
- console.error('Model Linker: Error searching CivitAI:', e);
- // Fall back to direct search
- const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
- const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
- window.open(searchUrl, '_blank');
- }
- }
-
- /**
- * Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog)
- */
- async showModelInfo(model) {
- if (!model) return;
-
- const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
- if (!name) return;
-
- // Create and show the info dialog
- this.showModelInfoDialog(name, model);
- }
-
- /**
- * Show the model info dialog
- */
- showModelInfoDialog(loraName, modelData) {
- // Create info dialog element
- const dialog = this.createInfoDialog(loraName, modelData);
- this.restoreInfoDialogSize(dialog);
-
- // Show the dialog
- document.body.appendChild(dialog);
- this.bindInfoDialogResizePersistence(dialog);
-
- // Add close handlers
- const closeBtn = dialog.querySelector('.ml-info-dialog-close');
- const footerCloseBtn = dialog.querySelector('.ml-info-dialog-close-btn');
- const backdrop = dialog.querySelector('.ml-info-dialog-backdrop');
-
- if (closeBtn) {
- closeBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
- }
- if (footerCloseBtn) {
- footerCloseBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
- }
- if (backdrop) {
- backdrop.addEventListener('click', (e) => {
- // Only close if clicking backdrop itself, not its children
- if (e.target === backdrop) {
- this.closeInfoDialog(dialog);
- }
- });
- }
-
- // Fetch CivitAI info
- this.fetchModelInfoForDialog(loraName, modelData, dialog);
- }
-
- /**
- * Create the info dialog element
- */
- createInfoDialog(loraName, modelData) {
- const loraDisplayName = loraName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
-
- const dialog = document.createElement('div');
- dialog.className = 'ml-info-dialog-backdrop';
- dialog._selectedTrainedWords = new Set();
- dialog.innerHTML = `
-
-
-
`;
+ }
+ } 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
+ */
+ handleContextMenuOutsideClick(e) {
+ if (!this.contextMenu) return;
+ if (this.contextMenu.style.display === 'none') return;
+
+ // Check if click is outside the context menu
+ if (!this.contextMenu.contains(e.target)) {
+ this.hideContextMenu();
+ }
+ }
+
+ /**
+ * Show context menu at the specified position
+ */
+ showContextMenu(x, y, model) {
+ if (!this.contextMenu) return;
+
+ this._contextMenuModel = model;
+
+ // Position the menu
+ this.contextMenu.style.left = `${x}px`;
+ this.contextMenu.style.top = `${y}px`;
+ this.contextMenu.style.display = 'block';
+
+ // Adjust position if menu would go off screen
+ const rect = this.contextMenu.getBoundingClientRect();
+ if (rect.right > window.innerWidth) {
+ this.contextMenu.style.left = `${window.innerWidth - rect.width - 10}px`;
+ }
+ if (rect.bottom > window.innerHeight) {
+ this.contextMenu.style.top = `${window.innerHeight - rect.height - 10}px`;
+ }
+ }
+
+ /**
+ * Hide context menu
+ */
+ hideContextMenu() {
+ if (!this.contextMenu) return;
+ this.contextMenu.style.display = 'none';
+ this._contextMenuModel = null;
+ }
+
+ /**
+ * Handle context menu item click
+ */
+ handleContextMenuAction(action) {
+ const model = this._contextMenuModel;
+ this.hideContextMenu();
+
+ if (!model) return;
+
+ 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
+ */
+ async openInCivitAI(model) {
+ if (!model) return;
+
+ const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
+ if (!name) return;
+
+ try {
+ // Search CivitAI for this model using hash (pass resolved_path for hash lookup)
+ const response = await api.fetchApi('/model_linker/civitai-search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ filename: name,
+ category: model.category,
+ resolved_path: model.resolved_path || ''
+ })
+ });
+
+ if (!response.ok) {
+ this.showNotification('Nie znaleziono modelu na CivitAI', 'error');
+ return;
+ }
+
+ const data = await response.json();
+ if (data.url) {
+ window.open(data.url, '_blank');
+ } else {
+ // Try direct search on CivitAI
+ const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
+ const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
+ window.open(searchUrl, '_blank');
+ }
+ } catch (e) {
+ console.error('Model Linker: Error searching CivitAI:', e);
+ // Fall back to direct search
+ const searchName = name.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
+ const searchUrl = `https://civitai.com/search?q=${encodeURIComponent(searchName)}`;
+ window.open(searchUrl, '_blank');
+ }
+ }
+
+ /**
+ * Show model info dialog (similar to rgthree's RgthreeLoraInfoDialog)
+ */
+ async showModelInfo(model) {
+ if (!model) return;
+
+ const name = model.name || model.original_path?.split(/[\/\\]/).pop() || '';
+ if (!name) return;
+
+ // Create and show the info dialog
+ this.showModelInfoDialog(name, model);
+ }
+
+ /**
+ * Show the model info dialog
+ */
+ showModelInfoDialog(loraName, modelData) {
+ // Create info dialog element
+ const dialog = this.createInfoDialog(loraName, modelData);
+ this.restoreInfoDialogSize(dialog);
+
+ // Show the dialog
+ document.body.appendChild(dialog);
+ this.bindInfoDialogResizePersistence(dialog);
+
+ // Add close handlers
+ const closeBtn = dialog.querySelector('.ml-info-dialog-close');
+ const footerCloseBtn = dialog.querySelector('.ml-info-dialog-close-btn');
+ const backdrop = dialog.querySelector('.ml-info-dialog-backdrop');
+
+ if (closeBtn) {
+ closeBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
+ }
+ if (footerCloseBtn) {
+ footerCloseBtn.addEventListener('click', () => this.closeInfoDialog(dialog));
+ }
+ if (backdrop) {
+ backdrop.addEventListener('click', (e) => {
+ // Only close if clicking backdrop itself, not its children
+ if (e.target === backdrop) {
+ this.closeInfoDialog(dialog);
+ }
+ });
+ }
+
+ // Fetch CivitAI info
+ this.fetchModelInfoForDialog(loraName, modelData, dialog);
+ }
+
+ /**
+ * Create the info dialog element
+ */
+ createInfoDialog(loraName, modelData) {
+ const loraDisplayName = loraName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
+
+ const dialog = document.createElement('div');
+ dialog.className = 'ml-info-dialog-backdrop';
+ dialog._selectedTrainedWords = new Set();
+ dialog.innerHTML = `
+
+
+
+
Loading...
-
-
-
-
-
-
-
- File ?
-
-
-
- Hash (sha256) ?
-
-
-
- CivitAI ?
-
-
-
- Name ?
-
-
-
- Base Model ?
-
-
+
+
+
+
+
+
+
+ File ?
+
+
+
+ Hash (sha256) ?
+
+
+
+ CivitAI ?
+
+
+
+ Name ?
+
+
+
+ Base Model ?
+
+
-
-
- Trained Words ?
-
- 0 selected
- Copy
-
-
-
-
- Click words to select them.
-
-
-
+
+
+ Trained Words ?
+
+ 0 selected
+ Copy
+
+
+
+
+ Click words to select them.
+
+
+
- Clip Skip ?
-
-
+ Clip Skip ?
+
+
- Description ?
-
-
-
+
Description ?
+
+
-
-
-
-
-
-
-
-
-
- `;
-
- return dialog;
- }
-
- escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-
- truncateText(value, maxLength = 160) {
- const text = String(value ?? '').trim();
- if (!text) return '';
- return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
- }
-
- sanitizeDescriptionHtml(html) {
- const raw = String(html ?? '').trim();
- if (!raw) return '';
-
- const parser = new DOMParser();
- const doc = parser.parseFromString(`${raw}
`, 'text/html');
- const root = doc.body.firstElementChild;
- if (!root) return this.escapeHtml(raw);
-
- const allowedTags = new Set([
- 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
- 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'a', 'span'
- ]);
- const allowedStyles = new Set(['color']);
-
- const sanitizeNode = (node) => {
- if (node.nodeType === Node.TEXT_NODE) {
- return;
- }
-
- if (node.nodeType !== Node.ELEMENT_NODE) {
- node.remove();
- return;
- }
-
- const tag = node.tagName.toLowerCase();
- if (!allowedTags.has(tag)) {
- const parent = node.parentNode;
- if (!parent) {
- node.remove();
- return;
- }
-
- while (node.firstChild) {
- parent.insertBefore(node.firstChild, node);
- }
- parent.removeChild(node);
- return;
- }
-
- const attrs = Array.from(node.attributes);
- for (const attr of attrs) {
- const name = attr.name.toLowerCase();
- if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
- node.removeAttribute(attr.name);
- continue;
- }
-
- if (name === 'href' && tag === 'a') {
- const href = node.getAttribute('href') || '';
- if (!/^https?:\/\//i.test(href)) {
- node.removeAttribute('href');
- } else {
- node.setAttribute('target', '_blank');
- node.setAttribute('rel', 'noopener noreferrer');
- }
- continue;
- }
-
- if (name === 'style') {
- const safeStyles = [];
- const styleValue = node.getAttribute('style') || '';
- for (const part of styleValue.split(';')) {
- const [prop, value] = part.split(':').map(v => v?.trim());
- if (!prop || !value) continue;
- if (allowedStyles.has(prop.toLowerCase())) {
- safeStyles.push(`${prop}: ${value}`);
- }
- }
- if (safeStyles.length) {
- node.setAttribute('style', safeStyles.join('; '));
- } else {
- node.removeAttribute('style');
- }
- continue;
- }
-
- if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
- node.removeAttribute(attr.name);
- }
- }
-
- Array.from(node.childNodes).forEach(child => sanitizeNode(child));
- };
-
- Array.from(root.childNodes).forEach(child => sanitizeNode(child));
- return root.innerHTML;
- }
-
- normalizeTrainedWords(words) {
- if (Array.isArray(words)) {
- return [...new Set(words.map(word => String(word || '').trim()).filter(Boolean))];
- }
-
- if (typeof words === 'string') {
- return [...new Set(
- words
- .split(/[\n,|;]/)
- .map(word => word.trim())
- .filter(Boolean)
- )];
- }
-
- return [];
- }
-
- updateSelectedTrainedWordsSummary(dialog) {
- if (!dialog) return;
-
- const countEl = dialog.querySelector('.ml-info-trained-words-count');
- const copyBtn = dialog.querySelector('.ml-info-copy-trained-words');
- const selected = dialog._selectedTrainedWords instanceof Set
- ? Array.from(dialog._selectedTrainedWords)
- : [];
-
- if (countEl) {
- countEl.textContent = `${selected.length} selected`;
- }
- if (copyBtn) {
- copyBtn.disabled = selected.length === 0;
- copyBtn.textContent = 'Copy';
- }
- }
-
- bindInfoDialogInteractions(dialog) {
- if (!dialog || dialog.dataset.mlInfoBound === 'true') return;
- dialog.dataset.mlInfoBound = 'true';
-
- dialog.addEventListener('click', async (event) => {
- const wordBtn = event.target.closest('.ml-info-trained-word');
- if (wordBtn && dialog.contains(wordBtn)) {
- const word = wordBtn.dataset.word || '';
- if (word) {
- if (!(dialog._selectedTrainedWords instanceof Set)) {
- dialog._selectedTrainedWords = new Set();
- }
-
- if (dialog._selectedTrainedWords.has(word)) {
- dialog._selectedTrainedWords.delete(word);
- wordBtn.classList.remove('is-selected');
- wordBtn.setAttribute('aria-pressed', 'false');
- } else {
- dialog._selectedTrainedWords.add(word);
- wordBtn.classList.add('is-selected');
- wordBtn.setAttribute('aria-pressed', 'true');
- }
-
- this.updateSelectedTrainedWordsSummary(dialog);
- }
- return;
- }
-
- const copyBtn = event.target.closest('.ml-info-copy-trained-words');
- if (copyBtn && dialog.contains(copyBtn)) {
- const words = dialog._selectedTrainedWords instanceof Set
- ? Array.from(dialog._selectedTrainedWords)
- : [];
-
- if (!words.length) return;
-
- try {
- await navigator.clipboard.writeText(words.join(', '));
- copyBtn.textContent = 'Copied';
- } catch (error) {
- console.error('Model Linker: Failed to copy trained words:', error);
- copyBtn.textContent = 'Failed';
- }
-
- setTimeout(() => {
- if (copyBtn.isConnected) {
- copyBtn.textContent = 'Copy';
- }
- }, 1200);
- return;
- }
-
- const descToggleBtn = event.target.closest('.ml-info-description-toggle');
- if (descToggleBtn && dialog.contains(descToggleBtn)) {
- const descEl = dialog.querySelector('.ml-info-description');
- if (!descEl) return;
-
- const isExpanded = descEl.classList.toggle('is-expanded');
- descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more';
- }
- });
- }
-
- getInfoDialogElement(dialog) {
- return dialog?.querySelector?.('.ml-info-dialog') || null;
- }
-
- restoreInfoDialogSize(dialog) {
- const panel = this.getInfoDialogElement(dialog);
- if (!panel) return;
-
- try {
- const saved = JSON.parse(localStorage.getItem('model_linker_info_dialog_size') || 'null');
- if (!saved || typeof saved !== 'object') return;
-
- const width = Number(saved.w);
- const height = Number(saved.h);
- if (!Number.isFinite(width) || !Number.isFinite(height)) return;
-
- const maxWidth = Math.floor(window.innerWidth * 0.9);
- const maxHeight = Math.floor(window.innerHeight * 0.8);
- const clampedWidth = Math.max(420, Math.min(width, maxWidth));
- const clampedHeight = Math.max(320, Math.min(height, maxHeight));
-
- panel.style.width = `${clampedWidth}px`;
- panel.style.height = `${clampedHeight}px`;
- } catch (error) {
- console.warn('Model Linker: Failed to restore info dialog size:', error);
- }
- }
-
- saveInfoDialogSize(dialog) {
- const panel = this.getInfoDialogElement(dialog);
- if (!panel) return;
-
- const rect = panel.getBoundingClientRect();
- const width = Math.round(rect.width);
- const height = Math.round(rect.height);
- if (!width || !height) return;
-
- try {
- localStorage.setItem('model_linker_info_dialog_size', JSON.stringify({ w: width, h: height }));
- } catch (error) {
- console.warn('Model Linker: Failed to save info dialog size:', error);
- }
- }
-
- bindInfoDialogResizePersistence(dialog) {
- const panel = this.getInfoDialogElement(dialog);
- if (!panel || typeof ResizeObserver === 'undefined') return;
- if (dialog._infoDialogResizeObserver) return;
-
- let resizeSaveTimer = null;
- const observer = new ResizeObserver(() => {
- clearTimeout(resizeSaveTimer);
- resizeSaveTimer = setTimeout(() => this.saveInfoDialogSize(dialog), 180);
- dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
- });
-
- observer.observe(panel);
- dialog._infoDialogResizeObserver = observer;
- dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
- }
-
- /**
- * Fetch model info and update the dialog
- */
- async fetchModelInfoForDialog(loraName, modelData, dialog) {
- try {
- const response = await api.fetchApi('/model_linker/civitai-search', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- filename: loraName,
- category: modelData?.category || '',
- resolved_path: modelData?.resolved_path || ''
- })
- });
-
- if (response.ok) {
- const data = await response.json();
- this.updateInfoDialogWithData(dialog, data);
- } else {
- this.updateInfoDialogError(dialog, 'Model not found on CivitAI');
- }
- } catch (e) {
- console.error('Model Linker: Error fetching model info:', e);
- this.updateInfoDialogError(dialog, 'Error fetching info');
- }
- }
-
- /**
- * Update the info dialog with data
- */
- updateInfoDialogWithData(dialog, data) {
- const loadingDiv = dialog.querySelector('.ml-info-dialog-loading');
- const bodyDiv = dialog.querySelector('.ml-info-dialog-body');
- this.bindInfoDialogInteractions(dialog);
-
- if (loadingDiv) loadingDiv.style.display = 'none';
- if (bodyDiv) bodyDiv.style.display = 'block';
-
- if (!data) {
- this.updateInfoDialogError(dialog, 'No data received');
- return;
- }
-
- // Update title
- const titleEl = dialog.querySelector('.ml-info-dialog-title');
- if (titleEl) {
- const modelName = data.model_name || data.modelName || 'Unknown Model';
- const versionName = data.version_name || data.versionName || '';
- titleEl.textContent = versionName ? `${modelName} - ${versionName}` : modelName;
- }
-
- // Update type tag
- const typeTag = dialog.querySelector('.ml-info-type');
- if (typeTag) {
- const modelType = data.model_type || data.modelType || '';
- typeTag.textContent = modelType.toUpperCase();
- typeTag.className = `ml-info-tag ml-info-type -type-${modelType.toLowerCase()}`;
- }
-
- // Update base model tag
- const baseModelTag = dialog.querySelector('.ml-info-basemodel');
- if (baseModelTag) {
- const baseModel = data.base_model || data.baseModel || '';
- baseModelTag.textContent = baseModel || '';
- if (baseModel) {
- baseModelTag.style.display = '';
- baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`;
- } else {
- baseModelTag.style.display = 'none';
- }
- }
-
- // Update file
- const fileEl = dialog.querySelector('.ml-info-file');
- if (fileEl && data.filename) {
- fileEl.textContent = data.filename;
- }
-
- // Update hash
- const hashEl = dialog.querySelector('.ml-info-hash');
- if (hashEl) {
- hashEl.textContent = data.sha256 || data.hash || '';
- }
-
- // Update CivitAI link
- const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link');
- if (civitaiLinkEl) {
- if (data.url || data.version_url) {
+ Show more
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ return dialog;
+ }
+
+ escapeHtml(value) {
+ return String(value ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ truncateText(value, maxLength = 160) {
+ const text = String(value ?? '').trim();
+ if (!text) return '';
+ return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text;
+ }
+
+ sanitizeDescriptionHtml(html) {
+ const raw = String(html ?? '').trim();
+ if (!raw) return '';
+
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(`
${raw}
`, 'text/html');
+ const root = doc.body.firstElementChild;
+ if (!root) return this.escapeHtml(raw);
+
+ const allowedTags = new Set([
+ 'p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'code', 'pre',
+ 'blockquote', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'a', 'span'
+ ]);
+ const allowedStyles = new Set(['color']);
+
+ const sanitizeNode = (node) => {
+ if (node.nodeType === Node.TEXT_NODE) {
+ return;
+ }
+
+ if (node.nodeType !== Node.ELEMENT_NODE) {
+ node.remove();
+ return;
+ }
+
+ const tag = node.tagName.toLowerCase();
+ if (!allowedTags.has(tag)) {
+ const parent = node.parentNode;
+ if (!parent) {
+ node.remove();
+ return;
+ }
+
+ while (node.firstChild) {
+ parent.insertBefore(node.firstChild, node);
+ }
+ parent.removeChild(node);
+ return;
+ }
+
+ const attrs = Array.from(node.attributes);
+ for (const attr of attrs) {
+ const name = attr.name.toLowerCase();
+ if (name.startsWith('on') || name === 'id' || name === 'class' || name.startsWith('data-')) {
+ node.removeAttribute(attr.name);
+ continue;
+ }
+
+ if (name === 'href' && tag === 'a') {
+ const href = node.getAttribute('href') || '';
+ if (!/^https?:\/\//i.test(href)) {
+ node.removeAttribute('href');
+ } else {
+ node.setAttribute('target', '_blank');
+ node.setAttribute('rel', 'noopener noreferrer');
+ }
+ continue;
+ }
+
+ if (name === 'style') {
+ const safeStyles = [];
+ const styleValue = node.getAttribute('style') || '';
+ for (const part of styleValue.split(';')) {
+ const [prop, value] = part.split(':').map(v => v?.trim());
+ if (!prop || !value) continue;
+ if (allowedStyles.has(prop.toLowerCase())) {
+ safeStyles.push(`${prop}: ${value}`);
+ }
+ }
+ if (safeStyles.length) {
+ node.setAttribute('style', safeStyles.join('; '));
+ } else {
+ node.removeAttribute('style');
+ }
+ continue;
+ }
+
+ if (!(tag === 'a' && (name === 'target' || name === 'rel')) ) {
+ node.removeAttribute(attr.name);
+ }
+ }
+
+ Array.from(node.childNodes).forEach(child => sanitizeNode(child));
+ };
+
+ Array.from(root.childNodes).forEach(child => sanitizeNode(child));
+ return root.innerHTML;
+ }
+
+ normalizeTrainedWords(words) {
+ if (Array.isArray(words)) {
+ return [...new Set(words.map(word => String(word || '').trim()).filter(Boolean))];
+ }
+
+ if (typeof words === 'string') {
+ return [...new Set(
+ words
+ .split(/[\n,|;]/)
+ .map(word => word.trim())
+ .filter(Boolean)
+ )];
+ }
+
+ return [];
+ }
+
+ updateSelectedTrainedWordsSummary(dialog) {
+ if (!dialog) return;
+
+ const countEl = dialog.querySelector('.ml-info-trained-words-count');
+ const copyBtn = dialog.querySelector('.ml-info-copy-trained-words');
+ const selected = dialog._selectedTrainedWords instanceof Set
+ ? Array.from(dialog._selectedTrainedWords)
+ : [];
+
+ if (countEl) {
+ countEl.textContent = `${selected.length} selected`;
+ }
+ if (copyBtn) {
+ copyBtn.disabled = selected.length === 0;
+ copyBtn.textContent = 'Copy';
+ }
+ }
+
+ bindInfoDialogInteractions(dialog) {
+ if (!dialog || dialog.dataset.mlInfoBound === 'true') return;
+ dialog.dataset.mlInfoBound = 'true';
+
+ dialog.addEventListener('click', async (event) => {
+ const wordBtn = event.target.closest('.ml-info-trained-word');
+ if (wordBtn && dialog.contains(wordBtn)) {
+ const word = wordBtn.dataset.word || '';
+ if (word) {
+ if (!(dialog._selectedTrainedWords instanceof Set)) {
+ dialog._selectedTrainedWords = new Set();
+ }
+
+ if (dialog._selectedTrainedWords.has(word)) {
+ dialog._selectedTrainedWords.delete(word);
+ wordBtn.classList.remove('is-selected');
+ wordBtn.setAttribute('aria-pressed', 'false');
+ } else {
+ dialog._selectedTrainedWords.add(word);
+ wordBtn.classList.add('is-selected');
+ wordBtn.setAttribute('aria-pressed', 'true');
+ }
+
+ this.updateSelectedTrainedWordsSummary(dialog);
+ }
+ return;
+ }
+
+ const copyBtn = event.target.closest('.ml-info-copy-trained-words');
+ if (copyBtn && dialog.contains(copyBtn)) {
+ const words = dialog._selectedTrainedWords instanceof Set
+ ? Array.from(dialog._selectedTrainedWords)
+ : [];
+
+ if (!words.length) return;
+
+ try {
+ await navigator.clipboard.writeText(words.join(', '));
+ copyBtn.textContent = 'Copied';
+ } catch (error) {
+ console.error('Model Linker: Failed to copy trained words:', error);
+ copyBtn.textContent = 'Failed';
+ }
+
+ setTimeout(() => {
+ if (copyBtn.isConnected) {
+ copyBtn.textContent = 'Copy';
+ }
+ }, 1200);
+ return;
+ }
+
+ const descToggleBtn = event.target.closest('.ml-info-description-toggle');
+ if (descToggleBtn && dialog.contains(descToggleBtn)) {
+ const descEl = dialog.querySelector('.ml-info-description');
+ if (!descEl) return;
+
+ const isExpanded = descEl.classList.toggle('is-expanded');
+ descToggleBtn.textContent = isExpanded ? 'Show less' : 'Show more';
+ }
+ });
+ }
+
+ getInfoDialogElement(dialog) {
+ return dialog?.querySelector?.('.ml-info-dialog') || null;
+ }
+
+ restoreInfoDialogSize(dialog) {
+ const panel = this.getInfoDialogElement(dialog);
+ if (!panel) return;
+
+ try {
+ const saved = JSON.parse(localStorage.getItem('model_linker_info_dialog_size') || 'null');
+ if (!saved || typeof saved !== 'object') return;
+
+ const width = Number(saved.w);
+ const height = Number(saved.h);
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return;
+
+ const maxWidth = Math.floor(window.innerWidth * 0.9);
+ const maxHeight = Math.floor(window.innerHeight * 0.8);
+ const clampedWidth = Math.max(420, Math.min(width, maxWidth));
+ const clampedHeight = Math.max(320, Math.min(height, maxHeight));
+
+ panel.style.width = `${clampedWidth}px`;
+ panel.style.height = `${clampedHeight}px`;
+ } catch (error) {
+ console.warn('Model Linker: Failed to restore info dialog size:', error);
+ }
+ }
+
+ saveInfoDialogSize(dialog) {
+ const panel = this.getInfoDialogElement(dialog);
+ if (!panel) return;
+
+ const rect = panel.getBoundingClientRect();
+ const width = Math.round(rect.width);
+ const height = Math.round(rect.height);
+ if (!width || !height) return;
+
+ try {
+ localStorage.setItem('model_linker_info_dialog_size', JSON.stringify({ w: width, h: height }));
+ } catch (error) {
+ console.warn('Model Linker: Failed to save info dialog size:', error);
+ }
+ }
+
+ bindInfoDialogResizePersistence(dialog) {
+ const panel = this.getInfoDialogElement(dialog);
+ if (!panel || typeof ResizeObserver === 'undefined') return;
+ if (dialog._infoDialogResizeObserver) return;
+
+ let resizeSaveTimer = null;
+ const observer = new ResizeObserver(() => {
+ clearTimeout(resizeSaveTimer);
+ resizeSaveTimer = setTimeout(() => this.saveInfoDialogSize(dialog), 180);
+ dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
+ });
+
+ observer.observe(panel);
+ dialog._infoDialogResizeObserver = observer;
+ dialog._infoDialogResizeSaveTimer = resizeSaveTimer;
+ }
+
+ /**
+ * Fetch model info and update the dialog
+ */
+ async fetchModelInfoForDialog(loraName, modelData, dialog) {
+ try {
+ const response = await api.fetchApi('/model_linker/civitai-search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ filename: loraName,
+ category: modelData?.category || '',
+ resolved_path: modelData?.resolved_path || ''
+ })
+ });
+
+ if (response.ok) {
+ const data = await response.json();
+ this.updateInfoDialogWithData(dialog, data);
+ } else {
+ this.updateInfoDialogError(dialog, 'Model not found on CivitAI');
+ }
+ } catch (e) {
+ console.error('Model Linker: Error fetching model info:', e);
+ this.updateInfoDialogError(dialog, 'Error fetching info');
+ }
+ }
+
+ /**
+ * Update the info dialog with data
+ */
+ updateInfoDialogWithData(dialog, data) {
+ const loadingDiv = dialog.querySelector('.ml-info-dialog-loading');
+ const bodyDiv = dialog.querySelector('.ml-info-dialog-body');
+ this.bindInfoDialogInteractions(dialog);
+
+ if (loadingDiv) loadingDiv.style.display = 'none';
+ if (bodyDiv) bodyDiv.style.display = 'block';
+
+ if (!data) {
+ this.updateInfoDialogError(dialog, 'No data received');
+ return;
+ }
+
+ // Update title
+ const titleEl = dialog.querySelector('.ml-info-dialog-title');
+ if (titleEl) {
+ const modelName = data.model_name || data.modelName || 'Unknown Model';
+ const versionName = data.version_name || data.versionName || '';
+ titleEl.textContent = versionName ? `${modelName} - ${versionName}` : modelName;
+ }
+
+ // Update type tag
+ const typeTag = dialog.querySelector('.ml-info-type');
+ if (typeTag) {
+ const modelType = data.model_type || data.modelType || '';
+ typeTag.textContent = modelType.toUpperCase();
+ typeTag.className = `ml-info-tag ml-info-type -type-${modelType.toLowerCase()}`;
+ }
+
+ // Update base model tag
+ const baseModelTag = dialog.querySelector('.ml-info-basemodel');
+ if (baseModelTag) {
+ const baseModel = data.base_model || data.baseModel || '';
+ baseModelTag.textContent = baseModel || '';
+ if (baseModel) {
+ baseModelTag.style.display = '';
+ baseModelTag.className = `ml-info-tag ml-info-basemodel -basemodel-${baseModel.toLowerCase().replace(/\s+/g, '-')}`;
+ } else {
+ baseModelTag.style.display = 'none';
+ }
+ }
+
+ // Update file
+ const fileEl = dialog.querySelector('.ml-info-file');
+ if (fileEl && data.filename) {
+ fileEl.textContent = data.filename;
+ }
+
+ // Update hash
+ const hashEl = dialog.querySelector('.ml-info-hash');
+ if (hashEl) {
+ hashEl.textContent = data.sha256 || data.hash || '';
+ }
+
+ // Update CivitAI link
+ const civitaiLinkEl = dialog.querySelector('.ml-info-civitai-link');
+ if (civitaiLinkEl) {
+ if (data.url || data.version_url) {
const url = data.version_url || data.url;
civitaiLinkEl.innerHTML = `
@@ -1173,402 +1173,403 @@ class LinkerManagerDialog extends ComfyDialog {
View on Civitai
`;
- } else {
- const searchName = data.model_name || data.modelName || 'Unknown';
- civitaiLinkEl.innerHTML = `
-
Model not found
-
- ${this.getSearchIconHtml()} Search on CivitAI
-
- `;
- }
- }
-
- // Update name
- const nameEl = dialog.querySelector('.ml-info-name');
- if (nameEl) {
- nameEl.textContent = data.model_name || data.modelName || '';
- }
-
- // Update base model row
- const baseModelRowEl = dialog.querySelector('.ml-info-base-model');
- if (baseModelRowEl) {
- const baseModel = data.base_model || data.baseModel || '';
- baseModelRowEl.textContent = baseModel;
- const row = baseModelRowEl.closest('tr');
- if (row && baseModel) {
- row.style.display = '';
- } else if (row) {
- row.style.display = 'none';
- }
- }
-
- // Update trained words
- const trainedWordsEl = dialog.querySelector('.ml-info-trained-words');
- if (trainedWordsEl) {
- const words = this.normalizeTrainedWords(data.trained_words || data.trainedWords || []);
- if (words.length > 0) {
- dialog._selectedTrainedWords = new Set();
- trainedWordsEl.innerHTML = `
${words.map(word => `
-
- ${this.escapeHtml(word)}
-
- `).join('')}
`;
- const row = trainedWordsEl.closest('tr');
- if (row) row.style.display = '';
- this.updateSelectedTrainedWordsSummary(dialog);
- } else {
- const row = trainedWordsEl.closest('tr');
- if (row) row.style.display = 'none';
- }
- }
-
- // Update clip skip
- const clipSkipEl = dialog.querySelector('.ml-info-clip-skip');
- if (clipSkipEl) {
- const clipSkip = data.clip_skip || data.clipSkip;
- if (clipSkip && clipSkip !== 'None') {
- clipSkipEl.textContent = clipSkip;
- const row = clipSkipEl.closest('tr');
- if (row) row.style.display = '';
- } else {
- const row = clipSkipEl.closest('tr');
- if (row) row.style.display = 'none';
- }
- }
-
- // Update description
- const descEl = dialog.querySelector('.ml-info-description');
- if (descEl) {
- const desc = data.description || data.model_description || data.modelDescription || '';
- if (desc) {
- const actionsEl = dialog.querySelector('.ml-info-description-actions');
- const toggleBtn = dialog.querySelector('.ml-info-description-toggle');
-
- let sanitizedHtml = '';
- try {
- sanitizedHtml = this.sanitizeDescriptionHtml(desc);
- } catch (error) {
- console.error('Model Linker: Failed to sanitize description HTML:', error);
- }
-
- const fallbackText = this.escapeHtml(String(desc).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
- const finalHtml = sanitizedHtml && sanitizedHtml.trim() ? sanitizedHtml : `
${fallbackText}
`;
- const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
-
- descEl.innerHTML = finalHtml;
- descEl.classList.remove('is-expanded');
-
- const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900;
- if (actionsEl) {
- actionsEl.style.display = shouldCollapse ? '' : 'none';
- }
- if (toggleBtn) {
- toggleBtn.textContent = 'Show more';
- }
- if (!shouldCollapse) {
- descEl.classList.add('is-expanded');
- }
-
- const row = descEl.closest('tr');
- if (row) row.style.display = '';
- } else {
- const row = descEl.closest('tr');
- if (row) row.style.display = 'none';
- }
- }
-
- // Update images
- this.updateInfoDialogImages(dialog, data.images || data.modelImages || []);
- }
-
- /**
- * Update images in the info dialog
- */
- updateInfoDialogImages(dialog, images) {
- const imagesContainer = dialog.querySelector('.ml-info-images');
- if (!imagesContainer) return;
- if (!images.length) {
- imagesContainer.innerHTML = '';
- return;
- }
-
- const visibleImages = images.slice(0, 8).filter(img => img?.url);
-
- const renderImageCard = (img) => {
- const captionParts = [];
- if (img.civitaiUrl) {
- captionParts.push(`
civitai `);
- }
- if (img.seed) captionParts.push(`
seed ${this.escapeHtml(img.seed)}`);
- if (img.steps) captionParts.push(`
steps ${this.escapeHtml(img.steps)}`);
- if (img.cfg) captionParts.push(`
cfg ${this.escapeHtml(img.cfg)}`);
- if (img.sampler) captionParts.push(`
sampler ${this.escapeHtml(img.sampler)}`);
- if (img.model) captionParts.push(`
model ${this.escapeHtml(this.truncateText(img.model, 72))}`);
- if (img.positive) captionParts.push(`
positive ${this.escapeHtml(this.truncateText(img.positive, 180))}`);
- if (img.negative) captionParts.push(`
negative ${this.escapeHtml(this.truncateText(img.negative, 180))}`);
-
- return `
-
-
-
- ${captionParts.join('')}
-
-
- `;
- };
-
- let imagesHtml = '
';
- imagesHtml += visibleImages.map(renderImageCard).join('');
- imagesHtml += '
';
- imagesContainer.innerHTML = imagesHtml;
- }
-
- /**
- * Update the info dialog with error
- */
- updateInfoDialogError(dialog, message) {
- const civitaiLink = dialog.querySelector('.ml-info-civitai-link');
- if (civitaiLink) {
- civitaiLink.innerHTML = `
${message} `;
- }
- }
-
- /**
- * Close the info dialog
- */
- closeInfoDialog(dialog) {
- if (dialog?._infoDialogResizeObserver) {
- dialog._infoDialogResizeObserver.disconnect();
- dialog._infoDialogResizeObserver = null;
- }
- if (dialog?._infoDialogResizeSaveTimer) {
- clearTimeout(dialog._infoDialogResizeSaveTimer);
- dialog._infoDialogResizeSaveTimer = null;
- }
- this.saveInfoDialogSize(dialog);
-
- if (dialog && dialog.parentNode) {
- dialog.parentNode.removeChild(dialog);
- }
- }
- /**
- * Get a colored confidence badge HTML
- * @param {number} confidence - Confidence percentage (0-100)
- * @returns {string} HTML for the badge
- */
- getConfidenceBadge(confidence) {
- let badgeClass;
- if (confidence >= 95) {
- badgeClass = 'ml-badge-high';
- } else if (confidence >= 70) {
- badgeClass = 'ml-badge-medium';
- } else {
- badgeClass = 'ml-badge-low';
- }
- return `
${confidence}% `;
- }
-
- getStatusBadge(label, variant = 'neutral') {
- return `
${label} `;
- }
-
- /**
- * Format a filename with smart truncation
- * @param {string} path - Full path or filename
- * @param {number} maxLength - Maximum display length
- * @returns {object} { display: truncated name, full: full name }
- */
- formatFilename(path, maxLength = 50) {
- if (!path) return { display: 'Unknown', full: 'Unknown' };
-
- // Extract just the filename from path
- const filename = path.split(/[\/\\]/).pop() || path;
-
- if (filename.length <= maxLength) {
- return { display: filename, full: filename };
- }
-
- // Smart truncation: keep extension visible
- const lastDot = filename.lastIndexOf('.');
- const ext = lastDot > 0 ? filename.slice(lastDot) : '';
- const name = lastDot > 0 ? filename.slice(0, lastDot) : filename;
-
- // Calculate how much of the name we can show
- const availableLength = maxLength - ext.length - 3; // 3 for "..."
- if (availableLength < 8) {
- // Too short, just truncate at the end
- return { display: filename.slice(0, maxLength - 3) + '...', full: filename };
- }
-
- // Truncate middle of name
- const frontLength = Math.ceil(availableLength / 2);
- const backLength = Math.floor(availableLength / 2);
- const truncated = name.slice(0, frontLength) + '...' + name.slice(-backLength) + ext;
-
- return { display: truncated, full: filename };
- }
-
- /**
- * Format a path showing directory context
- * @param {string} path - Full relative path
- * @param {number} maxLength - Maximum display length
- * @returns {object} { display: formatted path, full: full path }
- */
- formatPath(path, maxLength = 60) {
- if (!path) return { display: 'Unknown', full: 'Unknown' };
-
- if (path.length <= maxLength) {
- return { display: path, full: path };
- }
-
- // Try to show meaningful parts: first dir + filename
- const parts = path.split(/[\/\\]/);
- const filename = parts.pop() || '';
- const firstDir = parts[0] || '';
-
- if (parts.length === 0) {
- // Just a filename
- return this.formatFilename(path, maxLength);
- }
-
- // Show first directory + ... + filename
- const formatted = firstDir + '\\...' + (filename.length > 40 ? this.formatFilename(filename, 40).display : filename);
-
- if (formatted.length <= maxLength) {
- return { display: formatted, full: path };
- }
-
- // Still too long, just truncate
- return { display: path.slice(0, maxLength - 3) + '...', full: path };
- }
-
- /**
- * Render a status message with icon
- * @param {string} message - Message text
- * @param {string} type - 'error' | 'success' | 'info' | 'warning'
- * @returns {string} HTML for status message
- */
- renderStatusMessage(message, type = 'info') {
- const icons = {
- error: '⚠',
- success: '✓',
- info: 'ℹ',
- warning: '⚡'
- };
- const icon = icons[type] || icons.info;
-
- return `
-
- ${icon}
- ${message}
-
- `;
- }
-
- /**
- * Render a progress bar
- * @param {number} percent - Progress percentage (0-100)
- * @param {string} leftText - Text on the left
- * @param {string} rightText - Text on the right
- * @returns {string} HTML for progress bar
- */
- renderProgressBar(percent, leftText = '', rightText = '') {
- return `
-
-
-
- ${leftText}
- ${rightText}
-
-
- `;
- }
-
- renderAnalysisProgress(progress = {}) {
- const current = Number(progress.current) || 0;
- const total = Number(progress.total) || 0;
- const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((current / total) * 100))) : 0;
- const message = progress.message || 'Analyzing workflow...';
- const modelName = progress.model_name ? this.escapeHtml(String(progress.model_name)) : '';
- const detail = total > 0 ? `${current} / ${total}` : 'Preparing...';
-
- return `
-
-
- ${this.getStatusBadge('Analyzing', 'info')}
- ${message}
-
- ${this.renderProgressBar(percent, detail, `${percent}%`)}
- ${modelName ? `
${modelName}
` : ''}
-
- `;
- }
-
- async pollAnalysisProgress(analysisId, token) {
- while (this._analysisProgressToken === token) {
- try {
- const response = await api.fetchApi(`/model_linker/analyze-progress/${analysisId}`);
- if (response.ok && this.contentElement && this._analysisProgressToken === token) {
- const progress = await response.json();
- this.contentElement.innerHTML = this.renderAnalysisProgress(progress);
- if (progress.status === 'completed' || progress.status === 'error') {
- return;
- }
- }
- } catch (error) {
- console.warn('Model Linker: analysis progress polling failed', error);
- }
-
- await new Promise(resolve => setTimeout(resolve, 250));
- }
- }
-
- getWorkflowSignature(workflow) {
- if (!workflow) return null;
- try {
- return JSON.stringify(workflow);
- } catch (error) {
- console.warn('Model Linker: workflow signature generation failed', error);
- return null;
- }
- }
-
- /**
- * Handle clicks outside the dialog
- */
- handleOutsideClick(e) {
- // Close if click is on the backdrop (not on the dialog itself)
- if (e.target === this.backdrop) {
- this.close();
- }
- }
-
- createHeader() {
- // Create tabs
- this.missingTab = $el("button.ml-tab.ml-tab-active", {
- textContent: "Missing Models",
- onclick: () => this.switchTab('missing')
- });
-
- this.loadedTab = $el("button.ml-tab", {
- textContent: "Loaded Models",
- onclick: () => this.switchTab('loaded')
- });
-
- this.optionsTab = $el("button.ml-tab", {
- textContent: "Options",
- onclick: () => this.switchTab('options')
- });
-
+ } else {
+ const searchName = data.model_name || data.modelName || 'Unknown';
+ civitaiLinkEl.innerHTML = `
+
Model not found
+
+ ${this.getSearchIconHtml()} Search on CivitAI
+
+ `;
+ }
+ }
+
+ // Update name
+ const nameEl = dialog.querySelector('.ml-info-name');
+ if (nameEl) {
+ nameEl.textContent = data.model_name || data.modelName || '';
+ }
+
+ // Update base model row
+ const baseModelRowEl = dialog.querySelector('.ml-info-base-model');
+ if (baseModelRowEl) {
+ const baseModel = data.base_model || data.baseModel || '';
+ baseModelRowEl.textContent = baseModel;
+ const row = baseModelRowEl.closest('tr');
+ if (row && baseModel) {
+ row.style.display = '';
+ } else if (row) {
+ row.style.display = 'none';
+ }
+ }
+
+ // Update trained words
+ const trainedWordsEl = dialog.querySelector('.ml-info-trained-words');
+ if (trainedWordsEl) {
+ const words = this.normalizeTrainedWords(data.trained_words || data.trainedWords || []);
+ if (words.length > 0) {
+ dialog._selectedTrainedWords = new Set();
+ trainedWordsEl.innerHTML = `
${words.map(word => `
+
+ ${this.escapeHtml(word)}
+
+ `).join('')}
`;
+ const row = trainedWordsEl.closest('tr');
+ if (row) row.style.display = '';
+ this.updateSelectedTrainedWordsSummary(dialog);
+ } else {
+ const row = trainedWordsEl.closest('tr');
+ if (row) row.style.display = 'none';
+ }
+ }
+
+ // Update clip skip
+ const clipSkipEl = dialog.querySelector('.ml-info-clip-skip');
+ if (clipSkipEl) {
+ const clipSkip = data.clip_skip || data.clipSkip;
+ if (clipSkip && clipSkip !== 'None') {
+ clipSkipEl.textContent = clipSkip;
+ const row = clipSkipEl.closest('tr');
+ if (row) row.style.display = '';
+ } else {
+ const row = clipSkipEl.closest('tr');
+ if (row) row.style.display = 'none';
+ }
+ }
+
+ // Update description
+ const descEl = dialog.querySelector('.ml-info-description');
+ if (descEl) {
+ const desc = data.description || data.model_description || data.modelDescription || '';
+ if (desc) {
+ const actionsEl = dialog.querySelector('.ml-info-description-actions');
+ const toggleBtn = dialog.querySelector('.ml-info-description-toggle');
+
+ let sanitizedHtml = '';
+ try {
+ sanitizedHtml = this.sanitizeDescriptionHtml(desc);
+ } catch (error) {
+ console.error('Model Linker: Failed to sanitize description HTML:', error);
+ }
+
+ const fallbackText = this.escapeHtml(String(desc).replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim());
+ const finalHtml = sanitizedHtml && sanitizedHtml.trim() ? sanitizedHtml : `
${fallbackText}
`;
+ const textOnly = finalHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+
+ descEl.innerHTML = finalHtml;
+ descEl.classList.remove('is-expanded');
+
+ const shouldCollapse = textOnly.length > 520 || finalHtml.length > 900;
+ if (actionsEl) {
+ actionsEl.style.display = shouldCollapse ? '' : 'none';
+ }
+ if (toggleBtn) {
+ toggleBtn.textContent = 'Show more';
+ }
+ if (!shouldCollapse) {
+ descEl.classList.add('is-expanded');
+ }
+
+ const row = descEl.closest('tr');
+ if (row) row.style.display = '';
+ } else {
+ const row = descEl.closest('tr');
+ if (row) row.style.display = 'none';
+ }
+ }
+
+ // Update images
+ this.updateInfoDialogImages(dialog, data.images || data.modelImages || []);
+ }
+
+ /**
+ * Update images in the info dialog
+ */
+ updateInfoDialogImages(dialog, images) {
+ const imagesContainer = dialog.querySelector('.ml-info-images');
+ if (!imagesContainer) return;
+ if (!images.length) {
+ imagesContainer.innerHTML = '';
+ return;
+ }
+
+ const visibleImages = images.slice(0, 8).filter(img => img?.url);
+
+ const renderImageCard = (img) => {
+ const captionParts = [];
+ if (img.civitaiUrl) {
+ captionParts.push(`
civitai `);
+ }
+ if (img.seed) captionParts.push(`
seed ${this.escapeHtml(img.seed)}`);
+ if (img.steps) captionParts.push(`
steps ${this.escapeHtml(img.steps)}`);
+ if (img.cfg) captionParts.push(`
cfg ${this.escapeHtml(img.cfg)}`);
+ if (img.sampler) captionParts.push(`
sampler ${this.escapeHtml(img.sampler)}`);
+ if (img.model) captionParts.push(`
model ${this.escapeHtml(this.truncateText(img.model, 72))}`);
+ if (img.positive) captionParts.push(`
positive ${this.escapeHtml(this.truncateText(img.positive, 180))}`);
+ if (img.negative) captionParts.push(`
negative ${this.escapeHtml(this.truncateText(img.negative, 180))}`);
+
+ return `
+
+
+
+ ${captionParts.join('')}
+
+
+ `;
+ };
+
+ let imagesHtml = '
';
+ imagesHtml += visibleImages.map(renderImageCard).join('');
+ imagesHtml += '
';
+ imagesContainer.innerHTML = imagesHtml;
+ }
+
+ /**
+ * Update the info dialog with error
+ */
+ updateInfoDialogError(dialog, message) {
+ const civitaiLink = dialog.querySelector('.ml-info-civitai-link');
+ if (civitaiLink) {
+ civitaiLink.innerHTML = `
${message} `;
+ }
+ }
+
+ /**
+ * Close the info dialog
+ */
+ closeInfoDialog(dialog) {
+ if (dialog?._infoDialogResizeObserver) {
+ dialog._infoDialogResizeObserver.disconnect();
+ dialog._infoDialogResizeObserver = null;
+ }
+ if (dialog?._infoDialogResizeSaveTimer) {
+ clearTimeout(dialog._infoDialogResizeSaveTimer);
+ dialog._infoDialogResizeSaveTimer = null;
+ }
+ this.saveInfoDialogSize(dialog);
+
+ if (dialog && dialog.parentNode) {
+ dialog.parentNode.removeChild(dialog);
+ }
+ }
+
+ /**
+ * Get a colored confidence badge HTML
+ * @param {number} confidence - Confidence percentage (0-100)
+ * @returns {string} HTML for the badge
+ */
+ getConfidenceBadge(confidence) {
+ let badgeClass;
+ if (confidence >= 95) {
+ badgeClass = 'ml-badge-high';
+ } else if (confidence >= 70) {
+ badgeClass = 'ml-badge-medium';
+ } else {
+ badgeClass = 'ml-badge-low';
+ }
+ return `
${confidence}% `;
+ }
+
+ getStatusBadge(label, variant = 'neutral') {
+ return `
${label} `;
+ }
+
+ /**
+ * Format a filename with smart truncation
+ * @param {string} path - Full path or filename
+ * @param {number} maxLength - Maximum display length
+ * @returns {object} { display: truncated name, full: full name }
+ */
+ formatFilename(path, maxLength = 50) {
+ if (!path) return { display: 'Unknown', full: 'Unknown' };
+
+ // Extract just the filename from path
+ const filename = path.split(/[\/\\]/).pop() || path;
+
+ if (filename.length <= maxLength) {
+ return { display: filename, full: filename };
+ }
+
+ // Smart truncation: keep extension visible
+ const lastDot = filename.lastIndexOf('.');
+ const ext = lastDot > 0 ? filename.slice(lastDot) : '';
+ const name = lastDot > 0 ? filename.slice(0, lastDot) : filename;
+
+ // Calculate how much of the name we can show
+ const availableLength = maxLength - ext.length - 3; // 3 for "..."
+ if (availableLength < 8) {
+ // Too short, just truncate at the end
+ return { display: filename.slice(0, maxLength - 3) + '...', full: filename };
+ }
+
+ // Truncate middle of name
+ const frontLength = Math.ceil(availableLength / 2);
+ const backLength = Math.floor(availableLength / 2);
+ const truncated = name.slice(0, frontLength) + '...' + name.slice(-backLength) + ext;
+
+ return { display: truncated, full: filename };
+ }
+
+ /**
+ * Format a path showing directory context
+ * @param {string} path - Full relative path
+ * @param {number} maxLength - Maximum display length
+ * @returns {object} { display: formatted path, full: full path }
+ */
+ formatPath(path, maxLength = 60) {
+ if (!path) return { display: 'Unknown', full: 'Unknown' };
+
+ if (path.length <= maxLength) {
+ return { display: path, full: path };
+ }
+
+ // Try to show meaningful parts: first dir + filename
+ const parts = path.split(/[\/\\]/);
+ const filename = parts.pop() || '';
+ const firstDir = parts[0] || '';
+
+ if (parts.length === 0) {
+ // Just a filename
+ return this.formatFilename(path, maxLength);
+ }
+
+ // Show first directory + ... + filename
+ const formatted = firstDir + '\\...' + (filename.length > 40 ? this.formatFilename(filename, 40).display : filename);
+
+ if (formatted.length <= maxLength) {
+ return { display: formatted, full: path };
+ }
+
+ // Still too long, just truncate
+ return { display: path.slice(0, maxLength - 3) + '...', full: path };
+ }
+
+ /**
+ * Render a status message with icon
+ * @param {string} message - Message text
+ * @param {string} type - 'error' | 'success' | 'info' | 'warning'
+ * @returns {string} HTML for status message
+ */
+ renderStatusMessage(message, type = 'info') {
+ const icons = {
+ error: '⚠',
+ success: '✓',
+ info: 'ℹ',
+ warning: '⚡'
+ };
+ const icon = icons[type] || icons.info;
+
+ return `
+
+ ${icon}
+ ${message}
+
+ `;
+ }
+
+ /**
+ * Render a progress bar
+ * @param {number} percent - Progress percentage (0-100)
+ * @param {string} leftText - Text on the left
+ * @param {string} rightText - Text on the right
+ * @returns {string} HTML for progress bar
+ */
+ renderProgressBar(percent, leftText = '', rightText = '') {
+ return `
+
+
+
+ ${leftText}
+ ${rightText}
+
+
+ `;
+ }
+
+ renderAnalysisProgress(progress = {}) {
+ const current = Number(progress.current) || 0;
+ const total = Number(progress.total) || 0;
+ const percent = total > 0 ? Math.max(0, Math.min(100, Math.round((current / total) * 100))) : 0;
+ const message = progress.message || 'Analyzing workflow...';
+ const modelName = progress.model_name ? this.escapeHtml(String(progress.model_name)) : '';
+ const detail = total > 0 ? `${current} / ${total}` : 'Preparing...';
+
+ return `
+
+
+ ${this.getStatusBadge('Analyzing', 'info')}
+ ${message}
+
+ ${this.renderProgressBar(percent, detail, `${percent}%`)}
+ ${modelName ? `
${modelName}
` : ''}
+
+ `;
+ }
+
+ async pollAnalysisProgress(analysisId, token) {
+ while (this._analysisProgressToken === token) {
+ try {
+ const response = await api.fetchApi(`/model_linker/analyze-progress/${analysisId}`);
+ if (response.ok && this.contentElement && this._analysisProgressToken === token) {
+ const progress = await response.json();
+ this.contentElement.innerHTML = this.renderAnalysisProgress(progress);
+ if (progress.status === 'completed' || progress.status === 'error') {
+ return;
+ }
+ }
+ } catch (error) {
+ console.warn('Model Linker: analysis progress polling failed', error);
+ }
+
+ await new Promise(resolve => setTimeout(resolve, 250));
+ }
+ }
+
+ getWorkflowSignature(workflow) {
+ if (!workflow) return null;
+ try {
+ return JSON.stringify(workflow);
+ } catch (error) {
+ console.warn('Model Linker: workflow signature generation failed', error);
+ return null;
+ }
+ }
+
+ /**
+ * Handle clicks outside the dialog
+ */
+ handleOutsideClick(e) {
+ // Close if click is on the backdrop (not on the dialog itself)
+ if (e.target === this.backdrop) {
+ this.close();
+ }
+ }
+
+ createHeader() {
+ // Create tabs
+ this.missingTab = $el("button.ml-tab.ml-tab-active", {
+ textContent: "Missing Models",
+ onclick: () => this.switchTab('missing')
+ });
+
+ this.loadedTab = $el("button.ml-tab", {
+ textContent: "Loaded Models",
+ onclick: () => this.switchTab('loaded')
+ });
+
+ this.optionsTab = $el("button.ml-tab", {
+ textContent: "Options",
+ onclick: () => this.switchTab('options')
+ });
+
return $el("div.ml-dialog-shell", {}, [
$el("div.ml-dialog-topbar", {}, [
$el("div.ml-dialog-brand", {}, [
@@ -1596,215 +1597,215 @@ class LinkerManagerDialog extends ComfyDialog {
})
])
]),
- $el("div.ml-tabs", {}, [
- this.missingTab,
- this.loadedTab,
- this.optionsTab
- ])
- ]);
- }
-
- // Toggle full screen mode for the dialog
- toggleFullScreen() {
- this.setFullScreen(!this.fullscreen);
- }
-
- setFullScreen(enable) {
- this.fullscreen = !!enable;
- const el = this.element;
- if (!el) return;
- const btn = document.getElementById('model-linker-fullscreen-toggle');
- if (enable) {
- // Save current size
- try {
- const rect = el.getBoundingClientRect();
- localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) }));
- } catch (e) {}
- el.style.top = '0';
- el.style.left = '0';
- el.style.transform = 'none';
- el.style.width = '100vw';
- el.style.height = '100vh';
- el.style.maxWidth = '100vw';
- el.style.maxHeight = '100vh';
- el.style.borderRadius = '0';
- el.style.resize = 'none';
- if (btn) btn.textContent = '🗗';
- try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {}
- } else {
- // Restore centered sizing
- el.style.maxWidth = '95vw';
- el.style.maxHeight = '95vh';
- el.style.borderRadius = '8px';
- el.style.resize = 'both';
- // Restore saved pre-FS size if available
- let wh = null;
- try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {}
- if (wh && wh.w && wh.h) {
- el.style.width = `${wh.w}px`;
- el.style.height = `${wh.h}px`;
- } else {
- el.style.width = '1100px';
- el.style.height = '700px';
- }
- // Restore last known position if available, else center
- try {
- const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null');
- if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) {
- el.style.top = `${pos.top}px`;
- el.style.left = `${pos.left}px`;
- el.style.transform = 'none';
- } else {
- el.style.top = '50%';
- el.style.left = '50%';
- el.style.transform = 'translate(-50%, -50%)';
- }
- } catch (e) {
- el.style.top = '50%';
- el.style.left = '50%';
- el.style.transform = 'translate(-50%, -50%)';
- }
- if (btn) btn.textContent = '⛶';
- try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {}
- }
- }
-
- // Begin window drag
- startDrag(e) {
- try {
- const el = this.element;
- if (!el) return;
- const rect = el.getBoundingClientRect();
- // Switch to absolute top/left (no transform) before dragging
- el.style.top = `${rect.top}px`;
- el.style.left = `${rect.left}px`;
- el.style.transform = 'none';
- this._dragging = true;
- this._dragStart = {
- x: e.clientX,
- y: e.clientY,
- top: rect.top,
- left: rect.left
- };
- // Prevent text selection while dragging
- this._prevUserSelect = document.body.style.userSelect;
- document.body.style.userSelect = 'none';
- // Attach listeners
- this._onMouseMove = (ev) => this.onDrag(ev);
- this._onMouseUp = () => this.endDrag();
- document.addEventListener('mousemove', this._onMouseMove);
- document.addEventListener('mouseup', this._onMouseUp, { once: true });
- } catch (err) { /* ignore */ }
- }
-
- onDrag(e) {
- if (!this._dragging || !this._dragStart) return;
- const el = this.element;
- if (!el) return;
- const dx = e.clientX - this._dragStart.x;
- const dy = e.clientY - this._dragStart.y;
- let top = this._dragStart.top + dy;
- let left = this._dragStart.left + dx;
- // Clamp so the drag handle always stays reachable on screen.
- const vw = window.innerWidth;
- const vh = window.innerHeight;
- const pad = 4; // small padding
- const handle = document.getElementById('model-linker-drag-handle');
- if (handle) {
- const handleOffsetLeft = handle.offsetLeft;
- const handleOffsetTop = handle.offsetTop;
- const handleWidth = handle.offsetWidth;
- const handleHeight = handle.offsetHeight;
- const minLeft = pad - handleOffsetLeft;
- const maxLeft = vw - pad - handleOffsetLeft - handleWidth;
- const minTop = pad - handleOffsetTop;
- const maxTop = vh - pad - handleOffsetTop - handleHeight;
- left = Math.max(minLeft, Math.min(maxLeft, left));
- top = Math.max(minTop, Math.min(maxTop, top));
- } else {
- const w = el.offsetWidth;
- const h = el.offsetHeight;
- left = Math.max(-w + pad, Math.min(vw - pad, left));
- top = Math.max(-h + pad, Math.min(vh - pad, top));
- }
- el.style.top = `${Math.round(top)}px`;
- el.style.left = `${Math.round(left)}px`;
- }
-
- endDrag() {
- if (!this._dragging) return;
- this._dragging = false;
- document.removeEventListener('mousemove', this._onMouseMove);
- // Persist position
- try {
- const el = this.element;
- const rect = el.getBoundingClientRect();
- localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) }));
- } catch (e) { /* ignore */ }
- // Restore selection
- try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {}
- }
-
- /**
- * Simple debounce helper
- */
- debounce(callback, wait = 250) {
- let t = null;
- return (...args) => {
- if (t) clearTimeout(t);
- t = setTimeout(() => {
- callback.apply(this, args);
- }, wait);
- };
- }
-
- /**
- * Ensure all models are loaded for the dropdown.
- */
- async ensureAllModelsLoaded() {
- if (this.allModels && this.allModels.length) return;
- try {
- const resp = await api.fetchApi('/model_linker/models');
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const models = await resp.json();
- const list = Array.isArray(models) ? models : [];
- // Build labels and sort alphabetically
- this.allModels = list.map((m) => ({
- ...m,
- __label: `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`
- })).sort((a, b) => (a.__label || '').localeCompare(b.__label || ''));
- } catch (e) {
- console.warn('Model Linker: could not load all models', e);
- this.allModels = [];
- }
- }
-
- 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 = {};
- }
- }
-
- async ensureCapabilitiesLoaded() {
- if (this.capabilities) return;
- try {
- const resp = await api.fetchApi('/model_linker/capabilities');
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
- this.capabilities = data && typeof data === 'object' ? data : { sources: {} };
- } catch (e) {
- console.warn('Model Linker: could not load capabilities', e);
- this.capabilities = { sources: {} };
- }
- }
-
+ $el("div.ml-tabs", {}, [
+ this.missingTab,
+ this.loadedTab,
+ this.optionsTab
+ ])
+ ]);
+ }
+
+ // Toggle full screen mode for the dialog
+ toggleFullScreen() {
+ this.setFullScreen(!this.fullscreen);
+ }
+
+ setFullScreen(enable) {
+ this.fullscreen = !!enable;
+ const el = this.element;
+ if (!el) return;
+ const btn = document.getElementById('model-linker-fullscreen-toggle');
+ if (enable) {
+ // Save current size
+ try {
+ const rect = el.getBoundingClientRect();
+ localStorage.setItem('model_linker_modal_size_before_fs', JSON.stringify({ w: Math.round(rect.width), h: Math.round(rect.height) }));
+ } catch (e) {}
+ el.style.top = '0';
+ el.style.left = '0';
+ el.style.transform = 'none';
+ el.style.width = '100vw';
+ el.style.height = '100vh';
+ el.style.maxWidth = '100vw';
+ el.style.maxHeight = '100vh';
+ el.style.borderRadius = '0';
+ el.style.resize = 'none';
+ if (btn) btn.textContent = '🗗';
+ try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {}
+ } else {
+ // Restore centered sizing
+ el.style.maxWidth = '95vw';
+ el.style.maxHeight = '95vh';
+ el.style.borderRadius = '8px';
+ el.style.resize = 'both';
+ // Restore saved pre-FS size if available
+ let wh = null;
+ try { wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null'); } catch (e) {}
+ if (wh && wh.w && wh.h) {
+ el.style.width = `${wh.w}px`;
+ el.style.height = `${wh.h}px`;
+ } else {
+ el.style.width = '1100px';
+ el.style.height = '700px';
+ }
+ // Restore last known position if available, else center
+ try {
+ const pos = JSON.parse(localStorage.getItem('model_linker_modal_pos') || 'null');
+ if (pos && Number.isFinite(pos.top) && Number.isFinite(pos.left)) {
+ el.style.top = `${pos.top}px`;
+ el.style.left = `${pos.left}px`;
+ el.style.transform = 'none';
+ } else {
+ el.style.top = '50%';
+ el.style.left = '50%';
+ el.style.transform = 'translate(-50%, -50%)';
+ }
+ } catch (e) {
+ el.style.top = '50%';
+ el.style.left = '50%';
+ el.style.transform = 'translate(-50%, -50%)';
+ }
+ if (btn) btn.textContent = '⛶';
+ try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {}
+ }
+ }
+
+ // Begin window drag
+ startDrag(e) {
+ try {
+ const el = this.element;
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ // Switch to absolute top/left (no transform) before dragging
+ el.style.top = `${rect.top}px`;
+ el.style.left = `${rect.left}px`;
+ el.style.transform = 'none';
+ this._dragging = true;
+ this._dragStart = {
+ x: e.clientX,
+ y: e.clientY,
+ top: rect.top,
+ left: rect.left
+ };
+ // Prevent text selection while dragging
+ this._prevUserSelect = document.body.style.userSelect;
+ document.body.style.userSelect = 'none';
+ // Attach listeners
+ this._onMouseMove = (ev) => this.onDrag(ev);
+ this._onMouseUp = () => this.endDrag();
+ document.addEventListener('mousemove', this._onMouseMove);
+ document.addEventListener('mouseup', this._onMouseUp, { once: true });
+ } catch (err) { /* ignore */ }
+ }
+
+ onDrag(e) {
+ if (!this._dragging || !this._dragStart) return;
+ const el = this.element;
+ if (!el) return;
+ const dx = e.clientX - this._dragStart.x;
+ const dy = e.clientY - this._dragStart.y;
+ let top = this._dragStart.top + dy;
+ let left = this._dragStart.left + dx;
+ // Clamp so the drag handle always stays reachable on screen.
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
+ const pad = 4; // small padding
+ const handle = document.getElementById('model-linker-drag-handle');
+ if (handle) {
+ const handleOffsetLeft = handle.offsetLeft;
+ const handleOffsetTop = handle.offsetTop;
+ const handleWidth = handle.offsetWidth;
+ const handleHeight = handle.offsetHeight;
+ const minLeft = pad - handleOffsetLeft;
+ const maxLeft = vw - pad - handleOffsetLeft - handleWidth;
+ const minTop = pad - handleOffsetTop;
+ const maxTop = vh - pad - handleOffsetTop - handleHeight;
+ left = Math.max(minLeft, Math.min(maxLeft, left));
+ top = Math.max(minTop, Math.min(maxTop, top));
+ } else {
+ const w = el.offsetWidth;
+ const h = el.offsetHeight;
+ left = Math.max(-w + pad, Math.min(vw - pad, left));
+ top = Math.max(-h + pad, Math.min(vh - pad, top));
+ }
+ el.style.top = `${Math.round(top)}px`;
+ el.style.left = `${Math.round(left)}px`;
+ }
+
+ endDrag() {
+ if (!this._dragging) return;
+ this._dragging = false;
+ document.removeEventListener('mousemove', this._onMouseMove);
+ // Persist position
+ try {
+ const el = this.element;
+ const rect = el.getBoundingClientRect();
+ localStorage.setItem('model_linker_modal_pos', JSON.stringify({ top: Math.round(rect.top), left: Math.round(rect.left) }));
+ } catch (e) { /* ignore */ }
+ // Restore selection
+ try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) {}
+ }
+
+ /**
+ * Simple debounce helper
+ */
+ debounce(callback, wait = 250) {
+ let t = null;
+ return (...args) => {
+ if (t) clearTimeout(t);
+ t = setTimeout(() => {
+ callback.apply(this, args);
+ }, wait);
+ };
+ }
+
+ /**
+ * Ensure all models are loaded for the dropdown.
+ */
+ async ensureAllModelsLoaded() {
+ if (this.allModels && this.allModels.length) return;
+ try {
+ const resp = await api.fetchApi('/model_linker/models');
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const models = await resp.json();
+ const list = Array.isArray(models) ? models : [];
+ // Build labels and sort alphabetically
+ this.allModels = list.map((m) => ({
+ ...m,
+ __label: `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`
+ })).sort((a, b) => (a.__label || '').localeCompare(b.__label || ''));
+ } catch (e) {
+ console.warn('Model Linker: could not load all models', e);
+ this.allModels = [];
+ }
+ }
+
+ 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 = {};
+ }
+ }
+
+ async ensureCapabilitiesLoaded() {
+ if (this.capabilities) return;
+ try {
+ const resp = await api.fetchApi('/model_linker/capabilities');
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
+ const data = await resp.json();
+ this.capabilities = data && typeof data === 'object' ? data : { sources: {} };
+ } catch (e) {
+ console.warn('Model Linker: could not load capabilities', e);
+ this.capabilities = { sources: {} };
+ }
+ }
+
isSourceAvailable(source) {
if (!source || ['all', 'local', 'huggingface', 'civitai'].includes(source)) {
return true;
@@ -1851,730 +1852,730 @@ class LinkerManagerDialog extends ComfyDialog {
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?.civitai_search_result || {}),
- ...(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;
- }
- }
-
- applySearchResultSuggestion(missing) {
- const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
- const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
- if (!categoryEl || !subfolderEl) return;
- this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
- }
-
- async ensureDownloadSubfoldersLoaded(category = '') {
- const key = (category || '').trim().toLowerCase();
- if (!key) return [];
- if (key === 'unknown') {
- this.downloadSubfolders.set(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 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?.civitai_search_result || {}),
+ ...(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;
+ }
+ }
+
+ applySearchResultSuggestion(missing) {
+ const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
+ const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
+ if (!categoryEl || !subfolderEl) return;
+ this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
+ }
+
+ async ensureDownloadSubfoldersLoaded(category = '') {
+ const key = (category || '').trim().toLowerCase();
+ if (!key) return [];
+ if (key === 'unknown') {
+ this.downloadSubfolders.set(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 => `
${this.getCategoryDisplayName(category)} `)
.join('');
-
- let html = `
`;
- html += `
`;
- html += `
Folder `;
- html += `
Subfolder (optional) `;
- html += `
${options} `;
- html += `
`;
- html += `
`;
- html += `
`;
- html += `
`;
- html += `
`;
- html += `
`;
- return html;
- }
-
- getDownloadTargetSelection(missing, fallbackCategory = 'checkpoints') {
- const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
- const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
- return {
- category: categoryEl?.value || fallbackCategory || 'checkpoints',
- subfolder: (subfolderEl?.value || '').trim()
- };
- }
-
- enableWheelScrollChaining(scrollEl) {
- if (!scrollEl || scrollEl.dataset.mlWheelChainBound === 'true') return;
- scrollEl.dataset.mlWheelChainBound = 'true';
-
- scrollEl.addEventListener('wheel', (event) => {
- const deltaY = event.deltaY;
- if (!deltaY) {
- return;
- }
-
- event.preventDefault();
- event.stopPropagation();
-
- const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
- if (maxScrollTop <= 0) {
- return;
- }
-
- const nextScrollTop = Math.min(
- maxScrollTop,
- Math.max(0, scrollEl.scrollTop + deltaY)
- );
-
- scrollEl.scrollTop = nextScrollTop;
- }, { passive: false });
- }
-
- wireDownloadTargetAutocomplete(container, missing) {
- const categoryEl = container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
- const subfolderEl = container.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
- const listEl = container.querySelector(`#download-subfolder-list-${missing.node_id}-${missing.widget_index}`);
- if (!categoryEl || !subfolderEl || !listEl) return;
-
- this.enableWheelScrollChaining(listEl);
-
- const renderOptions = (targetEl, values, onSelect) => {
- if (!values.length) {
- targetEl.innerHTML = '';
- targetEl.style.display = 'none';
- return;
- }
-
- targetEl.innerHTML = values
- .slice(0, 50)
- .map(value => `
${value}
`)
- .join('');
-
- targetEl.style.display = 'block';
-
- targetEl.querySelectorAll('.ml-download-target-option').forEach(option => {
- option.addEventListener('mousedown', (event) => {
- event.preventDefault();
- const value = decodeURIComponent(option.dataset.value || '');
- onSelect(value);
- targetEl.style.display = 'none';
- });
- });
- };
-
- const populateSubfolderOptions = async (filterText = '') => {
- const filter = (filterText || '').toLowerCase();
- await this.ensureDownloadSubfoldersLoaded(categoryEl.value);
- const folders = this.getAvailableSubfolders(categoryEl.value);
- const filtered = filter
- ? folders.filter(folder => folder.toLowerCase().includes(filter))
- : folders;
-
- renderOptions(listEl, filtered, (value) => {
- subfolderEl.value = value;
- });
- };
-
- const hideList = (targetEl) => {
- setTimeout(() => {
- targetEl.style.display = 'none';
- }, 150);
- };
-
- categoryEl.addEventListener('change', () => {
- populateSubfolderOptions(subfolderEl.value);
- this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
- });
-
- subfolderEl.addEventListener('focus', () => {
- populateSubfolderOptions(subfolderEl.value);
- });
-
- subfolderEl.addEventListener('input', () => {
- populateSubfolderOptions(subfolderEl.value);
- });
-
- subfolderEl.addEventListener('blur', () => hideList(listEl));
- this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
- }
-
- getStoredTokens() {
- const civitaiCandidateLimitRaw = parseInt(localStorage.getItem('modelLinker.civitaiCandidateLimit') || '5', 10);
- const civitai_candidate_limit = Number.isFinite(civitaiCandidateLimitRaw)
- ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
- : 5;
-
- return {
- civitai_key: localStorage.getItem('modelLinker.civitaiApiKey') || '',
- civitai_session_token: localStorage.getItem('modelLinker.civitaiSessionToken') || '',
- hf_token: localStorage.getItem('modelLinker.huggingFaceToken') || '',
- brave_search_api_key: localStorage.getItem('modelLinker.braveSearchApiKey') || '',
- civitai_use_trpc_search: localStorage.getItem('modelLinker.civitaiUseTrpcSearch') !== 'false',
- civitai_use_html_fallback: localStorage.getItem('modelLinker.civitaiUseHtmlFallback') !== 'false',
- hf_use_api_search: localStorage.getItem('modelLinker.hfUseApiSearch') !== 'false',
- hf_use_comfy_org_fallback: localStorage.getItem('modelLinker.hfUseComfyOrgFallback') !== 'false',
- hf_use_brave_fallback: localStorage.getItem('modelLinker.hfUseBraveFallback') !== 'false',
- civitai_candidate_limit
- };
- }
-
- async clearSearchCaches() {
- this.searchResultCache.clear();
- try {
- const response = await api.fetchApi('/model_linker/clear-search-cache', {
- method: 'POST'
- });
- if (!response.ok) {
- throw new Error('Failed to clear backend search cache');
- }
- } catch (error) {
- console.error('Model Linker: Clear search cache error:', error);
- }
- }
-
- displayOptions() {
- if (!this.contentElement) return;
- this.contentElement.style.overflowY = 'hidden';
-
- const tokens = this.getStoredTokens();
- this.contentElement.innerHTML = `
-
-
-
-
-
-
-
CivitAI
-
-
-
-
-
-
+
+ let html = `
`;
+ html += `
`;
+ html += `
Folder `;
+ html += `
Subfolder (optional) `;
+ html += `
${options} `;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ html += `
`;
+ return html;
+ }
+
+ getDownloadTargetSelection(missing, fallbackCategory = 'checkpoints') {
+ const categoryEl = this.contentElement?.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
+ const subfolderEl = this.contentElement?.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
+ return {
+ category: categoryEl?.value || fallbackCategory || 'checkpoints',
+ subfolder: (subfolderEl?.value || '').trim()
+ };
+ }
+
+ enableWheelScrollChaining(scrollEl) {
+ if (!scrollEl || scrollEl.dataset.mlWheelChainBound === 'true') return;
+ scrollEl.dataset.mlWheelChainBound = 'true';
+
+ scrollEl.addEventListener('wheel', (event) => {
+ const deltaY = event.deltaY;
+ if (!deltaY) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ const maxScrollTop = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
+ if (maxScrollTop <= 0) {
+ return;
+ }
+
+ const nextScrollTop = Math.min(
+ maxScrollTop,
+ Math.max(0, scrollEl.scrollTop + deltaY)
+ );
+
+ scrollEl.scrollTop = nextScrollTop;
+ }, { passive: false });
+ }
+
+ wireDownloadTargetAutocomplete(container, missing) {
+ const categoryEl = container.querySelector(`#download-category-${missing.node_id}-${missing.widget_index}`);
+ const subfolderEl = container.querySelector(`#download-subfolder-${missing.node_id}-${missing.widget_index}`);
+ const listEl = container.querySelector(`#download-subfolder-list-${missing.node_id}-${missing.widget_index}`);
+ if (!categoryEl || !subfolderEl || !listEl) return;
+
+ this.enableWheelScrollChaining(listEl);
+
+ const renderOptions = (targetEl, values, onSelect) => {
+ if (!values.length) {
+ targetEl.innerHTML = '';
+ targetEl.style.display = 'none';
+ return;
+ }
+
+ targetEl.innerHTML = values
+ .slice(0, 50)
+ .map(value => `
${value}
`)
+ .join('');
+
+ targetEl.style.display = 'block';
+
+ targetEl.querySelectorAll('.ml-download-target-option').forEach(option => {
+ option.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+ const value = decodeURIComponent(option.dataset.value || '');
+ onSelect(value);
+ targetEl.style.display = 'none';
+ });
+ });
+ };
+
+ const populateSubfolderOptions = async (filterText = '') => {
+ const filter = (filterText || '').toLowerCase();
+ await this.ensureDownloadSubfoldersLoaded(categoryEl.value);
+ const folders = this.getAvailableSubfolders(categoryEl.value);
+ const filtered = filter
+ ? folders.filter(folder => folder.toLowerCase().includes(filter))
+ : folders;
+
+ renderOptions(listEl, filtered, (value) => {
+ subfolderEl.value = value;
+ });
+ };
+
+ const hideList = (targetEl) => {
+ setTimeout(() => {
+ targetEl.style.display = 'none';
+ }, 150);
+ };
+
+ categoryEl.addEventListener('change', () => {
+ populateSubfolderOptions(subfolderEl.value);
+ this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
+ });
+
+ subfolderEl.addEventListener('focus', () => {
+ populateSubfolderOptions(subfolderEl.value);
+ });
+
+ subfolderEl.addEventListener('input', () => {
+ populateSubfolderOptions(subfolderEl.value);
+ });
+
+ subfolderEl.addEventListener('blur', () => hideList(listEl));
+ this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
+ }
+
+ getStoredTokens() {
+ const civitaiCandidateLimitRaw = parseInt(localStorage.getItem('modelLinker.civitaiCandidateLimit') || '5', 10);
+ const civitai_candidate_limit = Number.isFinite(civitaiCandidateLimitRaw)
+ ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
+ : 5;
+
+ return {
+ civitai_key: localStorage.getItem('modelLinker.civitaiApiKey') || '',
+ civitai_session_token: localStorage.getItem('modelLinker.civitaiSessionToken') || '',
+ hf_token: localStorage.getItem('modelLinker.huggingFaceToken') || '',
+ brave_search_api_key: localStorage.getItem('modelLinker.braveSearchApiKey') || '',
+ civitai_use_trpc_search: localStorage.getItem('modelLinker.civitaiUseTrpcSearch') !== 'false',
+ civitai_use_html_fallback: localStorage.getItem('modelLinker.civitaiUseHtmlFallback') !== 'false',
+ hf_use_api_search: localStorage.getItem('modelLinker.hfUseApiSearch') !== 'false',
+ hf_use_comfy_org_fallback: localStorage.getItem('modelLinker.hfUseComfyOrgFallback') !== 'false',
+ hf_use_brave_fallback: localStorage.getItem('modelLinker.hfUseBraveFallback') !== 'false',
+ civitai_candidate_limit
+ };
+ }
+
+ async clearSearchCaches() {
+ this.searchResultCache.clear();
+ try {
+ const response = await api.fetchApi('/model_linker/clear-search-cache', {
+ method: 'POST'
+ });
+ if (!response.ok) {
+ throw new Error('Failed to clear backend search cache');
+ }
+ } catch (error) {
+ console.error('Model Linker: Clear search cache error:', error);
+ }
+ }
+
+ displayOptions() {
+ if (!this.contentElement) return;
+ this.contentElement.style.overflowY = 'hidden';
+
+ const tokens = this.getStoredTokens();
+ this.contentElement.innerHTML = `
+
+
+
+
+
+
+
CivitAI
+
+
+
+
+
-
+
+
CivitAI Session Token ?
${getSvgIcon('eye')}
-
-
-
-
-
-
-
-
- Use CivitAI tRPC search ?
-
-
-
-
-
-
-
-
- Use CivitAI HTML fallback ?
-
-
-
-
-
-
-
-
- CivitAI Models To Inspect ?
-
-
-
-
-
-
-
-
-
-
HuggingFace
-
-
-
+
+
+
+
+ Use CivitAI tRPC search ?
+
+
+
+
+
+
+
+
+ Use CivitAI HTML fallback ?
+
+
+
+
+
+
+
+
+ CivitAI Models To Inspect ?
+
+
+
+
+
+
+
+
-
-
-
- `;
-
- const civitaiInput = this.contentElement.querySelector('#ml-options-civitai');
- const civitaiSessionInput = this.contentElement.querySelector('#ml-options-civitai-session');
- const hfInput = this.contentElement.querySelector('#ml-options-hf');
- const braveInput = this.contentElement.querySelector('#ml-options-brave');
- const civitaiToggle = this.contentElement.querySelector('#ml-options-civitai-toggle');
- const civitaiSessionToggle = this.contentElement.querySelector('#ml-options-civitai-session-toggle');
- const hfToggle = this.contentElement.querySelector('#ml-options-hf-toggle');
- const braveToggle = this.contentElement.querySelector('#ml-options-brave-toggle');
- const civitaiLimitInput = this.contentElement.querySelector('#ml-options-civitai-limit');
- const civitaiUseTrpcSearchInput = this.contentElement.querySelector('#ml-options-civitai-use-trpc-search');
- const civitaiUseHtmlFallbackInput = this.contentElement.querySelector('#ml-options-civitai-use-html-fallback');
- const hfUseApiSearchInput = this.contentElement.querySelector('#ml-options-hf-use-api-search');
- const hfUseComfyOrgFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-comfy-org-fallback');
- const hfUseBraveFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-brave-fallback');
- const status = this.contentElement.querySelector('#ml-options-status');
- const saveBtn = this.contentElement.querySelector('#ml-options-save');
- const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn'));
- const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section'));
- const trackedInputs = [
- civitaiInput,
- civitaiSessionInput,
- hfInput,
- braveInput,
- civitaiLimitInput,
- civitaiUseTrpcSearchInput,
- civitaiUseHtmlFallbackInput,
- hfUseApiSearchInput,
- hfUseComfyOrgFallbackInput,
- hfUseBraveFallbackInput,
- ].filter(Boolean);
-
- const setStatus = (text, mode = '') => {
- if (!status) return;
- status.textContent = text;
- status.classList.remove('is-dirty', 'is-saved');
- if (mode) status.classList.add(mode);
- };
-
- const setActiveNav = (targetId) => {
- navButtons.forEach((btn) => {
- btn.classList.toggle('is-active', btn.dataset.target === targetId);
- });
- };
-
- const setVisibleSection = (targetId) => {
- optionSections.forEach((section) => {
- section.classList.toggle('is-hidden', section.id !== targetId);
- });
- setActiveNav(targetId);
- };
-
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ const civitaiInput = this.contentElement.querySelector('#ml-options-civitai');
+ const civitaiSessionInput = this.contentElement.querySelector('#ml-options-civitai-session');
+ const hfInput = this.contentElement.querySelector('#ml-options-hf');
+ const braveInput = this.contentElement.querySelector('#ml-options-brave');
+ const civitaiToggle = this.contentElement.querySelector('#ml-options-civitai-toggle');
+ const civitaiSessionToggle = this.contentElement.querySelector('#ml-options-civitai-session-toggle');
+ const hfToggle = this.contentElement.querySelector('#ml-options-hf-toggle');
+ const braveToggle = this.contentElement.querySelector('#ml-options-brave-toggle');
+ const civitaiLimitInput = this.contentElement.querySelector('#ml-options-civitai-limit');
+ const civitaiUseTrpcSearchInput = this.contentElement.querySelector('#ml-options-civitai-use-trpc-search');
+ const civitaiUseHtmlFallbackInput = this.contentElement.querySelector('#ml-options-civitai-use-html-fallback');
+ const hfUseApiSearchInput = this.contentElement.querySelector('#ml-options-hf-use-api-search');
+ const hfUseComfyOrgFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-comfy-org-fallback');
+ const hfUseBraveFallbackInput = this.contentElement.querySelector('#ml-options-hf-use-brave-fallback');
+ const status = this.contentElement.querySelector('#ml-options-status');
+ const saveBtn = this.contentElement.querySelector('#ml-options-save');
+ const navButtons = Array.from(this.contentElement.querySelectorAll('.ml-options-nav-btn'));
+ const optionSections = Array.from(this.contentElement.querySelectorAll('.ml-options-section'));
+ const trackedInputs = [
+ civitaiInput,
+ civitaiSessionInput,
+ hfInput,
+ braveInput,
+ civitaiLimitInput,
+ civitaiUseTrpcSearchInput,
+ civitaiUseHtmlFallbackInput,
+ hfUseApiSearchInput,
+ hfUseComfyOrgFallbackInput,
+ hfUseBraveFallbackInput,
+ ].filter(Boolean);
+
+ const setStatus = (text, mode = '') => {
+ if (!status) return;
+ status.textContent = text;
+ status.classList.remove('is-dirty', 'is-saved');
+ if (mode) status.classList.add(mode);
+ };
+
+ const setActiveNav = (targetId) => {
+ navButtons.forEach((btn) => {
+ btn.classList.toggle('is-active', btn.dataset.target === targetId);
+ });
+ };
+
+ const setVisibleSection = (targetId) => {
+ optionSections.forEach((section) => {
+ section.classList.toggle('is-hidden', section.id !== targetId);
+ });
+ setActiveNav(targetId);
+ };
+
const getVisibilityIcon = (visible) => visible
? getSvgIcon('eye')
: getSvgIcon('eyeOff');
-
- const syncVisibilityToggle = (input, button) => {
- if (!input || !button) return;
- const visible = input.type === 'text';
- button.innerHTML = getVisibilityIcon(visible);
- button.style.color = visible ? 'var(--ml-text)' : 'var(--ml-text-muted)';
- button.setAttribute('aria-pressed', visible ? 'true' : 'false');
- button.setAttribute('title', visible ? 'Hide value' : 'Show value');
- };
-
- const bindVisibilityToggle = (input, button) => {
- if (!input || !button) return;
- syncVisibilityToggle(input, button);
- button.addEventListener('click', () => {
- input.type = input.type === 'password' ? 'text' : 'password';
- syncVisibilityToggle(input, button);
- });
- };
-
- bindVisibilityToggle(civitaiInput, civitaiToggle);
- bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle);
- bindVisibilityToggle(hfInput, hfToggle);
- bindVisibilityToggle(braveInput, braveToggle);
- setStatus('Saved only on this machine.');
- setVisibleSection('ml-options-section-civitai');
-
- trackedInputs.forEach((input) => {
- const eventName = input.type === 'checkbox' ? 'change' : 'input';
- input.addEventListener(eventName, () => {
- setStatus('You have unsaved changes.', 'is-dirty');
- });
- });
-
- this.bindTooltips(this.contentElement);
-
- navButtons.forEach((btn) => {
- btn.addEventListener('click', () => {
- const targetId = btn.dataset.target;
- if (!targetId) return;
- setVisibleSection(targetId);
- });
- });
-
- if (saveBtn) {
- saveBtn.addEventListener('click', async () => {
- const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10);
- const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw)
- ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
- : 5;
- localStorage.setItem('modelLinker.civitaiApiKey', civitaiInput?.value || '');
- localStorage.setItem('modelLinker.civitaiSessionToken', civitaiSessionInput?.value || '');
- localStorage.setItem('modelLinker.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false');
- localStorage.setItem('modelLinker.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false');
- localStorage.setItem('modelLinker.huggingFaceToken', hfInput?.value || '');
- localStorage.setItem('modelLinker.braveSearchApiKey', braveInput?.value || '');
- localStorage.setItem('modelLinker.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false');
- localStorage.setItem('modelLinker.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false');
- localStorage.setItem('modelLinker.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false');
- localStorage.setItem('modelLinker.civitaiCandidateLimit', `${civitaiCandidateLimit}`);
- if (civitaiLimitInput) {
- civitaiLimitInput.value = `${civitaiCandidateLimit}`;
- }
- await this.clearSearchCaches();
- setStatus('Options saved locally.', 'is-saved');
- this.showNotification('Options saved and search cache cleared', 'success');
- });
- }
-
- }
-
- switchTab(tab) {
- this.activeTab = this.getValidTab(tab);
- this.persistActiveTab(this.activeTab);
- this.hideTooltip();
- this.animateTabContentTransition();
-
- if (this.activeTab === 'missing') {
- if (this.contentElement) {
- this.contentElement.style.overflowY = 'auto';
- }
- this.missingTab.classList.add('ml-tab-active');
- this.loadedTab.classList.remove('ml-tab-active');
- this.optionsTab.classList.remove('ml-tab-active');
- this.downloadAllButton.style.display = 'inline-flex';
- this.autoResolveButton.style.display = 'inline-flex';
- this.applyPendingBtn.style.display = 'inline-flex';
- // Show queue panel
- if (this.queueElement && !this.queueCollapsed) {
- this.queueElement.style.display = '';
- }
- if (this.splitterElement) {
- this.splitterElement.style.display = '';
- }
- this.loadWorkflowData();
- } else if (this.activeTab === 'loaded') {
- if (this.contentElement) {
- this.contentElement.style.overflowY = 'auto';
- }
- this.missingTab.classList.remove('ml-tab-active');
- this.loadedTab.classList.add('ml-tab-active');
- this.optionsTab.classList.remove('ml-tab-active');
- this.downloadAllButton.style.display = 'none';
- this.autoResolveButton.style.display = 'none';
- this.applyPendingBtn.style.display = 'none';
- // Hide queue panel in loaded models tab
- if (this.queueElement) {
- this.queueElement.style.display = 'none';
- }
- if (this.splitterElement) {
- this.splitterElement.style.display = 'none';
- }
- this.loadLoadedModels();
- } else {
- if (this.contentElement) {
- this.contentElement.style.overflowY = 'hidden';
- }
- this.missingTab.classList.remove('ml-tab-active');
- this.loadedTab.classList.remove('ml-tab-active');
- this.optionsTab.classList.add('ml-tab-active');
- this.downloadAllButton.style.display = 'none';
- this.autoResolveButton.style.display = 'none';
- this.applyPendingBtn.style.display = 'none';
- if (this.queueElement) {
- this.queueElement.style.display = 'none';
- }
- if (this.splitterElement) {
- this.splitterElement.style.display = 'none';
- }
- this.displayOptions();
- }
- }
-
- async loadLoadedModels() {
- if (!this.contentElement) return;
-
- this.contentElement.innerHTML = '
Loading loaded models...
';
-
- try {
- const workflow = this.getCurrentWorkflow();
- if (!workflow) {
- this.contentElement.innerHTML = '
No workflow loaded. Please load a workflow first.
';
- return;
- }
-
- const response = await api.fetchApi('/model_linker/loaded', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workflow })
- });
-
- if (!response.ok) {
- throw new Error(`API error: ${response.status}`);
- }
-
- const data = await response.json();
- this.displayLoadedModels(this.contentElement, data);
-
- } catch (error) {
- console.error('Model Linker: Error loading loaded models:', error);
- if (this.contentElement) {
+
+ const syncVisibilityToggle = (input, button) => {
+ if (!input || !button) return;
+ const visible = input.type === 'text';
+ button.innerHTML = getVisibilityIcon(visible);
+ button.style.color = visible ? 'var(--ml-text)' : 'var(--ml-text-muted)';
+ button.setAttribute('aria-pressed', visible ? 'true' : 'false');
+ button.setAttribute('title', visible ? 'Hide value' : 'Show value');
+ };
+
+ const bindVisibilityToggle = (input, button) => {
+ if (!input || !button) return;
+ syncVisibilityToggle(input, button);
+ button.addEventListener('click', () => {
+ input.type = input.type === 'password' ? 'text' : 'password';
+ syncVisibilityToggle(input, button);
+ });
+ };
+
+ bindVisibilityToggle(civitaiInput, civitaiToggle);
+ bindVisibilityToggle(civitaiSessionInput, civitaiSessionToggle);
+ bindVisibilityToggle(hfInput, hfToggle);
+ bindVisibilityToggle(braveInput, braveToggle);
+ setStatus('Saved only on this machine.');
+ setVisibleSection('ml-options-section-civitai');
+
+ trackedInputs.forEach((input) => {
+ const eventName = input.type === 'checkbox' ? 'change' : 'input';
+ input.addEventListener(eventName, () => {
+ setStatus('You have unsaved changes.', 'is-dirty');
+ });
+ });
+
+ this.bindTooltips(this.contentElement);
+
+ navButtons.forEach((btn) => {
+ btn.addEventListener('click', () => {
+ const targetId = btn.dataset.target;
+ if (!targetId) return;
+ setVisibleSection(targetId);
+ });
+ });
+
+ if (saveBtn) {
+ saveBtn.addEventListener('click', async () => {
+ const civitaiCandidateLimitRaw = parseInt(civitaiLimitInput?.value || `${tokens.civitai_candidate_limit}`, 10);
+ const civitaiCandidateLimit = Number.isFinite(civitaiCandidateLimitRaw)
+ ? Math.min(20, Math.max(1, civitaiCandidateLimitRaw))
+ : 5;
+ localStorage.setItem('modelLinker.civitaiApiKey', civitaiInput?.value || '');
+ localStorage.setItem('modelLinker.civitaiSessionToken', civitaiSessionInput?.value || '');
+ localStorage.setItem('modelLinker.civitaiUseTrpcSearch', civitaiUseTrpcSearchInput?.checked ? 'true' : 'false');
+ localStorage.setItem('modelLinker.civitaiUseHtmlFallback', civitaiUseHtmlFallbackInput?.checked ? 'true' : 'false');
+ localStorage.setItem('modelLinker.huggingFaceToken', hfInput?.value || '');
+ localStorage.setItem('modelLinker.braveSearchApiKey', braveInput?.value || '');
+ localStorage.setItem('modelLinker.hfUseApiSearch', hfUseApiSearchInput?.checked ? 'true' : 'false');
+ localStorage.setItem('modelLinker.hfUseComfyOrgFallback', hfUseComfyOrgFallbackInput?.checked ? 'true' : 'false');
+ localStorage.setItem('modelLinker.hfUseBraveFallback', hfUseBraveFallbackInput?.checked ? 'true' : 'false');
+ localStorage.setItem('modelLinker.civitaiCandidateLimit', `${civitaiCandidateLimit}`);
+ if (civitaiLimitInput) {
+ civitaiLimitInput.value = `${civitaiCandidateLimit}`;
+ }
+ await this.clearSearchCaches();
+ setStatus('Options saved locally.', 'is-saved');
+ this.showNotification('Options saved and search cache cleared', 'success');
+ });
+ }
+
+ }
+
+ switchTab(tab) {
+ this.activeTab = this.getValidTab(tab);
+ this.persistActiveTab(this.activeTab);
+ this.hideTooltip();
+ this.animateTabContentTransition();
+
+ if (this.activeTab === 'missing') {
+ if (this.contentElement) {
+ this.contentElement.style.overflowY = 'auto';
+ }
+ this.missingTab.classList.add('ml-tab-active');
+ this.loadedTab.classList.remove('ml-tab-active');
+ this.optionsTab.classList.remove('ml-tab-active');
+ this.downloadAllButton.style.display = 'inline-flex';
+ this.autoResolveButton.style.display = 'inline-flex';
+ this.applyPendingBtn.style.display = 'inline-flex';
+ // Show queue panel
+ if (this.queueElement && !this.queueCollapsed) {
+ this.queueElement.style.display = '';
+ }
+ if (this.splitterElement) {
+ this.splitterElement.style.display = '';
+ }
+ this.loadWorkflowData();
+ } else if (this.activeTab === 'loaded') {
+ if (this.contentElement) {
+ this.contentElement.style.overflowY = 'auto';
+ }
+ this.missingTab.classList.remove('ml-tab-active');
+ this.loadedTab.classList.add('ml-tab-active');
+ this.optionsTab.classList.remove('ml-tab-active');
+ this.downloadAllButton.style.display = 'none';
+ this.autoResolveButton.style.display = 'none';
+ this.applyPendingBtn.style.display = 'none';
+ // Hide queue panel in loaded models tab
+ if (this.queueElement) {
+ this.queueElement.style.display = 'none';
+ }
+ if (this.splitterElement) {
+ this.splitterElement.style.display = 'none';
+ }
+ this.loadLoadedModels();
+ } else {
+ if (this.contentElement) {
+ this.contentElement.style.overflowY = 'hidden';
+ }
+ this.missingTab.classList.remove('ml-tab-active');
+ this.loadedTab.classList.remove('ml-tab-active');
+ this.optionsTab.classList.add('ml-tab-active');
+ this.downloadAllButton.style.display = 'none';
+ this.autoResolveButton.style.display = 'none';
+ this.applyPendingBtn.style.display = 'none';
+ if (this.queueElement) {
+ this.queueElement.style.display = 'none';
+ }
+ if (this.splitterElement) {
+ this.splitterElement.style.display = 'none';
+ }
+ this.displayOptions();
+ }
+ }
+
+ async loadLoadedModels() {
+ if (!this.contentElement) return;
+
+ this.contentElement.innerHTML = '
Loading loaded models...
';
+
+ try {
+ const workflow = this.getCurrentWorkflow();
+ if (!workflow) {
+ this.contentElement.innerHTML = '
No workflow loaded. Please load a workflow first.
';
+ return;
+ }
+
+ const response = await api.fetchApi('/model_linker/loaded', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workflow })
+ });
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ this.displayLoadedModels(this.contentElement, data);
+
+ } catch (error) {
+ console.error('Model Linker: Error loading loaded models:', error);
+ if (this.contentElement) {
this.contentElement.innerHTML = `
Error: ${error.message}
`;
- }
- }
- }
-
- displayLoadedModels(container, data) {
- const loadedModels = data.loaded_models || [];
- const total = data.total || 0;
-
- if (total === 0) {
- container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info');
- return;
- }
-
+ }
+ }
+ }
+
+ displayLoadedModels(container, data) {
+ const loadedModels = data.loaded_models || [];
+ const total = data.total || 0;
+
+ if (total === 0) {
+ container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info');
+ return;
+ }
+
const byCategory = {};
-
- for (const model of loadedModels) {
- const cat = model.category || 'unknown';
- if (!byCategory[cat]) {
- byCategory[cat] = { active: [], inactive: [] };
- }
-
- // Determine if model is active or inactive
- // For LoraLoaderV2/LoraManager: check model.active field
- // For other nodes: check model.connected field (false means not connected or bypassed)
- let isActive = true;
- if (model.is_lora_v2) {
- // For text-based lora loaders, check both active flag AND connected status
- isActive = model.active !== false && model.connected !== false;
- } else {
- // For regular nodes, check connected status
- isActive = model.connected !== false;
- }
-
- if (isActive) {
- byCategory[cat].active.push(model);
- } else {
- byCategory[cat].inactive.push(model);
- }
- }
-
- const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0);
- const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0);
-
- const buildCategoryStrings = (filter) => {
+
+ for (const model of loadedModels) {
+ const cat = model.category || 'unknown';
+ if (!byCategory[cat]) {
+ byCategory[cat] = { active: [], inactive: [] };
+ }
+
+ // Determine if model is active or inactive
+ // For LoraLoaderV2/LoraManager: check model.active field
+ // For other nodes: check model.connected field (false means not connected or bypassed)
+ let isActive = true;
+ if (model.is_lora_v2) {
+ // For text-based lora loaders, check both active flag AND connected status
+ isActive = model.active !== false && model.connected !== false;
+ } else {
+ // For regular nodes, check connected status
+ isActive = model.connected !== false;
+ }
+
+ if (isActive) {
+ byCategory[cat].active.push(model);
+ } else {
+ byCategory[cat].inactive.push(model);
+ }
+ }
+
+ const activeCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.active.length, 0);
+ const inactiveCount = Object.values(byCategory).reduce((sum, cat) => sum + cat.inactive.length, 0);
+
+ const buildCategoryStrings = (filter) => {
const result = {};
for (const [category, modelsObj] of Object.entries(byCategory)) {
const displayCat = this.getCategoryTokenName(category);
- const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive];
- const parts = models.map(model => {
- const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
- let name = fullName;
- if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) {
- name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
- }
- const strength = model.strength !== null && model.strength !== undefined
- ? model.strength.toFixed(2)
- : '1.00';
- return `<${displayCat}:${name}:${strength}>`;
- });
- result[category] = parts.join(' ');
- }
- return Object.values(result).join(' ');
- };
-
- const activeString = buildCategoryStrings('active');
- const inactiveString = buildCategoryStrings('inactive');
- const allString = buildCategoryStrings('all');
-
- const self = this;
-
- let html = `
-
+ const models = filter === 'active' ? modelsObj.active : filter === 'inactive' ? modelsObj.inactive : [...modelsObj.active, ...modelsObj.inactive];
+ const parts = models.map(model => {
+ const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
+ let name = fullName;
+ if (fullName.match(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i)) {
+ name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
+ }
+ const strength = model.strength !== null && model.strength !== undefined
+ ? model.strength.toFixed(2)
+ : '1.00';
+ return `<${displayCat}:${name}:${strength}>`;
+ });
+ result[category] = parts.join(' ');
+ }
+ return Object.values(result).join(' ');
+ };
+
+ const activeString = buildCategoryStrings('active');
+ const inactiveString = buildCategoryStrings('inactive');
+ const allString = buildCategoryStrings('all');
+
+ const self = this;
+
+ let html = `
+
All (${activeCount + inactiveCount})
@@ -2584,75 +2585,75 @@ class LinkerManagerDialog extends ComfyDialog {
`;
-
+
for (const [category, modelsObj] of Object.entries(byCategory)) {
const displayName = this.getCategoryDisplayName(category);
- const hasActive = modelsObj.active.length > 0;
- const hasInactive = modelsObj.inactive.length > 0;
-
+ const hasActive = modelsObj.active.length > 0;
+ const hasInactive = modelsObj.inactive.length > 0;
+
html += `
`;
// Add category header
html += ``;
-
- if (hasActive) {
+
+ if (hasActive) {
const activeStr = modelsObj.active.map(m => {
const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown';
let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
}).join(' ');
-
+
html += `
● ACTIVE
Copy
`;
-
- for (const model of modelsObj.active) {
- const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
- const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
- const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
- const modelData = encodeURIComponent(JSON.stringify(model));
- html += `${name}${strength !== null ? `${strength} ` : ''} `;
- }
- html += `
`;
- }
-
- if (hasInactive) {
- const inactiveStr = modelsObj.inactive.map(m => {
+
+ for (const model of modelsObj.active) {
+ const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
+ const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
+ const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
+ const modelData = encodeURIComponent(JSON.stringify(model));
+ html += `
${name}${strength !== null ? `${strength} ` : ''} `;
+ }
+ html += `
`;
+ }
+
+ if (hasInactive) {
+ const inactiveStr = modelsObj.inactive.map(m => {
const fullName = m.name || m.original_path?.split(/[\/\\]/).pop() || 'Unknown';
let name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const strength = m.strength !== null && m.strength !== undefined ? m.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
}).join(' ');
-
- html += `
+
+ html += `
○ INACTIVE
Copy
`;
-
- for (const model of modelsObj.inactive) {
- const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
- const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
- const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
- const modelData = encodeURIComponent(JSON.stringify(model));
- html += `${name}${strength !== null ? `${strength} ` : ''} `;
- }
- html += `
`;
- }
-
- html += `
`;
- }
-
- const copySectionId = 'ml-copy-' + Date.now();
- html += `
-
+
+ for (const model of modelsObj.inactive) {
+ const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
+ const name = fullName.replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
+ const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : null;
+ const modelData = encodeURIComponent(JSON.stringify(model));
+ html += `
${name}${strength !== null ? `${strength} ` : ''} `;
+ }
+ html += `
`;
+ }
+
+ html += ``;
+ }
+
+ const copySectionId = 'ml-copy-' + Date.now();
+ html += `
+
No selections queued.
';
return;
}
@@ -2790,690 +2791,690 @@ class LinkerManagerDialog extends ComfyDialog {
html += `No workflow loaded. Please load a workflow first.
';
- return;
- }
-
- const workflowSignature = this.getWorkflowSignature(workflow);
- if (
- workflowSignature &&
- this.cachedWorkflowSignature === workflowSignature &&
- this.cachedAnalysisData
- ) {
- this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
- this.reconnectActiveDownloads();
- return;
- }
-
- const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
- this._analysisProgressToken = analysisId;
- this.contentElement.innerHTML = this.renderAnalysisProgress({
- status: 'starting',
- message: 'Starting analysis...',
- current: 0,
- total: 0
- });
-
- // Call analyze endpoint
- const progressPromise = this.pollAnalysisProgress(analysisId, analysisId);
- const response = await api.fetchApi('/model_linker/analyze', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ workflow, analysis_id: analysisId })
- });
- this._analysisProgressToken = null;
- await progressPromise;
-
- if (!response.ok) {
- throw new Error(`API error: ${response.status}`);
- }
-
- const data = await response.json();
- this.cachedWorkflowSignature = workflowSignature;
- this.cachedAnalysisData = data;
- this.searchResultCache.clear();
- this.displayMissingModels(this.contentElement, data);
-
- // Reconnect any active downloads to their new progress divs
- this.reconnectActiveDownloads();
-
- } catch (error) {
- this._analysisProgressToken = null;
- console.error('Model Linker: Error loading workflow data:', error);
- if (this.contentElement) {
+ ]);
+ }
+
+ animateTabContentTransition() {
+ if (!this.contentElement?.animate) return;
+
+ try {
+ this.contentElement.animate(
+ [
+ { opacity: 0.72, transform: 'translateY(7px)' },
+ { opacity: 1, transform: 'translateY(0)' }
+ ],
+ {
+ duration: 180,
+ easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)'
+ }
+ );
+ } catch (error) {
+ console.warn('Model Linker: tab content animation failed', error);
+ }
+ }
+
+ /**
+ * Handle click on Download All / Cancel All button
+ */
+ handleDownloadAllClick() {
+ if (Object.keys(this.activeDownloads).length > 0) {
+ // Cancel all active downloads
+ this.cancelAllDownloads();
+ } else {
+ // Start downloading all missing
+ this.downloadAllMissing();
+ }
+ }
+
+ /**
+ * Cancel all active downloads
+ */
+ async cancelAllDownloads() {
+ const downloadIds = Object.keys(this.activeDownloads);
+ if (downloadIds.length === 0) return;
+
+ this.showNotification(`Cancelling ${downloadIds.length} download${downloadIds.length > 1 ? 's' : ''}...`, 'info');
+
+ for (const downloadId of downloadIds) {
+ try {
+ await api.fetchApi(`/model_linker/cancel/${downloadId}`, {
+ method: 'POST'
+ });
+ } catch (error) {
+ console.error('Model Linker: Error cancelling download:', error);
+ }
+ }
+ }
+
+ /**
+ * Update the Download All button state based on active downloads
+ */
+ updateDownloadAllButtonState() {
+ if (!this.downloadAllButton) return;
+
+ const activeCount = Object.keys(this.activeDownloads).length;
+ if (activeCount > 0) {
+ this.downloadAllButton.innerHTML = `No workflow loaded. Please load a workflow first.
';
+ return;
+ }
+
+ const workflowSignature = this.getWorkflowSignature(workflow);
+ if (
+ workflowSignature &&
+ this.cachedWorkflowSignature === workflowSignature &&
+ this.cachedAnalysisData
+ ) {
+ this.displayMissingModels(this.contentElement, this.cachedAnalysisData);
+ this.reconnectActiveDownloads();
+ return;
+ }
+
+ const analysisId = `an-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+ this._analysisProgressToken = analysisId;
+ this.contentElement.innerHTML = this.renderAnalysisProgress({
+ status: 'starting',
+ message: 'Starting analysis...',
+ current: 0,
+ total: 0
+ });
+
+ // Call analyze endpoint
+ const progressPromise = this.pollAnalysisProgress(analysisId, analysisId);
+ const response = await api.fetchApi('/model_linker/analyze', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workflow, analysis_id: analysisId })
+ });
+ this._analysisProgressToken = null;
+ await progressPromise;
+
+ if (!response.ok) {
+ throw new Error(`API error: ${response.status}`);
+ }
+
+ const data = await response.json();
+ this.cachedWorkflowSignature = workflowSignature;
+ this.cachedAnalysisData = data;
+ this.searchResultCache.clear();
+ this.displayMissingModels(this.contentElement, data);
+
+ // Reconnect any active downloads to their new progress divs
+ this.reconnectActiveDownloads();
+
+ } catch (error) {
+ this._analysisProgressToken = null;
+ console.error('Model Linker: Error loading workflow data:', error);
+ if (this.contentElement) {
this.contentElement.innerHTML = `';
-
- // Skip rendering if active tab is not "missing"
- if (this.activeTab !== 'missing') {
- container.innerHTML = '';
- return;
- }
-
- // Sort missing models: those with 100% confidence matches first, then others
- const sortedMissingModels = missingModels.sort((a, b) => {
- const aMatches = a.matches || [];
- const bMatches = b.matches || [];
-
- // Filter to 70%+ confidence
- const aFiltered = aMatches.filter(m => m.confidence >= 70);
- const bFiltered = bMatches.filter(m => m.confidence >= 70);
-
- // Check if they have 100% matches
- const aHas100 = aFiltered.some(m => m.confidence === 100);
- const bHas100 = bFiltered.some(m => m.confidence === 100);
-
- // If one has 100% and the other doesn't, prioritize the one with 100%
- if (aHas100 && !bHas100) return -1;
- if (!aHas100 && bHas100) return 1;
-
- // If both have 100% or neither has 100%, sort by best confidence
- const aBestConf = aFiltered.length > 0 ? Math.max(...aFiltered.map(m => m.confidence)) : 0;
- const bBestConf = bFiltered.length > 0 ? Math.max(...bFiltered.map(m => m.confidence)) : 0;
-
- return bBestConf - aBestConf; // Higher confidence first
- });
-
- for (let mi = 0; mi < sortedMissingModels.length; mi++) {
- sortedMissingModels[mi].__displayIndex = mi;
- html += this.renderMissingModel(sortedMissingModels[mi], mi);
- }
-
- html += '
';
- container.innerHTML = html;
-
- // 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) => {
- this.wireLocalMatchButtons(container, missing, missingIndex);
-
- // Attach download button listener
- const downloadBtnId = `download-${missing.node_id}-${missing.widget_index}`;
- const downloadBtn = container.querySelector(`#${downloadBtnId}`);
- if (downloadBtn && missing.download_source) {
- downloadBtn.addEventListener('click', () => {
- this.downloadModel(missing);
- });
- }
-
- // Attach search button listener
- const searchBtnId = `search-${missing.node_id}-${missing.widget_index}`;
- const searchBtn = container.querySelector(`#${searchBtnId}`);
- if (searchBtn) {
- searchBtn.addEventListener('click', () => {
- this.searchOnline(missing);
- });
- }
-
- const sourceSelect = container.querySelector(`#search-source-select-${missing.node_id}-${missing.widget_index}`);
- if (sourceSelect) {
- sourceSelect.addEventListener('change', () => {
- this.setSearchSource(missing, sourceSelect.value, container);
- });
- this.syncSearchSourceUi(missing, container);
- }
-
- this.wireDownloadTargetAutocomplete(container, missing);
-
- // Wire locate chip (only for top-level nodes)
- const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
- const locateBtn = container.querySelector(`#${locateId}`);
- if (locateBtn && missing.is_top_level !== false) {
- locateBtn.addEventListener('click', () => {
- this.locateNodeInGraph(missing.node_id);
- });
- }
-
- // Wire up all-models search + dropdown (combo-style)
- const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
- const comboInput = container.querySelector(`#combo-input-${comboId}`);
- const comboList = container.querySelector(`#combo-list-${comboId}`);
- const comboRefresh = container.querySelector(`#combo-refresh-${comboId}`);
-
- if (comboList) {
- this.enableWheelScrollChaining(comboList);
- }
-
- const allModels = Array.isArray(this.allModels) ? this.allModels : [];
- const buildLabel = (m) => `${m.category ? m.category + ': ' : ''}${m.relative_path || m.filename || ''}`;
- const getFolder = (m) => m.path || m.base_directory || '';
-
- // Populate dropdown with filtered models
- const populateComboOptions = (filterText, highlightIdx = -1) => {
- if (!comboList) return;
- const f = (filterText || '').toLowerCase();
- const filtered = f
- ? allModels.filter(m => buildLabel(m).toLowerCase().includes(f))
- : allModels.slice(); // Copy to avoid mutation
-
- let html = '';
- for (let i = 0; i < filtered.length; i++) {
- const m = filtered[i];
- const label = buildLabel(m);
- const folder = getFolder(m);
- const isHighlighted = i === highlightIdx;
- const folderDisplay = folder ? folder.replace(/\\/g, '/').replace(/:/, '') : '';
- html += ``;
-
- // Card Header: Filename as headline + node chip
- html += ``;
+ html += `
`;
+
+ // Selected bar - shows if this slot has a queued selection (BELOW card header)
+ const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`;
html += `Unable to resolve direct download for this URN.
`;
- }
- } else {
- const loadingEl = document.getElementById(loadingElementId);
- if (loadingEl) {
- loadingEl.textContent = 'Error';
- loadingEl.style.color = '#f44336';
- }
- const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
- const downloadEl = document.getElementById(downloadContainerId);
- if (downloadEl) {
- downloadEl.innerHTML = `Failed to resolve URN download.
`;
- }
- }
- } catch (error) {
- console.error('Model Linker: URN resolve error:', error);
- const loadingEl = document.getElementById(loadingElementId);
- if (loadingEl) {
- loadingEl.textContent = 'Error';
- loadingEl.style.color = '#f44336';
- }
- const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
- const downloadEl = document.getElementById(downloadContainerId);
- if (downloadEl) {
- downloadEl.innerHTML = `Failed to resolve URN download.
`;
- }
- }
- }
-
- /**
- * Display search results
- */
- displaySearchResults(missing, state, container) {
- if (!container) return;
-
- const results = state?.results || {};
- const popular = results.popular;
- const modelListResult = results.model_list;
- const hfResult = results.huggingface ? (Array.isArray(results.huggingface) ? results.huggingface[0] : results.huggingface) : null;
- const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null;
- const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null;
- const hasResults = popular || modelListResult || hfResult || civitaiResult || loraManagerArchiveResult;
-
- if (!hasResults) {
- const searchedLabel = (state?.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', ');
- container.innerHTML = this.renderStatusMessage(
- searchedLabel ? `No matches found in ${searchedLabel}.` : 'No matches found online for this model.',
- 'warning'
- );
- return;
- }
-
+
+ // For URNs, include model_id and version_id for direct download
+ const tokens = this.getStoredTokens();
+ const searchData = {
+ filename,
+ category,
+ is_urn: isUrn,
+ sources: [selectedSource],
+ civitai_session_token: tokens.civitai_session_token,
+ civitai_candidate_limit: tokens.civitai_candidate_limit,
+ civitai_use_trpc_search: tokens.civitai_use_trpc_search,
+ civitai_use_html_fallback: tokens.civitai_use_html_fallback,
+ hf_token: tokens.hf_token,
+ brave_search_api_key: tokens.brave_search_api_key,
+ hf_use_api_search: tokens.hf_use_api_search,
+ hf_use_comfy_org_fallback: tokens.hf_use_comfy_org_fallback,
+ hf_use_brave_fallback: tokens.hf_use_brave_fallback
+ };
+ if (isUrn && missing.urn) {
+ searchData.model_id = missing.urn.model_id;
+ searchData.version_id = missing.urn.version_id;
+ }
+
+ console.log('Model Linker: Search request:', JSON.stringify(searchData));
+
+ const response = await api.fetchApi('/model_linker/search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(searchData)
+ });
+
+ if (!response.ok) {
+ throw new Error(`Search failed: ${response.status}`);
+ }
+
+ const data = await response.json();
+ console.log('Model Linker: Search response:', JSON.stringify(data));
+ state.results = this.mergeSearchResults(state.results, data);
+ state.lastAttemptSources = Array.isArray(data.searched_sources) ? data.searched_sources : [selectedSource];
+ state.lastAttemptFound = this.hasSearchResults(data);
+ if (data.civitai) {
+ missing.civitai_search_result = {
+ base_model: data.civitai.base_model,
+ tags: data.civitai.tags || [],
+ trained_words: data.civitai.trained_words || [],
+ filename: data.civitai.filename,
+ name: data.civitai.name,
+ type: data.civitai.type
+ };
+ }
+ this.displaySearchResults(missing, state, resultsDiv);
+ this.applySearchResultSuggestion(missing);
+
+ } catch (error) {
+ console.error('Model Linker: Search error:', error);
+ if (resultsDiv) {
+ resultsDiv.innerHTML = this.renderStatusMessage(`Search failed: ${error.message}`, 'error');
+ }
+ } finally {
+ if (searchBtn) {
+ searchBtn.disabled = false;
+ searchBtn.innerHTML = `${this.getSearchIconHtml()} Search Again`;
+ }
+ }
+ }
+
+ /**
+ * Resolve URN asynchronously - fetch CivitAI info and update UI
+ */
+ async resolveUrnAsync(modelId, versionId, loadingElementId, modelUrl) {
+ console.log('resolveUrnAsync called:', modelId, versionId);
+ if (!modelId || !versionId) {
+ console.log('resolveUrnAsync: missing modelId or versionId');
+ return;
+ }
+
+ try {
+ const tokens = this.getStoredTokens();
+ const payload = {
+ filename: modelId + '_' + versionId,
+ category: '',
+ is_urn: true,
+ sources: ['civitai'],
+ model_id: modelId,
+ version_id: versionId,
+ civitai_candidate_limit: tokens.civitai_candidate_limit
+ };
+ console.log('resolveUrnAsync payload:', JSON.stringify(payload));
+
+ const response = await api.fetchApi('/model_linker/search', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload)
+ });
+
+ console.log('resolveUrnAsync response status:', response.status);
+
+ if (response.ok) {
+ const data = await response.json();
+ const loadingEl = document.getElementById(loadingElementId);
+ if (loadingEl && data.civitai) {
+ const civitai = data.civitai;
+ const label = civitai.name || civitai.filename || 'Model';
+ const url = modelUrl || `https://civitai.com/models/${modelId}?modelVersionId=${versionId}`;
+ loadingEl.innerHTML = `Unable to resolve direct download for this URN.
`;
+ }
+ } else {
+ const loadingEl = document.getElementById(loadingElementId);
+ if (loadingEl) {
+ loadingEl.textContent = 'Error';
+ loadingEl.style.color = '#f44336';
+ }
+ const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
+ const downloadEl = document.getElementById(downloadContainerId);
+ if (downloadEl) {
+ downloadEl.innerHTML = `Failed to resolve URN download.
`;
+ }
+ }
+ } catch (error) {
+ console.error('Model Linker: URN resolve error:', error);
+ const loadingEl = document.getElementById(loadingElementId);
+ if (loadingEl) {
+ loadingEl.textContent = 'Error';
+ loadingEl.style.color = '#f44336';
+ }
+ const downloadContainerId = loadingElementId.replace('urn-loading-', 'urn-download-');
+ const downloadEl = document.getElementById(downloadContainerId);
+ if (downloadEl) {
+ downloadEl.innerHTML = `Failed to resolve URN download.
`;
+ }
+ }
+ }
+
+ /**
+ * Display search results
+ */
+ displaySearchResults(missing, state, container) {
+ if (!container) return;
+
+ const results = state?.results || {};
+ const popular = results.popular;
+ const modelListResult = results.model_list;
+ const hfResult = results.huggingface ? (Array.isArray(results.huggingface) ? results.huggingface[0] : results.huggingface) : null;
+ const civitaiResult = results.civitai ? (Array.isArray(results.civitai) ? results.civitai[0] : results.civitai) : null;
+ const loraManagerArchiveResult = results.lora_manager_archive ? (Array.isArray(results.lora_manager_archive) ? results.lora_manager_archive[0] : results.lora_manager_archive) : null;
+ const hasResults = popular || modelListResult || hfResult || civitaiResult || loraManagerArchiveResult;
+
+ if (!hasResults) {
+ const searchedLabel = (state?.lastAttemptSources || []).map(source => this.getSearchSourceLabel(source)).join(', ');
+ container.innerHTML = this.renderStatusMessage(
+ searchedLabel ? `No matches found in ${searchedLabel}.` : 'No matches found online for this model.',
+ 'warning'
+ );
+ return;
+ }
+
let html = '