💬 Commit message: Update 2026-02-15 08:56:42, 5 files, 971 lines
📁 Files changed: 5 📝 Lines changed: 971 • package.json • comfy-client.ts • comfy.ts • tsconfig.json • tsconfig.node.json
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<K extends keyof ComfyEventMap> = {
|
||||||
|
event: K;
|
||||||
|
handler: ComfyEventHandler<K>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ComfyClient {
|
||||||
|
private config: ComfyClientConfig;
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private listeners: Listener<keyof ComfyEventMap>[] = [];
|
||||||
|
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<void> {
|
||||||
|
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<K extends keyof ComfyEventMap>(
|
||||||
|
event: K,
|
||||||
|
handler: ComfyEventHandler<K>
|
||||||
|
): () => void {
|
||||||
|
const listener = { event, handler } as Listener<keyof ComfyEventMap>;
|
||||||
|
this.listeners.push(listener);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
const index = this.listeners.indexOf(listener);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends keyof ComfyEventMap>(
|
||||||
|
event: K,
|
||||||
|
handler?: ComfyEventHandler<K>
|
||||||
|
): void {
|
||||||
|
this.listeners = this.listeners.filter((listener) => {
|
||||||
|
if (listener.event !== event) return true;
|
||||||
|
if (handler && listener.handler !== handler) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof ComfyEventMap>(
|
||||||
|
event: K,
|
||||||
|
data: ComfyEventMap[K]
|
||||||
|
): void {
|
||||||
|
for (const listener of this.listeners) {
|
||||||
|
if (listener.event === event) {
|
||||||
|
(listener.handler as ComfyEventHandler<K>)(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HTTP Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
private async fetch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
options: RequestInit = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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<SystemStats> {
|
||||||
|
return this.fetch<SystemStats>('/system_stats');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get node definitions (object_info)
|
||||||
|
*/
|
||||||
|
async getObjectInfo(): Promise<ObjectInfo> {
|
||||||
|
return this.fetch<ObjectInfo>('/object_info');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get specific node definition
|
||||||
|
*/
|
||||||
|
async getNodeInfo(nodeType: string): Promise<ObjectInfo> {
|
||||||
|
return this.fetch<ObjectInfo>(`/object_info/${encodeURIComponent(nodeType)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Queue API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current queue status
|
||||||
|
*/
|
||||||
|
async getQueue(): Promise<QueueStatus> {
|
||||||
|
return this.fetch<QueueStatus>('/queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queue a prompt/workflow for execution
|
||||||
|
*/
|
||||||
|
async queuePrompt(
|
||||||
|
prompt: WorkflowPrompt,
|
||||||
|
options: Partial<Omit<PromptRequest, 'prompt'>> = {}
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
const request: PromptRequest = {
|
||||||
|
prompt,
|
||||||
|
client_id: options.client_id ?? this.clientId,
|
||||||
|
extra_data: options.extra_data,
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.fetch<PromptResponse>('/prompt', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a queued item
|
||||||
|
*/
|
||||||
|
async deleteQueueItem(deleteType: 'queue' | 'history', promptId: string): Promise<void> {
|
||||||
|
await this.fetch(`/${deleteType}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ delete: [promptId] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the queue
|
||||||
|
*/
|
||||||
|
async clearQueue(): Promise<void> {
|
||||||
|
await this.fetch('/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ clear: true }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interrupt the current generation
|
||||||
|
*/
|
||||||
|
async interrupt(): Promise<void> {
|
||||||
|
await this.fetch('/interrupt', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// History API
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get generation history
|
||||||
|
*/
|
||||||
|
async getHistory(maxItems?: number): Promise<HistoryResponse> {
|
||||||
|
const endpoint = maxItems ? `/history?max_items=${maxItems}` : '/history';
|
||||||
|
return this.fetch<HistoryResponse>(endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific history entry
|
||||||
|
*/
|
||||||
|
async getHistoryEntry(promptId: string): Promise<HistoryEntry | undefined> {
|
||||||
|
const history = await this.fetch<HistoryResponse>(
|
||||||
|
`/history/${encodeURIComponent(promptId)}`
|
||||||
|
);
|
||||||
|
return history[promptId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear history
|
||||||
|
*/
|
||||||
|
async clearHistory(): Promise<void> {
|
||||||
|
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<UploadImageResponse> {
|
||||||
|
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<ModelsResponse> {
|
||||||
|
// 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<string[]> {
|
||||||
|
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<string[]> {
|
||||||
|
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<HistoryEntry> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timeout = options.timeout ?? 300000; // 5 minutes default
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
||||||
|
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<HistoryEntry> {
|
||||||
|
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;
|
||||||
@@ -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<string, unknown>;
|
||||||
|
outputs_to_execute: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Workflow / Prompt Types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type WorkflowPrompt = Record<string, WorkflowNode>;
|
||||||
|
|
||||||
|
export interface WorkflowNode {
|
||||||
|
class_type: string;
|
||||||
|
inputs: Record<string, unknown>;
|
||||||
|
_meta?: {
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptRequest {
|
||||||
|
prompt: WorkflowPrompt;
|
||||||
|
client_id?: string;
|
||||||
|
extra_data?: {
|
||||||
|
extra_pnginfo?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptResponse {
|
||||||
|
prompt_id: string;
|
||||||
|
number: number;
|
||||||
|
node_errors?: Record<string, NodeError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeError {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
details: string;
|
||||||
|
extra_info: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// History Types
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type HistoryResponse = Record<string, HistoryEntry>;
|
||||||
|
|
||||||
|
export interface HistoryEntry {
|
||||||
|
prompt: [number, string, WorkflowPrompt, Record<string, unknown>, string[]];
|
||||||
|
outputs: Record<string, NodeOutput>;
|
||||||
|
status: {
|
||||||
|
status_str: string;
|
||||||
|
completed: boolean;
|
||||||
|
messages: Array<[string, Record<string, unknown>]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, NodeDefinition>;
|
||||||
|
|
||||||
|
export interface NodeDefinition {
|
||||||
|
input: {
|
||||||
|
required?: Record<string, InputDefinition>;
|
||||||
|
optional?: Record<string, InputDefinition>;
|
||||||
|
hidden?: Record<string, InputDefinition>;
|
||||||
|
};
|
||||||
|
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<string, unknown>;
|
||||||
|
current_outputs?: Record<string, unknown>[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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<K extends keyof ComfyEventMap> = (
|
||||||
|
data: ComfyEventMap[K]
|
||||||
|
) => void;
|
||||||
@@ -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" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user