mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-31 05:19:53 +00:00
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:
@@ -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())
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user