refactor: modularize web/linker.js into separate ES module files

This commit is contained in:
Dariusz L
2026-06-05 04:23:41 +02:00
parent d38e1cbb67
commit 51ebb17f89
18 changed files with 9635 additions and 9523 deletions
+4 -9523
View File
File diff suppressed because it is too large Load Diff
+625
View File
@@ -0,0 +1,625 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const dialogShellMethods = {
createHeader() {
// Create tabs
this.missingTab = $el("button.ml-tab.ml-tab-active", {
onclick: () => this.switchTab('missing')
}, [$el("span.ml-tab-label", { textContent: "Missing Models" })]);
this.loadedTab = $el("button.ml-tab", {
onclick: () => this.switchTab('loaded')
}, [$el("span.ml-tab-label", { textContent: "Loaded Models" })]);
this.optionsTab = $el("button.ml-tab", {
onclick: () => this.switchTab('options')
}, [$el("span.ml-tab-label", { textContent: "Options" })]);
if (this.activeTab === 'missing') {
this.updateTabButtonStates();
}
const dragHandle = $el("div", {
id: "model-linker-drag-handle",
ondragstart: (e) => e.preventDefault()
}, [
$el("span", { textContent: "⠿" })
]);
this.setTooltip(dragHandle, "Drag window");
const fullscreenButton = $el("button", {
id: "model-linker-fullscreen-toggle",
className: "ml-window-btn ml-window-btn--fullscreen",
innerHTML: getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Toggle full screen",
onclick: () => this.toggleFullScreen()
});
this.setTooltip(fullscreenButton, "Toggle full screen");
this.dockButton = $el("button", {
id: "model-linker-dock-toggle",
className: "ml-window-btn ml-window-btn--dock",
innerHTML: getSvgIcon('internalLink', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Dock Model Linker to sidebar",
onclick: () => this.dockToSidebar()
});
this.setTooltip(this.dockButton, "Dock to sidebar");
this.undockButton = $el("button", {
id: "model-linker-undock-toggle",
className: "ml-window-btn ml-window-btn--undock",
innerHTML: getSvgIcon('externalLink', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Undock Model Linker",
onclick: () => this.undockToFloating()
});
this.setTooltip(this.undockButton, "Undock to floating window");
return $el("div.ml-dialog-shell", {}, [
$el("div.ml-dialog-topbar", {}, [
$el("div.ml-dialog-brand", {}, [
dragHandle,
$el("div.ml-tabs", {}, [
this.missingTab,
this.loadedTab,
this.optionsTab
])
]),
$el("div.ml-dialog-controls", {}, [
this.dockButton,
this.undockButton,
fullscreenButton,
$el("button", {
className: "ml-window-btn ml-window-btn--close",
innerHTML: getSvgIcon('x', 'currentColor', 'ml-window-btn-icon'),
ariaLabel: "Close Model Linker",
onclick: () => this.close()
})
])
])
]);
},
captureFloatingRect() {
if (!this.element || this.docked || getComputedStyle(this.element).display === 'none') return;
const rect = this.element.getBoundingClientRect();
if (!rect.width || !rect.height) return;
this._floatingRectBeforeDock = {
top: Math.round(rect.top),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height)
};
},
clearModalPlacementStyles() {
if (!this.element) return;
[
'position',
'top',
'left',
'right',
'bottom',
'inset',
'transform',
'width',
'height',
'maxWidth',
'maxHeight',
'minWidth',
'minHeight',
'borderRadius',
'resize'
].forEach((property) => {
this.element.style[property] = '';
});
},
restoreFloatingGeometry() {
const el = this.element;
if (!el) return;
el.style.width = '1100px';
el.style.height = '700px';
el.style.maxWidth = '100vw';
el.style.maxHeight = '100vh';
el.style.minWidth = '640px';
el.style.minHeight = '420px';
el.style.resize = 'both';
el.style.borderRadius = '7px';
const rect = this._floatingRectBeforeDock;
if (rect?.width && rect?.height) {
el.style.width = `${rect.width}px`;
el.style.height = `${rect.height}px`;
el.style.top = `${rect.top}px`;
el.style.left = `${rect.left}px`;
el.style.transform = 'none';
return;
}
try {
const wh = JSON.parse(localStorage.getItem('model_linker_modal_size_before_fs') || 'null');
if (wh?.w && wh?.h) {
el.style.width = `${wh.w}px`;
el.style.height = `${wh.h}px`;
}
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';
return;
}
} catch (e) {
// Fall back to centered floating geometry below.
}
el.style.top = '50%';
el.style.left = '50%';
el.style.transform = 'translate(-50%, -50%)';
},
rememberSidebarOpenMode(mode) {
try {
localStorage.setItem(this.sidebarOpenModeStorageKey, mode === 'floating' ? 'floating' : 'docked');
} catch (e) {}
},
shouldOpenFromSidebarFloating() {
try {
return localStorage.getItem(this.sidebarOpenModeStorageKey) === 'floating';
} catch (e) {
return false;
}
},
isVisible() {
if (!this.element?.isConnected) return false;
return getComputedStyle(this.element).display !== 'none';
},
dockTo(container) {
if (!container || !this.element) return;
if (!this.docked) {
this.captureFloatingRect();
}
if (this.fullscreen) {
this.setFullScreen(false);
}
this.docked = true;
this.dockContainer = container;
this.lastDockContainer = container;
this.pendingDockToSidebar = false;
this.rememberSidebarOpenMode('docked');
this.backdrop.style.display = 'none';
container.classList.add('ml-sidebar-dock-panel');
this.element.classList.add('ml-is-docked');
this.clearModalPlacementStyles();
this.element.style.display = 'flex';
if (this.element.parentNode !== container) {
container.appendChild(this.element);
}
},
dockToSidebar() {
if (this.docked) return;
this.rememberSidebarOpenMode('docked');
this.pendingDockToSidebar = true;
if (this.isUsableDockContainer(this.lastDockContainer)) {
this.dockTo(this.lastDockContainer);
return;
}
this.tryOpenComfySidebarState();
requestAnimationFrame(() => {
if (!this.pendingDockToSidebar || this.docked) return;
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
if (button instanceof HTMLElement) {
const isActive = button.matches([
'[aria-pressed="true"]',
'[aria-selected="true"]',
'[data-active="true"]',
'[data-selected="true"]',
'.active',
'.is-active',
'.selected',
'.p-highlight'
].join(','));
if (!isActive) {
button.click();
}
}
});
},
isUsableDockContainer(container) {
if (!(container instanceof HTMLElement) || !container.isConnected) return false;
const style = getComputedStyle(container);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = container.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
},
trySetSidebarStateProperty(target, property, value) {
try {
if (!(property in target)) return false;
return Reflect.set(target, property, value);
} catch (error) {
return false;
}
},
tryOpenComfySidebarState() {
const extensionManager = app.extensionManager;
let opened = false;
const candidates = [
extensionManager?.sidebarTab,
extensionManager?.sidebarTabs,
extensionManager?.sidebar,
extensionManager
].filter(Boolean);
for (const candidate of candidates) {
try {
if (typeof candidate.openSidebar === 'function') {
candidate.openSidebar(this.sidebarTabId);
opened = true;
}
if (typeof candidate.openSidebarTab === 'function') {
candidate.openSidebarTab(this.sidebarTabId);
opened = true;
}
if (typeof candidate.setActiveSidebarTab === 'function') {
candidate.setActiveSidebarTab(this.sidebarTabId);
opened = true;
}
if (typeof candidate.setActiveSidebarTabId === 'function') {
candidate.setActiveSidebarTabId(this.sidebarTabId);
opened = true;
}
if (this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', this.sidebarTabId)) {
opened = true;
}
} catch (error) {
// Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored.
}
}
return opened;
},
undockToFloating({ persist = true, closeSidebar = true } = {}) {
if (!this.element) return;
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
this.docked = false;
this.dockContainer = null;
this.element.classList.remove('ml-is-docked');
this.clearModalPlacementStyles();
if (this.element.parentNode !== document.body) {
document.body.appendChild(this.element);
}
this.element.style.display = 'flex';
this.restoreFloatingGeometry();
this.ensureModalHandleInViewport({ persist });
if (wasDocked && closeSidebar) {
this.closeComfySidebar(dockContainer);
}
},
closeComfySidebar(dockContainer = null) {
const closedByState = this.tryCloseComfySidebarState();
requestAnimationFrame(() => {
if (dockContainer && !dockContainer.isConnected) return;
const button = document.querySelector(`.${this.sidebarTabId}-tab-button`);
if (!(button instanceof HTMLElement)) return;
const isActive = button.matches([
'[aria-pressed="true"]',
'[aria-selected="true"]',
'[data-active="true"]',
'[data-selected="true"]',
'.active',
'.is-active',
'.selected',
'.p-highlight'
].join(','));
if (!closedByState || isActive) {
button.click();
}
});
},
tryCloseComfySidebarState() {
const extensionManager = app.extensionManager;
let closed = false;
const candidates = [
extensionManager?.sidebarTab,
extensionManager?.sidebarTabs,
extensionManager?.sidebar,
extensionManager
].filter(Boolean);
for (const candidate of candidates) {
try {
if (typeof candidate.closeSidebar === 'function') {
candidate.closeSidebar();
closed = true;
}
if (typeof candidate.closeSidebarTab === 'function') {
candidate.closeSidebarTab(this.sidebarTabId);
closed = true;
}
if (typeof candidate.setActiveSidebarTab === 'function') {
candidate.setActiveSidebarTab(null);
closed = true;
}
if (typeof candidate.setActiveSidebarTabId === 'function') {
candidate.setActiveSidebarTabId(null);
closed = true;
}
if (
this.trySetSidebarStateProperty(candidate, 'activeSidebarTabId', null) ||
this.trySetSidebarStateProperty(candidate, 'activeSidebarTab', null)
) {
closed = true;
}
} catch (error) {
// Sidebar internals differ between ComfyUI versions; unsupported state APIs are ignored.
}
}
return closed;
},
// Toggle full screen mode for the dialog
toggleFullScreen() {
this.setFullScreen(!this.fullscreen);
},
setFullScreen(enable) {
const shouldReturnToDocked = !enable && this.returnToDockedAfterFullscreen;
if (enable && this.docked) {
this.returnToDockedAfterFullscreen = true;
this.undockToFloating({ persist: false });
} else if (enable) {
this.returnToDockedAfterFullscreen = false;
}
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.innerHTML = getSvgIcon('windowRestore', 'currentColor', 'ml-window-btn-icon');
btn.setAttribute('aria-label', 'Exit full screen');
this.setTooltip(btn, 'Exit full screen');
}
try { localStorage.setItem('model_linker_modal_fullscreen', '1'); } catch (e) {}
} else {
this.returnToDockedAfterFullscreen = false;
// Restore centered sizing
el.style.maxWidth = '100vw';
el.style.maxHeight = '100vh';
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.innerHTML = getSvgIcon('windowMaximize', 'currentColor', 'ml-window-btn-icon');
btn.setAttribute('aria-label', 'Enter full screen');
this.setTooltip(btn, 'Enter full screen');
}
this.ensureModalHandleInViewport({ persist: true });
try { localStorage.setItem('model_linker_modal_fullscreen', '0'); } catch (e) {}
if (shouldReturnToDocked) {
this.dockToSidebar();
}
}
},
getViewportClampedModalPosition(top, left) {
const el = this.element;
if (!el) return { top, left };
const vw = window.innerWidth || document.documentElement.clientWidth || 0;
const vh = window.innerHeight || document.documentElement.clientHeight || 0;
const pad = 4;
const handle = document.getElementById('model-linker-drag-handle');
if (handle) {
const elRect = el.getBoundingClientRect();
const handleRect = handle.getBoundingClientRect();
const handleOffsetLeft = handleRect.left - elRect.left;
const handleOffsetTop = handleRect.top - elRect.top;
const handleWidth = handleRect.width || handle.offsetWidth;
const handleHeight = handleRect.height || 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));
}
return { top, left };
},
saveModalPosition() {
if (this.docked) return;
try {
const el = this.element;
if (!el) return;
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 */ }
},
ensureModalHandleInViewport({ persist = false } = {}) {
if (this.docked) return;
if (this.fullscreen) return;
const el = this.element;
if (!el || getComputedStyle(el).display === 'none') return;
const rect = el.getBoundingClientRect();
const { top, left } = this.getViewportClampedModalPosition(rect.top, rect.left);
const nextTop = Math.round(top);
const nextLeft = Math.round(left);
if (Math.round(rect.top) === nextTop && Math.round(rect.left) === nextLeft) return;
el.style.top = `${nextTop}px`;
el.style.left = `${nextLeft}px`;
el.style.transform = 'none';
if (persist) this.saveModalPosition();
},
scheduleModalViewportClamp(persist = false) {
if (this._viewportClampFrame) {
cancelAnimationFrame(this._viewportClampFrame);
}
this._viewportClampFrame = requestAnimationFrame(() => {
this._viewportClampFrame = null;
this.ensureModalHandleInViewport({ persist });
});
},
// Begin window drag
startDrag(e) {
if (this.docked) return;
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;
({ top, left } = this.getViewportClampedModalPosition(top, left));
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
this.saveModalPosition();
// 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);
};
}
};
+508
View File
@@ -0,0 +1,508 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const downloadTargetMethods = {
/**
* 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', 'civarchive'].includes(source)) {
return true;
}
return Boolean(this.capabilities?.sources?.[source]);
},
getCategoryDisplayName(category = '') {
const displayNames = {
'checkpoints': 'checkpoint',
'loras': 'lora',
'vae': 'vae',
'controlnet': 'controlnet',
'embeddings': 'embedding',
'upscale_models': 'upscale_model',
'latent_upscale_models': 'latent_upscale_model',
'diffusion_models': 'unet',
'text_encoders': 'text encoders',
'clip': 'clip',
'clip_vision': 'clip_vision',
'hypernetworks': 'hypernetwork'
};
return displayNames[category] || category || 'unknown';
},
getModelTypeColorClass(value = '') {
const token = String(value || '')
.trim()
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase()
.replace(/[\s./\\-]+/g, '_')
.replace(/[^a-z0-9_]/g, '')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
const colorNames = {
checkpoints: 'model',
checkpoint: 'model',
ckpt: 'model',
model: 'model',
diffusion_models: 'model',
diffusion_model: 'model',
unet: 'model',
loras: 'lora',
lora: 'lora',
locon: 'lora',
lycoris: 'lora',
dora: 'lora',
hypernetworks: 'lora',
hypernetwork: 'lora',
style_models: 'style-model',
style_model: 'style-model',
vae: 'vae',
vaes: 'vae',
vae_approx: 'taesd',
taesd: 'taesd',
controlnet: 'controlnet',
control_net: 'controlnet',
controlnets: 'controlnet',
control_nets: 'controlnet',
t2i_adapter: 'controlnet',
t2i_adapters: 'controlnet',
embeddings: 'conditioning',
embedding: 'conditioning',
textualinversion: 'conditioning',
textual_inversion: 'conditioning',
aesthetic_gradient: 'conditioning',
text_encoders: 'clip',
text_encoder: 'clip',
clip: 'clip',
clips: 'clip',
clip_vision: 'clip-vision',
clipvision: 'clip-vision',
clip_vision_output: 'clip-vision-output',
upscale_models: 'image',
upscale_model: 'image',
upscaler: 'image',
upscalers: 'image',
latent_upscale_models: 'latent',
latent_upscale_model: 'latent',
latent: 'latent',
image: 'image',
images: 'image',
mask: 'mask',
masks: 'mask',
noise: 'noise',
sampler: 'sampler',
samplers: 'sampler',
sigmas: 'sigmas',
guider: 'guider',
guiders: 'guider'
};
return `ml-type-chip ml-type-chip--${colorNames[token] || 'generic'}`;
},
getCategoryTokenName(category = '') {
const tokenNames = {
'checkpoints': 'checkpoint',
'loras': 'lora',
'vae': 'vae',
'controlnet': 'controlnet',
'embeddings': 'embedding',
'upscale_models': 'upscale_model',
'latent_upscale_models': 'latent_upscale_model',
'diffusion_models': 'unet',
'text_encoders': 'text_encoders',
'clip': 'clip',
'clip_vision': 'clip_vision',
'hypernetworks': 'hypernetwork'
};
return tokenNames[category] || category || 'unknown';
},
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];
},
getSearchSourceOptions() {
const sources = ['all', ...this.getEnabledSearchSources()];
return sources.map(source => ({
value: source,
label: this.getSearchSourceLabel(source)
}));
},
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;
const category = this.getDropdownValue(categoryEl);
await this.ensureDownloadSubfoldersLoaded(category);
const folders = this.getAvailableSubfolders(category);
const suggestion = this.getSuggestedCivitaiSubfolder(missing, category, 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 categoryListId = `download-category-list-${missing.node_id}-${missing.widget_index}`;
const subfolderListId = `download-subfolder-list-${missing.node_id}-${missing.widget_index}`;
const selectedCategory = defaultCategory || 'checkpoints';
let html = `<div class="ml-download-target">`;
html += `<div class="ml-download-target-grid">`;
html += `<label class="ml-download-target-label" for="${selectId}">Folder</label>`;
html += `<label class="ml-download-target-label" for="${subfolderId}">Subfolder (optional)</label>`;
html += `<div class="ml-download-target-wrap">`;
html += `<input id="${selectId}" class="ml-download-target-input ml-download-target-select" type="text" readonly autocomplete="off" data-value="${this.escapeHtml(selectedCategory)}" value="${this.escapeHtml(this.getCategoryDisplayName(selectedCategory))}">`;
html += `<div id="${categoryListId}" class="ml-download-target-list"></div>`;
html += `</div>`;
html += `<div class="ml-download-target-wrap">`;
html += `<input id="${subfolderId}" class="ml-download-target-input" type="text" placeholder="e.g. ponyxl\\styles" autocomplete="off">`;
html += `<div id="${subfolderListId}" class="ml-download-target-list"></div>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
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: this.getDropdownValue(categoryEl) || 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 categoryListEl = container.querySelector(`#download-category-list-${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);
if (categoryListEl) {
this.enableWheelScrollChaining(categoryListEl);
}
const renderOptions = (targetEl, values, onSelect) => {
const options = values.map(value => (
typeof value === 'object'
? value
: { value, label: value }
));
if (!options.length) {
targetEl.innerHTML = '';
targetEl.style.display = 'none';
return;
}
targetEl.innerHTML = options
.slice(0, 50)
.map(option => {
const value = String(option.value || '');
const label = String(option.label || value);
return `<div class="ml-download-target-option" data-value="${encodeURIComponent(value)}" data-label="${encodeURIComponent(label)}">${this.escapeHtml(label)}</div>`;
})
.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 || '');
const label = decodeURIComponent(option.dataset.label || option.dataset.value || '');
onSelect(value, label);
targetEl.style.display = 'none';
});
});
};
const populateCategoryOptions = () => {
if (!categoryListEl) return;
const options = this.getDownloadCategoryOptions(this.getDropdownValue(categoryEl) || 'checkpoints')
.map(category => ({
value: category,
label: this.getCategoryDisplayName(category)
}));
renderOptions(categoryListEl, options, (value, label) => {
this.setDropdownValue(categoryEl, value, label);
subfolderEl.value = '';
listEl.innerHTML = '';
listEl.style.display = 'none';
this.applySuggestedCivitaiSubfolder(missing, categoryEl, subfolderEl);
});
};
const populateSubfolderOptions = async (filterText = '') => {
const filter = (filterText || '').toLowerCase();
const category = this.getDropdownValue(categoryEl);
await this.ensureDownloadSubfoldersLoaded(category);
const folders = this.getAvailableSubfolders(category);
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);
};
if (categoryListEl && categoryEl.dataset.mlCategoryBound !== 'true') {
categoryEl.dataset.mlCategoryBound = 'true';
categoryEl.addEventListener('focus', () => populateCategoryOptions());
categoryEl.addEventListener('click', () => populateCategoryOptions());
categoryEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
event.preventDefault();
populateCategoryOptions();
}
});
categoryEl.addEventListener('blur', () => hideList(categoryListEl));
}
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;
const search_source_enabled = this.getSearchSourceEnabledMap();
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,
search_source_enabled
};
},
async clearSearchCaches() {
for (const state of this.searchResultCache.values()) {
state.activeSearchRunId = null;
}
this.clearAllSearchProgressTimers();
this.searchResultCache.clear();
const key = this.getWorkflowScopedQueueKey();
if (key) {
this.workflowSearchResultCaches.delete(key);
}
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);
}
}
};
+129
View File
@@ -0,0 +1,129 @@
export function registerGlobalHelpers() {
// Global helper functions for inline onclick handlers
window.MLToggleHidden = function(id, trigger, collapsedText, expandedText) {
const element = document.getElementById(id);
if (!element) return;
const isHidden = element.classList.toggle('ml-hidden');
if (trigger) {
trigger.setAttribute('aria-expanded', String(!isHidden));
const label = trigger.querySelector?.('.ml-local-alternatives-label');
const state = trigger.querySelector?.('.ml-local-alternatives-state');
if (label || state) {
if (label) label.textContent = isHidden ? collapsedText : expandedText;
if (state) state.textContent = isHidden ? 'Show' : 'Hide';
} else {
trigger.textContent = isHidden ? collapsedText : expandedText;
}
}
};
window.MLSetCopyMode = function(sectionId, mode) {
const section = document.getElementById(sectionId);
if (!section) return;
const normalizedMode = ['all', 'active', 'inactive'].includes(mode) ? mode : 'all';
const codeEl = section.querySelector('.ml-copy-code');
const labelEl = section.querySelector('.ml-copy-label');
const metaEl = section.querySelector('.ml-copy-meta');
const dataKey = normalizedMode === 'all' ? 'mlAll' : normalizedMode === 'active' ? 'mlActive' : 'mlInactive';
const countKey = normalizedMode === 'all' ? 'mlAllCount' : normalizedMode === 'active' ? 'mlActiveCount' : 'mlInactiveCount';
const labelMap = {
all: 'Copy all',
active: 'Copy active',
inactive: 'Copy inactive'
};
if (codeEl) codeEl.textContent = section.dataset[dataKey] || '';
if (labelEl) labelEl.textContent = labelMap[normalizedMode];
if (metaEl) {
const count = Number(section.dataset[countKey] || 0);
metaEl.textContent = `${count} token${count === 1 ? '' : 's'}`;
}
section.querySelectorAll('.ml-copy-mode').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mlCopyMode === normalizedMode);
});
};
window.MLSetCopyButtonFeedback = function(btn, text) {
if (!btn) return;
const originalHtml = btn.dataset.mlOriginalHtml || btn.innerHTML;
btn.dataset.mlOriginalHtml = originalHtml;
btn.textContent = text;
setTimeout(() => {
if (btn.isConnected) btn.innerHTML = originalHtml;
}, 1500);
};
window.MLFilterSwitch = function(filter) {
const filterBtn = document.getElementById('filter-' + filter);
if (!filterBtn) return;
// Update button states
document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active'));
filterBtn.classList.add('active');
// Filter model sections
document.querySelectorAll('.ml-model-section').forEach(s => {
const hasActive = s.getAttribute('data-ml-active') === 'true';
const hasInactive = s.getAttribute('data-ml-inactive') === 'true';
const activeSection = s.querySelector('.ml-model-group-active');
const inactiveSection = s.querySelector('.ml-model-group-inactive');
if (filter === 'all') {
s.style.display = 'block';
if (activeSection) activeSection.style.display = 'block';
if (inactiveSection) inactiveSection.style.display = 'block';
} else if (filter === 'active') {
s.style.display = hasActive ? 'block' : 'none';
if (activeSection) activeSection.style.display = hasActive ? 'block' : 'none';
if (inactiveSection) inactiveSection.style.display = 'none';
} else if (filter === 'inactive') {
s.style.display = hasInactive ? 'block' : 'none';
if (activeSection) activeSection.style.display = 'none';
if (inactiveSection) inactiveSection.style.display = hasInactive ? 'block' : 'none';
}
});
const copySection = document.querySelector('[id^="ml-copy-"]');
if (copySection) {
window.MLSetCopyMode(copySection.id, filter);
}
};
window.MLCopy = function(text, btn) {
navigator.clipboard.writeText(text).then(() => {
window.MLSetCopyButtonFeedback(btn, 'Copied');
});
};
window.MLCopyCode = function(sectionId, btn) {
const section = document.getElementById(sectionId);
const codeEl = section.querySelector('code');
navigator.clipboard.writeText(codeEl.textContent).then(() => {
window.MLSetCopyButtonFeedback(btn, 'Copied');
});
};
window.MLOpenContextMenu = function(event, element) {
event.preventDefault();
event.stopPropagation();
try {
const modelData = element.getAttribute('data-model');
if (!modelData) return;
const model = JSON.parse(decodeURIComponent(modelData));
// Get dialog instance
const dialog = window.modelLinkerDialog;
if (dialog && dialog.showContextMenu) {
dialog.showContextMenu(event.clientX, event.clientY, model);
}
} catch (e) {
console.error('Model Linker: Error opening context menu:', e);
}
};
}
+453
View File
@@ -0,0 +1,453 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const lifecycleGraphMethods = {
async show(workflow = null) {
this.undockToFloating({ persist: false });
await this.showContent(workflow, { restoreFullscreen: true });
},
async showDocked(container, workflow = null) {
this.dockTo(container);
await this.showContent(workflow, { restoreFullscreen: false });
},
async showContent(workflow = null, { restoreFullscreen = true } = {}) {
this.backdrop.style.display = "none";
this.element.style.display = "flex";
if (!this.docked) {
this.scheduleModalViewportClamp();
}
// Update button state in case there are active downloads
this.updateDownloadAllButtonState();
// Ensure all models are loaded for dropdown
await this.ensureCapabilitiesLoaded();
await this.ensureAllModelsLoaded();
await this.ensureDownloadDirectoriesLoaded();
// Restore fullscreen state if enabled
try {
const fs = localStorage.getItem('model_linker_modal_fullscreen');
if (!this.docked && restoreFullscreen && fs === '1') this.setFullScreen(true);
} catch (e) { }
// Attach drag handle event listener (only once)
this.attachDragHandleIfNeeded();
this.activeTab = this.restoreActiveTab();
this.updateTabButtonStates();
this.updateQueueVisibility();
this.syncWorkflowScopedQueue(workflow || this.getCurrentWorkflow());
if (this.activeTab === 'missing') {
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
this.switchTab('loaded', { force: true });
} else {
this.switchTab('options', { force: true });
}
},
// Attach topbar drag listeners while preserving the handle-based viewport clamp.
attachDragHandleIfNeeded() {
if (this._dragHandleAttached) return;
const handle = document.getElementById('model-linker-drag-handle');
const topbar = this.element?.querySelector('.ml-dialog-topbar');
if (!handle || !topbar) return;
const onMouseDown = (e) => {
if (this.fullscreen || this.docked) return; // no drag in fullscreen or docked mode
if (e.button !== 0 || this.isTopbarDragExcluded(e.target)) return;
topbar.classList.add('ml-is-dragging');
this.startDrag(e);
};
const onMouseUp = () => { topbar.classList.remove('ml-is-dragging'); };
topbar.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
this._dragHandleAttached = true;
},
isTopbarDragExcluded(target) {
if (!(target instanceof Element)) return false;
return !!target.closest([
'.ml-tabs',
'.ml-dialog-controls',
'button',
'a',
'input',
'select',
'textarea',
'[role="button"]',
'[data-ml-no-drag]'
].join(','));
},
close({ collapseSidebar = true } = {}) {
const wasDocked = this.docked;
const dockContainer = this.dockContainer;
this.rememberSidebarOpenMode(this.docked ? 'docked' : 'floating');
this._hidePreview?.();
this.hideTooltip();
this.backdrop.style.display = "none";
this.element.style.display = "none";
if (wasDocked && collapseSidebar) {
this.closeComfySidebar(dockContainer);
}
},
/**
* Load workflow data and display missing models
*/
async loadWorkflowData(workflow = null) {
if (!this.contentElement) return;
// Show loading state
try {
// Use provided workflow, or get current workflow from ComfyUI
if (!workflow) {
workflow = this.getCurrentWorkflow();
}
if (!workflow) {
this._analysisProgressToken = null;
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
return;
}
this.syncWorkflowScopedQueue(workflow);
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.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 = `<p class="ml-error-text">Error: ${error.message}</p>`;
}
}
},
/**
* Get current workflow from ComfyUI
*/
getCurrentWorkflow() {
// Try to get workflow from app
if (app?.graph) {
try {
// Use ComfyUI's workflow serialization
const workflow = app.graph.serialize();
return workflow;
} catch (e) {
console.warn('Model Linker: Could not serialize workflow from graph:', e);
}
}
return null;
},
/**
* Locate and focus a node in the ComfyUI canvas
*/
locateNodeInGraph(nodeId) {
try {
if (!app?.graph) {
this.showNotification('Cannot locate node - graph not available', 'error');
return;
}
// Find the node in the graph
const node = app.graph.getNodeById(nodeId);
if (!node) {
this.showNotification(`Node #${nodeId} not found in graph`, 'error');
return;
}
let locatedWithAnimation = false;
// Focus on the node in the canvas with animated panning when possible
if (app.canvas?.ds && app.canvas?.canvas) {
locatedWithAnimation = this.animateCanvasToNode(node);
}
if (!locatedWithAnimation && app.graph._nodes && app.graph._nodes.get(nodeId)) {
// Alternative method for older versions
const canvasNode = app.graph._nodes.get(nodeId);
if (canvasNode && canvasNode.setSelected && canvasNode.graph) {
canvasNode.setSelected(true);
// Scroll to node
app.canvas.scrollToNode(canvasNode);
}
} else if (!locatedWithAnimation && app.ui && app.ui.nodeGraph && typeof app.ui.nodeGraph.scrollToNode === 'function') {
// Alternative for other versions
app.ui.nodeGraph.scrollToNode(node);
} else if (!locatedWithAnimation && app.canvas && typeof app.canvas.centerOnNode === 'function') {
// Final fallback for versions where animation path is unavailable
app.canvas.centerOnNode(node);
}
// Also try to flash/select the node
// Deselect all nodes first
if (app.graph && app.graph.nodes) {
app.graph.nodes.forEach(n => {
if (n.selected) n.selected = false;
});
}
// Select and highlight our node
node.selected = true;
this.showNotification(`Focused on Node #${nodeId} (${node.type})`, 'info');
} catch (e) {
console.error('Model Linker: Error locating node:', e);
this.showNotification('Error locating node: ' + e.message, 'error');
}
},
animateCanvasToNode(node) {
const canvas = app?.canvas;
const ds = canvas?.ds;
const htmlCanvas = canvas?.canvas;
if (!canvas || !ds || !htmlCanvas) return false;
const rect = node.boundingRect || [node.pos?.[0] || 0, node.pos?.[1] || 0, node.size?.[0] || 0, node.size?.[1] || 0];
const centerX = rect[0] + (rect[2] || 0) / 2;
const centerY = rect[1] + (rect[3] || 0) / 2;
const startScale = Number.isFinite(ds.scale) && ds.scale > 0 ? ds.scale : 1;
const locateViewport = this.getLocateViewport(htmlCanvas);
const viewportWidth = locateViewport.width;
const viewportHeight = locateViewport.height;
if (!viewportWidth || !viewportHeight) return false;
const nodeWidth = Math.max(rect[2] || 0, 1);
const nodeHeight = Math.max(rect[3] || 0, 1);
const paddingX = 36;
const paddingY = 32;
const maxLocateScale = 2.5;
const fitScaleX = viewportWidth / (nodeWidth + (paddingX * 2));
const fitScaleY = viewportHeight / (nodeHeight + (paddingY * 2));
const targetScale = Math.max(0.15, Math.min(maxLocateScale, fitScaleX, fitScaleY));
const getCenteredOffset = (scale) => {
return {
x: -centerX + (locateViewport.centerX / scale),
y: -centerY + (locateViewport.centerY / scale)
};
};
const startOffset = {
x: Number.isFinite(ds.offset?.[0]) ? ds.offset[0] : getCenteredOffset(startScale).x,
y: Number.isFinite(ds.offset?.[1]) ? ds.offset[1] : getCenteredOffset(startScale).y
};
const targetOffset = getCenteredOffset(targetScale);
if (this._locateAnimationFrame) {
cancelAnimationFrame(this._locateAnimationFrame);
this._locateAnimationFrame = null;
}
const durationMs = 380;
const startTime = performance.now();
const easeInOutCubic = (t) => t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2;
const tick = (now) => {
const progress = Math.min(1, (now - startTime) / durationMs);
const eased = easeInOutCubic(progress);
ds.scale = startScale + ((targetScale - startScale) * eased);
ds.offset[0] = startOffset.x + ((targetOffset.x - startOffset.x) * eased);
ds.offset[1] = startOffset.y + ((targetOffset.y - startOffset.y) * eased);
canvas.setDirty?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
if (progress < 1) {
this._locateAnimationFrame = requestAnimationFrame(tick);
} else {
this._locateAnimationFrame = null;
}
};
this._locateAnimationFrame = requestAnimationFrame(tick);
return true;
},
getLocateViewport(htmlCanvas) {
const canvasRect = htmlCanvas.getBoundingClientRect?.();
const dpr = window.devicePixelRatio || 1;
const canvasWidth = canvasRect?.width || htmlCanvas.clientWidth || ((htmlCanvas.width || 0) / dpr);
const canvasHeight = canvasRect?.height || htmlCanvas.clientHeight || ((htmlCanvas.height || 0) / dpr);
const viewport = {
left: 0,
top: 0,
right: canvasWidth,
bottom: canvasHeight
};
if (this.docked && canvasRect?.width && canvasRect?.height) {
const obstructionElement = this.dockContainer instanceof HTMLElement
? this.dockContainer
: this.element;
const obstructionRect = obstructionElement?.getBoundingClientRect?.();
if (obstructionRect?.width && obstructionRect?.height) {
this.subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect);
}
}
const width = Math.max(1, viewport.right - viewport.left);
const height = Math.max(1, viewport.bottom - viewport.top);
return {
width,
height,
centerX: viewport.left + (width / 2),
centerY: viewport.top + (height / 2)
};
},
subtractDockedObstructionFromLocateViewport(viewport, canvasRect, obstructionRect) {
const obstruction = {
left: Math.max(0, obstructionRect.left - canvasRect.left),
top: Math.max(0, obstructionRect.top - canvasRect.top),
right: Math.min(canvasRect.width, obstructionRect.right - canvasRect.left),
bottom: Math.min(canvasRect.height, obstructionRect.bottom - canvasRect.top)
};
const overlapWidth = obstruction.right - obstruction.left;
const overlapHeight = obstruction.bottom - obstruction.top;
if (overlapWidth <= 0 || overlapHeight <= 0) return;
const coversMostHeight = overlapHeight >= canvasRect.height * 0.5;
const coversMostWidth = overlapWidth >= canvasRect.width * 0.5;
const edgeTolerance = 2;
if (coversMostHeight) {
if (obstruction.left <= edgeTolerance) {
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
return;
}
if (obstruction.right >= canvasRect.width - edgeTolerance) {
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
return;
}
const leftSpace = obstruction.left - viewport.left;
const rightSpace = viewport.right - obstruction.right;
if (rightSpace >= leftSpace) {
viewport.left = Math.min(viewport.right, Math.max(viewport.left, obstruction.right));
} else {
viewport.right = Math.max(viewport.left, Math.min(viewport.right, obstruction.left));
}
return;
}
if (coversMostWidth) {
if (obstruction.top <= edgeTolerance) {
viewport.top = Math.min(viewport.bottom, Math.max(viewport.top, obstruction.bottom));
return;
}
if (obstruction.bottom >= canvasRect.height - edgeTolerance) {
viewport.bottom = Math.max(viewport.top, Math.min(viewport.bottom, obstruction.top));
}
}
},
/**
* Reconnect active downloads to their new progress div elements after UI refresh
*/
reconnectActiveDownloads() {
if (!this.contentElement) return;
for (const [downloadId, info] of Object.entries(this.activeDownloads)) {
const { missing } = info;
if (!missing) continue;
// Find the new progress div by ID
const progressId = `download-progress-${missing.node_id}-${missing.widget_index}`;
const newProgressDiv = this.contentElement.querySelector(`#${progressId}`);
const newDownloadBtn = this.contentElement.querySelector(`#download-${missing.node_id}-${missing.widget_index}`);
if (newProgressDiv) {
// Update the reference
info.progressDiv = newProgressDiv;
info.downloadBtn = newDownloadBtn;
// Show that download is in progress
newProgressDiv.classList.remove('ml-is-hidden');
newProgressDiv.classList.add('ml-is-visible');
newProgressDiv.innerHTML = this.renderProgressWithAction({
percent: 0,
leftText: '<span class="ml-info-accent-text">Downloading...</span>',
rightText: '',
actionClass: 'cancel-download-btn ml-btn ml-btn-danger ml-btn-sm',
actionText: 'Cancel',
actionDataAttr: `data-download-id="${downloadId}"`
});
// Attach cancel handler
const cancelBtn = newProgressDiv.querySelector('.cancel-download-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.cancelDownload(downloadId));
}
// Update download button if exists
if (newDownloadBtn) {
newDownloadBtn.disabled = true;
newDownloadBtn.textContent = 'Downloading...';
}
}
}
},
getMissingModelKey(missing = {}) {
const nodeId = missing.node_id ?? '';
const widgetIndex = missing.widget_index ?? '';
const subgraphId = missing.subgraph_id || '';
const scope = missing.is_top_level !== false ? 'T' : 'F';
return `${nodeId}:${widgetIndex}:${subgraphId}:${scope}`;
}
};
+163
View File
@@ -0,0 +1,163 @@
import { $el, ComfyDialog } from "../../../../scripts/ui.js";
import { searchPanelMethods } from "./search_panel.js";
import { modelInfoMethods } from "./model_info_methods.js";
import { renderFormatMethods } from "./render_format_methods.js";
import { workflowStateMethods } from "./workflow_state_methods.js";
import { selectionMethods } from "./selection_methods.js";
import { dialogShellMethods } from "./dialog_shell_methods.js";
import { downloadTargetMethods } from "./download_target_methods.js";
import { optionsMethods } from "./options_methods.js";
import { tabsLoadedMethods } from "./tabs_loaded_methods.js";
import { queueMethods } from "./queue_methods.js";
import { lifecycleGraphMethods } from "./lifecycle_graph_methods.js";
import { missingBrowserMethods } from "./missing_browser_methods.js";
import { resolveDownloadMethods } from "./resolve_download_methods.js";
import { workflowUpdateMethods } from "./workflow_update_methods.js";
export 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.workflowPendingSelections = new Map(); // workflow key -> queued selections
this.workflowSearchResultCaches = new Map(); // workflow key -> search results by missing model
this.activeDownloads = {}; // Track active downloads
this.searchResultCache = new Map();
this.searchProgressTimers = new Map();
this.urnResolvePromises = new Map();
this.urnLocalMatchPromises = new Map();
this.cachedAnalysisData = null;
this.cachedWorkflowSignature = null;
this.selectedMissingModelKey = null;
this.batchSelectedMissingKeys = new Set();
this.lastBatchSelectedMissingKey = null;
this.activeFooterMenu = null;
this.batchSearchRunning = false;
this.boundHandleOutsideClick = this.handleOutsideClick.bind(this);
this.activeTabStorageKey = 'model_linker_active_tab';
this.activeTab = this.restoreActiveTab(); // Default tab
this.fullscreen = false;
this.returnToDockedAfterFullscreen = false;
this.docked = false;
this.dockContainer = null;
this.lastDockContainer = null;
this.pendingDockToSidebar = false;
this.sidebarTabId = "comfyui-model-linker";
this.sidebarOpenModeStorageKey = "model_linker_sidebar_open_mode";
this.missingBrowserSplitStorageKey = "model_linker_missing_browser_detail_w";
this.dockButton = null;
this.undockButton = null;
this._floatingRectBeforeDock = null;
this._dragging = false;
this._dragStart = null;
this._analysisProgressToken = null;
this._locateAnimationFrame = null;
this._viewportClampFrame = null;
this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey();
this.activeWorkflowSignature = null;
this._workflowRefreshTimer = null;
this._workflowRefreshExpectedRoute = null;
this._workflowRefreshPreviousSignature = null;
this._boundHandleViewportResize = () => this.scheduleModalViewportClamp(true);
// 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
}, [
$el("div.ml-context-menu-item", {
onclick: () => this.handleContextMenuAction('showInfo')
}, [
$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("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" })
])
]);
this.tooltipElement = $el("div.ml-global-tooltip", { parent: document.body });
// 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()
]);
this.tooltipObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.bindTooltips(node);
}
}
}
});
this.tooltipObserver.observe(this.element, {
childList: true,
subtree: true
});
this.bindTooltips(this.element);
// Add click listener to hide context menu when clicking outside
this.boundHandleContextMenuClick = (e) => this.handleContextMenuOutsideClick(e);
document.addEventListener('click', this.boundHandleContextMenuClick);
this.boundHandleFooterMenuClick = (e) => this.handleFooterMenuOutsideClick(e);
document.addEventListener('click', this.boundHandleFooterMenuClick);
window.addEventListener('resize', this._boundHandleViewportResize);
}
}
function applyDialogMethods(...sources) {
for (const source of sources) {
for (const key of Reflect.ownKeys(source)) {
const descriptor = Object.getOwnPropertyDescriptor(source, key);
descriptor.enumerable = false;
Object.defineProperty(LinkerManagerDialog.prototype, key, descriptor);
}
}
}
applyDialogMethods(
searchPanelMethods,
modelInfoMethods,
renderFormatMethods,
workflowStateMethods,
selectionMethods,
dialogShellMethods,
downloadTargetMethods,
optionsMethods,
tabsLoadedMethods,
queueMethods,
lifecycleGraphMethods,
missingBrowserMethods,
resolveDownloadMethods,
workflowUpdateMethods
);
+948
View File
@@ -0,0 +1,948 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const missingBrowserMethods = {
getMissingFilename(missing = {}) {
return missing.original_path?.split('/').pop()?.split('\\').pop() || missing.name || 'Missing model';
},
getMissingNodeDisplay(missing = {}) {
const isSubgraphNode = missing.node_type && missing.node_type.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
let nodeLabel;
if (missing.subgraph_name) {
nodeLabel = missing.subgraph_name;
} else if (isSubgraphNode) {
nodeLabel = 'Subgraph';
} else {
nodeLabel = missing.node_type || 'Node';
}
const nodeId = missing.node_id ?? '';
const customNodeTitle = String(missing.node_title || '').trim();
const hasCustomNodeTitle = customNodeTitle && customNodeTitle !== nodeLabel;
const text = hasCustomNodeTitle
? `${nodeLabel} #${nodeId} · ${customNodeTitle}`
: `${nodeLabel} #${nodeId}`;
return {
label: nodeLabel,
text,
canLocate: missing.is_top_level !== false && nodeId !== ''
};
},
getBestLocalMatch(missing = {}, minConfidence = 0) {
const matches = Array.isArray(missing.matches) ? missing.matches : [];
return matches
.filter(match => Number(match.confidence) >= minConfidence)
.sort((a, b) => Number(b.confidence || 0) - Number(a.confidence || 0))[0] || null;
},
getMissingModelSummaryStats(missingModels = []) {
return missingModels.reduce((stats, missing) => {
const best = this.getBestLocalMatch(missing, 70);
if (best?.confidence === 100) {
stats.exact += 1;
} else if (best) {
stats.partial += 1;
} else {
stats.none += 1;
}
return stats;
}, { exact: 0, partial: 0, none: 0 });
},
getMissingSourceStatus(missing = {}, source = '') {
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const progress = state?.sourceProgress?.[source];
const resultStatus = this.getMissingSourceResultStatus(missing, source, state);
if (progress?.status === 'found') return resultStatus || 'found';
if (progress?.status) return progress.status;
if (resultStatus) return resultStatus;
return 'idle';
},
getMissingSourceResultStatus(missing = {}, source = '', state = null) {
const results = state?.results || {};
const candidates = [];
if (source === 'local') {
candidates.push(results.model_list, results.popular);
} else if (source === 'huggingface') {
candidates.push(results.huggingface);
} else if (source === 'civitai') {
candidates.push(results.civitai);
} else if (source === 'civarchive') {
candidates.push(results.civarchive);
} else if (source === 'lora_manager_archive') {
candidates.push(results.lora_manager_archive);
}
const downloadSource = missing.download_source || {};
const mappedDownloadSource = {
model_list: 'local',
popular: 'local',
workflow: 'civitai'
}[downloadSource.source] || downloadSource.source;
if (mappedDownloadSource === source && downloadSource.url) {
candidates.push(downloadSource);
}
return this.getSearchResultStatusLevel(candidates);
},
getSearchResultStatusLevel(resultOrResults) {
const results = Array.isArray(resultOrResults)
? resultOrResults.flatMap(result => Array.isArray(result) ? result : [result])
: [resultOrResults];
let hasPartial = false;
let hasUnknownFound = false;
for (const result of results) {
if (!result) continue;
const confidence = Number(result.confidence);
const hasConfidence = Number.isFinite(confidence);
const matchType = String(result.match_type || '').toLowerCase();
if (hasConfidence) {
if (confidence >= 100) return 'exact';
if (confidence > 0) {
hasPartial = true;
continue;
}
}
if (matchType === 'exact' || result.source === 'popular') {
return 'exact';
}
if (matchType === 'partial' || matchType === 'fuzzy' || matchType === 'similar') {
hasPartial = true;
continue;
}
if (result.url || result.download_url) {
hasUnknownFound = true;
}
}
if (hasPartial) return 'partial';
if (hasUnknownFound) return 'found';
return '';
},
renderMissingSourcesSummary(missing = {}) {
const sourceItems = this.getEnabledSearchSources().map(source => ({ source }));
return sourceItems.map(item => {
const status = this.getMissingSourceStatus(missing, item.source);
const statusClass = String(status || 'idle').replace(/[^a-z0-9_-]/gi, '');
const label = this.getSearchSourceLabel(item.source);
const statusLabels = {
pending: 'Queued',
running: 'Searching',
exact: 'Exact match',
partial: 'Partial match',
found: 'Found',
none: 'No match',
error: 'Error',
idle: 'Not searched'
};
const title = `${label}: ${statusLabels[status] || status}`;
const iconName = this.getSearchSourceIconName(item.source);
const iconHtml = getSvgIcon(iconName, 'currentColor', 'ml-missing-source-icon');
return `<span class="ml-missing-source-dot ml-missing-source-${statusClass}" data-tooltip="${this.escapeHtml(title)}" aria-label="${this.escapeHtml(title)}">${iconHtml}</span>`;
}).join('');
},
hasRenderableSearchState(state = {}) {
return Boolean(
(state.lastAttemptSources || []).length
|| Object.keys(state.sourceProgress || {}).length
|| this.hasSearchResults(state.results || {})
|| state.lastAttemptError
);
},
getMissingModelsListLayout(missingModels = []) {
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const textWidth = (value, charPx) => Math.ceil(String(value || '').length * charPx);
let modelPx = 180;
let typePx = 66;
for (const missing of missingModels) {
const filename = this.getMissingFilename(missing);
const nodeLabel = this.getMissingNodeDisplay(missing).text;
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
modelPx = Math.max(
modelPx,
textWidth(filename, 7.2) + 12,
textWidth(nodeLabel, 5.5) + 34
);
typePx = Math.max(typePx, textWidth(typeLabel, 5.8) + 18);
}
return {
modelPx: clamp(modelPx, 180, 430),
typePx: clamp(typePx, 66, 120)
};
},
renderMissingModelsBrowser(missingModels, selectedKey, totalMissing, activeCount, hasAny100Match) {
const stats = this.getMissingModelSummaryStats(missingModels);
const detailIndex = missingModels.findIndex(missing => this.getMissingModelKey(missing) === selectedKey);
const detailMissing = detailIndex >= 0 ? missingModels[detailIndex] : null;
const activeHint = activeCount > 0
? `${activeCount} downloading`
: (hasAny100Match ? 'Auto-link ready for exact matches' : 'Review matches or search online');
const listLayout = this.getMissingModelsListLayout(missingModels);
let savedDetailWidth = null;
try {
savedDetailWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
} catch (e) {}
const splitStyle = Number.isFinite(savedDetailWidth) && savedDetailWidth > 0
? `--ml-missing-detail-track:${savedDetailWidth}px;`
: '';
const listStyle = `--ml-missing-model-col:${listLayout.modelPx}px;--ml-missing-type-col:${listLayout.typePx}px;`;
let html = `
<div class="ml-missing-browser" style="${listStyle}">
<section class="ml-missing-list-pane" aria-label="Missing model list">
<div class="ml-missing-list-toolbar">
<div>
<div class="ml-missing-list-title">${totalMissing} missing model${totalMissing === 1 ? '' : 's'}</div>
<div class="ml-missing-list-meta">${this.escapeHtml(activeHint)}</div>
</div>
<div class="ml-missing-list-stats">
<span class="ml-missing-stat ml-missing-stat-exact">${stats.exact} exact</span>
<span class="ml-missing-stat ml-missing-stat-partial">${stats.partial} partial</span>
<span class="ml-missing-stat ml-missing-stat-none">${stats.none} no match</span>
</div>
</div>
<div class="ml-missing-list-head">
<span class="ml-missing-head-select">
<input type="checkbox" class="ml-missing-select-all-check" aria-label="Select or deselect all missing models">
</span>
<span>#</span>
<span>Missing Model</span>
<span>Type</span>
<span>Best Local Match</span>
<span>Match</span>
<span>Sources</span>
</div>
<div class="ml-missing-list">
`;
missingModels.forEach((missing, index) => {
const key = this.getMissingModelKey(missing);
const isSelected = key === selectedKey;
const isBatchSelected = this.batchSelectedMissingKeys?.has(key);
const filename = this.getMissingFilename(missing);
const formattedFilename = this.formatFilename(filename, 46);
const bestMatch = this.getBestLocalMatch(missing, 70);
const confidence = bestMatch ? Number(bestMatch.confidence || 0) : 0;
const matchName = bestMatch?.model?.relative_path || bestMatch?.filename || bestMatch?.path || '';
const matchDisplay = matchName || 'No local match';
const matchClass = confidence === 100 ? 'exact' : (bestMatch ? 'partial' : 'none');
const typeLabel = missing.category ? this.getCategoryDisplayName(missing.category) : 'unknown';
const typeColorClass = this.getModelTypeColorClass(missing.category || typeLabel);
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeId = missing.node_id ?? '';
const rowNodeHtml = nodeDisplay.canLocate
? `<button type="button" class="ml-node-chip is-locatable ml-missing-row-node ml-missing-row-locate" data-node-id="${this.escapeHtml(String(nodeId))}" data-tooltip="Center this node in the ComfyUI graph." aria-label="Center ${this.escapeHtml(nodeDisplay.text)} in the ComfyUI graph">${this.getLocateIconHtml()}<span class="ml-missing-row-node-label">${this.escapeHtml(nodeDisplay.text)}</span></button>`
: `<span class="ml-missing-row-node">${this.escapeHtml(nodeDisplay.text)}</span>`;
html += `
<div role="button" tabindex="0"
class="ml-missing-list-row ${isSelected ? 'is-selected' : ''} ${isBatchSelected ? 'is-batch-selected' : ''}"
data-missing-key="${this.escapeHtml(key)}">
<span class="ml-missing-row-select">
<input type="checkbox" class="ml-missing-row-check" data-ml-no-drag="1" aria-label="Select ${this.escapeHtml(filename)}" ${isBatchSelected ? 'checked' : ''}>
</span>
<span class="ml-missing-row-index">${index + 1}</span>
<span class="ml-missing-row-model">
<span class="ml-missing-row-name" data-tooltip="${this.escapeHtml(filename)}">${this.escapeHtml(formattedFilename.display)}</span>
${rowNodeHtml}
</span>
<span class="ml-missing-row-type ${typeColorClass}">${this.escapeHtml(typeLabel)}</span>
<span class="ml-missing-row-best" data-tooltip="${this.escapeHtml(matchDisplay)}">
${bestMatch ? this.escapeHtml(matchDisplay) : '<span class="ml-missing-row-none">-- No local match</span>'}
</span>
<span class="ml-missing-row-match ml-missing-row-match-${matchClass}">
<strong>${bestMatch ? `${confidence.toFixed(confidence % 1 ? 1 : 0)}%` : '--'}</strong>
</span>
<span class="ml-missing-row-sources">${this.renderMissingSourcesSummary(missing)}</span>
</div>
`;
});
html += `
</div>
</section>
<div class="ml-missing-browser-splitter" role="separator" aria-orientation="vertical" aria-label="Resize missing model panes" tabindex="0"></div>
<section class="ml-missing-detail-pane" aria-label="Missing model details" style="${splitStyle}">
${detailMissing ? this.renderMissingModel(detailMissing, detailIndex) : this.renderStatusMessage('Select a missing model to inspect details.', 'info')}
</section>
</div>
`;
return html;
},
wireMissingModelsBrowser(container, data, sortedMissingModels) {
this.wireMissingBrowserSplitter(container);
const selectRow = (row) => {
const key = row.dataset.missingKey;
if (!key || key === this.selectedMissingModelKey) return;
this.selectedMissingModelKey = key;
this.displayMissingModels(container, data);
};
const selectAllCheckbox = container.querySelector('.ml-missing-select-all-check');
if (selectAllCheckbox) {
this.updateBatchSelectAllCheckbox();
selectAllCheckbox.addEventListener('click', (event) => {
event.stopPropagation();
});
selectAllCheckbox.addEventListener('change', () => {
const shouldSelectAll = selectAllCheckbox.checked;
this.batchSelectedMissingKeys = shouldSelectAll
? new Set((sortedMissingModels || []).map(missing => this.getMissingModelKey(missing)))
: new Set();
this.lastBatchSelectedMissingKey = null;
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
});
}
container.querySelectorAll('.ml-missing-list-row').forEach(row => {
const checkbox = row.querySelector('.ml-missing-row-check');
if (checkbox) {
checkbox.addEventListener('click', (event) => {
event.stopPropagation();
checkbox.dataset.shiftClick = event.shiftKey ? '1' : '0';
});
checkbox.addEventListener('change', (event) => {
const key = row.dataset.missingKey;
if (!key) return;
const selected = checkbox.checked;
const isShiftRange = event.shiftKey || checkbox.dataset.shiftClick === '1';
if (isShiftRange && this.lastBatchSelectedMissingKey) {
this.applyBatchSelectionRange(
sortedMissingModels,
this.lastBatchSelectedMissingKey,
key,
selected
);
} else {
this.setBatchSelectionForKey(key, selected);
}
this.lastBatchSelectedMissingKey = key;
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
});
}
row.addEventListener('click', (event) => {
const clickedLocate = event.target instanceof Element && event.target.closest('.ml-missing-row-locate');
if (clickedLocate) return;
selectRow(row);
});
row.addEventListener('keydown', (event) => {
if (event.target !== row || (event.key !== 'Enter' && event.key !== ' ')) return;
event.preventDefault();
selectRow(row);
});
});
container.querySelectorAll('.ml-missing-row-locate').forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const rawNodeId = button.dataset.nodeId;
const numericNodeId = Number(rawNodeId);
this.locateNodeInGraph(Number.isNaN(numericNodeId) ? rawNodeId : numericNodeId);
});
});
const selectedMissing = sortedMissingModels.find(missing => this.getMissingModelKey(missing) === this.selectedMissingModelKey);
if (!selectedMissing) return;
const selectedIndex = sortedMissingModels.indexOf(selectedMissing);
this.wireMissingModelDetail(container, selectedMissing, selectedIndex);
},
wireMissingBrowserSplitter(container) {
const browser = container.querySelector('.ml-missing-browser');
const splitter = browser?.querySelector('.ml-missing-browser-splitter');
if (!(browser instanceof HTMLElement) || !(splitter instanceof HTMLElement)) return;
this.restoreMissingBrowserSplitWidth(browser);
splitter.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
this.startMissingBrowserSplitDrag(event, browser);
});
splitter.addEventListener('dblclick', () => {
browser.querySelector('.ml-missing-detail-pane')?.style.removeProperty('--ml-missing-detail-track');
try {
localStorage.removeItem(this.missingBrowserSplitStorageKey);
} catch (e) {}
});
splitter.addEventListener('keydown', (event) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
event.preventDefault();
this.resizeMissingBrowserDetailBy(browser, event.key === 'ArrowLeft' ? 32 : -32);
});
this._missingBrowserResizeObserver?.disconnect?.();
if (typeof ResizeObserver === 'function') {
this._missingBrowserResizeObserver = new ResizeObserver(() => {
if (!this._missingBrowserSplitDragging) {
this.restoreMissingBrowserSplitWidth(browser);
}
});
this._missingBrowserResizeObserver.observe(browser);
}
},
restoreMissingBrowserSplitWidth(browser) {
try {
const savedWidth = parseInt(localStorage.getItem(this.missingBrowserSplitStorageKey) || '', 10);
if (Number.isFinite(savedWidth) && savedWidth > 0) {
this.setMissingBrowserDetailWidth(browser, savedWidth, { persist: false });
}
} catch (e) {}
},
startMissingBrowserSplitDrag(event, browser) {
if (!(browser instanceof HTMLElement)) return;
const detailPane = browser.querySelector('.ml-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
event.preventDefault();
this._missingBrowserSplitDragging = true;
this._missingBrowserSplitStart = {
x: event.clientX,
width: detailPane.getBoundingClientRect().width,
bounds: this.getMissingBrowserSplitBounds(browser)
};
this._lastMissingBrowserSplitApply = 0;
this._missingBrowserSplitBrowser = browser;
this._missingBrowserSplitDetailPane = detailPane;
this._missingBrowserPrevUserSelect = document.body.style.userSelect;
this._missingBrowserPrevCursor = document.body.style.cursor;
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
browser.classList.add('is-resizing');
this._onMissingBrowserSplitMove = (moveEvent) => this.onMissingBrowserSplitDrag(moveEvent);
this._onMissingBrowserSplitUp = () => this.endMissingBrowserSplitDrag();
document.addEventListener('mousemove', this._onMissingBrowserSplitMove);
document.addEventListener('mouseup', this._onMissingBrowserSplitUp, { once: true });
},
onMissingBrowserSplitDrag(event) {
if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitStart || !this._missingBrowserSplitBrowser) return;
const nextWidth = this._missingBrowserSplitStart.width - (event.clientX - this._missingBrowserSplitStart.x);
const bounds = this._missingBrowserSplitStart.bounds;
this._pendingMissingBrowserSplitWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, nextWidth)));
const now = performance.now();
if (this._lastMissingBrowserSplitApply && now - this._lastMissingBrowserSplitApply < 33) return;
this._lastMissingBrowserSplitApply = now;
if (this._missingBrowserSplitFrame) return;
this._missingBrowserSplitFrame = requestAnimationFrame(() => {
this._missingBrowserSplitFrame = null;
if (!this._missingBrowserSplitDragging || !this._missingBrowserSplitDetailPane || !this._pendingMissingBrowserSplitWidth) return;
this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`);
});
},
endMissingBrowserSplitDrag() {
if (!this._missingBrowserSplitDragging) return;
this._missingBrowserSplitDragging = false;
document.removeEventListener('mousemove', this._onMissingBrowserSplitMove);
if (this._missingBrowserSplitFrame) {
cancelAnimationFrame(this._missingBrowserSplitFrame);
this._missingBrowserSplitFrame = null;
}
if (this._missingBrowserSplitDetailPane && this._pendingMissingBrowserSplitWidth) {
this._missingBrowserSplitDetailPane.style.setProperty('--ml-missing-detail-track', `${this._pendingMissingBrowserSplitWidth}px`);
try {
localStorage.setItem(this.missingBrowserSplitStorageKey, String(this._pendingMissingBrowserSplitWidth));
} catch (e) {}
}
this._missingBrowserSplitBrowser?.classList.remove('is-resizing');
this._missingBrowserSplitBrowser = null;
this._missingBrowserSplitDetailPane = null;
this._missingBrowserSplitStart = null;
this._pendingMissingBrowserSplitWidth = null;
this._lastMissingBrowserSplitApply = 0;
try {
document.body.style.userSelect = this._missingBrowserPrevUserSelect || '';
document.body.style.cursor = this._missingBrowserPrevCursor || '';
} catch (e) {}
},
resizeMissingBrowserDetailBy(browser, delta) {
const detailPane = browser.querySelector('.ml-missing-detail-pane');
if (!(detailPane instanceof HTMLElement)) return;
this.setMissingBrowserDetailWidth(browser, detailPane.getBoundingClientRect().width + delta);
},
setMissingBrowserDetailWidth(browser, width, { persist = true } = {}) {
const bounds = this.getMissingBrowserSplitBounds(browser);
const nextWidth = Math.round(Math.max(bounds.min, Math.min(bounds.max, width)));
const detailPane = browser.querySelector('.ml-missing-detail-pane');
const target = detailPane instanceof HTMLElement ? detailPane : browser;
target.style.setProperty('--ml-missing-detail-track', `${nextWidth}px`);
if (persist) {
try {
localStorage.setItem(this.missingBrowserSplitStorageKey, String(nextWidth));
} catch (e) {}
}
},
getMissingBrowserSplitBounds(browser) {
const browserRect = browser.getBoundingClientRect();
const splitter = browser.querySelector('.ml-missing-browser-splitter');
const splitterWidth = splitter instanceof HTMLElement
? splitter.getBoundingClientRect().width
: 10;
const available = Math.max(1, browserRect.width - splitterWidth);
const minListWidth = Math.min(360, Math.max(240, Math.floor(available * 0.28)));
const max = Math.max(1, available - minListWidth);
const min = Math.min(max, Math.min(420, Math.max(300, Math.floor(available * 0.3))));
return { min, max };
},
wireMissingModelDetail(container, missing, missingIndex) {
this.wireLocalMatchButtons(container, missing, missingIndex);
this.wireDownloadSearchPanel(container, missing);
this.updateSelectedBarForMissing(missing);
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);
});
}
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 || '';
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();
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 += `<div data-idx="${allModels.indexOf(m)}" class="ml-combo-option ${isHighlighted ? 'is-highlighted' : ''}">`;
html += `<div class="ml-combo-option-row">`;
html += `<code>${this.escapeHtml(label)}</code>`;
html += `</div>`;
if (folderDisplay) {
html += `<div class="ml-combo-folder" data-tooltip="${this.escapeHtml(folderDisplay)}">Folder ${this.escapeHtml(folderDisplay)}</div>`;
}
html += `</div>`;
}
comboList.innerHTML = html;
comboList.querySelectorAll('div[data-idx]').forEach(el => {
el.addEventListener('click', () => {
const idx = parseInt(el.dataset.idx, 10);
if (!Number.isNaN(idx) && idx >= 0 && idx < allModels.length) {
const chosenModel = allModels[idx];
if (chosenModel) {
this.queueResolution(missing, chosenModel);
}
}
});
});
};
if (comboList) {
populateComboOptions('');
}
if (comboInput) {
const debouncedFilter = this.debounce(() => {
populateComboOptions(comboInput.value);
}, 200);
comboInput.addEventListener('input', debouncedFilter);
comboInput.addEventListener('focus', () => {
if (comboList) {
comboList.classList.remove('ml-is-hidden');
comboList.classList.add('ml-is-visible');
}
populateComboOptions(comboInput.value);
});
comboInput.addEventListener('blur', () => {
setTimeout(() => {
if (comboList) {
comboList.classList.remove('ml-is-visible');
comboList.classList.add('ml-is-hidden');
}
}, 200);
});
}
if (comboRefresh) {
comboRefresh.addEventListener('click', async () => {
this.allModels = null;
await this.ensureAllModelsLoaded();
populateComboOptions(comboInput?.value || '');
});
}
const state = this.searchResultCache.get(this.getMissingSearchKey(missing));
const searchResultsDiv = container.querySelector(`#search-results-${missing.node_id}-${missing.widget_index}`);
if (state && searchResultsDiv && this.hasRenderableSearchState(state)) {
searchResultsDiv.classList.remove('ml-is-hidden');
searchResultsDiv.classList.add('ml-is-visible');
this.displaySearchResults(missing, state, searchResultsDiv);
}
},
/**
* Display missing models in the dialog
*/
displayMissingModels(container, data) {
const missingModels = data.missing_models || [];
const totalMissing = data.total_missing || 0;
this.missingModels = missingModels;
this.syncBatchSelectionForMissingModels(missingModels);
// Check if there are active downloads
const activeCount = Object.keys(this.activeDownloads).length;
// Check if any model has a 100% confidence match
const hasAny100Match = missingModels.some(m =>
(m.matches || []).some(match => match.confidence === 100)
);
// Show/hide Auto-Link button based on whether 100% matches exist
this.setMissingFooterControlsVisible(totalMissing > 0 || activeCount > 0);
if (this.autoResolveButton) {
this.autoResolveButton.style.display = hasAny100Match ? 'inline-flex' : 'none';
}
// Hide download all button if no missing models
if (this.downloadAllButton) {
this.downloadAllButton.style.display = (totalMissing > 0 || activeCount > 0) ? 'inline-flex' : 'none';
}
if (totalMissing === 0 && activeCount === 0) {
container.innerHTML = this.renderStatusMessage('All models are available! No missing models found.', 'success');
return;
}
// If no missing models but downloads are active, show a waiting message
if (totalMissing === 0 && activeCount > 0) {
container.innerHTML = this.renderStatusMessage(
`${activeCount} download${activeCount > 1 ? 's' : ''} in progress. Models will be auto-linked when complete.`,
'info'
);
return;
}
// 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;
}
const selectedStillExists = sortedMissingModels.some(missing => (
this.getMissingModelKey(missing) === this.selectedMissingModelKey
));
if (!selectedStillExists) {
this.selectedMissingModelKey = sortedMissingModels.length
? this.getMissingModelKey(sortedMissingModels[0])
: null;
}
this.missingModels = sortedMissingModels;
container.innerHTML = this.renderMissingModelsBrowser(
sortedMissingModels,
this.selectedMissingModelKey,
totalMissing,
activeCount,
hasAny100Match
);
this.wireMissingModelsBrowser(container, data, sortedMissingModels);
this.scheduleInitialUrnLocalMatchRefresh(sortedMissingModels, container, data);
this.updateBatchFooterButtons();
},
renderMissingModel(missing, missingIndex = 0) {
const allMatches = missing.matches || [];
// Filter out matches below 70% confidence threshold
const filteredMatches = allMatches.filter(m => m.confidence >= 70);
const hasMatches = filteredMatches.length > 0;
// Calculate 100% matches upfront (needed for download section)
const perfectMatches = filteredMatches.filter(m => m.confidence === 100);
const otherMatches = filteredMatches.filter(m => m.confidence < 100 && m.confidence >= 70);
// Format the missing filename for display
const missingFilename = this.formatFilename(missing.original_path, 60);
// Determine node info for the chip
const nodeDisplay = this.getMissingNodeDisplay(missing);
const nodeChipText = this.escapeHtml(nodeDisplay.text);
// Start card
let html = `<div class="ml-card">`;
// Card Header: Filename as headline + node chip
html += `<div class="ml-card-header">`;
html += `<div class="ml-card-title-wrap">`;
const titleMetaParts = [];
let titlePrimaryHtml = `<span class="ml-card-title-primary" data-tooltip="${this.escapeHtml(missingFilename.full)}">${missingFilename.display}</span>`;
let titleSecondaryHtml = '';
const modelId = missing.urn_model_id || missing.urn?.model_id;
const versionId = missing.urn_version_id || missing.urn?.version_id;
const modelUrl = missing.is_urn && modelId ? `https://civitai.com/models/${modelId}${versionId ? '?modelVersionId=' + versionId : ''}` : '';
const urnLoadingId = `urn-loading-${missing.node_id}-${missing.widget_index}`;
if (missing.is_urn) {
titleMetaParts.push(`<span class="ml-card-title-eyebrow" data-tooltip="${this.escapeHtml(missingFilename.full)}">${missingFilename.display}</span>`);
}
if (missing.is_urn && !missing.civitai_info) {
// URN without info - show Loading and fetch async in background
titlePrimaryHtml = `<span class="ml-card-title-primary" id="${urnLoadingId}">Resolving CivitAI model...</span>`;
setTimeout(() => this.resolveUrnAsync(modelId, versionId, urnLoadingId, modelUrl), 10);
} else if (missing.is_urn && missing.civitai_info) {
// URN with resolved info - show model name/version
const civitaiInfo = missing.civitai_info;
const civitaiLabelHtml = this.renderVersionedModelNameHtml(civitaiInfo.model_name, civitaiInfo.version_name);
if (civitaiLabelHtml) {
const linkHtml = modelUrl ? `<a href="${modelUrl}" target="_blank" class="ml-inline-civitai-link">${civitaiLabelHtml}</a>` : `<span class="ml-inline-civitai-link">${civitaiLabelHtml}</span>`;
titlePrimaryHtml = `<span class="ml-card-title-primary">${linkHtml}</span>`;
}
if (civitaiInfo.expected_filename) {
titleSecondaryHtml = `<span class="ml-card-title-secondary">Expected file: ${civitaiInfo.expected_filename}</span>`;
}
}
html += `<div class="ml-card-title-meta">`;
html += titleMetaParts.join('');
html += `<h3 class="ml-card-title">${titlePrimaryHtml}</h3>`;
if (titleSecondaryHtml) {
html += titleSecondaryHtml;
}
html += `</div>`;
const locateId = `locate-${missing.node_id}-${missing.widget_index}`;
const nodeChipClasses = missing.is_top_level !== false ? 'ml-node-chip is-locatable' : 'ml-node-chip';
const nodeChipTitle = missing.is_top_level !== false ? 'Center this node in the ComfyUI graph.' : '';
html += `<div class="ml-card-subtitle">`;
if (missing.category) {
html += `<span class="ml-category-chip ${this.getModelTypeColorClass(missing.category)}" data-tooltip="${this.escapeHtml(missing.category)}">${this.getCategoryDisplayName(missing.category)}</span>`;
}
html += `<span id="${locateId}" class="${nodeChipClasses}"${nodeChipTitle ? ` data-tooltip="${this.escapeHtml(nodeChipTitle)}"` : ''}>`;
if (missing.is_top_level !== false) {
html += this.getLocateIconHtml();
}
html += `${nodeChipText}</span>`;
html += `</div>`;
html += `</div>`;
html += `</div>`;
// Selected bar - shows if this slot has a queued selection (BELOW card header)
const selectedBarId = `selected-bar-${missing.node_id}-${missing.widget_index}`;
html += `<div id="${selectedBarId}" class="model-linker-selected"></div>`;
// Two-column layout
html += `<div class="ml-columns">`;
// LEFT COLUMN: Local Matches
html += `<div class="ml-column">`;
html += `<div class="ml-column-header">Local Matches</div>`;
html += `<div id="local-matches-body-${missing.node_id}-${missing.widget_index}">`;
html += this.renderLocalMatchesContent(missing, missingIndex);
html += `</div>`;
// Add all-models search picker - combo-style dropdown
const comboId = `combo-${missing.node_id}-${missing.widget_index}`;
html += `<div class="ml-combo-section">`;
html += `<div class="ml-combo-row">`;
html += `<label class="ml-combo-label">Model</label>`;
html += `<input id="combo-input-${comboId}" class="ml-combo-input" type="text" placeholder="Type to filter local models...">`;
html += `<button id="combo-refresh-${comboId}" type="button" aria-label="Reload local model list" data-tooltip="Reload local model list" class="ml-btn ml-btn-secondary ml-btn-sm ml-btn-icon-only ml-combo-refresh-btn">${getSvgIcon('refreshCw', 'currentColor', 'ml-combo-refresh-icon')}</button>`;
html += `</div>`;
html += `<div id="combo-list-${comboId}" class="ml-combo-list"></div>`;
html += `</div>`;
html += `</div>`; // End left column
// RIGHT COLUMN: Download Option
html += `<div class="ml-column">`;
html += `<div class="ml-column-header">Download</div>`;
const filename = missing.original_path?.split('/').pop()?.split('\\').pop() || '';
const downloadSource = missing.download_source;
const urnDownloadId = `urn-download-${missing.node_id}-${missing.widget_index}`;
if (downloadSource && downloadSource.url) {
html += this.renderKnownDownloadPanel(missing, downloadSource);
} else if (perfectMatches.length > 0) {
// Has perfect local match - download not needed, but allow online re-check.
html += `<div class="ml-download-section">`;
html += this.renderSearchControls(missing, { buttonText: 'Search Online' });
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `</div>`;
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" class="ml-search-results"></div>`;
} else if (missing.is_urn) {
html += `<div id="${urnDownloadId}" class="ml-download-section">`;
html += `<div class="ml-download-info">Resolving CivitAI download for this URN...</div>`;
html += `</div>`;
} else {
// No known download - offer search
html += `<div class="ml-download-section">`;
html += this.renderSearchControls(missing);
html += this.renderDownloadTargetControls(missing, missing.category || 'checkpoints');
html += `</div>`;
html += `<div id="search-results-${missing.node_id}-${missing.widget_index}" class="ml-search-results"></div>`;
}
// Progress container (for downloads)
html += `<div id="download-progress-${missing.node_id}-${missing.widget_index}" class="ml-download-progress-slot"></div>`;
html += `</div>`; // End right column
html += `</div>`; // End columns
html += `</div>`; // End card
return html;
},
/**
* Show a notification banner (similar to ComfyUI's "Reconnecting" banner)
*/
showNotification(message, type = 'success') {
// Build children array, filtering out nulls
const children = [];
if (type === 'success') {
children.push($el("span", {
textContent: "✓",
className: "ml-notification-icon"
}));
} else if (type === 'error') {
children.push($el("span", {
textContent: "×",
className: "ml-notification-icon"
}));
} else if (type === 'info') {
children.push($el("span", {
textContent: "ℹ",
className: "ml-notification-icon"
}));
}
// Create notification banner
const notification = $el("div", {
className: `ml-notification ml-notification--${type}`
}, [
...children,
$el("span", {
textContent: message
}),
$el("button", {
className: "ml-notification-close",
textContent: "×",
onclick: () => {
if (notification.parentNode) {
notification.style.opacity = "0";
notification.style.transform = "translateX(-50%) translateY(-100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}
})
]);
document.body.appendChild(notification);
// Auto-dismiss after 4 seconds for success, 6 seconds for errors
const dismissTime = type === 'success' ? 4000 : 6000;
setTimeout(() => {
if (notification.parentNode) {
notification.style.opacity = "0";
notification.style.transform = "translateX(-50%) translateY(-100%)";
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}, dismissTime);
}
};
+823
View File
@@ -0,0 +1,823 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const modelInfoMethods = {
/**
* 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.bindTooltips(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 = `
<div class="ml-info-dialog">
<div class="ml-info-dialog-header">
<h3 class="ml-info-dialog-title">${loraDisplayName}</h3>
<button class="ml-info-dialog-close">×</button>
</div>
<div class="ml-info-dialog-content">
<div class="ml-info-dialog-loading">Loading...</div>
<div class="ml-info-dialog-body ml-hidden-initial">
<div class="ml-info-area">
<span class="ml-info-tag ml-info-type"></span>
<span class="ml-info-tag ml-info-basemodel"></span>
</div>
<table class="ml-info-table">
<tbody>
<tr class="ml-info-file-row">
<td><span>File <span class="ml-tooltip-badge" data-tooltip="The model file name found locally or returned by CivitAI.">?</span></span></td>
<td><span class="ml-info-file"></span></td>
</tr>
<tr class="ml-info-hash-row">
<td><span>Hash (sha256) <span class="ml-tooltip-badge" data-tooltip="Unique fingerprint of the local file. Model Linker uses it to confirm the exact CivitAI version.">?</span></span></td>
<td><span class="ml-info-hash"></span></td>
</tr>
<tr class="ml-info-civitai-row">
<td><span>CivitAI <span class="ml-tooltip-badge" data-tooltip="Opens the matching CivitAI model or version page when one was found.">?</span></span></td>
<td><span class="ml-info-civitai-link"></span></td>
</tr>
<tr class="ml-info-name-row">
<td><span>Name <span class="ml-tooltip-badge" data-tooltip="Model name from CivitAI or local metadata.">?</span></span></td>
<td><span class="ml-info-name"></span></td>
</tr>
<tr class="ml-info-basemodel-row">
<td><span>Base Model <span class="ml-tooltip-badge" data-tooltip="Base model this resource was made for, for example SD1.5, SDXL or Flux.">?</span></span></td>
<td><span class="ml-info-base-model"></span></td>
</tr>
<tr class="ml-info-trainedwords-row ml-hidden-initial">
<td>
<div class="ml-info-trained-words-label">
Trained Words <span class="ml-tooltip-badge" data-tooltip="Trigger words recommended by the model author. Click the words you want, then copy them into your prompt.">?</span>
<small class="ml-info-trained-words-meta">
<span class="ml-info-trained-words-count">0 selected</span>
<button type="button" class="ml-info-copy-trained-words" disabled>Copy</button>
</small>
</div>
</td>
<td>
<div class="ml-info-trained-words-hint">Click words to select them.</div>
<div class="ml-info-trained-words"></div>
</td>
</tr>
<tr class="ml-info-clipskip-row ml-hidden-initial">
<td><span>Clip Skip <span class="ml-tooltip-badge" data-tooltip="Recommended Clip Skip value from the model author, if one is provided.">?</span></span></td>
<td><span class="ml-info-clip-skip"></span></td>
</tr>
<tr class="ml-info-description-row ml-hidden-initial">
<td><span>Description <span class="ml-tooltip-badge" data-tooltip="Model description from CivitAI or local metadata. Long descriptions are shortened until you click Show more.">?</span></span></td>
<td>
<div class="ml-info-description-wrap">
<div class="ml-info-description"></div>
<div class="ml-info-description-actions ml-hidden-initial">
<button type="button" class="ml-info-description-toggle">Show more</button>
</div>
</div>
</td>
</tr>
</tbody>
</table>
<div class="ml-info-images"></div>
</div>
</div>
<div class="ml-info-dialog-footer">
<button class="ml-btn ml-btn-secondary ml-info-dialog-close-btn">Close</button>
</div>
</div>
`;
return dialog;
},
escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
},
escapeJsString(value) {
return this.escapeHtml(JSON.stringify(String(value ?? '')));
},
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(`<div>${raw}</div>`, '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.innerHTML = this.renderVersionedModelNameHtml(modelName, versionName) || this.escapeHtml(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 ${this.getModelTypeColorClass(modelType)}`;
}
// 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 = `
<a href="${url}" target="_blank" class="ml-info-link">
${getSvgIcon('civitai', 'currentColor', 'ml-info-civitai-logo')}
View on Civitai
</a>
`;
} else {
const searchName = data.model_name || data.modelName || 'Unknown';
civitaiLinkEl.innerHTML = `
<span class="ml-info-not-found">Model not found</span>
<a href="https://civitai.com/search?q=${encodeURIComponent(searchName)}" target="_blank" class="ml-info-link">
${this.getSearchIconHtml()} Search on CivitAI
</a>
`;
}
}
// 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 = `<div class="ml-info-trained-words-list">${words.map(word => `
<button
type="button"
class="ml-info-trained-word"
data-word="${this.escapeHtml(word)}"
data-tooltip="${this.escapeHtml(word)}"
aria-pressed="false"
>
${this.escapeHtml(word)}
</button>
`).join('')}</div>`;
const row = trainedWordsEl.closest('tr');
if (row) row.style.display = '';
this.bindTooltips(trainedWordsEl);
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 : `<p>${fallbackText}</p>`;
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(`<a href="${this.escapeHtml(img.civitaiUrl)}" target="_blank" rel="noopener noreferrer" class="ml-info-image-link">civitai</a>`);
}
if (img.seed) captionParts.push(`<span><label>seed</label> ${this.escapeHtml(img.seed)}</span>`);
if (img.steps) captionParts.push(`<span><label>steps</label> ${this.escapeHtml(img.steps)}</span>`);
if (img.cfg) captionParts.push(`<span><label>cfg</label> ${this.escapeHtml(img.cfg)}</span>`);
if (img.sampler) captionParts.push(`<span><label>sampler</label> ${this.escapeHtml(img.sampler)}</span>`);
if (img.model) captionParts.push(`<span><label>model</label> ${this.escapeHtml(this.truncateText(img.model, 72))}</span>`);
if (img.positive) captionParts.push(`<span><label>positive</label> ${this.escapeHtml(this.truncateText(img.positive, 180))}</span>`);
if (img.negative) captionParts.push(`<span><label>negative</label> ${this.escapeHtml(this.truncateText(img.negative, 180))}</span>`);
return `
<div class="ml-info-image-item">
<figure>
<img src="${this.escapeHtml(img.url)}" alt="Example" loading="lazy" />
<figcaption>${captionParts.join('')}</figcaption>
</figure>
</div>
`;
};
let imagesHtml = '<div class="ml-info-images-header">Example Images</div><div class="ml-info-images-layout">';
imagesHtml += visibleImages.map(renderImageCard).join('');
imagesHtml += '</div>';
imagesContainer.innerHTML = imagesHtml;
},
/**
* Update the info dialog with error
*/
updateInfoDialogError(dialog, message) {
const civitaiLink = dialog.querySelector('.ml-info-civitai-link');
if (civitaiLink) {
civitaiLink.innerHTML = `<span class="ml-info-error">${message}</span>`;
}
},
/**
* 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);
}
}
};
+730
View File
@@ -0,0 +1,730 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { loadStylesWhenNeeded } from "../utils/css_loader.js";
import { LinkerManagerDialog } from "./linker_dialog.js";
// Main extension class
export class ModelLinker {
constructor() {
this.linkerButton = null;
this.buttonGroup = null;
this.buttonId = "model-linker-button";
this.sidebarTabId = "comfyui-model-linker";
this.sidebarRegistered = false;
this.openTooltip = "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.";
this.dialog = null;
this.isCheckingMissing = false; // Prevent multiple simultaneous checks
this.lastCheckedWorkflow = null; // Track to avoid duplicate checks
}
setup = async () => {
loadStylesWhenNeeded();
// Remove any existing button
this.removeExistingButton();
// Create dialog instance
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
// Register keyboard shortcut (Ctrl+Shift+L)
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'l') {
e.preventDefault();
this.openLinkerManager();
}
});
// Listen for workflow load events to auto-check for missing models
this.setupAutoOpenOnMissingModels();
this.setupActiveWorkflowChangeListeners();
const sidebarRegistered = this.registerSidebarButton();
if (!sidebarRegistered) {
await this.registerTopbarButton();
} else {
this.attachSidebarButtonToggleHandler();
}
}
registerSidebarButton() {
const registerSidebarTab = app.extensionManager?.registerSidebarTab;
if (typeof registerSidebarTab !== "function") {
return false;
}
if (this.sidebarRegistered || window.__modelLinkerSidebarRegistered) {
this.sidebarRegistered = true;
return true;
}
try {
registerSidebarTab.call(app.extensionManager, {
id: this.sidebarTabId,
icon: "mdi mdi-link-variant",
title: "Model Linker",
tooltip: this.openTooltip,
type: "custom",
render: (element) => this.renderSidebarPanel(element),
});
this.sidebarRegistered = true;
window.__modelLinkerSidebarRegistered = true;
return true;
} catch (error) {
console.warn("Model Linker: Sidebar tab registration failed, falling back to top menu button.", error);
return false;
}
}
attachSidebarButtonToggleHandler() {
window.__modelLinkerSidebarToggleOwner = this;
if (window.__modelLinkerSidebarToggleHandlerAttached) return;
window.__modelLinkerSidebarToggleHandlerAttached = true;
window.__modelLinkerSidebarToggleHandler = (event) => {
window.__modelLinkerSidebarToggleOwner?.handleSidebarButtonClick(event);
};
document.addEventListener('click', window.__modelLinkerSidebarToggleHandler, true);
}
handleSidebarButtonClick(event) {
const target = event.target instanceof Element ? event.target : null;
const button = target?.closest(`.${this.sidebarTabId}-tab-button`);
if (!(button instanceof HTMLElement)) return;
if (!this.dialog?.isVisible()) return;
const wasDocked = this.dialog.docked;
this.dialog.close({ collapseSidebar: false });
if (!wasDocked) {
event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation?.();
requestAnimationFrame(() => {
if (this.isSidebarButtonActive(button)) {
button.click();
}
});
}
}
isSidebarButtonActive(button) {
return button.matches([
'[aria-pressed="true"]',
'[aria-selected="true"]',
'[data-active="true"]',
'[data-selected="true"]',
'.active',
'.is-active',
'.selected',
'.p-highlight'
].join(','));
}
renderSidebarPanel(element) {
element.style.height = "100%";
element.classList.add("ml-sidebar-dock-panel");
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
if (this.dialog.shouldOpenFromSidebarFloating()) {
if (this.dialog.isVisible() && !this.dialog.docked) {
this.dialog.close();
this.dialog.closeComfySidebar(element);
return;
}
this.openLinkerManager({
forceFloating: true,
closeSidebarContainer: element,
});
return;
}
element.replaceChildren();
this.openLinkerManager({ dockContainer: element });
}
async registerTopbarButton() {
// Try to use new ComfyUI button system (like ComfyUI Manager does)
try {
// Dynamic imports for ComfyUI's button components
const { ComfyButtonGroup } = await import("../../../../scripts/ui/components/buttonGroup.js");
const { ComfyButton } = await import("../../../../scripts/ui/components/button.js");
// Create button group with Model Linker button
const modelLinkerButton = new ComfyButton({
icon: "link-variant",
action: () => this.openLinkerManager(),
content: "Model Linker",
classList: "comfyui-button comfyui-menu-mobile-collapse"
}).element;
modelLinkerButton.id = this.buttonId;
this.dialog.setTooltip(modelLinkerButton, this.openTooltip);
this.buttonGroup = new ComfyButtonGroup(
modelLinkerButton
);
// Insert before settings group in the menu
app.menu?.settingsGroup.element.before(this.buttonGroup.element);
} catch (e) {
// Fallback for older ComfyUI versions without the new button system
console.log('Model Linker: New button system not available, using floating button fallback.');
this.createFloatingButton();
}
}
/**
* Setup auto-open functionality when workflow is loaded with missing models
*/
setupAutoOpenOnMissingModels() {
// Watch for ComfyUI's Missing Models popup and inject our button
this.setupMissingModelsPopupObserver();
console.log('Model Linker: Missing models popup button injection enabled');
}
setupActiveWorkflowChangeListeners() {
if (window.__modelLinkerWorkflowChangeHandlers) {
for (const { target, event, handler } of window.__modelLinkerWorkflowChangeHandlers) {
target?.removeEventListener?.(event, handler);
}
}
if (!window.__modelLinkerHistoryPatched) {
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function(...args) {
const result = originalPushState.apply(this, args);
window.dispatchEvent(new Event('model-linker-locationchange'));
return result;
};
history.replaceState = function(...args) {
const result = originalReplaceState.apply(this, args);
window.dispatchEvent(new Event('model-linker-locationchange'));
return result;
};
window.__modelLinkerHistoryPatched = true;
}
window.__modelLinkerWorkflowChangeOwner = this;
const routeHandler = () => {
window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('route-change');
};
const focusHandler = () => {
window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('window-focus');
};
const visibilityHandler = () => {
if (document.visibilityState === 'visible') {
window.__modelLinkerWorkflowChangeOwner?.handleActiveWorkflowRouteChange('visibility-change');
}
};
window.addEventListener('hashchange', routeHandler);
window.addEventListener('popstate', routeHandler);
window.addEventListener('model-linker-locationchange', routeHandler);
window.addEventListener('focus', focusHandler);
document.addEventListener('visibilitychange', visibilityHandler);
window.__modelLinkerWorkflowChangeHandlers = [
{ target: window, event: 'hashchange', handler: routeHandler },
{ target: window, event: 'popstate', handler: routeHandler },
{ target: window, event: 'model-linker-locationchange', handler: routeHandler },
{ target: window, event: 'focus', handler: focusHandler },
{ target: document, event: 'visibilitychange', handler: visibilityHandler }
];
}
handleActiveWorkflowRouteChange(reason = 'workflow-change') {
if (!this.dialog?.isVisible()) return;
this.dialog.scheduleActiveWorkflowRefresh(reason);
}
/**
* Setup MutationObserver to detect ComfyUI's Missing Models popup and inject our button
*/
setupMissingModelsPopupObserver() {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
this.checkAndInjectButton(node);
}
}
}
});
// Observe the entire document for added nodes
observer.observe(document.body, {
childList: true,
subtree: true
});
}
/**
* Check if a node is the Missing Models popup and inject our buttons
*/
checkAndInjectButton(node) {
// Look for the Missing Models popup by finding elements with "Missing Models" text
const findMissingModelsDialog = (element) => {
// Check if this element or its children contain "Missing Models" heading
const headings = element.querySelectorAll ? element.querySelectorAll('h2, h3, [class*="title"], [class*="header"]') : [];
for (const heading of headings) {
if (heading.textContent?.includes('Missing Models')) {
return element;
}
}
// Also check text content directly
if (element.textContent?.includes('Missing Models') &&
element.textContent?.includes('following models were not found')) {
return element;
}
return null;
};
const dialog = findMissingModelsDialog(node);
if (!dialog) return;
// Check if we already injected buttons
if (dialog.querySelector('#model-linker-btn-container')) return;
// Find a suitable place to inject the button
const injectButtons = () => {
// Auto-resolve button (green)
const autoResolveBtn = document.createElement('button');
autoResolveBtn.id = 'model-linker-btn-container'; // Use this ID to prevent duplicate injection
autoResolveBtn.className = 'ml-popup-auto-resolve-btn';
autoResolveBtn.textContent = '🔗 Auto-resolve 100%';
this.dialog?.setTooltip(autoResolveBtn, 'Link every missing model that already has an exact local match, then open Model Linker for the rest.');
autoResolveBtn.addEventListener('click', async () => {
await this.handleAutoResolveInPopup(dialog, autoResolveBtn);
});
// Find the "Don't show this again" checkbox row and add button next to it
const checkbox = dialog.querySelector('input[type="checkbox"]');
if (checkbox) {
const checkboxRow = checkbox.closest('label') || checkbox.parentElement;
if (checkboxRow && checkboxRow.parentElement) {
// Make the parent a flex container to align checkbox and button
checkboxRow.parentElement.classList.add('ml-popup-inline-actions');
// Insert button at the beginning (left side)
checkboxRow.parentElement.insertBefore(autoResolveBtn, checkboxRow);
return;
}
}
// Fallback: Find the list of models and insert before it
const modelList = dialog.querySelector('[style*="overflow"]') ||
dialog.querySelector('[class*="list"]') ||
dialog.querySelector('[class*="content"]');
if (modelList) {
// Create a wrapper and insert before the model list
const wrapper = document.createElement('div');
wrapper.className = 'ml-popup-actions-wrap';
wrapper.appendChild(autoResolveBtn);
modelList.parentElement?.insertBefore(wrapper, modelList);
} else {
// Find after the description text
const allElements = dialog.querySelectorAll('*');
for (const el of allElements) {
if (el.textContent?.includes('following models were not found') &&
el.children.length === 0) {
el.parentElement?.insertBefore(btnContainer, el.nextSibling);
break;
}
}
}
console.log('Model Linker: Injected buttons into Missing Models popup');
};
// Small delay to ensure popup is fully rendered
setTimeout(injectButtons, 100);
}
/**
* Handle auto-resolve in the popup - resolve 100% matches and open Model Linker for remaining
*/
async handleAutoResolveInPopup(dialog, button) {
button.textContent = '⏳ Resolving...';
button.disabled = true;
// Close the popup first
const closeBtn = dialog.querySelector('button[class*="close"]') ||
dialog.querySelector('svg')?.closest('button') ||
Array.from(dialog.querySelectorAll('button')).find(b =>
b.textContent === '×' || b.innerHTML.includes('×') || b.innerHTML.includes('close'));
if (closeBtn) {
closeBtn.click();
}
// Small delay to let popup close
await new Promise(r => setTimeout(r, 200));
// Create dialog if needed
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
// Run auto-resolve for 100% matches - returns the updated workflow
const updatedWorkflow = await this.dialog.autoResolve100Percent();
// Always open Model Linker to show remaining unresolved models
// Pass the updated workflow if available to avoid race condition
this.dialog.show(updatedWorkflow || null);
}
/**
* Mark resolved model items in the popup as linked (green) and hide download buttons
*/
removeResolvedFromPopup(dialog, resolvedFilenames) {
console.log('Model Linker: Looking for resolved filenames:', resolvedFilenames);
// Strategy: For each filename, find text nodes containing it,
// then find the nearest Download button and mark that row
for (const filename of resolvedFilenames) {
// Get all text in the dialog and find elements containing our filename
const walker = document.createTreeWalker(
dialog,
NodeFilter.SHOW_TEXT,
null,
false
);
let node;
while (node = walker.nextNode()) {
if (node.textContent?.toLowerCase().includes(filename)) {
// Found text containing filename - now find parent with Download button
let parent = node.parentElement;
let attempts = 0;
while (parent && parent !== dialog && attempts < 10) {
// Look for Download button at this level
const downloadBtn = Array.from(parent.querySelectorAll('button'))
.find(btn => btn.textContent?.includes('Download') &&
!btn.id?.includes('model-linker'));
if (downloadBtn) {
console.log('Model Linker: Found entry for', filename);
this.markEntryAsResolved(parent, downloadBtn);
break;
}
parent = parent.parentElement;
attempts++;
}
// Only process first match for this filename
break;
}
}
}
}
/**
* Mark a model entry as resolved with visual feedback
*/
markEntryAsResolved(container, downloadBtn) {
// Already marked?
if (container.dataset.resolved === 'true') return;
container.dataset.resolved = 'true';
console.log('Model Linker: Marking entry as resolved', container);
// Add green background/styling to the container
container.classList.add('ml-resolved-entry');
// Hide the Download button and replace with badge
if (downloadBtn) {
// Create badge
const badge = document.createElement('span');
badge.className = 'ml-resolved-badge';
badge.textContent = '✓ Linked';
// Replace download button with badge
downloadBtn.style.display = 'none';
downloadBtn.parentElement?.insertBefore(badge, downloadBtn);
}
// Find and hide Copy URL button
const allBtns = container.querySelectorAll('button');
for (const btn of allBtns) {
if (btn.textContent?.includes('Copy URL')) {
btn.style.display = 'none';
}
}
}
/**
* Count remaining model items in the popup
*/
countRemainingItems(dialog) {
// Count elements that look like model entries (have Download buttons)
const downloadButtons = dialog.querySelectorAll('button');
let count = 0;
for (const btn of downloadButtons) {
if (btn.textContent?.includes('Download') && !btn.id?.includes('model-linker')) {
count++;
}
}
return count;
}
/**
* Update nodes directly in the graph without triggering a full workflow reload
* This prevents the Missing Models popup from closing
*/
updateNodesDirectly(resolutions) {
if (!app?.graph) {
console.warn('Model Linker: Cannot update nodes - graph not available');
return;
}
for (const resolution of resolutions) {
const nodeId = resolution.node_id;
const widgetIndex = resolution.widget_index;
const resolvedPath = resolution.resolved_path;
// Find the node in the graph
const node = app.graph.getNodeById(nodeId);
if (!node) {
console.warn(`Model Linker: Node ${nodeId} not found in graph`);
continue;
}
// Update the widget value
if (node.widgets && node.widgets[widgetIndex]) {
const widget = node.widgets[widgetIndex];
widget.value = resolvedPath;
// Trigger widget callback if it exists
if (widget.callback) {
widget.callback(resolvedPath, app.graph, node, null, null);
}
console.log(`Model Linker: Updated node ${nodeId} widget ${widgetIndex} to ${resolvedPath}`);
} else if (node.widgets_values) {
// Fallback: update widgets_values array directly
node.widgets_values[widgetIndex] = resolvedPath;
console.log(`Model Linker: Updated node ${nodeId} widgets_values[${widgetIndex}] to ${resolvedPath}`);
}
// Mark node as dirty to trigger redraw
if (node.setDirtyCanvas) {
node.setDirtyCanvas(true, true);
}
}
// Trigger canvas redraw
if (app.graph.setDirtyCanvas) {
app.graph.setDirtyCanvas(true, true);
}
}
/**
* Check if auto-open is enabled in user settings
*/
isAutoOpenEnabled() {
return localStorage.getItem('modelLinker.autoOpenOnMissing') !== 'false';
}
/**
* Set auto-open preference
*/
setAutoOpenEnabled(enabled) {
localStorage.setItem('modelLinker.autoOpenOnMissing', enabled ? 'true' : 'false');
}
/**
* Check for missing models and auto-open dialog if any are found
*/
async checkAndOpenForMissingModels() {
// Check if auto-open is enabled
if (!this.isAutoOpenEnabled()) {
return;
}
// Prevent multiple simultaneous checks
if (this.isCheckingMissing) {
return;
}
this.isCheckingMissing = true;
try {
// Small delay to let workflow fully load
await new Promise(r => setTimeout(r, 500));
// Get current workflow
const workflow = app?.graph?.serialize();
if (!workflow) {
return;
}
// Create a simple hash to detect if workflow changed
const workflowHash = JSON.stringify(workflow.nodes?.map(n => n.type + ':' + JSON.stringify(n.widgets_values || [])));
// Skip if we already checked this exact workflow
if (this.lastCheckedWorkflow === workflowHash) {
return;
}
this.lastCheckedWorkflow = workflowHash;
// Call analyze endpoint to check for missing models
const response = await api.fetchApi('/model_linker/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow })
});
if (!response.ok) {
console.warn('Model Linker: Failed to analyze workflow for missing models');
return;
}
const data = await response.json();
// Auto-open dialog if there are missing models
if (data.total_missing > 0) {
console.log(`Model Linker: Found ${data.total_missing} missing model(s), opening dialog...`);
this.openLinkerManager();
}
} catch (error) {
console.error('Model Linker: Error checking for missing models:', error);
} finally {
this.isCheckingMissing = false;
}
}
removeExistingButton() {
// Remove any existing button by ID
const existingButton = document.getElementById(this.buttonId);
if (existingButton) {
existingButton.remove();
}
document.querySelectorAll('.comfyui-button-group button.comfyui-button').forEach((button) => {
const label = button.querySelector('span')?.textContent?.trim() || button.textContent?.trim();
if (label === 'Model Linker') {
button.closest('.comfyui-button-group')?.remove();
}
});
// Remove button group if it exists
if (this.buttonGroup?.element?.parentNode) {
this.buttonGroup.element.remove();
this.buttonGroup = null;
}
// Also remove the stored reference if it exists
if (this.linkerButton && this.linkerButton.parentNode) {
this.linkerButton.remove();
this.linkerButton = null;
}
}
createFloatingButton() {
// Create a floating button as fallback for legacy ComfyUI versions
this.linkerButton = $el("button", {
id: this.buttonId,
textContent: "🔗 Model Linker",
onclick: () => {
this.openLinkerManager();
},
className: "model-linker-floating-button"
});
document.body.appendChild(this.linkerButton);
this.dialog?.setTooltip(this.linkerButton, "Open Model Linker to find or download missing workflow models. Shortcut: Ctrl+Shift+L.");
}
async openLinkerManager(options = {}) {
try {
if (!this.dialog) {
this.dialog = new LinkerManagerDialog();
window.modelLinkerDialog = this.dialog;
}
if (options.dockContainer && !options.forceFloating) {
await this.dialog.showDocked(options.dockContainer, options.workflow || null);
return;
}
const wasDocked = this.dialog.docked;
const showPromise = this.dialog.show(options.workflow || null);
if (options.closeSidebarContainer && !wasDocked) {
this.dialog.closeComfySidebar(options.closeSidebarContainer);
}
await showPromise;
} catch (error) {
console.error("🔗 Model Linker: Error creating/showing dialog:", error);
alert("Error opening Model Linker: " + error.message);
}
}
static switchFilter(filter) {
document.querySelectorAll('.ml-btn-filter').forEach(b => b.classList.remove('active'));
document.getElementById('filter-' + filter).classList.add('active');
document.querySelectorAll('.ml-model-section').forEach(s => {
const hasActive = s.dataset.mlActive === 'true';
const hasInactive = s.dataset.mlInactive === 'true';
if (filter === 'all') {
s.style.display = 'block';
} else if (filter === 'active') {
s.style.display = hasActive ? 'block' : 'none';
} else if (filter === 'inactive') {
s.style.display = hasInactive ? 'block' : 'none';
}
});
const copySection = document.querySelector('[id^="ml-copy-"]');
if (copySection) {
const codeEl = copySection.querySelector('code');
const labelEl = copySection.querySelector('div');
if (filter === 'all') {
codeEl.textContent = copySection.dataset.mlAll;
labelEl.textContent = 'Copy all:';
} else if (filter === 'active') {
codeEl.textContent = copySection.dataset.mlActive;
labelEl.textContent = 'Copy active:';
} else if (filter === 'inactive') {
codeEl.textContent = copySection.dataset.mlInactive;
labelEl.textContent = 'Copy inactive:';
}
}
}
static copyToClipboard(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = '✓';
setTimeout(() => btn.textContent = orig, 1500);
});
}
static copyFromCode(sectionId, btn) {
const section = document.getElementById(sectionId);
const codeEl = section.querySelector('code');
navigator.clipboard.writeText(codeEl.textContent).then(() => {
const orig = btn.textContent;
btn.textContent = '✓ Copied!';
setTimeout(() => btn.textContent = orig, 1500);
});
}
}
+752
View File
@@ -0,0 +1,752 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const optionsMethods = {
displayOptions() {
if (!this.contentElement) return;
this.contentElement.style.overflowY = 'hidden';
const tokens = this.getStoredTokens();
const sourceDefaultsRows = this.getSearchSourceDefinitions()
.filter(def => this.isSourceAvailable(def.source))
.map(def => {
const iconName = this.getSearchSourceIconName(def.source);
const checked = tokens.search_source_enabled?.[def.source] !== false;
const inputId = `ml-options-source-${def.source.replace(/_/g, '-')}`;
return `
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">
<span class="ml-options-source-icon" aria-hidden="true">${getSvgIcon(iconName)}</span>
<span>${this.escapeHtml(this.getSearchSourceLabel(def.source))}</span>
<span class="ml-tooltip-badge" data-tooltip="${this.escapeHtml(def.tooltip)}">?</span>
</span>
</div>
<span class="ml-options-toggle-control">
<input id="${inputId}" class="ml-options-switch-input ml-options-source-enabled" type="checkbox" data-source="${this.escapeHtml(def.source)}" data-storage-key="${this.escapeHtml(def.storageKey)}" ${checked ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
`;
})
.join('');
this.contentElement.innerHTML = `
<div class="ml-options-wrap">
<div class="ml-options-shell">
<aside class="ml-options-sidebar">
<div class="ml-options-sidebar-group">
<h3 class="ml-options-sidebar-title">Application Settings</h3>
</div>
<div class="ml-options-sidebar-group">
<div class="ml-options-sidebar-label">Download</div>
<div class="ml-options-nav">
<button type="button" class="ml-options-nav-btn is-active" data-target="ml-options-section-sources">
<span class="ml-options-nav-main">
<span class="ml-options-nav-icon" aria-hidden="true">${getSvgIcon('download')}</span>
<span>Sources</span>
</span>
<span class="ml-options-nav-meta">01</span>
</button>
<button type="button" class="ml-options-nav-btn" data-target="ml-options-section-local-db">
<span class="ml-options-nav-main">
<span class="ml-options-nav-icon ml-options-comfyui-icon" aria-hidden="true">${getSvgIcon('comfyui')}</span>
<span>Local Database</span>
</span>
<span class="ml-options-nav-meta">02</span>
</button>
</div>
</div>
<div class="ml-options-sidebar-group">
<div class="ml-options-sidebar-label">Providers</div>
<div class="ml-options-nav">
<button type="button" class="ml-options-nav-btn" data-target="ml-options-section-civitai">
<span class="ml-options-nav-main">
<span class="ml-options-nav-icon ml-options-provider-icon ml-options-provider-icon-civitai" aria-hidden="true">${getSvgIcon('civitai')}</span>
<span>CivitAI</span>
</span>
<span class="ml-options-nav-meta">03</span>
</button>
<button type="button" class="ml-options-nav-btn" data-target="ml-options-section-hf">
<span class="ml-options-nav-main">
<span class="ml-options-nav-icon ml-options-provider-icon ml-options-provider-icon-huggingface" aria-hidden="true">${getSvgIcon('huggingface')}</span>
<span>HuggingFace</span>
</span>
<span class="ml-options-nav-meta">04</span>
</button>
</div>
</div>
<div class="ml-options-actions">
<div id="ml-options-status" class="ml-options-status">Saved only on this machine.</div>
<button id="ml-options-save" class="ml-btn ml-btn-primary ml-footer-btn">Save</button>
</div>
</aside>
<div class="ml-options-main">
<section id="ml-options-section-sources" class="ml-options-card ml-options-section">
<div class="ml-options-section-head">
<h4 class="ml-options-section-title">Download Sources</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
${sourceDefaultsRows}
</div>
</div>
</div>
</section>
<section id="ml-options-section-local-db" class="ml-options-card ml-options-section is-hidden">
<div class="ml-options-section-head">
<h4 class="ml-options-section-title">Local Database</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-stack">
<div class="ml-options-db-summary">
<div class="ml-options-db-row">
<span>Models</span>
<strong id="ml-options-model-list-count">Loading...</strong>
</div>
<div class="ml-options-db-row">
<span>Last update</span>
<strong id="ml-options-model-list-updated">Loading...</strong>
</div>
<div class="ml-options-db-row">
<span>Status</span>
<strong id="ml-options-model-list-state">Checking local file...</strong>
</div>
</div>
<div id="ml-options-model-list-message" class="ml-options-db-message">Local Database uses ComfyUI-Manager model-list.json.</div>
<div class="ml-options-db-actions">
<button id="ml-options-model-list-check" type="button" class="ml-btn ml-btn-secondary">${getSvgIcon('refreshCw')} Check latest</button>
<button id="ml-options-model-list-update" type="button" class="ml-btn ml-btn-primary">${getSvgIcon('download')} Update Local Database</button>
<a class="ml-options-inline-link" href="https://github.com/Comfy-Org/ComfyUI-Manager/blob/main/model-list.json" target="_blank" rel="noopener noreferrer">Source</a>
</div>
</div>
</div>
</div>
</section>
<section id="ml-options-section-civitai" class="ml-options-card ml-options-section">
<div class="ml-options-section-head">
<h4 class="ml-options-section-title">CivitAI</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use CivitAI tRPC search <span class="ml-tooltip-badge" data-tooltip="Main CivitAI search method. Keep this enabled unless CivitAI search stops working.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-civitai-use-trpc-search" class="ml-options-switch-input" type="checkbox" ${tokens.civitai_use_trpc_search ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai" class="ml-options-label">CivitAI API Key <a href="https://civitai.com/user/account" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used when downloading from CivitAI requires your account. Add this if CivitAI downloads fail or need access to models available to your account.">?</span></label>
<input id="ml-options-civitai" class="ml-options-input" type="password" placeholder="Paste CivitAI API key" value="${tokens.civitai_key}">
<button id="ml-options-civitai-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<div class="ml-options-token-check">
<button id="ml-options-civitai-key-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check API key</button>
<span id="ml-options-civitai-key-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-civitai-session" class="ml-options-label">CivitAI Session Token <span class="ml-tooltip-badge" data-tooltip="Optional. Makes CivitAI search use your logged-in session, so results can match what you see in the browser. Useful for NSFW or account-visible results.">?</span></label>
<input id="ml-options-civitai-session" class="ml-options-input" type="password" placeholder="Paste __Secure-civitai-token" value="${tokens.civitai_session_token}">
<button id="ml-options-civitai-session-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved CivitAI session token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<details class="ml-options-help">
<summary>How to find the session token</summary>
<ol>
<li>Sign in on civitai.com in your browser.</li>
<li>Open DevTools, then Application or Storage.</li>
<li>Open Cookies for civitai.com and copy <code>__Secure-civitai-token</code>.</li>
</ol>
<p>Treat it like a password. It is only needed when session-aware CivitAI search is required.</p>
</details>
<div class="ml-options-token-check">
<button id="ml-options-civitai-session-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check session</button>
<span id="ml-options-civitai-session-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
</div>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use CivitAI HTML fallback <span class="ml-tooltip-badge" data-tooltip="Backup CivitAI search. Leave this enabled to try the regular CivitAI page when the main search does not find enough results.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-civitai-use-html-fallback" class="ml-options-switch-input" type="checkbox" ${tokens.civitai_use_html_fallback ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-number-row">
<div class="ml-options-number-copy">
<span class="ml-options-label">CivitAI Models To Inspect <span class="ml-tooltip-badge" data-tooltip="How many CivitAI results to check for the exact file. Higher values may find more matches, but searches can take longer. Range: 1-20.">?</span></span>
</div>
<input id="ml-options-civitai-limit" class="ml-options-input" type="number" min="1" max="20" step="1" value="${tokens.civitai_candidate_limit}">
</div>
</div>
</div>
</div>
</div>
</section>
<section id="ml-options-section-hf" class="ml-options-card ml-options-section">
<div class="ml-options-section-head">
<h4 class="ml-options-section-title">HuggingFace</h4>
</div>
<div class="ml-options-grid">
<div class="ml-options-panel">
<div class="ml-options-toggle-list">
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use HuggingFace API repo search <span class="ml-tooltip-badge" data-tooltip="Main Hugging Face search method. It searches by filename, then checks matching repos for the actual file.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-hf-use-api-search" class="ml-options-switch-input" type="checkbox" ${tokens.hf_use_api_search ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-hf" class="ml-options-label">HuggingFace Token <a href="https://huggingface.co/settings/tokens" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used to search and download files from Hugging Face repos your account can access, including gated repos. A read-only token is enough.">?</span></label>
<input id="ml-options-hf" class="ml-options-input" type="password" placeholder="Paste HuggingFace token" value="${tokens.hf_token}">
<button id="ml-options-hf-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved HuggingFace token" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<div class="ml-options-token-check">
<button id="ml-options-hf-token-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check token</button>
<span id="ml-options-hf-token-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
</div>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use Comfy-Org fallback <span class="ml-tooltip-badge" data-tooltip="Checks Comfy-Org repositories directly. Useful for ComfyUI model packs that normal Hugging Face search may miss.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-hf-use-comfy-org-fallback" class="ml-options-switch-input" type="checkbox" ${tokens.hf_use_comfy_org_fallback ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-db-summary">
<div class="ml-options-db-row">
<span>Comfy-Org files</span>
<strong id="ml-options-hf-index-count">Loading...</strong>
</div>
<div class="ml-options-db-row">
<span>Last refresh</span>
<strong id="ml-options-hf-index-updated">Loading...</strong>
</div>
<div class="ml-options-db-row">
<span>Status</span>
<strong id="ml-options-hf-index-state">Checking local index...</strong>
</div>
</div>
<div id="ml-options-hf-index-message" class="ml-options-db-message">Comfy-Org fallback uses a cached HuggingFace file index.</div>
<div class="ml-options-db-actions">
<button id="ml-options-hf-index-refresh" type="button" class="ml-btn ml-btn-secondary">${getSvgIcon('refreshCw')} Refresh Comfy-Org Index</button>
</div>
</div>
<label class="ml-options-toggle-row">
<div class="ml-options-toggle-copy">
<span class="ml-options-toggle-title">Use Brave fallback <span class="ml-tooltip-badge" data-tooltip="Last-resort web search for the exact filename on huggingface.co. Results are still checked before Model Linker offers them.">?</span></span>
</div>
<span class="ml-options-toggle-control">
<input id="ml-options-hf-use-brave-fallback" class="ml-options-switch-input" type="checkbox" ${tokens.hf_use_brave_fallback ? 'checked' : ''}>
<span class="ml-options-switch"></span>
</span>
</label>
<div class="ml-options-dependent-block">
<div class="ml-options-field">
<div class="ml-options-input-row">
<label for="ml-options-brave" class="ml-options-label">Brave Search API Key <a href="https://api-dashboard.search.brave.com/app/keys" target="_blank" rel="noopener noreferrer" class="ml-options-inline-link">Get key</a> <span class="ml-tooltip-badge" data-tooltip="Optional. Used only by the Brave fallback. It helps find Hugging Face files when Hugging Face search does not show the right repo.">?</span></label>
<input id="ml-options-brave" class="ml-options-input" type="password" placeholder="Paste Brave Search API key" value="${tokens.brave_search_api_key}">
<button id="ml-options-brave-toggle" type="button" class="ml-options-visibility-btn" aria-label="Toggle visibility for saved Brave Search API key" data-tooltip="Show saved value">
${getSvgIcon('eye')}
</button>
</div>
<div class="ml-options-token-check">
<button id="ml-options-brave-key-check" type="button" class="ml-btn ml-btn-secondary ml-btn-sm">${getSvgIcon('refreshCw')} Check API key</button>
<span id="ml-options-brave-key-check-status" class="ml-options-token-check-status">Not checked</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
`;
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 civitaiKeyCheckBtn = this.contentElement.querySelector('#ml-options-civitai-key-check');
const civitaiKeyCheckStatus = this.contentElement.querySelector('#ml-options-civitai-key-check-status');
const civitaiSessionCheckBtn = this.contentElement.querySelector('#ml-options-civitai-session-check');
const civitaiSessionCheckStatus = this.contentElement.querySelector('#ml-options-civitai-session-check-status');
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 hfTokenCheckBtn = this.contentElement.querySelector('#ml-options-hf-token-check');
const hfTokenCheckStatus = this.contentElement.querySelector('#ml-options-hf-token-check-status');
const braveKeyCheckBtn = this.contentElement.querySelector('#ml-options-brave-key-check');
const braveKeyCheckStatus = this.contentElement.querySelector('#ml-options-brave-key-check-status');
const sourceEnabledInputs = Array.from(this.contentElement.querySelectorAll('.ml-options-source-enabled'));
const status = this.contentElement.querySelector('#ml-options-status');
const saveBtn = this.contentElement.querySelector('#ml-options-save');
const modelListCountEl = this.contentElement.querySelector('#ml-options-model-list-count');
const modelListUpdatedEl = this.contentElement.querySelector('#ml-options-model-list-updated');
const modelListStateEl = this.contentElement.querySelector('#ml-options-model-list-state');
const modelListMessageEl = this.contentElement.querySelector('#ml-options-model-list-message');
const modelListCheckBtn = this.contentElement.querySelector('#ml-options-model-list-check');
const modelListUpdateBtn = this.contentElement.querySelector('#ml-options-model-list-update');
const hfIndexCountEl = this.contentElement.querySelector('#ml-options-hf-index-count');
const hfIndexUpdatedEl = this.contentElement.querySelector('#ml-options-hf-index-updated');
const hfIndexStateEl = this.contentElement.querySelector('#ml-options-hf-index-state');
const hfIndexMessageEl = this.contentElement.querySelector('#ml-options-hf-index-message');
const hfIndexRefreshBtn = this.contentElement.querySelector('#ml-options-hf-index-refresh');
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,
...sourceEnabledInputs,
].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 formatOptionDate = (value) => {
if (!value) return 'Bundled';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
};
const shortSha = (value) => value ? String(value).slice(0, 8) : '';
const setModelListBusy = (busy) => {
if (modelListCheckBtn) modelListCheckBtn.disabled = busy;
if (modelListUpdateBtn) modelListUpdateBtn.disabled = busy;
};
const setHfIndexBusy = (busy) => {
if (hfIndexRefreshBtn) hfIndexRefreshBtn.disabled = busy;
};
const setTokenCheckStatus = (statusEl, text, mode = '') => {
if (!statusEl) return;
statusEl.textContent = text;
statusEl.classList.remove('is-valid', 'is-invalid', 'is-pending');
if (mode) statusEl.classList.add(mode);
};
const checkCredential = async ({ input, button, statusEl, endpoint, payloadKey, missingText }) => {
const value = (input?.value || '').trim();
if (!value) {
setTokenCheckStatus(statusEl, missingText, 'is-invalid');
return;
}
try {
if (button) button.disabled = true;
setTokenCheckStatus(statusEl, 'Checking...', 'is-pending');
const response = await api.fetchApi(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [payloadKey]: value })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Check failed: ${response.status}`);
}
setTokenCheckStatus(
statusEl,
data.message || (data.valid ? 'Valid' : 'Invalid'),
data.valid ? 'is-valid' : 'is-invalid'
);
} catch (error) {
console.error('Model Linker: Credential check error:', error);
setTokenCheckStatus(statusEl, error.message || 'Check failed', 'is-invalid');
} finally {
if (button) button.disabled = false;
}
};
const renderModelListStatus = (data = {}, checkedRemote = false) => {
if (modelListCountEl) {
modelListCountEl.textContent = Number.isFinite(Number(data.local_count))
? Number(data.local_count).toLocaleString()
: '-';
}
if (modelListUpdatedEl) {
modelListUpdatedEl.textContent = formatOptionDate(data.local_updated_at);
}
if (modelListStateEl) {
if (checkedRemote && data.can_compare && data.update_available) {
modelListStateEl.textContent = 'Update available';
} else if (checkedRemote && data.can_compare) {
modelListStateEl.textContent = 'Up to date';
} else if (checkedRemote && !data.can_compare) {
modelListStateEl.textContent = 'Remote checked';
} else {
modelListStateEl.textContent = data.local_sha ? 'Tracked' : 'Bundled';
}
}
if (modelListMessageEl) {
const localSha = shortSha(data.local_sha);
const remoteSha = shortSha(data.remote_sha);
if (checkedRemote && data.update_available) {
modelListMessageEl.textContent = `A newer ComfyUI-Manager model-list.json is available (${localSha || 'local'} -> ${remoteSha}).`;
} else if (checkedRemote && data.can_compare) {
modelListMessageEl.textContent = `Local Database is current (${localSha}).`;
} else if (checkedRemote) {
modelListMessageEl.textContent = `Remote file was checked. Local SHA is not recorded yet; run update once to track future changes.`;
} else {
modelListMessageEl.textContent = `Local Database uses ComfyUI-Manager model-list.json.`;
}
}
};
const loadModelListStatus = async (checkRemote = false) => {
try {
setModelListBusy(true);
if (modelListStateEl) {
modelListStateEl.textContent = checkRemote ? 'Checking GitHub...' : 'Loading...';
}
const response = await api.fetchApi(`/model_linker/model-list/status${checkRemote ? '?check_remote=1' : ''}`);
if (!response.ok) {
throw new Error(`Status failed: ${response.status}`);
}
const data = await response.json();
renderModelListStatus(data, checkRemote);
return data;
} catch (error) {
console.error('Model Linker: model-list status error:', error);
if (modelListStateEl) modelListStateEl.textContent = 'Error';
if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to read Local Database status.';
return null;
} finally {
setModelListBusy(false);
}
};
const updateModelList = async () => {
try {
setModelListBusy(true);
if (modelListStateEl) modelListStateEl.textContent = 'Updating...';
if (modelListMessageEl) modelListMessageEl.textContent = 'Downloading latest ComfyUI-Manager model-list.json...';
const response = await api.fetchApi('/model_linker/model-list/update', {
method: 'POST'
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || `Update failed: ${response.status}`);
}
await this.clearSearchCaches();
renderModelListStatus(data, false);
if (modelListStateEl) modelListStateEl.textContent = 'Updated';
if (modelListMessageEl) modelListMessageEl.textContent = `Local Database updated. ${Number(data.local_count || 0).toLocaleString()} models loaded.`;
this.showNotification('Local Database updated', 'success');
} catch (error) {
console.error('Model Linker: model-list update error:', error);
if (modelListStateEl) modelListStateEl.textContent = 'Error';
if (modelListMessageEl) modelListMessageEl.textContent = error.message || 'Failed to update Local Database.';
this.showNotification('Local Database update failed', 'error');
} finally {
setModelListBusy(false);
}
};
const renderHfIndexStatus = (data = {}) => {
if (hfIndexCountEl) {
hfIndexCountEl.textContent = Number.isFinite(Number(data.file_count))
? Number(data.file_count).toLocaleString()
: '-';
}
if (hfIndexUpdatedEl) {
hfIndexUpdatedEl.textContent = data.updated_at
? formatOptionDate(Number(data.updated_at) * 1000)
: 'Never';
}
if (hfIndexStateEl) {
if (!data.exists) {
hfIndexStateEl.textContent = 'Not cached';
} else if (data.stale) {
hfIndexStateEl.textContent = 'Refresh due';
} else {
hfIndexStateEl.textContent = 'Cached';
}
}
if (hfIndexMessageEl) {
if (!data.exists) {
hfIndexMessageEl.textContent = 'The first Comfy-Org fallback search will build the local HuggingFace file index.';
} else if (data.stale) {
hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and should be refreshed.`;
} else {
hfIndexMessageEl.textContent = `Index has ${Number(data.repo_count || 0).toLocaleString()} repos and is used before per-repo fallback checks.`;
}
}
};
const loadHfIndexStatus = async () => {
try {
setHfIndexBusy(true);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Loading...';
const response = await api.fetchApi('/model_linker/huggingface/author-index/status');
if (!response.ok) {
throw new Error(`Status failed: ${response.status}`);
}
const data = await response.json();
renderHfIndexStatus(data);
return data;
} catch (error) {
console.error('Model Linker: HuggingFace index status error:', error);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error';
if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to read HuggingFace index status.';
return null;
} finally {
setHfIndexBusy(false);
}
};
const refreshHfIndex = async () => {
try {
setHfIndexBusy(true);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshing...';
if (hfIndexMessageEl) hfIndexMessageEl.textContent = 'Downloading Comfy-Org file index from HuggingFace...';
const response = await api.fetchApi('/model_linker/huggingface/author-index/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hf_token: hfInput?.value || '' })
});
const data = await response.json();
if (!response.ok || data.success === false) {
throw new Error(data.error || `Refresh failed: ${response.status}`);
}
await this.clearSearchCaches();
renderHfIndexStatus(data);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Refreshed';
this.showNotification('HuggingFace Comfy-Org index refreshed', 'success');
} catch (error) {
console.error('Model Linker: HuggingFace index refresh error:', error);
if (hfIndexStateEl) hfIndexStateEl.textContent = 'Error';
if (hfIndexMessageEl) hfIndexMessageEl.textContent = error.message || 'Failed to refresh HuggingFace index.';
this.showNotification('HuggingFace index refresh failed', 'error');
} finally {
setHfIndexBusy(false);
}
};
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.classList.toggle('ml-is-active-text', visible);
button.classList.toggle('ml-is-muted', !visible);
button.setAttribute('aria-pressed', visible ? 'true' : 'false');
this.setTooltip(button, visible ? 'Hide saved value' : 'Show saved 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-sources');
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 (targetId === 'ml-options-section-local-db') {
loadModelListStatus(false);
} else if (targetId === 'ml-options-section-hf') {
loadHfIndexStatus();
}
});
});
if (modelListCheckBtn) {
modelListCheckBtn.addEventListener('click', () => {
loadModelListStatus(true);
});
}
if (modelListUpdateBtn) {
modelListUpdateBtn.addEventListener('click', () => {
updateModelList();
});
}
if (hfIndexRefreshBtn) {
hfIndexRefreshBtn.addEventListener('click', () => {
refreshHfIndex();
});
}
if (civitaiSessionCheckBtn) {
civitaiSessionCheckBtn.addEventListener('click', () => {
checkCredential({
input: civitaiSessionInput,
button: civitaiSessionCheckBtn,
statusEl: civitaiSessionCheckStatus,
endpoint: '/model_linker/civitai/session-token/check',
payloadKey: 'civitai_session_token',
missingText: 'Paste token first'
});
});
}
if (civitaiKeyCheckBtn) {
civitaiKeyCheckBtn.addEventListener('click', () => {
checkCredential({
input: civitaiInput,
button: civitaiKeyCheckBtn,
statusEl: civitaiKeyCheckStatus,
endpoint: '/model_linker/civitai/api-key/check',
payloadKey: 'civitai_key',
missingText: 'Paste API key first'
});
});
}
if (hfTokenCheckBtn) {
hfTokenCheckBtn.addEventListener('click', () => {
checkCredential({
input: hfInput,
button: hfTokenCheckBtn,
statusEl: hfTokenCheckStatus,
endpoint: '/model_linker/huggingface/token/check',
payloadKey: 'hf_token',
missingText: 'Paste token first'
});
});
}
if (braveKeyCheckBtn) {
braveKeyCheckBtn.addEventListener('click', () => {
checkCredential({
input: braveInput,
button: braveKeyCheckBtn,
statusEl: braveKeyCheckStatus,
endpoint: '/model_linker/brave/api-key/check',
payloadKey: 'brave_search_api_key',
missingText: 'Paste API key first'
});
});
}
loadModelListStatus(false);
loadHfIndexStatus();
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 (sourceEnabledInputs.length && !sourceEnabledInputs.some(input => input.checked)) {
const localInput = sourceEnabledInputs.find(input => input.dataset.source === 'local');
if (localInput) {
localInput.checked = true;
}
}
sourceEnabledInputs.forEach((input) => {
const key = input.dataset.storageKey;
if (key) {
localStorage.setItem(key, input.checked ? 'true' : 'false');
}
});
if (civitaiLimitInput) {
civitaiLimitInput.value = `${civitaiCandidateLimit}`;
}
await this.clearSearchCaches();
setStatus('Options saved locally.', 'is-saved');
this.showNotification('Options saved and search cache cleared', 'success');
});
}
}
};
+829
View File
@@ -0,0 +1,829 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const queueMethods = {
createContent() {
// Wrap the body in a two-column layout: left = items, right = queued panel
const body = $el("div", {
id: "model-linker-body"
});
this.contentElement = $el("div.ml-scrollable", {
id: "model-linker-content"
});
this.queueElement = $el("div", {
id: "model-linker-queue"
}, [
this.createQueuePanel()
]);
// Splitter between content and queue
this.splitterElement = $el("div", {
id: "model-linker-splitter",
ondragstart: (e) => e.preventDefault()
});
body.appendChild(this.contentElement);
body.appendChild(this.splitterElement);
body.appendChild(this.queueElement);
// Restore saved queue width and wire splitter
try {
const savedSplit = localStorage.getItem('model_linker_split_w');
if (savedSplit) {
const w = parseInt(savedSplit, 10);
if (!isNaN(w) && w > 0) {
this.queueElement.style.width = `${w}px`;
}
}
} catch (e) { }
try {
const onSplitMouseDown = (e) => this.startSplitDrag(e);
this.splitterElement.addEventListener('mousedown', onSplitMouseDown);
this._splitterMouseDown = onSplitMouseDown;
} catch (e) { }
// Toggle icon always visible
try {
this.queueToggleIcon = $el("button", {
id: "queue-toggle-icon",
type: "button",
"aria-label": "Toggle queued selections",
onclick: (event) => this.onQueueEdgeClick(event),
onmousedown: (event) => this.startQueueEdgeDrag(event)
});
body.appendChild(this.queueToggleIcon);
this.updateQueueToggleIcon();
} catch (e) { }
// Restore queue collapsed state
try {
const col = localStorage.getItem('model_linker_queue_collapsed');
if (col === '1') this.setQueueCollapsed(true);
} catch (e) { }
return body;
},
createQueuePanel() {
// Header row with title and clear button
this.queueHeader = $el("div.ml-queue-header", {}, [
$el("div#queue-title.ml-queue-title", { textContent: "Queued Selections (0)" }),
$el("div.ml-queue-actions", {}, [
$el("button", {
id: "queue-toggle",
className: "ml-btn ml-btn-secondary ml-btn-sm",
textContent: "Collapse",
onclick: () => this.toggleQueueCollapsed()
}),
$el("button", {
id: "queue-clear",
className: "ml-btn ml-btn-secondary ml-btn-sm",
textContent: "Clear All",
onclick: () => this.clearAllQueued()
})
])
]);
// Scrollable list
this.queueList = $el("div#queue-list.ml-queue-list");
const panel = $el("div.ml-queue-panel", {}, [
$el("div.ml-queue-stack", {}, [this.queueHeader, this.queueList])
]);
return panel;
},
updateQueuePanel() {
if (!this.queueList || !this.queueHeader) return;
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
// Update title count
const title = this.queueHeader.querySelector('#queue-title');
if (title) title.textContent = `Queued Selections (${list.length})`;
const toggleBtn = this.queueHeader.querySelector('#queue-toggle');
if (toggleBtn) toggleBtn.textContent = this.queueCollapsed ? 'Expand' : 'Collapse';
if (!list.length) {
this.queueList.innerHTML = '<div class="ml-queue-empty">No selections queued.</div>';
return;
}
let html = '<div class="ml-queue-items">';
for (let i = 0; i < list.length; i++) {
const r = list[i];
const label = (r.resolved_model?.relative_path || r.resolved_model?.filename || r.resolved_path || '').toString();
const nodeLabel = r.node_label || r.node_type || (r.subgraph_id ? 'Subgraph' : 'Node');
const orig = (r.original_path || '').toString();
const rmId = `queue-remove-${i}`;
html += `<div class="ml-queue-item">`;
html += `<div class="ml-queue-item-title">${this.escapeHtml(nodeLabel)} #${this.escapeHtml(String(r.node_id))}</div>`;
html += `<div class="ml-queue-item-meta"><span>Original</span><code>${this.escapeHtml(orig)}</code></div>`;
html += `<div class="ml-queue-item-selection"><span>Selected</span><code>${this.escapeHtml(label)}</code></div>`;
html += `<div class="ml-queue-item-actions"><button id="${rmId}" class="ml-btn ml-btn-secondary ml-btn-sm">Remove</button></div>`;
html += `</div>`;
}
html += '</div>';
this.queueList.innerHTML = html;
// Wire remove buttons
for (let i = 0; i < list.length; i++) {
const rmId = `queue-remove-${i}`;
const btn = this.queueList.querySelector(`#${rmId}`);
if (btn) {
btn.addEventListener('click', () => this.removeQueuedByIndex(i));
}
}
},
// Remove queued by index
removeQueuedByIndex(i) {
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
if (i < 0 || i >= list.length) return;
const r = list[i];
// Remove
this.pendingResolutions.splice(i, 1);
this.rebuildPendingIndex();
this.savePendingQueueForActiveWorkflow();
// Update per-item selected bar
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
this.updateSelectedBarForMissing?.(m);
this.updateApplyPendingButton?.();
this.updateQueuePanel();
},
// Clear all queued selections
clearAllQueued() {
this.pendingResolutions = [];
this.pendingIndex = new Map();
this.savePendingQueueForActiveWorkflow();
this.updateApplyPendingButton?.();
this.updateQueuePanel();
try {
document.querySelectorAll('.model-linker-selected').forEach(el => { el.style.display = 'none'; el.innerHTML = ''; });
} catch (e) { /* ignore */ }
},
// Update selected bar for a specific missing model slot
updateSelectedBarForMissing(missing) {
if (!missing) return;
const nodeId = missing.node_id;
const widgetIndex = missing.widget_index;
const subgraphId = missing.subgraph_id || '';
const isTopLevel = missing.is_top_level !== false;
const key = `${nodeId}:${widgetIndex}:${subgraphId}:${isTopLevel ? 'T' : 'F'}`;
const selectedBar = document.getElementById(`selected-bar-${nodeId}-${widgetIndex}`);
if (!selectedBar) return;
// Find selection for this slot
let selection = null;
let selectionIdx = -1;
if (this.pendingIndex.has(key)) {
const idx = this.pendingIndex.get(key);
if (idx >= 0 && idx < this.pendingResolutions.length) {
selection = this.pendingResolutions[idx];
selectionIdx = idx;
}
}
if (!selection) {
selectedBar.style.display = 'none';
selectedBar.innerHTML = '';
return;
}
// Build selected bar content
const label = selection.resolved_model?.relative_path || selection.resolved_model?.filename || selection.resolved_path || '';
const resolveBtnId = `selected-remove-${nodeId}-${widgetIndex}`;
selectedBar.innerHTML = `<div class="ml-selected-bar-inner">`;
selectedBar.innerHTML += `<span class="ml-selected-label">✓ Selected:</span>`;
selectedBar.innerHTML += `<code class="ml-selected-code">${label}</code>`;
selectedBar.innerHTML += `<button id="${resolveBtnId}" class="ml-btn ml-btn-secondary ml-btn-sm">Remove</button>`;
selectedBar.innerHTML += `</div>`;
selectedBar.style.display = 'block';
// Wire remove button - use key-based removal
const removeBtn = selectedBar.querySelector(`#${resolveBtnId}`);
if (removeBtn) {
removeBtn.addEventListener('click', () => {
this.removeQueuedByKey(key);
});
}
},
// Remove queued by key (more reliable than index)
removeQueuedByKey(key) {
if (!key || !this.pendingIndex.has(key)) return;
// Find and remove the item with this key
const idx = this.pendingIndex.get(key);
if (idx >= 0 && idx < this.pendingResolutions.length) {
const r = this.pendingResolutions[idx];
// Update the selected bar before removing
const m = { node_id: r.node_id, widget_index: r.widget_index, subgraph_id: r.subgraph_id, is_top_level: r.is_top_level };
// Remove from array
this.pendingResolutions.splice(idx, 1);
this.rebuildPendingIndex();
this.savePendingQueueForActiveWorkflow();
this.updateSelectedBarForMissing(m);
this.updateApplyPendingButton?.();
this.updateQueuePanel();
}
},
// Rebuild pending index after modification
rebuildPendingIndex() {
this.pendingIndex = new Map();
for (let i = 0; i < this.pendingResolutions.length; i++) {
const r = this.pendingResolutions[i];
const key = `${r.node_id}:${r.widget_index}:${r.subgraph_id || ''}:${r.is_top_level ? 'T' : 'F'}`;
this.pendingIndex.set(key, i);
}
},
// Collapse/expand queue panel
toggleQueueCollapsed() {
this.setQueueCollapsed(!this.queueCollapsed);
},
setQueueCollapsed(collapsed) {
this.queueCollapsed = !!collapsed;
if (this.queueCollapsed) {
try { localStorage.setItem('model_linker_queue_collapsed', '1'); } catch (e) { }
} else {
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { }
}
this.updateQueueVisibility();
this.updateQueuePanel();
},
updateQueueVisibility() {
const isMissingTab = this.activeTab === 'missing';
const showQueuePanel = isMissingTab && !this.queueCollapsed;
if (this.queueElement) {
this.queueElement.style.display = showQueuePanel ? '' : 'none';
}
if (this.splitterElement) {
this.splitterElement.style.display = showQueuePanel ? '' : 'none';
}
if (this.queueToggleIcon) {
this.queueToggleIcon.style.display = isMissingTab ? '' : 'none';
}
this.updateQueueToggleIcon();
},
updateQueueToggleIcon() {
if (!this.queueToggleIcon) return;
this.queueToggleIcon.style.display = this.activeTab === 'missing' ? '' : 'none';
this.queueToggleIcon.textContent = '';
this.queueToggleIcon.removeAttribute('data-tooltip');
this.queueToggleIcon.removeAttribute('title');
if (this.queueCollapsed) {
this.queueToggleIcon.classList.add('is-collapsed');
this.queueToggleIcon.setAttribute('aria-label', 'Show queued selections');
} else {
this.queueToggleIcon.classList.remove('is-collapsed');
this.queueToggleIcon.setAttribute('aria-label', 'Hide queued selections');
}
},
onQueueEdgeClick(event) {
if (this._suppressQueueEdgeClick) {
event?.preventDefault?.();
event?.stopPropagation?.();
this._suppressQueueEdgeClick = false;
return;
}
this.toggleQueueCollapsed();
},
startQueueEdgeDrag(event) {
if (event.button !== 0 || !this.queueCollapsed || !this.queueElement) return;
const startX = event.clientX;
const previousCursor = document.body.style.cursor;
event.preventDefault();
document.body.style.cursor = 'col-resize';
this._suppressQueueEdgeClick = true;
this.splitterElement.style.display = '';
document.getElementById('model-linker-body')?.classList.add('ml-queue-preview-collapsed');
this.queueElement.style.display = 'none';
this.queueElement.style.width = '0px';
this.startSplitDrag(
{ clientX: startX, preventDefault: () => {} },
{ startWidth: 0, edgeOpen: true, previousCursor, startCollapsedPreview: true }
);
},
// Begin split drag for resizable panels
startSplitDrag(e, { startWidth = null, edgeOpen = false, previousCursor = null, startCollapsedPreview = false } = {}) {
try {
e?.preventDefault?.();
if (!this.queueElement) return;
const rect = this.queueElement.getBoundingClientRect();
const body = document.getElementById('model-linker-body');
const bodyRect = body ? body.getBoundingClientRect() : { width: window.innerWidth };
this._splitDragging = true;
body?.classList.add('ml-is-resizing-queue');
this._splitStart = {
x: e.clientX,
startWidth: Number.isFinite(startWidth) ? startWidth : rect.width,
containerWidth: bodyRect.width
};
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = !!startCollapsedPreview;
this._splitEdgeOpen = !!edgeOpen;
this._splitEdgeOpenedPastThreshold = false;
this._splitPreviousCursor = previousCursor;
this._prevUserSelect = document.body.style.userSelect;
document.body.style.userSelect = 'none';
this._onSplitMove = (ev) => this.onSplitDrag(ev);
this._onSplitUp = () => this.endSplitDrag();
document.addEventListener('mousemove', this._onSplitMove);
document.addEventListener('mouseup', this._onSplitUp, { once: true });
} catch (err) { /* ignore */ }
},
onSplitDrag(e) {
if (!this._splitDragging || !this._splitStart || !this.queueElement) return;
const dx = e.clientX - this._splitStart.x;
let newW = this._splitStart.startWidth - dx;
const minW = this._splitEdgeOpen ? 0 : 56;
const maxW = Math.max(minW, Math.floor(this._splitStart.containerWidth - 360));
if (newW < minW) newW = minW;
if (newW > maxW) newW = maxW;
this._pendingSplitWidth = Math.round(newW);
if (this._splitEdgeOpen && this._pendingSplitWidth > 140) {
this._splitEdgeOpenedPastThreshold = true;
if (this.queueCollapsed) {
this.queueCollapsed = false;
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (error) { }
this.updateQueueToggleIcon();
this.updateQueuePanel();
}
}
const previewCollapsed = this._splitEdgeOpen
? (!this._splitEdgeOpenedPastThreshold || this._pendingSplitWidth <= 140)
: this._pendingSplitWidth <= 140;
if (previewCollapsed !== this._splitPreviewCollapsed) {
this._splitPreviewCollapsed = previewCollapsed;
const body = document.getElementById('model-linker-body');
body?.classList.toggle('ml-queue-preview-collapsed', previewCollapsed);
this.queueElement.style.display = previewCollapsed ? 'none' : '';
}
const now = performance.now();
if (this._lastSplitDragApply && now - this._lastSplitDragApply < 33) return;
this._lastSplitDragApply = now;
if (this._splitDragFrame) return;
this._splitDragFrame = requestAnimationFrame(() => {
this._splitDragFrame = null;
if (!this._splitDragging || !this.queueElement || !this._pendingSplitWidth || this._splitPreviewCollapsed) return;
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
});
},
endSplitDrag() {
if (!this._splitDragging) return;
this._splitDragging = false;
if (this._splitDragFrame) {
cancelAnimationFrame(this._splitDragFrame);
this._splitDragFrame = null;
}
if (this.queueElement && this._pendingSplitWidth && !this._splitPreviewCollapsed) {
this.queueElement.style.width = `${this._pendingSplitWidth}px`;
}
const body = document.getElementById('model-linker-body');
body?.classList.remove('ml-is-resizing-queue');
body?.classList.remove('ml-queue-preview-collapsed');
document.removeEventListener('mousemove', this._onSplitMove);
if (this._splitEdgeOpen && !this._pendingSplitWidth) {
try {
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
this.queueElement.style.width = `${restoreWidth}px`;
this.queueElement.style.display = '';
if (this.splitterElement) this.splitterElement.style.display = '';
localStorage.setItem('model_linker_split_w', String(restoreWidth));
} catch (e) { }
this.queueCollapsed = false;
try { localStorage.setItem('model_linker_queue_collapsed', '0'); } catch (e) { }
this.updateQueueVisibility();
this.updateQueuePanel();
this._splitEdgeOpen = false;
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
this._splitEdgeOpenedPastThreshold = false;
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
return;
}
const shouldCollapse = !!this._splitPreviewCollapsed;
if (shouldCollapse) {
try {
const restoreWidth = Math.max(240, Math.round(this._splitStart?.startWidth || 320));
this.queueElement.style.width = `${restoreWidth}px`;
this.queueElement.style.display = '';
localStorage.setItem('model_linker_split_w', String(restoreWidth));
} catch (e) { }
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
this._splitEdgeOpen = false;
this._splitEdgeOpenedPastThreshold = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
this.setQueueCollapsed(true);
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
return;
}
if (this.queueElement) this.queueElement.style.display = '';
try {
const rect = this.queueElement.getBoundingClientRect();
localStorage.setItem('model_linker_split_w', String(Math.round(rect.width)));
} catch (e) { }
this._pendingSplitWidth = null;
this._lastSplitDragApply = 0;
this._splitPreviewCollapsed = false;
this._splitEdgeOpen = false;
this._splitEdgeOpenedPastThreshold = false;
try { document.body.style.userSelect = this._prevUserSelect || ''; } catch (e) { }
if (this._splitPreviousCursor !== null) {
try { document.body.style.cursor = this._splitPreviousCursor || ''; } catch (e) { }
this._splitPreviousCursor = null;
}
setTimeout(() => { this._suppressQueueEdgeClick = false; }, 0);
},
/**
* Queue a resolution for later batch apply
*/
queueResolution(missing, resolvedModel) {
if (!resolvedModel) {
this.showNotification('No model selected', 'error');
return;
}
const resolution = {
node_id: missing.node_id,
widget_index: missing.widget_index,
resolved_path: resolvedModel.path,
category: missing.category,
resolved_model: resolvedModel,
original_path: missing.original_path,
subgraph_id: missing.subgraph_id,
is_top_level: missing.is_top_level,
node_type: missing.node_type,
node_label: missing.subgraph_name || missing.node_type
};
const key = `${resolution.node_id}:${resolution.widget_index}:${resolution.subgraph_id || ''}:${resolution.is_top_level ? 'T' : 'F'}`;
if (this.pendingIndex.has(key)) {
// replace existing selection for this slot
const idx = this.pendingIndex.get(key);
this.pendingResolutions[idx] = resolution;
} else {
this.pendingIndex.set(key, this.pendingResolutions.length);
this.pendingResolutions.push(resolution);
}
this.savePendingQueueForActiveWorkflow();
// Update selected bar UI
this.updateSelectedBarForMissing?.(missing);
this.updateQueuePanel();
this.updateApplyPendingButton();
},
/**
* Apply all pending resolutions in batch
*/
async applyPendingResolutions() {
const list = this.pendingResolutions || [];
if (!list.length) {
this.showNotification('No selections queued', 'error');
return;
}
try {
const workflow = this.getCurrentWorkflow();
if (!workflow) {
this.showNotification('No workflow loaded', 'error');
return;
}
const response = await api.fetchApi('/model_linker/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow, resolutions: list })
});
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
if (data.success) {
await this.updateWorkflowInComfyUI(data.workflow);
this.showNotification(`✓ Linked ${list.length} selection${list.length>1?'s':''}`, 'success');
// Clear queue and refresh analysis
this.pendingResolutions = [];
this.pendingIndex = new Map();
this.savePendingQueueForActiveWorkflow();
this.updateApplyPendingButton();
this.updateQueuePanel();
await this.loadWorkflowData(data.workflow);
} else {
this.showNotification('Failed to apply selections: ' + (data.error || 'Unknown error'), 'error');
}
} catch (e) {
console.error('Model Linker: applyPendingResolutions error', e);
this.showNotification('Error applying selections: ' + e.message, 'error');
}
},
updateApplyPendingButton() {
if (!this.applyPendingBtn) return;
const count = this.pendingResolutions?.length || 0;
const isEmpty = count === 0;
const tooltip = count > 0
? `Apply ${count} queued model link${count > 1 ? 's' : ''} to the current workflow.`
: 'Apply the model links you selected from local matches or search results.';
this.applyPendingBtn.textContent = `Apply Selected (${count})`;
this.setTooltip(this.applyPendingBtn, tooltip);
this.applyPendingBtn.setAttribute('aria-disabled', String(isEmpty));
this.applyPendingBtn.classList.toggle('ml-btn-is-disabled', isEmpty);
this.updateBatchFooterButtons();
},
updateBatchFooterButtons() {
const missingModels = this.missingModels || [];
const selectedCount = this.getSelectedMissingModels().length;
const totalCount = missingModels.length;
const pendingCount = this.pendingResolutions?.length || 0;
const activeCount = Object.keys(this.activeDownloads || {}).length;
const downloadableSelected = this.getMissingWithDownloadSources(this.getSelectedMissingModels()).length;
const downloadableAll = this.getMissingWithDownloadSources(missingModels).length;
if (this.selectMenuButton) {
this.selectMenuButton.textContent = `Select (${selectedCount}/${totalCount})`;
this.selectMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0);
}
if (this.searchMenuButton) {
this.searchMenuButton.textContent = this.batchSearchRunning ? 'Searching...' : 'Search';
this.searchMenuButton.classList.toggle('ml-btn-is-disabled', totalCount === 0 || this.batchSearchRunning);
}
if (this.downloadMenuButton) {
const label = activeCount > 0 ? `Cancel Downloads (${activeCount})` : 'Download';
this.downloadMenuButton.textContent = label;
this.downloadMenuButton.classList.toggle('ml-btn-danger', activeCount > 0);
this.downloadMenuButton.classList.toggle('ml-btn-download', activeCount === 0);
this.downloadMenuButton.classList.toggle('ml-btn-is-disabled', activeCount === 0 && downloadableAll === 0);
this.setTooltip(
this.downloadMenuButton,
activeCount > 0
? `Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.`
: `${downloadableSelected || downloadableAll} missing model${(downloadableSelected || downloadableAll) === 1 ? '' : 's'} currently have download sources.`
);
}
if (this.applyPendingBtn) {
this.applyPendingBtn.textContent = `Apply Selected (${pendingCount})`;
}
},
createFooterMenu(name, label, items = [], buttonClass = 'ml-btn-secondary') {
const button = $el(`button.ml-btn.${buttonClass}.ml-footer-btn.ml-footer-menu-button`, {
type: 'button',
'aria-haspopup': 'menu',
'aria-expanded': 'false',
onclick: (event) => {
event.stopPropagation();
if (button.classList.contains('ml-btn-is-disabled')) {
if (name === 'search' && this.batchSearchRunning) {
this.showNotification('Batch search is already running.', 'info');
} else if (name === 'download') {
this.showNotification('No missing models have downloadable sources yet.', 'info');
} else {
this.showNotification('No missing models available.', 'info');
}
return;
}
this.toggleFooterMenu(name);
}
}, [$el("span", { textContent: label })]);
const menu = $el("div.ml-footer-menu", { role: 'menu' }, items.map(item => (
item === 'divider'
? $el("div.ml-footer-menu-divider", {})
: $el("button.ml-footer-menu-item", {
type: 'button',
role: 'menuitem',
onclick: (event) => {
event.stopPropagation();
item.action();
}
}, [$el("span", { textContent: item.label })])
)));
this.footerMenuButtons.set(name, button);
this.footerMenus.set(name, menu);
return $el("div.ml-footer-menu-wrap", {}, [button, menu]);
},
createFooter() {
this.footerMenus = new Map();
this.footerMenuButtons = new Map();
const selectMenu = this.createFooterMenu('select', 'Select (0/0)', [
{ label: 'Select All', action: () => this.selectBatchMissingModels('all') },
{ label: 'Select None', action: () => this.selectBatchMissingModels('none') },
{ label: 'Invert Selection', action: () => this.selectBatchMissingModels('invert') },
'divider',
{ label: 'Select Exact Local', action: () => this.selectBatchMissingModels('exact') },
{ label: 'Select No Exact Local', action: () => this.selectBatchMissingModels('no_exact') },
{ label: 'Select Partial Local', action: () => this.selectBatchMissingModels('partial') },
{ label: 'Select No Local Match', action: () => this.selectBatchMissingModels('no_local') },
'divider',
{ label: 'Select With Download Source', action: () => this.selectBatchMissingModels('downloadable') },
{ label: 'Select Without Download Source', action: () => this.selectBatchMissingModels('no_download') },
'divider',
{ label: 'Select Searched', action: () => this.selectBatchMissingModels('searched') },
{ label: 'Select Unsearched', action: () => this.selectBatchMissingModels('unsearched') }
]);
this.selectMenuButton = this.footerMenuButtons.get('select');
this.selectMenuWrap = selectMenu;
const searchMenu = this.createFooterMenu('search', 'Search', [
{ label: 'Search Selected', action: () => this.searchMissingBatch('selected', 'all') },
{ label: 'Search All Missing', action: () => this.searchMissingBatch('all', 'all') },
{ label: 'Search Unsearched', action: () => this.searchMissingBatch('unsearched', 'all') },
'divider',
{ label: 'Selected: CivitAI', action: () => this.searchMissingBatch('selected', 'civitai') },
{ label: 'Selected: HuggingFace', action: () => this.searchMissingBatch('selected', 'huggingface') }
]);
this.searchMenuButton = this.footerMenuButtons.get('search');
this.searchMenuWrap = searchMenu;
// Store reference to download all button so we can update its text
this.downloadAllButton = $el("button.ml-btn.ml-btn-download.ml-footer-btn", {
"data-tooltip": "Download every missing model that has a known download source.",
"aria-label": "Download all missing models",
onclick: () => this.handleDownloadAllClick()
}, [
$el("span.ml-btn-icon", { textContent: "☁" }),
$el("span", { textContent: " Download All Missing" })
]);
// Auto-resolve button (secondary style)
this.autoResolveButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", {
"data-tooltip": "Automatically link all missing models with a 100% local match.",
"aria-label": "Auto-link all 100 percent local matches",
onclick: () => this.autoResolve100Percent()
}, [
$el("span.ml-btn-icon", { textContent: "🔗" }),
$el("span", { textContent: " Auto-Link 100%" })
]);
this.queueExactButton = $el("button.ml-btn.ml-btn-secondary.ml-footer-btn", {
type: 'button',
onclick: () => this.queueExactLocalMatchesBatch('selected')
}, [
$el("span", { textContent: "Queue Exact" })
]);
// Apply pending resolutions button
this.applyPendingBtn = $el("button.ml-btn.ml-btn-primary.ml-footer-btn", {
id: "apply-pending-resolutions",
"data-tooltip": "Apply the model links you selected from local matches or search results.",
"aria-label": "Apply selected model links",
"aria-disabled": "true",
textContent: "Apply Selected (0)",
onclick: () => {
if ((this.pendingResolutions?.length || 0) === 0) return;
this.applyPendingResolutions();
}
});
this.applyPendingBtn.classList.add('ml-btn-is-disabled');
this.downloadAllButton.setAttribute('aria-label', 'Download all missing models');
this.autoResolveButton.setAttribute('aria-label', 'Auto-link all 100 percent local matches');
this.queueExactButton.setAttribute('aria-label', 'Queue exact local matches');
this.applyPendingBtn.setAttribute('aria-label', 'Apply selected model links');
this.applyPendingBtn.setAttribute('aria-disabled', 'true');
this.setTooltip(this.downloadAllButton, 'Download every missing model that has a known download source.');
this.setTooltip(this.autoResolveButton, 'Automatically link all missing models with a 100% local match.');
this.setTooltip(this.queueExactButton, 'Queue exact local matches for the selected rows, or all exact matches if nothing is selected.');
this.setTooltip(this.applyPendingBtn, 'Apply the model links you selected from local matches or search results.');
const downloadMenu = this.createFooterMenu('download', 'Download', [
{ label: 'Download Selected', action: () => this.downloadMissingBatch('selected') },
{ label: 'Download All With Sources', action: () => this.downloadMissingBatch('all') },
'divider',
{ label: 'Cancel Downloads', action: () => { this.closeFooterMenus(); this.cancelAllDownloads(); } }
], 'ml-btn-download');
this.downloadMenuButton = this.footerMenuButtons.get('download');
this.downloadMenuWrap = downloadMenu;
this.downloadAllButton = this.downloadMenuButton;
this.downloadAllButton.setAttribute('aria-label', 'Download missing models');
this.setTooltip(this.downloadAllButton, 'Download selected models or all missing models with known download sources.');
return $el("div.ml-footer", {}, [
selectMenu,
searchMenu,
this.queueExactButton,
this.applyPendingBtn,
this.autoResolveButton,
downloadMenu
]);
},
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;
if (this.downloadMenuButton && this.downloadAllButton === this.downloadMenuButton) {
this.updateBatchFooterButtons();
return;
}
const activeCount = Object.keys(this.activeDownloads).length;
if (activeCount > 0) {
this.downloadAllButton.innerHTML = `<span class="ml-btn-icon">✕</span> Cancel All (${activeCount})`;
this.downloadAllButton.setAttribute(
'data-tooltip',
`Cancel ${activeCount} active download${activeCount > 1 ? 's' : ''}.`
);
this.downloadAllButton.setAttribute('aria-label', 'Cancel all active downloads');
this.downloadAllButton.classList.remove('ml-btn-download');
this.downloadAllButton.classList.add('ml-btn-danger');
} else {
this.downloadAllButton.innerHTML = `<span class="ml-btn-icon">☁</span> Download All Missing`;
this.downloadAllButton.setAttribute(
'data-tooltip',
'Download every missing model that has a known download source.'
);
this.downloadAllButton.setAttribute('aria-label', 'Download all missing models');
this.downloadAllButton.classList.remove('ml-btn-danger');
this.downloadAllButton.classList.add('ml-btn-download');
}
}
};
+179
View File
@@ -0,0 +1,179 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const renderFormatMethods = {
/**
* 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 `<span class="ml-badge ${badgeClass}">${confidence}%</span>`;
},
getStatusBadge(label, variant = 'neutral') {
return `<span class="ml-badge ml-badge-${variant}">${label}</span>`;
},
/**
* 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 `
<div class="ml-status ml-status-${type}">
<span class="ml-status-icon">${icon}</span>
<span>${message}</span>
</div>
`;
},
/**
* 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 `
<div class="ml-progress-container">
<div class="ml-progress-bar">
<div class="ml-progress-fill" style="width: ${percent}%"></div>
</div>
<div class="ml-progress-text">
<span>${leftText}</span>
<span>${rightText}</span>
</div>
</div>
`;
},
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 `
<div class="ml-download-section">
<div class="ml-status-inline">
${this.getStatusBadge('Analyzing', 'info')}
<span class="ml-download-info">${message}</span>
</div>
${this.renderProgressBar(percent, detail, `${percent}%`)}
${modelName ? `<div class="ml-download-info">${modelName}</div>` : ''}
</div>
`;
},
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));
}
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+220
View File
@@ -0,0 +1,220 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const selectionMethods = {
/**
* 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();
}
},
handleFooterMenuOutsideClick(e) {
if (!this.activeFooterMenu) return;
if (e.target?.closest?.('.ml-footer-menu-wrap')) return;
this.closeFooterMenus();
},
closeFooterMenus() {
this.activeFooterMenu = null;
this.footerMenus?.forEach?.((menu, name) => {
menu.classList.remove('is-open');
const button = this.footerMenuButtons?.get?.(name);
button?.setAttribute('aria-expanded', 'false');
});
},
setMissingFooterControlsVisible(visible) {
const display = visible ? 'inline-flex' : 'none';
[
this.selectMenuWrap,
this.searchMenuWrap,
this.queueExactButton,
this.applyPendingBtn,
this.autoResolveButton,
this.downloadMenuWrap
].forEach(element => {
if (element) element.style.display = display;
});
if (!visible) {
this.closeFooterMenus();
}
},
toggleFooterMenu(name) {
const menu = this.footerMenus?.get?.(name);
const button = this.footerMenuButtons?.get?.(name);
if (!menu || !button) return;
const shouldOpen = this.activeFooterMenu !== name;
this.closeFooterMenus();
if (shouldOpen) {
this.activeFooterMenu = name;
menu.classList.add('is-open');
button.setAttribute('aria-expanded', 'true');
}
},
syncBatchSelectionForMissingModels(missingModels = this.missingModels || []) {
if (!(this.batchSelectedMissingKeys instanceof Set)) {
this.batchSelectedMissingKeys = new Set();
}
const validKeys = new Set(missingModels.map(missing => this.getMissingModelKey(missing)));
for (const key of Array.from(this.batchSelectedMissingKeys)) {
if (!validKeys.has(key)) {
this.batchSelectedMissingKeys.delete(key);
}
}
if (this.lastBatchSelectedMissingKey && !validKeys.has(this.lastBatchSelectedMissingKey)) {
this.lastBatchSelectedMissingKey = null;
}
},
getSelectedMissingModels() {
const selectedKeys = this.batchSelectedMissingKeys || new Set();
return (this.missingModels || []).filter(missing => selectedKeys.has(this.getMissingModelKey(missing)));
},
getMissingWithExactLocalMatches(missingModels = this.missingModels || []) {
return missingModels.filter(missing => this.getBestLocalMatch(missing, 100));
},
getMissingWithoutExactLocalMatches(missingModels = this.missingModels || []) {
return missingModels.filter(missing => !this.getBestLocalMatch(missing, 100));
},
getMissingWithPartialLocalMatches(missingModels = this.missingModels || []) {
return missingModels.filter(missing => {
const bestMatch = this.getBestLocalMatch(missing, 70);
return bestMatch && Number(bestMatch.confidence || 0) < 100;
});
},
getMissingWithoutLocalMatches(missingModels = this.missingModels || []) {
return missingModels.filter(missing => !this.getBestLocalMatch(missing, 70));
},
getMissingWithDownloadSources(missingModels = this.missingModels || []) {
return missingModels.filter(missing => this.getBestDownloadSourceForMissing(missing));
},
getMissingWithoutDownloadSources(missingModels = this.missingModels || []) {
return missingModels.filter(missing => !this.getBestDownloadSourceForMissing(missing));
},
getSearchedMissingModels(missingModels = this.missingModels || []) {
return missingModels.filter(missing => this.hasRenderableSearchState(this.getSearchState(missing)));
},
getUnsearchedMissingModels(missingModels = this.missingModels || []) {
return missingModels.filter(missing => !this.hasRenderableSearchState(this.getSearchState(missing)));
},
selectBatchMissingModels(mode) {
const missingModels = this.missingModels || [];
if (!missingModels.length) {
this.showNotification('No missing models to select.', 'info');
return;
}
if (!(this.batchSelectedMissingKeys instanceof Set)) {
this.batchSelectedMissingKeys = new Set();
}
if (mode === 'none') {
this.batchSelectedMissingKeys.clear();
} else if (mode === 'invert') {
const nextSelection = new Set();
for (const missing of missingModels) {
const key = this.getMissingModelKey(missing);
if (!this.batchSelectedMissingKeys.has(key)) {
nextSelection.add(key);
}
}
this.batchSelectedMissingKeys = nextSelection;
} else {
let models = missingModels;
if (mode === 'exact') {
models = this.getMissingWithExactLocalMatches(missingModels);
} else if (mode === 'no_exact') {
models = this.getMissingWithoutExactLocalMatches(missingModels);
} else if (mode === 'partial') {
models = this.getMissingWithPartialLocalMatches(missingModels);
} else if (mode === 'no_local') {
models = this.getMissingWithoutLocalMatches(missingModels);
} else if (mode === 'downloadable') {
models = this.getMissingWithDownloadSources(missingModels);
} else if (mode === 'no_download') {
models = this.getMissingWithoutDownloadSources(missingModels);
} else if (mode === 'searched') {
models = this.getSearchedMissingModels(missingModels);
} else if (mode === 'unsearched') {
models = this.getUnsearchedMissingModels(missingModels);
}
this.batchSelectedMissingKeys = new Set(models.map(missing => this.getMissingModelKey(missing)));
}
this.refreshBatchSelectionUi();
this.updateBatchFooterButtons();
this.closeFooterMenus();
},
refreshBatchSelectionUi() {
const selectedKeys = this.batchSelectedMissingKeys || new Set();
this.contentElement?.querySelectorAll?.('.ml-missing-list-row')?.forEach(row => {
const key = row.getAttribute('data-missing-key');
const selected = selectedKeys.has(key);
row.classList.toggle('is-batch-selected', selected);
const checkbox = row.querySelector('.ml-missing-row-check');
if (checkbox) {
checkbox.checked = selected;
}
});
this.updateBatchSelectAllCheckbox();
},
updateBatchSelectAllCheckbox() {
const checkbox = this.contentElement?.querySelector?.('.ml-missing-select-all-check');
if (!checkbox) return;
const totalCount = (this.missingModels || []).length;
const selectedCount = this.getSelectedMissingModels().length;
checkbox.checked = totalCount > 0 && selectedCount === totalCount;
checkbox.indeterminate = selectedCount > 0 && selectedCount < totalCount;
checkbox.disabled = totalCount === 0;
},
setBatchSelectionForKey(key, selected) {
if (!key) return;
if (!(this.batchSelectedMissingKeys instanceof Set)) {
this.batchSelectedMissingKeys = new Set();
}
if (selected) {
this.batchSelectedMissingKeys.add(key);
} else {
this.batchSelectedMissingKeys.delete(key);
}
},
applyBatchSelectionRange(sortedMissingModels, fromKey, toKey, selected) {
const models = sortedMissingModels || this.missingModels || [];
const fromIndex = models.findIndex(missing => this.getMissingModelKey(missing) === fromKey);
const toIndex = models.findIndex(missing => this.getMissingModelKey(missing) === toKey);
if (fromIndex < 0 || toIndex < 0) {
this.setBatchSelectionForKey(toKey, selected);
return;
}
const start = Math.min(fromIndex, toIndex);
const end = Math.max(fromIndex, toIndex);
for (let index = start; index <= end; index += 1) {
this.setBatchSelectionForKey(this.getMissingModelKey(models[index]), selected);
}
}
};
+283
View File
@@ -0,0 +1,283 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const tabsLoadedMethods = {
getTabButton(tab) {
return {
missing: this.missingTab,
loaded: this.loadedTab,
options: this.optionsTab
}[tab] || null;
},
updateTabButtonStates() {
['missing', 'loaded', 'options'].forEach((tab) => {
const button = this.getTabButton(tab);
if (!button) return;
const isActive = tab === this.activeTab;
button.classList.toggle('ml-tab-active', isActive);
button.setAttribute('aria-selected', isActive ? 'true' : 'false');
button.setAttribute('aria-disabled', isActive ? 'true' : 'false');
if (isActive) {
button.setAttribute('aria-current', 'page');
} else {
button.removeAttribute('aria-current');
}
});
},
switchTab(tab, { force = false } = {}) {
const nextTab = this.getValidTab(tab);
const nextTabButton = this.getTabButton(nextTab);
if (!force && nextTab === this.activeTab && nextTabButton?.classList.contains('ml-tab-active')) {
this.hideTooltip();
this.updateQueueVisibility();
return;
}
this.activeTab = nextTab;
this.persistActiveTab(this.activeTab);
this.hideTooltip();
this.animateTabContentTransition();
this.updateTabButtonStates();
this.updateQueueVisibility();
if (this.activeTab === 'missing') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(true);
this.loadWorkflowData();
} else if (this.activeTab === 'loaded') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(false);
this.loadLoadedModels();
} else {
if (this.contentElement) {
this.contentElement.style.overflowY = 'hidden';
}
this.setMissingFooterControlsVisible(false);
this.displayOptions();
}
},
async loadLoadedModels(workflow = null) {
if (!this.contentElement) return;
this.contentElement.innerHTML = '<p>Loading loaded models...</p>';
try {
workflow = workflow || this.getCurrentWorkflow();
if (!workflow) {
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
return;
}
this.syncWorkflowScopedQueue(workflow);
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 = `<p class="ml-error-text">Error: ${error.message}</p>`;
}
}
},
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) => {
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 copyIcon = getSvgIcon('copy', 'currentColor', 'ml-copy-btn-icon');
const stripModelExtension = (value) => String(value || 'Unknown').replace(/\.(safetensors|ckpt|pt|pth|bin|pkl|sft|onnx|gguf)$/i, '');
const modelToken = (model, category) => {
const fullName = model.name || model.original_path?.split(/[\/\\]/).pop() || 'Unknown';
const name = stripModelExtension(fullName);
const strength = model.strength !== null && model.strength !== undefined ? model.strength.toFixed(2) : '1.00';
return `<${this.getCategoryTokenName(category)}:${name}:${strength}>`;
};
const copyButtonHtml = (text, extraClass = '') => `
<button class="ml-btn-filter ml-btn-copy-compact ${extraClass}" onclick="window.MLCopy(${this.escapeJsString(text)}, this)">
${copyIcon}<span>Copy</span>
</button>`;
const countText = (count, label) => `${count} ${label}${count === 1 ? '' : 's'}`;
let html = `
<div class="ml-loaded-models-header">
<div class="ml-loaded-title-block">
<h3 class="ml-loaded-models-title">Loaded Models <span class="ml-loaded-total">${total}</span></h3>
<p class="ml-loaded-models-subtitle">${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}</p>
</div>
<div class="ml-loaded-filter-row">
<button class="ml-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all')">All (${activeCount + inactiveCount})</button>
<button class="ml-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active')">Active (${activeCount})</button>
<button class="ml-btn-filter" id="filter-inactive" onclick="window.MLFilterSwitch('inactive')">Inactive (${inactiveCount})</button>
</div>
</div>
<div class="ml-models-list ml-models-list-pad">
`;
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 sectionTotal = modelsObj.active.length + modelsObj.inactive.length;
html += `<div class="ml-model-section" data-ml-filter="all" data-ml-active="${hasActive}" data-ml-inactive="${hasInactive}">`;
html += `<div class="ml-model-section-header">
<div class="ml-model-section-heading">
<span class="ml-model-section-title">${this.escapeHtml(displayName.toUpperCase())}</span>
<span class="ml-model-section-total">${countText(sectionTotal, 'model')}</span>
</div>
<div class="ml-model-section-counts">
${hasActive ? `<span class="ml-model-count-pill is-active">${countText(modelsObj.active.length, 'active')}</span>` : ''}
${hasInactive ? `<span class="ml-model-count-pill is-inactive">${countText(modelsObj.inactive.length, 'inactive')}</span>` : ''}
</div>
</div>`;
if (hasActive) {
const activeStr = modelsObj.active.map(m => modelToken(m, category)).join(' ');
html += `<div class="ml-model-group ml-model-group-active">
<div class="ml-model-group-head">
<span class="ml-model-group-label ml-model-group-label-active"><span class="ml-model-group-dot"></span>Active <span class="ml-model-group-count">${modelsObj.active.length}</span></span>
${copyButtonHtml(activeStr)}
</div>
<div class="ml-model-chip-list">`;
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 += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${this.escapeHtml(name)}${strength !== null ? `<span class="ml-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
if (hasInactive) {
const inactiveStr = modelsObj.inactive.map(m => modelToken(m, category)).join(' ');
html += `<div class="ml-model-group ml-model-group-inactive">
<div class="ml-model-group-head">
<span class="ml-model-group-label ml-model-group-label-inactive"><span class="ml-model-group-dot"></span>Inactive <span class="ml-model-group-count">${modelsObj.inactive.length}</span></span>
${copyButtonHtml(inactiveStr, 'is-muted')}
</div>
<div class="ml-model-chip-list ml-model-chip-list-inactive">`;
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 += `<span class="ml-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${this.escapeHtml(name)}${strength !== null ? `<span class="ml-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
html += `</div>`;
}
const copySectionId = 'ml-copy-' + Date.now();
html += `
</div>
<div id="${copySectionId}" class="ml-copy-section" data-ml-active="${this.escapeHtml(activeString)}" data-ml-inactive="${this.escapeHtml(inactiveString)}" data-ml-all="${this.escapeHtml(allString)}" data-ml-active-count="${activeCount}" data-ml-inactive-count="${inactiveCount}" data-ml-all-count="${activeCount + inactiveCount}">
<div class="ml-copy-toolbar">
<div class="ml-copy-heading">
<div class="ml-copy-label" id="${copySectionId}-label">Copy all</div>
<div class="ml-copy-meta" id="${copySectionId}-meta">${countText(activeCount + inactiveCount, 'token')}</div>
</div>
<div class="ml-copy-actions">
<button type="button" class="ml-btn-filter ml-copy-mode active" data-ml-copy-mode="all" onclick="window.MLSetCopyMode('${copySectionId}', 'all', this)">All</button>
<button type="button" class="ml-btn-filter ml-copy-mode" data-ml-copy-mode="active" onclick="window.MLSetCopyMode('${copySectionId}', 'active', this)">Active</button>
<button type="button" class="ml-btn-filter ml-copy-mode" data-ml-copy-mode="inactive" onclick="window.MLSetCopyMode('${copySectionId}', 'inactive', this)">Inactive</button>
<button class="ml-btn-filter ml-copy-main-btn" onclick="window.MLCopyCode('${copySectionId}', this)">${copyIcon}<span>Copy</span></button>
</div>
</div>
<code class="ml-copy-code" id="${copySectionId}-code">${this.escapeHtml(allString)}</code>
</div>
`;
container.innerHTML = html;
// Store data on container for filter function
container.dataset.mlActiveString = activeString;
container.dataset.mlInactiveString = inactiveString;
container.dataset.mlAllString = allString;
}
};
+256
View File
@@ -0,0 +1,256 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const workflowStateMethods = {
getWorkflowSignature(workflow) {
if (!workflow) return null;
try {
return JSON.stringify(workflow);
} catch (error) {
console.warn('Model Linker: workflow signature generation failed', error);
return null;
}
},
getActiveWorkflowRouteKey() {
try {
return window.location?.hash || '';
} catch (error) {
return '';
}
},
rememberActiveWorkflow(workflow = null) {
const currentWorkflow = workflow || this.getCurrentWorkflow();
this.activeWorkflowRouteKey = this.getActiveWorkflowRouteKey();
this.activeWorkflowSignature = this.getWorkflowSignature(currentWorkflow);
},
getWorkflowScopedQueueKey(route = this.activeWorkflowRouteKey, signature = this.activeWorkflowSignature) {
return route || signature || null;
},
clonePendingResolutions(list = this.pendingResolutions) {
try {
return JSON.parse(JSON.stringify(Array.isArray(list) ? list : []));
} catch (error) {
console.warn('Model Linker: failed to clone queued selections', error);
return Array.isArray(list) ? list.map(item => ({ ...item })) : [];
}
},
savePendingQueueForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
if (!key) return;
const list = Array.isArray(this.pendingResolutions) ? this.pendingResolutions : [];
if (list.length) {
this.workflowPendingSelections.set(key, this.clonePendingResolutions(list));
} else {
this.workflowPendingSelections.delete(key);
}
},
restorePendingQueueForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
const saved = key ? this.workflowPendingSelections.get(key) : null;
this.pendingResolutions = saved ? this.clonePendingResolutions(saved) : [];
this.rebuildPendingIndex();
this.updateApplyPendingButton?.();
this.updateQueuePanel?.();
this.updateQueueVisibility?.();
},
cloneSearchState(state = {}) {
let clone = {};
try {
clone = JSON.parse(JSON.stringify(state || {}));
} catch (error) {
console.warn('Model Linker: failed to clone search state', error);
clone = { ...state };
}
clone.selectedSource = clone.selectedSource || 'all';
clone.results = this.mergeSearchResults({}, clone.results || {});
clone.lastAttemptSources = Array.isArray(clone.lastAttemptSources)
? clone.lastAttemptSources
: [];
clone.lastAttemptFound = clone.lastAttemptFound ?? null;
clone.lastAttemptError = clone.lastAttemptError || null;
clone.sourceProgress = clone.sourceProgress || {};
clone.activeSearchRunId = null;
for (const progress of Object.values(clone.sourceProgress)) {
if (!progress || (progress.status !== 'pending' && progress.status !== 'running')) continue;
progress.status = 'error';
progress.percent = 100;
progress.message = 'Search interrupted';
}
return clone;
},
cloneSearchResultCache(cache = this.searchResultCache) {
const cloned = new Map();
for (const [key, state] of cache.entries()) {
if (!this.isPersistableSearchState(state)) {
continue;
}
cloned.set(key, this.cloneSearchState(state));
}
return cloned;
},
isPersistableSearchState(state = {}) {
if ((state?.selectedSource || 'all') !== 'all') return true;
if (state?.lastAttemptError) return true;
if (this.hasSearchResults(state?.results || {})) return true;
const progressEntries = Object.values(state?.sourceProgress || {});
return progressEntries.some(progress => (
progress?.status && progress.status !== 'pending' && progress.status !== 'running'
));
},
saveSearchCacheForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
if (!key) return;
const cloned = this.cloneSearchResultCache();
if (cloned.size) {
this.workflowSearchResultCaches.set(key, cloned);
} else {
this.workflowSearchResultCaches.delete(key);
}
},
restoreSearchCacheForActiveWorkflow() {
const key = this.getWorkflowScopedQueueKey();
const saved = key ? this.workflowSearchResultCaches.get(key) : null;
this.searchResultCache = saved ? this.cloneSearchResultCache(saved) : new Map();
},
persistSearchStateForActiveWorkflow() {
this.saveSearchCacheForActiveWorkflow();
},
clearAllSearchProgressTimers() {
for (const timer of this.searchProgressTimers.values()) {
clearInterval(timer);
}
this.searchProgressTimers.clear();
},
syncWorkflowScopedQueue(workflow = null) {
const currentWorkflow = workflow || this.getCurrentWorkflow();
const nextRoute = this.getActiveWorkflowRouteKey();
const nextSignature = this.getWorkflowSignature(currentWorkflow);
const previousKey = this.getWorkflowScopedQueueKey();
const nextKey = this.getWorkflowScopedQueueKey(nextRoute, nextSignature);
if (!nextKey || nextKey === previousKey) {
this.activeWorkflowRouteKey = nextRoute;
this.activeWorkflowSignature = nextSignature;
return;
}
this.savePendingQueueForActiveWorkflow();
this.saveSearchCacheForActiveWorkflow();
this.clearWorkflowScopedState();
this.activeWorkflowRouteKey = nextRoute;
this.activeWorkflowSignature = nextSignature;
this.restorePendingQueueForActiveWorkflow();
this.restoreSearchCacheForActiveWorkflow();
},
clearWorkflowScopedState() {
this.cachedWorkflowSignature = null;
this.cachedAnalysisData = null;
for (const state of this.searchResultCache.values()) {
state.activeSearchRunId = null;
}
this.clearAllSearchProgressTimers();
this.searchResultCache.clear();
this.urnResolvePromises.clear();
this.urnLocalMatchPromises.clear();
this.pendingResolutions = [];
this.rebuildPendingIndex();
this.updateApplyPendingButton?.();
this.updateQueuePanel?.();
this.updateQueueVisibility();
},
scheduleActiveWorkflowRefresh(reason = 'workflow-change') {
if (!this.isVisible()) return;
this._workflowRefreshExpectedRoute = this.getActiveWorkflowRouteKey();
this._workflowRefreshPreviousSignature = this.activeWorkflowSignature;
if (this._workflowRefreshTimer) {
clearTimeout(this._workflowRefreshTimer);
}
this._workflowRefreshTimer = setTimeout(() => {
this._workflowRefreshTimer = null;
this.refreshForActiveWorkflowChange({
reason,
expectedRoute: this._workflowRefreshExpectedRoute,
previousSignature: this._workflowRefreshPreviousSignature,
attempt: 0
});
}, 180);
},
async refreshForActiveWorkflowChange({ reason = 'workflow-change', expectedRoute = '', previousSignature = null, attempt = 0 } = {}) {
if (!this.isVisible()) return;
const currentRoute = this.getActiveWorkflowRouteKey();
if (expectedRoute && currentRoute !== expectedRoute) {
this.scheduleActiveWorkflowRefresh(reason);
return;
}
const workflow = this.getCurrentWorkflow();
const signature = this.getWorkflowSignature(workflow);
const routeChanged = currentRoute !== this.activeWorkflowRouteKey;
const signatureChanged = signature && signature !== this.activeWorkflowSignature;
const graphStillLooksOld = routeChanged && previousSignature && signature === previousSignature;
if ((!signature || graphStillLooksOld) && attempt < 8) {
setTimeout(() => {
this.refreshForActiveWorkflowChange({
reason,
expectedRoute,
previousSignature,
attempt: attempt + 1
});
}, 180 + (attempt * 120));
return;
}
if (!routeChanged && !signatureChanged) return;
console.log('Model Linker: active workflow changed, refreshing current tab', {
reason,
route: currentRoute,
tab: this.activeTab
});
this.savePendingQueueForActiveWorkflow();
this.saveSearchCacheForActiveWorkflow();
this.clearWorkflowScopedState();
this.activeWorkflowRouteKey = currentRoute;
this.activeWorkflowSignature = signature;
this.restorePendingQueueForActiveWorkflow();
this.restoreSearchCacheForActiveWorkflow();
if (this.activeTab === 'missing') {
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
await this.loadWorkflowData(workflow);
} else if (this.activeTab === 'loaded') {
if (this.contentElement) this.contentElement.style.overflowY = 'auto';
await this.loadLoadedModels(workflow);
}
}
};
+88
View File
@@ -0,0 +1,88 @@
import { app } from "../../../../scripts/app.js";
import { api } from "../../../../scripts/api.js";
import { $el } from "../../../../scripts/ui.js";
import { getSvgIcon } from "../utils/icon_utils.js";
export const workflowUpdateMethods = {
/**
* Extract model page URL from a download URL
* HuggingFace file: https://huggingface.co/Owner/Repo/resolve/main/file.safetensors -> https://huggingface.co/Owner/Repo/blob/main/file.safetensors
* CivitAI: https://civitai.com/api/download/models/123?type=Model -> https://civitai.com/models/123
*/
getModelCardUrl(downloadUrl) {
if (!downloadUrl) return null;
try {
// HuggingFace URLs
if (downloadUrl.includes('huggingface.co')) {
const fileMatch = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)\/(?:resolve|blob)\/([^\/]+)\/(.+)$/);
if (fileMatch) {
const repo = fileMatch[1];
const revision = fileMatch[2];
const filePath = fileMatch[3].split(/[?#]/)[0];
return `https://huggingface.co/${repo}/blob/${revision}/${filePath}`;
}
const match = downloadUrl.match(/huggingface\.co\/([^\/]+\/[^\/]+)/);
if (match) {
return `https://huggingface.co/${match[1]}`;
}
}
// CivitAI URLs
if (downloadUrl.includes('civitai.com')) {
// Format: /api/download/models/123456 or /models/123456/...
const modelIdMatch = downloadUrl.match(/models\/(\d+)/);
if (modelIdMatch) {
return `https://civitai.com/models/${modelIdMatch[1]}`;
}
}
} catch (e) {
console.error('Error parsing model card URL:', e);
}
return null;
},
/**
* Update workflow in ComfyUI's UI/memory
* Updates the current workflow in place instead of creating a new tab
*/
async updateWorkflowInComfyUI(workflow) {
if (!app || !app.graph) {
console.warn('Model Linker: Could not update workflow - app or app.graph not available');
return;
}
try {
// Method 1: Try to directly update the current graph using configure
// This is the most direct way to update in place
if (app.graph && typeof app.graph.configure === 'function') {
app.graph.configure(workflow);
return;
}
// Method 2: Try deserialize to update the graph in place
if (app.graph && typeof app.graph.deserialize === 'function') {
app.graph.deserialize(workflow);
return;
}
// Method 3: Use loadGraphData with explicit parameters to update current tab
// The key is to NOT create a new workflow - pass null or undefined for the workflow parameter
// clean=false means don't clear the graph first
// restore_view=false means don't restore the viewport
// workflow=null means update current workflow instead of creating new one
if (app.loadGraphData) {
// Try with null as 4th parameter first
await app.loadGraphData(workflow, false, false, null);
return;
}
console.warn('Model Linker: No method available to update workflow');
} catch (error) {
console.error('Model Linker: Error updating workflow in ComfyUI:', error);
// Don't throw - allow the workflow update to continue even if UI update fails
// The backend has already updated the workflow data
}
}
};