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

@@ -12,7 +12,7 @@ This directory contains the service layer for the ComfyUI frontend application.
## Overview
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
@@ -57,21 +57,25 @@ While services can interact with both UI components and stores (centralized stat
## Core Services
The following table lists ALL services in the system as of 2025-01-30:
The following table lists ALL services in the system as of 2025-09-01:
### Main Services
| Service | Description | Category |
|---------|-------------|----------|
| audioService.ts | Manages audio recording and WAV encoding functionality | Media |
| autoQueueService.ts | Manages automatic queue execution | Execution |
| colorPaletteService.ts | Handles color palette management and customization | UI |
| comfyManagerService.ts | Manages ComfyUI application packages and updates | Manager |
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions | Registry |
| customerEventsService.ts | Handles customer event tracking and audit logs | Analytics |
| dialogService.ts | Provides dialog and modal management | UI |
| extensionService.ts | Manages extension registration and lifecycle | Extensions |
| keybindingService.ts | Handles keyboard shortcuts and keybindings | Input |
| litegraphService.ts | Provides utilities for working with the LiteGraph library | Graph |
| load3dService.ts | Manages 3D model loading and visualization | 3D |
| mediaCacheService.ts | Manages media file caching with blob storage and cleanup | Media |
| newUserService.ts | Handles new user initialization and onboarding | System |
| nodeHelpService.ts | Provides node documentation and help | Nodes |
| nodeOrganizationService.ts | Handles node organization and categorization | Nodes |
| nodeSearchService.ts | Implements node search functionality | Search |
@@ -105,47 +109,82 @@ For complex services with state management and multiple methods, class-based ser
```typescript
export class NodeSearchService {
// Service state
private readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
private readonly filters: Record<string, FuseFilter<ComfyNodeDefImpl, string>>
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
public readonly inputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
public readonly outputTypeFilter: FuseFilter<ComfyNodeDefImpl, string>
public readonly nodeCategoryFilter: FuseFilter<ComfyNodeDefImpl, string>
public readonly nodeSourceFilter: FuseFilter<ComfyNodeDefImpl, string>
constructor(data: ComfyNodeDefImpl[]) {
// Initialize state
this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
// Setup filters
this.filters = {
inputType: new FuseFilter<ComfyNodeDefImpl, string>(/* options */),
category: new FuseFilter<ComfyNodeDefImpl, string>(/* options */)
}
// Initialize search index
this.nodeFuseSearch = new FuseSearch(data, {
fuseOptions: {
keys: ['name', 'display_name'],
includeScore: true,
threshold: 0.3,
shouldSort: false,
useExtendedSearch: true
},
createIndex: true,
advancedScoring: true
})
// Setup individual filters
const fuseOptions = { includeScore: true, threshold: 0.3, shouldSort: true }
this.inputTypeFilter = new FuseFilter<ComfyNodeDefImpl, string>(data, {
id: 'input',
name: 'Input Type',
invokeSequence: 'i',
getItemOptions: (node) => Object.values(node.inputs).map((input) => input.type),
fuseOptions
})
// Additional filters initialized similarly...
}
public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
// Implementation
return results
public searchNode(
query: string,
filters: FuseFilterWithValue<ComfyNodeDefImpl, string>[] = []
): ComfyNodeDefImpl[] {
const matchedNodes = this.nodeFuseSearch.search(query)
return matchedNodes.filter((node) => {
return filters.every((filterAndValue) => {
const { filterDef, value } = filterAndValue
return filterDef.matches(node, value, { wildcard: '*' })
})
})
}
get nodeFilters(): FuseFilter<ComfyNodeDefImpl, string>[] {
return [
this.inputTypeFilter,
this.outputTypeFilter,
this.nodeCategoryFilter,
this.nodeSourceFilter
]
}
}
```
### 2. Composable-style Services
For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
For services that need to integrate with Vue's reactivity system or handle API interactions, we use composable-style services:
```typescript
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
// State (reactive if needed)
const data = ref(initialData)
// Search functionality
function searchNodes(query: string) {
// Implementation
return results
}
// Additional methods
function refreshData(newData: ComfyNodeDefImpl[]) {
data.value = newData
}
// Return public API
return {
searchNodes,
@@ -154,12 +193,35 @@ export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
}
```
When deciding between these approaches, consider:
### Service Pattern Comparison
1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
| Aspect | Class-Based Services | Composable-Style Services | Bootstrap Services | Shared State Services |
|--------|---------------------|---------------------------|-------------------|---------------------|
| **Count** | 4 services | 18+ services | 1 service | 1 service |
| **Export Pattern** | `export class ServiceName` | `export function useServiceName()` | `export function setupX()` | `export function serviceFactory()` |
| **Instantiation** | `new ServiceName(data)` | `useServiceName()` | Direct function call | Direct function call |
| **Best For** | Complex data structures, search algorithms, expensive initialization | Vue integration, API calls, reactive state | One-time app initialization | Singleton-like shared state |
| **State Management** | Encapsulated private/public properties | External stores + reactive refs | Event listeners, side effects | Module-level state |
| **Vue Integration** | Manual integration needed | Native reactivity support | N/A | Varies |
| **Examples** | `NodeSearchService`, `Load3dService` | `workflowService`, `dialogService` | `autoQueueService` | `newUserService` |
### Decision Criteria
When choosing between these approaches, consider:
1. **Data Structure Complexity**: Classes work well for services managing multiple related data structures (search indices, filters, complex state)
2. **Initialization Cost**: Classes are ideal when expensive setup should happen once and be controlled by instantiation
3. **Vue Integration**: Composables integrate seamlessly with Vue's reactivity system and stores
4. **API Interactions**: Composables handle async operations and API calls more naturally
5. **State Management**: Classes provide strong encapsulation; composables work better with external state management
6. **Application Bootstrap**: Bootstrap services handle one-time app initialization, event listener setup, and side effects
7. **Singleton Behavior**: Shared state services provide module-level state that persists across multiple function calls
**Current Usage Patterns:**
- **Class-based services (4)**: Complex data processing, search algorithms, expensive initialization
- **Composable-style services (18+)**: UI interactions, API calls, store integration, reactive state management
- **Bootstrap services (1)**: One-time application initialization and event handler setup
- **Shared state services (1)**: Singleton-like behavior with module-level state management
### Service Template
@@ -172,7 +234,7 @@ Here's a template for creating a new composable-style service:
export function useExampleService() {
// Private state/functionality
const cache = new Map()
/**
* Description of what this method does
* @param param1 Description of parameter
@@ -188,7 +250,7 @@ export function useExampleService() {
throw error
}
}
// Return public API
return {
performOperation
@@ -206,16 +268,16 @@ Services in ComfyUI frequently use the following design patterns:
export function useCachedService() {
const cache = new Map()
const pendingRequests = new Map()
async function fetchData(key: string) {
// Check cache first
if (cache.has(key)) return cache.get(key)
// Check if request is already in progress
if (pendingRequests.has(key)) {
return pendingRequests.get(key)
}
// Perform new request
const requestPromise = fetch(`/api/${key}`)
.then(response => response.json())
@@ -224,11 +286,11 @@ export function useCachedService() {
pendingRequests.delete(key)
return data
})
pendingRequests.set(key, requestPromise)
return requestPromise
}
return { fetchData }
}
```
@@ -248,7 +310,7 @@ export function useNodeFactory() {
throw new Error(`Unknown node type: ${type}`)
}
}
return { createNode }
}
```
@@ -267,11 +329,243 @@ export function useWorkflowService(
const storagePath = await storageService.getPath(name)
return apiService.saveData(storagePath, graphData)
}
return { saveWorkflow }
}
```
## Testing Services
Services in ComfyUI can be tested effectively using different approaches depending on their implementation pattern.
### Testing Class-Based Services
**Setup Requirements:**
```typescript
// Manual instantiation required
const mockData = [/* test data */]
const service = new NodeSearchService(mockData)
```
**Characteristics:**
- Requires constructor argument preparation
- State is encapsulated within the class instance
- Direct method calls on the instance
- Good isolation - each test gets a fresh instance
**Example:**
```typescript
describe('NodeSearchService', () => {
let service: NodeSearchService
beforeEach(() => {
const mockNodes = [/* mock node definitions */]
service = new NodeSearchService(mockNodes)
})
test('should search nodes by query', () => {
const results = service.searchNode('test query')
expect(results).toHaveLength(2)
})
test('should apply filters correctly', () => {
const filters = [{ filterDef: service.inputTypeFilter, value: 'IMAGE' }]
const results = service.searchNode('*', filters)
expect(results.every(node => /* has IMAGE input */)).toBe(true)
})
})
```
### Testing Composable-Style Services
**Setup Requirements:**
```typescript
// Direct function call, no instantiation
const { saveWorkflow, loadWorkflow } = useWorkflowService()
```
**Characteristics:**
- No instantiation needed
- Integrates naturally with Vue Test Utils
- Easy mocking of reactive dependencies
- External store dependencies need mocking
**Example:**
```typescript
describe('useWorkflowService', () => {
beforeEach(() => {
// Mock external dependencies
vi.mock('@/stores/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn().mockReturnValue(true),
set: vi.fn()
})
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({
add: vi.fn()
})
}))
})
test('should save workflow with prompt', async () => {
const { saveWorkflow } = useWorkflowService()
await saveWorkflow('test-workflow')
// Verify interactions with mocked dependencies
expect(mockSettingStore.get).toHaveBeenCalledWith('Comfy.PromptFilename')
})
})
```
### Testing Bootstrap Services
**Focus on Setup Behavior:**
```typescript
describe('autoQueueService', () => {
beforeEach(() => {
// Mock global dependencies
vi.mock('@/scripts/api', () => ({
api: {
addEventListener: vi.fn()
}
}))
vi.mock('@/scripts/app', () => ({
app: {
queuePrompt: vi.fn()
}
}))
})
test('should setup event listeners', () => {
setupAutoQueueHandler()
expect(mockApi.addEventListener).toHaveBeenCalledWith('graphChanged', expect.any(Function))
})
test('should handle graph changes when auto-queue enabled', () => {
setupAutoQueueHandler()
// Simulate graph change event
const graphChangeHandler = mockApi.addEventListener.mock.calls[0][1]
graphChangeHandler()
expect(mockApp.queuePrompt).toHaveBeenCalled()
})
})
```
### Testing Shared State Services
**Focus on Shared State Behavior:**
```typescript
describe('newUserService', () => {
beforeEach(() => {
// Reset module state between tests
vi.resetModules()
})
test('should return consistent API across calls', () => {
const service1 = newUserService()
const service2 = newUserService()
// Same functions returned (shared behavior)
expect(service1.isNewUser).toBeDefined()
expect(service2.isNewUser).toBeDefined()
})
test('should share state between service instances', async () => {
const service1 = newUserService()
const service2 = newUserService()
// Initialize through one instance
const mockSettingStore = { set: vi.fn() }
await service1.initializeIfNewUser(mockSettingStore)
// State should be shared
expect(service2.isNewUser()).toBe(true) // or false, depending on mock
})
})
```
### Common Testing Patterns
**Mocking External Dependencies:**
```typescript
// Mock stores
vi.mock('@/stores/settingStore', () => ({
useSettingStore: () => ({
get: vi.fn(),
set: vi.fn()
})
}))
// Mock API calls
vi.mock('@/scripts/api', () => ({
api: {
get: vi.fn().mockResolvedValue({ data: 'mock' }),
post: vi.fn().mockResolvedValue({ success: true })
}
}))
// Mock Vue composables
vi.mock('vue', () => ({
ref: vi.fn((val) => ({ value: val })),
reactive: vi.fn((obj) => obj)
}))
```
**Async Testing:**
```typescript
test('should handle async operations', async () => {
const service = useMyService()
const result = await service.performAsyncOperation()
expect(result).toBeTruthy()
})
test('should handle concurrent requests', async () => {
const service = useMyService()
const promises = [
service.loadData('key1'),
service.loadData('key2')
]
const results = await Promise.all(promises)
expect(results).toHaveLength(2)
})
```
**Error Handling:**
```typescript
test('should handle service errors gracefully', async () => {
const service = useMyService()
// Mock API to throw error
mockApi.get.mockRejectedValue(new Error('Network error'))
await expect(service.fetchData()).rejects.toThrow('Network error')
})
test('should provide meaningful error messages', async () => {
const service = useMyService()
const consoleSpy = vi.spyOn(console, 'error').mockImplementation()
await service.handleError('test error')
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('test error'))
})
```
### Testing Best Practices
1. **Isolate Dependencies**: Always mock external dependencies (stores, APIs, DOM)
2. **Reset State**: Use `beforeEach` to ensure clean test state
3. **Test Error Paths**: Don't just test happy paths - test error scenarios
4. **Mock Timers**: Use `vi.useFakeTimers()` for time-dependent services
5. **Test Async Properly**: Use `async/await` and proper promise handling
For more detailed information about the service layer pattern and its applications, refer to:
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)

View File

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

View File

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

View File

@@ -1,273 +0,0 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import {
type InstallPackParams,
type InstalledPacksResponse,
type ManagerPackInfo,
type ManagerQueueStatus,
SelectedVersion,
type UpdateAllPacksParams
} from '@/types/comfyManagerTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const GENERIC_SECURITY_ERR_MSG =
'Forbidden: A security error has occurred. Please check the terminal logs'
/**
* API routes for ComfyUI Manager
*/
enum ManagerRoute {
START_QUEUE = 'manager/queue/start',
RESET_QUEUE = 'manager/queue/reset',
QUEUE_STATUS = 'manager/queue/status',
INSTALL = 'manager/queue/install',
UPDATE = 'manager/queue/update',
UPDATE_ALL = 'manager/queue/update_all',
UNINSTALL = 'manager/queue/uninstall',
DISABLE = 'manager/queue/disable',
FIX_NODE = 'manager/queue/fix',
LIST_INSTALLED = 'customnode/installed',
GET_NODES = 'customnode/getmappings',
GET_PACKS = 'customnode/getlist',
IMPORT_FAIL_INFO = 'customnode/import_fail_info',
REBOOT = 'manager/reboot'
}
const managerApiClient = axios.create({
baseURL: api.apiURL(''),
headers: {
'Content-Type': 'application/json'
}
})
/**
* Service for interacting with the ComfyUI Manager API
* Provides methods for managing packs, ComfyUI-Manager queue operations, and system functions
*/
export const useComfyManagerService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
const didStartQueue = ref(false)
const handleRequestError = (
err: unknown,
context: string,
routeSpecificErrors?: Record<number, string>
) => {
// Don't treat cancellation as an error
if (isAbortError(err)) return
let message: string
if (!axios.isAxiosError(err)) {
message = `${context} failed: ${err instanceof Error ? err.message : String(err)}`
} else {
const axiosError = err as AxiosError<{ message: string }>
const status = axiosError.response?.status
if (status && routeSpecificErrors?.[status]) {
message = routeSpecificErrors[status]
} else if (status === 404) {
message = 'Could not connect to ComfyUI-Manager'
} else {
message =
axiosError.response?.data?.message ??
`${context} failed with status ${status}`
}
}
error.value = message
}
const executeRequest = async <T>(
requestCall: () => Promise<AxiosResponse<T>>,
options: {
errorContext: string
routeSpecificErrors?: Record<number, string>
isQueueOperation?: boolean
}
): Promise<T | null> => {
const { errorContext, routeSpecificErrors, isQueueOperation } = options
isLoading.value = true
error.value = null
try {
const response = await requestCall()
if (isQueueOperation) await startQueue()
return response.data
} catch (err) {
handleRequestError(err, errorContext, routeSpecificErrors)
return null
} finally {
isLoading.value = false
}
}
const startQueue = async (signal?: AbortSignal) => {
const errorContext = 'Starting ComfyUI-Manager job queue'
const routeSpecificErrors = {
201: 'Created: ComfyUI-Manager job queue is already running'
}
didStartQueue.value = true
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.START_QUEUE, { signal }),
{ errorContext, routeSpecificErrors }
)
}
const getQueueStatus = async (signal?: AbortSignal) => {
const errorContext = 'Getting ComfyUI-Manager queue status'
return executeRequest<ManagerQueueStatus>(
() => managerApiClient.get(ManagerRoute.QUEUE_STATUS, { signal }),
{ errorContext }
)
}
const resetQueue = async (signal?: AbortSignal) => {
const errorContext = 'Resetting ComfyUI-Manager queue'
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.RESET_QUEUE, { signal }),
{ errorContext }
)
}
const listInstalledPacks = async (signal?: AbortSignal) => {
const errorContext = 'Fetching installed packs'
return executeRequest<InstalledPacksResponse>(
() => managerApiClient.get(ManagerRoute.LIST_INSTALLED, { signal }),
{ errorContext }
)
}
const getImportFailInfo = async (signal?: AbortSignal) => {
const errorContext = 'Fetching import failure information'
return executeRequest<any>(
() => managerApiClient.get(ManagerRoute.IMPORT_FAIL_INFO, { signal }),
{ errorContext }
)
}
const installPack = async (
params: InstallPackParams,
signal?: AbortSignal
) => {
const errorContext = `Installing pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG,
404:
params.selected_version === SelectedVersion.NIGHTLY
? `Not Found: Node pack ${params.id} does not provide nightly version`
: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.INSTALL, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const uninstallPack = async (
params: ManagerPackInfo,
signal?: AbortSignal
) => {
const errorContext = `Uninstalling pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.UNINSTALL, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const disablePack = async (
params: ManagerPackInfo,
signal?: AbortSignal
): Promise<null> => {
const errorContext = `Disabling pack ${params.id}`
const routeSpecificErrors = {
404: `Pack ${params.id} not found or not installed`,
409: `Pack ${params.id} is already disabled`
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.DISABLE, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const updatePack = async (
params: ManagerPackInfo,
signal?: AbortSignal
): Promise<null> => {
const errorContext = `Updating pack ${params.id}`
const routeSpecificErrors = {
403: GENERIC_SECURITY_ERR_MSG
}
return executeRequest<null>(
() => managerApiClient.post(ManagerRoute.UPDATE, params, { signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const updateAllPacks = async (
params?: UpdateAllPacksParams,
signal?: AbortSignal
) => {
const errorContext = 'Updating all packs'
const routeSpecificErrors = {
403: 'Forbidden: To use this action, a security_level of `middle or below` is required',
401: 'Unauthorized: ComfyUI-Manager job queue is busy'
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.UPDATE_ALL, { params, signal }),
{ errorContext, routeSpecificErrors, isQueueOperation: true }
)
}
const rebootComfyUI = async (signal?: AbortSignal) => {
const errorContext = 'Rebooting ComfyUI'
const routeSpecificErrors = {
403: 'Forbidden: Rebooting ComfyUI requires security_level of middle or below'
}
return executeRequest<null>(
() => managerApiClient.get(ManagerRoute.REBOOT, { signal }),
{ errorContext, routeSpecificErrors }
)
}
return {
// State
isLoading,
error,
// Queue operations
startQueue,
resetQueue,
getQueueStatus,
// Pack management
listInstalledPacks,
getImportFailInfo,
installPack,
uninstallPack,
enablePack: installPack, // enable is done via install
disablePack,
updatePack,
updateAllPacks,
// System operations
rebootComfyUI
}
}

View File

@@ -1,4 +1,5 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import type { components, operations } from '@/types/comfyRegistryTypes'
@@ -359,6 +360,55 @@ export const useComfyRegistryService = () => {
)
}
/**
* Get multiple pack versions in a single bulk request.
* This is more efficient than making individual requests for each pack version.
*
* @param nodeVersions - Array of node ID and version pairs to retrieve
* @param signal - Optional AbortSignal for request cancellation
* @returns Bulk response containing the requested node versions or null on error
*
* @example
* ```typescript
* const versions = await getBulkNodeVersions([
* { node_id: 'ComfyUI-Manager', version: '1.0.0' },
* { node_id: 'ComfyUI-Impact-Pack', version: '2.0.0' }
* ])
* if (versions) {
* versions.node_versions.forEach(result => {
* if (result.status === 'success' && result.node_version) {
* console.log(`Retrieved ${result.identifier.node_id}@${result.identifier.version}`)
* }
* })
* }
* ```
*/
const getBulkNodeVersions = async (
nodeVersions: components['schemas']['NodeVersionIdentifier'][],
signal?: AbortSignal
) => {
const endpoint = '/bulk/nodes/versions'
const errorContext = 'Failed to get bulk node versions'
const routeSpecificErrors = {
400: 'Bad request: Invalid node version identifiers provided'
}
const requestBody: components['schemas']['BulkNodeVersionsRequest'] = {
node_versions: nodeVersions
}
return executeApiRequest(
() =>
registryApiClient.post<
components['schemas']['BulkNodeVersionsResponse']
>(endpoint, requestBody, {
signal
}),
errorContext,
routeSpecificErrors
)
}
return {
isLoading,
error,
@@ -372,6 +422,7 @@ export const useComfyRegistryService = () => {
listPacksForPublisher,
getNodeDefs,
postPackReview,
inferPackFromNodeName
inferPackFromNodeName,
getBulkNodeVersions
}
}

View File

@@ -1,10 +1,12 @@
import axios, { AxiosError, AxiosResponse } from 'axios'
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import { type components, operations } from '@/types/comfyRegistryTypes'
import type { operations } from '@/types/comfyRegistryTypes'
import type { components } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
export enum EventType {

View File

@@ -1,37 +1,39 @@
import { merge } from 'es-toolkit/compat'
import { Component } from 'vue'
import type { Component } from 'vue'
import ApiNodesSignInContent from '@/components/dialog/content/ApiNodesSignInContent.vue'
import ConfirmationDialogContent from '@/components/dialog/content/ConfirmationDialogContent.vue'
import ErrorDialogContent from '@/components/dialog/content/ErrorDialogContent.vue'
import IssueReportDialogContent from '@/components/dialog/content/IssueReportDialogContent.vue'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import ManagerProgressDialogContent from '@/components/dialog/content/ManagerProgressDialogContent.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SignInContent from '@/components/dialog/content/SignInContent.vue'
import TopUpCreditsDialogContent from '@/components/dialog/content/TopUpCreditsDialogContent.vue'
import UpdatePasswordContent from '@/components/dialog/content/UpdatePasswordContent.vue'
import ManagerDialogContent from '@/components/dialog/content/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/components/dialog/content/manager/ManagerHeader.vue'
import ManagerProgressFooter from '@/components/dialog/footer/ManagerProgressFooter.vue'
import ComfyOrgHeader from '@/components/dialog/header/ComfyOrgHeader.vue'
import ManagerProgressHeader from '@/components/dialog/header/ManagerProgressHeader.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
import TemplateWorkflowsDialogHeader from '@/components/templates/TemplateWorkflowsDialogHeader.vue'
import { t } from '@/i18n'
import SettingDialogContent from '@/platform/settings/components/SettingDialogContent.vue'
import type { ExecutionErrorWsMessage } from '@/schemas/apiSchema'
import {
type DialogComponentProps,
type ShowDialogOptions,
useDialogStore
} from '@/stores/dialogStore'
import ManagerProgressDialogContent from '@/workbench/extensions/manager/components/ManagerProgressDialogContent.vue'
import ManagerProgressFooter from '@/workbench/extensions/manager/components/ManagerProgressFooter.vue'
import ManagerProgressHeader from '@/workbench/extensions/manager/components/ManagerProgressHeader.vue'
import ManagerDialogContent from '@/workbench/extensions/manager/components/manager/ManagerDialogContent.vue'
import ManagerHeader from '@/workbench/extensions/manager/components/manager/ManagerHeader.vue'
import NodeConflictDialogContent from '@/workbench/extensions/manager/components/manager/NodeConflictDialogContent.vue'
import NodeConflictFooter from '@/workbench/extensions/manager/components/manager/NodeConflictFooter.vue'
import NodeConflictHeader from '@/workbench/extensions/manager/components/manager/NodeConflictHeader.vue'
import type { ConflictDetectionResult } from '@/workbench/extensions/manager/types/conflictDetectionTypes'
export type ConfirmationDialogType =
| 'default'
| 'overwrite'
| 'overwriteBlueprint'
| 'delete'
| 'dirtyClose'
| 'reinstall'
@@ -107,33 +109,6 @@ export const useDialogService = () => {
})
}
function showTemplateWorkflowsDialog(
props: InstanceType<typeof TemplateWorkflowsContent>['$props'] = {}
) {
dialogStore.showDialog({
key: 'global-template-workflows',
title: t('templateWorkflows.title'),
component: TemplateWorkflowsContent,
headerComponent: TemplateWorkflowsDialogHeader,
dialogComponentProps: {
pt: {
content: { class: '!px-0 overflow-y-hidden' }
}
},
props
})
}
function showIssueReportDialog(
props: InstanceType<typeof IssueReportDialogContent>['$props']
) {
dialogStore.showDialog({
key: 'global-issue-report',
component: IssueReportDialogContent,
props
})
}
function showManagerDialog(
props: InstanceType<typeof ManagerDialogContent>['$props'] = {}
) {
@@ -150,9 +125,9 @@ export const useDialogService = () => {
'bg-gray-500 dark-theme:bg-neutral-700 w-9 h-9 p-1.5 rounded-full text-white'
}
},
header: { class: '!py-0 px-6 !m-0 h-[68px]' },
header: { class: 'py-0! px-6 m-0! h-[68px]' },
content: {
class: '!p-0 h-full w-[90vw] max-w-full flex-1 overflow-hidden'
class: 'p-0! h-full w-[90vw] max-w-full flex-1 overflow-hidden'
},
root: { class: 'manager-dialog' }
}
@@ -161,30 +136,6 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: '!p-0' },
header: { class: '!p-0 border-none' },
footer: { class: '!p-0 border-none' }
}
}
})
}
function parseError(error: Error) {
const filename =
'fileName' in error
@@ -241,6 +192,30 @@ export const useDialogService = () => {
})
}
function showManagerProgressDialog(options?: {
props?: InstanceType<typeof ManagerProgressDialogContent>['$props']
}) {
return dialogStore.showDialog({
key: 'global-manager-progress-dialog',
component: ManagerProgressDialogContent,
headerComponent: ManagerProgressHeader,
footerComponent: ManagerProgressFooter,
props: options?.props,
priority: 2,
dialogComponentProps: {
closable: false,
modal: false,
position: 'bottom',
pt: {
root: { class: 'w-[80%] max-w-2xl mx-auto border-none' },
content: { class: 'p-0!' },
header: { class: 'p-0! border-none' },
footer: { class: 'p-0! border-none' }
}
}
})
}
/**
* Shows a dialog requiring sign in for API nodes
* @returns Promise that resolves to true if user clicks login, false if cancelled
@@ -372,7 +347,7 @@ export const useDialogService = () => {
props: options,
dialogComponentProps: {
pt: {
header: { class: '!p-3' }
header: { class: 'p-3!' }
}
}
})
@@ -446,10 +421,10 @@ export const useDialogService = () => {
class: 'rounded-2xl overflow-hidden'
},
header: {
class: '!p-0 hidden'
class: 'p-0! hidden'
},
content: {
class: '!p-0 !m-0'
class: 'p-0! m-0!'
}
}
}
@@ -463,26 +438,73 @@ export const useDialogService = () => {
})
}
function showNodeConflictDialog(
options: {
showAfterWhatsNew?: boolean
conflictedPackages?: ConflictDetectionResult[]
dialogComponentProps?: DialogComponentProps
buttonText?: string
onButtonClick?: () => void
} = {}
) {
const {
dialogComponentProps,
buttonText,
onButtonClick,
showAfterWhatsNew,
conflictedPackages
} = options
return dialogStore.showDialog({
key: 'global-node-conflict',
headerComponent: NodeConflictHeader,
footerComponent: NodeConflictFooter,
component: NodeConflictDialogContent,
dialogComponentProps: {
closable: true,
pt: {
header: { class: '!p-0 !m-0' },
content: { class: '!p-0 overflow-y-hidden' },
footer: { class: '!p-0' },
pcCloseButton: {
root: {
class:
'!w-7 !h-7 !border-none !outline-none !p-2 !m-1.5 bg-gray-500 dark-theme:bg-neutral-700 text-white'
}
}
},
...dialogComponentProps
},
props: {
showAfterWhatsNew,
conflictedPackages
},
footerProps: {
buttonText,
onButtonClick
}
})
}
return {
showLoadWorkflowWarning,
showMissingModelsWarning,
showSettingsDialog,
showAboutDialog,
showExecutionErrorDialog,
showTemplateWorkflowsDialog,
showIssueReportDialog,
showManagerDialog,
showManagerProgressDialog,
showErrorDialog,
showApiNodesSignInDialog,
showSignInDialog,
showTopUpCreditsDialog,
showUpdatePasswordDialog,
showExtensionDialog,
prompt,
showErrorDialog,
confirm,
toggleManagerDialog,
toggleManagerProgressDialog,
showLayoutDialog
showLayoutDialog,
showNodeConflictDialog
}
}

View File

@@ -1,12 +1,12 @@
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { useSettingStore } from '@/platform/settings/settingStore'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { KeybindingImpl, useKeybindingStore } from '@/stores/keybindingStore'
import { useMenuItemStore } from '@/stores/menuItemStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import type { ComfyExtension } from '@/types/comfy'

View File

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

View File

@@ -12,10 +12,10 @@ import {
LGraphEventMode,
LGraphNode,
LiteGraph,
type Point,
RenderShape,
type Subgraph,
SubgraphNode,
type Vector2,
createBounds
} from '@/lib/litegraph/src/litegraph'
import type {
@@ -24,7 +24,11 @@ import type {
ISerialisableNodeOutput,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import { useSettingStore } from '@/platform/settings/settingStore'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import type {
ComfyNodeDef as ComfyNodeDefV2,
@@ -37,13 +41,10 @@ import { isComponentWidget, isDOMWidget } from '@/scripts/domWidget'
import { $el } from '@/scripts/ui'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useCanvasStore } from '@/stores/graphStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useSubgraphStore } from '@/stores/subgraphStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
isImageNode,
@@ -483,7 +484,18 @@ export const useLitegraphService = () => {
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
// Check if this is an Asset Browser button widget
const isAssetBrowserButton =
widget.type === 'button' && widget.value === 'Select model'
if (isAssetBrowserButton) {
// Preserve Asset Browser button label (don't translate)
widget.label = String(widget.value)
} else {
// Apply normal translation for other widgets
widget.label = st(nameKey, widget.label ?? inputName)
}
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
@@ -949,6 +961,25 @@ export const useLitegraphService = () => {
): LGraphNode {
options.pos ??= getCanvasCenter()
if (nodeDef.name.startsWith(useSubgraphStore().typePrefix)) {
const canvas = canvasStore.getCanvas()
const bp = useSubgraphStore().getBlueprint(nodeDef.name)
const items: object = {
nodes: bp.nodes,
subgraphs: bp.definitions?.subgraphs
}
const results = canvas._deserializeItems(items, {
position: options.pos
})
if (!results) throw new Error('Failed to add subgraph blueprint')
const node = results.nodes.values().next().value
if (!node)
throw new Error(
'Subgraph blueprint was added, but failed to resolve a subgraph Node'
)
return node
}
const node = LiteGraph.createNode(
nodeDef.name,
nodeDef.display_name,
@@ -963,7 +994,7 @@ export const useLitegraphService = () => {
return node
}
function getCanvasCenter(): Vector2 {
function getCanvasCenter(): Point {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const [x, y, w, h] = app.canvas.ds.visible_area
return [x + w / dpi / 2, y + h / dpi / 2]

View File

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

View File

@@ -1,6 +1,6 @@
import { reactive } from 'vue'
export interface CachedMedia {
interface CachedMedia {
src: string
blob?: Blob
objectUrl?: string
@@ -9,7 +9,7 @@ export interface CachedMedia {
lastAccessed: number
}
export interface MediaCacheOptions {
interface MediaCacheOptions {
maxSize?: number
maxAge?: number // in milliseconds
preloadDistance?: number // pixels from viewport
@@ -113,7 +113,7 @@ class MediaCacheService {
try {
// Fetch the media
const response = await fetch(src)
const response = await fetch(src, { cache: 'force-cache' })
if (!response.ok) {
throw new Error(`Failed to fetch: ${response.status}`)
}
@@ -194,7 +194,7 @@ class MediaCacheService {
}
// Global instance
export let mediaCacheInstance: MediaCacheService | null = null
let mediaCacheInstance: MediaCacheService | null = null
export function useMediaCache(options?: MediaCacheOptions) {
if (!mediaCacheInstance) {

View File

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

View File

@@ -3,7 +3,7 @@ import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
export class NodeHelpService {
class NodeHelpService {
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {
const nodeSource = getNodeSource(node.python_module)

View File

@@ -1,4 +1,5 @@
import { ComfyNodeDefImpl, buildNodeDefTree } from '@/stores/nodeDefStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
@@ -13,7 +14,7 @@ const DEFAULT_ICON = 'pi pi-sort'
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export class NodeOrganizationService {
class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
{
id: 'category',

View File

@@ -1,7 +1,8 @@
import { FuseSearchOptions } from 'fuse.js'
import type { FuseSearchOptions } from 'fuse.js'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { FuseFilter, FuseFilterWithValue, FuseSearch } from '@/utils/fuseUtil'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import type { FuseFilterWithValue } from '@/utils/fuseUtil'
import { FuseFilter, FuseSearch } from '@/utils/fuseUtil'
export class NodeSearchService {
public readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
@@ -34,7 +35,7 @@ export class NodeSearchService {
name: 'Input Type',
invokeSequence: 'i',
getItemOptions: (node) =>
Object.values(node.inputs).map((input) => input.type),
Object.values(node.inputs ?? []).map((input) => input.type),
fuseOptions
})

View File

@@ -16,7 +16,6 @@ import type {
SearchAttribute,
SearchNodePacksParams
} from '@/types/algoliaTypes'
import { SortableAlgoliaField } from '@/types/comfyManagerTypes'
import type { components } from '@/types/comfyRegistryTypes'
import type {
NodePackSearchProvider,
@@ -24,6 +23,7 @@ import type {
SortableField
} from '@/types/searchServiceTypes'
import { paramsToCacheKey } from '@/utils/formatUtil'
import { SortableAlgoliaField } from '@/workbench/extensions/manager/types/comfyManagerTypes'
type RegistryNodePack = components['schemas']['Node']

View File

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

View File

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

View File

@@ -1,463 +0,0 @@
import { toRaw } from 'vue'
import { t } from '@/i18n'
import { LGraph, LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph, Vector2 } from '@/lib/litegraph/src/litegraph'
import { useWorkflowThumbnail } from '@/renderer/thumbnail/composables/useWorkflowThumbnail'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import { TaskItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { appendJsonExt, generateUUID } from '@/utils/formatUtil'
import { useDialogService } from './dialogService'
import { useExtensionService } from './extensionService'
export const useWorkflowService = () => {
const settingStore = useSettingStore()
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const workflowThumbnail = useWorkflowThumbnail()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
let filename = await dialogService.prompt({
title: t('workflowService.exportWorkflow'),
message: t('workflowService.enterFilename') + ':',
defaultValue: defaultName
})
if (!filename) return null
if (!filename.toLowerCase().endsWith('.json')) {
filename += '.json'
}
return filename
}
return defaultName
}
/**
* Adds scale and offset from litegraph canvas to the workflow JSON.
* @param workflow The workflow to add the view restore data to
*/
function addViewRestore(workflow: ComfyWorkflowJSON) {
if (!settingStore.get('Comfy.EnableWorkflowViewRestore')) return
const { offset, scale } = app.canvas.ds
const [x, y] = offset
workflow.extra ??= {}
workflow.extra.ds = { scale, offset: [x, y] }
}
/**
* Export the current workflow as a JSON file
* @param filename The filename to save the workflow as
* @param promptProperty The property of the prompt to export
*/
const exportWorkflow = async (
filename: string,
promptProperty: 'workflow' | 'output'
): Promise<void> => {
const workflow = workflowStore.activeWorkflow
if (workflow?.path) {
filename = workflow.filename
}
const p = await app.graphToPrompt()
addViewRestore(p.workflow)
const json = JSON.stringify(p[promptProperty], null, 2)
const blob = new Blob([json], { type: 'application/json' })
const file = await getFilename(filename)
if (!file) return
downloadBlob(file, blob)
}
/**
* Save a workflow as a new file
* @param workflow The workflow to save
*/
const saveWorkflowAs = async (workflow: ComfyWorkflow) => {
const newFilename = await dialogService.prompt({
title: t('workflowService.saveWorkflow'),
message: t('workflowService.enterFilename') + ':',
defaultValue: workflow.filename
})
if (!newFilename) return
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const newKey = newPath.substring(ComfyWorkflow.basePath.length)
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmOverwriteTitle'),
type: 'overwrite',
message: t('sideToolbar.workflowTab.confirmOverwrite'),
itemList: [newPath]
})
if (res !== true) return
if (existingWorkflow.path === workflow.path) {
await saveWorkflow(workflow)
return
}
const deleted = await deleteWorkflow(existingWorkflow, true)
if (!deleted) return
}
if (workflow.isTemporary) {
await renameWorkflow(workflow, newPath)
await workflowStore.saveWorkflow(workflow)
} else {
// Generate new id when saving existing workflow as a new file
const id = generateUUID()
const state = JSON.parse(
JSON.stringify(workflow.activeState)
) as ComfyWorkflowJSON
state.id = id
const tempWorkflow = workflowStore.createTemporary(newKey, state)
await openWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
}
/**
* Save a workflow
* @param workflow The workflow to save
*/
const saveWorkflow = async (workflow: ComfyWorkflow) => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
await workflowStore.saveWorkflow(workflow)
}
}
/**
* Load the default workflow
*/
const loadDefaultWorkflow = async () => {
await app.loadGraphData(defaultGraph)
}
/**
* Load a blank workflow
*/
const loadBlankWorkflow = async () => {
await app.loadGraphData(blankGraph)
}
/**
* Load a workflow from a task item (queue/history)
* For history items, fetches workflow data from /history_v2/{prompt_id}
* @param task The task item to load the workflow from
*/
const loadTaskWorkflow = async (task: TaskItemImpl) => {
let workflowData = task.workflow
// History items don't include workflow data - fetch from API
if (task.isHistory) {
const promptId = task.prompt.prompt_id
if (promptId) {
workflowData = (await api.getWorkflowFromHistory(promptId)) || undefined
}
}
if (!workflowData) {
return
}
await app.loadGraphData(toRaw(workflowData))
if (task.outputs) {
const nodeOutputsStore = useNodeOutputStore()
const rawOutputs = toRaw(task.outputs)
// Set outputs by execution ID to account for outputs inside of subgraphs
for (const nodeExecutionId in rawOutputs) {
nodeOutputsStore.setNodeOutputsByExecutionId(
nodeExecutionId,
rawOutputs[nodeExecutionId]
)
}
// Invoke extension (e.g., 3D nodes) hooks to allow them to update
useExtensionService().invokeExtensions(
'onNodeOutputsUpdated',
app.nodeOutputs
)
}
}
/**
* Reload the current workflow
* This is used to refresh the node definitions update, e.g. when the locale changes.
*/
const reloadCurrentWorkflow = async () => {
const workflow = workflowStore.activeWorkflow
if (workflow) {
await openWorkflow(workflow, { force: true })
}
}
/**
* Open a workflow in the current workspace
* @param workflow The workflow to open
* @param options The options for opening the workflow
*/
const openWorkflow = async (
workflow: ComfyWorkflow,
options: { force: boolean } = { force: false }
) => {
if (workflowStore.isActive(workflow) && !options.force) return
const loadFromRemote = !workflow.isLoaded
if (loadFromRemote) {
await workflow.load()
}
await app.loadGraphData(
toRaw(workflow.activeState) as ComfyWorkflowJSON,
/* clean=*/ true,
/* restore_view=*/ true,
workflow,
{
showMissingModelsDialog: loadFromRemote,
showMissingNodesDialog: loadFromRemote,
checkForRerouteMigration: false
}
)
}
/**
* Close a workflow with confirmation if there are unsaved changes
* @param workflow The workflow to close
* @returns true if the workflow was closed, false if the user cancelled
*/
const closeWorkflow = async (
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean; hint?: string } = {
warnIfUnsaved: true
}
): Promise<boolean> => {
if (workflow.isModified && options.warnIfUnsaved) {
const confirmed = await dialogService.confirm({
title: t('sideToolbar.workflowTab.dirtyCloseTitle'),
type: 'dirtyClose',
message: t('sideToolbar.workflowTab.dirtyClose'),
itemList: [workflow.path],
hint: options.hint
})
// Cancel
if (confirmed === null) return false
if (confirmed === true) {
await saveWorkflow(workflow)
}
}
// If this is the last workflow, create a new default temporary workflow
if (workflowStore.openWorkflows.length === 1) {
await loadDefaultWorkflow()
}
// If this is the active workflow, load the next workflow
if (workflowStore.isActive(workflow)) {
await loadNextOpenedWorkflow()
}
await workflowStore.closeWorkflow(workflow)
return true
}
const renameWorkflow = async (workflow: ComfyWorkflow, newPath: string) => {
await workflowStore.renameWorkflow(workflow, newPath)
}
/**
* Delete a workflow
* @param workflow The workflow to delete
* @returns `true` if the workflow was deleted, `false` if the user cancelled
*/
const deleteWorkflow = async (
workflow: ComfyWorkflow,
silent = false
): Promise<boolean> => {
const bypassConfirm = !settingStore.get('Comfy.Workflow.ConfirmDelete')
let confirmed: boolean | null = bypassConfirm || silent
if (!confirmed) {
confirmed = await dialogService.confirm({
title: t('sideToolbar.workflowTab.confirmDeleteTitle'),
type: 'delete',
message: t('sideToolbar.workflowTab.confirmDelete'),
itemList: [workflow.path]
})
if (!confirmed) return false
}
if (workflowStore.isOpen(workflow)) {
const closed = await closeWorkflow(workflow, {
warnIfUnsaved: !confirmed
})
if (!closed) return false
}
await workflowStore.deleteWorkflow(workflow)
if (!silent) {
toastStore.add({
severity: 'info',
summary: t('sideToolbar.workflowTab.deleted'),
life: 1000
})
}
return true
}
/**
* This method is called before loading a new graph.
* There are 3 major functions that loads a new graph to the graph editor:
* 1. loadGraphData
* 2. loadApiJson
* 3. importA1111
*
* This function is used to save the current workflow states before loading
* a new graph.
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
// Capture thumbnail before loading new graph
void workflowThumbnail.storeThumbnail(activeWorkflow)
domWidgetStore.clear()
}
}
/**
* Set the active workflow after the new graph is loaded.
*
* The call relationship is
* useWorkflowService().openWorkflow -> app.loadGraphData -> useWorkflowService().afterLoadNewGraph
* app.loadApiJson -> useWorkflowService().afterLoadNewGraph
* app.importA1111 -> useWorkflowService().afterLoadNewGraph
*
* @param value The value to set as the active workflow.
* @param workflowData The initial workflow data loaded to the graph editor.
*/
const afterLoadNewGraph = async (
value: string | ComfyWorkflow | null,
workflowData: ComfyWorkflowJSON
) => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
if (typeof value === 'string') {
const workflow = workflowStore.getWorkflowByPath(
ComfyWorkflow.basePath + appendJsonExt(value)
)
if (workflow?.isPersisted) {
const loadedWorkflow = await workflowStore.openWorkflow(workflow)
loadedWorkflow.changeTracker.restore()
loadedWorkflow.changeTracker.reset(workflowData)
return
}
}
if (value === null || typeof value === 'string') {
const path = value as string | null
const tempWorkflow = workflowStore.createTemporary(
path ? appendJsonExt(path) : undefined,
workflowData
)
await workflowStore.openWorkflow(tempWorkflow)
return
}
// value is a ComfyWorkflow.
const loadedWorkflow = await workflowStore.openWorkflow(value)
loadedWorkflow.changeTracker.reset(workflowData)
loadedWorkflow.changeTracker.restore()
}
/**
* Insert the given workflow into the current graph editor.
*/
const insertWorkflow = async (
workflow: ComfyWorkflow,
options: { position?: Vector2 } = {}
) => {
const loadedWorkflow = await workflow.load()
const workflowJSON = toRaw(loadedWorkflow.initialState)
const old = localStorage.getItem('litegrapheditor_clipboard')
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
// serialisation schema.
const graph = new LGraph(workflowJSON as unknown as SerialisableGraph)
const canvasElement = document.createElement('canvas')
const canvas = new LGraphCanvas(canvasElement, graph, {
skip_events: true,
skip_render: true
})
canvas.selectItems()
canvas.copyToClipboard()
app.canvas.pasteFromClipboard(options)
if (old !== null) {
localStorage.setItem('litegrapheditor_clipboard', old)
}
}
const loadNextOpenedWorkflow = async () => {
const nextWorkflow = workflowStore.openedWorkflowIndexShift(1)
if (nextWorkflow) {
await openWorkflow(nextWorkflow)
}
}
const loadPreviousOpenedWorkflow = async () => {
const previousWorkflow = workflowStore.openedWorkflowIndexShift(-1)
if (previousWorkflow) {
await openWorkflow(previousWorkflow)
}
}
/**
* Takes an existing workflow and duplicates it with a new name
*/
const duplicateWorkflow = async (workflow: ComfyWorkflow) => {
const state = JSON.parse(JSON.stringify(workflow.activeState))
const suffix = workflow.isPersisted ? ' (Copy)' : ''
// Remove the suffix `(2)` or similar
const filename = workflow.filename.replace(/\s*\(\d+\)$/, '') + suffix
await app.loadGraphData(state, true, true, filename)
}
return {
exportWorkflow,
saveWorkflowAs,
saveWorkflow,
loadDefaultWorkflow,
loadBlankWorkflow,
loadTaskWorkflow,
reloadCurrentWorkflow,
openWorkflow,
closeWorkflow,
renameWorkflow,
deleteWorkflow,
insertWorkflow,
loadNextOpenedWorkflow,
loadPreviousOpenedWorkflow,
duplicateWorkflow,
afterLoadNewGraph,
beforeLoadNewGraph
}
}