merge main into rh-test

This commit is contained in:
bymyself
2025-09-28 15:33:29 -07:00
parent 1c0f151d02
commit ff0c15b119
1317 changed files with 85439 additions and 18373 deletions

View File

@@ -100,57 +100,64 @@ The following diagram illustrates the store architecture and data flow:
## Core Stores
The following table lists ALL stores in the system as of 2025-01-30:
The following table lists ALL 46 store instances in the system as of 2025-09-01:
### Main Stores
| Store | Description | Category |
|-------|-------------|----------|
| aboutPanelStore.ts | Manages the About panel state and badges | UI |
| apiKeyAuthStore.ts | Handles API key authentication | Auth |
| comfyManagerStore.ts | Manages ComfyUI application state | Core |
| comfyRegistryStore.ts | Handles extensions registry | Registry |
| commandStore.ts | Manages commands and command execution | Core |
| dialogStore.ts | Controls dialog/modal display and state | UI |
| domWidgetStore.ts | Manages DOM widget state | Widgets |
| electronDownloadStore.ts | Handles Electron-specific download operations | Platform |
| executionStore.ts | Tracks workflow execution state | Execution |
| extensionStore.ts | Manages extension registration and state | Extensions |
| firebaseAuthStore.ts | Handles Firebase authentication | Auth |
| graphStore.ts | Manages the graph canvas state | Core |
| imagePreviewStore.ts | Controls image preview functionality | Media |
| keybindingStore.ts | Manages keyboard shortcuts | Input |
| maintenanceTaskStore.ts | Handles system maintenance tasks | System |
| menuItemStore.ts | Handles menu items and their state | UI |
| modelStore.ts | Manages AI models information | Models |
| modelToNodeStore.ts | Maps models to compatible nodes | Models |
| nodeBookmarkStore.ts | Manages node bookmarks and favorites | Nodes |
| nodeDefStore.ts | Manages node definitions | Nodes |
| queueStore.ts | Handles the execution queue | Execution |
| releaseStore.ts | Manages application release information | System |
| serverConfigStore.ts | Handles server configuration | Config |
| settingStore.ts | Manages application settings | Config |
| subgraphNavigationStore.ts | Handles subgraph navigation state | Navigation |
| systemStatsStore.ts | Tracks system performance statistics | System |
| toastStore.ts | Manages toast notifications | UI |
| userFileStore.ts | Manages user file operations | Files |
| userStore.ts | Manages user data and preferences | User |
| versionCompatibilityStore.ts | Manages frontend/backend version compatibility warnings | Core |
| widgetStore.ts | Manages widget configurations | Widgets |
| workflowStore.ts | Handles workflow data and operations | Workflows |
| workflowTemplatesStore.ts | Manages workflow templates | Workflows |
| workspaceStore.ts | Manages overall workspace state | Workspace |
| File | Store | Description | Category |
|------|-------|-------------|----------|
| aboutPanelStore.ts | useAboutPanelStore | Manages the About panel state and badges | UI |
| apiKeyAuthStore.ts | useApiKeyAuthStore | Handles API key authentication | Auth |
| comfyManagerStore.ts | useComfyManagerStore | Manages ComfyUI application state | Core |
| comfyManagerStore.ts | useManagerProgressDialogStore | Manages manager progress dialog state | UI |
| comfyRegistryStore.ts | useComfyRegistryStore | Handles extensions registry | Registry |
| commandStore.ts | useCommandStore | Manages commands and command execution | Core |
| dialogStore.ts | useDialogStore | Controls dialog/modal display and state | UI |
| domWidgetStore.ts | useDomWidgetStore | Manages DOM widget state | Widgets |
| electronDownloadStore.ts | useElectronDownloadStore | Handles Electron-specific download operations | Platform |
| executionStore.ts | useExecutionStore | Tracks workflow execution state | Execution |
| extensionStore.ts | useExtensionStore | Manages extension registration and state | Extensions |
| firebaseAuthStore.ts | useFirebaseAuthStore | Handles Firebase authentication | Auth |
| graphStore.ts | useTitleEditorStore | Manages title editing for nodes and groups | UI |
| graphStore.ts | useCanvasStore | Manages the graph canvas state and interactions | Core |
| helpCenterStore.ts | useHelpCenterStore | Manages help center visibility and state | UI |
| imagePreviewStore.ts | useNodeOutputStore | Manages node outputs and execution results | Media |
| keybindingStore.ts | useKeybindingStore | Manages keyboard shortcuts | Input |
| maintenanceTaskStore.ts | useMaintenanceTaskStore | Handles system maintenance tasks | System |
| menuItemStore.ts | useMenuItemStore | Handles menu items and their state | UI |
| modelStore.ts | useModelStore | Manages AI models information | Models |
| modelToNodeStore.ts | useModelToNodeStore | Maps models to compatible nodes | Models |
| nodeBookmarkStore.ts | useNodeBookmarkStore | Manages node bookmarks and favorites | Nodes |
| nodeDefStore.ts | useNodeDefStore | Manages node definitions and schemas | Nodes |
| nodeDefStore.ts | useNodeFrequencyStore | Tracks node usage frequency | Nodes |
| queueStore.ts | useQueueStore | Manages execution queue and task history | Execution |
| queueStore.ts | useQueuePendingTaskCountStore | Tracks pending task counts | Execution |
| queueStore.ts | useQueueSettingsStore | Manages queue execution settings | Execution |
| releaseStore.ts | useReleaseStore | Manages application release information | System |
| serverConfigStore.ts | useServerConfigStore | Handles server configuration | Config |
| settingStore.ts | useSettingStore | Manages application settings | Config |
| subgraphNavigationStore.ts | useSubgraphNavigationStore | Handles subgraph navigation state | Navigation |
| systemStatsStore.ts | useSystemStatsStore | Tracks system performance statistics | System |
| toastStore.ts | useToastStore | Manages toast notifications | UI |
| userFileStore.ts | useUserFileStore | Manages user file operations | Files |
| userStore.ts | useUserStore | Manages user data and preferences | User |
| versionCompatibilityStore.ts | useVersionCompatibilityStore | Manages frontend/backend version compatibility warnings | Core |
| widgetStore.ts | useWidgetStore | Manages widget configurations | Widgets |
| workflowStore.ts | useWorkflowStore | Handles workflow data and operations | Workflows |
| workflowStore.ts | useWorkflowBookmarkStore | Manages workflow bookmarks and favorites | Workflows |
| workflowTemplatesStore.ts | useWorkflowTemplatesStore | Manages workflow templates | Workflows |
| workspaceStore.ts | useWorkspaceStore | Manages overall workspace state | Workspace |
### Workspace Stores
Located in `stores/workspace/`:
| Store | Description |
|-------|-------------|
| bottomPanelStore.ts | Controls bottom panel visibility and state |
| colorPaletteStore.ts | Manages color palette configurations |
| nodeHelpStore.ts | Handles node help and documentation display |
| searchBoxStore.ts | Manages search box functionality |
| sidebarTabStore.ts | Controls sidebar tab states and navigation |
| File | Store | Description | Category |
|------|-------|-------------|----------|
| bottomPanelStore.ts | useBottomPanelStore | Controls bottom panel visibility and state | UI |
| colorPaletteStore.ts | useColorPaletteStore | Manages color palette configurations | UI |
| nodeHelpStore.ts | useNodeHelpStore | Handles node help and documentation display | UI |
| searchBoxStore.ts | useSearchBoxStore | Manages search box functionality | UI |
| sidebarTabStore.ts | useSidebarTabStore | Controls sidebar tab states and navigation | UI |
## Store Development Guidelines
@@ -189,7 +196,7 @@ export const useExampleStore = defineStore('example', () => {
async function fetchItems() {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/items')
const data = await response.json()
@@ -207,11 +214,11 @@ export const useExampleStore = defineStore('example', () => {
items,
isLoading,
error,
// Getters
itemCount,
hasError,
// Actions
addItem,
fetchItems
@@ -238,7 +245,7 @@ export const useDataStore = defineStore('data', () => {
async function fetchData() {
loading.value = true
try {
const result = await api.getData()
const result = await api.getExtensions()
data.value = result
} catch (err) {
error.value = err.message
@@ -266,21 +273,21 @@ import { useOtherStore } from './otherStore'
export const useComposedStore = defineStore('composed', () => {
const otherStore = useOtherStore()
const { someData } = storeToRefs(otherStore)
// Local state
const localState = ref(0)
// Computed value based on other store
const derivedValue = computed(() => {
return computeFromOtherData(someData.value, localState.value)
})
// Action that uses another store
async function complexAction() {
await otherStore.someAction()
localState.value += 1
}
return {
localState,
derivedValue,
@@ -299,20 +306,20 @@ export const usePreferencesStore = defineStore('preferences', () => {
// Load from localStorage if available
const theme = ref(localStorage.getItem('theme') || 'light')
const fontSize = ref(parseInt(localStorage.getItem('fontSize') || '14'))
// Save to localStorage when changed
watch(theme, (newTheme) => {
localStorage.setItem('theme', newTheme)
})
watch(fontSize, (newSize) => {
localStorage.setItem('fontSize', newSize.toString())
})
function setTheme(newTheme) {
theme.value = newTheme
}
return {
theme,
fontSize,
@@ -347,7 +354,7 @@ describe('useExampleStore', () => {
// Create a fresh pinia instance and make it active
setActivePinia(createPinia())
store = useExampleStore()
// Clear all mocks
vi.clearAllMocks()
})
@@ -363,14 +370,14 @@ describe('useExampleStore', () => {
expect(store.items).toEqual(['test'])
expect(store.itemCount).toBe(1)
})
it('should fetch items', async () => {
// Setup mock response
vi.mocked(api.getData).mockResolvedValue(['item1', 'item2'])
// Call the action
await store.fetchItems()
// Verify state changes
expect(store.isLoading).toBe(false)
expect(store.items).toEqual(['item1', 'item2'])

View File

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { AboutPageBadge } from '@/types/comfy'
import type { AboutPageBadge } from '@/types/comfy'
import { electronAPI, isElectron } from '@/utils/envUtil'
import { useExtensionStore } from './extensionStore'

View File

@@ -4,10 +4,10 @@ import { computed, ref, watch } from 'vue'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { t } from '@/i18n'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { useToastStore } from '@/stores/toastStore'
import { ApiKeyAuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
import type { ApiKeyAuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
type ComfyApiUser =
operations['createCustomer']['responses']['201']['content']['application/json']

View File

@@ -1,256 +0,0 @@
import { whenever } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCachedRequest } from '@/composables/useCachedRequest'
import { useManagerQueue } from '@/composables/useManagerQueue'
import { useServerLogs } from '@/composables/useServerLogs'
import { useComfyManagerService } from '@/services/comfyManagerService'
import { useDialogService } from '@/services/dialogService'
import {
InstallPackParams,
InstalledPacksResponse,
ManagerPackInfo,
ManagerPackInstalled,
TaskLog,
UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
/**
* Store for state of installed node packs
*/
export const useComfyManagerStore = defineStore('comfyManager', () => {
const { t } = useI18n()
const managerService = useComfyManagerService()
const { showManagerProgressDialog } = useDialogService()
const installedPacks = ref<InstalledPacksResponse>({})
const enabledPacksIds = ref<Set<string>>(new Set())
const disabledPacksIds = ref<Set<string>>(new Set())
const installedPacksIds = ref<Set<string>>(new Set())
const isStale = ref(true)
const taskLogs = ref<TaskLog[]>([])
const { statusMessage, allTasksDone, enqueueTask, uncompletedCount } =
useManagerQueue()
const setStale = () => {
isStale.value = true
}
const getPackId = (pack: ManagerPackInstalled) => pack.cnr_id || pack.aux_id
const isInstalledPackId = (packName: string | undefined): boolean =>
!!packName && installedPacksIds.value.has(packName)
const isEnabledPackId = (packName: string | undefined): boolean =>
!!packName &&
isInstalledPackId(packName) &&
enabledPacksIds.value.has(packName)
const packsToIdSet = (packs: ManagerPackInstalled[]) =>
packs.reduce((acc, pack) => {
const id = pack.cnr_id || pack.aux_id
if (id) acc.add(id)
return acc
}, new Set<string>())
/**
* A pack is disabled if there is a disabled entry and no corresponding
* enabled entry. If `packname@1.0.2` is disabled, but `packname@1.0.3` is
* enabled, then `packname` is considered enabled.
*
* @example
* installedPacks = {
* "packname@1_0_2": { enabled: false, cnr_id: "packname" },
* "packname": { enabled: true, cnr_id: "packname" }
* }
* isDisabled("packname") // false
*
* installedPacks = {
* "packname@1_0_2": { enabled: false, cnr_id: "packname" },
* }
* isDisabled("packname") // true
*/
const updateDisabledIds = (packs: ManagerPackInstalled[]) => {
// Use temporary variables to avoid triggering reactivity
const enabledIds = new Set<string>()
const disabledIds = new Set<string>()
for (const pack of packs) {
const id = getPackId(pack)
if (!id) continue
const { enabled } = pack
if (enabled === true) enabledIds.add(id)
else if (enabled === false) disabledIds.add(id)
// If pack in both (has a disabled and enabled version), remove from disabled
const inBothSets = enabledIds.has(id) && disabledIds.has(id)
if (inBothSets) disabledIds.delete(id)
}
enabledPacksIds.value = enabledIds
disabledPacksIds.value = disabledIds
}
const updateInstalledIds = (packs: ManagerPackInstalled[]) => {
installedPacksIds.value = packsToIdSet(packs)
}
const onPacksChanged = () => {
const packs = Object.values(installedPacks.value)
updateDisabledIds(packs)
updateInstalledIds(packs)
}
watch(installedPacks, onPacksChanged, { deep: true })
const refreshInstalledList = async () => {
const packs = await managerService.listInstalledPacks()
if (packs) installedPacks.value = packs
isStale.value = false
}
whenever(isStale, refreshInstalledList, { immediate: true })
whenever(uncompletedCount, () => showManagerProgressDialog())
const withLogs = (task: () => Promise<null>, taskName: string) => {
const { startListening, stopListening, logs } = useServerLogs()
const loggedTask = async () => {
taskLogs.value.push({ taskName, logs: logs.value })
await startListening()
return task()
}
const onComplete = async () => {
await stopListening()
setStale()
}
return { task: loggedTask, onComplete }
}
const installPack = useCachedRequest<InstallPackParams, void>(
async (params: InstallPackParams, signal?: AbortSignal) => {
if (!params.id) return
let actionDescription = t('g.installing')
if (installedPacksIds.value.has(params.id)) {
const installedPack = installedPacks.value[params.id]
if (installedPack && installedPack.ver !== params.selected_version) {
actionDescription = t('manager.changingVersion', {
from: installedPack.ver,
to: params.selected_version
})
} else {
actionDescription = t('g.enabling')
}
}
const task = () => managerService.installPack(params, signal)
enqueueTask(withLogs(task, `${actionDescription} ${params.id}`))
},
{ maxSize: 1 }
)
const uninstallPack = (params: ManagerPackInfo, signal?: AbortSignal) => {
installPack.clear()
installPack.cancel()
const task = () => managerService.uninstallPack(params, signal)
enqueueTask(withLogs(task, t('manager.uninstalling', { id: params.id })))
}
const updatePack = useCachedRequest<ManagerPackInfo, void>(
async (params: ManagerPackInfo, signal?: AbortSignal) => {
updateAllPacks.cancel()
const task = () => managerService.updatePack(params, signal)
enqueueTask(withLogs(task, t('g.updating', { id: params.id })))
},
{ maxSize: 1 }
)
const updateAllPacks = useCachedRequest<UpdateAllPacksParams, void>(
async (params: UpdateAllPacksParams, signal?: AbortSignal) => {
const task = () => managerService.updateAllPacks(params, signal)
enqueueTask(withLogs(task, t('manager.updatingAllPacks')))
},
{ maxSize: 1 }
)
const disablePack = (params: ManagerPackInfo, signal?: AbortSignal) => {
const task = () => managerService.disablePack(params, signal)
enqueueTask(withLogs(task, t('g.disabling', { id: params.id })))
}
const getInstalledPackVersion = (packId: string) => {
const pack = installedPacks.value[packId]
return pack?.ver
}
const clearLogs = () => {
taskLogs.value = []
}
return {
// Manager state
isLoading: managerService.isLoading,
error: managerService.error,
statusMessage,
allTasksDone,
uncompletedCount,
taskLogs,
clearLogs,
setStale,
// Installed packs state
installedPacks,
installedPacksIds,
isPackInstalled: isInstalledPackId,
isPackEnabled: isEnabledPackId,
getInstalledPackVersion,
refreshInstalledList,
// Pack actions
installPack,
uninstallPack,
updatePack,
updateAllPacks,
disablePack,
enablePack: installPack // Enable is done via install endpoint with a disabled pack
}
})
/**
* Store for state of the manager progress dialog content.
* The dialog itself is managed by the dialog store. This store is used to
* manage the visibility of the dialog's content, header, footer.
*/
export const useManagerProgressDialogStore = defineStore(
'managerProgressDialog',
() => {
const isExpanded = ref(false)
const toggle = () => {
isExpanded.value = !isExpanded.value
}
const collapse = () => {
isExpanded.value = false
}
const expand = () => {
isExpanded.value = true
}
return {
isExpanded,
toggle,
collapse,
expand
}
}
)

View File

@@ -43,6 +43,7 @@ interface DialogInstance {
component: Component
contentProps: Record<string, any>
footerComponent?: Component
footerProps?: Record<string, any>
dialogComponentProps: DialogComponentProps
priority: number
}
@@ -54,6 +55,7 @@ export interface ShowDialogOptions {
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
/**
* Optional priority for dialog stacking.
@@ -127,6 +129,7 @@ export const useDialogStore = defineStore('dialog', () => {
footerComponent?: Component
component: Component
props?: Record<string, any>
footerProps?: Record<string, any>
dialogComponentProps?: DialogComponentProps
priority?: number
}) {
@@ -146,6 +149,7 @@ export const useDialogStore = defineStore('dialog', () => {
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
footerProps: { ...options.footerProps },
priority: options.priority ?? 1,
dialogComponentProps: {
maximizable: false,

View File

@@ -57,6 +57,13 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
if (state) state.active = false
}
const setWidget = (widget: BaseDOMWidget) => {
const state = widgetStates.value.get(widget.id)
if (!state) return
state.active = true
state.widget = widget
}
const clear = () => {
widgetStates.value.clear()
}
@@ -69,6 +76,7 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
unregisterWidget,
activateWidget,
deactivateWidget,
setWidget,
clear
}
})

View File

@@ -5,6 +5,14 @@ import type ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
import { useNodeProgressText } from '@/composables/node/useNodeProgressText'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyNode,
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type {
DisplayComponentWsMessage,
ExecutedWsMessage,
@@ -17,21 +25,13 @@ import type {
ProgressTextWsMessage,
ProgressWsMessage
} from '@/schemas/apiSchema'
import type {
ComfyNode,
ComfyWorkflowJSON,
NodeId
} from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { useCanvasStore } from './graphStore'
import { ComfyWorkflow, useWorkflowStore } from './workflowStore'
export interface QueuedPrompt {
interface QueuedPrompt {
/**
* The nodes that are queued to be executed. The key is the node id and the
* value is a boolean indicating if the node has been executed.
@@ -43,6 +43,57 @@ export interface QueuedPrompt {
workflow?: ComfyWorkflow
}
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
/**
* Recursively get the subgraph objects for the given subgraph instance IDs
* @param currentGraph The current graph
* @param subgraphNodeIds The instance IDs
* @param subgraphs The subgraphs
* @returns The subgraphs that correspond to each of the instance IDs.
*/
function getSubgraphsFromInstanceIds(
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
function executionIdToNodeLocatorId(nodeId: string | number): NodeLocatorId {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
export const useExecutionStore = defineStore('execution', () => {
const workflowStore = useWorkflowStore()
const canvasStore = useCanvasStore()
@@ -55,29 +106,6 @@ export const useExecutionStore = defineStore('execution', () => {
// This is the progress of all nodes in the currently executing workflow
const nodeProgressStates = ref<Record<string, NodeProgressState>>({})
/**
* Convert execution context node IDs to NodeLocatorIds
* @param nodeId The node ID from execution context (could be execution ID)
* @returns The NodeLocatorId
*/
const executionIdToNodeLocatorId = (
nodeId: string | number
): NodeLocatorId => {
const nodeIdStr = String(nodeId)
if (!nodeIdStr.includes(':')) {
// It's a top-level node ID
return nodeIdStr
}
// It's an execution node ID
const parts = nodeIdStr.split(':')
const localNodeId = parts[parts.length - 1]
const subgraphs = getSubgraphsFromInstanceIds(app.graph, parts)
const nodeLocatorId = createNodeLocatorId(subgraphs.at(-1)!.id, localNodeId)
return nodeLocatorId
}
const mergeExecutionProgressStates = (
currentState: NodeProgressState | undefined,
newState: NodeProgressState
@@ -132,16 +160,20 @@ export const useExecutionStore = defineStore('execution', () => {
// Easily access all currently executing node IDs
const executingNodeIds = computed<NodeId[]>(() => {
return Object.entries(nodeProgressStates)
return Object.entries(nodeProgressStates.value)
.filter(([_, state]) => state.state === 'running')
.map(([nodeId, _]) => nodeId)
})
// @deprecated For backward compatibility - stores the primary executing node ID
const executingNodeId = computed<NodeId | null>(() => {
return executingNodeIds.value.length > 0 ? executingNodeIds.value[0] : null
return executingNodeIds.value[0] ?? null
})
const uniqueExecutingNodeIdStrings = computed(
() => new Set(executingNodeIds.value.map(String))
)
// For backward compatibility - returns the primary executing node
const executingNode = computed<ComfyNode | null>(() => {
if (!executingNodeId.value) return null
@@ -159,36 +191,6 @@ export const useExecutionStore = defineStore('execution', () => {
)
})
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
/**
* Recursively get the subgraph objects for the given subgraph instance IDs
* @param currentGraph The current graph
* @param subgraphNodeIds The instance IDs
* @param subgraphs The subgraphs
* @returns The subgraphs that correspond to each of the instance IDs.
*/
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
// Last segment is the node portion; nothing to do.
if (subgraphNodeIds.length === 1) return subgraphs
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (!subgraph) throw new Error(`Subgraph not found: ${currentPart}`)
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
// This is the progress of the currently executing node (for backward compatibility)
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
const executingNodeProgress = computed(() =>
@@ -220,6 +222,19 @@ export const useExecutionStore = defineStore('execution', () => {
return total > 0 ? done / total : 0
})
const lastExecutionErrorNodeLocatorId = computed(() => {
const err = lastExecutionError.value
if (!err) return null
return executionIdToNodeLocatorId(String(err.node_id))
})
const lastExecutionErrorNodeId = computed(() => {
const locator = lastExecutionErrorNodeLocatorId.value
if (!locator) return null
const localId = workflowStore.nodeLocatorIdToNodeId(locator)
return localId != null ? String(localId) : null
})
function bindExecutionEvents() {
api.addEventListener('execution_start', handleExecutionStart)
api.addEventListener('execution_cached', handleExecutionCached)
@@ -410,62 +425,25 @@ export const useExecutionStore = defineStore('execution', () => {
return {
isIdle,
clientId,
/**
* The id of the prompt that is currently being executed
*/
activePromptId,
/**
* The queued prompts
*/
queuedPrompts,
/**
* The node errors from the previous execution.
*/
lastNodeErrors,
/**
* The error from the previous execution.
*/
lastExecutionError,
/**
* The id of the node that is currently being executed (backward compatibility)
*/
lastExecutionErrorNodeId,
executingNodeId,
/**
* The list of all nodes that are currently executing
*/
executingNodeIds,
/**
* The prompt that is currently being executed
*/
activePrompt,
/**
* The total number of nodes to execute
*/
totalNodesToExecute,
/**
* The number of nodes that have been executed
*/
nodesExecuted,
/**
* The progress of the execution
*/
executionProgress,
/**
* The node that is currently being executed (backward compatibility)
*/
executingNode,
/**
* The progress of the executing node (backward compatibility)
*/
executingNodeProgress,
/**
* All node progress states from progress_state events
*/
nodeProgressStates,
nodeLocationProgressStates,
bindExecutionEvents,
unbindExecutionEvents,
storePrompt,
uniqueExecutingNodeIdStrings,
// Raw executing progress data for backward compatibility in ComfyApp.
_executingNodeProgress,
// NodeLocatorId conversion helpers

View File

@@ -6,9 +6,9 @@ import type { ComfyExtension } from '@/types/comfy'
/**
* These extensions are always active, even if they are disabled in the setting.
*/
export const ALWAYS_ENABLED_EXTENSIONS: readonly string[] = []
const ALWAYS_ENABLED_EXTENSIONS: readonly string[] = []
export const ALWAYS_DISABLED_EXTENSIONS: readonly string[] = [
const ALWAYS_DISABLED_EXTENSIONS: readonly string[] = [
// pysssss.Locking is replaced by pin/unpin in ComfyUI core.
// https://github.com/Comfy-Org/litegraph.js/pull/117
'pysssss.Locking',

View File

@@ -8,6 +8,7 @@ import {
type UserCredential,
browserLocalPersistence,
createUserWithEmailAndPassword,
deleteUser,
onAuthStateChanged,
sendEmailVerification,
sendPasswordResetEmail,
@@ -25,8 +26,8 @@ import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { t } from '@/i18n'
import { useDialogService } from '@/services/dialogService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { type AuthHeader } from '@/types/authTypes'
import { operations } from '@/types/comfyRegistryTypes'
import type { AuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
type CreditPurchaseResponse =
operations['InitiateCreditPurchase']['responses']['201']['content']['application/json']
@@ -41,7 +42,7 @@ type AccessBillingPortalResponse =
type AccessBillingPortalReqBody =
operations['AccessBillingPortal']['requestBody']
export class FirebaseAuthStoreError extends Error {
class FirebaseAuthStoreError extends Error {
constructor(message: string) {
super(message)
this.name = 'FirebaseAuthStoreError'
@@ -301,6 +302,14 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
await sendEmailVerification(currentUser.value)
}
/** Delete the current user account */
const _deleteAccount = async (): Promise<void> => {
if (!currentUser.value) {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
await deleteUser(currentUser.value)
}
const addCredits = async (
requestBodyContent: CreditPurchasePayload
): Promise<CreditPurchaseResponse> => {
@@ -401,6 +410,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
sendPasswordReset,
updatePassword: _updatePassword,
getAuthHeader,
verifyEmail
verifyEmail,
deleteAccount: _deleteAccount
}
})

View File

@@ -1,110 +0,0 @@
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import type { Point, Positionable } from '@/lib/litegraph/src/interfaces'
import type {
LGraphCanvas,
LGraphGroup,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
export const useTitleEditorStore = defineStore('titleEditor', () => {
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
return {
titleEditorTarget
}
})
export const useCanvasStore = defineStore('canvas', () => {
/**
* The LGraphCanvas instance.
*
* The root LGraphCanvas object is a shallow ref.
*/
const canvas = shallowRef<LGraphCanvas | null>(null)
/**
* The selected items on the canvas. All stored items are raw.
*/
const selectedItems = ref<Raw<Positionable>[]>([])
const updateSelectedItems = () => {
const items = Array.from(canvas.value?.selectedItems ?? [])
selectedItems.value = items.map((item) => markRaw(item))
}
// Reactive scale percentage that syncs with app.canvas.ds.scale
const appScalePercentage = ref(100)
// Set up scale synchronization when canvas is available
let originalOnChanged: ((scale: number, offset: Point) => void) | undefined =
undefined
const initScaleSync = () => {
if (app.canvas?.ds) {
// Initial sync
originalOnChanged = app.canvas.ds.onChanged
appScalePercentage.value = Math.round(app.canvas.ds.scale * 100)
// Set up continuous sync
app.canvas.ds.onChanged = () => {
if (app.canvas?.ds?.scale) {
appScalePercentage.value = Math.round(app.canvas.ds.scale * 100)
}
// Call original handler if exists
originalOnChanged?.(app.canvas.ds.scale, app.canvas.ds.offset)
}
}
}
const cleanupScaleSync = () => {
if (app.canvas?.ds) {
app.canvas.ds.onChanged = originalOnChanged
originalOnChanged = undefined
}
}
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
const getCanvas = () => {
if (!canvas.value) throw new Error('getCanvas: canvas is null')
return canvas.value
}
/**
* Sets the canvas zoom level from a percentage value
* @param percentage - Zoom percentage value (1-1000, where 1000 = 1000% zoom)
*/
const setAppZoomFromPercentage = (percentage: number) => {
if (!app.canvas?.ds || percentage <= 0) return
// Convert percentage to scale (1000% = 10.0 scale)
const newScale = percentage / 100
const ds = app.canvas.ds
ds.changeScale(
newScale,
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
)
app.canvas.setDirty(true, true)
// Update reactive value immediately for UI consistency
appScalePercentage.value = Math.round(newScale * 100)
}
return {
canvas,
selectedItems,
nodeSelected,
groupSelected,
rerouteSelected,
appScalePercentage,
updateSelectedItems,
getCanvas,
setAppZoomFromPercentage,
initScaleSync,
cleanupScaleSync
}
})

View File

@@ -1,7 +1,11 @@
import { useTimeoutFn } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import {
import type { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type {
ExecutedWsMessage,
ResultItem,
ResultItemType
@@ -9,11 +13,12 @@ import {
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import type { NodeLocatorId } from '@/types/nodeIdentification'
import { parseFilePath } from '@/utils/formatUtil'
import { isVideoNode } from '@/utils/litegraphUtil'
const PREVIEW_REVOKE_DELAY_MS = 400
const createOutputs = (
filenames: string[],
type: ResultItemType,
@@ -35,6 +40,25 @@ interface SetOutputOptions {
export const useNodeOutputStore = defineStore('nodeOutput', () => {
const { nodeIdToNodeLocatorId } = useWorkflowStore()
const { executionIdToNodeLocatorId } = useExecutionStore()
const scheduledRevoke: Record<NodeLocatorId, { stop: () => void }> = {}
function scheduleRevoke(locator: NodeLocatorId, cb: () => void) {
scheduledRevoke[locator]?.stop()
const { stop } = useTimeoutFn(() => {
delete scheduledRevoke[locator]
cb()
}, PREVIEW_REVOKE_DELAY_MS)
scheduledRevoke[locator] = { stop }
}
const nodeOutputs = ref<Record<string, ExecutedWsMessage['output']>>({})
// Reactive state for node preview images - mirrors app.nodePreviewImages
const nodePreviewImages = ref<Record<string, string[]>>(
app.nodePreviewImages || {}
)
function getNodeOutputs(
node: LGraphNode
@@ -127,6 +151,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
app.nodeOutputs[nodeLocatorId] = outputs
nodeOutputs.value[nodeLocatorId] = outputs
}
function setNodeOutputs(
@@ -139,17 +164,22 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
if (!filenames || !node) return
const locatorId =
node.graph instanceof Subgraph
? nodeIdToNodeLocatorId(node.id, node.graph ?? undefined)
: `${node.id}`
if (!locatorId) return
if (typeof filenames === 'string') {
setNodeOutputsByNodeId(
node.id,
setOutputsByLocatorId(
locatorId,
createOutputs([filenames], folder, isAnimated)
)
} else if (!Array.isArray(filenames)) {
setNodeOutputsByNodeId(node.id, filenames)
setOutputsByLocatorId(locatorId, filenames)
} else {
const resultItems = createOutputs(filenames, folder, isAnimated)
if (!resultItems?.images?.length) return
setNodeOutputsByNodeId(node.id, resultItems)
setOutputsByLocatorId(locatorId, resultItems)
}
}
@@ -173,26 +203,6 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
setOutputsByLocatorId(nodeLocatorId, outputs, options)
}
/**
* Set node outputs by node ID.
* Uses the current graph context to create the appropriate NodeLocatorId.
*
* @param nodeId - The node ID
* @param outputs - The outputs to store
* @param options - Options for setting outputs
* @param options.merge - If true, merge with existing outputs (arrays are concatenated)
*/
function setNodeOutputsByNodeId(
nodeId: string | number,
outputs: ExecutedWsMessage['output'] | ResultItem,
options: SetOutputOptions = {}
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
if (!nodeLocatorId) return
setOutputsByLocatorId(nodeLocatorId, outputs, options)
}
/**
* Set node preview images by execution ID (hierarchical ID from backend).
* Converts the execution ID to a NodeLocatorId before storing.
@@ -206,8 +216,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -222,7 +236,12 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
previewImages: string[]
) {
const nodeLocatorId = nodeIdToNodeLocatorId(nodeId)
if (scheduledRevoke[nodeLocatorId]) {
scheduledRevoke[nodeLocatorId].stop()
delete scheduledRevoke[nodeLocatorId]
}
app.nodePreviewImages[nodeLocatorId] = previewImages
nodePreviewImages.value[nodeLocatorId] = previewImages
}
/**
@@ -234,8 +253,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
function revokePreviewsByExecutionId(executionId: string) {
const nodeLocatorId = executionIdToNodeLocatorId(executionId)
if (!nodeLocatorId) return
revokePreviewsByLocatorId(nodeLocatorId)
scheduleRevoke(nodeLocatorId, () =>
revokePreviewsByLocatorId(nodeLocatorId)
)
}
/**
@@ -253,6 +273,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}
/**
@@ -269,6 +290,7 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
}
app.nodePreviewImages = {}
nodePreviewImages.value = {}
}
/**
@@ -285,18 +307,66 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
}
}
/**
* Remove node outputs for a specific node
* Clears both outputs and preview images
*/
function removeNodeOutputs(nodeId: number | string) {
const nodeLocatorId = nodeIdToNodeLocatorId(Number(nodeId))
if (!nodeLocatorId) return false
// Clear from app.nodeOutputs
const hadOutputs = !!app.nodeOutputs[nodeLocatorId]
delete app.nodeOutputs[nodeLocatorId]
// Clear from reactive state
delete nodeOutputs.value[nodeLocatorId]
// Clear preview images
if (app.nodePreviewImages[nodeLocatorId]) {
delete app.nodePreviewImages[nodeLocatorId]
delete nodePreviewImages.value[nodeLocatorId]
}
return hadOutputs
}
function restoreOutputs(
outputs: Record<string, ExecutedWsMessage['output']>
) {
app.nodeOutputs = outputs
nodeOutputs.value = outputs
}
function resetAllOutputsAndPreviews() {
app.nodeOutputs = {}
nodeOutputs.value = {}
revokeAllPreviews()
}
return {
// Getters
getNodeOutputs,
getNodeImageUrls,
getNodePreviews,
getPreviewParam,
// Setters
setNodeOutputs,
setNodeOutputsByExecutionId,
setNodeOutputsByNodeId,
setNodePreviewsByExecutionId,
setNodePreviewsByNodeId,
// Cleanup
revokePreviewsByExecutionId,
revokeAllPreviews,
revokeSubgraphPreviews,
getPreviewParam
removeNodeOutputs,
restoreOutputs,
resetAllOutputsAndPreviews,
// State
nodeOutputs,
nodePreviewImages
}
})

View File

@@ -1,9 +1,10 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { Ref, computed, ref, toRaw } from 'vue'
import type { Ref } from 'vue'
import { computed, ref, toRaw } from 'vue'
import { RESERVED_BY_TEXT_INPUT } from '@/constants/reservedKeyCombos'
import { KeyCombo, Keybinding } from '@/schemas/keyBindingSchema'
import type { KeyCombo, Keybinding } from '@/schemas/keyBindingSchema'
export class KeybindingImpl implements Keybinding {
commandId: string

View File

@@ -3,7 +3,7 @@ import type { MenuItem } from 'primevue/menuitem'
import { ref } from 'vue'
import { CORE_MENU_COMMANDS } from '@/constants/coreMenuCommands'
import { ComfyExtension } from '@/types/comfy'
import type { ComfyExtension } from '@/types/comfy'
import { useCommandStore } from './commandStore'

View File

@@ -1,6 +1,9 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { ModelFile } from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
/** (Internal helper) finds a value in a metadata object from any of a list of keys. */
@@ -153,7 +156,10 @@ export class ModelFolder {
models: Record<string, ComfyModelDef> = {}
state: ResourceState = ResourceState.Uninitialized
constructor(public directory: string) {}
constructor(
public directory: string,
private getModelsFunc: (folder: string) => Promise<ModelFile[]>
) {}
get key(): string {
return this.directory + '/'
@@ -167,7 +173,7 @@ export class ModelFolder {
return this
}
this.state = ResourceState.Loading
const models = await api.getModels(this.directory)
const models = await this.getModelsFunc(this.directory)
for (const model of models) {
this.models[`${model.pathIndex}/${model.name}`] = new ComfyModelDef(
model.name,
@@ -182,6 +188,7 @@ export class ModelFolder {
/** Model store handler, wraps individual per-folder model stores */
export const useModelStore = defineStore('models', () => {
const settingStore = useSettingStore()
const modelFolderNames = ref<string[]>([])
const modelFolderByName = ref<Record<string, ModelFolder>>({})
const modelFolders = computed<ModelFolder[]>(() =>
@@ -197,11 +204,22 @@ export const useModelStore = defineStore('models', () => {
* Loads the model folders from the server
*/
async function loadModelFolders() {
const resData = await api.getModelFolders()
const useAssetAPI: boolean = settingStore.get('Comfy.Assets.UseAssetAPI')
const resData = useAssetAPI
? await assetService.getAssetModelFolders()
: await api.getModelFolders()
modelFolderNames.value = resData.map((folder) => folder.name)
modelFolderByName.value = {}
for (const folderName of modelFolderNames.value) {
modelFolderByName.value[folderName] = new ModelFolder(folderName)
const getModelsFunc = useAssetAPI
? (folder: string) => assetService.getAssetModels(folder)
: (folder: string) => api.getModels(folder)
modelFolderByName.value[folderName] = new ModelFolder(
folderName,
getModelsFunc
)
}
}

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
/** Helper class that defines how to construct a node from a model. */
export class ModelNodeProvider {
@@ -22,6 +23,53 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
const modelToNodeMap = ref<Record<string, ModelNodeProvider[]>>({})
const nodeDefStore = useNodeDefStore()
const haveDefaultsLoaded = ref(false)
/** Internal computed for reactive caching of registered node types */
const registeredNodeTypes = computed(() => {
return new Set(
Object.values(modelToNodeMap.value)
.flat()
.map((provider) => provider.nodeDef.name)
)
})
/** Internal computed for efficient reverse lookup: nodeType -> category */
const nodeTypeToCategory = computed(() => {
const lookup: Record<string, string> = {}
for (const [category, providers] of Object.entries(modelToNodeMap.value)) {
for (const provider of providers) {
// Only store the first category for each node type (matches current assetService behavior)
if (!lookup[provider.nodeDef.name]) {
lookup[provider.nodeDef.name] = category
}
}
}
return lookup
})
/** Get set of all registered node types for efficient lookup */
function getRegisteredNodeTypes(): Set<string> {
registerDefaults()
return registeredNodeTypes.value
}
/**
* Get the category for a given node type.
* Performs efficient O(1) lookup using cached reverse map.
* @param nodeType The node type name to find the category for
* @returns The category name, or undefined if not found
*/
function getCategoryForNodeType(nodeType: string): string | undefined {
registerDefaults()
// Handle invalid input gracefully
if (!nodeType || typeof nodeType !== 'string') {
return undefined
}
return nodeTypeToCategory.value[nodeType]
}
/**
* Get the node provider for the given model type name.
* @param modelType The name of the model type to get the node provider for.
@@ -83,10 +131,16 @@ export const useModelToNodeStore = defineStore('modelToNode', () => {
quickRegister('loras', 'LoraLoaderModelOnly', 'lora_name')
quickRegister('vae', 'VAELoader', 'vae_name')
quickRegister('controlnet', 'ControlNetLoader', 'control_net_name')
quickRegister('unet', 'UNETLoader', 'unet_name')
quickRegister('upscale_models', 'UpscaleModelLoader', 'model_name')
quickRegister('style_models', 'StyleModelLoader', 'style_model')
quickRegister('gligen', 'GLIGENLoader', 'gligen_name')
}
return {
modelToNodeMap,
getRegisteredNodeTypes,
getCategoryForNodeType,
getNodeProvider,
getAllNodeProviders,
registerNodeProvider,

View File

@@ -2,15 +2,16 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { BookmarkCustomization } from '@/schemas/apiSchema'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { useNodeDefStore } from './nodeDefStore'
import { ComfyNodeDefImpl, createDummyFolderNodeDef } from './nodeDefStore'
import type { ComfyNodeDefImpl } from './nodeDefStore'
import { createDummyFolderNodeDef } from './nodeDefStore'
import { buildNodeDefTree } from './nodeDefStore'
import { useSettingStore } from './settingStore'
export const BOOKMARK_SETTING_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
const BOOKMARK_SETTING_ID = 'Comfy.NodeLibrary.Bookmarks.V2'
export const useNodeBookmarkStore = defineStore('nodeBookmark', () => {
const settingStore = useSettingStore()

View File

@@ -16,6 +16,7 @@ import type {
ComfyOutputTypesSpec as ComfyOutputSpecV1
} from '@/schemas/nodeDefSchema'
import { NodeSearchService } from '@/services/nodeSearchService'
import { useSubgraphStore } from '@/stores/subgraphStore'
import {
type NodeSource,
NodeSourceType,
@@ -223,7 +224,7 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
}
}
export interface BuildNodeDefTreeOptions {
interface BuildNodeDefTreeOptions {
/**
* Custom function to extract the tree path from a node definition.
* If not provided, uses the default path based on nodeDef.nodePath.
@@ -292,7 +293,13 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
const showExperimental = ref(false)
const nodeDefFilters = ref<NodeDefFilter[]>([])
const nodeDefs = computed(() => Object.values(nodeDefsByName.value))
const nodeDefs = computed(() => {
const subgraphStore = useSubgraphStore()
return [
...Object.values(nodeDefsByName.value),
...subgraphStore.subgraphBlueprints
]
})
const nodeDataTypes = computed(() => {
const types = new Set<string>()
for (const nodeDef of nodeDefs.value) {
@@ -345,6 +352,16 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
return nodeDef
}
function getInputSpecForWidget(
node: LGraphNode,
widgetName: string
): InputSpecV2 | undefined {
const nodeDef = fromLGraphNode(node)
if (!nodeDef) return undefined
return nodeDef.inputs[widgetName]
}
/**
* Registers a node definition filter.
* @param filter - The filter to register
@@ -383,7 +400,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
})
// Subgraph nodes filter
// @todo Remove this filter when subgraph v2 is released
// Filter out litegraph typed subgraphs, saved blueprints are added in separately
registerNodeDefFilter({
id: 'core.subgraph',
name: 'Hide Subgraph Nodes',
@@ -417,6 +434,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
fromLGraphNode,
getInputSpecForWidget,
registerNodeDefFilter,
unregisterNodeDefFilter
}

View File

@@ -2,6 +2,10 @@ import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, toRaw } from 'vue'
import type {
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import type {
ResultItem,
StatusWsMessageStatus,
@@ -11,14 +15,13 @@ import type {
TaskStatus,
TaskType
} from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON, NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import type { ComfyApp } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
// Task type used in the API.
export type APITaskType = 'queue' | 'history'
type APITaskType = 'queue' | 'history'
export enum TaskItemDisplayStatus {
Running = 'Running',

View File

@@ -1,287 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { type ReleaseNote, useReleaseService } from '@/services/releaseService'
import { useSettingStore } from '@/stores/settingStore'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { isElectron } from '@/utils/envUtil'
import { compareVersions, stringToLocale } from '@/utils/formatUtil'
// Store for managing release notes
export const useReleaseStore = defineStore('release', () => {
// State
const releases = ref<ReleaseNote[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
// Services
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
const settingStore = useSettingStore()
// Current ComfyUI version
const currentComfyUIVersion = computed(
() => systemStatsStore?.systemStats?.system?.comfyui_version ?? ''
)
// Release data from settings
const locale = computed(() => settingStore.get('Comfy.Locale'))
const releaseVersion = computed(() =>
settingStore.get('Comfy.Release.Version')
)
const releaseStatus = computed(() => settingStore.get('Comfy.Release.Status'))
const releaseTimestamp = computed(() =>
settingStore.get('Comfy.Release.Timestamp')
)
const showVersionUpdates = computed(() =>
settingStore.get('Comfy.Notification.ShowVersionUpdates')
)
// Most recent release
const recentRelease = computed(() => {
return releases.value[0] ?? null
})
// 3 most recent releases
const recentReleases = computed(() => {
return releases.value.slice(0, 3)
})
// Helper constants
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 // 3 days
// New version available?
const isNewVersionAvailable = computed(
() =>
!!recentRelease.value &&
compareVersions(
recentRelease.value.version,
currentComfyUIVersion.value
) > 0
)
const isLatestVersion = computed(
() =>
!!recentRelease.value &&
!compareVersions(recentRelease.value.version, currentComfyUIVersion.value)
)
const hasMediumOrHighAttention = computed(() =>
recentReleases.value
.slice(0, -1)
.some(
(release) =>
release.attention === 'medium' || release.attention === 'high'
)
)
// Show toast if needed
const shouldShowToast = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isNewVersionAvailable.value) {
return false
}
// Skip if low attention
if (!hasMediumOrHighAttention.value) {
return false
}
// Skip if user already skipped or changelog seen
if (
releaseVersion.value === recentRelease.value?.version &&
['skipped', 'changelog seen'].includes(releaseStatus.value)
) {
return false
}
return true
})
// Show red-dot indicator
const shouldShowRedDot = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
// Already latest → no dot
if (!isNewVersionAvailable.value) {
return false
}
const { version } = recentRelease.value
// Changelog seen → clear dot
if (
releaseVersion.value === version &&
releaseStatus.value === 'changelog seen'
) {
return false
}
// Attention medium / high (levels 2 & 3)
if (hasMediumOrHighAttention.value) {
// Persist until changelog is opened
return true
}
// Attention low (level 1) and skipped → keep up to 3 d
if (
releaseVersion.value === version &&
releaseStatus.value === 'skipped' &&
releaseTimestamp.value &&
Date.now() - releaseTimestamp.value >= THREE_DAYS_MS
) {
return false
}
// Not skipped → show
return true
})
// Show "What's New" popup
const shouldShowPopup = computed(() => {
// Only show on desktop version
if (!isElectron()) {
return false
}
// Skip if notifications are disabled
if (!showVersionUpdates.value) {
return false
}
if (!isLatestVersion.value) {
return false
}
// Hide if already seen
if (
releaseVersion.value === recentRelease.value.version &&
releaseStatus.value === "what's new seen"
) {
return false
}
return true
})
// Action handlers for user interactions
async function handleSkipRelease(version: string): Promise<void> {
if (
version !== recentRelease.value?.version ||
releaseStatus.value === 'changelog seen'
) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', 'skipped')
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
async function handleShowChangelog(version: string): Promise<void> {
if (version !== recentRelease.value?.version) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', 'changelog seen')
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
async function handleWhatsNewSeen(version: string): Promise<void> {
if (version !== recentRelease.value?.version) {
return
}
await settingStore.set('Comfy.Release.Version', version)
await settingStore.set('Comfy.Release.Status', "what's new seen")
await settingStore.set('Comfy.Release.Timestamp', Date.now())
}
// Fetch releases from API
async function fetchReleases(): Promise<void> {
if (isLoading.value) {
return
}
// Skip fetching if notifications are disabled
if (!showVersionUpdates.value) {
return
}
// Skip fetching if API nodes are disabled via argv
if (
systemStatsStore.systemStats?.system?.argv?.includes(
'--disable-api-nodes'
)
) {
return
}
isLoading.value = true
error.value = null
try {
// Ensure system stats are loaded
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
const fetchedReleases = await releaseService.getReleases({
project: 'comfyui',
current_version: currentComfyUIVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
if (fetchedReleases !== null) {
releases.value = fetchedReleases
} else if (releaseService.error.value) {
error.value = releaseService.error.value
}
} catch (err) {
error.value =
err instanceof Error ? err.message : 'Unknown error occurred'
} finally {
isLoading.value = false
}
}
// Initialize store
async function initialize(): Promise<void> {
await fetchReleases()
}
return {
releases,
isLoading,
error,
recentRelease,
recentReleases,
shouldShowToast,
shouldShowRedDot,
shouldShowPopup,
shouldShowUpdateButton: isNewVersionAvailable,
handleSkipRelease,
handleShowChangelog,
handleWhatsNewSeen,
fetchReleases,
initialize
}
})

View File

@@ -1,9 +1,9 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import type { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
export type ServerConfigWithValue<T> = ServerConfig<T> & {
type ServerConfigWithValue<T> = ServerConfig<T> & {
/**
* Current value.
*/

View File

@@ -1,203 +0,0 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Settings } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { compareVersions, isSemVer } from '@/utils/formatUtil'
export const getSettingInfo = (setting: SettingParams) => {
const parts = setting.category || setting.id.split('.')
return {
category: parts[0] ?? 'Other',
subCategory: parts[1] ?? 'Other'
}
}
export interface SettingTreeNode extends TreeNode {
data?: SettingParams
}
function tryMigrateDeprecatedValue(
setting: SettingParams | undefined,
value: unknown
) {
return setting?.migrateDeprecatedValue?.(value) ?? value
}
function onChange(
setting: SettingParams | undefined,
newValue: unknown,
oldValue: unknown
) {
if (setting?.onChange) {
setting.onChange(newValue, oldValue)
}
// Backward compatibility with old settings dialog.
// Some extensions still listens event emitted by the old settings dialog.
// @ts-expect-error 'setting' is possibly 'undefined'.ts(18048)
app.ui.settings.dispatchChange(setting.id, newValue, oldValue)
}
export const useSettingStore = defineStore('setting', () => {
const settingValues = ref<Record<string, any>>({})
const settingsById = ref<Record<string, SettingParams>>({})
/**
* Check if a setting's value exists, i.e. if the user has set it manually.
* @param key - The key of the setting to check.
* @returns Whether the setting exists.
*/
function exists(key: string) {
return settingValues.value[key] !== undefined
}
/**
* Set a setting value.
* @param key - The key of the setting to set.
* @param value - The value to set.
*/
async function set<K extends keyof Settings>(key: K, value: Settings[K]) {
// Clone the incoming value to prevent external mutations
const clonedValue = _.cloneDeep(value)
const newValue = tryMigrateDeprecatedValue(
settingsById.value[key],
clonedValue
)
const oldValue = get(key)
if (newValue === oldValue) return
onChange(settingsById.value[key], newValue, oldValue)
settingValues.value[key] = newValue
await api.storeSetting(key, newValue)
}
/**
* Get a setting value.
* @param key - The key of the setting to get.
* @returns The value of the setting.
*/
function get<K extends keyof Settings>(key: K): Settings[K] {
// Clone the value when returning to prevent external mutations
return _.cloneDeep(settingValues.value[key] ?? getDefaultValue(key))
}
/**
* Gets the setting params, asserting the type that is intentionally left off
* of {@link settingsById}.
* @param key The key of the setting to get.
* @returns The setting.
*/
function getSettingById<K extends keyof Settings>(
key: K
): SettingParams<Settings[K]> | undefined {
return settingsById.value[key] as SettingParams<Settings[K]> | undefined
}
/**
* Get the default value of a setting.
* @param key - The key of the setting to get.
* @returns The default value of the setting.
*/
function getDefaultValue<K extends keyof Settings>(
key: K
): Settings[K] | undefined {
// Assertion: settingsById is not typed.
const param = getSettingById(key)
if (param === undefined) return
const versionedDefault = getVersionedDefaultValue(key, param)
if (versionedDefault) {
return versionedDefault
}
return typeof param.defaultValue === 'function'
? param.defaultValue()
: param.defaultValue
}
function getVersionedDefaultValue<
K extends keyof Settings,
TValue = Settings[K]
>(key: K, param: SettingParams<TValue> | undefined): TValue | null {
// get default versioned value, skipping if the key is 'Comfy.InstalledVersion' to prevent infinite loop
const defaultsByInstallVersion = param?.defaultsByInstallVersion
if (defaultsByInstallVersion && key !== 'Comfy.InstalledVersion') {
const installedVersion = get('Comfy.InstalledVersion')
if (installedVersion) {
const sortedVersions = Object.keys(defaultsByInstallVersion).sort(
(a, b) => compareVersions(b, a)
)
for (const version of sortedVersions) {
// Ensure the version is in a valid format before comparing
if (!isSemVer(version)) {
continue
}
if (compareVersions(installedVersion, version) >= 0) {
const versionedDefault = defaultsByInstallVersion[version]
return typeof versionedDefault === 'function'
? versionedDefault()
: versionedDefault
}
}
}
}
return null
}
/**
* Register a setting.
* @param setting - The setting to register.
*/
function addSetting(setting: SettingParams) {
if (!setting.id) {
throw new Error('Settings must have an ID')
}
if (setting.id in settingsById.value) {
throw new Error(`Setting ${setting.id} must have a unique ID.`)
}
settingsById.value[setting.id] = setting
if (settingValues.value[setting.id] !== undefined) {
settingValues.value[setting.id] = tryMigrateDeprecatedValue(
setting,
settingValues.value[setting.id]
)
}
onChange(setting, get(setting.id), undefined)
}
/*
* Load setting values from server.
* This needs to be called before any setting is registered.
*/
async function loadSettingValues() {
if (Object.keys(settingsById.value).length) {
throw new Error(
'Setting values must be loaded before any setting is registered.'
)
}
settingValues.value = await api.getSettings()
}
return {
settingValues,
settingsById,
addSetting,
loadSettingValues,
set,
get,
exists,
getDefaultValue
}
})

View File

@@ -4,13 +4,12 @@ import { computed, ref, shallowRef, watch } from 'vue'
import type { DragAndScaleState } from '@/lib/litegraph/src/DragAndScale'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { isNonNullish } from '@/utils/typeGuardUtil'
import { useCanvasStore } from './graphStore'
import { useWorkflowStore } from './workflowStore'
/**
* Stores the current subgraph navigation state; a stack representing subgraph
* navigation history from the root graph to the subgraph that is currently

321
src/stores/subgraphStore.ts Normal file
View File

@@ -0,0 +1,321 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
import type { LoadedComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import type {
ComfyNode,
ComfyWorkflowJSON,
NodeId
} from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import type { NodeError } from '@/schemas/apiSchema'
import type {
ComfyNodeDef as ComfyNodeDefV1,
InputSpec
} from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useDialogService } from '@/services/dialogService'
import { useExecutionStore } from '@/stores/executionStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { UserFile } from '@/stores/userFileStore'
async function confirmOverwrite(name: string): Promise<boolean | null> {
return await useDialogService().confirm({
title: t('subgraphStore.overwriteBlueprintTitle'),
type: 'overwriteBlueprint',
message: t('subgraphStore.overwriteBlueprint'),
itemList: [name]
})
}
export const useSubgraphStore = defineStore('subgraph', () => {
class SubgraphBlueprint extends ComfyWorkflow {
static override readonly basePath = 'subgraphs/'
override readonly tintCanvasBg = '#22227740'
hasPromptedSave: boolean = false
constructor(
options: { path: string; modified: number; size: number },
confirmFirstSave: boolean = false
) {
super(options)
this.hasPromptedSave = !confirmFirstSave
}
validateSubgraph() {
if (!this.activeState?.definitions)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
const { subgraphs } = this.activeState.definitions
const { nodes } = this.activeState
//Instanceof doesn't function as nodes are serialized
function isSubgraphNode(node: ComfyNode) {
return node && subgraphs.some((s) => s.id === node.type)
}
if (nodes.length == 1 && isSubgraphNode(nodes[0])) return
const errors: Record<NodeId, NodeError> = {}
//mark errors for all but first subgraph node
let firstSubgraphFound = false
for (let i = 0; i < nodes.length; i++) {
if (!firstSubgraphFound && isSubgraphNode(nodes[i])) {
firstSubgraphFound = true
continue
}
errors[nodes[i].id] = {
errors: [],
class_type: nodes[i].type,
dependent_outputs: []
}
}
useExecutionStore().lastNodeErrors = errors
useCanvasStore().getCanvas().draw(true, true)
throw new Error(
'The root graph of a subgraph blueprint must consist of only a single subgraph node'
)
}
override async save(): Promise<UserFile> {
this.validateSubgraph()
if (
!this.hasPromptedSave &&
useSettingStore().get('Comfy.Workflow.WarnBlueprintOverwrite')
) {
if (!(await confirmOverwrite(this.filename))) return this
this.hasPromptedSave = true
}
const ret = await super.save()
useSubgraphStore().updateDef(await this.load())
return ret
}
override async saveAs(path: string) {
this.validateSubgraph()
this.hasPromptedSave = true
const ret = await super.saveAs(path)
useSubgraphStore().updateDef(await this.load())
return ret
}
override async load({
force = false
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
if (!force && this.isLoaded) return await super.load({ force })
const loaded = await super.load({ force })
const st = loaded.activeState
const sg = (st.definitions?.subgraphs ?? []).find(
(sg) => sg.id == st.nodes[0].type
)
if (!sg)
throw new Error(
'Loaded subgraph blueprint does not contain valid subgraph'
)
sg.name = st.nodes[0].title = this.filename
return loaded
}
override async promptSave(): Promise<string | null> {
return await useDialogService().prompt({
title: t('subgraphStore.saveBlueprint'),
message: t('subgraphStore.blueprintName') + ':',
defaultValue: this.filename
})
}
override unload(): void {
//Skip unloading. Even if a workflow is closed after editing,
//it must remain loaded in order to be added to the graph
}
}
const subgraphCache: Record<string, LoadedComfyWorkflow> = {}
const typePrefix = 'SubgraphBlueprint.'
const subgraphDefCache = ref<Map<string, ComfyNodeDefImpl>>(new Map())
const canvasStore = useCanvasStore()
const subgraphBlueprints = computed(() => [
...subgraphDefCache.value.values()
])
async function fetchSubgraphs() {
async function loadBlueprint(options: {
path: string
modified: number
size: number
}): Promise<void> {
const name = options.path.slice(0, -'.json'.length)
options.path = SubgraphBlueprint.basePath + options.path
const bp = await new SubgraphBlueprint(options, true).load()
useWorkflowStore().attachWorkflow(bp)
const nodeDef = convertToNodeDef(bp)
subgraphDefCache.value.set(name, nodeDef)
subgraphCache[name] = bp
}
const res = (
await api.listUserDataFullInfo(SubgraphBlueprint.basePath)
).filter((f) => f.path.endsWith('.json'))
const settled = await Promise.allSettled(res.map(loadBlueprint))
const errors = settled.filter((i) => 'reason' in i).map((i) => i.reason)
errors.forEach((e) => console.error('Failed to load subgraph blueprint', e))
if (errors.length > 0) {
useToastStore().add({
severity: 'error',
summary: t('subgraphStore.loadFailure'),
detail: errors.length > 3 ? `x${errors.length}` : `${errors}`,
life: 6000
})
}
}
function convertToNodeDef(workflow: LoadedComfyWorkflow): ComfyNodeDefImpl {
const name = workflow.filename
const subgraphNode = workflow.changeTracker.initialState.nodes[0]
if (!subgraphNode) throw new Error('Invalid Subgraph Blueprint')
subgraphNode.inputs ??= []
subgraphNode.outputs ??= []
//NOTE: Types are cast to string. This is only used for input coloring on previews
const inputs = Object.fromEntries(
subgraphNode.inputs.map((i) => [
i.name,
[`${i.type}`, undefined] satisfies InputSpec
])
)
let description = 'User generated subgraph blueprint'
if (workflow.initialState.extra?.BlueprintDescription)
description = `${workflow.initialState.extra.BlueprintDescription}`
const nodedefv1: ComfyNodeDefV1 = {
input: { required: inputs },
output: subgraphNode.outputs.map((o) => `${o.type}`),
output_name: subgraphNode.outputs.map((o) => o.name),
name: typePrefix + name,
display_name: name,
description,
category: 'Subgraph Blueprints',
output_node: false,
python_module: 'blueprint'
}
const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1)
return nodeDefImpl
}
async function publishSubgraph() {
const canvas = canvasStore.getCanvas()
const subgraphNode = [...canvas.selectedItems][0]
if (
canvas.selectedItems.size !== 1 ||
!(subgraphNode instanceof SubgraphNode)
)
throw new TypeError('Must have single SubgraphNode selected to publish')
const { nodes = [], subgraphs = [] } = canvas._serializeItems([
subgraphNode
])
if (nodes.length != 1) {
throw new TypeError('Must have single SubgraphNode selected to publish')
}
//create minimal workflow
const workflowData = {
revision: 0,
last_node_id: subgraphNode.id,
last_link_id: 0,
nodes,
links: [],
version: 0.4,
definitions: { subgraphs }
}
//prompt name
const name = await useDialogService().prompt({
title: t('subgraphStore.saveBlueprint'),
message: t('subgraphStore.blueprintName') + ':',
defaultValue: subgraphNode.title
})
if (!name) return
if (subgraphDefCache.value.has(name) && !(await confirmOverwrite(name)))
//User has chosen not to overwrite.
return
//upload file
const path = SubgraphBlueprint.basePath + name + '.json'
const workflow = new SubgraphBlueprint({
path,
size: -1,
modified: Date.now()
})
workflow.originalContent = JSON.stringify(workflowData)
const loadedWorkflow = await workflow.load()
//Mark non-temporary
workflow.size = 1
await workflow.save()
//add to files list?
useWorkflowStore().attachWorkflow(loadedWorkflow)
subgraphDefCache.value.set(name, convertToNodeDef(loadedWorkflow))
subgraphCache[name] = loadedWorkflow
useToastStore().add({
severity: 'success',
summary: t('subgraphStore.publishSuccess'),
detail: t('subgraphStore.publishSuccessMessage'),
life: 4000
})
}
function updateDef(blueprint: LoadedComfyWorkflow) {
subgraphDefCache.value.set(blueprint.filename, convertToNodeDef(blueprint))
}
async function editBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
useWorkflowStore().attachWorkflow(subgraphCache[name])
await useWorkflowService().openWorkflow(subgraphCache[name])
const canvas = useCanvasStore().getCanvas()
if (canvas.graph && 'subgraph' in canvas.graph.nodes[0])
canvas.setGraph(canvas.graph.nodes[0].subgraph)
}
function getBlueprint(nodeType: string): ComfyWorkflowJSON {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
return subgraphCache[name].changeTracker.initialState
}
async function deleteBlueprint(nodeType: string) {
const name = nodeType.slice(typePrefix.length)
if (!(name in subgraphCache))
//As loading is blocked on in startup, this can likely be changed to invalid type
throw new Error('not yet loaded')
if (
!(await useDialogService().confirm({
title: t('subgraphStore.confirmDeleteTitle'),
type: 'delete',
message: t('subgraphStore.confirmDelete'),
itemList: [name]
}))
)
return
await subgraphCache[name].delete()
delete subgraphCache[name]
subgraphDefCache.value.delete(name)
}
function isSubgraphBlueprint(
workflow: unknown
): workflow is SubgraphBlueprint {
return workflow instanceof SubgraphBlueprint
}
return {
deleteBlueprint,
editBlueprint,
fetchSubgraphs,
getBlueprint,
isSubgraphBlueprint,
publishSubgraph,
subgraphBlueprints,
typePrefix,
updateDef
}
})

View File

@@ -1,32 +1,34 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { SystemStats } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { isElectron } from '@/utils/envUtil'
export const useSystemStatsStore = defineStore('systemStats', () => {
const systemStats = ref<SystemStats | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchSystemStats() {
isLoading.value = true
error.value = null
const fetchSystemStatsData = async () => {
try {
systemStats.value = await api.getSystemStats()
return await api.getSystemStats()
} catch (err) {
error.value =
err instanceof Error
? err.message
: 'An error occurred while fetching system stats'
console.error('Error fetching system stats:', err)
} finally {
isLoading.value = false
throw err
}
}
const {
state: systemStats,
isLoading,
error,
isReady: isInitialized,
execute: refetchSystemStats
} = useAsyncState<SystemStats | null>(
fetchSystemStatsData,
null, // initial value
{
immediate: true
}
)
function getFormFactor(): string {
if (!systemStats.value?.system?.os) {
return 'other'
@@ -62,7 +64,8 @@ export const useSystemStatsStore = defineStore('systemStats', () => {
systemStats,
isLoading,
error,
fetchSystemStats,
isInitialized,
refetchSystemStats,
getFormFactor
}
})

View File

@@ -1,39 +0,0 @@
// Within Vue component context, you can directly call useToast().add()
// instead of going through the store.
// The store is useful when you need to call it from outside the Vue component context.
import { defineStore } from 'pinia'
import type { ToastMessageOptions } from 'primevue/toast'
import { ref } from 'vue'
export const useToastStore = defineStore('toast', () => {
const messagesToAdd = ref<ToastMessageOptions[]>([])
const messagesToRemove = ref<ToastMessageOptions[]>([])
const removeAllRequested = ref(false)
function add(message: ToastMessageOptions) {
messagesToAdd.value = [...messagesToAdd.value, message]
}
function remove(message: ToastMessageOptions) {
messagesToRemove.value = [...messagesToRemove.value, message]
}
function removeAll() {
removeAllRequested.value = true
}
function addAlert(message: string) {
add({ severity: 'warn', summary: 'Alert', detail: message })
}
return {
messagesToAdd,
messagesToRemove,
removeAllRequested,
add,
remove,
removeAll,
addAlert
}
})

View File

@@ -1,9 +1,9 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { UserDataFullInfo } from '@/schemas/apiSchema'
import type { UserDataFullInfo } from '@/schemas/apiSchema'
import { api } from '@/scripts/api'
import { TreeExplorerNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { buildTree } from '@/utils/treeUtil'
@@ -182,7 +182,7 @@ export class UserFile {
}
}
export interface LoadedUserFile extends UserFile {
interface LoadedUserFile extends UserFile {
isLoaded: true
originalContent: string
content: string

View File

@@ -1,138 +0,0 @@
import { useStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import * as semver from 'semver'
import { computed } from 'vue'
import config from '@/config'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
const DISMISSAL_DURATION_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
export const useVersionCompatibilityStore = defineStore(
'versionCompatibility',
() => {
const systemStatsStore = useSystemStatsStore()
const frontendVersion = computed(() => config.app_version)
const backendVersion = computed(
() => systemStatsStore.systemStats?.system?.comfyui_version ?? ''
)
const requiredFrontendVersion = computed(
() =>
systemStatsStore.systemStats?.system?.required_frontend_version ?? ''
)
const isFrontendOutdated = computed(() => {
if (
!frontendVersion.value ||
!requiredFrontendVersion.value ||
!semver.valid(frontendVersion.value) ||
!semver.valid(requiredFrontendVersion.value)
) {
return false
}
// Returns true if required version is greater than frontend version
return semver.gt(requiredFrontendVersion.value, frontendVersion.value)
})
const isFrontendNewer = computed(() => {
// We don't warn about frontend being newer than backend
// Only warn when frontend is outdated (behind required version)
return false
})
const hasVersionMismatch = computed(() => {
return isFrontendOutdated.value
})
const versionKey = computed(() => {
if (
!frontendVersion.value ||
!backendVersion.value ||
!requiredFrontendVersion.value
) {
return null
}
return `${frontendVersion.value}-${backendVersion.value}-${requiredFrontendVersion.value}`
})
// Use reactive storage for dismissals - creates a reactive ref that syncs with localStorage
// All version mismatch dismissals are stored in a single object for clean localStorage organization
const dismissalStorage = useStorage(
'comfy.versionMismatch.dismissals',
{} as Record<string, number>,
localStorage,
{
serializer: {
read: (value: string) => {
try {
return JSON.parse(value)
} catch {
return {}
}
},
write: (value: Record<string, number>) => JSON.stringify(value)
}
}
)
const isDismissed = computed(() => {
if (!versionKey.value) return false
const dismissedUntil = dismissalStorage.value[versionKey.value]
if (!dismissedUntil) return false
// Check if dismissal has expired
return Date.now() < dismissedUntil
})
const shouldShowWarning = computed(() => {
return hasVersionMismatch.value && !isDismissed.value
})
const warningMessage = computed(() => {
if (isFrontendOutdated.value) {
return {
type: 'outdated' as const,
frontendVersion: frontendVersion.value,
requiredVersion: requiredFrontendVersion.value
}
}
return null
})
async function checkVersionCompatibility() {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
}
function dismissWarning() {
if (!versionKey.value) return
const dismissUntil = Date.now() + DISMISSAL_DURATION_MS
dismissalStorage.value = {
...dismissalStorage.value,
[versionKey.value]: dismissUntil
}
}
async function initialize() {
await checkVersionCompatibility()
}
return {
frontendVersion,
backendVersion,
requiredFrontendVersion,
hasVersionMismatch,
shouldShowWarning,
warningMessage,
isFrontendOutdated,
isFrontendNewer,
checkVersionCompatibility,
dismissWarning,
initialize
}
}
)

View File

@@ -6,7 +6,8 @@ import {
type InputSpec as InputSpecV1,
getInputSpecType
} from '@/schemas/nodeDefSchema'
import { ComfyWidgetConstructor, ComfyWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import { ComfyWidgets } from '@/scripts/widgets'
export const useWidgetStore = defineStore('widget', () => {
const coreWidgets = ComfyWidgets

View File

@@ -1,722 +0,0 @@
import _ from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app as comfyApp } from '@/scripts/app'
import { ChangeTracker } from '@/scripts/changeTracker'
import { defaultGraphJSON } from '@/scripts/defaultGraph'
import type { NodeExecutionId, NodeLocatorId } from '@/types/nodeIdentification'
import {
createNodeExecutionId,
createNodeLocatorId,
parseNodeExecutionId,
parseNodeLocatorId
} from '@/types/nodeIdentification'
import { getPathDetails } from '@/utils/formatUtil'
import { syncEntities } from '@/utils/syncUtil'
import { isSubgraph } from '@/utils/typeGuardUtil'
import { UserFile } from './userFileStore'
export class ComfyWorkflow extends UserFile {
static readonly basePath = 'workflows/'
/**
* The change tracker for the workflow. Non-reactive raw object.
*/
changeTracker: ChangeTracker | null = null
/**
* Whether the workflow has been modified comparing to the initial state.
*/
_isModified: boolean = false
/**
* @param options The path, modified, and size of the workflow.
* Note: path is the full path, including the 'workflows/' prefix.
*/
constructor(options: { path: string; modified: number; size: number }) {
super(options.path, options.modified, options.size)
}
override get key() {
return this.path.substring(ComfyWorkflow.basePath.length)
}
get activeState(): ComfyWorkflowJSON | null {
return this.changeTracker?.activeState ?? null
}
get initialState(): ComfyWorkflowJSON | null {
return this.changeTracker?.initialState ?? null
}
override get isLoaded(): boolean {
return this.changeTracker !== null
}
override get isModified(): boolean {
return this._isModified
}
override set isModified(value: boolean) {
this._isModified = value
}
/**
* Load the workflow content from remote storage. Directly returns the loaded
* workflow if the content is already loaded.
*
* @param force Whether to force loading the content even if it is already loaded.
* @returns this
*/
override async load({
force = false
}: { force?: boolean } = {}): Promise<LoadedComfyWorkflow> {
await super.load({ force })
if (!force && this.isLoaded) return this as LoadedComfyWorkflow
if (!this.originalContent) {
throw new Error('[ASSERT] Workflow content should be loaded')
}
// Note: originalContent is populated by super.load()
console.debug('load and start tracking of workflow', this.path)
this.changeTracker = markRaw(
new ChangeTracker(
this,
/* initialState= */ JSON.parse(this.originalContent)
)
)
return this as LoadedComfyWorkflow
}
override unload(): void {
console.debug('unload workflow', this.path)
this.changeTracker = null
super.unload()
}
override async save() {
this.content = JSON.stringify(this.activeState)
// Force save to ensure the content is updated in remote storage incase
// the isModified state is screwed by changeTracker.
const ret = await super.save({ force: true })
this.changeTracker?.reset()
this.isModified = false
return ret
}
/**
* Save the workflow as a new file.
* @param path The path to save the workflow to. Note: with 'workflows/' prefix.
* @returns this
*/
override async saveAs(path: string) {
this.content = JSON.stringify(this.activeState)
return await super.saveAs(path)
}
}
export interface LoadedComfyWorkflow extends ComfyWorkflow {
isLoaded: true
originalContent: string
content: string
changeTracker: ChangeTracker
initialState: ComfyWorkflowJSON
activeState: ComfyWorkflowJSON
}
/**
* Exposed store interface for the workflow store.
* Explicitly typed to avoid trigger following error:
* error TS7056: The inferred type of this node exceeds the maximum length the
* compiler will serialize. An explicit type annotation is needed.
*/
export interface WorkflowStore {
activeWorkflow: LoadedComfyWorkflow | null
isActive: (workflow: ComfyWorkflow) => boolean
openWorkflows: ComfyWorkflow[]
openedWorkflowIndexShift: (shift: number) => ComfyWorkflow | null
openWorkflow: (workflow: ComfyWorkflow) => Promise<LoadedComfyWorkflow>
openWorkflowsInBackground: (paths: {
left?: string[]
right?: string[]
}) => void
isOpen: (workflow: ComfyWorkflow) => boolean
isBusy: boolean
closeWorkflow: (workflow: ComfyWorkflow) => Promise<void>
createTemporary: (
path?: string,
workflowData?: ComfyWorkflowJSON
) => ComfyWorkflow
renameWorkflow: (workflow: ComfyWorkflow, newPath: string) => Promise<void>
deleteWorkflow: (workflow: ComfyWorkflow) => Promise<void>
saveWorkflow: (workflow: ComfyWorkflow) => Promise<void>
workflows: ComfyWorkflow[]
bookmarkedWorkflows: ComfyWorkflow[]
persistedWorkflows: ComfyWorkflow[]
modifiedWorkflows: ComfyWorkflow[]
getWorkflowByPath: (path: string) => ComfyWorkflow | null
syncWorkflows: (dir?: string) => Promise<void>
reorderWorkflows: (from: number, to: number) => void
/** `true` if any subgraph is currently being viewed. */
isSubgraphActive: boolean
activeSubgraph: Subgraph | undefined
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
executionIdToCurrentId: (id: string) => any
nodeIdToNodeLocatorId: (nodeId: NodeId, subgraph?: Subgraph) => NodeLocatorId
nodeExecutionIdToNodeLocatorId: (
nodeExecutionId: NodeExecutionId | string
) => NodeLocatorId | null
nodeLocatorIdToNodeId: (locatorId: NodeLocatorId | string) => NodeId | null
nodeLocatorIdToNodeExecutionId: (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
) => NodeExecutionId | null
}
export const useWorkflowStore = defineStore('workflow', () => {
/**
* Detach the workflow from the store. lightweight helper function.
* @param workflow The workflow to detach.
* @returns The index of the workflow in the openWorkflowPaths array, or -1 if the workflow was not open.
*/
const detachWorkflow = (workflow: ComfyWorkflow) => {
delete workflowLookup.value[workflow.path]
const index = openWorkflowPaths.value.indexOf(workflow.path)
if (index !== -1) {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
}
return index
}
/**
* Attach the workflow to the store. lightweight helper function.
* @param workflow The workflow to attach.
* @param openIndex The index to open the workflow at.
*/
const attachWorkflow = (workflow: ComfyWorkflow, openIndex: number = -1) => {
workflowLookup.value[workflow.path] = workflow
if (openIndex !== -1) {
openWorkflowPaths.value.splice(openIndex, 0, workflow.path)
}
}
/**
* The active workflow currently being edited.
*/
const activeWorkflow = ref<LoadedComfyWorkflow | null>(null)
const isActive = (workflow: ComfyWorkflow) =>
activeWorkflow.value?.path === workflow.path
/**
* All workflows.
*/
const workflowLookup = ref<Record<string, ComfyWorkflow>>({})
const workflows = computed<ComfyWorkflow[]>(() =>
Object.values(workflowLookup.value)
)
const getWorkflowByPath = (path: string): ComfyWorkflow | null =>
workflowLookup.value[path] ?? null
/**
* The paths of the open workflows. It is setup as a ref to allow user
* to reorder the workflows opened.
*/
const openWorkflowPaths = ref<string[]>([])
const openWorkflowPathSet = computed(() => new Set(openWorkflowPaths.value))
const openWorkflows = computed(() =>
openWorkflowPaths.value.map((path) => workflowLookup.value[path])
)
const reorderWorkflows = (from: number, to: number) => {
const movedTab = openWorkflowPaths.value[from]
openWorkflowPaths.value.splice(from, 1)
openWorkflowPaths.value.splice(to, 0, movedTab)
}
const isOpen = (workflow: ComfyWorkflow) =>
openWorkflowPathSet.value.has(workflow.path)
/**
* Add paths to the list of open workflow paths without loading the files
* or changing the active workflow.
*
* @param paths - The workflows to open, specified as:
* - `left`: Workflows to be added to the left.
* - `right`: Workflows to be added to the right.
*
* Invalid paths (non-strings or paths not found in `workflowLookup.value`)
* will be ignored. Duplicate paths are automatically removed.
*/
const openWorkflowsInBackground = (paths: {
left?: string[]
right?: string[]
}) => {
const { left = [], right = [] } = paths
if (!left.length && !right.length) return
const isValidPath = (
path: unknown
): path is keyof typeof workflowLookup.value =>
typeof path === 'string' && path in workflowLookup.value
openWorkflowPaths.value = _.union(
left,
openWorkflowPaths.value,
right
).filter(isValidPath)
}
/**
* Set the workflow as the active workflow.
* @param workflow The workflow to open.
*/
const openWorkflow = async (
workflow: ComfyWorkflow
): Promise<LoadedComfyWorkflow> => {
if (isActive(workflow)) return workflow as LoadedComfyWorkflow
if (!openWorkflowPaths.value.includes(workflow.path)) {
openWorkflowPaths.value.push(workflow.path)
}
const loadedWorkflow = await workflow.load()
activeWorkflow.value = loadedWorkflow
console.debug('[workflowStore] open workflow', workflow.path)
return loadedWorkflow
}
const getUnconflictedPath = (basePath: string): string => {
const { directory, filename, suffix } = getPathDetails(basePath)
let counter = 2
let newPath = basePath
while (workflowLookup.value[newPath]) {
newPath = `${directory}/${filename} (${counter}).${suffix}`
counter++
}
return newPath
}
const createTemporary = (path?: string, workflowData?: ComfyWorkflowJSON) => {
const fullPath = getUnconflictedPath(
ComfyWorkflow.basePath + (path ?? 'Unsaved Workflow.json')
)
const workflow = new ComfyWorkflow({
path: fullPath,
modified: Date.now(),
size: -1
})
workflow.originalContent = workflow.content = workflowData
? JSON.stringify(workflowData)
: defaultGraphJSON
workflowLookup.value[workflow.path] = workflow
return workflow
}
const closeWorkflow = async (workflow: ComfyWorkflow) => {
openWorkflowPaths.value = openWorkflowPaths.value.filter(
(path) => path !== workflow.path
)
if (workflow.isTemporary) {
// Clear thumbnail when temporary workflow is closed
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
} else {
workflow.unload()
}
console.debug('[workflowStore] close workflow', workflow.path)
}
/**
* Get the workflow at the given index shift from the active workflow.
* @param shift The shift to the next workflow. Positive for next, negative for previous.
* @returns The next workflow or null if the shift is out of bounds.
*/
const openedWorkflowIndexShift = (shift: number): ComfyWorkflow | null => {
const index = openWorkflowPaths.value.indexOf(
activeWorkflow.value?.path ?? ''
)
if (index !== -1) {
const length = openWorkflows.value.length
const nextIndex = (index + shift + length) % length
const nextWorkflow = openWorkflows.value[nextIndex]
return nextWorkflow ?? null
}
return null
}
const persistedWorkflows = computed(() =>
Array.from(workflows.value).filter((workflow) => workflow.isPersisted)
)
const syncWorkflows = async (dir: string = '') => {
await syncEntities(
dir ? 'workflows/' + dir : 'workflows',
workflowLookup.value,
(file) =>
new ComfyWorkflow({
path: file.path,
modified: file.modified,
size: file.size
}),
(existingWorkflow, file) => {
existingWorkflow.lastModified = file.modified
existingWorkflow.size = file.size
existingWorkflow.unload()
},
/* exclude */ (workflow) => workflow.isTemporary
)
}
const bookmarkStore = useWorkflowBookmarkStore()
const bookmarkedWorkflows = computed(() =>
workflows.value.filter((workflow) =>
bookmarkStore.isBookmarked(workflow.path)
)
)
const modifiedWorkflows = computed(() =>
workflows.value.filter((workflow) => workflow.isModified)
)
/** A filesystem operation is currently in progress (e.g. save, rename, delete) */
const isBusy = ref<boolean>(false)
const { moveWorkflowThumbnail, clearThumbnail } = useWorkflowThumbnail()
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
isBusy.value = true
try {
// Capture all needed values upfront
const oldPath = workflow.path
const oldKey = workflow.key
const wasBookmarked = bookmarkStore.isBookmarked(oldPath)
const openIndex = detachWorkflow(workflow)
// Perform the actual rename operation first
try {
await workflow.rename(newPath)
} finally {
attachWorkflow(workflow, openIndex)
}
// Move thumbnail from old key to new key (using workflow keys, not full paths)
const newKey = workflow.key
moveWorkflowThumbnail(oldKey, newKey)
// Update bookmarks
if (wasBookmarked) {
await bookmarkStore.setBookmarked(oldPath, false)
await bookmarkStore.setBookmarked(newPath, true)
}
} finally {
isBusy.value = false
}
}
const deleteWorkflow = async (workflow: ComfyWorkflow) => {
isBusy.value = true
try {
await workflow.delete()
if (bookmarkStore.isBookmarked(workflow.path)) {
await bookmarkStore.setBookmarked(workflow.path, false)
}
// Clear thumbnail when workflow is deleted
clearThumbnail(workflow.key)
delete workflowLookup.value[workflow.path]
} finally {
isBusy.value = false
}
}
/**
* Save a workflow.
* @param workflow The workflow to save.
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
isBusy.value = true
try {
// Detach the workflow and re-attach to force refresh the tree objects.
const openIndex = detachWorkflow(workflow)
try {
await workflow.save()
} finally {
attachWorkflow(workflow, openIndex)
}
} finally {
isBusy.value = false
}
}
/** @see WorkflowStore.isSubgraphActive */
const isSubgraphActive = ref(false)
/** @see WorkflowStore.activeSubgraph */
const activeSubgraph = shallowRef<Raw<Subgraph>>()
/** @see WorkflowStore.updateActiveGraph */
const updateActiveGraph = () => {
const subgraph = comfyApp.canvas?.subgraph
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
if (!comfyApp.canvas) return
isSubgraphActive.value = isSubgraph(subgraph)
}
const subgraphNodeIdToSubgraph = (id: string, graph: LGraph | Subgraph) => {
const node = graph.getNodeById(id)
if (node?.isSubgraphNode()) return node.subgraph
}
const getSubgraphsFromInstanceIds = (
currentGraph: LGraph | Subgraph,
subgraphNodeIds: string[],
subgraphs: Subgraph[] = []
): Subgraph[] => {
const currentPart = subgraphNodeIds.shift()
if (currentPart === undefined) return subgraphs
const subgraph = subgraphNodeIdToSubgraph(currentPart, currentGraph)
if (subgraph === undefined) throw new Error('Subgraph not found')
subgraphs.push(subgraph)
return getSubgraphsFromInstanceIds(subgraph, subgraphNodeIds, subgraphs)
}
const executionIdToCurrentId = (id: string) => {
const subgraph = activeSubgraph.value
// Short-circuit: ID belongs to the parent workflow / no active subgraph
if (!id.includes(':')) {
return !subgraph ? id : undefined
} else if (!subgraph) {
return
}
// Parse the execution ID (e.g., "123:456:789")
const subgraphNodeIds = id.split(':')
// Start from the root graph
const { graph } = comfyApp
// If the last subgraph is the active subgraph, return the node ID
const subgraphs = getSubgraphsFromInstanceIds(graph, subgraphNodeIds)
if (subgraphs.at(-1) === subgraph) {
return subgraphNodeIds.at(-1)
}
}
watch(activeWorkflow, updateActiveGraph)
/**
* Convert a node ID to a NodeLocatorId
* @param nodeId The local node ID
* @param subgraph The subgraph containing the node (defaults to active subgraph)
* @returns The NodeLocatorId (for root graph nodes, returns the node ID as-is)
*/
const nodeIdToNodeLocatorId = (
nodeId: NodeId,
subgraph?: Subgraph
): NodeLocatorId => {
const targetSubgraph = subgraph ?? activeSubgraph.value
if (!targetSubgraph) {
// Node is in the root graph, return the node ID as-is
return String(nodeId)
}
return createNodeLocatorId(targetSubgraph.id, nodeId)
}
/**
* Convert an execution ID to a NodeLocatorId
* @param nodeExecutionId The execution node ID (e.g., "123:456:789")
* @returns The NodeLocatorId or null if conversion fails
*/
const nodeExecutionIdToNodeLocatorId = (
nodeExecutionId: NodeExecutionId | string
): NodeLocatorId | null => {
// Handle simple node IDs (root graph - no colons)
if (!nodeExecutionId.includes(':')) {
return nodeExecutionId
}
const parts = parseNodeExecutionId(nodeExecutionId)
if (!parts || parts.length === 0) return null
const nodeId = parts[parts.length - 1]
const subgraphNodeIds = parts.slice(0, -1)
if (subgraphNodeIds.length === 0) {
// Node is in root graph, return the node ID as-is
return String(nodeId)
}
try {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
subgraphNodeIds.map((id) => String(id))
)
const immediateSubgraph = subgraphs[subgraphs.length - 1]
return createNodeLocatorId(immediateSubgraph.id, nodeId)
} catch {
return null
}
}
/**
* Extract the node ID from a NodeLocatorId
* @param locatorId The NodeLocatorId
* @returns The local node ID or null if invalid
*/
const nodeLocatorIdToNodeId = (
locatorId: NodeLocatorId | string
): NodeId | null => {
const parsed = parseNodeLocatorId(locatorId)
return parsed?.localNodeId ?? null
}
/**
* Convert a NodeLocatorId to an execution ID for a specific context
* @param locatorId The NodeLocatorId
* @param targetSubgraph The subgraph context (defaults to active subgraph)
* @returns The execution ID or null if the node is not accessible from the target context
*/
const nodeLocatorIdToNodeExecutionId = (
locatorId: NodeLocatorId | string,
targetSubgraph?: Subgraph
): NodeExecutionId | null => {
const parsed = parseNodeLocatorId(locatorId)
if (!parsed) return null
const { subgraphUuid, localNodeId } = parsed
// If no subgraph UUID, this is a root graph node
if (!subgraphUuid) {
return String(localNodeId)
}
// Find the path from root to the subgraph with this UUID
const findSubgraphPath = (
graph: LGraph | Subgraph,
targetUuid: string,
path: NodeId[] = []
): NodeId[] | null => {
if (isSubgraph(graph) && graph.id === targetUuid) {
return path
}
for (const node of graph._nodes) {
if (node.isSubgraphNode() && node.subgraph) {
const result = findSubgraphPath(node.subgraph, targetUuid, [
...path,
node.id
])
if (result) return result
}
}
return null
}
const path = findSubgraphPath(comfyApp.graph, subgraphUuid)
if (!path) return null
// If we have a target subgraph, check if the path goes through it
if (
targetSubgraph &&
!path.some((_, idx) => {
const subgraphs = getSubgraphsFromInstanceIds(
comfyApp.graph,
path.slice(0, idx + 1).map((id) => String(id))
)
return subgraphs[subgraphs.length - 1] === targetSubgraph
})
) {
return null
}
return createNodeExecutionId([...path, localNodeId])
}
return {
activeWorkflow,
isActive,
openWorkflows,
openedWorkflowIndexShift,
openWorkflow,
openWorkflowsInBackground,
isOpen,
isBusy,
closeWorkflow,
createTemporary,
renameWorkflow,
deleteWorkflow,
saveWorkflow,
reorderWorkflows,
workflows,
bookmarkedWorkflows,
persistedWorkflows,
modifiedWorkflows,
getWorkflowByPath,
syncWorkflows,
isSubgraphActive,
activeSubgraph,
updateActiveGraph,
executionIdToCurrentId,
nodeIdToNodeLocatorId,
nodeExecutionIdToNodeLocatorId,
nodeLocatorIdToNodeId,
nodeLocatorIdToNodeExecutionId
}
}) satisfies () => WorkflowStore
export const useWorkflowBookmarkStore = defineStore('workflowBookmark', () => {
const bookmarks = ref<Set<string>>(new Set())
const isBookmarked = (path: string) => bookmarks.value.has(path)
const loadBookmarks = async () => {
const resp = await api.getUserData('workflows/.index.json')
if (resp.status === 200) {
const info = await resp.json()
bookmarks.value = new Set(info?.favorites ?? [])
}
}
const saveBookmarks = async () => {
await api.storeUserData('workflows/.index.json', {
favorites: Array.from(bookmarks.value)
})
}
const setBookmarked = async (path: string, value: boolean) => {
if (bookmarks.value.has(path) === value) return
if (value) {
bookmarks.value.add(path)
} else {
bookmarks.value.delete(path)
}
await saveBookmarks()
}
const toggleBookmarked = async (path: string) => {
await setBookmarked(path, !bookmarks.value.has(path))
}
return {
isBookmarked,
loadBookmarks,
saveBookmarks,
setBookmarked,
toggleBookmarked
}
})

View File

@@ -1,224 +0,0 @@
import axios, { type AxiosError } from 'axios'
import { groupBy } from 'es-toolkit/compat'
import { defineStore } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import { st } from '@/i18n'
import { api } from '@/scripts/api'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/types/workflowTemplateTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
const SHOULD_SORT_CATEGORIES = new Set([
// API Node templates should be strictly sorted by name to avoid any
// favoritism or bias towards a particular API. Other categories can
// have their ordering specified in index.json freely.
'Image API',
'Video API'
])
export const useWorkflowTemplatesStore = defineStore(
'workflowTemplates',
() => {
const customTemplates = shallowRef<{ [moduleName: string]: string[] }>({})
const coreTemplates = shallowRef<WorkflowTemplates[]>([])
const isLoaded = ref(false)
/**
* Sort a list of templates in alphabetical order by localized display name.
*/
const sortTemplateList = (templates: TemplateInfo[]) =>
templates.sort((a, b) => {
const aName = st(
`templateWorkflows.name.${normalizeI18nKey(a.name)}`,
a.title ?? a.name
)
const bName = st(
`templateWorkflows.name.${normalizeI18nKey(b.name)}`,
b.name
)
return aName.localeCompare(bName)
})
/**
* Sort any template categories (grouped templates) that should be sorted.
* Leave other categories' templates in their original order specified in index.json
*/
const sortCategoryTemplates = (categories: WorkflowTemplates[]) =>
categories.map((category) => {
if (SHOULD_SORT_CATEGORIES.has(category.title)) {
return {
...category,
templates: sortTemplateList(category.templates)
}
}
return category
})
/**
* Add localization fields to a template.
*/
const addLocalizedFieldsToTemplate = (
template: TemplateInfo,
categoryTitle: string
) => ({
...template,
localizedTitle: st(
`templateWorkflows.template.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
template.title ?? template.name
),
localizedDescription: st(
`templateWorkflows.templateDescription.${normalizeI18nKey(categoryTitle)}.${normalizeI18nKey(template.name)}`,
template.description
)
})
/**
* Add localization fields to all templates in a list of templates.
*/
const localizeTemplateList = (
templates: TemplateInfo[],
categoryTitle: string
) =>
templates.map((template) =>
addLocalizedFieldsToTemplate(template, categoryTitle)
)
/**
* Add localization fields to a template category and all its constituent templates.
*/
const localizeTemplateCategory = (templateCategory: WorkflowTemplates) => ({
...templateCategory,
localizedTitle: st(
`templateWorkflows.category.${normalizeI18nKey(templateCategory.title)}`,
templateCategory.title ?? templateCategory.moduleName
),
templates: localizeTemplateList(
templateCategory.templates,
templateCategory.title
)
})
// Create an "All" category that combines all templates
const createAllCategory = () => {
// First, get core templates with source module added
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
(category) =>
// For each template in each category, add the sourceModule and pass through any localized fields
category.templates.map((template) => {
// Get localized template with its original category title for i18n lookup
const localizedTemplate = addLocalizedFieldsToTemplate(
template,
category.title
)
return {
...localizedTemplate,
sourceModule: category.moduleName
}
})
)
// Now handle custom templates
const customTemplatesWithSourceModule = Object.entries(
customTemplates.value
).flatMap(([moduleName, templates]) =>
templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name,
sourceModule: moduleName
}))
)
return {
moduleName: 'all',
title: 'All',
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
templates: [
...coreTemplatesWithSourceModule,
...customTemplatesWithSourceModule
]
}
}
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
...sortCategoryTemplates(coreTemplates.value).map(
localizeTemplateCategory
),
...Object.entries(customTemplates.value).map(
([moduleName, templates]) => ({
moduleName,
title: moduleName,
localizedTitle: st(
`templateWorkflows.category.${normalizeI18nKey(moduleName)}`,
moduleName
),
templates: templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name
}))
})
)
]
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
'templateWorkflows.category.ComfyUI Examples',
'ComfyUI Examples'
)
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
)
}
return groupedByCategory
})
async function loadWorkflowTemplates() {
try {
if (!isLoaded.value) {
customTemplates.value = await api.getWorkflowTemplates()
coreTemplates.value = await api.getCoreWorkflowTemplates()
isLoaded.value = true
}
} catch (error) {
console.error('Error fetching workflow templates:', error)
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError
if (axiosError.response?.data) {
console.error('Template error details:', axiosError.response.data)
}
}
}
}
return {
groupedTemplates,
isLoaded,
loadWorkflowTemplates
}
}
)

View File

@@ -7,7 +7,7 @@ import {
useLogsTerminalTab
} from '@/composables/bottomPanelTabs/useTerminalTabs'
import { useCommandStore } from '@/stores/commandStore'
import { ComfyExtension } from '@/types/comfy'
import type { ComfyExtension } from '@/types/comfy'
import type { BottomPanelExtension } from '@/types/extensionTypes'
import { isElectron } from '@/utils/envUtil'

View File

@@ -4,7 +4,7 @@ import { computed, ref, shallowRef } from 'vue'
import type NodeSearchBoxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
import { useSettingStore } from '@/stores/settingStore'
import { useSettingStore } from '@/platform/settings/settingStore'
export const useSearchBoxStore = defineStore('searchBox', () => {
const settingStore = useSettingStore()

View File

@@ -4,11 +4,11 @@ import { computed, ref } from 'vue'
import { useModelLibrarySidebarTab } from '@/composables/sidebarTabs/useModelLibrarySidebarTab'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/composables/sidebarTabs/useQueueSidebarTab'
import { useWorkflowsSidebarTab } from '@/composables/sidebarTabs/useWorkflowsSidebarTab'
import { t, te } from '@/i18n'
import { useWorkflowsSidebarTab } from '@/platform/workflow/management/composables/useWorkflowsSidebarTab'
import { useCommandStore } from '@/stores/commandStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { SidebarTabExtension } from '@/types/extensionTypes'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useSidebarTabStore = defineStore('sidebarTab', () => {
const sidebarTabs = ref<SidebarTabExtension[]>([])

View File

@@ -1,6 +1,9 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { Settings } from '@/schemas/apiSchema'
import { useColorPaletteService } from '@/services/colorPaletteService'
import { useDialogService } from '@/services/dialogService'
@@ -10,9 +13,6 @@ import { useApiKeyAuthStore } from './apiKeyAuthStore'
import { useCommandStore } from './commandStore'
import { useFirebaseAuthStore } from './firebaseAuthStore'
import { useQueueSettingsStore } from './queueStore'
import { useSettingStore } from './settingStore'
import { useToastStore } from './toastStore'
import { useWorkflowStore } from './workflowStore'
import { useBottomPanelStore } from './workspace/bottomPanelStore'
import { useSidebarTabStore } from './workspace/sidebarTabStore'