mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-28 18:54:09 +00:00
merge main into rh-test
This commit is contained in:
@@ -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)
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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']
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user