diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index 3c0d20f99..c6aa2aea7 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -72,7 +72,6 @@ import { useWorkflowAutoSave } from '@/composables/useWorkflowAutoSave' import { useWorkflowPersistence } from '@/composables/useWorkflowPersistence' import { CORE_SETTINGS } from '@/constants/coreSettings' import { i18n, t } from '@/i18n' -import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { UnauthorizedError, api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { ChangeTracker } from '@/scripts/changeTracker' @@ -192,22 +191,26 @@ watch( } ) -// Update the progress of the executing node +// Update the progress of executing nodes watch( () => - [ - executionStore.executingNodeId, - executionStore.executingNodeProgress - ] satisfies [NodeId | null, number | null], - ([executingNodeId, executingNodeProgress]) => { - for (const node of comfyApp.graph.nodes) { - if (node.id == executingNodeId) { - node.progress = executingNodeProgress ?? undefined + [executionStore.nodeLocationProgressStates, canvasStore.canvas] as const, + ([nodeLocationProgressStates, canvas]) => { + if (!canvas?.graph) return + for (const node of canvas.graph.nodes) { + const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(node.id) + const progressState = nodeLocationProgressStates[nodeLocatorId] + if (progressState && progressState.state === 'running') { + node.progress = progressState.value / progressState.max } else { node.progress = undefined } } - } + + // Force canvas redraw to ensure progress updates are visible + canvas.graph.setDirtyCanvas(true, false) + }, + { deep: true } ) // Update node slot errors diff --git a/src/components/graph/widgets/TextPreviewWidget.vue b/src/components/graph/widgets/TextPreviewWidget.vue index 8d161b8e3..7244634a4 100644 --- a/src/components/graph/widgets/TextPreviewWidget.vue +++ b/src/components/graph/widgets/TextPreviewWidget.vue @@ -20,33 +20,44 @@ import { useExecutionStore } from '@/stores/executionStore' import { linkifyHtml, nl2br } from '@/utils/formatUtil' const modelValue = defineModel({ required: true }) -defineProps<{ +const props = defineProps<{ widget?: object + nodeId: NodeId }>() const executionStore = useExecutionStore() const isParentNodeExecuting = ref(true) const formattedText = computed(() => nl2br(linkifyHtml(modelValue.value))) -let executingNodeId: NodeId | null = null +let parentNodeId: NodeId | null = null onMounted(() => { - executingNodeId = executionStore.executingNodeId + // Get the parent node ID from props if provided + // For backward compatibility, fall back to the first executing node + parentNodeId = props.nodeId }) // Watch for either a new node has starting execution or overall execution ending const stopWatching = watch( - [() => executionStore.executingNode, () => executionStore.isIdle], + [() => executionStore.executingNodeIds, () => executionStore.isIdle], () => { + if (executionStore.isIdle) { + isParentNodeExecuting.value = false + stopWatching() + return + } + + // Check if parent node is no longer in the executing nodes list if ( - executionStore.isIdle || - (executionStore.executingNode && - executionStore.executingNode.id !== executingNodeId) + parentNodeId && + !executionStore.executingNodeIds.includes(parentNodeId) ) { isParentNodeExecuting.value = false stopWatching() } - if (!executingNodeId) { - executingNodeId = executionStore.executingNodeId + + // Set parent node ID if not set yet + if (!parentNodeId && executionStore.executingNodeIds.length > 0) { + parentNodeId = executionStore.executingNodeIds[0] } } ) diff --git a/src/composables/useBrowserTabTitle.ts b/src/composables/useBrowserTabTitle.ts index 95d60752d..82da27149 100644 --- a/src/composables/useBrowserTabTitle.ts +++ b/src/composables/useBrowserTabTitle.ts @@ -1,6 +1,7 @@ import { useTitle } from '@vueuse/core' import { computed } from 'vue' +import { t } from '@/i18n' import { useExecutionStore } from '@/stores/executionStore' import { useSettingStore } from '@/stores/settingStore' import { useWorkflowStore } from '@/stores/workflowStore' @@ -36,11 +37,34 @@ export const useBrowserTabTitle = () => { : DEFAULT_TITLE }) - const nodeExecutionTitle = computed(() => - executionStore.executingNode && executionStore.executingNodeProgress - ? `${executionText.value}[${Math.round(executionStore.executingNodeProgress * 100)}%] ${executionStore.executingNode.type}` - : '' - ) + const nodeExecutionTitle = computed(() => { + // Check if any nodes are in progress + const nodeProgressEntries = Object.entries( + executionStore.nodeProgressStates + ) + const runningNodes = nodeProgressEntries.filter( + ([_, state]) => state.state === 'running' + ) + + if (runningNodes.length === 0) { + return '' + } + + // If multiple nodes are running + if (runningNodes.length > 1) { + return `${executionText.value}[${runningNodes.length} ${t('g.nodesRunning', 'nodes running')}]` + } + + // If only one node is running + const [nodeId, state] = runningNodes[0] + const progress = Math.round((state.value / state.max) * 100) + const nodeType = + executionStore.activePrompt?.workflow?.changeTracker?.activeState.nodes.find( + (n) => String(n.id) === nodeId + )?.type || 'Node' + + return `${executionText.value}[${progress}%] ${nodeType}` + }) const workflowTitle = computed( () => diff --git a/src/composables/widgets/useChatHistoryWidget.ts b/src/composables/widgets/useChatHistoryWidget.ts index 9f807a3c6..a3568fb21 100644 --- a/src/composables/widgets/useChatHistoryWidget.ts +++ b/src/composables/widgets/useChatHistoryWidget.ts @@ -3,14 +3,23 @@ import { ref } from 'vue' import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' +import { + ComponentWidgetImpl, + type ComponentWidgetStandardProps, + addWidget +} from '@/scripts/domWidget' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +type ChatHistoryCustomProps = Omit< + InstanceType['$props'], + ComponentWidgetStandardProps +> + const PADDING = 16 export const useChatHistoryWidget = ( options: { - props?: Omit['$props'], 'widget'> + props?: ChatHistoryCustomProps } = {} ) => { const widgetConstructor: ComfyWidgetConstructorV2 = ( @@ -20,7 +29,7 @@ export const useChatHistoryWidget = ( const widgetValue = ref('') const widget = new ComponentWidgetImpl< string | object, - InstanceType['$props'] + ChatHistoryCustomProps >({ node, name: inputSpec.name, diff --git a/src/composables/widgets/useProgressTextWidget.ts b/src/composables/widgets/useProgressTextWidget.ts index 625f0f9cf..1e7dc45ce 100644 --- a/src/composables/widgets/useProgressTextWidget.ts +++ b/src/composables/widgets/useProgressTextWidget.ts @@ -3,9 +3,18 @@ import { ref } from 'vue' import TextPreviewWidget from '@/components/graph/widgets/TextPreviewWidget.vue' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2' -import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget' +import { + ComponentWidgetImpl, + type ComponentWidgetStandardProps, + addWidget +} from '@/scripts/domWidget' import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets' +type TextPreviewCustomProps = Omit< + InstanceType['$props'], + ComponentWidgetStandardProps +> + const PADDING = 16 export const useTextPreviewWidget = ( @@ -18,11 +27,17 @@ export const useTextPreviewWidget = ( inputSpec: InputSpec ) => { const widgetValue = ref('') - const widget = new ComponentWidgetImpl({ + const widget = new ComponentWidgetImpl< + string | object, + TextPreviewCustomProps + >({ node, name: inputSpec.name, component: TextPreviewWidget, inputSpec, + props: { + nodeId: node.id + }, options: { getValue: () => widgetValue.value, setValue: (value: string | object) => { diff --git a/src/config/clientFeatureFlags.json b/src/config/clientFeatureFlags.json index ebd6bcf60..84a233ccf 100644 --- a/src/config/clientFeatureFlags.json +++ b/src/config/clientFeatureFlags.json @@ -1,3 +1,3 @@ { - "supports_preview_metadata": false + "supports_preview_metadata": true } diff --git a/src/extensions/core/groupNode.ts b/src/extensions/core/groupNode.ts index 800255af1..7aa714133 100644 --- a/src/extensions/core/groupNode.ts +++ b/src/extensions/core/groupNode.ts @@ -15,6 +15,7 @@ import { } from '@/schemas/comfyWorkflowSchema' import type { ComfyNodeDef } from '@/schemas/nodeDefSchema' import { useDialogService } from '@/services/dialogService' +import { useExecutionStore } from '@/stores/executionStore' import { useNodeDefStore } from '@/stores/nodeDefStore' import { useToastStore } from '@/stores/toastStore' import { useWidgetStore } from '@/stores/widgetStore' @@ -1224,9 +1225,10 @@ export class GroupNodeHandler { node.onDrawForeground = function (ctx) { // @ts-expect-error fixme ts strict error onDrawForeground?.apply?.(this, arguments) + const progressState = useExecutionStore().nodeProgressStates[this.id] if ( - // @ts-expect-error fixme ts strict error - +app.runningNodeId === this.id && + progressState && + progressState.state === 'running' && this.runningInternalNodeId !== null ) { // @ts-expect-error fixme ts strict error @@ -1340,6 +1342,7 @@ export class GroupNodeHandler { this.node.onRemoved = function () { // @ts-expect-error fixme ts strict error onRemoved?.apply(this, arguments) + // api.removeEventListener('progress_state', progress_state) api.removeEventListener('executing', executing) api.removeEventListener('executed', executed) } diff --git a/src/locales/en/main.json b/src/locales/en/main.json index df92e481b..02f54ca35 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -133,7 +133,8 @@ "copyURL": "Copy URL", "releaseTitle": "{package} {version} Release", "progressCountOf": "of", - "keybindingAlreadyExists": "Keybinding already exists on" + "keybindingAlreadyExists": "Keybinding already exists on", + "nodesRunning": "nodes running" }, "manager": { "title": "Custom Nodes Manager", diff --git a/src/locales/es/main.json b/src/locales/es/main.json index e59f889d0..199b213bd 100644 --- a/src/locales/es/main.json +++ b/src/locales/es/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "No hay tareas en la cola.", "noWorkflowsFound": "No se encontraron flujos de trabajo.", "nodes": "Nodos", + "nodesRunning": "nodos en ejecución", "ok": "OK", "openNewIssue": "Abrir nuevo problema", "overwrite": "Sobrescribir", diff --git a/src/locales/fr/main.json b/src/locales/fr/main.json index 4529f7c8e..f2d321bb1 100644 --- a/src/locales/fr/main.json +++ b/src/locales/fr/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "Il n'y a pas de tâches dans la file d'attente.", "noWorkflowsFound": "Aucun flux de travail trouvé.", "nodes": "Nœuds", + "nodesRunning": "nœuds en cours d’exécution", "ok": "OK", "openNewIssue": "Ouvrir un nouveau problème", "overwrite": "Écraser", diff --git a/src/locales/ja/main.json b/src/locales/ja/main.json index 3a2b3ff53..2377ed619 100644 --- a/src/locales/ja/main.json +++ b/src/locales/ja/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "キューにタスクがありません。", "noWorkflowsFound": "ワークフローが見つかりません。", "nodes": "ノード", + "nodesRunning": "ノードが実行中", "ok": "OK", "openNewIssue": "新しい問題を開く", "overwrite": "上書き", diff --git a/src/locales/ko/main.json b/src/locales/ko/main.json index b09399130..b3cfc3403 100644 --- a/src/locales/ko/main.json +++ b/src/locales/ko/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "대기열에 작업이 없습니다.", "noWorkflowsFound": "워크플로를 찾을 수 없습니다.", "nodes": "노드", + "nodesRunning": "노드 실행 중", "ok": "확인", "openNewIssue": "새 문제 열기", "overwrite": "덮어쓰기", diff --git a/src/locales/ru/main.json b/src/locales/ru/main.json index bd75079e9..2b5172db1 100644 --- a/src/locales/ru/main.json +++ b/src/locales/ru/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "В очереди нет задач.", "noWorkflowsFound": "Рабочие процессы не найдены.", "nodes": "Узлы", + "nodesRunning": "запущено узлов", "ok": "ОК", "openNewIssue": "Открыть новую проблему", "overwrite": "Перезаписать", diff --git a/src/locales/zh-TW/main.json b/src/locales/zh-TW/main.json index 13781075c..1e7ac401d 100644 --- a/src/locales/zh-TW/main.json +++ b/src/locales/zh-TW/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "佇列中沒有任務。", "noWorkflowsFound": "找不到工作流程。", "nodes": "節點", + "nodesRunning": "節點執行中", "ok": "確定", "openNewIssue": "開啟新問題", "overwrite": "覆蓋", diff --git a/src/locales/zh/main.json b/src/locales/zh/main.json index 8181ee0ed..779664e42 100644 --- a/src/locales/zh/main.json +++ b/src/locales/zh/main.json @@ -336,6 +336,7 @@ "noTasksFoundMessage": "队列中没有任务。", "noWorkflowsFound": "未找到工作流。", "nodes": "节点", + "nodesRunning": "节点正在运行", "ok": "确定", "openNewIssue": "打开新问题", "overwrite": "覆盖", @@ -785,13 +786,13 @@ "Toggle Bottom Panel": "切换底部面板", "Toggle Focus Mode": "切换专注模式", "Toggle Logs Bottom Panel": "切换日志底部面板", - "Toggle Model Library Sidebar": "切换模型库侧边栏", - "Toggle Node Library Sidebar": "切换节点库侧边栏", - "Toggle Queue Sidebar": "切换队列侧边栏", + "Toggle Model Library Sidebar": "切換模型庫側邊欄", + "Toggle Node Library Sidebar": "切換節點庫側邊欄", + "Toggle Queue Sidebar": "切換佇列側邊欄", "Toggle Search Box": "切换搜索框", "Toggle Terminal Bottom Panel": "切换终端底部面板", "Toggle Theme (Dark/Light)": "切换主题(暗/亮)", - "Toggle Workflows Sidebar": "切换工作流侧边栏", + "Toggle Workflows Sidebar": "切換工作流程側邊欄", "Toggle the Custom Nodes Manager": "切换自定义节点管理器", "Toggle the Custom Nodes Manager Progress Bar": "切换自定义节点管理器进度条", "Undo": "撤销", diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index 12fbe2981..8d1f223d3 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -48,6 +48,22 @@ const zProgressWsMessage = z.object({ node: zNodeId }) +const zNodeProgressState = z.object({ + value: z.number(), + max: z.number(), + state: z.enum(['pending', 'running', 'finished', 'error']), + node_id: zNodeId, + prompt_id: zPromptId, + display_node_id: zNodeId.optional(), + parent_node_id: zNodeId.optional(), + real_node_id: zNodeId.optional() +}) + +const zProgressStateWsMessage = z.object({ + prompt_id: zPromptId, + nodes: z.record(zNodeId, zNodeProgressState) +}) + const zExecutingWsMessage = z.object({ node: zNodeId, display_node: zNodeId, @@ -134,6 +150,8 @@ export type ProgressTextWsMessage = z.infer export type DisplayComponentWsMessage = z.infer< typeof zDisplayComponentWsMessage > +export type NodeProgressState = z.infer +export type ProgressStateWsMessage = z.infer export type FeatureFlagsWsMessage = z.infer // End of ws messages diff --git a/src/scripts/api.ts b/src/scripts/api.ts index 7be323588..d46b4e031 100644 --- a/src/scripts/api.ts +++ b/src/scripts/api.ts @@ -17,6 +17,7 @@ import type { LogsRawResponse, LogsWsMessage, PendingTaskItem, + ProgressStateWsMessage, ProgressTextWsMessage, ProgressWsMessage, PromptResponse, @@ -105,7 +106,17 @@ interface BackendApiCalls { logs: LogsWsMessage /** Binary preview/progress data */ b_preview: Blob + /** Binary preview with metadata (node_id, prompt_id) */ + b_preview_with_metadata: { + blob: Blob + nodeId: string + parentNodeId: string + displayNodeId: string + realNodeId: string + promptId: string + } progress_text: ProgressTextWsMessage + progress_state: ProgressStateWsMessage display_component: DisplayComponentWsMessage feature_flags: FeatureFlagsWsMessage } @@ -457,6 +468,33 @@ export class ComfyApi extends EventTarget { }) this.dispatchCustomEvent('b_preview', imageBlob) break + case 4: + // PREVIEW_IMAGE_WITH_METADATA + const decoder4 = new TextDecoder() + const metadataLength = view.getUint32(4) + const metadataBytes = event.data.slice(8, 8 + metadataLength) + const metadata = JSON.parse(decoder4.decode(metadataBytes)) + const imageData4 = event.data.slice(8 + metadataLength) + + let imageMime4 = metadata.image_type + + const imageBlob4 = new Blob([imageData4], { + type: imageMime4 + }) + + // Dispatch enhanced preview event with metadata + this.dispatchCustomEvent('b_preview_with_metadata', { + blob: imageBlob4, + nodeId: metadata.node_id, + displayNodeId: metadata.display_node_id, + parentNodeId: metadata.parent_node_id, + realNodeId: metadata.real_node_id, + promptId: metadata.prompt_id + }) + + // Also dispatch legacy b_preview for backward compatibility + this.dispatchCustomEvent('b_preview', imageBlob4) + break default: throw new Error( `Unknown binary websocket message of type ${eventType}` @@ -486,6 +524,7 @@ export class ComfyApi extends EventTarget { case 'execution_cached': case 'execution_success': case 'progress': + case 'progress_state': case 'executed': case 'graphChanged': case 'promptQueued': diff --git a/src/scripts/app.ts b/src/scripts/app.ts index 7744ae757..c1474de52 100644 --- a/src/scripts/app.ts +++ b/src/scripts/app.ts @@ -194,6 +194,8 @@ export class ComfyApp { /** * @deprecated Use useExecutionStore().executingNodeId instead + * TODO: Update to support multiple executing nodes. This getter returns only the first executing node. + * Consider updating consumers to handle multiple nodes or use executingNodeIds array. */ get runningNodeId(): NodeId | null { return useExecutionStore().executingNodeId @@ -635,10 +637,6 @@ export class ComfyApp { api.addEventListener('executing', () => { this.graph.setDirtyCanvas(true, false) - // @ts-expect-error fixme ts strict error - this.revokePreviews(this.runningNodeId) - // @ts-expect-error fixme ts strict error - delete this.nodePreviewImages[this.runningNodeId] }) api.addEventListener('executed', ({ detail }) => { @@ -689,15 +687,13 @@ export class ComfyApp { this.canvas.draw(true, true) }) - api.addEventListener('b_preview', ({ detail }) => { - const id = this.runningNodeId - if (id == null) return - - const blob = detail + api.addEventListener('b_preview_with_metadata', ({ detail }) => { + // Enhanced preview with explicit node context + const { blob, displayNodeId } = detail + this.revokePreviews(displayNodeId) const blobUrl = URL.createObjectURL(blob) - // Ensure clean up if `executing` event is missed. - this.revokePreviews(id) - this.nodePreviewImages[id] = [blobUrl] + // Preview cleanup is now handled in progress_state event to support multiple concurrent previews + this.nodePreviewImages[displayNodeId] = [blobUrl] }) api.init() diff --git a/src/scripts/domWidget.ts b/src/scripts/domWidget.ts index 093d29869..be7da87f6 100644 --- a/src/scripts/domWidget.ts +++ b/src/scripts/domWidget.ts @@ -44,12 +44,30 @@ export interface DOMWidget inputEl?: T } +/** + * Additional props that can be passed to component widgets. + * These are in addition to the standard props that are always provided: + * - modelValue: The widget's value (handled by v-model) + * - widget: Reference to the widget instance + * - onUpdate:modelValue: The update handler for v-model + */ +export type ComponentWidgetCustomProps = Record + +/** + * Standard props that are handled separately by DomWidget.vue and should be + * omitted when defining custom props for component widgets + */ +export type ComponentWidgetStandardProps = + | 'modelValue' + | 'widget' + | 'onUpdate:modelValue' + /** * A DOM widget that wraps a Vue component as a litegraph widget. */ export interface ComponentWidget< V extends object | string, - P = Record + P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps > extends BaseDOMWidget { readonly component: Component readonly inputSpec: InputSpec @@ -247,7 +265,7 @@ export class DOMWidgetImpl export class ComponentWidgetImpl< V extends object | string, - P = Record + P extends ComponentWidgetCustomProps = ComponentWidgetCustomProps > extends BaseDOMWidgetImpl implements ComponentWidget diff --git a/src/services/litegraphService.ts b/src/services/litegraphService.ts index 17e51fe67..fc3726fcc 100644 --- a/src/services/litegraphService.ts +++ b/src/services/litegraphService.ts @@ -35,6 +35,7 @@ import { ComfyApp, app } from '@/scripts/app' import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget' import { $el } from '@/scripts/ui' import { useDomWidgetStore } from '@/stores/domWidgetStore' +import { useExecutionStore } from '@/stores/executionStore' import { useCanvasStore } from '@/stores/graphStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { ComfyNodeDefImpl } from '@/stores/nodeDefStore' @@ -140,7 +141,11 @@ export const useLitegraphService = () => { */ #setupStrokeStyles() { this.strokeStyles['running'] = function (this: LGraphNode) { - if (this.id == app.runningNodeId) { + const nodeId = String(this.id) + const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId) + const state = + useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state + if (state === 'running') { return { color: '#0f0' } } } @@ -395,7 +400,11 @@ export const useLitegraphService = () => { */ #setupStrokeStyles() { this.strokeStyles['running'] = function (this: LGraphNode) { - if (this.id == app.runningNodeId) { + const nodeId = String(this.id) + const nodeLocatorId = useWorkflowStore().nodeIdToNodeLocatorId(nodeId) + const state = + useExecutionStore().nodeLocationProgressStates[nodeLocatorId]?.state + if (state === 'running') { return { color: '#0f0' } } } diff --git a/src/stores/executionStore.ts b/src/stores/executionStore.ts index a8667da0d..dad7981dc 100644 --- a/src/stores/executionStore.ts +++ b/src/stores/executionStore.ts @@ -12,6 +12,8 @@ import type { ExecutionErrorWsMessage, ExecutionStartWsMessage, NodeError, + NodeProgressState, + ProgressStateWsMessage, ProgressTextWsMessage, ProgressWsMessage } from '@/schemas/apiSchema' @@ -21,6 +23,9 @@ import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' +import { app } from '@/scripts/app' +import type { NodeLocatorId } from '@/types/nodeIdentification' +import { createNodeLocatorId } from '@/types/nodeIdentification' import { useCanvasStore } from './graphStore' import { ComfyWorkflow, useWorkflowStore } from './workflowStore' @@ -46,7 +51,97 @@ export const useExecutionStore = defineStore('execution', () => { const queuedPrompts = ref>({}) const lastNodeErrors = ref | null>(null) const lastExecutionError = ref(null) - const executingNodeId = ref(null) + // This is the progress of all nodes in the currently executing workflow + const nodeProgressStates = ref>({}) + + /** + * Convert execution context node IDs to NodeLocatorIds + * @param nodeId The node ID from execution context (could be execution ID) + * @returns The NodeLocatorId + */ + const executionIdToNodeLocatorId = ( + nodeId: string | number + ): NodeLocatorId => { + const nodeIdStr = String(nodeId) + + if (!nodeIdStr.includes(':')) { + // It's a top-level node ID + return nodeIdStr + } + + // It's an execution node ID + const parts = nodeIdStr.split(':') + const localNodeId = parts[parts.length - 1] + const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts) + const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId) + return nodeLocatorId + } + + const mergeExecutionProgressStates = ( + currentState: NodeProgressState | undefined, + newState: NodeProgressState + ): NodeProgressState => { + if (currentState === undefined) { + return newState + } + + const mergedState = { ...currentState } + if (mergedState.state === 'error') { + return mergedState + } else if (newState.state === 'running') { + const newPerc = newState.max > 0 ? newState.value / newState.max : 0.0 + const oldPerc = + mergedState.max > 0 ? mergedState.value / mergedState.max : 0.0 + if ( + mergedState.state !== 'running' || + oldPerc === 0.0 || + newPerc < oldPerc + ) { + mergedState.value = newState.value + mergedState.max = newState.max + } + mergedState.state = 'running' + } + + return mergedState + } + + const nodeLocationProgressStates = computed< + Record + >(() => { + const result: Record = {} + + const states = nodeProgressStates.value // Apparently doing this inside `Object.entries` causes issues + for (const state of Object.values(states)) { + const parts = String(state.display_node_id).split(':') + for (let i = 0; i < parts.length; i++) { + const executionId = parts.slice(0, i + 1).join(':') + const locatorId = executionIdToNodeLocatorId(executionId) + if (!locatorId) continue + + result[locatorId] = mergeExecutionProgressStates( + result[locatorId], + state + ) + } + } + + return result + }) + + // Easily access all currently executing node IDs + const executingNodeIds = computed(() => { + return Object.entries(nodeProgressStates) + .filter(([_, state]) => state.state === 'running') + .map(([nodeId, _]) => nodeId) + }) + + // @deprecated For backward compatibility - stores the primary executing node ID + const executingNodeId = computed(() => { + return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null + }) + + // For backward compatibility - returns the primary executing node const executingNode = computed(() => { if (!executingNodeId.value) return null @@ -93,30 +188,7 @@ export const useExecutionStore = defineStore('execution', () => { return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs) } - const executionIdToCurrentId = (id: string) => { - const subgraph = workflowStore.activeSubgraph - - // Short-circuit: ID belongs to the parent workflow / no active subgraph - if (!id.includes(':')) { - return !subgraph ? id : undefined - } else if (!subgraph) { - return - } - - // Parse the hierarchical ID (e.g., "123:456:789") - const subgraphNodeIds = id.split(':') - - // If the last subgraph is the active subgraph, return the node ID - const subgraphs = getSubgraphsFromInstanceIds( - subgraph.rootGraph, - subgraphNodeIds - ) - if (subgraphs.at(-1) === subgraph) { - return subgraphNodeIds.at(-1) - } - } - - // This is the progress of the currently executing node, if any + // This is the progress of the currently executing node (for backward compatibility) const _executingNodeProgress = ref(null) const executingNodeProgress = computed(() => _executingNodeProgress.value @@ -153,6 +225,7 @@ export const useExecutionStore = defineStore('execution', () => { api.addEventListener('executed', handleExecuted) api.addEventListener('executing', handleExecuting) api.addEventListener('progress', handleProgress) + api.addEventListener('progress_state', handleProgressState) api.addEventListener('status', handleStatus) api.addEventListener('execution_error', handleExecutionError) } @@ -165,6 +238,7 @@ export const useExecutionStore = defineStore('execution', () => { api.removeEventListener('executed', handleExecuted) api.removeEventListener('executing', handleExecuting) api.removeEventListener('progress', handleProgress) + api.removeEventListener('progress_state', handleProgressState) api.removeEventListener('status', handleStatus) api.removeEventListener('execution_error', handleExecutionError) api.removeEventListener('progress_text', handleProgressText) @@ -194,19 +268,42 @@ export const useExecutionStore = defineStore('execution', () => { if (!activePrompt.value) return - if (executingNodeId.value && activePrompt.value) { - // Seems sometimes nodes that are cached fire executing but not executed - activePrompt.value.nodes[executingNodeId.value] = true + // Update the executing nodes list + if (typeof e.detail !== 'string') { + if (activePromptId.value) { + delete queuedPrompts.value[activePromptId.value] + } + activePromptId.value = null } - if (typeof e.detail === 'string') { - executingNodeId.value = executionIdToCurrentId(e.detail) ?? null - } else { - executingNodeId.value = e.detail - if (executingNodeId.value === null) { - if (activePromptId.value) { - delete queuedPrompts.value[activePromptId.value] - } - activePromptId.value = null + } + + function handleProgressState(e: CustomEvent) { + const { nodes } = e.detail + + // Revoke previews for nodes that are starting to execute + for (const nodeId in nodes) { + const nodeState = nodes[nodeId] + if (nodeState.state === 'running' && !nodeProgressStates.value[nodeId]) { + // This node just started executing, revoke its previews + // Note that we're doing the *actual* node id instead of the display node id + // here intentionally. That way, we don't clear the preview every time a new node + // within an expanded graph starts executing. + app.revokePreviews(nodeId) + delete app.nodePreviewImages[nodeId] + } + } + + // Update the progress states for all nodes + nodeProgressStates.value = nodes + + // If we have progress for the currently executing node, update it for backwards compatibility + if (executingNodeId.value && nodes[executingNodeId.value]) { + const nodeState = nodes[executingNodeId.value] + _executingNodeProgress.value = { + value: nodeState.value, + max: nodeState.max, + prompt_id: nodeState.prompt_id, + node: nodeState.display_node_id || nodeState.node_id } } } @@ -239,7 +336,7 @@ export const useExecutionStore = defineStore('execution', () => { const { nodeId, text } = e.detail if (!text || !nodeId) return - // Handle hierarchical node IDs for subgraphs + // Handle execution node IDs for subgraphs const currentId = getNodeIdIfExecuting(nodeId) const node = canvasStore.getCanvas().graph?.getNodeById(currentId) if (!node) return @@ -250,7 +347,7 @@ export const useExecutionStore = defineStore('execution', () => { function handleDisplayComponent(e: CustomEvent) { const { node_id: nodeId, component, props = {} } = e.detail - // Handle hierarchical node IDs for subgraphs + // Handle execution node IDs for subgraphs const currentId = getNodeIdIfExecuting(nodeId) const node = canvasStore.getCanvas().graph?.getNodeById(currentId) if (!node) return @@ -290,6 +387,18 @@ export const useExecutionStore = defineStore('execution', () => { ) } + /** + * Convert a NodeLocatorId to an execution context ID + * @param locatorId The NodeLocatorId + * @returns The execution ID or null if conversion fails + */ + const nodeLocatorIdToExecutionId = ( + locatorId: NodeLocatorId | string + ): string | null => { + const executionId = workflowStore.nodeLocatorIdToNodeExecutionId(locatorId) + return executionId + } + return { isIdle, clientId, @@ -310,9 +419,13 @@ export const useExecutionStore = defineStore('execution', () => { */ lastExecutionError, /** - * The id of the node that is currently being executed + * The id of the node that is currently being executed (backward compatibility) */ executingNodeId, + /** + * The list of all nodes that are currently executing + */ + executingNodeIds, /** * The prompt that is currently being executed */ @@ -330,17 +443,25 @@ export const useExecutionStore = defineStore('execution', () => { */ executionProgress, /** - * The node that is currently being executed + * The node that is currently being executed (backward compatibility) */ executingNode, /** - * The progress of the executing node (if the node reports progress) + * The progress of the executing node (backward compatibility) */ executingNodeProgress, + /** + * All node progress states from progress_state events + */ + nodeProgressStates, + nodeLocationProgressStates, bindExecutionEvents, unbindExecutionEvents, storePrompt, // Raw executing progress data for backward compatibility in ComfyApp. - _executingNodeProgress + _executingNodeProgress, + // NodeLocatorId conversion helpers + executionIdToNodeLocatorId, + nodeLocatorIdToExecutionId } }) diff --git a/src/stores/workflowStore.ts b/src/stores/workflowStore.ts index 6b0044e9a..f11068852 100644 --- a/src/stores/workflowStore.ts +++ b/src/stores/workflowStore.ts @@ -4,10 +4,18 @@ import { defineStore } from 'pinia' import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue' import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema' +import type { NodeId } from '@/schemas/comfyWorkflowSchema' import { api } from '@/scripts/api' import { app as comfyApp } from '@/scripts/app' import { ChangeTracker } from '@/scripts/changeTracker' import { defaultGraphJSON } from '@/scripts/defaultGraph' +import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification' +import { + createNodeExecutionId, + createNodeLocatorId, + parseNodeExecutionId, + parseNodeLocatorId +} from '@/types/nodeIdentification' import { getPathDetails } from '@/utils/formatUtil' import { syncEntities } from '@/utils/syncUtil' import { isSubgraph } from '@/utils/typeGuardUtil' @@ -163,6 +171,15 @@ export interface WorkflowStore { /** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */ updateActiveGraph: () => void executionIdToCurrentId: (id: string) => any + nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId + nodeExecutionIdToNodeLocatorId: ( + nodeExecutionId: NodeExecutionId | string + ) => NodeLocatorId | null + nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null + nodeLocatorIdToNodeExecutionId: ( + locatorId: NodeLocatorId | string, + targetSubgraph?: Subgraph + ) => NodeExecutionId | null } export const useWorkflowStore = defineStore('workflow', () => { @@ -473,7 +490,7 @@ export const useWorkflowStore = defineStore('workflow', () => { return } - // Parse the hierarchical ID (e.g., "123:456:789") + // Parse the execution ID (e.g., "123:456:789") const subgraphNodeIds = id.split(':') // Start from the root graph @@ -488,6 +505,136 @@ export const useWorkflowStore = defineStore('workflow', () => { watch(activeWorkflow, updateActiveGraph) + /** + * Convert a node ID to a NodeLocatorId + * @param nodeId The local node ID + * @param subgraph The subgraph containing the node (defaults to active subgraph) + * @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is) + */ + const nodeIdToNodeLocatorId = ( + nodeId: NodeId, + subgraph?: Subgraph + ): NodeLocatorId => { + const targetSubgraph = subgraph ?? activeSubgraph.value + if (!targetSubgraph) { + // Node is in the root graph, return the node ID as-is + return String(nodeId) + } + + return createNodeLocatorId(targetSubgraph.id, nodeId) + } + + /** + * Convert an execution ID to a NodeLocatorId + * @param nodeExecutionId The execution node ID (e.g., "123:456:789") + * @returns The NodeLocatorId or null if conversion fails + */ + const nodeExecutionIdToNodeLocatorId = ( + nodeExecutionId: NodeExecutionId | string + ): NodeLocatorId | null => { + // Handle simple node IDs (root graph - no colons) + if (!nodeExecutionId.includes(':')) { + return nodeExecutionId + } + + const parts = parseNodeExecutionId(nodeExecutionId) + if (!parts || parts.length === 0) return null + + const nodeId = parts[parts.length - 1] + const subgraphNodeIds = parts.slice(0, -1) + + if (subgraphNodeIds.length === 0) { + // Node is in root graph, return the node ID as-is + return String(nodeId) + } + + try { + const subgraphs = getSubgraphsFromInstanceIds( + comfyApp.graph, + subgraphNodeIds.map((id) => String(id)) + ) + const immediateSubgraph = subgraphs[subgraphs.length - 1] + return createNodeLocatorId(immediateSubgraph.id, nodeId) + } catch { + return null + } + } + + /** + * Extract the node ID from a NodeLocatorId + * @param locatorId The NodeLocatorId + * @returns The local node ID or null if invalid + */ + const nodeLocatorIdToNodeId = ( + locatorId: NodeLocatorId | string + ): NodeId | null => { + const parsed = parseNodeLocatorId(locatorId) + return parsed?.localNodeId ?? null + } + + /** + * Convert a NodeLocatorId to an execution ID for a specific context + * @param locatorId The NodeLocatorId + * @param targetSubgraph The subgraph context (defaults to active subgraph) + * @returns The execution ID or null if the node is not accessible from the target context + */ + const nodeLocatorIdToNodeExecutionId = ( + locatorId: NodeLocatorId | string, + targetSubgraph?: Subgraph + ): NodeExecutionId | null => { + const parsed = parseNodeLocatorId(locatorId) + if (!parsed) return null + + const { subgraphUuid, localNodeId } = parsed + + // If no subgraph UUID, this is a root graph node + if (!subgraphUuid) { + return String(localNodeId) + } + + // Find the path from root to the subgraph with this UUID + const findSubgraphPath = ( + graph: LGraph | Subgraph, + targetUuid: string, + path: NodeId[] = [] + ): NodeId[] | null => { + if (isSubgraph(graph) && graph.id === targetUuid) { + return path + } + + for (const node of graph._nodes) { + if (node.isSubgraphNode() && node.subgraph) { + const result = findSubgraphPath(node.subgraph, targetUuid, [ + ...path, + node.id + ]) + if (result) return result + } + } + + return null + } + + const path = findSubgraphPath(comfyApp.graph, subgraphUuid) + if (!path) return null + + // If we have a target subgraph, check if the path goes through it + if ( + targetSubgraph && + !path.some((_, idx) => { + const subgraphs = getSubgraphsFromInstanceIds( + comfyApp.graph, + path.slice(0, idx + 1).map((id) => String(id)) + ) + return subgraphs[subgraphs.length - 1] === targetSubgraph + }) + ) { + return null + } + + return createNodeExecutionId([...path, localNodeId]) + } + return { activeWorkflow, isActive, @@ -514,7 +661,11 @@ export const useWorkflowStore = defineStore('workflow', () => { isSubgraphActive, activeSubgraph, updateActiveGraph, - executionIdToCurrentId + executionIdToCurrentId, + nodeIdToNodeLocatorId, + nodeExecutionIdToNodeLocatorId, + nodeLocatorIdToNodeId, + nodeLocatorIdToNodeExecutionId } }) satisfies () => WorkflowStore diff --git a/src/types/index.ts b/src/types/index.ts index 20d38a345..377581465 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -31,6 +31,16 @@ export type { ComfyApi } from '@/scripts/api' export type { ComfyApp } from '@/scripts/app' export type { ComfyNodeDef } from '@/schemas/nodeDefSchema' export type { InputSpec } from '@/schemas/nodeDefSchema' +export type { + NodeLocatorId, + NodeExecutionId, + isNodeLocatorId, + isNodeExecutionId, + parseNodeLocatorId, + createNodeLocatorId, + parseNodeExecutionId, + createNodeExecutionId +} from './nodeIdentification' export type { EmbeddingsResponse, ExtensionsResponse, diff --git a/src/types/nodeIdentification.ts b/src/types/nodeIdentification.ts new file mode 100644 index 000000000..038f28449 --- /dev/null +++ b/src/types/nodeIdentification.ts @@ -0,0 +1,123 @@ +import type { NodeId } from '@/schemas/comfyWorkflowSchema' + +/** + * A globally unique identifier for nodes that maintains consistency across + * multiple instances of the same subgraph. + * + * Format: + * - For subgraph nodes: `:` + * - For root graph nodes: `` + * + * Examples: + * - "a1b2c3d4-e5f6-7890-abcd-ef1234567890:123" (node in subgraph) + * - "456" (node in root graph) + * + * Unlike execution IDs which change based on the instance path, + * NodeLocatorId remains the same for all instances of a particular node. + */ +export type NodeLocatorId = string + +/** + * An execution identifier representing a node's position in nested subgraphs. + * Also known as ExecutionId in some contexts. + * + * Format: Colon-separated path of node IDs + * Example: "123:456:789" (node 789 in subgraph 456 in subgraph 123) + */ +export type NodeExecutionId = string + +/** + * Type guard to check if a value is a NodeLocatorId + */ +export function isNodeLocatorId(value: unknown): value is NodeLocatorId { + if (typeof value !== 'string') return false + + // Check if it's a simple node ID (root graph node) + const parts = value.split(':') + if (parts.length === 1) { + // Simple node ID - must be non-empty + return value.length > 0 + } + + // Check for UUID:nodeId format + if (parts.length !== 2) return false + + // Check that node ID part is not empty + if (!parts[1]) return false + + // Basic UUID format check (8-4-4-4-12 hex characters) + const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + return uuidPattern.test(parts[0]) +} + +/** + * Type guard to check if a value is a NodeExecutionId + */ +export function isNodeExecutionId(value: unknown): value is NodeExecutionId { + if (typeof value !== 'string') return false + // Must contain at least one colon to be an execution ID + return value.includes(':') +} + +/** + * Parse a NodeLocatorId into its components + * @param id The NodeLocatorId to parse + * @returns The subgraph UUID and local node ID, or null if invalid + */ +export function parseNodeLocatorId( + id: string +): { subgraphUuid: string | null; localNodeId: NodeId } | null { + if (!isNodeLocatorId(id)) return null + + const parts = id.split(':') + + if (parts.length === 1) { + // Simple node ID (root graph) + return { + subgraphUuid: null, + localNodeId: isNaN(Number(id)) ? id : Number(id) + } + } + + const [subgraphUuid, localNodeId] = parts + return { + subgraphUuid, + localNodeId: isNaN(Number(localNodeId)) ? localNodeId : Number(localNodeId) + } +} + +/** + * Create a NodeLocatorId from components + * @param subgraphUuid The UUID of the immediate containing subgraph + * @param localNodeId The local node ID within that subgraph + * @returns A properly formatted NodeLocatorId + */ +export function createNodeLocatorId( + subgraphUuid: string, + localNodeId: NodeId +): NodeLocatorId { + return `${subgraphUuid}:${localNodeId}` +} + +/** + * Parse a NodeExecutionId into its component node IDs + * @param id The NodeExecutionId to parse + * @returns Array of node IDs from root to target, or null if not an execution ID + */ +export function parseNodeExecutionId(id: string): NodeId[] | null { + if (!isNodeExecutionId(id)) return null + + return id + .split(':') + .map((part) => (isNaN(Number(part)) ? part : Number(part))) +} + +/** + * Create a NodeExecutionId from an array of node IDs + * @param nodeIds Array of node IDs from root to target + * @returns A properly formatted NodeExecutionId + */ +export function createNodeExecutionId(nodeIds: NodeId[]): NodeExecutionId { + return nodeIds.join(':') +} diff --git a/tests-ui/tests/composables/BrowserTabTitle.spec.ts b/tests-ui/tests/composables/BrowserTabTitle.spec.ts index 328f41e80..dd88fb1cd 100644 --- a/tests-ui/tests/composables/BrowserTabTitle.spec.ts +++ b/tests-ui/tests/composables/BrowserTabTitle.spec.ts @@ -3,12 +3,20 @@ import { nextTick, reactive } from 'vue' import { useBrowserTabTitle } from '@/composables/useBrowserTabTitle' +// Mock i18n module +vi.mock('@/i18n', () => ({ + t: (key: string, fallback: string) => + key === 'g.nodesRunning' ? 'nodes running' : fallback +})) + // Mock the execution store const executionStore = reactive({ isIdle: true, executionProgress: 0, executingNode: null as any, - executingNodeProgress: 0 + executingNodeProgress: 0, + nodeProgressStates: {} as any, + activePrompt: null as any }) vi.mock('@/stores/executionStore', () => ({ useExecutionStore: () => executionStore @@ -37,6 +45,8 @@ describe('useBrowserTabTitle', () => { executionStore.executionProgress = 0 executionStore.executingNode = null as any executionStore.executingNodeProgress = 0 + executionStore.nodeProgressStates = {} + executionStore.activePrompt = null // reset setting and workflow stores ;(settingStore.get as any).mockReturnValue('Enabled') @@ -97,13 +107,41 @@ describe('useBrowserTabTitle', () => { expect(document.title).toBe('[30%]ComfyUI') }) - it('shows node execution title when executing a node', async () => { + it('shows node execution title when executing a node using nodeProgressStates', async () => { executionStore.isIdle = false executionStore.executionProgress = 0.4 - executionStore.executingNodeProgress = 0.5 - executionStore.executingNode = { type: 'Foo' } + executionStore.nodeProgressStates = { + '1': { state: 'running', value: 5, max: 10, node: '1', prompt_id: 'test' } + } + executionStore.activePrompt = { + workflow: { + changeTracker: { + activeState: { + nodes: [{ id: 1, type: 'Foo' }] + } + } + } + } useBrowserTabTitle() await nextTick() expect(document.title).toBe('[40%][50%] Foo') }) + + it('shows multiple nodes running when multiple nodes are executing', async () => { + executionStore.isIdle = false + executionStore.executionProgress = 0.4 + executionStore.nodeProgressStates = { + '1': { + state: 'running', + value: 5, + max: 10, + node: '1', + prompt_id: 'test' + }, + '2': { state: 'running', value: 8, max: 10, node: '2', prompt_id: 'test' } + } + useBrowserTabTitle() + await nextTick() + expect(document.title).toBe('[40%][2 nodes running]') + }) }) diff --git a/tests-ui/tests/store/executionStore.test.ts b/tests-ui/tests/store/executionStore.test.ts index 590ce6955..d34bf4867 100644 --- a/tests-ui/tests/store/executionStore.test.ts +++ b/tests-ui/tests/store/executionStore.test.ts @@ -1,11 +1,22 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { app } from '@/scripts/app' import { useExecutionStore } from '@/stores/executionStore' +import { useWorkflowStore } from '@/stores/workflowStore' + +// Mock the workflowStore +vi.mock('@/stores/workflowStore', () => ({ + useWorkflowStore: vi.fn(() => ({ + nodeExecutionIdToNodeLocatorId: vi.fn(), + nodeIdToNodeLocatorId: vi.fn(), + nodeLocatorIdToNodeExecutionId: vi.fn() + })) +})) // Remove any previous global types declare global { - // Empty interface to override any previous declarations + // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface Window {} } @@ -22,12 +33,16 @@ vi.mock('@/composables/node/useNodeProgressText', () => ({ }) })) -// Create a local mock instead of using global to avoid conflicts -const mockApp = { - graph: { - getNodeById: vi.fn() +// Mock the app import with proper implementation +vi.mock('@/scripts/app', () => ({ + app: { + graph: { + getNodeById: vi.fn() + }, + revokePreviews: vi.fn(), + nodePreviewImages: {} } -} +})) describe('executionStore - display_component handling', () => { function createDisplayComponentEvent( @@ -47,7 +62,7 @@ describe('executionStore - display_component handling', () => { function handleDisplayComponentMessage(event: CustomEvent) { const { node_id, component } = event.detail - const node = mockApp.graph.getNodeById(node_id) + const node = vi.mocked(app.graph.getNodeById)(node_id) if (node && component === 'ChatHistoryWidget') { mockShowChatHistory(node) } @@ -60,23 +75,121 @@ describe('executionStore - display_component handling', () => { }) it('handles ChatHistoryWidget display_component messages', () => { - const mockNode = { id: '123' } - mockApp.graph.getNodeById.mockReturnValue(mockNode) + const mockNode = { id: '123' } as any + vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode) const event = createDisplayComponentEvent('123') handleDisplayComponentMessage(event) - expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123') + expect(app.graph.getNodeById).toHaveBeenCalledWith('123') expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode) }) it('does nothing if node is not found', () => { - mockApp.graph.getNodeById.mockReturnValue(null) + vi.mocked(app.graph.getNodeById).mockReturnValue(null) const event = createDisplayComponentEvent('non-existent') handleDisplayComponentMessage(event) - expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent') + expect(app.graph.getNodeById).toHaveBeenCalledWith('non-existent') expect(mockShowChatHistory).not.toHaveBeenCalled() }) }) + +describe('useExecutionStore - NodeLocatorId conversions', () => { + let store: ReturnType + let workflowStore: ReturnType + + beforeEach(() => { + setActivePinia(createPinia()) + + // Create the mock workflowStore instance + const mockWorkflowStore = { + nodeExecutionIdToNodeLocatorId: vi.fn(), + nodeIdToNodeLocatorId: vi.fn(), + nodeLocatorIdToNodeExecutionId: vi.fn() + } + + // Mock the useWorkflowStore function to return our mock + vi.mocked(useWorkflowStore).mockReturnValue(mockWorkflowStore as any) + + workflowStore = mockWorkflowStore as any + store = useExecutionStore() + vi.clearAllMocks() + }) + + describe('executionIdToNodeLocatorId', () => { + it('should convert execution ID to NodeLocatorId', () => { + // Mock subgraph structure + const mockSubgraph = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + _nodes: [] + } + + const mockNode = { + id: 123, + isSubgraphNode: () => true, + subgraph: mockSubgraph + } as any + + // Mock app.graph.getNodeById to return the mock node + vi.mocked(app.graph.getNodeById).mockReturnValue(mockNode) + + const result = store.executionIdToNodeLocatorId('123:456') + + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should convert simple node ID to NodeLocatorId', () => { + const result = store.executionIdToNodeLocatorId('123') + + // For simple node IDs, it should return the ID as-is + expect(result).toBe('123') + }) + + it('should handle numeric node IDs', () => { + const result = store.executionIdToNodeLocatorId(123) + + // For numeric IDs, it should convert to string and return as-is + expect(result).toBe('123') + }) + + it('should return null when conversion fails', () => { + // Mock app.graph.getNodeById to return null (node not found) + vi.mocked(app.graph.getNodeById).mockReturnValue(null) + + // This should throw an error as the node is not found + expect(() => store.executionIdToNodeLocatorId('999:456')).toThrow( + 'Subgraph not found: 999' + ) + }) + }) + + describe('nodeLocatorIdToExecutionId', () => { + it('should convert NodeLocatorId to execution ID', () => { + const mockExecutionId = '123:456' + vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue( + mockExecutionId as any + ) + + const result = store.nodeLocatorIdToExecutionId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + + expect(workflowStore.nodeLocatorIdToNodeExecutionId).toHaveBeenCalledWith( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe(mockExecutionId) + }) + + it('should return null when conversion fails', () => { + vi.spyOn(workflowStore, 'nodeLocatorIdToNodeExecutionId').mockReturnValue( + null + ) + + const result = store.nodeLocatorIdToExecutionId('invalid:format') + + expect(result).toBeNull() + }) + }) +}) diff --git a/tests-ui/tests/store/workflowStore.test.ts b/tests-ui/tests/store/workflowStore.test.ts index 4c5668c8f..579f330ff 100644 --- a/tests-ui/tests/store/workflowStore.test.ts +++ b/tests-ui/tests/store/workflowStore.test.ts @@ -1,3 +1,4 @@ +import type { Subgraph } from '@comfyorg/litegraph' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick } from 'vue' @@ -11,6 +12,7 @@ import { useWorkflowBookmarkStore, useWorkflowStore } from '@/stores/workflowStore' +import { isSubgraph } from '@/utils/typeGuardUtil' // Add mock for api at the top of the file vi.mock('@/scripts/api', () => ({ @@ -26,10 +28,15 @@ vi.mock('@/scripts/api', () => ({ // Mock comfyApp globally for the store setup vi.mock('@/scripts/app', () => ({ app: { - canvas: null // Start with canvas potentially undefined or null + canvas: {} // Start with empty canvas object } })) +// Mock isSubgraph +vi.mock('@/utils/typeGuardUtil', () => ({ + isSubgraph: vi.fn(() => false) +})) + describe('useWorkflowStore', () => { let store: ReturnType let bookmarkStore: ReturnType @@ -518,8 +525,13 @@ describe('useWorkflowStore', () => { { name: 'Level 1 Subgraph' }, { name: 'Level 2 Subgraph' } ] - } - vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any + } as any + vi.mocked(comfyApp.canvas).subgraph = mockSubgraph + + // Mock isSubgraph to return true for our mockSubgraph + vi.mocked(isSubgraph).mockImplementation( + (obj): obj is Subgraph => obj === mockSubgraph + ) // Act: Trigger the update store.updateActiveGraph() @@ -536,8 +548,13 @@ describe('useWorkflowStore', () => { name: 'Initial Subgraph', pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }], isRootGraph: false - } - vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any + } as any + vi.mocked(comfyApp.canvas).subgraph = initialSubgraph + + // Mock isSubgraph to return true for our initialSubgraph + vi.mocked(isSubgraph).mockImplementation( + (obj): obj is Subgraph => obj === initialSubgraph + ) // Trigger initial update based on the *first* workflow opened in beforeEach store.updateActiveGraph() @@ -561,6 +578,11 @@ describe('useWorkflowStore', () => { // This ensures the watcher *does* cause a state change we can assert vi.mocked(comfyApp.canvas).subgraph = undefined + // Mock isSubgraph to return false for undefined + vi.mocked(isSubgraph).mockImplementation( + (_obj): _obj is Subgraph => false + ) + await store.openWorkflow(workflow2) // This changes activeWorkflow and triggers the watch await nextTick() // Allow watcher and potential async operations in updateActiveGraph to complete @@ -569,4 +591,131 @@ describe('useWorkflowStore', () => { expect(store.activeSubgraph).toBeUndefined() }) }) + + describe('NodeLocatorId conversions', () => { + beforeEach(() => { + // Setup mock graph structure with subgraphs + const mockSubgraph = { + id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + _nodes: [] + } + + const mockNode = { + id: 123, + isSubgraphNode: () => true, + subgraph: mockSubgraph + } + + const mockRootGraph = { + _nodes: [mockNode], + subgraphs: new Map([[mockSubgraph.id, mockSubgraph]]), + getNodeById: (id: string | number) => { + if (String(id) === '123') return mockNode + return null + } + } + + vi.mocked(comfyApp).graph = mockRootGraph as any + vi.mocked(comfyApp.canvas).subgraph = mockSubgraph as any + store.activeSubgraph = mockSubgraph as any + }) + + describe('nodeIdToNodeLocatorId', () => { + it('should convert node ID to NodeLocatorId for subgraph nodes', () => { + const result = store.nodeIdToNodeLocatorId(456) + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should return simple node ID for root graph nodes', () => { + store.activeSubgraph = undefined + const result = store.nodeIdToNodeLocatorId(123) + expect(result).toBe('123') + }) + + it('should use provided subgraph instead of active one', () => { + const customSubgraph = { + id: 'custom-uuid-1234-5678-90ab-cdef12345678' + } as any + const result = store.nodeIdToNodeLocatorId(789, customSubgraph) + expect(result).toBe('custom-uuid-1234-5678-90ab-cdef12345678:789') + }) + }) + + describe('nodeExecutionIdToNodeLocatorId', () => { + it('should convert execution ID to NodeLocatorId', () => { + const result = store.nodeExecutionIdToNodeLocatorId('123:456') + expect(result).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890:456') + }) + + it('should return simple node ID for root level nodes', () => { + const result = store.nodeExecutionIdToNodeLocatorId('123') + expect(result).toBe('123') + }) + + it('should return null for invalid execution IDs', () => { + const result = store.nodeExecutionIdToNodeLocatorId('999:456') + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToNodeId', () => { + it('should extract node ID from NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe(456) + }) + + it('should handle string node IDs', () => { + const result = store.nodeLocatorIdToNodeId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:node_1' + ) + expect(result).toBe('node_1') + }) + + it('should handle simple node IDs (root graph)', () => { + const result = store.nodeLocatorIdToNodeId('123') + expect(result).toBe(123) + + const stringResult = store.nodeLocatorIdToNodeId('node_1') + expect(stringResult).toBe('node_1') + }) + + it('should return null for invalid NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeId('invalid:format') + expect(result).toBeNull() + }) + }) + + describe('nodeLocatorIdToNodeExecutionId', () => { + it('should convert NodeLocatorId to execution ID', () => { + // Need to mock isSubgraph to identify our mockSubgraph + vi.mocked(isSubgraph).mockImplementation((obj): obj is Subgraph => { + return obj === store.activeSubgraph + }) + + const result = store.nodeLocatorIdToNodeExecutionId( + 'a1b2c3d4-e5f6-7890-abcd-ef1234567890:456' + ) + expect(result).toBe('123:456') + }) + + it('should handle simple node IDs (root graph)', () => { + const result = store.nodeLocatorIdToNodeExecutionId('123') + expect(result).toBe('123') + }) + + it('should return null for unknown subgraph UUID', () => { + const result = store.nodeLocatorIdToNodeExecutionId( + 'unknown-uuid-1234-5678-90ab-cdef12345678:456' + ) + expect(result).toBeNull() + }) + + it('should return null for invalid NodeLocatorId', () => { + const result = store.nodeLocatorIdToNodeExecutionId('invalid:format') + expect(result).toBeNull() + }) + }) + }) }) diff --git a/tests-ui/tests/types/nodeIdentification.test.ts b/tests-ui/tests/types/nodeIdentification.test.ts new file mode 100644 index 000000000..0d9aa647b --- /dev/null +++ b/tests-ui/tests/types/nodeIdentification.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest' + +import type { NodeId } from '@/schemas/comfyWorkflowSchema' +import { + type NodeLocatorId, + createNodeExecutionId, + createNodeLocatorId, + isNodeExecutionId, + isNodeLocatorId, + parseNodeExecutionId, + parseNodeLocatorId +} from '@/types/nodeIdentification' + +describe('nodeIdentification', () => { + describe('NodeLocatorId', () => { + const validUuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const validNodeId = '123' + const validNodeLocatorId = `${validUuid}:${validNodeId}` as NodeLocatorId + + describe('isNodeLocatorId', () => { + it('should return true for valid NodeLocatorId', () => { + expect(isNodeLocatorId(validNodeLocatorId)).toBe(true) + expect(isNodeLocatorId(`${validUuid}:456`)).toBe(true) + expect(isNodeLocatorId(`${validUuid}:node_1`)).toBe(true) + // Simple node IDs (root graph) + expect(isNodeLocatorId('123')).toBe(true) + expect(isNodeLocatorId('node_1')).toBe(true) + expect(isNodeLocatorId('5')).toBe(true) + }) + + it('should return false for invalid formats', () => { + expect(isNodeLocatorId('123:456')).toBe(false) // No UUID in first part + expect(isNodeLocatorId('not-a-uuid:123')).toBe(false) + expect(isNodeLocatorId('')).toBe(false) // Empty string + expect(isNodeLocatorId(':123')).toBe(false) // Empty UUID + expect(isNodeLocatorId(`${validUuid}:`)).toBe(false) // Empty node ID + expect(isNodeLocatorId(`${validUuid}:123:456`)).toBe(false) // Too many parts + expect(isNodeLocatorId(123)).toBe(false) // Not a string + expect(isNodeLocatorId(null)).toBe(false) + expect(isNodeLocatorId(undefined)).toBe(false) + }) + + it('should validate UUID format correctly', () => { + // Valid UUID formats + expect( + isNodeLocatorId('00000000-0000-0000-0000-000000000000:123') + ).toBe(true) + expect( + isNodeLocatorId('A1B2C3D4-E5F6-7890-ABCD-EF1234567890:123') + ).toBe(true) + + // Invalid UUID formats + expect(isNodeLocatorId('00000000-0000-0000-0000-00000000000:123')).toBe( + false + ) // Too short + expect( + isNodeLocatorId('00000000-0000-0000-0000-0000000000000:123') + ).toBe(false) // Too long + expect( + isNodeLocatorId('00000000_0000_0000_0000_000000000000:123') + ).toBe(false) // Wrong separator + expect( + isNodeLocatorId('g0000000-0000-0000-0000-000000000000:123') + ).toBe(false) // Invalid hex + }) + }) + + describe('parseNodeLocatorId', () => { + it('should parse valid NodeLocatorId', () => { + const result = parseNodeLocatorId(validNodeLocatorId) + expect(result).toEqual({ + subgraphUuid: validUuid, + localNodeId: 123 + }) + }) + + it('should handle string node IDs', () => { + const stringNodeId = `${validUuid}:node_1` + const result = parseNodeLocatorId(stringNodeId) + expect(result).toEqual({ + subgraphUuid: validUuid, + localNodeId: 'node_1' + }) + }) + + it('should handle simple node IDs (root graph)', () => { + const result = parseNodeLocatorId('123') + expect(result).toEqual({ + subgraphUuid: null, + localNodeId: 123 + }) + + const stringResult = parseNodeLocatorId('node_1') + expect(stringResult).toEqual({ + subgraphUuid: null, + localNodeId: 'node_1' + }) + }) + + it('should return null for invalid formats', () => { + expect(parseNodeLocatorId('123:456')).toBeNull() // No UUID in first part + expect(parseNodeLocatorId('')).toBeNull() + }) + }) + + describe('createNodeLocatorId', () => { + it('should create NodeLocatorId from components', () => { + const result = createNodeLocatorId(validUuid, 123) + expect(result).toBe(validNodeLocatorId) + expect(isNodeLocatorId(result)).toBe(true) + }) + + it('should handle string node IDs', () => { + const result = createNodeLocatorId(validUuid, 'node_1') + expect(result).toBe(`${validUuid}:node_1`) + expect(isNodeLocatorId(result)).toBe(true) + }) + }) + }) + + describe('NodeExecutionId', () => { + describe('isNodeExecutionId', () => { + it('should return true for execution IDs', () => { + expect(isNodeExecutionId('123:456')).toBe(true) + expect(isNodeExecutionId('123:456:789')).toBe(true) + expect(isNodeExecutionId('node_1:node_2')).toBe(true) + }) + + it('should return false for non-execution IDs', () => { + expect(isNodeExecutionId('123')).toBe(false) + expect(isNodeExecutionId('node_1')).toBe(false) + expect(isNodeExecutionId('')).toBe(false) + expect(isNodeExecutionId(123)).toBe(false) + expect(isNodeExecutionId(null)).toBe(false) + expect(isNodeExecutionId(undefined)).toBe(false) + }) + }) + + describe('parseNodeExecutionId', () => { + it('should parse execution IDs correctly', () => { + expect(parseNodeExecutionId('123:456')).toEqual([123, 456]) + expect(parseNodeExecutionId('123:456:789')).toEqual([123, 456, 789]) + expect(parseNodeExecutionId('node_1:node_2')).toEqual([ + 'node_1', + 'node_2' + ]) + expect(parseNodeExecutionId('123:node_2:456')).toEqual([ + 123, + 'node_2', + 456 + ]) + }) + + it('should return null for non-execution IDs', () => { + expect(parseNodeExecutionId('123')).toBeNull() + expect(parseNodeExecutionId('')).toBeNull() + }) + }) + + describe('createNodeExecutionId', () => { + it('should create execution IDs from node arrays', () => { + expect(createNodeExecutionId([123, 456])).toBe('123:456') + expect(createNodeExecutionId([123, 456, 789])).toBe('123:456:789') + expect(createNodeExecutionId(['node_1', 'node_2'])).toBe( + 'node_1:node_2' + ) + expect(createNodeExecutionId([123, 'node_2', 456])).toBe( + '123:node_2:456' + ) + }) + + it('should handle single node ID', () => { + const result = createNodeExecutionId([123]) + expect(result).toBe('123') + // Single node IDs are not execution IDs + expect(isNodeExecutionId(result)).toBe(false) + }) + + it('should handle empty array', () => { + expect(createNodeExecutionId([])).toBe('') + }) + }) + }) + + describe('Integration tests', () => { + it('should round-trip NodeLocatorId correctly', () => { + const uuid = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + const nodeId: NodeId = 123 + + const locatorId = createNodeLocatorId(uuid, nodeId) + const parsed = parseNodeLocatorId(locatorId) + + expect(parsed).toBeTruthy() + expect(parsed!.subgraphUuid).toBe(uuid) + expect(parsed!.localNodeId).toBe(nodeId) + }) + + it('should round-trip NodeExecutionId correctly', () => { + const nodeIds: NodeId[] = [123, 'node_2', 456] + + const executionId = createNodeExecutionId(nodeIds) + const parsed = parseNodeExecutionId(executionId) + + expect(parsed).toEqual(nodeIds) + }) + }) +})