mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-22 07:19:41 +00:00
[refactor] Improve workflow domain organization (#5584)
* [refactor] move workflow domain to its own folder * [refactor] Fix workflow platform architecture organization - Move workflow rendering functionality to renderer/thumbnail domain - Rename ui folder to management for better semantic clarity - Update all import paths to reflect proper domain boundaries - Fix test imports to use new structure Architecture improvements: - rendering → renderer/thumbnail (belongs with other rendering logic) - ui → management (better name for state management and UI integration) This ensures proper separation of concerns and domain boundaries. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * [fix] Resolve circular dependency between nodeDefStore and subgraphStore * [fix] Update browser test imports to use new workflow platform paths --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { markRaw } from 'vue'
|
||||
|
||||
import WorkflowsSidebarTab from '@/components/sidebar/tabs/WorkflowsSidebarTab.vue'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useWorkflowsSidebarTab = (): SidebarTabExtension => {
|
||||
const settingStore = useSettingStore()
|
||||
const workflowStore = useWorkflowStore()
|
||||
return {
|
||||
id: 'workflows',
|
||||
icon: 'icon-[comfy--workflow]',
|
||||
iconBadge: () => {
|
||||
if (
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition') !== 'Sidebar'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const value = workflowStore.openWorkflows.length.toString()
|
||||
return value === '0' ? null : value
|
||||
},
|
||||
title: 'sideToolbar.workflows',
|
||||
tooltip: 'sideToolbar.workflows',
|
||||
label: 'sideToolbar.labels.workflows',
|
||||
component: markRaw(WorkflowsSidebarTab),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
765
src/platform/workflow/management/stores/workflowStore.ts
Normal file
765
src/platform/workflow/management/stores/workflowStore.ts
Normal file
@@ -0,0 +1,765 @@
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { defineStore } from 'pinia'
|
||||
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { useWorkflowThumbnail } from '@/renderer/thumbnail/useWorkflowThumbnail'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
import { defaultGraphJSON } from '@/scripts/defaultGraph'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { UserFile } from '@/stores/userFileStore'
|
||||
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'
|
||||
|
||||
export class ComfyWorkflow extends UserFile {
|
||||
static readonly basePath: string = 'workflows/'
|
||||
readonly tintCanvasBg?: string
|
||||
|
||||
/**
|
||||
* The change tracker for the workflow. Non-reactive raw object.
|
||||
*/
|
||||
changeTracker: ChangeTracker | null = null
|
||||
/**
|
||||
* Whether the workflow has been modified comparing to the initial state.
|
||||
*/
|
||||
_isModified: boolean = false
|
||||
|
||||
/**
|
||||
* @param options The path, modified, and size of the workflow.
|
||||
* Note: path is the full path, including the 'workflows/' prefix.
|
||||
*/
|
||||
constructor(options: { path: string; modified: number; size: number }) {
|
||||
super(options.path, options.modified, options.size)
|
||||
}
|
||||
|
||||
override get key() {
|
||||
return this.path.substring(ComfyWorkflow.basePath.length)
|
||||
}
|
||||
|
||||
get activeState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.activeState ?? null
|
||||
}
|
||||
|
||||
get initialState(): ComfyWorkflowJSON | null {
|
||||
return this.changeTracker?.initialState ?? null
|
||||
}
|
||||
|
||||
override get isLoaded(): boolean {
|
||||
return this.changeTracker !== null
|
||||
}
|
||||
|
||||
override get isModified(): boolean {
|
||||
return this._isModified
|
||||
}
|
||||
|
||||
override set isModified(value: boolean) {
|
||||
this._isModified = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the workflow content from remote storage. Directly returns the loaded
|
||||
* workflow if the content is already loaded.
|
||||
*
|
||||
* @param force Whether to force loading the content even if it is already loaded.
|
||||
* @returns this
|
||||
*/
|
||||
override async load({
|
||||
force = false
|
||||
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
|
||||
await super.load({ force })
|
||||
if (!force && this.isLoaded) return this as LoadedComfyWorkflow
|
||||
|
||||
if (!this.originalContent) {
|
||||
throw new Error('[ASSERT] Workflow content should be loaded')
|
||||
}
|
||||
|
||||
// Note: originalContent is populated by super.load()
|
||||
console.debug('load and start tracking of workflow', this.path)
|
||||
this.changeTracker = markRaw(
|
||||
new ChangeTracker(
|
||||
this,
|
||||
/* initialState= */ JSON.parse(this.originalContent)
|
||||
)
|
||||
)
|
||||
return this as LoadedComfyWorkflow
|
||||
}
|
||||
|
||||
override unload(): void {
|
||||
console.debug('unload workflow', this.path)
|
||||
this.changeTracker = null
|
||||
super.unload()
|
||||
}
|
||||
|
||||
override async save() {
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
// Force save to ensure the content is updated in remote storage incase
|
||||
// the isModified state is screwed by changeTracker.
|
||||
const ret = await super.save({ force: true })
|
||||
this.changeTracker?.reset()
|
||||
this.isModified = false
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the workflow as a new file.
|
||||
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
|
||||
* @returns this
|
||||
*/
|
||||
override async saveAs(path: string) {
|
||||
this.content = JSON.stringify(this.activeState)
|
||||
return await super.saveAs(path)
|
||||
}
|
||||
|
||||
async promptSave(): Promise<string | null> {
|
||||
return await useDialogService().prompt({
|
||||
title: t('workflowService.saveWorkflow'),
|
||||
message: t('workflowService.enterFilename') + ':',
|
||||
defaultValue: this.filename
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export interface LoadedComfyWorkflow extends ComfyWorkflow {
|
||||
isLoaded: true
|
||||
originalContent: string
|
||||
content: string
|
||||
changeTracker: ChangeTracker
|
||||
initialState: ComfyWorkflowJSON
|
||||
activeState: ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposed store interface for the workflow store.
|
||||
* Explicitly typed to avoid trigger following error:
|
||||
* error TS7056: The inferred type of this node exceeds the maximum length the
|
||||
* compiler will serialize. An explicit type annotation is needed.
|
||||
*/
|
||||
interface WorkflowStore {
|
||||
activeWorkflow: LoadedComfyWorkflow | null
|
||||
attachWorkflow: (workflow: ComfyWorkflow, openIndex?: number) => void
|
||||
isActive: (workflow: ComfyWorkflow) => boolean
|
||||
openWorkflows: ComfyWorkflow[]
|
||||
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
|
||||
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
|
||||
openWorkflowsInBackground: (paths: {
|
||||
left?: string[]
|
||||
right?: string[]
|
||||
}) => void
|
||||
isOpen: (workflow: ComfyWorkflow) => boolean
|
||||
isBusy: boolean
|
||||
closeWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
createTemporary: (
|
||||
path?: string,
|
||||
workflowData?: ComfyWorkflowJSON
|
||||
) => ComfyWorkflow
|
||||
renameWorkflow: (workflow: ComfyWorkflow, newPath: string) => Promise<void>
|
||||
deleteWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
saveWorkflow: (workflow: ComfyWorkflow) => Promise<void>
|
||||
|
||||
workflows: ComfyWorkflow[]
|
||||
bookmarkedWorkflows: ComfyWorkflow[]
|
||||
persistedWorkflows: ComfyWorkflow[]
|
||||
modifiedWorkflows: ComfyWorkflow[]
|
||||
getWorkflowByPath: (path: string) => ComfyWorkflow | null
|
||||
syncWorkflows: (dir?: string) => Promise<void>
|
||||
reorderWorkflows: (from: number, to: number) => void
|
||||
|
||||
/** `true` if any subgraph is currently being viewed. */
|
||||
isSubgraphActive: boolean
|
||||
activeSubgraph: Subgraph | undefined
|
||||
/** 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', () => {
|
||||
/**
|
||||
* Detach the workflow from the store. lightweight helper function.
|
||||
* @param workflow The workflow to detach.
|
||||
* @returns The index of the workflow in the openWorkflowPaths array, or -1 if the workflow was not open.
|
||||
*/
|
||||
const detachWorkflow = (workflow: ComfyWorkflow) => {
|
||||
delete workflowLookup.value[workflow.path]
|
||||
const index = openWorkflowPaths.value.indexOf(workflow.path)
|
||||
if (index !== -1) {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach the workflow to the store. lightweight helper function.
|
||||
* @param workflow The workflow to attach.
|
||||
* @param openIndex The index to open the workflow at.
|
||||
*/
|
||||
const attachWorkflow = (workflow: ComfyWorkflow, openIndex: number = -1) => {
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
|
||||
if (openIndex !== -1) {
|
||||
openWorkflowPaths.value.splice(openIndex, 0, workflow.path)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active workflow currently being edited.
|
||||
*/
|
||||
const activeWorkflow = ref<LoadedComfyWorkflow | null>(null)
|
||||
const isActive = (workflow: ComfyWorkflow) =>
|
||||
activeWorkflow.value?.path === workflow.path
|
||||
/**
|
||||
* All workflows.
|
||||
*/
|
||||
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
|
||||
const workflows = computed<ComfyWorkflow[]>(() =>
|
||||
Object.values(workflowLookup.value)
|
||||
)
|
||||
const getWorkflowByPath = (path: string): ComfyWorkflow | null =>
|
||||
workflowLookup.value[path] ?? null
|
||||
|
||||
/**
|
||||
* The paths of the open workflows. It is setup as a ref to allow user
|
||||
* to reorder the workflows opened.
|
||||
*/
|
||||
const openWorkflowPaths = ref<string[]>([])
|
||||
const openWorkflowPathSet = computed(() => new Set(openWorkflowPaths.value))
|
||||
const openWorkflows = computed(() =>
|
||||
openWorkflowPaths.value.map((path) => workflowLookup.value[path])
|
||||
)
|
||||
const reorderWorkflows = (from: number, to: number) => {
|
||||
const movedTab = openWorkflowPaths.value[from]
|
||||
openWorkflowPaths.value.splice(from, 1)
|
||||
openWorkflowPaths.value.splice(to, 0, movedTab)
|
||||
}
|
||||
const isOpen = (workflow: ComfyWorkflow) =>
|
||||
openWorkflowPathSet.value.has(workflow.path)
|
||||
|
||||
/**
|
||||
* Add paths to the list of open workflow paths without loading the files
|
||||
* or changing the active workflow.
|
||||
*
|
||||
* @param paths - The workflows to open, specified as:
|
||||
* - `left`: Workflows to be added to the left.
|
||||
* - `right`: Workflows to be added to the right.
|
||||
*
|
||||
* Invalid paths (non-strings or paths not found in `workflowLookup.value`)
|
||||
* will be ignored. Duplicate paths are automatically removed.
|
||||
*/
|
||||
const openWorkflowsInBackground = (paths: {
|
||||
left?: string[]
|
||||
right?: string[]
|
||||
}) => {
|
||||
const { left = [], right = [] } = paths
|
||||
if (!left.length && !right.length) return
|
||||
|
||||
const isValidPath = (
|
||||
path: unknown
|
||||
): path is keyof typeof workflowLookup.value =>
|
||||
typeof path === 'string' && path in workflowLookup.value
|
||||
|
||||
openWorkflowPaths.value = _.union(
|
||||
left,
|
||||
openWorkflowPaths.value,
|
||||
right
|
||||
).filter(isValidPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the workflow as the active workflow.
|
||||
* @param workflow The workflow to open.
|
||||
*/
|
||||
const openWorkflow = async (
|
||||
workflow: ComfyWorkflow
|
||||
): Promise<LoadedComfyWorkflow> => {
|
||||
if (isActive(workflow)) return workflow as LoadedComfyWorkflow
|
||||
|
||||
if (!openWorkflowPaths.value.includes(workflow.path)) {
|
||||
openWorkflowPaths.value.push(workflow.path)
|
||||
}
|
||||
const loadedWorkflow = await workflow.load()
|
||||
activeWorkflow.value = loadedWorkflow
|
||||
comfyApp.canvas.bg_tint = loadedWorkflow.tintCanvasBg
|
||||
console.debug('[workflowStore] open workflow', workflow.path)
|
||||
return loadedWorkflow
|
||||
}
|
||||
|
||||
const getUnconflictedPath = (basePath: string): string => {
|
||||
const { directory, filename, suffix } = getPathDetails(basePath)
|
||||
let counter = 2
|
||||
let newPath = basePath
|
||||
while (workflowLookup.value[newPath]) {
|
||||
newPath = `${directory}/${filename} (${counter}).${suffix}`
|
||||
counter++
|
||||
}
|
||||
return newPath
|
||||
}
|
||||
const saveAs = (
|
||||
existingWorkflow: ComfyWorkflow,
|
||||
path: string
|
||||
): ComfyWorkflow => {
|
||||
const workflow: ComfyWorkflow = new (existingWorkflow.constructor as any)({
|
||||
path,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
workflow.originalContent = workflow.content = existingWorkflow.content
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
}
|
||||
|
||||
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
|
||||
const fullPath = getUnconflictedPath(
|
||||
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
|
||||
)
|
||||
const existingWorkflow = workflows.value.find((w) => w.fullFilename == path)
|
||||
if (
|
||||
path &&
|
||||
workflowData &&
|
||||
existingWorkflow?.changeTracker &&
|
||||
!existingWorkflow.directory.startsWith(
|
||||
ComfyWorkflow.basePath.slice(0, -1)
|
||||
)
|
||||
) {
|
||||
existingWorkflow.changeTracker.reset(workflowData)
|
||||
return existingWorkflow
|
||||
}
|
||||
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: fullPath,
|
||||
modified: Date.now(),
|
||||
size: -1
|
||||
})
|
||||
|
||||
workflow.originalContent = workflow.content = workflowData
|
||||
? JSON.stringify(workflowData)
|
||||
: defaultGraphJSON
|
||||
|
||||
workflowLookup.value[workflow.path] = workflow
|
||||
return workflow
|
||||
}
|
||||
|
||||
const closeWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
openWorkflowPaths.value = openWorkflowPaths.value.filter(
|
||||
(path) => path !== workflow.path
|
||||
)
|
||||
if (workflow.isTemporary) {
|
||||
// Clear thumbnail when temporary workflow is closed
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} else {
|
||||
workflow.unload()
|
||||
}
|
||||
console.debug('[workflowStore] close workflow', workflow.path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workflow at the given index shift from the active workflow.
|
||||
* @param shift The shift to the next workflow. Positive for next, negative for previous.
|
||||
* @returns The next workflow or null if the shift is out of bounds.
|
||||
*/
|
||||
const openedWorkflowIndexShift = (shift: number): ComfyWorkflow | null => {
|
||||
const index = openWorkflowPaths.value.indexOf(
|
||||
activeWorkflow.value?.path ?? ''
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
const length = openWorkflows.value.length
|
||||
const nextIndex = (index + shift + length) % length
|
||||
const nextWorkflow = openWorkflows.value[nextIndex]
|
||||
return nextWorkflow ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const persistedWorkflows = computed(() =>
|
||||
Array.from(workflows.value).filter(
|
||||
(workflow) =>
|
||||
workflow.isPersisted && !workflow.path.startsWith('subgraphs/')
|
||||
)
|
||||
)
|
||||
const syncWorkflows = async (dir: string = '') => {
|
||||
await syncEntities(
|
||||
dir ? 'workflows/' + dir : 'workflows',
|
||||
workflowLookup.value,
|
||||
(file) =>
|
||||
new ComfyWorkflow({
|
||||
path: file.path,
|
||||
modified: file.modified,
|
||||
size: file.size
|
||||
}),
|
||||
(existingWorkflow, file) => {
|
||||
existingWorkflow.lastModified = file.modified
|
||||
existingWorkflow.size = file.size
|
||||
existingWorkflow.unload()
|
||||
},
|
||||
/* exclude */ (workflow) => workflow.isTemporary
|
||||
)
|
||||
}
|
||||
|
||||
const bookmarkStore = useWorkflowBookmarkStore()
|
||||
const bookmarkedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) =>
|
||||
bookmarkStore.isBookmarked(workflow.path)
|
||||
)
|
||||
)
|
||||
const modifiedWorkflows = computed(() =>
|
||||
workflows.value.filter((workflow) => workflow.isModified)
|
||||
)
|
||||
|
||||
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
|
||||
const isBusy = ref<boolean>(false)
|
||||
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
|
||||
|
||||
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
// Capture all needed values upfront
|
||||
const oldPath = workflow.path
|
||||
const oldKey = workflow.key
|
||||
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
|
||||
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
// Perform the actual rename operation first
|
||||
try {
|
||||
await workflow.rename(newPath)
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
|
||||
// Move thumbnail from old key to new key (using workflow keys, not full paths)
|
||||
const newKey = workflow.key
|
||||
moveWorkflowThumbnail(oldKey, newKey)
|
||||
// Update bookmarks
|
||||
if (wasBookmarked) {
|
||||
await bookmarkStore.setBookmarked(oldPath, false)
|
||||
await bookmarkStore.setBookmarked(newPath, true)
|
||||
}
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
await workflow.delete()
|
||||
if (bookmarkStore.isBookmarked(workflow.path)) {
|
||||
await bookmarkStore.setBookmarked(workflow.path, false)
|
||||
}
|
||||
// Clear thumbnail when workflow is deleted
|
||||
clearThumbnail(workflow.key)
|
||||
delete workflowLookup.value[workflow.path]
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a workflow.
|
||||
* @param workflow The workflow to save.
|
||||
*/
|
||||
const saveWorkflow = async (workflow: ComfyWorkflow) => {
|
||||
isBusy.value = true
|
||||
try {
|
||||
// Detach the workflow and re-attach to force refresh the tree objects.
|
||||
const openIndex = detachWorkflow(workflow)
|
||||
try {
|
||||
await workflow.save()
|
||||
} finally {
|
||||
attachWorkflow(workflow, openIndex)
|
||||
}
|
||||
} finally {
|
||||
isBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** @see WorkflowStore.isSubgraphActive */
|
||||
const isSubgraphActive = ref(false)
|
||||
|
||||
/** @see WorkflowStore.activeSubgraph */
|
||||
const activeSubgraph = shallowRef<Raw<Subgraph>>()
|
||||
|
||||
/** @see WorkflowStore.updateActiveGraph */
|
||||
const updateActiveGraph = () => {
|
||||
const subgraph = comfyApp.canvas?.subgraph
|
||||
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
|
||||
if (!comfyApp.canvas) return
|
||||
|
||||
isSubgraphActive.value = isSubgraph(subgraph)
|
||||
}
|
||||
|
||||
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
|
||||
const node = graph.getNodeById(id)
|
||||
if (node?.isSubgraphNode()) return node.subgraph
|
||||
}
|
||||
|
||||
const getSubgraphsFromInstanceIds = (
|
||||
currentGraph: LGraph | Subgraph,
|
||||
subgraphNodeIds: string[],
|
||||
subgraphs: Subgraph[] = []
|
||||
): Subgraph[] => {
|
||||
const currentPart = subgraphNodeIds.shift()
|
||||
if (currentPart === undefined) return subgraphs
|
||||
|
||||
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
|
||||
if (subgraph === undefined) throw new Error('Subgraph not found')
|
||||
|
||||
subgraphs.push(subgraph)
|
||||
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
|
||||
}
|
||||
|
||||
const executionIdToCurrentId = (id: string) => {
|
||||
const subgraph = activeSubgraph.value
|
||||
|
||||
// Short-circuit: ID belongs to the parent workflow / no active subgraph
|
||||
if (!id.includes(':')) {
|
||||
return !subgraph ? id : undefined
|
||||
} else if (!subgraph) {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the execution ID (e.g., "123:456:789")
|
||||
const subgraphNodeIds = id.split(':')
|
||||
|
||||
// Start from the root graph
|
||||
const { graph } = comfyApp
|
||||
|
||||
// If the last subgraph is the active subgraph, return the node ID
|
||||
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
|
||||
if (subgraphs.at(-1) === subgraph) {
|
||||
return subgraphNodeIds.at(-1)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
attachWorkflow,
|
||||
isActive,
|
||||
openWorkflows,
|
||||
openedWorkflowIndexShift,
|
||||
openWorkflow,
|
||||
openWorkflowsInBackground,
|
||||
isOpen,
|
||||
isBusy,
|
||||
closeWorkflow,
|
||||
createTemporary,
|
||||
renameWorkflow,
|
||||
deleteWorkflow,
|
||||
saveAs,
|
||||
saveWorkflow,
|
||||
reorderWorkflows,
|
||||
|
||||
workflows,
|
||||
bookmarkedWorkflows,
|
||||
persistedWorkflows,
|
||||
modifiedWorkflows,
|
||||
getWorkflowByPath,
|
||||
syncWorkflows,
|
||||
|
||||
isSubgraphActive,
|
||||
activeSubgraph,
|
||||
updateActiveGraph,
|
||||
executionIdToCurrentId,
|
||||
nodeIdToNodeLocatorId,
|
||||
nodeExecutionIdToNodeLocatorId,
|
||||
nodeLocatorIdToNodeId,
|
||||
nodeLocatorIdToNodeExecutionId
|
||||
}
|
||||
}) satisfies () => WorkflowStore
|
||||
|
||||
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
|
||||
const bookmarks = ref<Set<string>>(new Set())
|
||||
|
||||
const isBookmarked = (path: string) => bookmarks.value.has(path)
|
||||
|
||||
const loadBookmarks = async () => {
|
||||
const resp = await api.getUserData('workflows/.index.json')
|
||||
if (resp.status === 200) {
|
||||
const info = await resp.json()
|
||||
bookmarks.value = new Set(info?.favorites ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
const saveBookmarks = async () => {
|
||||
await api.storeUserData('workflows/.index.json', {
|
||||
favorites: Array.from(bookmarks.value)
|
||||
})
|
||||
}
|
||||
|
||||
const setBookmarked = async (path: string, value: boolean) => {
|
||||
if (bookmarks.value.has(path) === value) return
|
||||
if (value) {
|
||||
bookmarks.value.add(path)
|
||||
} else {
|
||||
bookmarks.value.delete(path)
|
||||
}
|
||||
await saveBookmarks()
|
||||
}
|
||||
|
||||
const toggleBookmarked = async (path: string) => {
|
||||
await setBookmarked(path, !bookmarks.value.has(path))
|
||||
}
|
||||
|
||||
return {
|
||||
isBookmarked,
|
||||
loadBookmarks,
|
||||
saveBookmarks,
|
||||
setBookmarked,
|
||||
toggleBookmarked
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user