Files
willmiao-ComfyUI-Lora-Manager/static/js/components/ContextMenu/WorkflowContextMenu.js
T
Will Miao fa774de9ea feat(workflows): update managed workflows from the LM card context menu
Add an Update from ComfyUI entry to the workflow card context menu,
enabled only when the workflow is the active graph in a connected
ComfyUI tab and carries unsaved edits.

ComfyUI side (workflow_writeback.js): the first graphChanged after a
load/write-back flips a dirty flag inside the extra.lm_source marker
(ChangeTracker only dispatches on real state changes); the active
graph's link state (file_path, sha256, dirty) is reported to
POST /api/lm/workflows/comfyui-state on load, tab switch, dirty flip
and write-back. A new lm_collect_workflow WS handler answers LM-page
write-back requests: it reuses the regular PUT content flow and posts
the outcome to /api/lm/workflows/collect-result.

Backend: an in-memory WorkflowLinkRegistry holds per-client link states
(pruned against live websocket sids) and pending collect futures;
POST /api/lm/workflows/update-from-comfy targets the tab holding the
requested file (dirty holders win) and awaits the collect result,
mapping no_changes/cancelled to 200, timeout to 504.
2026-10-29 08:29:26 +08:00

127 lines
4.9 KiB
JavaScript

import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { moveManager } from '../../managers/MoveManager.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
import { copyWorkflowToClipboard, sendWorkflowToComfy, updateWorkflowFromComfy } from '../../utils/workflowActions.js';
import { state } from '../../state/index.js';
export class WorkflowContextMenu extends BaseContextMenu {
constructor() {
super('workflowContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'workflows';
this.resetAndReload = resetAndReload;
this.initNSFWSelector();
}
// Implementation needed by the mixin
async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data);
}
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
// Standalone has no running ComfyUI to push a workflow into; the
// copy-to-clipboard action covers that mode instead.
const sendItem = this.menu?.querySelector('[data-action="send-to-comfy"]');
const updateItem = this.menu?.querySelector('[data-action="update-from-comfy"]');
const standalone = state?.global?.settings?.standalone_mode;
if (sendItem) {
sendItem.style.display = standalone ? 'none' : '';
}
if (updateItem) {
if (standalone) {
updateItem.style.display = 'none';
} else {
updateItem.style.display = '';
this._refreshUpdateFromComfyItem(card, updateItem);
}
}
}
/**
* Enable "Update from ComfyUI" only when this workflow is the active
* graph in a connected ComfyUI tab and carries unsaved edits. The state
* is pushed by the ComfyUI-side extension; the item starts disabled and
* flips when the answer arrives while the menu is still open.
*/
async _refreshUpdateFromComfyItem(card, item) {
item.classList.add('disabled');
const filePath = card?.dataset?.filepath;
if (!filePath) return;
try {
const states = await getModelApiClient().fetchComfyuiState();
// Menu may have been closed/re-opened for another card meanwhile.
if (this.currentCard !== card) return;
const linked = states.some(
(entry) => entry.file_path === filePath && entry.dirty
);
item.classList.toggle('disabled', !linked);
} catch (_error) {
// State endpoint unavailable (older backend): keep disabled.
}
}
handleMenuAction(action) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
const apiClient = getModelApiClient();
// Otherwise handle workflow-specific actions
switch(action) {
case 'details':
// Show workflow details
this.currentCard.click();
break;
case 'send-to-comfy':
sendWorkflowToComfy(this.currentCard.dataset.filepath);
break;
case 'update-from-comfy':
updateWorkflowFromComfy(this.currentCard.dataset.filepath);
break;
case 'copy-workflow':
copyWorkflowToClipboard(this.currentCard.dataset.filepath);
break;
case 'replace-preview':
// Add new action for replacing preview images
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
break;
case 'delete':
showDeleteModal(this.currentCard.dataset.filepath);
break;
case 'copyname':
// Copy workflow name
if (this.currentCard.querySelector('.fa-copy')) {
this.currentCard.querySelector('.fa-copy').click();
}
break;
case 'refresh-metadata':
// Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
}
}
async sendToComfy(filePath) {
await sendWorkflowToComfy(filePath);
}
}
// Mix in shared methods
Object.assign(WorkflowContextMenu.prototype, ModelContextMenuMixin);