[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:
Christian Byrne
2025-09-15 02:22:37 -07:00
committed by GitHub
parent bbff9c8217
commit ca312fd1ea
101 changed files with 251 additions and 213 deletions

View File

@@ -0,0 +1,414 @@
import { toRaw } from 'vue'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/useWorkflowThumbnail'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
import { useDialogService } from '@/services/dialogService'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
export const useWorkflowService = () => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
let filename = await dialogService.prompt({
title: t('workflowService.exportWorkflow'),
message: t('workflowService.enterFilename') + ':',
defaultValue: defaultName
})
if (!filename) return null
if (!filename.toLowerCase().endsWith('.json')) {
filename += '.json'
}
return filename
}
return defaultName
}
/**
* Adds scale and offset from litegraph canvas to the workflow JSON.
* @param workflow The workflow to add the view restore data to
*/
function addViewRestore(workflow: ComfyWorkflowJSON) {
if (!settingStore.get('Comfy.EnableWorkflowViewRestore')) return
const { offset, scale } = app.canvas.ds
const [x, y] = offset
workflow.extra ??= {}
workflow.extra.ds = { scale, offset: [x, y] }
}
/**
* Export the current workflow as a JSON file
* @param filename The filename to save the workflow as
* @param promptProperty The property of the prompt to export
*/
const exportWorkflow = async (
filename: string,
promptProperty: 'workflow' | 'output'
): Promise<void> => {
const workflow = workflowStore.activeWorkflow
if (workflow?.path) {
filename = workflow.filename
}
const p = await app.graphToPrompt()
addViewRestore(p.workflow)
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = await getFilename(filename)
if (!file) return
downloadBlob(file, blob)
}
/**
* Save a workflow as a new file
* @param workflow The workflow to save
*/
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
const newFilename = await workflow.promptSave()
if (!newFilename) return
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
type: 'overwrite',
message: t('sideToolbar.workflowTab.confirmOverwrite'),
itemList: [newPath]
})
if (res !== true) return
if (existingWorkflow.path === workflow.path) {
await saveWorkflow(workflow)
return
}
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return
}
if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
// Generate new id when saving existing workflow as a new file
const id = generateUUID()
const state = JSON.parse(
JSON.stringify(workflow.activeState)
) as ComfyWorkflowJSON
state.id = id
const tempWorkflow = workflowStore.saveAs(workflow, newPath)
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
}
/**
* Save a workflow
* @param workflow The workflow to save
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
await workflowStore.saveWorkflow(workflow)
}
}
/**
* Load the default workflow
*/
const loadDefaultWorkflow = async () => {
await app.loadGraphData(defaultGraph)
}
/**
* Load a blank workflow
*/
const loadBlankWorkflow = async () => {
await app.loadGraphData(blankGraph)
}
/**
* Reload the current workflow
* This is used to refresh the node definitions update, e.g. when the locale changes.
*/
const reloadCurrentWorkflow = async () => {
const workflow = workflowStore.activeWorkflow
if (workflow) {
await openWorkflow(workflow, { force: true })
}
}
/**
* Open a workflow in the current workspace
* @param workflow The workflow to open
* @param options The options for opening the workflow
*/
const openWorkflow = async (
workflow: ComfyWorkflow,
options: { force: boolean } = { force: false }
) => {
if (workflowStore.isActive(workflow) && !options.force) return
const loadFromRemote = !workflow.isLoaded
if (loadFromRemote) {
await workflow.load()
}
await app.loadGraphData(
toRaw(workflow.activeState) as ComfyWorkflowJSON,
/* clean=*/ true,
/* restore_view=*/ true,
workflow,
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote,
checkForRerouteMigration: false
}
)
}
/**
* Close a workflow with confirmation if there are unsaved changes
* @param workflow The workflow to close
* @returns true if the workflow was closed, false if the user cancelled
*/
const closeWorkflow = async (
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean; hint?: string } = {
warnIfUnsaved: true
}
): Promise<boolean> => {
if (workflow.isModified && options.warnIfUnsaved) {
const confirmed = await dialogService.confirm({
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
await saveWorkflow(workflow)
}
}
// If this is the last workflow, create a new default temporary workflow
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()
}
// If this is the active workflow, load the next workflow
if (workflowStore.isActive(workflow)) {
await loadNextOpenedWorkflow()
}
await workflowStore.closeWorkflow(workflow)
return true
}
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
await workflowStore.renameWorkflow(workflow, newPath)
}
/**
* Delete a workflow
* @param workflow The workflow to delete
* @returns `true` if the workflow was deleted, `false` if the user cancelled
*/
const deleteWorkflow = async (
workflow: ComfyWorkflow,
silent = false
): Promise<boolean> => {
const bypassConfirm = !settingStore.get('Comfy.Workflow.ConfirmDelete')
let confirmed: boolean | null = bypassConfirm || silent
if (!confirmed) {
confirmed = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmDeleteTitle'),
type: 'delete',
message: t('sideToolbar.workflowTab.confirmDelete'),
itemList: [workflow.path]
})
if (!confirmed) return false
}
if (workflowStore.isOpen(workflow)) {
const closed = await closeWorkflow(workflow, {
warnIfUnsaved: !confirmed
})
if (!closed) return false
}
await workflowStore.deleteWorkflow(workflow)
if (!silent) {
toastStore.add({
severity: 'info',
summary: t('sideToolbar.workflowTab.deleted'),
life: 1000
})
}
return true
}
/**
* This method is called before loading a new graph.
* There are 3 major functions that loads a new graph to the graph editor:
* 1. loadGraphData
* 2. loadApiJson
* 3. importA1111
*
* This function is used to save the current workflow states before loading
* a new graph.
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
}
}
/**
* Set the active workflow after the new graph is loaded.
*
* The call relationship is
* useWorkflowService().openWorkflow -> app.loadGraphData -> useWorkflowService().afterLoadNewGraph
* app.loadApiJson -> useWorkflowService().afterLoadNewGraph
* app.importA1111 -> useWorkflowService().afterLoadNewGraph
*
* @param value The value to set as the active workflow.
* @param workflowData The initial workflow data loaded to the graph editor.
*/
const afterLoadNewGraph = async (
value: string | ComfyWorkflow | null,
workflowData: ComfyWorkflowJSON
) => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
if (typeof value === 'string') {
const workflow = workflowStore.getWorkflowByPath(
ComfyWorkflow.basePath + appendJsonExt(value)
)
if (workflow?.isPersisted) {
const loadedWorkflow = await workflowStore.openWorkflow(workflow)
loadedWorkflow.changeTracker.restore()
loadedWorkflow.changeTracker.reset(workflowData)
return
}
}
if (value === null || typeof value === 'string') {
const path = value as string | null
const tempWorkflow = workflowStore.createTemporary(
path ? appendJsonExt(path) : undefined,
workflowData
)
await workflowStore.openWorkflow(tempWorkflow)
return
}
// value is a ComfyWorkflow.
const loadedWorkflow = await workflowStore.openWorkflow(value)
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
}
/**
* Insert the given workflow into the current graph editor.
*/
const insertWorkflow = async (
workflow: ComfyWorkflow,
options: { position?: Vector2 } = {}
) => {
const loadedWorkflow = await workflow.load()
const workflowJSON = toRaw(loadedWorkflow.initialState)
const old = localStorage.getItem('litegrapheditor_clipboard')
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
// serialisation schema.
const graph = new LGraph(workflowJSON as unknown as SerialisableGraph)
const canvasElement = document.createElement('canvas')
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
canvas.selectItems()
canvas.copyToClipboard()
app.canvas.pasteFromClipboard(options)
if (old !== null) {
localStorage.setItem('litegrapheditor_clipboard', old)
}
}
const loadNextOpenedWorkflow = async () => {
const nextWorkflow = workflowStore.openedWorkflowIndexShift(1)
if (nextWorkflow) {
await openWorkflow(nextWorkflow)
}
}
const loadPreviousOpenedWorkflow = async () => {
const previousWorkflow = workflowStore.openedWorkflowIndexShift(-1)
if (previousWorkflow) {
await openWorkflow(previousWorkflow)
}
}
/**
* Takes an existing workflow and duplicates it with a new name
*/
const duplicateWorkflow = async (workflow: ComfyWorkflow) => {
const state = JSON.parse(JSON.stringify(workflow.activeState))
const suffix = workflow.isPersisted ? ' (Copy)' : ''
// Remove the suffix `(2)` or similar
const filename = workflow.filename.replace(/\s*\(\d+\)$/, '') + suffix
await app.loadGraphData(state, true, true, filename)
}
return {
exportWorkflow,
saveWorkflowAs,
saveWorkflow,
loadDefaultWorkflow,
loadBlankWorkflow,
reloadCurrentWorkflow,
openWorkflow,
closeWorkflow,
renameWorkflow,
deleteWorkflow,
insertWorkflow,
loadNextOpenedWorkflow,
loadPreviousOpenedWorkflow,
duplicateWorkflow,
afterLoadNewGraph,
beforeLoadNewGraph
}
}

View File

@@ -0,0 +1,72 @@
/**
* Supported workflow file formats organized by type category
*/
/**
* All supported image formats that can contain workflow data
*/
const IMAGE_WORKFLOW_FORMATS = {
extensions: ['.png', '.webp', '.svg', '.avif'],
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml', 'image/avif']
}
/**
* All supported audio formats that can contain workflow data
*/
const AUDIO_WORKFLOW_FORMATS = {
extensions: ['.mp3', '.ogg', '.flac'],
mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/flac', 'audio/x-flac']
}
/**
* All supported video formats that can contain workflow data
*/
const VIDEO_WORKFLOW_FORMATS = {
extensions: ['.mp4', '.mov', '.m4v', '.webm'],
mimeTypes: ['video/mp4', 'video/quicktime', 'video/x-m4v', 'video/webm']
}
/**
* All supported 3D model formats that can contain workflow data
*/
const MODEL_WORKFLOW_FORMATS = {
extensions: ['.glb'],
mimeTypes: ['model/gltf-binary']
}
/**
* All supported data formats that directly contain workflow data
*/
const DATA_WORKFLOW_FORMATS = {
extensions: ['.json', '.latent', '.safetensors'],
mimeTypes: ['application/json']
}
/**
* Combines all supported formats into a single object
*/
const ALL_WORKFLOW_FORMATS = {
extensions: [
...IMAGE_WORKFLOW_FORMATS.extensions,
...AUDIO_WORKFLOW_FORMATS.extensions,
...VIDEO_WORKFLOW_FORMATS.extensions,
...MODEL_WORKFLOW_FORMATS.extensions,
...DATA_WORKFLOW_FORMATS.extensions
],
mimeTypes: [
...IMAGE_WORKFLOW_FORMATS.mimeTypes,
...AUDIO_WORKFLOW_FORMATS.mimeTypes,
...VIDEO_WORKFLOW_FORMATS.mimeTypes,
...MODEL_WORKFLOW_FORMATS.mimeTypes,
...DATA_WORKFLOW_FORMATS.mimeTypes
]
}
/**
* Generate a comma-separated accept string for file inputs
* Combines all extensions and mime types
*/
export const WORKFLOW_ACCEPT_STRING = [
...ALL_WORKFLOW_FORMATS.extensions,
...ALL_WORKFLOW_FORMATS.mimeTypes
].join(',')

View File

@@ -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'
}
}

View 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
}
})

View File

@@ -0,0 +1,95 @@
import { computed, onUnmounted, watch } from 'vue'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { api } from '@/scripts/api'
import { useSettingStore } from '@/stores/settingStore'
export function useWorkflowAutoSave() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const workflowService = useWorkflowService()
// Use computed refs to cache autosave settings
const autoSaveSetting = computed(() =>
settingStore.get('Comfy.Workflow.AutoSave')
)
const autoSaveDelay = computed(() =>
settingStore.get('Comfy.Workflow.AutoSaveDelay')
)
let autoSaveTimeout: NodeJS.Timeout | null = null
let isSaving = false
let needsAutoSave = false
const scheduleAutoSave = () => {
// Clear any existing timeout
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout)
autoSaveTimeout = null
}
// If autosave is enabled
if (autoSaveSetting.value === 'after delay') {
// If a save is in progress, mark that we need an autosave after saving
if (isSaving) {
needsAutoSave = true
return
}
const delay = autoSaveDelay.value
autoSaveTimeout = setTimeout(async () => {
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow?.isModified && activeWorkflow.isPersisted) {
try {
isSaving = true
await workflowService.saveWorkflow(activeWorkflow)
} catch (err) {
console.error('Auto save failed:', err)
} finally {
isSaving = false
if (needsAutoSave) {
needsAutoSave = false
scheduleAutoSave()
}
}
}
}, delay)
}
}
// Watch for autosave setting changes
watch(
autoSaveSetting,
(newSetting) => {
// Clear any existing timeout when settings change
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout)
autoSaveTimeout = null
}
// If there's an active modified workflow and autosave is enabled, schedule a save
if (
newSetting === 'after delay' &&
workflowStore.activeWorkflow?.isModified
) {
scheduleAutoSave()
}
},
{ immediate: true }
)
// Listen for graph changes and schedule autosave when they occur
const onGraphChanged = () => {
scheduleAutoSave()
}
api.addEventListener('graphChanged', onGraphChanged)
onUnmounted(() => {
if (autoSaveTimeout) {
clearTimeout(autoSaveTimeout)
autoSaveTimeout = null
}
api.removeEventListener('graphChanged', onGraphChanged)
})
}

View File

@@ -0,0 +1,147 @@
import { tryOnScopeDispose } from '@vueuse/core'
import { computed, watch } from 'vue'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { getStorageValue, setStorageValue } from '@/scripts/utils'
import { useCommandStore } from '@/stores/commandStore'
import { useSettingStore } from '@/stores/settingStore'
export function useWorkflowPersistence() {
const workflowStore = useWorkflowStore()
const settingStore = useSettingStore()
const workflowPersistenceEnabled = computed(() =>
settingStore.get('Comfy.Workflow.Persist')
)
const persistCurrentWorkflow = () => {
if (!workflowPersistenceEnabled.value) return
const workflow = JSON.stringify(comfyApp.graph.serialize())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}
const loadWorkflowFromStorage = async (
json: string | null,
workflowName: string | null
) => {
if (!json) return false
const workflow = JSON.parse(json)
await comfyApp.loadGraphData(workflow, true, true, workflowName)
return true
}
const loadPreviousWorkflowFromStorage = async () => {
const workflowName = getStorageValue('Comfy.PreviousWorkflow')
const clientId = api.initialClientId ?? api.clientId
// Try loading from session storage first
if (clientId) {
const sessionWorkflow = sessionStorage.getItem(`workflow:${clientId}`)
if (await loadWorkflowFromStorage(sessionWorkflow, workflowName)) {
return true
}
}
// Fall back to local storage
const localWorkflow = localStorage.getItem('workflow')
return await loadWorkflowFromStorage(localWorkflow, workflowName)
}
const loadDefaultWorkflow = async () => {
if (!settingStore.get('Comfy.TutorialCompleted')) {
await settingStore.set('Comfy.TutorialCompleted', true)
await useWorkflowService().loadBlankWorkflow()
await useCommandStore().execute('Comfy.BrowseTemplates')
} else {
await comfyApp.loadGraphData()
}
}
const restorePreviousWorkflow = async () => {
if (!workflowPersistenceEnabled.value) return
try {
const restored = await loadPreviousWorkflowFromStorage()
if (!restored) {
await loadDefaultWorkflow()
}
} catch (err) {
console.error('Error loading previous workflow', err)
await loadDefaultWorkflow()
}
}
// Setup watchers
watch(
() => workflowStore.activeWorkflow?.key,
(activeWorkflowKey) => {
if (!activeWorkflowKey) return
setStorageValue('Comfy.PreviousWorkflow', activeWorkflowKey)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
)
api.addEventListener('graphChanged', persistCurrentWorkflow)
// Clean up event listener when component unmounts
tryOnScopeDispose(() => {
api.removeEventListener('graphChanged', persistCurrentWorkflow)
})
// Restore workflow tabs states
const openWorkflows = computed(() => workflowStore.openWorkflows)
const activeWorkflow = computed(() => workflowStore.activeWorkflow)
const restoreState = computed<{ paths: string[]; activeIndex: number }>(
() => {
if (!openWorkflows.value || !activeWorkflow.value) {
return { paths: [], activeIndex: -1 }
}
const paths = openWorkflows.value
.filter((workflow) => workflow?.isPersisted && !workflow.isModified)
.map((workflow) => workflow.path)
const activeIndex = openWorkflows.value.findIndex(
(workflow) => workflow.path === activeWorkflow.value?.path
)
return { paths, activeIndex }
}
)
// Get storage values before setting watchers
const storedWorkflows = JSON.parse(
getStorageValue('Comfy.OpenWorkflowsPaths') || '[]'
)
const storedActiveIndex = JSON.parse(
getStorageValue('Comfy.ActiveWorkflowIndex') || '-1'
)
watch(restoreState, ({ paths, activeIndex }) => {
if (workflowPersistenceEnabled.value) {
setStorageValue('Comfy.OpenWorkflowsPaths', JSON.stringify(paths))
setStorageValue('Comfy.ActiveWorkflowIndex', JSON.stringify(activeIndex))
}
})
const restoreWorkflowTabsState = () => {
if (!workflowPersistenceEnabled.value) return
const isRestorable = storedWorkflows?.length > 0 && storedActiveIndex >= 0
if (isRestorable) {
workflowStore.openWorkflowsInBackground({
left: storedWorkflows.slice(0, storedActiveIndex),
right: storedWorkflows.slice(storedActiveIndex)
})
}
}
return {
restorePreviousWorkflow,
restoreWorkflowTabsState
}
}

View File

@@ -0,0 +1,190 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
export function useTemplateWorkflows() {
const { t } = useI18n()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const dialogStore = useDialogStore()
// State
const selectedTemplate = ref<WorkflowTemplates | null>(null)
const loadingTemplateId = ref<string | null>(null)
// Computed
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
const allTemplateGroups = computed<TemplateGroup[]>(
() => workflowTemplatesStore.groupedTemplates
)
/**
* Loads all template workflows from the API
*/
const loadTemplates = async () => {
if (!workflowTemplatesStore.isLoaded) {
await workflowTemplatesStore.loadWorkflowTemplates()
}
return workflowTemplatesStore.isLoaded
}
/**
* Selects the first template category as default
*/
const selectFirstTemplateCategory = () => {
if (allTemplateGroups.value.length > 0) {
const firstCategory = allTemplateGroups.value[0].modules[0]
selectTemplateCategory(firstCategory)
}
}
/**
* Selects a template category
*/
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
selectedTemplate.value = category
return category !== null
}
/**
* Gets template thumbnail URL
*/
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Gets formatted template title
*/
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
}
/**
* Gets formatted template description
*/
const getTemplateDescription = (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
}
/**
* Loads a workflow template
*/
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
if (!isTemplatesLoaded.value) return false
loadingTemplateId.value = id
let json
try {
// Handle "All" category as a special case
if (sourceModule === 'all') {
// Find "All" category in the ComfyUI Examples group
const comfyExamplesGroup = allTemplateGroups.value.find(
(g) =>
g.label ===
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
const allCategory = comfyExamplesGroup?.modules.find(
(m) => m.moduleName === 'all'
)
const template = allCategory?.templates.find((t) => t.name === id)
if (!template || !template.sourceModule) return false
// Use the stored source module for loading
const actualSourceModule = template.sourceModule
json = await fetchTemplateJson(id, actualSourceModule)
// Use source module for name
const workflowName =
actualSourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
return true
}
// Regular case for normal categories
json = await fetchTemplateJson(id, sourceModule)
const workflowName =
sourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
return true
} catch (error) {
console.error('Error loading workflow template:', error)
return false
} finally {
loadingTemplateId.value = null
}
}
/**
* Fetches template JSON from the appropriate endpoint
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
} else {
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
}
}
return {
// State
selectedTemplate,
loadingTemplateId,
// Computed
isTemplatesLoaded,
allTemplateGroups,
// Methods
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription,
loadWorkflowTemplate
}
}

View File

@@ -0,0 +1,217 @@
import { groupBy } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { st } from '@/i18n'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/platform/workflow/templates/types/template'
import { api } from '@/scripts/api'
import { normalizeI18nKey } from '@/utils/formatUtil'
const SHOULD_SORT_CATEGORIES = new Set([
// API Node templates should be strictly sorted by name to avoid any
// favoritism or bias towards a particular API. Other categories can
// have their ordering specified in index.json freely.
'Image API',
'Video API'
])
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
() => {
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
/**
* Sort a list of templates in alphabetical order by localized display name.
*/
const sortTemplateList = (templates: TemplateInfo[]) =>
templates.sort((a, b) => {
const aName = st(
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
a.title ?? a.name
)
const bName = st(
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
b.name
)
return aName.localeCompare(bName)
})
/**
* Sort any template categories (grouped templates) that should be sorted.
* Leave other categories' templates in their original order specified in index.json
*/
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
categories.map((category) => {
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
return {
...category,
templates: sortTemplateList(category.templates)
}
}
return category
})
/**
* Add localization fields to a template.
*/
const addLocalizedFieldsToTemplate = (
template: TemplateInfo,
categoryTitle: string
) => ({
...template,
localizedTitle: st(
`templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
template.title ?? template.name
),
localizedDescription: st(
`templateWorkflows.templateDescription.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
template.description
)
})
/**
* Add localization fields to all templates in a list of templates.
*/
const localizeTemplateList = (
templates: TemplateInfo[],
categoryTitle: string
) =>
templates.map((template) =>
addLocalizedFieldsToTemplate(template, categoryTitle)
)
/**
* Add localization fields to a template category and all its constituent templates.
*/
const localizeTemplateCategory = (templateCategory: WorkflowTemplates) => ({
...templateCategory,
localizedTitle: st(
`templateWorkflows.category.${normalizeI18nKey(templateCategory.title)}`,
templateCategory.title ?? templateCategory.moduleName
),
templates: localizeTemplateList(
templateCategory.templates,
templateCategory.title
)
})
// Create an "All" category that combines all templates
const createAllCategory = () => {
// First, get core templates with source module added
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
(category) =>
// For each template in each category, add the sourceModule and pass through any localized fields
category.templates.map((template) => {
// Get localized template with its original category title for i18n lookup
const localizedTemplate = addLocalizedFieldsToTemplate(
template,
category.title
)
return {
...localizedTemplate,
sourceModule: category.moduleName
}
})
)
// Now handle custom templates
const customTemplatesWithSourceModule = Object.entries(
customTemplates.value
).flatMap(([moduleName, templates]) =>
templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name,
sourceModule: moduleName
}))
)
return {
moduleName: 'all',
title: 'All',
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
templates: [
...coreTemplatesWithSourceModule,
...customTemplatesWithSourceModule
]
}
}
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
...sortCategoryTemplates(coreTemplates.value).map(
localizeTemplateCategory
),
...Object.entries(customTemplates.value).map(
([moduleName, templates]) => ({
moduleName,
title: moduleName,
localizedTitle: st(
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
moduleName
),
templates: templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name
}))
})
)
]
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
)
}
return groupedByCategory
})
async function loadWorkflowTemplates() {
try {
if (!isLoaded.value) {
customTemplates.value = await api.getWorkflowTemplates()
coreTemplates.value = await api.getCoreWorkflowTemplates()
isLoaded.value = true
}
} catch (error) {
console.error('Error fetching workflow templates:', error)
}
}
return {
groupedTemplates,
isLoaded,
loadWorkflowTemplates
}
}
)

View File

@@ -0,0 +1,27 @@
export interface TemplateInfo {
name: string
/**
* Optional title which is used as the fallback if the name is not in the locales dictionary.
*/
title?: string
tutorialUrl?: string
mediaType: string
mediaSubtype: string
thumbnailVariant?: string
description: string
localizedTitle?: string
localizedDescription?: string
sourceModule?: string
}
export interface WorkflowTemplates {
moduleName: string
templates: TemplateInfo[]
title: string
}
export interface TemplateGroup {
label: string
icon?: string
modules: WorkflowTemplates[]
}

View File

@@ -0,0 +1,98 @@
import type { ISerialisedGraph } from '@/lib/litegraph/src/types/serialisation'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useToastStore } from '@/stores/toastStore'
import { fixBadLinks } from '@/utils/linkFixer'
interface ValidationResult {
graphData: ComfyWorkflowJSON | null
}
export function useWorkflowValidation() {
const toastStore = useToastStore()
function tryFixLinks(
graphData: ComfyWorkflowJSON,
options: { silent?: boolean } = {}
) {
const { silent = false } = options
// Collect all logs in an array
const logs: string[] = []
// Then validate and fix links if schema validation passed
const linkValidation = fixBadLinks(
graphData as unknown as ISerialisedGraph,
{
fix: true,
silent,
logger: {
log: (message: string) => {
logs.push(message)
}
}
}
)
if (!silent && logs.length > 0) {
toastStore.add({
severity: 'warn',
summary: 'Workflow Validation',
detail: logs.join('\n')
})
}
// If links were fixed, notify the user
if (linkValidation.fixed) {
if (!silent) {
toastStore.add({
severity: 'success',
summary: 'Workflow Links Fixed',
detail: `Fixed ${linkValidation.patched} node connections and removed ${linkValidation.deleted} invalid links.`
})
}
}
return linkValidation.graph as unknown as ComfyWorkflowJSON
}
/**
* Validates a workflow, including link validation and schema validation
*/
async function validateWorkflow(
graphData: ComfyWorkflowJSON,
options: {
silent?: boolean
} = {}
): Promise<ValidationResult> {
const { silent = false } = options
let validatedData: ComfyWorkflowJSON | null = null
// First do schema validation
const validatedGraphData = await validateComfyWorkflow(
graphData,
/* onError=*/ (err) => {
if (!silent) {
toastStore.addAlert(err)
}
}
)
if (validatedGraphData) {
try {
validatedData = tryFixLinks(validatedGraphData, { silent })
} catch (err) {
// Link fixer itself is throwing an error
console.error(err)
}
}
return {
graphData: validatedData
}
}
return {
validateWorkflow
}
}

View File

@@ -0,0 +1,526 @@
import { type SafeParseReturnType, z } from 'zod'
import { fromZodError } from 'zod-validation-error'
// GroupNode is hacking node id to be a string, so we need to allow that.
// innerNode.id = `${this.node.id}:${i}`
// Remove it after GroupNode is redesigned.
export const zNodeId = z.union([z.number().int(), z.string()])
const zNodeInputName = z.string()
export type NodeId = z.infer<typeof zNodeId>
const zSlotIndex = z.union([
z.number().int(),
z
.string()
.transform((val) => parseInt(val))
.refine((val) => !isNaN(val), {
message: 'Invalid number'
})
])
// TODO: Investigate usage of array and number as data type usage in custom nodes.
// Known usage:
// - https://github.com/rgthree/rgthree-comfy Context Big node is using array as type.
const zDataType = z.union([z.string(), z.array(z.string()), z.number()])
const zVector2 = z.union([
z
.object({ 0: z.number(), 1: z.number() })
.passthrough()
.transform((v) => [v[0], v[1]] as [number, number]),
z.tuple([z.number(), z.number()])
])
// Definition of an AI model file used in the workflow.
const zModelFile = z.object({
name: z.string(),
url: z.string().url(),
hash: z.string().optional(),
hash_type: z.string().optional(),
directory: z.string()
})
const zGraphState = z
.object({
lastGroupId: z.number(),
lastNodeId: z.number(),
lastLinkId: z.number(),
lastRerouteId: z.number()
})
.passthrough()
const zComfyLink = z.tuple([
z.number(), // Link id
zNodeId, // Node id of source node
zSlotIndex, // Output slot# of source node
zNodeId, // Node id of destination node
zSlotIndex, // Input slot# of destination node
zDataType // Data type
])
/** Extension to 0.4 schema (links as arrays): parent reroute ID */
const zComfyLinkExtension = z
.object({
id: z.number(),
parentId: z.number()
})
.passthrough()
const zComfyLinkObject = z
.object({
id: z.number(),
origin_id: zNodeId,
origin_slot: zSlotIndex,
target_id: zNodeId,
target_slot: zSlotIndex,
type: zDataType,
parentId: z.number().optional()
})
.passthrough()
const zReroute = z
.object({
id: z.number(),
parentId: z.number().optional(),
pos: zVector2,
linkIds: z.array(z.number()).nullish(),
floating: z
.object({
slotType: z.enum(['input', 'output'])
})
.optional()
})
.passthrough()
const zNodeOutput = z
.object({
name: z.string(),
type: zDataType,
links: z.array(z.number()).nullable().optional(),
slot_index: zSlotIndex.optional()
})
.passthrough()
const zNodeInput = z
.object({
name: zNodeInputName,
type: zDataType,
link: z.number().nullable().optional(),
slot_index: zSlotIndex.optional()
})
.passthrough()
const zFlags = z
.object({
collapsed: z.boolean().optional(),
pinned: z.boolean().optional(),
allow_interaction: z.boolean().optional(),
horizontal: z.boolean().optional(),
skip_repeated_outputs: z.boolean().optional()
})
.passthrough()
const repoLikeIdPattern = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/
const githubUsernamePattern = /^(?!-)(?!.*--)[a-zA-Z0-9-]+(?<!-)$/
const gitHashPattern = /^[0-9a-f]{4,40}$/i
const semverPattern =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([\da-z-]+(?:\.[\da-z-]+)*))?(?:\+([\da-z-]+(?:\.[\da-z-]+)*))?$/
// Shared schema for Comfy Node Registry IDs and GitHub repo names
const zRepoLikeId = z
.string()
.min(1)
.max(100)
.regex(repoLikeIdPattern, {
message: "ID can only contain ASCII letters, digits, '_', '-', and '.'"
})
.refine((id) => !/^[_\-.]|[_\-.]$/.test(id), {
message: "ID must not start or end with '_', '-', or '.'"
})
const zCnrId = zRepoLikeId
const zGithubRepoName = zRepoLikeId
// GitHub username/organization schema
const zGithubUsername = z
.string()
.min(1)
.max(39)
.regex(githubUsernamePattern, 'Invalid GitHub username/org')
// Auxiliary ID identifies node packs not installed via the Comfy Node Registry
const zAuxId = z
.string()
.regex(/^[^/]+\/[^/]+$/, "Invalid format. Must be 'github-user/repo-name'")
.transform((id) => id.split('/'))
.refine(
([username, repo]) =>
zGithubUsername.safeParse(username).success &&
zGithubRepoName.safeParse(repo).success,
"Invalid aux_id: Must be valid 'github-username/github-repo-name'"
)
.transform(([username, repo]) => `${username}/${repo}`)
const zGitHash = z.string().superRefine((val: string, ctx) => {
if (!gitHashPattern.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Node pack version has invalid Git commit hash: "${val}"`
})
}
})
const zSemVer = z.string().superRefine((val: string, ctx) => {
if (!semverPattern.test(val)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Node pack version has invalid semantic version: "${val}"`
})
}
})
const zVersion = z.union([
z
.string()
.transform((ver) => ver.replace(/^v/, '')) // Strip leading 'v'
.pipe(z.union([zSemVer, zGitHash])),
z.literal('unknown')
])
const zProperties = z
.object({
['Node name for S&R']: z.string().optional(),
cnr_id: zCnrId.optional(),
aux_id: zAuxId.optional(),
ver: zVersion.optional(),
models: z.array(zModelFile).optional()
})
.passthrough()
const zWidgetValues = z.union([z.array(z.any()), z.record(z.any())])
const zComfyNode = z
.object({
id: zNodeId,
type: z.string(),
pos: zVector2,
size: zVector2,
flags: zFlags,
order: z.number(),
mode: z.number(),
inputs: z.array(zNodeInput).optional(),
outputs: z.array(zNodeOutput).optional(),
properties: zProperties,
widgets_values: zWidgetValues.optional(),
color: z.string().optional(),
bgcolor: z.string().optional()
})
.passthrough()
const zSubgraphIO = zNodeInput.extend({
/** Slot ID (internal; never changes once instantiated). */
id: z.string().uuid(),
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
type: z.string(),
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
linkIds: z.array(z.number()).optional()
})
const zSubgraphInstance = z
.object({
id: zNodeId,
type: z.string().uuid(),
pos: zVector2,
size: zVector2,
flags: zFlags,
order: z.number(),
mode: z.number(),
inputs: z.array(zSubgraphIO).optional(),
outputs: z.array(zSubgraphIO).optional(),
widgets_values: zWidgetValues.optional(),
color: z.string().optional(),
bgcolor: z.string().optional()
})
.passthrough()
const zGroup = z
.object({
id: z.number().optional(),
title: z.string(),
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
color: z.string().optional(),
font_size: z.number().optional(),
locked: z.boolean().optional()
})
.passthrough()
const zDS = z
.object({
scale: z.number(),
offset: zVector2
})
.passthrough()
const zConfig = z
.object({
links_ontop: z.boolean().optional(),
align_to_grid: z.boolean().optional()
})
.passthrough()
const zExtra = z
.object({
ds: zDS.optional(),
frontendVersion: z.string().optional(),
linkExtensions: z.array(zComfyLinkExtension).optional(),
reroutes: z.array(zReroute).optional()
})
.passthrough()
const zGraphDefinitions = z.object({
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
})
const zBaseExportableGraph = z.object({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid().optional(),
revision: z.number().optional(),
config: zConfig.optional().nullable(),
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
subgraphs: z.array(zSubgraphInstance).optional()
})
/** Schema version 0.4 */
export const zComfyWorkflow = zBaseExportableGraph
.extend({
id: z.string().uuid().optional(),
revision: z.number().optional(),
last_node_id: zNodeId,
last_link_id: z.number(),
nodes: z.array(zComfyNode),
links: z.array(zComfyLink),
floatingLinks: z.array(zComfyLinkObject).optional(),
groups: z.array(zGroup).optional(),
config: zConfig.optional().nullable(),
extra: zExtra.optional().nullable(),
version: z.number(),
models: z.array(zModelFile).optional(),
definitions: zGraphDefinitions.optional()
})
.passthrough()
/** Required for recursive definition of subgraphs. */
interface ComfyWorkflow1BaseType {
id?: string
revision?: number
version: 1
models?: z.infer<typeof zModelFile>[]
state: z.infer<typeof zGraphState>
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
groups?: z.input<typeof zGroup>[]
nodes: z.input<typeof zComfyNode>[]
links?: z.input<typeof zComfyLinkObject>[]
floatingLinks?: z.input<typeof zComfyLinkObject>[]
reroutes?: z.input<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
}
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
groups?: z.output<typeof zGroup>[]
nodes: z.output<typeof zComfyNode>[]
links?: z.output<typeof zComfyLinkObject>[]
floatingLinks?: z.output<typeof zComfyLinkObject>[]
reroutes?: z.output<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
}
}
/** Schema version 1 */
export const zComfyWorkflow1 = zBaseExportableGraph
.extend({
id: z.string().uuid().optional(),
revision: z.number().optional(),
version: z.literal(1),
config: zConfig.optional().nullable(),
state: zGraphState,
groups: z.array(zGroup).optional(),
nodes: z.array(zComfyNode),
links: z.array(zComfyLinkObject).optional(),
floatingLinks: z.array(zComfyLinkObject).optional(),
reroutes: z.array(zReroute).optional(),
extra: zExtra.optional().nullable(),
models: z.array(zModelFile).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => z.array(zSubgraphDefinition)
)
})
.optional()
})
.passthrough()
const zExportedSubgraphIONode = z.object({
id: zNodeId,
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
pinned: z.boolean().optional()
})
const zExposedWidget = z.object({
id: z.string(),
name: z.string()
})
interface SubgraphDefinitionBase<
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
> {
/** Unique graph ID. Automatically generated if not provided. */
id: string
revision: number
name: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
outputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExposedWidget>[]
: z.output<typeof zExposedWidget>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<T>[]
}
}
/** A subgraph definition `worfklow.definitions.subgraphs` */
const zSubgraphDefinition = zComfyWorkflow1
.extend({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs: z.array(zSubgraphIO).optional(),
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs: z.array(zSubgraphIO).optional(),
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets: z.array(zExposedWidget).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => zSubgraphDefinition.array()
)
})
.optional()
})
.passthrough()
export type ModelFile = z.infer<typeof zModelFile>
export type ComfyLink = z.infer<typeof zComfyLink>
export type ComfyLinkObject = z.infer<typeof zComfyLinkObject>
export type ComfyNode = z.infer<typeof zComfyNode>
export type Reroute = z.infer<typeof zReroute>
export type WorkflowJSON04 = z.infer<typeof zComfyWorkflow>
export type ComfyWorkflowJSON = z.infer<
typeof zComfyWorkflow | typeof zComfyWorkflow1
>
type SubgraphDefinition = z.infer<typeof zSubgraphDefinition>
/**
* Type guard to check if an object is a SubgraphDefinition.
* This helps TypeScript understand the type when z.lazy() breaks inference.
*/
export function isSubgraphDefinition(obj: any): obj is SubgraphDefinition {
return (
obj &&
typeof obj === 'object' &&
'id' in obj &&
'name' in obj &&
'nodes' in obj &&
Array.isArray(obj.nodes) &&
'inputNode' in obj &&
'outputNode' in obj
)
}
const zWorkflowVersion = z.object({
version: z.number()
})
export async function validateComfyWorkflow(
data: unknown,
onError: (error: string) => void = console.warn
): Promise<ComfyWorkflowJSON | null> {
const versionResult = zWorkflowVersion.safeParse(data)
let result: SafeParseReturnType<unknown, ComfyWorkflowJSON>
if (!versionResult.success) {
// Invalid workflow
const error = fromZodError(versionResult.error)
onError(`Workflow does not contain a valid version. Zod error:\n${error}`)
return null
} else if (versionResult.data.version === 1) {
// Schema version 1
result = await zComfyWorkflow1.safeParseAsync(data)
} else {
// Unknown or old version: 0.4
result = await zComfyWorkflow.safeParseAsync(data)
}
if (result.success) return result.data
const error = fromZodError(result.error)
onError(`Invalid workflow against zod schema:\n${error}`)
return null
}
/**
* API format workflow for direct API usage.
*/
const zNodeInputValue = z.union([
// For widget values (can be any type)
z.any(),
// For node links [nodeId, slotIndex]
z.tuple([zNodeId, zSlotIndex])
])
const zNodeData = z.object({
inputs: z.record(zNodeInputName, zNodeInputValue),
class_type: z.string(),
_meta: z.object({
title: z.string()
})
})
const zComfyApiWorkflow = z.record(zNodeId, zNodeData)
export type ComfyApiWorkflow = z.infer<typeof zComfyApiWorkflow>