Spaces:
Sleeping
Sleeping
| // @ts-check | |
| import { api } from "./api.js"; | |
| import { ChangeTracker } from "./changeTracker.js"; | |
| import { ComfyAsyncDialog } from "./ui/components/asyncDialog.js"; | |
| import { getStorageValue, setStorageValue } from "./utils.js"; | |
| function appendJsonExt(path) { | |
| if (!path.toLowerCase().endsWith(".json")) { | |
| path += ".json"; | |
| } | |
| return path; | |
| } | |
| export function trimJsonExt(path) { | |
| return path?.replace(/\.json$/, ""); | |
| } | |
| export class ComfyWorkflowManager extends EventTarget { | |
| /** @type {string | null} */ | |
| #activePromptId = null; | |
| #unsavedCount = 0; | |
| #activeWorkflow; | |
| /** @type {Record<string, ComfyWorkflow>} */ | |
| workflowLookup = {}; | |
| /** @type {Array<ComfyWorkflow>} */ | |
| workflows = []; | |
| /** @type {Array<ComfyWorkflow>} */ | |
| openWorkflows = []; | |
| /** @type {Record<string, {workflow?: ComfyWorkflow, nodes?: Record<string, boolean>}>} */ | |
| queuedPrompts = {}; | |
| get activeWorkflow() { | |
| return this.#activeWorkflow ?? this.openWorkflows[0]; | |
| } | |
| get activePromptId() { | |
| return this.#activePromptId; | |
| } | |
| get activePrompt() { | |
| return this.queuedPrompts[this.#activePromptId]; | |
| } | |
| /** | |
| * @param {import("./app.js").ComfyApp} app | |
| */ | |
| constructor(app) { | |
| super(); | |
| this.app = app; | |
| ChangeTracker.init(app); | |
| this.#bindExecutionEvents(); | |
| } | |
| #bindExecutionEvents() { | |
| // TODO: on reload, set active prompt based on the latest ws message | |
| const emit = () => this.dispatchEvent(new CustomEvent("execute", { detail: this.activePrompt })); | |
| let executing = null; | |
| api.addEventListener("execution_start", (e) => { | |
| this.#activePromptId = e.detail.prompt_id; | |
| // This event can fire before the event is stored, so put a placeholder | |
| this.queuedPrompts[this.#activePromptId] ??= { nodes: {} }; | |
| emit(); | |
| }); | |
| api.addEventListener("execution_cached", (e) => { | |
| if (!this.activePrompt) return; | |
| for (const n of e.detail.nodes) { | |
| this.activePrompt.nodes[n] = true; | |
| } | |
| emit(); | |
| }); | |
| api.addEventListener("executed", (e) => { | |
| if (!this.activePrompt) return; | |
| this.activePrompt.nodes[e.detail.node] = true; | |
| emit(); | |
| }); | |
| api.addEventListener("executing", (e) => { | |
| if (!this.activePrompt) return; | |
| if (executing) { | |
| // Seems sometimes nodes that are cached fire executing but not executed | |
| this.activePrompt.nodes[executing] = true; | |
| } | |
| executing = e.detail; | |
| if (!executing) { | |
| delete this.queuedPrompts[this.#activePromptId]; | |
| this.#activePromptId = null; | |
| } | |
| emit(); | |
| }); | |
| } | |
| async loadWorkflows() { | |
| try { | |
| let favorites; | |
| const resp = await api.getUserData("workflows/.index.json"); | |
| let info; | |
| if (resp.status === 200) { | |
| info = await resp.json(); | |
| favorites = new Set(info?.favorites ?? []); | |
| } else { | |
| favorites = new Set(); | |
| } | |
| const workflows = (await api.listUserData("workflows", true, true)).map((w) => { | |
| let workflow = this.workflowLookup[w[0]]; | |
| if (!workflow) { | |
| workflow = new ComfyWorkflow(this, w[0], w.slice(1), favorites.has(w[0])); | |
| this.workflowLookup[workflow.path] = workflow; | |
| } | |
| return workflow; | |
| }); | |
| this.workflows = workflows; | |
| } catch (error) { | |
| alert("Error loading workflows: " + (error.message ?? error)); | |
| this.workflows = []; | |
| } | |
| } | |
| async saveWorkflowMetadata() { | |
| await api.storeUserData("workflows/.index.json", { | |
| favorites: [...this.workflows.filter((w) => w.isFavorite).map((w) => w.path)], | |
| }); | |
| } | |
| /** | |
| * @param {string | ComfyWorkflow | null} workflow | |
| */ | |
| setWorkflow(workflow) { | |
| if (workflow && typeof workflow === "string") { | |
| // Selected by path, i.e. on reload of last workflow | |
| const found = this.workflows.find((w) => w.path === workflow); | |
| if (found) { | |
| workflow = found; | |
| workflow.unsaved = !workflow || getStorageValue("Comfy.PreviousWorkflowUnsaved") === "true"; | |
| } | |
| } | |
| if (!(workflow instanceof ComfyWorkflow)) { | |
| // Still not found, either reloading a deleted workflow or blank | |
| workflow = new ComfyWorkflow(this, workflow || "Unsaved Workflow" + (this.#unsavedCount++ ? ` (${this.#unsavedCount})` : "")); | |
| } | |
| const index = this.openWorkflows.indexOf(workflow); | |
| if (index === -1) { | |
| // Opening a new workflow | |
| this.openWorkflows.push(workflow); | |
| } | |
| this.#activeWorkflow = workflow; | |
| setStorageValue("Comfy.PreviousWorkflow", this.activeWorkflow.path ?? ""); | |
| this.dispatchEvent(new CustomEvent("changeWorkflow")); | |
| } | |
| storePrompt({ nodes, id }) { | |
| this.queuedPrompts[id] ??= {}; | |
| this.queuedPrompts[id].nodes = { | |
| ...nodes.reduce((p, n) => { | |
| p[n] = false; | |
| return p; | |
| }, {}), | |
| ...this.queuedPrompts[id].nodes, | |
| }; | |
| this.queuedPrompts[id].workflow = this.activeWorkflow; | |
| } | |
| /** | |
| * @param {ComfyWorkflow} workflow | |
| */ | |
| async closeWorkflow(workflow, warnIfUnsaved = 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; | |
| this.openWorkflows.splice(this.openWorkflows.indexOf(workflow), 1); | |
| if (this.openWorkflows.length) { | |
| this.#activeWorkflow = this.openWorkflows[0]; | |
| await this.#activeWorkflow.load(); | |
| } else { | |
| // Load default | |
| await this.app.loadGraphData(); | |
| } | |
| } | |
| } | |
| export class ComfyWorkflow { | |
| #name; | |
| #path; | |
| #pathParts; | |
| #isFavorite = false; | |
| /** @type {ChangeTracker | null} */ | |
| changeTracker = null; | |
| unsaved = false; | |
| get name() { | |
| return this.#name; | |
| } | |
| get path() { | |
| return this.#path; | |
| } | |
| get pathParts() { | |
| return this.#pathParts; | |
| } | |
| get isFavorite() { | |
| return this.#isFavorite; | |
| } | |
| get isOpen() { | |
| return !!this.changeTracker; | |
| } | |
| /** | |
| * @overload | |
| * @param {ComfyWorkflowManager} manager | |
| * @param {string} path | |
| */ | |
| /** | |
| * @overload | |
| * @param {ComfyWorkflowManager} manager | |
| * @param {string} path | |
| * @param {string[]} pathParts | |
| * @param {boolean} isFavorite | |
| */ | |
| /** | |
| * @param {ComfyWorkflowManager} manager | |
| * @param {string} path | |
| * @param {string[]} [pathParts] | |
| * @param {boolean} [isFavorite] | |
| */ | |
| constructor(manager, path, pathParts, isFavorite) { | |
| this.manager = manager; | |
| if (pathParts) { | |
| this.#updatePath(path, pathParts); | |
| this.#isFavorite = isFavorite; | |
| } else { | |
| this.#name = path; | |
| this.unsaved = true; | |
| } | |
| } | |
| /** | |
| * @param {string} path | |
| * @param {string[]} [pathParts] | |
| */ | |
| #updatePath(path, pathParts) { | |
| 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) { | |
| alert(`Error loading workflow file '${this.path}': ${resp.status} ${resp.statusText}`); | |
| return; | |
| } | |
| return await resp.json(); | |
| } | |
| load = async () => { | |
| if (this.isOpen) { | |
| await this.manager.app.loadGraphData(this.changeTracker.activeState, true, true, this); | |
| } else { | |
| const data = await this.getWorkflowData(); | |
| if (!data) return; | |
| await this.manager.app.loadGraphData(data, true, true, this); | |
| } | |
| }; | |
| async save(saveAs = false) { | |
| if (!this.path || saveAs) { | |
| return !!(await this.#save(null, false)); | |
| } else { | |
| return !!(await this.#save(this.path, true)); | |
| } | |
| } | |
| /** | |
| * @param {boolean} value | |
| */ | |
| async favorite(value) { | |
| try { | |
| if (this.#isFavorite === value) return; | |
| this.#isFavorite = value; | |
| await this.manager.saveWorkflowMetadata(); | |
| this.manager.dispatchEvent(new CustomEvent("favorite", { detail: this })); | |
| } catch (error) { | |
| alert("Error favoriting workflow " + this.path + "\n" + (error.message ?? error)); | |
| } | |
| } | |
| /** | |
| * @param {string} path | |
| */ | |
| async rename(path) { | |
| 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) { | |
| alert(`Error renaming workflow file '${this.path}': ${resp.status} ${resp.statusText}`); | |
| return; | |
| } | |
| const isFav = this.isFavorite; | |
| if (isFav) { | |
| await this.favorite(false); | |
| } | |
| path = (await resp.json()).substring("workflows/".length); | |
| this.#updatePath(path, null); | |
| if (isFav) { | |
| await this.favorite(true); | |
| } | |
| this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); | |
| setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); | |
| } | |
| 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 | |
| try { | |
| if (this.isFavorite) { | |
| await this.favorite(false); | |
| } | |
| await api.deleteUserData("workflows/" + this.path); | |
| 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 })); | |
| } catch (error) { | |
| alert(`Error deleting workflow: ${error.message || error}`); | |
| } | |
| } | |
| track() { | |
| if (this.changeTracker) { | |
| this.changeTracker.restore(); | |
| } else { | |
| this.changeTracker = new ChangeTracker(this); | |
| } | |
| } | |
| /** | |
| * @param {string|null} path | |
| * @param {boolean} overwrite | |
| */ | |
| async #save(path, overwrite) { | |
| if (!path) { | |
| path = prompt("Save workflow as:", trimJsonExt(this.path) ?? this.name ?? "workflow"); | |
| if (!path) return; | |
| } | |
| path = appendJsonExt(path); | |
| const p = await this.manager.app.graphToPrompt(); | |
| const json = JSON.stringify(p.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) { | |
| alert(`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 | |
| this.#updatePath(path, null); | |
| await this.manager.loadWorkflows(); | |
| this.unsaved = false; | |
| this.manager.dispatchEvent(new CustomEvent("rename", { detail: this })); | |
| setStorageValue("Comfy.PreviousWorkflow", this.path ?? ""); | |
| } 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; | |
| } | |
| } | |