Spaces:
Running
on
Zero
Running
on
Zero
class ComfyApi extends EventTarget { | |
#registered = new Set(); | |
constructor() { | |
super(); | |
this.api_host = location.host; | |
this.api_base = location.pathname.split('/').slice(0, -1).join('/'); | |
this.initialClientId = sessionStorage.getItem("clientId"); | |
} | |
apiURL(route) { | |
return this.api_base + route; | |
} | |
fetchApi(route, options) { | |
if (!options) { | |
options = {}; | |
} | |
if (!options.headers) { | |
options.headers = {}; | |
} | |
options.headers["Comfy-User"] = this.user; | |
return fetch(this.apiURL(route), options); | |
} | |
addEventListener(type, callback, options) { | |
super.addEventListener(type, callback, options); | |
this.#registered.add(type); | |
} | |
/** | |
* 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(); | |
this.dispatchEvent(new CustomEvent("status", { detail: status })); | |
} catch (error) { | |
this.dispatchEvent(new CustomEvent("status", { detail: null })); | |
} | |
}, 1000); | |
} | |
/** | |
* Creates and connects a WebSocket for realtime updates | |
* @param {boolean} isReconnect If the socket is connection is a reconnect attempt | |
*/ | |
#createSocket(isReconnect) { | |
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; | |
if (isReconnect) { | |
this.dispatchEvent(new CustomEvent("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.dispatchEvent(new CustomEvent("status", { detail: null })); | |
this.dispatchEvent(new CustomEvent("reconnecting")); | |
} | |
}); | |
this.socket.addEventListener("message", (event) => { | |
try { | |
if (event.data instanceof ArrayBuffer) { | |
const view = new DataView(event.data); | |
const eventType = view.getUint32(0); | |
const buffer = event.data.slice(4); | |
switch (eventType) { | |
case 1: | |
const view2 = new DataView(event.data); | |
const imageType = view2.getUint32(0) | |
let imageMime | |
switch (imageType) { | |
case 1: | |
default: | |
imageMime = "image/jpeg"; | |
break; | |
case 2: | |
imageMime = "image/png" | |
} | |
const imageBlob = new Blob([buffer.slice(4)], { type: imageMime }); | |
this.dispatchEvent(new CustomEvent("b_preview", { detail: imageBlob })); | |
break; | |
default: | |
throw new Error(`Unknown binary websocket message of type ${eventType}`); | |
} | |
} | |
else { | |
const msg = JSON.parse(event.data); | |
switch (msg.type) { | |
case "status": | |
if (msg.data.sid) { | |
this.clientId = msg.data.sid; | |
window.name = this.clientId; // use window name so it isnt reused when duplicating tabs | |
sessionStorage.setItem("clientId", this.clientId); // store in session storage so duplicate tab can load correct workflow | |
} | |
this.dispatchEvent(new CustomEvent("status", { detail: msg.data.status })); | |
break; | |
case "progress": | |
this.dispatchEvent(new CustomEvent("progress", { detail: msg.data })); | |
break; | |
case "executing": | |
this.dispatchEvent(new CustomEvent("executing", { detail: msg.data.node })); | |
break; | |
case "executed": | |
this.dispatchEvent(new CustomEvent("executed", { detail: msg.data })); | |
break; | |
case "execution_start": | |
this.dispatchEvent(new CustomEvent("execution_start", { detail: msg.data })); | |
break; | |
case "execution_success": | |
this.dispatchEvent(new CustomEvent("execution_success", { detail: msg.data })); | |
break; | |
case "execution_error": | |
this.dispatchEvent(new CustomEvent("execution_error", { detail: msg.data })); | |
break; | |
case "execution_cached": | |
this.dispatchEvent(new CustomEvent("execution_cached", { detail: msg.data })); | |
break; | |
default: | |
if (this.#registered.has(msg.type)) { | |
this.dispatchEvent(new CustomEvent(msg.type, { detail: msg.data })); | |
} else { | |
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 | |
* @returns An array of script urls to import | |
*/ | |
async getExtensions() { | |
const resp = await this.fetchApi("/extensions", { cache: "no-store" }); | |
return await resp.json(); | |
} | |
/** | |
* Gets a list of embedding names | |
* @returns An array of script urls to import | |
*/ | |
async getEmbeddings() { | |
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() { | |
const resp = await this.fetchApi("/object_info", { cache: "no-store" }); | |
return await resp.json(); | |
} | |
/** | |
* | |
* @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} prompt The prompt data to queue | |
*/ | |
async queuePrompt(number, { output, workflow }) { | |
const body = { | |
client_id: this.clientId, | |
prompt: output, | |
extra_data: { 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 { | |
response: await res.json(), | |
}; | |
} | |
return await res.json(); | |
} | |
/** | |
* 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) { | |
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() { | |
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) => ({ | |
prompt, | |
remove: { name: "Cancel", cb: () => api.interrupt() }, | |
})), | |
Pending: data.queue_pending.map((prompt) => ({ prompt })), | |
}; | |
} catch (error) { | |
console.error(error); | |
return { Running: [], Pending: [] }; | |
} | |
} | |
/** | |
* Gets the prompt execution history | |
* @returns Prompt history including node outputs | |
*/ | |
async getHistory(max_items=200) { | |
try { | |
const res = await this.fetchApi(`/history?max_items=${max_items}`); | |
return { History: Object.values(await res.json()) }; | |
} catch (error) { | |
console.error(error); | |
return { History: [] }; | |
} | |
} | |
/** | |
* Gets system & device stats | |
* @returns System stats such as python version, OS, per device info | |
*/ | |
async getSystemStats() { | |
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, body) { | |
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, id) { | |
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) { | |
await this.#postItem(type, { clear: true }); | |
} | |
/** | |
* Interrupts the execution of the running prompt | |
*/ | |
async interrupt() { | |
await this.#postItem("interrupt", null); | |
} | |
/** | |
* Gets user configuration data and where data should be stored | |
* @returns { Promise<{ storage: "server" | "browser", users?: Promise<string, unknown>, migrated?: boolean }> } | |
*/ | |
async getUserConfig() { | |
return (await this.fetchApi("/users")).json(); | |
} | |
/** | |
* Creates a new user | |
* @param { string } username | |
* @returns The fetch response | |
*/ | |
createUser(username) { | |
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() { | |
return (await this.fetchApi("/settings")).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) { | |
return (await this.fetchApi(`/settings/${encodeURIComponent(id)}`)).json(); | |
} | |
/** | |
* Stores a dictionary of settings for the current user | |
* @param { Record<string, unknown> } settings Dictionary of setting id -> value to save | |
* @returns { Promise<void> } | |
*/ | |
async storeSettings(settings) { | |
return this.fetchApi(`/settings`, { | |
method: "POST", | |
body: JSON.stringify(settings) | |
}); | |
} | |
/** | |
* Stores a setting for the current user | |
* @param { string } id The id of the setting to update | |
* @param { unknown } value The value of the setting | |
* @returns { Promise<void> } | |
*/ | |
async storeSetting(id, value) { | |
return this.fetchApi(`/settings/${encodeURIComponent(id)}`, { | |
method: "POST", | |
body: JSON.stringify(value) | |
}); | |
} | |
/** | |
* Gets a user data file for the current user | |
* @param { string } file The name of the userdata file to load | |
* @param { RequestInit } [options] | |
* @returns { Promise<Response> } The fetch response object | |
*/ | |
async getUserData(file, options) { | |
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 & { overwrite?: boolean, stringify?: boolean, throwOnError?: boolean } } [options] | |
* @returns { Promise<Response> } | |
*/ | |
async storeUserData(file, data, options = { overwrite: true, stringify: true, throwOnError: true }) { | |
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}?overwrite=${options?.overwrite}`, { | |
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) { | |
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, { | |
method: "DELETE", | |
}); | |
if (resp.status !== 204) { | |
throw new Error(`Error removing user data file '${file}': ${resp.status} ${(resp).statusText}`); | |
} | |
} | |
/** | |
* 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, dest, options = { overwrite: false }) { | |
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(source)}/move/${encodeURIComponent(dest)}?overwrite=${options?.overwrite}`, { | |
method: "POST", | |
}); | |
return resp; | |
} | |
/** | |
* @overload | |
* Lists user data files for the current user | |
* @param { string } dir The directory in which to list files | |
* @param { boolean } [recurse] If the listing should be recursive | |
* @param { true } [split] If the paths should be split based on the os path separator | |
* @returns { Promise<string[][]>> } The list of split file paths in the format [fullPath, ...splitPath] | |
*/ | |
/** | |
* @overload | |
* Lists user data files for the current user | |
* @param { string } dir The directory in which to list files | |
* @param { boolean } [recurse] If the listing should be recursive | |
* @param { false | undefined } [split] If the paths should be split based on the os path separator | |
* @returns { Promise<string[]>> } The list of files | |
*/ | |
async listUserData(dir, recurse, split) { | |
const resp = await this.fetchApi( | |
`/userdata?${new URLSearchParams({ | |
recurse, | |
dir, | |
split, | |
})}` | |
); | |
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(); | |
} | |
} | |
export const api = new ComfyApi(); | |