Merge remote-tracking branch 'origin/main' into feat/new-workflow-templates

This commit is contained in:
Johnpaul
2025-09-17 03:07:00 +01:00
345 changed files with 12105 additions and 1151 deletions

View File

@@ -1,137 +0,0 @@
import { fromZodError } from 'zod-validation-error'
import {
type AssetResponse,
type ModelFile,
type ModelFolder,
assetResponseSchema
} from '@/schemas/assetSchema'
import { api } from '@/scripts/api'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const ASSETS_ENDPOINT = '/assets'
const MODELS_TAG = 'models'
const MISSING_TAG = 'missing'
/**
* Input names that are eligible for asset browser
*/
const WHITELISTED_INPUTS = new Set(['ckpt_name', 'lora_name', 'vae_name'])
/**
* Validates asset response data using Zod schema
*/
function validateAssetResponse(data: unknown): AssetResponse {
const result = assetResponseSchema.safeParse(data)
if (result.success) return result.data
const error = fromZodError(result.error)
throw new Error(`Invalid asset response against zod schema:\n${error}`)
}
/**
* Private service for asset-related network requests
* Not exposed globally - used internally by ComfyApi
*/
function createAssetService() {
/**
* Handles API response with consistent error handling and Zod validation
*/
async function handleAssetRequest(
url: string,
context: string
): Promise<AssetResponse> {
const res = await api.fetchApi(url)
if (!res.ok) {
throw new Error(
`Unable to load ${context}: Server returned ${res.status}. Please try again.`
)
}
const data = await res.json()
return validateAssetResponse(data)
}
/**
* Gets a list of model folder keys from the asset API
*
* Logic:
* 1. Extract directory names directly from asset tags
* 2. Filter out blacklisted directories
* 3. Return alphabetically sorted directories with assets
*
* @returns The list of model folder keys
*/
async function getAssetModelFolders(): Promise<ModelFolder[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}`,
'model folders'
)
// Blacklist directories we don't want to show
const blacklistedDirectories = ['configs']
// Extract directory names from assets that actually exist, exclude missing assets
const discoveredFolders = new Set<string>(
data?.assets
?.filter((asset) => !asset.tags.includes(MISSING_TAG))
?.flatMap((asset) => asset.tags)
?.filter(
(tag) => tag !== MODELS_TAG && !blacklistedDirectories.includes(tag)
) ?? []
)
// Return only discovered folders in alphabetical order
const sortedFolders = Array.from(discoveredFolders).toSorted()
return sortedFolders.map((name) => ({ name, folders: [] }))
}
/**
* Gets a list of models in the specified folder from the asset API
* @param folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async function getAssetModels(folder: string): Promise<ModelFile[]> {
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}`,
`models for ${folder}`
)
return (
data?.assets
?.filter(
(asset) =>
!asset.tags.includes(MISSING_TAG) && asset.tags.includes(folder)
)
?.map((asset) => ({
name: asset.name,
pathIndex: 0
})) ?? []
)
}
/**
* Checks if a widget input should use the asset browser based on both input name and node comfyClass
*
* @param inputName - The input name (e.g., 'ckpt_name', 'lora_name')
* @param nodeType - The ComfyUI node comfyClass (e.g., 'CheckpointLoaderSimple', 'LoraLoader')
* @returns true if this input should use asset browser
*/
function isAssetBrowserEligible(
inputName: string,
nodeType: string
): boolean {
return (
// Must be an approved input name
WHITELISTED_INPUTS.has(inputName) &&
// Must be a registered node type
useModelToNodeStore().getRegisteredNodeTypes().has(nodeType)
)
}
return {
getAssetModelFolders,
getAssetModels,
isAssetBrowserEligible
}
}
export const assetService = createAssetService()

View File

@@ -1,8 +1,8 @@
import { register } from 'extendable-media-recorder'
import { connect } from 'extendable-media-recorder-wav-encoder'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
export interface AudioRecordingError {
type: 'permission' | 'not_supported' | 'encoder' | 'recording' | 'unknown'

View File

@@ -4,6 +4,7 @@ import { fromZodError } from 'zod-validation-error'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import {
Colors,
type Palette,
@@ -12,7 +13,6 @@ import {
import { app } from '@/scripts/app'
import { downloadBlob, uploadFile } from '@/scripts/utils'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
export const useColorPaletteService = () => {

View File

@@ -8,7 +8,6 @@ import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue'
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
@@ -22,6 +21,7 @@ import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import { t } from '@/i18n'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import {
type DialogComponentProps,

View File

@@ -1,11 +1,12 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'
@@ -72,6 +73,13 @@ export const useExtensionService = () => {
}
})()
}
if (extension.onAuthUserResolved) {
const { onUserResolved } = useCurrentUser()
onUserResolved((user) => {
void extension.onAuthUserResolved?.(user, app)
})
}
}
/**

View File

@@ -1,4 +1,6 @@
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'
import {
@@ -6,7 +8,6 @@ import {
KeybindingImpl,
useKeybindingStore
} from '@/stores/keybindingStore'
import { useSettingStore } from '@/stores/settingStore'
export const useKeybindingService = () => {
const keybindingStore = useKeybindingStore()
@@ -14,6 +15,19 @@ export const useKeybindingService = () => {
const settingStore = useSettingStore()
const dialogStore = useDialogStore()
// Helper function to determine if an event should be forwarded to canvas
const shouldForwardToCanvas = (event: KeyboardEvent): boolean => {
// Don't forward if modifier keys are pressed (except shift)
if (event.ctrlKey || event.altKey || event.metaKey) {
return false
}
// Keys that LiteGraph handles but aren't in core keybindings
const canvasKeys = ['Delete', 'Backspace']
return canvasKeys.includes(event.key)
}
const keybindHandler = async function (event: KeyboardEvent) {
const keyCombo = KeyComboImpl.fromEvent(event)
if (keyCombo.isModifier) {
@@ -26,6 +40,7 @@ export const useKeybindingService = () => {
keyCombo.isReservedByTextInput &&
(target.tagName === 'TEXTAREA' ||
target.tagName === 'INPUT' ||
target.contentEditable === 'true' ||
(target.tagName === 'SPAN' &&
target.classList.contains('property_value')))
) {
@@ -53,6 +68,20 @@ export const useKeybindingService = () => {
return
}
// Forward unhandled canvas-targeted events to LiteGraph
if (!keybinding && shouldForwardToCanvas(event)) {
const canvas = app.canvas
if (
canvas &&
canvas.processKey &&
typeof canvas.processKey === 'function'
) {
// Let LiteGraph handle the event
canvas.processKey(event)
return
}
}
// Only clear dialogs if not using modifiers
if (event.ctrlKey || event.altKey || event.metaKey) {
return

View File

@@ -24,7 +24,11 @@ import type {
ISerialisableNodeOutput,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type {
ComfyNodeDef as ComfyNodeDefV2,
@@ -37,14 +41,10 @@ import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { $el } from '@/scripts/ui'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
isImageNode,

View File

@@ -4,7 +4,7 @@ import { useLoad3dViewer } from '@/composables/useLoad3dViewer'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { NodeId } from '@/schemas/comfyWorkflowSchema'
import { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void

View File

@@ -1,4 +1,4 @@
import type { useSettingStore } from '@/stores/settingStore'
import type { useSettingStore } from '@/platform/settings/settingStore'
let pendingCallbacks: Array<() => Promise<void>> = []
let isNewUserDetermined = false

View File

@@ -1,119 +0,0 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Use generated types from OpenAPI spec
export type ReleaseNote = components['schemas']['ReleaseNote']
type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
// Use generated error response type
type ErrorResponse = components['schemas']['ErrorResponse']
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
// No transformation needed - API response matches the generated type
// Handle API errors with context
const handleApiError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
): string => {
if (!axios.isAxiosError(err))
return err instanceof Error
? `${context}: ${err.message}`
: `${context}: Unknown error occurred`
const axiosError = err as AxiosError<ErrorResponse>
if (axiosError.response) {
const { status, data } = axiosError.response
if (routeSpecificErrors && routeSpecificErrors[status])
return routeSpecificErrors[status]
switch (status) {
case 400:
return `Bad request: ${data?.message || 'Invalid input'}`
case 401:
return 'Unauthorized: Authentication required'
case 403:
return `Forbidden: ${data?.message || 'Access denied'}`
case 404:
return `Not found: ${data?.message || 'Resource not found'}`
case 500:
return `Server error: ${data?.message || 'Internal server error'}`
default:
return `${context}: ${data?.message || axiosError.message}`
}
}
return `${context}: ${axiosError.message}`
}
// Execute API request with error handling
const executeApiRequest = async <T>(
apiCall: () => Promise<AxiosResponse<T>>,
errorContext: string,
routeSpecificErrors?: Record<number, string>
): Promise<T | null> => {
isLoading.value = true
error.value = null
try {
const response = await apiCall()
return response.data
} catch (err) {
// Don't treat cancellations as errors
if (isAbortError(err)) return null
error.value = handleApiError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
signal?: AbortSignal
): Promise<ReleaseNote[] | null> => {
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
400: 'Invalid project or version parameter'
}
const apiResponse = await executeApiRequest(
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal
}),
errorContext,
routeSpecificErrors
)
return apiResponse
}
return {
isLoading,
error,
getReleases
}
}

View File

@@ -3,7 +3,7 @@ import {
type ExportedSubgraphInstance,
type Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'

View File

@@ -1,412 +0,0 @@
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 { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
import { useDialogService } from './dialogService'
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
}
}