Files
Dariusz L 794083b212 Support clipboard pasting in Metadata tab and resolve Civitai model filenames
- Add paste handler (Ctrl+V) in Metadata tab for workflow JSON, images, and Civitai image URLs/HTML
- Resolve authentic .safetensors filenames and local ComfyUI model paths via Civitai modelVersionId
- Normalize prompt <lora:...> tags to authentic filenames and enrich transfer/loaded chip labels
2026-10-16 22:57:04 +02:00

562 lines
26 KiB
JavaScript

import { getSvgIcon } from "../../utils/icon_utils.js";
import {
getCustomNodeModelEntries,
getCustomNodeOriginalIdentity,
} from "../custom_nodes/registry.js";
import { normalizePathIdentity } from "../utils/html_utils.js";
import {
buildLoadedModelTokenStrings,
groupLoadedModelsByCategory,
} from "../utils/loaded_model_utils.js";
import {
initLoadedModelSelection,
updateLoadedModelSelectionState,
} from "../utils/loaded_model_selection.js";
export const tabsLoadedMethods = {
getTabButton(tab) {
return {
missing: this.missingTab,
loaded: this.loadedTab,
metadata: this.metadataTab,
options: this.optionsTab
}[tab] || null;
},
updateTabButtonStates() {
['missing', 'loaded', 'metadata', 'options'].forEach((tab) => {
const button = this.getTabButton(tab);
if (!button) return;
const isActive = tab === this.activeTab;
button.classList.toggle('mr-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 (
this.activeTab === 'options'
&& nextTab !== 'options'
&& !this.confirmDiscardOptionsChanges()
) {
return null;
}
if (!force && nextTab === this.activeTab && nextTabButton?.classList.contains('mr-tab-active')) {
this.hideTooltip();
this.updateQueueVisibility();
return;
}
this.activeTab = nextTab;
this.persistActiveTab(this.activeTab);
if (this.activeTab !== 'missing') {
this._analysisProgressToken = null;
}
if (this.activeTab !== 'loaded') {
this._loadedModelsLoadToken = null;
this._loadedModelsProgressToken = null;
}
if (this.activeTab !== 'loaded' && this.activeTab !== 'metadata') {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
}
if (this.activeTab !== 'metadata') {
if (typeof this._metadataPasteCleanup === 'function') {
this._metadataPasteCleanup();
this._metadataPasteCleanup = null;
}
}
this.hideTooltip();
this.animateTabContentTransition();
this.updateTabButtonStates();
this.updateQueueVisibility();
if (this.activeTab === 'missing') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(true);
return this.loadWorkflowData();
} else if (this.activeTab === 'loaded') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(false);
return this.loadLoadedModels();
} else if (this.activeTab === 'metadata') {
if (this.contentElement) {
this.contentElement.style.overflowY = 'auto';
}
this.setMissingFooterControlsVisible(false);
return this.loadImageMetadata();
} else {
if (this.contentElement) {
this.contentElement.style.overflowY = 'hidden';
}
this.setMissingFooterControlsVisible(false);
this.displayOptions();
}
return null;
},
async loadLoadedModels(
workflow = null,
{ force = false, preserveContent = false } = {}
) {
if (!this.contentElement) return;
this._loadedModelsLoadToken = null;
this._loadedModelsProgressToken = null;
let loadToken = null;
const preservedScrollTop = preserveContent
? this.contentElement.scrollTop
: null;
const shouldRenderLoadedModels = () => (
this.activeTab === 'loaded' &&
loadToken &&
this._loadedModelsLoadToken === loadToken &&
this.contentElement
);
try {
workflow = workflow || this.getCurrentWorkflow();
if (!workflow) {
if (shouldRenderLoadedModels()) {
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
} else if (this.activeTab === 'loaded' && this.contentElement) {
this.contentElement.innerHTML = '<p>No workflow loaded. Please load a workflow first.</p>';
}
return;
}
this.syncWorkflowScopedQueue(workflow);
loadToken = `loaded-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._loadedModelsLoadToken = loadToken;
const workflowSignature = this.getWorkflowSignature(workflow);
const cachedLoadedModels = this.getCachedLoadedModelsForSignature?.(
workflowSignature,
{ force },
);
if (cachedLoadedModels) {
if (shouldRenderLoadedModels()) {
this.displayLoadedModels(this.contentElement, cachedLoadedModels);
}
return;
}
const loadedProgressId = `loaded-progress-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
this._loadedModelsProgressToken = loadedProgressId;
if (!preserveContent && shouldRenderLoadedModels()) {
this.patchLoadedModelsProgress(
this.contentElement,
this.renderLoadedModelsProgress({
status: 'starting',
stage: 'starting',
message: 'Preparing loaded model scan...',
percent: 0,
current: 0,
total: 0
})
);
}
const progressPromise = preserveContent
? Promise.resolve()
: this.pollLoadedModelsProgress(loadedProgressId, loadedProgressId);
let data;
try {
data = await this.fetchJson('/model_resolver/loaded', {
method: 'POST',
body: JSON.stringify({ workflow, loaded_id: loadedProgressId })
}, 'Fetch loaded models');
} finally {
if (this._loadedModelsProgressToken === loadedProgressId) {
this._loadedModelsProgressToken = null;
}
await progressPromise;
}
if (this._loadedModelsLoadToken === loadToken) {
this.cachedLoadedModelsSignature = workflowSignature;
this.cachedLoadedModelsData = data;
this.saveLoadedModelsCacheForActiveWorkflow();
}
if (shouldRenderLoadedModels()) {
this.displayLoadedModels(this.contentElement, data);
if (preservedScrollTop !== null) {
this.contentElement.scrollTop = preservedScrollTop;
}
}
} catch (error) {
console.error('Model Resolver: Error loading loaded models:', error);
if (!preserveContent && shouldRenderLoadedModels()) {
this.contentElement.innerHTML = `<p class="mr-error-text">Error: ${error.message}</p>`;
} else if (
!preserveContent &&
!loadToken &&
this.activeTab === 'loaded' &&
this.contentElement
) {
this.contentElement.innerHTML = `<p class="mr-error-text">Error: ${error.message}</p>`;
}
}
},
getModelNameAndStrength(model) {
const fullName = model.name || this.getFilenameFromPath(model.original_path) || 'Unknown';
const name = this.stripModelExtension(fullName);
const strength = model.strength !== null && model.strength !== undefined
? model.strength.toFixed(2)
: null;
return { name, strength };
},
getModelToken(model, category) {
const { name, strength } = this.getModelNameAndStrength(model);
return `<${this.getCategoryTokenName(category)}:${name}:${strength || '1.00'}>`;
},
getLoadedModelContext(model = {}) {
return {
...model,
context_scope: 'loaded_model'
};
},
getLoadedModelDomKey(model = {}) {
return this.getMissingModelKey(model);
},
updateLoadedModelCopyValues(container, loadedModels = []) {
if (!container) return;
const byCategory = groupLoadedModelsByCategory(loadedModels);
const strings = buildLoadedModelTokenStrings(
byCategory,
(model, category) => this.getModelToken(model, category),
);
container.dataset.mlActiveString = strings.active;
container.dataset.mlInactiveString = strings.inactive;
container.dataset.mlAllString = strings.all;
const copySection = container.querySelector?.('.mr-copy-section');
if (copySection) {
copySection.dataset.mlActive = strings.active;
copySection.dataset.mlInactive = strings.inactive;
copySection.dataset.mlAll = strings.all;
const activeMode = copySection.querySelector?.('.mr-copy-mode.active')
?.dataset?.mlCopyMode || 'all';
const copyCode = copySection.querySelector?.('.mr-copy-code');
if (copyCode) {
if (activeMode === 'selected') {
copyCode.textContent = copySection.dataset.mlSelected || '';
} else {
copyCode.textContent = strings[activeMode] || strings.all;
}
}
}
const sections = container.querySelectorAll?.(
'.mr-model-section[data-ml-category]'
) || [];
for (const section of sections) {
const category = section.dataset?.mlCategory || 'unknown';
const categoryModels = byCategory[category];
if (!categoryModels) continue;
for (const filter of ['active', 'inactive']) {
const button = section.querySelector?.(
`.mr-model-group-${filter} .mr-btn-copy-compact`
);
if (!button) continue;
const text = categoryModels[filter]
.map(model => this.getModelToken(model, category))
.join(' ');
button.setAttribute(
'onclick',
`window.MLCopy(${this.escapeJsString(text)}, this)`
);
}
}
},
updateLoadedModelStrengthsFromNode(node) {
if (
this.activeTab !== 'loaded'
|| !this.contentElement
|| !Array.isArray(this.cachedLoadedModelsData?.loaded_models)
) {
return false;
}
const entries = getCustomNodeModelEntries(node)
.filter(entry => Number.isFinite(entry.strength))
.map(entry => ({
identity: normalizePathIdentity(entry.identity),
strength: entry.strength,
}));
if (!entries.length) return false;
const nodeId = String(node?.id ?? '');
const modelBuckets = new Map();
for (const model of this.cachedLoadedModelsData.loaded_models) {
if (String(model.node_id ?? '') !== nodeId) continue;
const identity = normalizePathIdentity(
getCustomNodeOriginalIdentity(model)
|| model.original_path
|| model.name
|| model.filename
);
if (!identity) continue;
if (!modelBuckets.has(identity)) {
modelBuckets.set(identity, []);
}
modelBuckets.get(identity).push(model);
}
const chips = Array.from(
this.contentElement.querySelectorAll?.('[data-ml-loaded-model-key]') || []
);
let updated = false;
for (const entry of entries) {
const model = modelBuckets.get(entry.identity)?.shift();
if (!model || Number(model.strength) === entry.strength) continue;
model.strength = entry.strength;
const modelKey = this.getLoadedModelDomKey(model);
const chip = chips.find(
candidate => candidate.dataset?.mlLoadedModelKey === modelKey
);
const strengthElement = chip?.querySelector?.('.mr-model-chip-strength');
if (strengthElement) {
strengthElement.textContent = entry.strength.toFixed(2);
}
if (chip && typeof this.getModelToken === 'function') {
chip.dataset.mlToken = this.getModelToken(model, model.category || 'unknown');
}
updated = true;
}
if (!updated) return false;
this.updateLoadedModelCopyValues(
this.contentElement,
this.cachedLoadedModelsData.loaded_models
);
this.updateLoadedModelSelectionState?.(this.contentElement);
const workflow = this.getCurrentWorkflow?.();
const workflowSignature = workflow
? this.getWorkflowSignature?.(workflow)
: '';
if (workflowSignature) {
this.cachedLoadedModelsSignature = workflowSignature;
this.activeWorkflowSignature = workflowSignature;
}
this.saveLoadedModelsCacheForActiveWorkflow?.();
return true;
},
displayLoadedModels(container, data, options = {}) {
const loadedModels = data.loaded_models || [];
const total = data.total || 0;
const title = String(options.title || 'Loaded Models');
const subtitle = options.subtitle ? String(options.subtitle) : '';
const includeContextMenu = options.includeContextMenu !== false;
if (total === 0) {
container.innerHTML = this.renderStatusMessage('No models found in workflow.', 'info');
return;
}
const byCategory = groupLoadedModelsByCategory(loadedModels);
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 {
active: activeString,
inactive: inactiveString,
all: allString,
} = buildLoadedModelTokenStrings(
byCategory,
(model, category) => this.getModelToken(model, category),
{ preserveEmptyCategories: true },
);
const copyIcon = getSvgIcon('copy', 'currentColor', 'mr-copy-btn-icon');
const copyButtonHtml = (text, extraClass = '') => `
<button class="mr-btn-filter mr-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'}`;
const modelContextAttrs = model => includeContextMenu
? this.getContextMenuAttrs(this.getLoadedModelContext(model))
: '';
const modelSubtitle = subtitle || `${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}`;
const isCollapsed = Boolean(options.isCollapsed);
const tooltipText = options.tooltip || (options.collapsible && subtitle ? subtitle : '');
const tooltipHtml = tooltipText
? (typeof this.getMetadataTooltipBadge === 'function'
? this.getMetadataTooltipBadge(tooltipText, `${title} help`)
: `<span class="mr-tooltip-badge" data-tooltip="${this.escapeHtml(tooltipText)}" aria-label="${this.escapeHtml(title)} help">?</span>`)
: '';
const titleHtml = options.collapsible
? `
<div class="mr-section-collapse-header-row">
<button type="button" class="mr-section-collapse-btn" data-image-inspector-collapse="${this.escapeHtml(options.sectionId || 'models')}" aria-expanded="${isCollapsed ? 'false' : 'true'}" aria-label="Toggle ${this.escapeHtml(title)} section">
<span class="mr-section-collapse-chevron" aria-hidden="true">${getSvgIcon('chevronDown')}</span>
<span class="mr-loaded-models-title">${this.escapeHtml(title)} <span class="mr-loaded-total">${total}</span></span>
</button>
${tooltipHtml}
</div>
`
: `<h3 class="mr-loaded-models-title">${this.escapeHtml(title)} <span class="mr-loaded-total">${total}</span>${tooltipHtml}</h3>`;
const showSubtitle = (options.showSubtitle ?? !options.collapsible) && Boolean(modelSubtitle);
const subtitleHtml = showSubtitle
? `<p class="mr-loaded-models-subtitle">${this.escapeHtml(modelSubtitle)}</p>`
: '';
let html = `
<div class="mr-loaded-models-header">
<div class="mr-loaded-title-block">
${titleHtml}
${subtitleHtml}
</div>
<div class="mr-loaded-filter-row">
<button class="mr-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all', this)">All (${activeCount + inactiveCount})</button>
<button class="mr-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active', this)">Active (${activeCount})</button>
<button class="mr-btn-filter" id="filter-inactive" onclick="window.MLFilterSwitch('inactive', this)">Inactive (${inactiveCount})</button>
</div>
</div>
<div class="mr-models-list mr-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="mr-model-section" data-ml-filter="all" data-ml-category="${this.escapeHtml(category)}" data-ml-active="${hasActive}" data-ml-inactive="${hasInactive}">`;
html += `<div class="mr-model-section-header">
<div class="mr-model-section-heading">
<span class="mr-model-section-title">${this.escapeHtml(displayName.toUpperCase())}</span>
<span class="mr-model-section-total">${countText(sectionTotal, 'model')}</span>
</div>
<div class="mr-model-section-counts">
${hasActive ? `<span class="mr-model-count-pill is-active">${countText(modelsObj.active.length, 'active')}</span>` : ''}
${hasInactive ? `<span class="mr-model-count-pill is-inactive">${countText(modelsObj.inactive.length, 'inactive')}</span>` : ''}
</div>
</div>`;
if (hasActive) {
const activeStr = modelsObj.active.map(m => this.getModelToken(m, category)).join(' ');
html += `<div class="mr-model-group mr-model-group-active">
<div class="mr-model-group-head">
<span class="mr-model-group-label mr-model-group-label-active"><span class="mr-model-group-dot"></span>Active <span class="mr-model-group-count">${modelsObj.active.length}</span></span>
${copyButtonHtml(activeStr)}
</div>
<div class="mr-model-chip-list">`;
for (const model of modelsObj.active) {
const { name, strength } = this.getModelNameAndStrength(model);
const fullName = model.original_path || model.name || name;
const modelKey = this.getLoadedModelDomKey(model);
const token = this.getModelToken(model, category);
const tooltipLabel = model.model_name && model.model_name.toLowerCase() !== name.toLowerCase()
? `${model.model_name}${model.version_name ? ` (${model.version_name})` : ''} — ${fullName}`
: fullName;
html += `<span class="mr-model-chip" data-ml-token="${this.escapeHtml(token)}" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, tooltipLabel)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
if (hasInactive) {
const inactiveStr = modelsObj.inactive.map(m => this.getModelToken(m, category)).join(' ');
html += `<div class="mr-model-group mr-model-group-inactive">
<div class="mr-model-group-head">
<span class="mr-model-group-label mr-model-group-label-inactive"><span class="mr-model-group-dot"></span>Inactive <span class="mr-model-group-count">${modelsObj.inactive.length}</span></span>
${copyButtonHtml(inactiveStr, 'is-muted')}
</div>
<div class="mr-model-chip-list mr-model-chip-list-inactive">`;
for (const model of modelsObj.inactive) {
const { name, strength } = this.getModelNameAndStrength(model);
const fullName = model.original_path || model.name || name;
const modelKey = this.getLoadedModelDomKey(model);
const token = this.getModelToken(model, category);
const tooltipLabel = model.model_name && model.model_name.toLowerCase() !== name.toLowerCase()
? `${model.model_name}${model.version_name ? ` (${model.version_name})` : ''} — ${fullName}`
: fullName;
html += `<span class="mr-model-chip" data-ml-token="${this.escapeHtml(token)}" data-ml-loaded-model-key="${this.escapeHtml(modelKey)}"${modelContextAttrs(model)}${this.getModelPreviewTooltipAttrs(model, tooltipLabel)}>${this.escapeHtml(name)}${strength !== null ? `<span class="mr-model-chip-strength">${this.escapeHtml(strength)}</span>` : ''}</span>`;
}
html += `</div></div>`;
}
html += `</div>`;
}
const copySectionId = 'mr-copy-' + Date.now();
html += `
</div>
<div id="${copySectionId}" class="mr-copy-section" data-ml-active="${this.escapeHtml(activeString)}" data-ml-inactive="${this.escapeHtml(inactiveString)}" data-ml-all="${this.escapeHtml(allString)}" data-ml-selected="" data-ml-active-count="${activeCount}" data-ml-inactive-count="${inactiveCount}" data-ml-all-count="${activeCount + inactiveCount}" data-ml-selected-count="0" data-ml-previous-mode="all">
<div class="mr-copy-toolbar">
<div class="mr-copy-heading">
<div class="mr-copy-label" id="${copySectionId}-label">Copy all</div>
<div class="mr-copy-meta" id="${copySectionId}-meta">${countText(activeCount + inactiveCount, 'token')}</div>
</div>
<div class="mr-copy-actions">
<button type="button" class="mr-btn-filter mr-copy-mode active" data-ml-copy-mode="all" onclick="window.MLSetCopyMode('${copySectionId}', 'all', this)">All</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="active" onclick="window.MLSetCopyMode('${copySectionId}', 'active', this)">Active</button>
<button type="button" class="mr-btn-filter mr-copy-mode" data-ml-copy-mode="inactive" onclick="window.MLSetCopyMode('${copySectionId}', 'inactive', this)">Inactive</button>
<button type="button" class="mr-btn-filter mr-copy-mode mr-copy-mode-selected" data-ml-copy-mode="selected" style="display: none;" onclick="window.MLSetCopyMode('${copySectionId}', 'selected', this)">Selected (<span class="mr-copy-selected-count">0</span>)</button>
<button class="mr-btn-filter mr-copy-main-btn" onclick="window.MLCopyCode('${copySectionId}', this)">${copyIcon}<span>Copy</span></button>
</div>
</div>
<code class="mr-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;
if (typeof container?.querySelector === 'function') {
this.bindLoadedModelSelection?.(container);
this.bindTooltips?.(container);
}
},
bindLoadedModelSelection(container) {
if (typeof this._mlSelectionCleanup === 'function') {
this._mlSelectionCleanup();
this._mlSelectionCleanup = null;
}
const cleanup = initLoadedModelSelection(container, this);
this._mlSelectionCleanup = cleanup;
return cleanup;
},
updateLoadedModelSelectionState(container) {
return updateLoadedModelSelectionState(container);
}
};