mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-09 15:10:17 +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:
414
src/platform/workflow/core/services/workflowService.ts
Normal file
414
src/platform/workflow/core/services/workflowService.ts
Normal 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
|
||||
}
|
||||
}
|
||||
72
src/platform/workflow/core/types/formats.ts
Normal file
72
src/platform/workflow/core/types/formats.ts
Normal 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(',')
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
)
|
||||
27
src/platform/workflow/templates/types/template.ts
Normal file
27
src/platform/workflow/templates/types/template.ts
Normal 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[]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
526
src/platform/workflow/validation/schemas/workflowSchema.ts
Normal file
526
src/platform/workflow/validation/schemas/workflowSchema.ts
Normal 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>
|
||||
Reference in New Issue
Block a user