Workflow Management Reworked (#1406)

* Merge temp userfile

Basic migration

Remove deprecated isFavourite

Rename

nit

nit

Rework open/load

Refactor save

Refactor delete

Remove workflow dep on manager

WIP

Change map to record

Fix directory

nit

isActive

Move

nit

Add unload

Add close workflow

Remove workflowManager.closeWorkflow

nit

Remove workflowManager.storePrompt

move from commandStore

move more from commandStore

nit

Use workflowservice

nit

nit

implement setWorkflow

nit

Remove workflows.ts

Fix strict errors

nit

nit

Resolves circular dep

nit

nit

Fix workflow switching

Add openworkflowPaths

Fix store

Fix key

Serialize by default

Fix proxy

nit

Update path

Proper sync

Fix tabs

WIP

nit

Resolve merge conflict

Fix userfile store tests

Update jest test

Update tabs

patch tests

Fix changeTracker init

Move insert to service

nit

Fix insert

nit

Handle bookmark rename

Refactor tests

Add delete workflow

Add test on deleting workflow

Add closeWorkflow tests

nit

* Fix path

* Move load next/previous

* Move logic from store to service

* nit

* nit

* nit

* nit

* nit

* Add ChangeTracker.initialState

* ChangeTracker load/unload

* Remove app.changeWorkflow

* Hook to app.ts

* Changetracker restore

* nit

* nit

* nit

* Add debug logs

* Remove unnecessary checkState on graphLoad

* nit

* Fix strict

* Fix temp workflow name

* Track ismodified

* Fix reactivity

* nit

* Fix graph equal

* nit

* update test

* nit

* nit

* Fix modified state

* nit

* Fix modified state

* Sidebar force close

* tabs force close

* Fix save

* Add load remote workflow test

* Force save

* Add save test

* nit

* Correctly handle delete last opened workflow

* nit

* Fix workflow rename

* Fix save

* Fix tests

* Fix strict

* Update playwright tests

* Fix filename conflict handling

* nit

* Merge temporary and persisted ref

* Update playwright expectations

* nit

* nit

* Fix saveAs

* Add playwright test

* nit
This commit is contained in:
Chenlei Hu
2024-11-05 11:03:27 -05:00
committed by GitHub
parent 1387d7e627
commit c56533bb23
28 changed files with 1409 additions and 784 deletions

View File

@@ -25,7 +25,7 @@ import { ComfyNodeDef, StatusWsMessageStatus } from '@/types/apiTypes'
import { adjustColor, ColorAdjustOptions } from '@/utils/colorUtil'
import { ComfyAppMenu } from './ui/menu/index'
import { getStorageValue } from './utils'
import { ComfyWorkflowManager, ComfyWorkflow } from './workflows'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import {
LGraphCanvas,
LGraph,
@@ -58,6 +58,7 @@ import { KeyComboImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useCommandStore } from '@/stores/commandStore'
import { shallowReactive } from 'vue'
import { type IBaseWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { workflowService } from '@/services/workflowService'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -141,7 +142,6 @@ export class ComfyApp {
multiUserServer: boolean
ctx: CanvasRenderingContext2D
widgets: Record<string, ComfyWidgetConstructor>
workflowManager: ComfyWorkflowManager
bodyTop: HTMLElement
bodyLeft: HTMLElement
bodyRight: HTMLElement
@@ -170,7 +170,6 @@ export class ComfyApp {
this.vueAppReady = false
this.ui = new ComfyUI(this)
this.logging = new ComfyLogging(this)
this.workflowManager = new ComfyWorkflowManager(this)
this.bodyTop = $el('div.comfyui-body-top', { parent: document.body })
this.bodyLeft = $el('div.comfyui-body-left', { parent: document.body })
this.bodyRight = $el('div.comfyui-body-right', { parent: document.body })
@@ -1789,7 +1788,7 @@ export class ComfyApp {
this.resizeCanvas()
await Promise.all([
this.workflowManager.loadWorkflows(),
useWorkspaceStore().workflow.syncWorkflows(),
this.ui.settings.load()
])
await this.#loadExtensions()
@@ -2160,21 +2159,6 @@ export class ComfyApp {
})
}
async changeWorkflow(callback, workflow = null) {
try {
this.workflowManager.activeWorkflow?.changeTracker?.store()
} catch (error) {
console.error(error)
}
await callback()
try {
this.workflowManager.setWorkflow(workflow)
this.workflowManager.activeWorkflow?.track()
} catch (error) {
console.error(error)
}
}
async loadGraphData(
graphData?: ComfyWorkflowJSON,
clean: boolean = true,
@@ -2198,12 +2182,6 @@ export class ComfyApp {
graphData = structuredClone(graphData)
}
try {
this.workflowManager.setWorkflow(workflow)
} catch (error) {
console.error(error)
}
if (useSettingStore().get('Comfy.Validation.Workflows')) {
// TODO: Show validation error in a dialog.
const validatedGraphData = await validateComfyWorkflow(
@@ -2217,6 +2195,8 @@ export class ComfyApp {
graphData = validatedGraphData ?? graphData
}
workflowService.beforeLoadNewGraph()
const missingNodeTypes: MissingNodeType[] = []
const missingModels = []
await this.#invokeExtensionsAsync(
@@ -2270,12 +2250,6 @@ export class ComfyApp {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
}
try {
this.workflowManager.activeWorkflow?.track()
} catch (error) {
// TODO: Do we want silently fail here?
}
} catch (error) {
let errorHint = []
// Try extracting filename to see if it was caused by an extension script
@@ -2384,6 +2358,8 @@ export class ComfyApp {
this.#showMissingModelsError(missingModels, paths)
}
await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes)
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
workflowService.afterLoadNewGraph(workflow, this.graph.serialize())
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true)
})
@@ -2602,9 +2578,11 @@ export class ComfyApp {
this.canvas.draw(true, true)
} else {
try {
this.workflowManager.storePrompt({
useExecutionStore().storePrompt({
id: res.prompt_id,
nodes: Object.keys(p.output)
nodes: Object.keys(p.output),
workflow: useWorkspaceStore().workflow
.activeWorkflow as ComfyWorkflow
})
} catch (error) {}
}
@@ -2678,9 +2656,12 @@ export class ComfyApp {
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
} else if (pngInfo?.parameters) {
this.changeWorkflow(() => {
importA1111(this.graph, pngInfo.parameters)
}, fileName)
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
workflowService.beforeLoadNewGraph()
importA1111(this.graph, pngInfo.parameters)
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
workflowService.afterLoadNewGraph(fileName, this.serializeGraph())
} else {
this.showErrorOnFileLoad(file)
}
@@ -2764,6 +2745,8 @@ export class ComfyApp {
}
loadApiJson(apiData, fileName: string) {
workflowService.beforeLoadNewGraph()
const missingNodeTypes = Object.values(apiData).filter(
// @ts-expect-error
(n) => !LiteGraph.registered_node_types[n.class_type]
@@ -2786,40 +2769,38 @@ export class ComfyApp {
app.graph.add(node)
}
this.changeWorkflow(() => {
for (const id of ids) {
const data = apiData[id]
const node = app.graph.getNodeById(id)
for (const input in data.inputs ?? {}) {
const value = data.inputs[input]
if (value instanceof Array) {
const [fromId, fromSlot] = value
const fromNode = app.graph.getNodeById(fromId)
let toSlot = node.inputs?.findIndex((inp) => inp.name === input)
if (toSlot == null || toSlot === -1) {
try {
// Target has no matching input, most likely a converted widget
const widget = node.widgets?.find((w) => w.name === input)
// @ts-expect-error
if (widget && node.convertWidgetToInput?.(widget)) {
toSlot = node.inputs?.length - 1
}
} catch (error) {}
}
if (toSlot != null || toSlot !== -1) {
fromNode.connect(fromSlot, node, toSlot)
}
} else {
const widget = node.widgets?.find((w) => w.name === input)
if (widget) {
widget.value = value
widget.callback?.(value)
}
for (const id of ids) {
const data = apiData[id]
const node = app.graph.getNodeById(id)
for (const input in data.inputs ?? {}) {
const value = data.inputs[input]
if (value instanceof Array) {
const [fromId, fromSlot] = value
const fromNode = app.graph.getNodeById(fromId)
let toSlot = node.inputs?.findIndex((inp) => inp.name === input)
if (toSlot == null || toSlot === -1) {
try {
// Target has no matching input, most likely a converted widget
const widget = node.widgets?.find((w) => w.name === input)
// @ts-expect-error
if (widget && node.convertWidgetToInput?.(widget)) {
toSlot = node.inputs?.length - 1
}
} catch (error) {}
}
if (toSlot != null || toSlot !== -1) {
fromNode.connect(fromSlot, node, toSlot)
}
} else {
const widget = node.widgets?.find((w) => w.name === input)
if (widget) {
widget.value = value
widget.callback?.(value)
}
}
}
app.graph.arrange()
}, fileName)
}
app.graph.arrange()
for (const id of ids) {
const data = apiData[id]
@@ -2854,6 +2835,9 @@ export class ComfyApp {
}
app.graph.arrange()
// @ts-expect-error zod type issue on ComfyWorkflowJSON. Should be resolved after enabling ts-strict globally.
workflowService.afterLoadNewGraph(fileName, this.serializeGraph())
}
/**

View File

@@ -1,34 +1,62 @@
import type { ComfyApp } from './app'
import { api } from './api'
import { clone } from './utils'
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { ComfyWorkflow } from './workflows'
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { LGraphNode } from '@comfyorg/litegraph'
import { ExecutedWsMessage } from '@/types/apiTypes'
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import type { ExecutedWsMessage } from '@/types/apiTypes'
import { useExecutionStore } from '@/stores/executionStore'
import _ from 'lodash'
function clone(obj: any) {
try {
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
return JSON.parse(JSON.stringify(obj))
}
export class ChangeTracker {
static MAX_HISTORY = 50
#app?: ComfyApp
/**
* The active state of the workflow.
*/
activeState: ComfyWorkflowJSON
undoQueue: ComfyWorkflowJSON[] = []
redoQueue: ComfyWorkflowJSON[] = []
activeState: ComfyWorkflowJSON | null = null
isOurLoad: boolean = false
changeCount: number = 0
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
static app?: ComfyApp
get app(): ComfyApp {
// Global tracker has #app set, while other trackers have workflow bounded
return this.#app ?? this.workflow.manager.app
return ChangeTracker.app!
}
constructor(public workflow: ComfyWorkflow) {}
constructor(
/**
* The workflow that this change tracker is tracking
*/
public workflow: ComfyWorkflow,
/**
* The initial state of the workflow
*/
public initialState: ComfyWorkflowJSON
) {
this.activeState = initialState
}
#setApp(app: ComfyApp) {
this.#app = app
/**
* Save the current state as the initial state.
*/
reset(state?: ComfyWorkflowJSON) {
this.activeState = state ?? this.activeState
this.initialState = this.activeState
}
store() {
@@ -48,10 +76,22 @@ export class ChangeTracker {
}
}
updateModified() {
// Get the workflow from the store as ChangeTracker is raw object, i.e.
// `this.workflow` is not reactive.
const workflow = useWorkflowStore().getWorkflowByPath(this.workflow.path)
if (workflow) {
workflow.isModified = !ChangeTracker.graphEqual(
this.initialState,
this.activeState
)
}
}
checkState() {
if (!this.app.graph || this.changeCount) return
const currentState = this.app.graph.serialize()
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
const currentState = this.app.graph.serialize() as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = clone(currentState)
return
@@ -63,10 +103,10 @@ export class ChangeTracker {
}
this.activeState = clone(currentState)
this.redoQueue.length = 0
this.workflow.unsaved = true
api.dispatchEvent(
new CustomEvent('graphChanged', { detail: this.activeState })
)
this.updateModified()
}
}
@@ -74,12 +114,12 @@ export class ChangeTracker {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState!)
this.isOurLoad = true
await this.app.loadGraphData(prevState, false, false, this.workflow, {
showMissingModelsDialog: false,
showMissingNodesDialog: false
})
this.activeState = prevState
this.updateModified()
}
}
@@ -114,21 +154,11 @@ export class ChangeTracker {
}
static init(app: ComfyApp) {
const changeTracker = () =>
app.workflowManager.activeWorkflow?.changeTracker ?? globalTracker
globalTracker.#setApp(app)
const getCurrentChangeTracker = () =>
useWorkflowStore().activeWorkflow?.changeTracker
const checkState = () => getCurrentChangeTracker()?.checkState()
const loadGraphData = app.loadGraphData
app.loadGraphData = async function (...args) {
const v = await loadGraphData.apply(this, args)
const ct = changeTracker()
if (ct.isOurLoad) {
ct.isOurLoad = false
} else {
ct.checkState()
}
return v
}
ChangeTracker.app = app
let keyIgnored = false
window.addEventListener(
@@ -160,12 +190,15 @@ export class ChangeTracker {
e.key === 'Meta'
if (keyIgnored) return
const changeTracker = getCurrentChangeTracker()
if (!changeTracker) return
// Check if this is a ctrl+z ctrl+y
if (await changeTracker().undoRedo(e)) return
if (await changeTracker.undoRedo(e)) return
// If our active element is some type of input then handle changes after they're done
if (ChangeTracker.bindInput(app, bindInputEl)) return
changeTracker().checkState()
changeTracker.checkState()
})
},
true
@@ -174,35 +207,35 @@ export class ChangeTracker {
window.addEventListener('keyup', (e) => {
if (keyIgnored) {
keyIgnored = false
changeTracker().checkState()
checkState()
}
})
// Handle clicking DOM elements (e.g. widgets)
window.addEventListener('mouseup', () => {
changeTracker().checkState()
checkState()
})
// Handle prompt queue event for dynamic widget changes
api.addEventListener('promptQueued', () => {
changeTracker().checkState()
checkState()
})
api.addEventListener('graphCleared', () => {
changeTracker().checkState()
checkState()
})
// Handle litegraph clicks
const processMouseUp = LGraphCanvas.prototype.processMouseUp
LGraphCanvas.prototype.processMouseUp = function (e) {
const v = processMouseUp.apply(this, [e])
changeTracker().checkState()
checkState()
return v
}
const processMouseDown = LGraphCanvas.prototype.processMouseDown
LGraphCanvas.prototype.processMouseDown = function (e) {
const v = processMouseDown.apply(this, [e])
changeTracker().checkState()
checkState()
return v
}
@@ -216,7 +249,7 @@ export class ChangeTracker {
) {
const extendedCallback = (v: any) => {
callback(v)
changeTracker().checkState()
checkState()
}
return prompt.apply(this, [title, value, extendedCallback, event])
}
@@ -225,7 +258,7 @@ export class ChangeTracker {
const close = LiteGraph.ContextMenu.prototype.close
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
const v = close.apply(this, [e])
changeTracker().checkState()
checkState()
return v
}
@@ -234,10 +267,7 @@ export class ChangeTracker {
LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) {
const v = onNodeAdded?.apply(this, [node])
if (!app?.configuringGraph) {
const ct = changeTracker()
if (!ct.isOurLoad) {
ct.checkState()
}
checkState()
}
return v
}
@@ -246,9 +276,9 @@ export class ChangeTracker {
document.addEventListener('litegraph:canvas', (e: Event) => {
const detail = (e as CustomEvent).detail
if (detail.subType === 'before-change') {
changeTracker().beforeChange()
getCurrentChangeTracker()?.beforeChange()
} else if (detail.subType === 'after-change') {
changeTracker().afterChange()
getCurrentChangeTracker()?.afterChange()
}
})
@@ -290,7 +320,7 @@ export class ChangeTracker {
const htmlElement = activeEl as HTMLElement
if (`on${evt}` in htmlElement) {
const listener = () => {
app.workflowManager.activeWorkflow?.changeTracker?.checkState()
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
htmlElement.removeEventListener(evt, listener)
}
htmlElement.addEventListener(evt, listener)
@@ -300,28 +330,24 @@ export class ChangeTracker {
return false
}
static graphEqual(a: any, b: any, path = '') {
static graphEqual(a: ComfyWorkflowJSON, b: ComfyWorkflowJSON) {
if (a === b) return true
if (typeof a == 'object' && a && typeof b == 'object' && b) {
const keys = Object.getOwnPropertyNames(a)
if (keys.length != Object.getOwnPropertyNames(b).length) {
// Compare nodes ignoring order
if (
!_.isEqualWith(a.nodes, b.nodes, (arrA, arrB) => {
if (Array.isArray(arrA) && Array.isArray(arrB)) {
return _.isEqual(new Set(arrA), new Set(arrB))
}
})
) {
return false
}
for (const key of keys) {
let av = a[key]
let bv = b[key]
if (!path && key === 'nodes') {
// Nodes need to be sorted as the order changes when selecting nodes
av = [...av].sort((a, b) => a.id - b.id)
bv = [...bv].sort((a, b) => a.id - b.id)
} else if (path === 'extra.ds') {
// Ignore view changes
continue
}
if (!ChangeTracker.graphEqual(av, bv, path + (path ? '.' : '') + key)) {
// Compare other properties normally
for (const key of ['links', 'groups']) {
if (!_.isEqual(a[key], b[key])) {
return false
}
}
@@ -332,5 +358,3 @@ export class ChangeTracker {
return false
}
}
export const globalTracker = new ChangeTracker({} as ComfyWorkflow)

View File

@@ -135,3 +135,16 @@ export const defaultGraph: ComfyWorkflowJSON = {
extra: {},
version: 0.4
}
export const defaultGraphJSON = JSON.stringify(defaultGraph)
export const blankGraph: ComfyWorkflowJSON = {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0.4
}

View File

@@ -3,6 +3,7 @@ import { LiteGraph } from '@comfyorg/litegraph'
import { api } from './api'
import { getFromPngFile } from './metadata/png'
import { getFromFlacFile } from './metadata/flac'
import { workflowService } from '@/services/workflowService'
// Original functions left in for backwards compatibility
export function getPngMetadata(file: File): Promise<Record<string, string>> {

View File

@@ -52,7 +52,15 @@ export class ComfyAsyncDialog extends ComfyDialog<HTMLDialogElement> {
super.close()
}
static async prompt({ title = null, message, actions }) {
static async prompt({
title = null,
message,
actions
}: {
title: string | null
message: string
actions: Array<string | { value?: any; text: string }>
}) {
const dialog = new ComfyAsyncDialog(actions)
const content = [$el('span', message)]
if (title) {

View File

@@ -28,7 +28,7 @@ function formatDate(text: string, date: Date) {
})
}
export function clone(obj) {
export function clone(obj: any) {
try {
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)

View File

@@ -1,430 +0,0 @@
// @ts-strict-ignore
import type { ComfyApp } from './app'
import { api } from './api'
import { ChangeTracker } from './changeTracker'
import { ComfyAsyncDialog } from './ui/components/asyncDialog'
import { LGraphCanvas, LGraph } from '@comfyorg/litegraph'
import { appendJsonExt, trimJsonExt } from '@/utils/formatUtil'
import {
useWorkflowStore,
useWorkflowBookmarkStore
} from '@/stores/workflowStore'
import { useExecutionStore } from '@/stores/executionStore'
import { markRaw, toRaw } from 'vue'
import { UserDataFullInfo } from '@/types/apiTypes'
import { useToastStore } from '@/stores/toastStore'
import { showPromptDialog } from '@/services/dialogService'
export class ComfyWorkflowManager extends EventTarget {
executionStore: ReturnType<typeof useExecutionStore> | null
workflowStore: ReturnType<typeof useWorkflowStore> | null
workflowBookmarkStore: ReturnType<typeof useWorkflowBookmarkStore> | null
app: ComfyApp
#unsavedCount = 0
get workflowLookup(): Record<string, ComfyWorkflow> {
return this.workflowStore?.workflowLookup ?? {}
}
get workflows(): ComfyWorkflow[] {
return this.workflowStore?.workflows ?? []
}
get openWorkflows(): ComfyWorkflow[] {
return (this.workflowStore?.openWorkflows ?? []) as ComfyWorkflow[]
}
get _activeWorkflow(): ComfyWorkflow | null {
if (!this.app.vueAppReady) return null
return this.workflowStore!.activeWorkflow as ComfyWorkflow | null
}
set _activeWorkflow(workflow: ComfyWorkflow | null) {
if (!this.app.vueAppReady) return
this.workflowStore!.activeWorkflow = workflow ? workflow : null
}
get activeWorkflow(): ComfyWorkflow | null {
return this._activeWorkflow ?? this.openWorkflows[0]
}
get activePromptId() {
return this.executionStore?.activePromptId
}
get activePrompt() {
return this.executionStore?.activePrompt
}
constructor(app: ComfyApp) {
super()
this.app = app
ChangeTracker.init(app)
}
async loadWorkflows() {
try {
const [files, _] = await Promise.all([
api.listUserDataFullInfo('workflows'),
this.workflowBookmarkStore?.loadBookmarks()
])
files.forEach((file: UserDataFullInfo) => {
let workflow = this.workflowLookup[file.path]
if (!workflow) {
workflow = new ComfyWorkflow(this, file.path, file.path.split('/'))
this.workflowLookup[workflow.path] = workflow
}
})
} catch (error) {
useToastStore().addAlert(
'Error loading workflows: ' + (error.message ?? error)
)
}
}
createTemporary(path?: string): ComfyWorkflow {
const workflow = new ComfyWorkflow(
this,
path ??
`Unsaved Workflow${
this.#unsavedCount++ ? ` (${this.#unsavedCount})` : ''
}`
)
this.workflowLookup[workflow.key] = workflow
return workflow
}
/**
* @param {string | ComfyWorkflow | null} workflow
*/
setWorkflow(workflow) {
if (workflow && typeof workflow === 'string') {
const found = this.workflows.find((w) => w.path === workflow)
if (found) {
workflow = found
workflow.unsaved = !workflow
}
}
if (!workflow || typeof workflow === 'string') {
workflow = this.createTemporary(workflow)
}
if (!workflow.isOpen) {
// Opening a new workflow
workflow.track()
}
this._activeWorkflow = workflow
this.dispatchEvent(new CustomEvent('changeWorkflow'))
}
storePrompt({ nodes, id }) {
this.executionStore?.storePrompt({
nodes,
id,
workflow: this.activeWorkflow
})
}
async closeWorkflow(workflow: ComfyWorkflow, warnIfUnsaved: boolean = true) {
if (!workflow.isOpen) {
return true
}
if (workflow.unsaved && warnIfUnsaved) {
const res = await ComfyAsyncDialog.prompt({
title: 'Save Changes?',
message: `Do you want to save changes to "${workflow.path ?? workflow.name}" before closing?`,
actions: ['Yes', 'No', 'Cancel']
})
if (res === 'Yes') {
const active = this.activeWorkflow
if (active !== workflow) {
// We need to switch to the workflow to save it
await workflow.load()
}
if (!(await workflow.save())) {
// Save was canceled, restore the previous workflow
if (active !== workflow) {
await active.load()
}
return
}
} else if (res === 'Cancel') {
return
}
}
workflow.changeTracker = null
workflow.isOpen = false
if (this.openWorkflows.length > 0) {
this._activeWorkflow = this.openWorkflows[0]
await this._activeWorkflow.load()
} else {
// Load default
await this.app.loadGraphData()
}
}
}
export class ComfyWorkflow {
name: string
path: string | null
pathParts: string[] | null
unsaved = false
// Raw
manager: ComfyWorkflowManager
changeTracker: ChangeTracker | null = null
isOpen: boolean = false
get isTemporary() {
return !this.path
}
get isPersisted() {
return !this.isTemporary
}
get key() {
return this.pathParts?.join('/') ?? this.name + '.json'
}
get isBookmarked() {
return this.manager.workflowBookmarkStore?.isBookmarked(this.path) ?? false
}
constructor(
manager: ComfyWorkflowManager,
path: string,
pathParts?: string[]
) {
this.manager = markRaw(manager)
if (pathParts) {
this.updatePath(path, pathParts)
} else {
this.name = path
this.unsaved = true
}
}
private updatePath(path: string, pathParts: string[]) {
this.path = path
if (!pathParts) {
if (!path.includes('\\')) {
pathParts = path.split('/')
} else {
pathParts = path.split('\\')
}
}
this.pathParts = pathParts
this.name = trimJsonExt(pathParts[pathParts.length - 1])
}
async getWorkflowData() {
const resp = await api.getUserData('workflows/' + this.path)
if (resp.status !== 200) {
useToastStore().addAlert(
`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`
)
return
}
return await resp.json()
}
async load() {
if (this.isOpen) {
await this.manager.app.loadGraphData(
this.changeTracker.activeState,
true,
true,
this,
{
showMissingModelsDialog: false,
showMissingNodesDialog: false
}
)
} else {
const data = await this.getWorkflowData()
if (!data) return
await this.manager.app.loadGraphData(data, true, true, this)
}
}
async save(saveAs = false) {
const createNewFile = !this.path || saveAs
return !!(await this._save(
createNewFile ? null : this.path,
/* overwrite */ !createNewFile
))
}
async favorite(value: boolean) {
try {
if (this.isBookmarked === value) return
this.manager.workflowBookmarkStore?.setBookmarked(this.path, value)
this.manager.dispatchEvent(new CustomEvent('favorite', { detail: this }))
} catch (error) {
useToastStore().addAlert(
'Error favoriting workflow ' +
this.path +
'\n' +
(error.message ?? error)
)
}
}
async rename(path: string) {
path = appendJsonExt(path)
let resp = await api.moveUserData(
'workflows/' + this.path,
'workflows/' + path
)
if (resp.status === 409) {
if (
!confirm(
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return resp
resp = await api.moveUserData(
'workflows/' + this.path,
'workflows/' + path,
{ overwrite: true }
)
}
if (resp.status !== 200) {
useToastStore().addAlert(
`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`
)
return
}
if (this.isBookmarked) {
await this.favorite(false)
}
path = (await resp.json()).substring('workflows/'.length)
this.updatePath(path, null)
if (this.isBookmarked) {
await this.favorite(true)
}
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
}
async insert() {
const data = await this.getWorkflowData()
if (!data) return
const old = localStorage.getItem('litegrapheditor_clipboard')
const graph = new LGraph(data)
const canvas = new LGraphCanvas(null, graph, {
skip_events: true,
skip_render: true
})
canvas.selectNodes()
canvas.copyToClipboard()
this.manager.app.canvas.pasteFromClipboard()
localStorage.setItem('litegrapheditor_clipboard', old)
}
async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
if (this.isBookmarked) {
await this.favorite(false)
}
const resp = await api.deleteUserData('workflows/' + this.path)
if (resp.status !== 204) {
useToastStore().addAlert(
`Error removing user data file '${this.path}': ${resp.status} ${resp.statusText}`
)
}
this.unsaved = true
this.path = null
this.pathParts = null
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1)
this.manager.dispatchEvent(new CustomEvent('delete', { detail: this }))
}
track() {
if (this.changeTracker) {
this.changeTracker.restore()
} else {
this.changeTracker = markRaw(new ChangeTracker(this))
}
this.isOpen = true
}
private async _save(path: string | null, overwrite: boolean) {
if (!path) {
path = await showPromptDialog({
title: 'Save workflow',
message: 'Enter the filename:',
defaultValue: trimJsonExt(this.path) ?? this.name ?? 'workflow'
})
if (!path) return
}
path = appendJsonExt(path)
const workflow = this.manager.app.serializeGraph()
const json = JSON.stringify(workflow, null, 2)
let resp = await api.storeUserData('workflows/' + path, json, {
stringify: false,
throwOnError: false,
overwrite
})
if (resp.status === 409) {
if (
!confirm(
`Workflow '${path}' already exists, do you want to overwrite it?`
)
)
return
resp = await api.storeUserData('workflows/' + path, json, {
stringify: false
})
}
if (resp.status !== 200) {
useToastStore().addAlert(
`Error saving workflow '${this.path}': ${resp.status} ${resp.statusText}`
)
return
}
path = (await resp.json()).substring('workflows/'.length)
if (!this.path) {
// Saved new workflow, patch this instance
const oldKey = this.key
this.updatePath(path, null)
// Update workflowLookup: change the key from the old unsaved path to the new saved path
delete this.manager.workflowStore.workflowLookup[oldKey]
this.manager.workflowStore.workflowLookup[this.key] = this
await this.manager.loadWorkflows()
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('rename', { detail: this }))
} else if (path !== this.path) {
// Saved as, open the new copy
await this.manager.loadWorkflows()
const workflow = this.manager.workflowLookup[path]
await workflow.load()
} else {
// Normal save
this.unsaved = false
this.manager.dispatchEvent(new CustomEvent('save', { detail: this }))
}
return true
}
}