Files
Azornes-Comfyui-Model-Resolver/web/resolver/views/tabs_loaded_methods.js
T
Dariusz L 40b1108bd2 feat: expand multi-ref pending resolutions and fix race conditions in loaded/missing tabs
- Add getResolutionQueueKey, getResolutionNodeRefs, and expandPendingResolutionsForApply helpers in queue_methods.js so each queued resolution can carry multiple node refs and be fully resolved before apply.
- Store node_refs, is_lora_v2, original_lora_name, and nested_key on pending resolutions; expand them during apply, with a guard for empty selections.
- Introduce optimistic analysis updates (applyOptimisticAnalysisData + getOptimisticAnalysisDataAfterApply) so the missing/loaded views refresh immediately after apply, then refresh in the background via refreshAnalysisInBackground.
- Update notification messaging to reflect expanded reference counts.
- Fix race conditions in lifecycle_graph_methods.js and tabs_loaded_methods.js by assigning load tokens only after the async workflow-load path begins, so early returns no longer clobber the token and stale panels are suppressed.
- Minor README badge styling fixes: add labelColor=111827 and update the Python badge to an SVG-based asset.
2026-06-17 01:06:51 +02:00

322 lines
16 KiB
JavaScript

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('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 (!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.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, { force = false } = {}) {
if (!this.contentElement) return;
this._loadedModelsLoadToken = null;
let loadToken = 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);
if (
!force &&
workflowSignature &&
this.cachedLoadedModelsSignature === workflowSignature &&
this.cachedLoadedModelsData
) {
if (shouldRenderLoadedModels()) {
this.displayLoadedModels(this.contentElement, this.cachedLoadedModelsData);
}
return;
}
if (shouldRenderLoadedModels()) {
this.contentElement.innerHTML = '<p>Loading loaded models...</p>';
}
const response = await api.fetchApi('/model_resolver/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();
if (this._loadedModelsLoadToken === loadToken) {
this.cachedLoadedModelsSignature = workflowSignature;
this.cachedLoadedModelsData = data;
this.saveLoadedModelsCacheForActiveWorkflow();
}
if (shouldRenderLoadedModels()) {
this.displayLoadedModels(this.contentElement, data);
}
} catch (error) {
console.error('Model Resolver: Error loading loaded models:', error);
if (shouldRenderLoadedModels()) {
this.contentElement.innerHTML = `<p class="mr-error-text">Error: ${error.message}</p>`;
} else if (!loadToken && this.activeTab === 'loaded' && this.contentElement) {
this.contentElement.innerHTML = `<p class="mr-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: [] };
}
// Some loaders expose per-model enablement, e.g. LoraManager's
// active flag and rgthree Power Lora Loader's on flag.
const isActive = model.active !== false && 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', 'mr-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="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'}`;
let html = `
<div class="mr-loaded-models-header">
<div class="mr-loaded-title-block">
<h3 class="mr-loaded-models-title">Loaded Models <span class="mr-loaded-total">${total}</span></h3>
<p class="mr-loaded-models-subtitle">${countText(activeCount, 'active')} / ${countText(inactiveCount, 'inactive')}</p>
</div>
<div class="mr-loaded-filter-row">
<button class="mr-btn-filter active" id="filter-all" onclick="window.MLFilterSwitch('all')">All (${activeCount + inactiveCount})</button>
<button class="mr-btn-filter" id="filter-active" onclick="window.MLFilterSwitch('active')">Active (${activeCount})</button>
<button class="mr-btn-filter" id="filter-inactive" onclick="window.MLFilterSwitch('inactive')">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-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 => modelToken(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 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="mr-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${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 => modelToken(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 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="mr-model-chip" data-model="${modelData}" oncontextmenu="window.MLOpenContextMenu(event, this)">${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-active-count="${activeCount}" data-ml-inactive-count="${inactiveCount}" data-ml-all-count="${activeCount + inactiveCount}">
<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 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;
}
};