From 688d824cac485c26892e1f7323780d97d1f25198 Mon Sep 17 00:00:00 2001 From: Adam Ladachowski Date: Sun, 15 Feb 2026 08:56:42 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=92=AC=20Commit=20message:=20Update=20202?= =?UTF-8?q?6-02-15=2008:56:42,=205=20files,=20971=20lines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 📁 Files changed: 5 📝 Lines changed: 971 • package.json • comfy-client.ts • comfy.ts • tsconfig.json • tsconfig.node.json --- web/package.json | 25 ++ web/src/lib/comfy-client.ts | 592 ++++++++++++++++++++++++++++++++++++ web/src/types/comfy.ts | 318 +++++++++++++++++++ web/tsconfig.json | 25 ++ web/tsconfig.node.json | 11 + 5 files changed, 971 insertions(+) create mode 100644 web/package.json create mode 100644 web/src/lib/comfy-client.ts create mode 100644 web/src/types/comfy.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..fbf4335 --- /dev/null +++ b/web/package.json @@ -0,0 +1,25 @@ +{ + "name": "@tensors/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/web/src/lib/comfy-client.ts b/web/src/lib/comfy-client.ts new file mode 100644 index 0000000..91e7216 --- /dev/null +++ b/web/src/lib/comfy-client.ts @@ -0,0 +1,592 @@ +/** + * ComfyUI WebSocket/REST Client + * + * A TypeScript client for interacting with the ComfyUI API. + * + * Example usage: + * const client = new ComfyClient({ baseUrl: 'http://localhost:8188' }); + * await client.connect(); + * + * client.on('progress', (data) => console.log(`Progress: ${data.value}/${data.max}`)); + * client.on('executed', (data) => console.log('Generated:', data.output)); + * + * const { prompt_id } = await client.queuePrompt(workflow); + * // Listen for events... + */ + +import type { + ComfyClientConfig, + ComfyEventHandler, + ComfyEventMap, + ComfyMessage, + HistoryEntry, + HistoryResponse, + ModelsResponse, + ObjectInfo, + PromptRequest, + PromptResponse, + QueueStatus, + SystemStats, + UploadImageResponse, + ViewImageParams, + WorkflowPrompt, +} from '../types/comfy'; + +type Listener = { + event: K; + handler: ComfyEventHandler; +}; + +export class ComfyClient { + private config: ComfyClientConfig; + private ws: WebSocket | null = null; + private listeners: Listener[] = []; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private shouldReconnect = true; + + constructor(config: ComfyClientConfig) { + this.config = { + ...config, + clientId: config.clientId ?? crypto.randomUUID(), + }; + } + + // ============================================================ + // Connection Management + // ============================================================ + + get clientId(): string { + return this.config.clientId!; + } + + get baseUrl(): string { + return this.config.baseUrl; + } + + get isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + /** + * Connect to ComfyUI WebSocket server + */ + connect(): Promise { + return new Promise((resolve, reject) => { + if (this.isConnected) { + resolve(); + return; + } + + const wsUrl = this.config.baseUrl + .replace(/^http/, 'ws') + .replace(/\/$/, ''); + + this.ws = new WebSocket(`${wsUrl}/ws?clientId=${this.clientId}`); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + this.emit('connected', undefined); + resolve(); + }; + + this.ws.onclose = () => { + this.emit('disconnected', undefined); + this.handleReconnect(); + }; + + this.ws.onerror = (event) => { + const error = new Error('WebSocket error'); + this.emit('error', error); + reject(error); + }; + + this.ws.onmessage = (event) => { + this.handleMessage(event.data); + }; + }); + } + + /** + * Disconnect from ComfyUI WebSocket server + */ + disconnect(): void { + this.shouldReconnect = false; + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private handleReconnect(): void { + if (!this.shouldReconnect) return; + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.emit('error', new Error('Max reconnection attempts reached')); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1); + + setTimeout(() => { + this.connect().catch(() => { + // Reconnect will be attempted again via onclose + }); + }, delay); + } + + private handleMessage(data: string): void { + try { + const message = JSON.parse(data) as ComfyMessage; + this.emit(message.type, message.data as ComfyEventMap[typeof message.type]); + } catch (error) { + console.error('Failed to parse WebSocket message:', error); + } + } + + // ============================================================ + // Event Handling + // ============================================================ + + on( + event: K, + handler: ComfyEventHandler + ): () => void { + const listener = { event, handler } as Listener; + this.listeners.push(listener); + + // Return unsubscribe function + return () => { + const index = this.listeners.indexOf(listener); + if (index !== -1) { + this.listeners.splice(index, 1); + } + }; + } + + off( + event: K, + handler?: ComfyEventHandler + ): void { + this.listeners = this.listeners.filter((listener) => { + if (listener.event !== event) return true; + if (handler && listener.handler !== handler) return true; + return false; + }); + } + + private emit( + event: K, + data: ComfyEventMap[K] + ): void { + for (const listener of this.listeners) { + if (listener.event === event) { + (listener.handler as ComfyEventHandler)(data); + } + } + } + + // ============================================================ + // HTTP Helpers + // ============================================================ + + private async fetch( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.config.baseUrl}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text}`); + } + + return response.json(); + } + + // ============================================================ + // System API + // ============================================================ + + /** + * Get system statistics (RAM, VRAM, versions) + */ + async getSystemStats(): Promise { + return this.fetch('/system_stats'); + } + + /** + * Get node definitions (object_info) + */ + async getObjectInfo(): Promise { + return this.fetch('/object_info'); + } + + /** + * Get specific node definition + */ + async getNodeInfo(nodeType: string): Promise { + return this.fetch(`/object_info/${encodeURIComponent(nodeType)}`); + } + + // ============================================================ + // Queue API + // ============================================================ + + /** + * Get current queue status + */ + async getQueue(): Promise { + return this.fetch('/queue'); + } + + /** + * Queue a prompt/workflow for execution + */ + async queuePrompt( + prompt: WorkflowPrompt, + options: Partial> = {} + ): Promise { + const request: PromptRequest = { + prompt, + client_id: options.client_id ?? this.clientId, + extra_data: options.extra_data, + }; + + return this.fetch('/prompt', { + method: 'POST', + body: JSON.stringify(request), + }); + } + + /** + * Delete a queued item + */ + async deleteQueueItem(deleteType: 'queue' | 'history', promptId: string): Promise { + await this.fetch(`/${deleteType}`, { + method: 'POST', + body: JSON.stringify({ delete: [promptId] }), + }); + } + + /** + * Clear the queue + */ + async clearQueue(): Promise { + await this.fetch('/queue', { + method: 'POST', + body: JSON.stringify({ clear: true }), + }); + } + + /** + * Interrupt the current generation + */ + async interrupt(): Promise { + await this.fetch('/interrupt', { + method: 'POST', + }); + } + + // ============================================================ + // History API + // ============================================================ + + /** + * Get generation history + */ + async getHistory(maxItems?: number): Promise { + const endpoint = maxItems ? `/history?max_items=${maxItems}` : '/history'; + return this.fetch(endpoint); + } + + /** + * Get a specific history entry + */ + async getHistoryEntry(promptId: string): Promise { + const history = await this.fetch( + `/history/${encodeURIComponent(promptId)}` + ); + return history[promptId]; + } + + /** + * Clear history + */ + async clearHistory(): Promise { + await this.fetch('/history', { + method: 'POST', + body: JSON.stringify({ clear: true }), + }); + } + + // ============================================================ + // Image API + // ============================================================ + + /** + * Get URL for viewing an image + */ + getImageUrl(params: ViewImageParams): string { + const searchParams = new URLSearchParams(); + searchParams.set('filename', params.filename); + if (params.subfolder) searchParams.set('subfolder', params.subfolder); + if (params.type) searchParams.set('type', params.type); + if (params.preview) searchParams.set('preview', params.preview); + if (params.channel) searchParams.set('channel', params.channel); + + return `${this.config.baseUrl}/view?${searchParams.toString()}`; + } + + /** + * Upload an image + */ + async uploadImage( + file: File | Blob, + filename: string, + options: { + overwrite?: boolean; + subfolder?: string; + type?: 'input' | 'temp'; + } = {} + ): Promise { + const formData = new FormData(); + formData.append('image', file, filename); + if (options.overwrite) formData.append('overwrite', 'true'); + if (options.subfolder) formData.append('subfolder', options.subfolder); + if (options.type) formData.append('type', options.type); + + const response = await fetch(`${this.config.baseUrl}/upload/image`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.status}`); + } + + return response.json(); + } + + // ============================================================ + // Models API (custom endpoints - may need ComfyUI-Manager) + // ============================================================ + + /** + * Get list of available models by category + * Note: Uses internal folder structure, not a standard endpoint + */ + async getModels(): Promise { + // ComfyUI doesn't have a direct /models endpoint. + // We get model lists from object_info for specific nodes + const objectInfo = await this.getObjectInfo(); + const models: ModelsResponse = {}; + + // Extract checkpoint list from CheckpointLoaderSimple + const checkpointLoader = objectInfo['CheckpointLoaderSimple']; + if (checkpointLoader?.input?.required?.['ckpt_name']) { + const input = checkpointLoader.input.required['ckpt_name']; + if (Array.isArray(input) && Array.isArray(input[0])) { + models.checkpoints = input[0] as string[]; + } + } + + // Extract LoRA list from LoraLoader + const loraLoader = objectInfo['LoraLoader']; + if (loraLoader?.input?.required?.['lora_name']) { + const input = loraLoader.input.required['lora_name']; + if (Array.isArray(input) && Array.isArray(input[0])) { + models.loras = input[0] as string[]; + } + } + + // Extract VAE list from VAELoader + const vaeLoader = objectInfo['VAELoader']; + if (vaeLoader?.input?.required?.['vae_name']) { + const input = vaeLoader.input.required['vae_name']; + if (Array.isArray(input) && Array.isArray(input[0])) { + models.vae = input[0] as string[]; + } + } + + // Extract upscaler list from UpscaleModelLoader + const upscaleLoader = objectInfo['UpscaleModelLoader']; + if (upscaleLoader?.input?.required?.['model_name']) { + const input = upscaleLoader.input.required['model_name']; + if (Array.isArray(input) && Array.isArray(input[0])) { + models.upscale_models = input[0] as string[]; + } + } + + // Extract ControlNet list from ControlNetLoader + const controlnetLoader = objectInfo['ControlNetLoader']; + if (controlnetLoader?.input?.required?.['control_net_name']) { + const input = controlnetLoader.input.required['control_net_name']; + if (Array.isArray(input) && Array.isArray(input[0])) { + models.controlnet = input[0] as string[]; + } + } + + // Extract embeddings from object_info + const clipLoader = objectInfo['CLIPTextEncode']; + if (clipLoader) { + // Embeddings are typically shown in the text field tooltips or separate endpoint + // This is a simplification - full embedding list requires different approach + } + + return models; + } + + /** + * Get list of samplers + */ + async getSamplers(): Promise { + const objectInfo = await this.getObjectInfo(); + const ksampler = objectInfo['KSampler']; + if (ksampler?.input?.required?.['sampler_name']) { + const input = ksampler.input.required['sampler_name']; + if (Array.isArray(input) && Array.isArray(input[0])) { + return input[0] as string[]; + } + } + return []; + } + + /** + * Get list of schedulers + */ + async getSchedulers(): Promise { + const objectInfo = await this.getObjectInfo(); + const ksampler = objectInfo['KSampler']; + if (ksampler?.input?.required?.['scheduler']) { + const input = ksampler.input.required['scheduler']; + if (Array.isArray(input) && Array.isArray(input[0])) { + return input[0] as string[]; + } + } + return []; + } + + // ============================================================ + // Utility Methods + // ============================================================ + + /** + * Wait for a prompt to complete + * Returns the history entry when done + */ + async waitForPrompt( + promptId: string, + options: { + onProgress?: (value: number, max: number) => void; + onExecuting?: (node: string | null) => void; + timeout?: number; + } = {} + ): Promise { + return new Promise((resolve, reject) => { + const timeout = options.timeout ?? 300000; // 5 minutes default + let timeoutId: ReturnType; + + const cleanup = () => { + clearTimeout(timeoutId); + unsubProgress(); + unsubExecuting(); + unsubExecuted(); + unsubError(); + }; + + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error(`Prompt ${promptId} timed out after ${timeout}ms`)); + }, timeout); + + const unsubProgress = this.on('progress', (data) => { + if (data.prompt_id === promptId) { + options.onProgress?.(data.value, data.max); + } + }); + + const unsubExecuting = this.on('executing', (data) => { + if (data.prompt_id === promptId) { + options.onExecuting?.(data.node); + + // When node is null, execution is complete + if (data.node === null) { + cleanup(); + this.getHistoryEntry(promptId) + .then((entry) => { + if (entry) { + resolve(entry); + } else { + reject(new Error(`No history entry for prompt ${promptId}`)); + } + }) + .catch(reject); + } + } + }); + + const unsubExecuted = this.on('executed', (data) => { + // Individual node executed - could be useful for streaming results + // but main completion is signaled by executing with node=null + }); + + const unsubError = this.on('execution_error', (data) => { + if (data.prompt_id === promptId) { + cleanup(); + reject( + new Error( + `Execution error in ${data.node_type}: ${data.exception_message}` + ) + ); + } + }); + }); + } + + /** + * Queue a prompt and wait for completion + */ + async generate( + prompt: WorkflowPrompt, + options: { + onProgress?: (value: number, max: number) => void; + onExecuting?: (node: string | null) => void; + timeout?: number; + } = {} + ): Promise { + const { prompt_id } = await this.queuePrompt(prompt); + return this.waitForPrompt(prompt_id, options); + } +} + +// ============================================================ +// Singleton / Factory +// ============================================================ + +let defaultClient: ComfyClient | null = null; + +/** + * Get or create the default ComfyUI client + */ +export function getComfyClient(config?: ComfyClientConfig): ComfyClient { + if (!defaultClient && !config) { + throw new Error('ComfyClient not initialized. Provide config on first call.'); + } + + if (config) { + defaultClient = new ComfyClient(config); + } + + return defaultClient!; +} + +export default ComfyClient; diff --git a/web/src/types/comfy.ts b/web/src/types/comfy.ts new file mode 100644 index 0000000..9bbd638 --- /dev/null +++ b/web/src/types/comfy.ts @@ -0,0 +1,318 @@ +/** + * ComfyUI API Type Definitions + */ + +// ============================================================ +// System Types +// ============================================================ + +export interface SystemStats { + system: { + os: string; + ram_total: number; + ram_free: number; + comfyui_version: string; + required_frontend_version: string; + installed_templates_version: string; + required_templates_version: string; + python_version: string; + pytorch_version: string; + embedded_python: boolean; + argv: string[]; + }; + devices: DeviceInfo[]; +} + +export interface DeviceInfo { + name: string; + type: string; + index: number; + vram_total: number; + vram_free: number; + torch_vram_total: number; + torch_vram_free: number; +} + +// ============================================================ +// Queue Types +// ============================================================ + +export interface QueueStatus { + queue_running: QueueItem[]; + queue_pending: QueueItem[]; +} + +export interface QueueItem { + prompt_id: string; + number: number; + prompt: WorkflowPrompt; + extra_data: Record; + outputs_to_execute: string[]; +} + +// ============================================================ +// Workflow / Prompt Types +// ============================================================ + +export type WorkflowPrompt = Record; + +export interface WorkflowNode { + class_type: string; + inputs: Record; + _meta?: { + title?: string; + }; +} + +export interface PromptRequest { + prompt: WorkflowPrompt; + client_id?: string; + extra_data?: { + extra_pnginfo?: Record; + }; +} + +export interface PromptResponse { + prompt_id: string; + number: number; + node_errors?: Record; +} + +export interface NodeError { + type: string; + message: string; + details: string; + extra_info: Record; +} + +// ============================================================ +// History Types +// ============================================================ + +export type HistoryResponse = Record; + +export interface HistoryEntry { + prompt: [number, string, WorkflowPrompt, Record, string[]]; + outputs: Record; + status: { + status_str: string; + completed: boolean; + messages: Array<[string, Record]>; + }; +} + +export interface NodeOutput { + images?: ImageOutput[]; + [key: string]: unknown; +} + +export interface ImageOutput { + filename: string; + subfolder: string; + type: string; +} + +// ============================================================ +// Object Info Types (Node Definitions) +// ============================================================ + +export type ObjectInfo = Record; + +export interface NodeDefinition { + input: { + required?: Record; + optional?: Record; + hidden?: Record; + }; + input_order?: { + required?: string[]; + optional?: string[]; + }; + output: string[]; + output_is_list: boolean[]; + output_name: string[]; + name: string; + display_name: string; + description: string; + python_module: string; + category: string; + output_node: boolean; + deprecated: boolean; + experimental: boolean; +} + +export type InputDefinition = + | [string, InputOptions?] // Type reference with options + | [string[], InputOptions?]; // Enum choices with options + +export interface InputOptions { + default?: unknown; + min?: number; + max?: number; + step?: number; + round?: number; + tooltip?: string; + multiline?: boolean; + dynamicPrompts?: boolean; + control_after_generate?: boolean; + forceInput?: boolean; +} + +// ============================================================ +// WebSocket Message Types +// ============================================================ + +export type ComfyMessage = + | StatusMessage + | ProgressMessage + | ExecutingMessage + | ExecutedMessage + | ExecutionStartMessage + | ExecutionCachedMessage + | ExecutionErrorMessage; + +export interface StatusMessage { + type: 'status'; + data: { + status: { + exec_info: { + queue_remaining: number; + }; + }; + sid?: string; + }; +} + +export interface ProgressMessage { + type: 'progress'; + data: { + value: number; + max: number; + prompt_id: string; + node: string; + }; +} + +export interface ExecutingMessage { + type: 'executing'; + data: { + node: string | null; + prompt_id: string; + display_node?: string; + }; +} + +export interface ExecutedMessage { + type: 'executed'; + data: { + node: string; + display_node: string; + output: NodeOutput; + prompt_id: string; + }; +} + +export interface ExecutionStartMessage { + type: 'execution_start'; + data: { + prompt_id: string; + timestamp: number; + }; +} + +export interface ExecutionCachedMessage { + type: 'execution_cached'; + data: { + nodes: string[]; + prompt_id: string; + timestamp: number; + }; +} + +export interface ExecutionErrorMessage { + type: 'execution_error'; + data: { + prompt_id: string; + node_id: string; + node_type: string; + exception_message: string; + exception_type: string; + traceback: string[]; + current_inputs?: Record; + current_outputs?: Record[]; + }; +} + +// ============================================================ +// Image View Types +// ============================================================ + +export interface ViewImageParams { + filename: string; + subfolder?: string; + type?: 'output' | 'input' | 'temp'; + preview?: string; + channel?: string; +} + +// ============================================================ +// Models Types +// ============================================================ + +export interface ModelsResponse { + checkpoints?: string[]; + loras?: string[]; + vae?: string[]; + controlnet?: string[]; + upscale_models?: string[]; + embeddings?: string[]; + hypernetworks?: string[]; + clip?: string[]; + clip_vision?: string[]; + style_models?: string[]; + diffusers?: string[]; + gligen?: string[]; + diffusion_models?: string[]; + unet?: string[]; + photomaker?: string[]; +} + +// ============================================================ +// Upload Types +// ============================================================ + +export interface UploadImageResponse { + name: string; + subfolder: string; + type: string; +} + +// ============================================================ +// Client Configuration +// ============================================================ + +export interface ComfyClientConfig { + baseUrl: string; + clientId?: string; +} + +// ============================================================ +// Event Types +// ============================================================ + +export interface ComfyEventMap { + status: StatusMessage['data']; + progress: ProgressMessage['data']; + executing: ExecutingMessage['data']; + executed: ExecutedMessage['data']; + execution_start: ExecutionStartMessage['data']; + execution_cached: ExecutionCachedMessage['data']; + execution_error: ExecutionErrorMessage['data']; + connected: undefined; + disconnected: undefined; + error: Error; +} + +export type ComfyEventHandler = ( + data: ComfyEventMap[K] +) => void; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4ee1df7 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "paths": { + "@/*": ["./src/*"] + }, + "baseUrl": "." + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +}