Files
ComfyUI_frontend/src/scripts/api.ts
Christian Byrne 66a76c0ee0 Upstream ComfyUI Manager frontend and add custom node conflict detection (#5291)
* migrate manager menu items

* Update locales [skip ci]

* switch to v2 manager API endpoints

* re-arrange menu items

* await promises. update settings schema

* move legacy option to startup arg

* Add banner indicating how to use legacy manager UI

* Update locales [skip ci]

* add "Check for Updates", "Install Missing" menu items

* Update locales [skip ci]

* use correct response shape

* improve command names

* dont show missing nodes button in legacy manager mode

* [Update to v2 API] update WS done message

* Update locales [skip ci]

* [fix] Fix json syntax error from rebase (#4607)

* Fix errors from rebase (removed `Tag` component import and duplicated imports in api.ts) (#4608)

Co-authored-by: github-actions <github-actions@github.com>

* Update locales [skip ci]

* [Manager] "Restarting" state after clicking restart button (#4637)

* [feat] Add reactive feature flags foundation (#4817)

* [feat] Add v2/ prefix to manager service base URL (#4872)

* [cleanup] Remove unused manager route enums (#4875)

* fix: v2 prefix (#5145)

* Fix: Restore api.ts from main branch after incorrect rebase (#5150)

* fix: api.ts file is different with main branch

* Update locales [skip ci]

* fix: restore support dotprop access

* fix: apply locales based on manager/menu-items-migration

* fix: Add missing shortcuts translation section for CI tests

- Added shortcuts section with keyboardShortcuts key
- Fixes failing Playwright test looking for 'Keyboard Shortcuts' aria-label
- Issue was caused by incomplete rebase from main branch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Add missing versionMismatchWarning translations for CI tests

- Added versionMismatchWarning section with all required keys
- Added general versionMismatch related keys (updateFrontend, dismiss, etc.)
- Fixes failing Playwright tests for version mismatch warnings
- These keys were lost during the rebase from main branch

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>

* feat: Add loading state to PackInstallButton and improve UI (#5153)

* [restore] conflict notification commits restore

* [fix] Restore conflict notification work and fix tests

- Fix missing footerProps property in DialogInstance interface
- Add missing InstalledPacksResponse type import in tests
- Add missing getImportFailInfoBulk method to test mock
- Remove unused ManagerComponents import causing type error
- All unit and component tests now pass successfully

* [fix] Use Vue 3.5 destructuring syntax for props with defaults

Remove deprecated withDefaults usage in NodeConflictDialogContent.vue and use destructuring with default values instead

* [feature] dual modal supported

* [fix] Fix date format in PackCard test for locale consistency

* [fix] title text modified

* [fix] Fix conflict red dot not syncing
  between components

  Resolve reactivity issue by sharing
  useStorage refs across all
  composable instances to ensure UI
  consistency.

* [fix] Add conflict detection when installed packages list updates

- Import useConflictDetection composable in comfyManagerStore
- Call performConflictDetection after refreshing installed packages list
- Ensures conflict status stays up-to-date when packages change
- Follows existing codebase patterns for composable usage

* fix: use selected target_branch for PR base in update-manager-types workflow

* [fix]  test code timeout error fixed

* [chore] Update ComfyUI-Manager API types from ComfyUI-Manager@4e6f970 (#4782)

Co-authored-by: viva-jinyi <53567196+viva-jinyi@users.noreply.github.com>

* [types] Add proper types for ImportFailInfo API endpoints (#4783)

* [fix] ci error fixed & button max-width modified

* fix: node pack card width adapted

* fix: prevent duplicate api calls & installedPacksWithVersions instead of installpackids

* feat: run conflict detection after Apply Changes

Run performConflictDetection automatically after the backend restarts from Apply Changes button to detect conflicts in newly installed packages

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: simplify PackInstallButton isInstalling state management

- Remove isInstalling prop from PackInstallButton component
- Use internal computed property with comfyManagerStore.isPackInstalling()
- Remove redundant isInstalling computations from parent components
- Fix test mocks for useConflictDetection and es-toolkit/compat
- Clean up unused imports and inject dependencies

This centralizes the installation state management in the store,
reducing code duplication and complexity across components.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: improve multi-package selection handling (#5116)

* feat: improve multi-package selection handling

- Check each package individually for conflicts in install dialog
- Show only packages with actual conflicts in warning dialog
- Hide action buttons for mixed installed/uninstalled selections
- Display dynamic status based on selected packages priority
- Deduplicate conflict information across multiple packages
- Fix PackIcon blur background opacity

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: extract multi-package logic into reusable composables

- Create usePackageSelection composable for installation state management
- Create usePackageStatus composable for status priority logic
- Refactor InfoPanelMultiItem to use new composables
- Reduce component complexity by separating business logic
- Improve code reusability across components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: directory modified

* test: add comprehensive tests for multi-package selection composables

- Add tests for usePacksSelection composable
  - Test installation status filtering
  - Test selection state determination (all/none/mixed)
  - Test dynamic status changes

- Add tests for usePacksStatus composable
  - Test import failure detection
  - Test status priority handling
  - Test integration with conflict detection store

- Fix existing test mocking issues
  - Update es-toolkit/compat mock to use async import
  - Add Pinia setup for store-dependent tests
  - Update vue-i18n mock to preserve all exports

---------

Co-authored-by: Claude <noreply@anthropic.com>

* feat: Integrate ComfyUI Manager migration with v2 API and enhanced UI

This commit integrates the previously recovered ComfyUI Manager functionality
with significant enhancements from PR #3367, including:

## Core Manager System Recovery
- **v2 API Integration**: All manager endpoints now use `/v2/manager/queue/*`
- **Task Queue System**: Complete client-side task queuing with WebSocket status
- **Service Layer**: Comprehensive manager service with all CRUD operations
- **Store Integration**: Full manager store with progress dialog support

## New Features & Enhancements
- **Reactive Feature Flags**: Foundation for dynamic feature toggling
- **Enhanced UI Components**: Improved loading states, progress tracking
- **Package Management**: Install, update, enable/disable functionality
- **Version Selection**: Support for latest/nightly package versions
- **Progress Dialogs**: Real-time installation progress with logs
- **Missing Node Detection**: Automated detection and installation prompts

## Technical Improvements
- **TypeScript Definitions**: Complete type system for manager operations
- **WebSocket Integration**: Real-time status updates via `cm-queue-status`
- **Error Handling**: Comprehensive error handling with user feedback
- **Testing**: Updated test suites for new functionality
- **Documentation**: Complete backup documentation for recovery process

## API Endpoints Restored
- `manager/queue/start` - Start task queue
- `manager/queue/status` - Get queue status
- `manager/queue/task` - Queue individual tasks
- `manager/queue/install` - Install packages
- `manager/queue/update` - Update packages
- `manager/queue/disable` - Disable packages

## Breaking Changes
- Manager API base URL changed to `/v2/`
- Updated TypeScript interfaces for manager operations
- New WebSocket message format for queue status

This restores all critical manager functionality lost during the previous
rebase while integrating the latest enhancements and maintaining compatibility
with the current main branch.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Restore correct interfaces from PR #3367

- Restore original useManagerQueue, useServerLogs, and comfyManagerService interfaces
- Restore original component implementations for ManagerProgressDialogContent and ManagerProgressHeader
- Fix all TypeScript interface compatibility issues by using original PR implementations
- Remove duplicate setting that was causing runtime errors

This fixes merge errors where interfaces were incorrectly mixed between old and new implementations.

* fix: Add missing IconTextButton import in PackUninstallButton

Component was using IconTextButton in template but missing explicit import,
causing Vue runtime warning about unresolved component.

* docs: Update backup documentation with working state backup

Added manager-migration-clean-working-backup entry documenting the working state after fixing runtime issues, ready for PR integration.

* [feat] Add manager capability feature flags

Add support for manager v4 feature flag and client UI capability:
- MANAGER_SUPPORTS_V4: Server-side flag for v4 manager support
- supports_manager_v4_ui: Client-side flag for v4 UI support

These flags enable proper capability negotiation between frontend and
backend for manager UI selection (legacy vs v4).

Also fix TypeScript errors by adding @types/lodash.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [feat] Add managerStateStore for three-state manager UI logic

- Create managerStateStore to determine manager UI state (disabled, legacy, new)
- Check command line args, feature flags, and legacy API endpoints
- Update useCoreCommands to use the new store instead of async API calls
- Initialize manager state after system stats are loaded in GraphView
- Add comprehensive tests for all manager state scenarios

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] Fix API URL prefix slash and add error handling

- Update comfyManagerService to use conditional API URL prefix based on manager v4 support
- Fix manager UI state handling in command menubar and workflow warning dialog
- Add proper manager state detection with fallback to settings panel
- Remove unused imports and variables

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [docs] Update backup documentation with PR #5063 integration status

- Document manager-migration-pr5063-integrated backup branch
- Add comprehensive recovery verification for all integrated features
- Update next steps to reflect current progress
- Document successful integration of both PR #4654 and PR #5063

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] Fix manager button visibility when manager is disabled

- Use managerStateStore instead of legacy isLegacyManager check
- Initialize manager state on component mount to detect --disable-manager
- Hide Install All Missing Custom Nodes button when manager is disabled
- Fixes issue where buttons showed even when comfyui_manager package not installed

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* [fix] Correct Install All button visibility for manager UI states

- Install All Missing Custom Nodes button only shows for NEW_UI state
- Legacy UI state only shows Open Manager button
- Disabled state shows no buttons
- Matches original PR #5063 behavior exactly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Complete manager migration with bug fixes and locale updates

- Restore proper task queue implementation with generated types
- Fix manager button visibility based on server feature flags
- Add task completion tracking with taskIdToPackId mapping
- Fix log separation with task-specific filtering
- Implement failed tab functionality with proper task partitioning
- Fix task progress status detection using actual queue state
- Add missing locale entries for all manager operations
- Remove legacy manager menu items, keep only 'Manage Extensions'
- Fix task panel expansion state and count display issues
- All TypeScript and ESLint checks pass

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: Complete manager migration with conflict detection integration

This completes the integration of ComfyUI Manager migration features with enhanced conflict detection system. Key changes include:

## Manager Migration & Conflict Detection
- Integrated PR #4637 (4-state manager restart workflow) with PR #4654 (comprehensive conflict detection)
- Fixed conflict detection to properly check `latest_version` fields for registry API compatibility
- Added conflict detection to PackCardFooter and InfoPanelHeader for comprehensive warning coverage
- Merged missing English locale translations from main branch with proper conflict resolution

## Bug Fixes
- Fixed double API path issue (`/api/v2/v2/`) in manager service routes
- Corrected PackUpdateButton payload structure and service method calls
- Enhanced conflict detection system to handle both installed and registry package structures

## Technical Improvements
- Updated conflict detection composable to handle both installed and registry package structures
- Enhanced manager service with proper error handling and route corrections
- Improved type safety across manager components with proper TypeScript definitions

* Remove temporary error log files from commits

* Remove temporary documentation files

- Remove MANAGER_MIGRATION_BACKUPS.md (temporary notes)
- Remove TASK_QUEUE_RESTORATION_PLAN.md (temporary notes)

These were development artifacts and shouldn't be in commits.

* feat: Complete manager migration cleanup and integration

- Remove outdated legacy manager detection from LoadWorkflowWarning
- Update InfoPanelHeader with conflict detection improvements
- Fix all failing unit tests from state management transition
- Clean up algolia search provider type mappings
- Remove unused @ts-expect-error directives
- Add .nx to .gitignore

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Update CustomNodesManager command to use tri-state manager system

Replace legacy isLegacyManagerUI() call with new ManagerUIState system:
- Use useManagerStateStore().managerUIState instead of async API call
- Handle DISABLED state by showing settings dialog
- Handle LEGACY_UI state with fallback to new UI on error
- Handle NEW_UI state by showing manager dialog
- Remove unused useComfyManagerService import

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: Remove no-op refreshTaskState function

- Remove unused refreshTaskState function from useManagerQueue
- Function was left as no-op only to make tests pass
- Since queue is now push-based (WebSocket), no need to refresh state
- Clean up export and remove extra blank lines

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: Replace lodash with es-toolkit/compat in useManagerQueue

Replace lodash import with es-toolkit/compat to match project standards:
- Change 'lodash' import to 'es-toolkit/compat' for pickBy function
- Add specific type helper for history task filtering
- Update JSDoc comment to remove lodash reference
- Fixes component test failures from missing lodash dependency

* fix: Add missing whats-new-dismissed event emission in WhatsNewPopup

During merge with main, the event emission was lost from the hide() function.
- Add defineEmits for 'whats-new-dismissed' event
- Emit event in hide() function to maintain test compatibility
- Fixes 3 failing unit tests in WhatsNewPopup.test.ts

* ci: Force CI run for Playwright tests

Previous commits contained [skip ci] which prevented test execution.
This empty commit ensures all CI checks run properly.

* test: Temporarily disable workflow.avif test due to missing nodes dialog

The workflow.avif test asset contains custom nodes that trigger the missing
nodes dialog, which is outside the scope of AVIF loading functionality testing.

TODO: Update test asset to use core nodes only, then re-enable the test.

---------

Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Jin Yi <jin12cc@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Comfy Org PR Bot <snomiao+comfy-pr@gmail.com>
Co-authored-by: viva-jinyi <53567196+viva-jinyi@users.noreply.github.com>
2025-09-02 19:14:36 -07:00

1113 lines
32 KiB
TypeScript

import axios from 'axios'
import { get } from 'es-toolkit/compat'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
import type {
DisplayComponentWsMessage,
EmbeddingsResponse,
ExecutedWsMessage,
ExecutingWsMessage,
ExecutionCachedWsMessage,
ExecutionErrorWsMessage,
ExecutionInterruptedWsMessage,
ExecutionStartWsMessage,
ExecutionSuccessWsMessage,
ExtensionsResponse,
FeatureFlagsWsMessage,
HistoryTaskItem,
LogsRawResponse,
LogsWsMessage,
PendingTaskItem,
ProgressStateWsMessage,
ProgressTextWsMessage,
ProgressWsMessage,
PromptResponse,
RunningTaskItem,
Settings,
StatusWsMessage,
StatusWsMessageStatus,
SystemStats,
User,
UserDataFullInfo
} from '@/schemas/apiSchema'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON,
NodeId
} from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import { useToastStore } from '@/stores/toastStore'
import type { NodeExecutionId } from '@/types/nodeIdentification'
import { WorkflowTemplates } from '@/types/workflowTemplateTypes'
interface QueuePromptRequestBody {
client_id: string
prompt: ComfyApiWorkflow
partial_execution_targets?: NodeExecutionId[]
extra_data: {
extra_pnginfo: {
workflow: ComfyWorkflowJSON
}
/**
* The auth token for the comfy org account if the user is logged in.
*
* Backend node can access this token by specifying following input:
* ```python
@classmethod
def INPUT_TYPES(s):
return {
"hidden": { "auth_token": "AUTH_TOKEN_COMFY_ORG"}
}
def execute(self, auth_token: str):
print(f"Auth token: {auth_token}")
* ```
*/
auth_token_comfy_org?: string
/**
* The auth token for the comfy org account if the user is logged in.
*
* Backend node can access this token by specifying following input:
* ```python
* def INPUT_TYPES(s):
* return {
* "hidden": { "api_key": "API_KEY_COMFY_ORG" }
* }
*
* def execute(self, api_key: str):
* print(f"API Key: {api_key}")
* ```
*/
api_key_comfy_org?: string
}
front?: boolean
number?: number
}
/**
* Options for queuePrompt method
*/
interface QueuePromptOptions {
/**
* Optional list of node execution IDs to execute (partial execution).
* Each ID represents a node's position in nested subgraphs.
* Format: Colon-separated path of node IDs (e.g., "123:456:789")
*/
partialExecutionTargets?: NodeExecutionId[]
}
/** Dictionary of Frontend-generated API calls */
interface FrontendApiCalls {
graphChanged: ComfyWorkflowJSON
promptQueued: { number: number; batchCount: number }
graphCleared: never
reconnecting: never
reconnected: never
}
/** Dictionary of calls originating from ComfyUI core */
interface BackendApiCalls {
progress: ProgressWsMessage
executing: ExecutingWsMessage
executed: ExecutedWsMessage
status: StatusWsMessage
execution_start: ExecutionStartWsMessage
execution_success: ExecutionSuccessWsMessage
execution_error: ExecutionErrorWsMessage
execution_interrupted: ExecutionInterruptedWsMessage
execution_cached: ExecutionCachedWsMessage
logs: LogsWsMessage
/** Binary preview/progress data */
b_preview: Blob
/** Binary preview with metadata (node_id, prompt_id) */
b_preview_with_metadata: {
blob: Blob
nodeId: string
parentNodeId: string
displayNodeId: string
realNodeId: string
promptId: string
}
progress_text: ProgressTextWsMessage
progress_state: ProgressStateWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}
/** Dictionary of all api calls */
interface ApiCalls extends BackendApiCalls, FrontendApiCalls {}
/** Used to create a discriminating union on type value. */
interface ApiMessage<T extends keyof ApiCalls> {
type: T
data: ApiCalls[T]
}
export class UnauthorizedError extends Error {}
/** Ensures workers get a fair shake. */
type Unionize<T> = T[keyof T]
/**
* Discriminated union of generic, i.e.:
* ```ts
* // Convert
* type ApiMessageUnion = ApiMessage<'status' | 'executing' | ...>
* // To
* type ApiMessageUnion = ApiMessage<'status'> | ApiMessage<'executing'> | ...
* ```
*/
type ApiMessageUnion = Unionize<{
[Key in keyof ApiCalls]: ApiMessage<Key>
}>
/** Wraps all properties in {@link CustomEvent}. */
type AsCustomEvents<T> = {
readonly [K in keyof T]: CustomEvent<T[K]>
}
/** Handles differing event and API signatures. */
type ApiToEventType<T = ApiCalls> = {
[K in keyof T]: K extends 'status'
? StatusWsMessageStatus
: K extends 'executing'
? NodeId
: T[K]
}
/** Dictionary of types used in the detail for a custom event */
type ApiEventTypes = ApiToEventType<ApiCalls>
/** Dictionary of API events: `[name]: CustomEvent<Type>` */
type ApiEvents = AsCustomEvents<ApiEventTypes>
/** {@link Omit} all properties that evaluate to `never`. */
type NeverNever<T> = {
[K in keyof T as T[K] extends never ? never : K]: T[K]
}
/** {@link Pick} only properties that evaluate to `never`. */
type PickNevers<T> = {
[K in keyof T as T[K] extends never ? K : never]: T[K]
}
/** Keys (names) of API events that _do not_ pass a {@link CustomEvent} `detail` object. */
type SimpleApiEvents = keyof PickNevers<ApiEventTypes>
/** Keys (names) of API events that pass a {@link CustomEvent} `detail` object. */
type ComplexApiEvents = keyof NeverNever<ApiEventTypes>
/** EventTarget typing has no generic capability. */
export interface ComfyApi extends EventTarget {
addEventListener<TEvent extends keyof ApiEvents>(
type: TEvent,
callback: ((event: ApiEvents[TEvent]) => void) | null,
options?: AddEventListenerOptions | boolean
): void
removeEventListener<TEvent extends keyof ApiEvents>(
type: TEvent,
callback: ((event: ApiEvents[TEvent]) => void) | null,
options?: EventListenerOptions | boolean
): void
}
export class PromptExecutionError extends Error {
response: PromptResponse
constructor(response: PromptResponse) {
super('Prompt execution failed')
this.response = response
}
override toString() {
let message = ''
if (typeof this.response.error === 'string') {
message += this.response.error
} else if (this.response.error) {
message +=
this.response.error.message + ': ' + this.response.error.details
}
for (const [_, nodeError] of Object.entries(
this.response.node_errors ?? []
)) {
message += '\n' + nodeError.class_type + ':'
for (const errorReason of nodeError.errors) {
message += '\n - ' + errorReason.message + ': ' + errorReason.details
}
}
return message
}
}
export class ComfyApi extends EventTarget {
#registered = new Set()
api_host: string
api_base: string
/**
* The client id from the initial session storage.
*/
initialClientId: string | null
/**
* The current client id from websocket status updates.
*/
clientId?: string
/**
* The current user id.
*/
user: string
socket: WebSocket | null = null
reportedUnknownMessageTypes = new Set<string>()
/**
* Get feature flags supported by this frontend client.
* Returns a copy to prevent external modification.
*/
getClientFeatureFlags(): Record<string, unknown> {
return { ...defaultClientFeatureFlags }
}
/**
* Feature flags received from the backend server.
*/
serverFeatureFlags: Record<string, unknown> = {}
/**
* The auth token for the comfy org account if the user is logged in.
* This is only used for {@link queuePrompt} now. It is not directly
* passed as parameter to the function because some custom nodes are hijacking
* {@link queuePrompt} improperly, which causes extra parameters to be lost
* in the function call chain.
*
* Ref: https://cs.comfy.org/search?q=context:global+%22api.queuePrompt+%3D%22&patternType=keyword&sm=0
*
* TODO: Move this field to parameter of {@link queuePrompt} once all
* custom nodes are patched.
*/
authToken?: string
/**
* The API key for the comfy org account if the user logged in via API key.
*/
apiKey?: string
constructor() {
super()
this.user = ''
this.api_host = location.host
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
console.log('Running on', this.api_host)
this.initialClientId = sessionStorage.getItem('clientId')
}
internalURL(route: string): string {
return this.api_base + '/internal' + route
}
apiURL(route: string): string {
return this.api_base + '/api' + route
}
fileURL(route: string): string {
return this.api_base + route
}
fetchApi(route: string, options?: RequestInit) {
if (!options) {
options = {}
}
if (!options.headers) {
options.headers = {}
}
if (!options.cache) {
options.cache = 'no-cache'
}
if (Array.isArray(options.headers)) {
options.headers.push(['Comfy-User', this.user])
} else if (options.headers instanceof Headers) {
options.headers.set('Comfy-User', this.user)
} else {
options.headers['Comfy-User'] = this.user
}
return fetch(this.apiURL(route), options)
}
override addEventListener<TEvent extends keyof ApiEvents>(
type: TEvent,
callback: ((event: ApiEvents[TEvent]) => void) | null,
options?: AddEventListenerOptions | boolean
) {
// Type assertion: strictFunctionTypes. So long as we emit events in a type-safe fashion, this is safe.
super.addEventListener(type, callback as EventListener, options)
this.#registered.add(type)
}
override removeEventListener<TEvent extends keyof ApiEvents>(
type: TEvent,
callback: ((event: ApiEvents[TEvent]) => void) | null,
options?: EventListenerOptions | boolean
): void {
super.removeEventListener(type, callback as EventListener, options)
}
/**
* Dispatches a custom event.
* Provides type safety for the contravariance issue with EventTarget (last checked TS 5.6).
* @param type The type of event to emit
* @param detail The detail property used for a custom event ({@link CustomEventInit.detail})
*/
dispatchCustomEvent<T extends SimpleApiEvents>(type: T): boolean
dispatchCustomEvent<T extends ComplexApiEvents>(
type: T,
detail: ApiEventTypes[T] | null
): boolean
dispatchCustomEvent<T extends keyof ApiEventTypes>(
type: T,
detail?: ApiEventTypes[T]
): boolean {
const event =
detail === undefined
? new CustomEvent(type)
: new CustomEvent(type, { detail })
return super.dispatchEvent(event)
}
/** @deprecated Use {@link dispatchCustomEvent}. */
override dispatchEvent(event: never): boolean {
return super.dispatchEvent(event)
}
/**
* Poll status for colab and other things that don't support websockets.
*/
#pollQueue() {
setInterval(async () => {
try {
const resp = await this.fetchApi('/prompt')
const status = (await resp.json()) as StatusWsMessageStatus
this.dispatchCustomEvent('status', status)
} catch (error) {
this.dispatchCustomEvent('status', null)
}
}, 1000)
}
/**
* Creates and connects a WebSocket for realtime updates
* @param {boolean} isReconnect If the socket is connection is a reconnect attempt
*/
#createSocket(isReconnect?: boolean) {
if (this.socket) {
return
}
let opened = false
let existingSession = window.name
if (existingSession) {
existingSession = '?clientId=' + existingSession
}
this.socket = new WebSocket(
`ws${window.location.protocol === 'https:' ? 's' : ''}://${this.api_host}${this.api_base}/ws${existingSession}`
)
this.socket.binaryType = 'arraybuffer'
this.socket.addEventListener('open', () => {
opened = true
// Send feature flags as the first message
this.socket!.send(
JSON.stringify({
type: 'feature_flags',
data: this.getClientFeatureFlags()
})
)
if (isReconnect) {
this.dispatchCustomEvent('reconnected')
}
})
this.socket.addEventListener('error', () => {
if (this.socket) this.socket.close()
if (!isReconnect && !opened) {
this.#pollQueue()
}
})
this.socket.addEventListener('close', () => {
setTimeout(() => {
this.socket = null
this.#createSocket(true)
}, 300)
if (opened) {
this.dispatchCustomEvent('status', null)
this.dispatchCustomEvent('reconnecting')
}
})
this.socket.addEventListener('message', (event) => {
try {
if (event.data instanceof ArrayBuffer) {
const view = new DataView(event.data)
const eventType = view.getUint32(0)
let imageMime
switch (eventType) {
case 3:
const decoder = new TextDecoder()
const data = event.data.slice(4)
const nodeIdLength = view.getUint32(4)
this.dispatchCustomEvent('progress_text', {
nodeId: decoder.decode(data.slice(4, 4 + nodeIdLength)),
text: decoder.decode(data.slice(4 + nodeIdLength))
})
break
case 1:
const imageType = view.getUint32(4)
const imageData = event.data.slice(8)
switch (imageType) {
case 2:
imageMime = 'image/png'
break
case 1:
default:
imageMime = 'image/jpeg'
break
}
const imageBlob = new Blob([imageData], {
type: imageMime
})
this.dispatchCustomEvent('b_preview', imageBlob)
break
case 4:
// PREVIEW_IMAGE_WITH_METADATA
const decoder4 = new TextDecoder()
const metadataLength = view.getUint32(4)
const metadataBytes = event.data.slice(8, 8 + metadataLength)
const metadata = JSON.parse(decoder4.decode(metadataBytes))
const imageData4 = event.data.slice(8 + metadataLength)
let imageMime4 = metadata.image_type
const imageBlob4 = new Blob([imageData4], {
type: imageMime4
})
// Dispatch enhanced preview event with metadata
this.dispatchCustomEvent('b_preview_with_metadata', {
blob: imageBlob4,
nodeId: metadata.node_id,
displayNodeId: metadata.display_node_id,
parentNodeId: metadata.parent_node_id,
realNodeId: metadata.real_node_id,
promptId: metadata.prompt_id
})
// Also dispatch legacy b_preview for backward compatibility
this.dispatchCustomEvent('b_preview', imageBlob4)
break
default:
throw new Error(
`Unknown binary websocket message of type ${eventType}`
)
}
} else {
const msg = JSON.parse(event.data) as ApiMessageUnion
switch (msg.type) {
case 'status':
if (msg.data.sid) {
const clientId = msg.data.sid
this.clientId = clientId
window.name = clientId // use window name so it isnt reused when duplicating tabs
sessionStorage.setItem('clientId', clientId) // store in session storage so duplicate tab can load correct workflow
}
this.dispatchCustomEvent('status', msg.data.status ?? null)
break
case 'executing':
this.dispatchCustomEvent(
'executing',
msg.data.display_node || msg.data.node
)
break
case 'execution_start':
case 'execution_error':
case 'execution_interrupted':
case 'execution_cached':
case 'execution_success':
case 'progress':
case 'progress_state':
case 'executed':
case 'graphChanged':
case 'promptQueued':
case 'logs':
case 'b_preview':
this.dispatchCustomEvent(msg.type, msg.data)
break
case 'feature_flags':
// Store server feature flags
this.serverFeatureFlags = msg.data
console.log(
'Server feature flags received:',
this.serverFeatureFlags
)
break
default:
if (this.#registered.has(msg.type)) {
// Fallback for custom types - calls super direct.
super.dispatchEvent(
new CustomEvent(msg.type, { detail: msg.data })
)
} else if (!this.reportedUnknownMessageTypes.has(msg.type)) {
this.reportedUnknownMessageTypes.add(msg.type)
throw new Error(`Unknown message type ${msg.type}`)
}
}
}
} catch (error) {
console.warn('Unhandled message:', event.data, error)
}
})
}
/**
* Initialises sockets and realtime updates
*/
init() {
this.#createSocket()
}
/**
* Gets a list of extension urls
*/
async getExtensions(): Promise<ExtensionsResponse> {
const resp = await this.fetchApi('/extensions', { cache: 'no-store' })
return await resp.json()
}
/**
* Gets the available workflow templates from custom nodes.
* @returns A map of custom_node names and associated template workflow names.
*/
async getWorkflowTemplates(): Promise<{
[customNodesName: string]: string[]
}> {
const res = await this.fetchApi('/workflow_templates')
return await res.json()
}
/**
* Gets the index of core workflow templates.
*/
async getCoreWorkflowTemplates(): Promise<WorkflowTemplates[]> {
const res = await axios.get(this.fileURL('/templates/index.json'))
const contentType = res.headers['content-type']
return contentType?.includes('application/json') ? res.data : []
}
/**
* Gets a list of embedding names
*/
async getEmbeddings(): Promise<EmbeddingsResponse> {
const resp = await this.fetchApi('/embeddings', { cache: 'no-store' })
return await resp.json()
}
/**
* Loads node object definitions for the graph
* @returns The node definitions
*/
async getNodeDefs(): Promise<Record<string, ComfyNodeDef>> {
const resp = await this.fetchApi('/object_info', { cache: 'no-store' })
return await resp.json()
}
/**
* Queues a prompt to be executed
* @param {number} number The index at which to queue the prompt, passing -1 will insert the prompt at the front of the queue
* @param {object} data The prompt data to queue
* @param {QueuePromptOptions} options Optional execution options
* @throws {PromptExecutionError} If the prompt fails to execute
*/
async queuePrompt(
number: number,
data: { output: ComfyApiWorkflow; workflow: ComfyWorkflowJSON },
options?: QueuePromptOptions
): Promise<PromptResponse> {
const { output: prompt, workflow } = data
const body: QueuePromptRequestBody = {
client_id: this.clientId ?? '', // TODO: Unify clientId access
prompt,
...(options?.partialExecutionTargets && {
partial_execution_targets: options.partialExecutionTargets
}),
extra_data: {
auth_token_comfy_org: this.authToken,
api_key_comfy_org: this.apiKey,
extra_pnginfo: { workflow }
}
}
if (number === -1) {
body.front = true
} else if (number != 0) {
body.number = number
}
const res = await this.fetchApi('/prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
})
if (res.status !== 200) {
throw new PromptExecutionError(await res.json())
}
return await res.json()
}
/**
* Gets a list of model folder keys (eg ['checkpoints', 'loras', ...])
* @returns The list of model folder keys
*/
async getModelFolders(): Promise<{ name: string; folders: string[] }[]> {
const res = await this.fetchApi(`/experiment/models`)
if (res.status === 404) {
return []
}
const folderBlacklist = ['configs', 'custom_nodes']
return (await res.json()).filter(
(folder: string) => !folderBlacklist.includes(folder)
)
}
/**
* Gets a list of models in the specified folder
* @param {string} folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async getModels(
folder: string
): Promise<{ name: string; pathIndex: number }[]> {
const res = await this.fetchApi(`/experiment/models/${folder}`)
if (res.status === 404) {
return []
}
return await res.json()
}
/**
* Gets the metadata for a model
* @param {string} folder The folder containing the model
* @param {string} model The model to get metadata for
* @returns The metadata for the model
*/
async viewMetadata(folder: string, model: string) {
const res = await this.fetchApi(
`/view_metadata/${folder}?filename=${encodeURIComponent(model)}`
)
const rawResponse = await res.text()
if (!rawResponse) {
return null
}
try {
return JSON.parse(rawResponse)
} catch (error) {
console.error(
'Error viewing metadata',
res.status,
res.statusText,
rawResponse,
error
)
return null
}
}
/**
* Loads a list of items (queue or history)
* @param {string} type The type of items to load, queue or history
* @returns The items of the specified type grouped by their status
*/
async getItems(type: 'queue' | 'history') {
if (type === 'queue') {
return this.getQueue()
}
return this.getHistory()
}
/**
* Gets the current state of the queue
* @returns The currently running and queued items
*/
async getQueue(): Promise<{
Running: RunningTaskItem[]
Pending: PendingTaskItem[]
}> {
try {
const res = await this.fetchApi('/queue')
const data = await res.json()
return {
// Running action uses a different endpoint for cancelling
Running: data.queue_running.map((prompt: Record<number, any>) => ({
taskType: 'Running',
prompt,
// prompt[1] is the prompt id
remove: { name: 'Cancel', cb: () => api.interrupt(prompt[1]) }
})),
Pending: data.queue_pending.map((prompt: Record<number, any>) => ({
taskType: 'Pending',
prompt
}))
}
} catch (error) {
console.error(error)
return { Running: [], Pending: [] }
}
}
/**
* Gets the prompt execution history
* @returns Prompt history including node outputs
*/
async getHistory(
max_items: number = 200
): Promise<{ History: HistoryTaskItem[] }> {
try {
const res = await this.fetchApi(`/history?max_items=${max_items}`)
const json: Promise<HistoryTaskItem[]> = await res.json()
return {
History: Object.values(json).map((item) => ({
...item,
taskType: 'History'
}))
}
} catch (error) {
console.error(error)
return { History: [] }
}
}
/**
* Gets system & device stats
* @returns System stats such as python version, OS, per device info
*/
async getSystemStats(): Promise<SystemStats> {
const res = await this.fetchApi('/system_stats')
return await res.json()
}
/**
* Sends a POST request to the API
* @param {*} type The endpoint to post to
* @param {*} body Optional POST data
*/
async #postItem(type: string, body: any) {
try {
await this.fetchApi('/' + type, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : undefined
})
} catch (error) {
console.error(error)
}
}
/**
* Deletes an item from the specified list
* @param {string} type The type of item to delete, queue or history
* @param {number} id The id of the item to delete
*/
async deleteItem(type: string, id: string) {
await this.#postItem(type, { delete: [id] })
}
/**
* Clears the specified list
* @param {string} type The type of list to clear, queue or history
*/
async clearItems(type: string) {
await this.#postItem(type, { clear: true })
}
/**
* Interrupts the execution of the running prompt. If runningPromptId is provided,
* it is included in the payload as a helpful hint to the backend.
* @param {string | null} [runningPromptId] Optional Running Prompt ID to interrupt
*/
async interrupt(runningPromptId: string | null) {
await this.#postItem(
'interrupt',
runningPromptId ? { prompt_id: runningPromptId } : undefined
)
}
/**
* Gets user configuration data and where data should be stored
*/
async getUserConfig(): Promise<User> {
return (await this.fetchApi('/users')).json()
}
/**
* Creates a new user
* @param { string } username
* @returns The fetch response
*/
createUser(username: string) {
return this.fetchApi('/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username })
})
}
/**
* Gets all setting values for the current user
* @returns { Promise<string, unknown> } A dictionary of id -> value
*/
async getSettings(): Promise<Settings> {
const resp = await this.fetchApi('/settings')
if (resp.status == 401) {
throw new UnauthorizedError(resp.statusText)
}
return await resp.json()
}
/**
* Gets a setting for the current user
* @param { string } id The id of the setting to fetch
* @returns { Promise<unknown> } The setting value
*/
async getSetting(id: keyof Settings): Promise<Settings[keyof Settings]> {
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json()
}
/**
* Stores a dictionary of settings for the current user
*/
async storeSettings(settings: Settings) {
return this.fetchApi(`/settings`, {
method: 'POST',
body: JSON.stringify(settings)
})
}
/**
* Stores a setting for the current user
*/
async storeSetting(id: keyof Settings, value: Settings[keyof Settings]) {
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, {
method: 'POST',
body: JSON.stringify(value)
})
}
/**
* Gets a user data file for the current user
*/
async getUserData(file: string, options?: RequestInit) {
return this.fetchApi(`/userdata/${encodeURIComponent(file)}`, options)
}
/**
* Stores a user data file for the current user
* @param { string } file The name of the userdata file to save
* @param { unknown } data The data to save to the file
* @param { RequestInit & { stringify?: boolean, throwOnError?: boolean } } [options]
* @returns { Promise<Response> }
*/
async storeUserData(
file: string,
data: any,
options: RequestInit & {
overwrite?: boolean
stringify?: boolean
throwOnError?: boolean
full_info?: boolean
} = {
overwrite: true,
stringify: true,
throwOnError: true,
full_info: false
}
): Promise<Response> {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(file)}?overwrite=${options.overwrite}&full_info=${options.full_info}`,
{
method: 'POST',
body: options?.stringify ? JSON.stringify(data) : data,
...options
}
)
if (resp.status !== 200 && options.throwOnError !== false) {
throw new Error(
`Error storing user data file '${file}': ${resp.status} ${(await resp).statusText}`
)
}
return resp
}
/**
* Deletes a user data file for the current user
* @param { string } file The name of the userdata file to delete
*/
async deleteUserData(file: string) {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
method: 'DELETE'
})
return resp
}
/**
* Move a user data file for the current user
* @param { string } source The userdata file to move
* @param { string } dest The destination for the file
*/
async moveUserData(
source: string,
dest: string,
options = { overwrite: false }
) {
const resp = await this.fetchApi(
`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`,
{
method: 'POST'
}
)
return resp
}
async listUserDataFullInfo(dir: string): Promise<UserDataFullInfo[]> {
const resp = await this.fetchApi(
`/userdata?dir=${encodeURIComponent(dir)}&recurse=true&split=false&full_info=true`
)
if (resp.status === 404) return []
if (resp.status !== 200) {
throw new Error(
`Error getting user data list '${dir}': ${resp.status} ${resp.statusText}`
)
}
return resp.json()
}
async getLogs(): Promise<string> {
return (await axios.get(this.internalURL('/logs'))).data
}
async getRawLogs(): Promise<LogsRawResponse> {
return (await axios.get(this.internalURL('/logs/raw'))).data
}
async subscribeLogs(enabled: boolean): Promise<void> {
return await axios.patch(this.internalURL('/logs/subscribe'), {
enabled,
clientId: this.clientId
})
}
async getFolderPaths(): Promise<Record<string, string[]>> {
return (await axios.get(this.internalURL('/folder_paths'))).data
}
/* Frees memory by unloading models and optionally freeing execution cache
* @param {Object} options - The options object
* @param {boolean} options.freeExecutionCache - If true, also frees execution cache
*/
async freeMemory(options: { freeExecutionCache: boolean }) {
try {
let mode = ''
if (options.freeExecutionCache) {
mode = '{"unload_models": true, "free_memory": true}'
} else {
mode = '{"unload_models": true}'
}
const res = await this.fetchApi(`/free`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: mode
})
if (res.status === 200) {
if (options.freeExecutionCache) {
useToastStore().add({
severity: 'success',
summary: 'Models and Execution Cache have been cleared.',
life: 3000
})
} else {
useToastStore().add({
severity: 'success',
summary: 'Models have been unloaded.',
life: 3000
})
}
} else {
useToastStore().add({
severity: 'error',
summary:
'Unloading of models failed. Installed ComfyUI may be an outdated version.',
life: 5000
})
}
} catch (error) {
useToastStore().add({
severity: 'error',
summary: 'An error occurred while trying to unload models.',
life: 5000
})
}
}
/**
* Gets the custom nodes i18n data from the server.
*
* @returns The custom nodes i18n data
*/
async getCustomNodesI18n(): Promise<Record<string, any>> {
return (await axios.get(this.apiURL('/i18n'))).data
}
/**
* Checks if the server supports a specific feature.
* @param featureName The name of the feature to check (supports dot notation for nested values)
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
return get(this.serverFeatureFlags, featureName) === true
}
/**
* Gets a server feature flag value.
* @param featureName The name of the feature to get (supports dot notation for nested values)
* @param defaultValue The default value if the feature is not found
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
return get(this.serverFeatureFlags, featureName, defaultValue) as T
}
/**
* Gets all server feature flags.
* @returns Copy of all server feature flags
*/
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags }
}
}
export const api = new ComfyApi()