From 49dc1e79d4cd2599cedf3e13ccc2f0923cc07d46 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 13 Mar 2024 13:10:20 -0700 Subject: [PATCH] New UI --- js/button/actions.js | 17 ++-- js/client.js | 144 ++++++++++++++++++++++------- js/comfy/ui.js | 6 +- js/constants.js | 5 +- js/logger.js | 1 + js/node/auth.ui.js | 195 ++++++++++++++++++++++++++++++++++++++++ js/node/index.js | 7 ++ js/node/workflows.ui.js | 187 ++++++++++++++++++++++++++++++++++++++ js/store.js | 23 +++++ js/ui.js | 12 ++- js/utils.js | 32 +------ python/custom_routes.py | 14 ++- 12 files changed, 559 insertions(+), 84 deletions(-) create mode 100644 js/node/auth.ui.js create mode 100644 js/node/workflows.ui.js create mode 100644 js/store.js diff --git a/js/button/actions.js b/js/button/actions.js index 9e9e777..a3ad7f9 100644 --- a/js/button/actions.js +++ b/js/button/actions.js @@ -7,11 +7,9 @@ import { createRun } from "../client.js" import { - getData, createMetaNode, getApiToken, validatePrompt, - getWorkflowId, compareWorkflows, isWorkflowUpToDate } from "../utils.js" @@ -19,7 +17,6 @@ import { app, } from '../comfy/comfy.js'; import { - configDialog, infoDialog, } from '../comfy/ui.js'; import { @@ -28,12 +25,12 @@ import { setMessage, } from './ui.js'; import { logger } from '../logger.js'; +import { authDialog } from '../node/auth.ui.js'; export async function onGeneration() { logger.newLog(); try { - const { endpoint } = getData(); setButtonDefault() // check auth @@ -42,7 +39,8 @@ export async function onGeneration() { if(!doesApiTokenExist) { // Request auth - configDialog.show(); + setButtonDefault() + return authDialog.show(); } // check if ComfyCloud meta node exists @@ -79,11 +77,9 @@ export async function onGeneration() { await pollSyncDependencies(s.taskId) } - if(s?.nodesToUpload) { - setMessage("Building environment..."); - } + setMessage("Updating workflow..."); - await uploadLocalWorkflow() + await uploadLocalWorkflow(s) } // @todo patch workflow inputs @@ -92,11 +88,10 @@ export async function onGeneration() { // and workflow api is synced to the cloud // create run - const workflow_id = getWorkflowId() await createRun() infoDialog.showMessage( "Item queued!", - `View your generation results at this URL: ${endpoint}/workflows/${workflow_id}`, + "You can view your generation results by clicking the 'Menu' button in your Comfy Cloud custom node." ) } catch (e) { // handle error diff --git a/js/client.js b/js/client.js index 2629f13..61b88f7 100644 --- a/js/client.js +++ b/js/client.js @@ -1,10 +1,8 @@ +import { setData, getData } from './store.js'; import { - getData, getUser, getWorkflowId, - getVersion, setWorkflowId, - setVersion, getWorkflowName, getCustomNodesList, isCustomNode, @@ -14,18 +12,69 @@ import { app } from './comfy/comfy.js'; import retry from "https://esm.sh/gh/vercel/async-retry"; import { endpoint } from './constants.js'; +export const loginUser = async ({ email, password }) => { + const apiRoute = endpoint + "/auth/login"; + const body = { + email: email, + password: password, + } + const res = await fetch(apiRoute, { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, + }) + const data = await res.json() + + // @todo - save api token + if(res.status == 200 && data) { + setData({ + apiKey: data.token, + user: data.user + }) + } + + return { ...data, status: res.status }; +} + +export const registerUser = async ({ email, password }) => { + const apiRoute = endpoint + "/auth/register"; + const body = { + email: email, + password: password, + } + const res = await fetch(apiRoute, { + method: "POST", + body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + }, + }) + + const data = await res.json() + + if(res.status == 200 && data) { + setData({ + apiKey: data.token, + user: data.user + }) + } + + return { ...data, status: res.status }; +} + /** * Calls endpoints from ComfyCloud API * * */ - const fetchRetry = async (url, options = {}, retries) => { return await retry( async bail => { logger.info("fetchRetry: sending request...") const res = await fetch(url, options); - if (res.status !== 200) { + if (res.status !== 200 && res.status !== 201) { bail(new Error("Server error")); return; } @@ -44,21 +93,16 @@ const fetchRetry = async (url, options = {}, retries) => { export const getCloudWorkflow = async () => { logger.info("Retrieving existing workflow from cloud") try { - const { endpoint, apiKey } = getData(); + const { apiKey } = getData(); const workflow_id = getWorkflowId(); - const body = { - version: parseInt(getVersion()), - } - const existing_workflow = await fetchRetry(endpoint + "/api/workflow/" + workflow_id, { - method: "POST", - body: JSON.stringify(body), + const existing_workflow = await fetchRetry(endpoint + "/workflow/" + workflow_id, { + method: "GET", headers: { "Content-Type": "application/json", Authorization: "Bearer " + apiKey, }, }, 2) - logger.info("Successfully retrieved workflow") return existing_workflow; @@ -69,22 +113,48 @@ export const getCloudWorkflow = async () => { } } + +export const getWorkflowRunOutput = async (run_id) => { + logger.info("Retrieving existing workflow from cloud") + try { + const { apiKey } = getData(); + const workflow_id = getWorkflowId(); + + const output = await fetchRetry(endpoint + "/workflow/" + workflow_id + "/runs/" + run_id, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + apiKey, + }, + }, 2) + logger.info("Successfully retrieved workflow output") + + return output; + } catch(e) { + logger.error("Failed to retrieve workflow output", e) + + throw new Error("Failed to retrieve workflow output") + } +} + /** * Sends data to createRun api endpoint */ export const createRun = async () => { logger.info("Creating run workflow request") try { - const { endpoint, apiKey } = getData(); + const { apiKey } = getData(); const user = await getUser(); + const workflow_id = getWorkflowId() - const apiRoute = endpoint + "/api/run"; + const apiRoute = endpoint + "/workflow/" + workflow_id + "/runs"; const body = { - workflow_id: getWorkflowId(), - version: getVersion(), + workflow_id, + //version: getVersion(), user_id: user?.id, inputs: {}, } + await fetch(apiRoute, { method: "POST", body: JSON.stringify(body), @@ -106,7 +176,9 @@ export const createRun = async () => { export const createEmptyWorkflow = async () => { logger.info("Uploading local workflow to cloud") try { - const { endpoint, apiKey } = getData(); + const { apiKey } = getData(); + const apiRoute = endpoint + "/workflow"; + /* const apiRoute = endpoint + "/api/workflow"; const body = { @@ -120,7 +192,11 @@ export const createEmptyWorkflow = async () => { file_custom_nodes: [], }, }; + */ //let data = { status: 200 } + const body = { + name: getWorkflowName(), + } let data = await fetchRetry(apiRoute, { method: "POST", body: JSON.stringify(body), @@ -130,8 +206,8 @@ export const createEmptyWorkflow = async () => { }, }, 2); - setWorkflowId(data.workflow_id) - setVersion(data.version) + setWorkflowId(data.id) + //setVersion(data.version) logger.info("Successfully uploaded local workflow to cloud") } catch (e) { @@ -148,27 +224,23 @@ export const createEmptyWorkflow = async () => { /** * Sends local workflow to the cloud to be stored */ -export const uploadLocalWorkflow = async () => { +export const uploadLocalWorkflow = async (dependencies) => { logger.info("Uploading local workflow to cloud") try { - const { endpoint, apiKey } = getData(); - const apiRoute = endpoint + "/api/workflow"; + const { apiKey } = getData(); + const workflow_id = getWorkflowId() + const apiRoute = endpoint + "/workflow/" + workflow_id; const prompt = await app.graphToPrompt(); const body = { - workflow_name: getWorkflowName(), - workflow_id: getWorkflowId(), + name: getWorkflowName(), workflow: prompt.workflow, workflow_api: prompt.output, - snapshot: { - comfyui: "", - git_custom_nodes: {}, - file_custom_nodes: [], - }, + dependencies: dependencies, }; //let data = { status: 200 } let data = await fetchRetry(apiRoute, { - method: "POST", + method: "PUT", body: JSON.stringify(body), headers: { "Content-Type": "application/json", @@ -176,8 +248,8 @@ export const uploadLocalWorkflow = async () => { }, }, 2); - setWorkflowId(data.workflow_id) - setVersion(data.version) + setWorkflowId(data.id) + //setVersion(data.version) logger.info("Successfully uploaded local workflow to cloud") } catch (e) { @@ -196,7 +268,7 @@ export const syncDependencies = async (diff) => { try { // update with new workflow const workflow_id = getWorkflowId() - const { endpoint } = getData(); + const { apiKey } = getData(); if(!diff) { return {} @@ -277,6 +349,10 @@ export const syncDependencies = async (diff) => { const res = await fetch("/comfy-cloud/upload", { method: "POST", body: JSON.stringify(body), + headers: { + "Content-Type": "application/json", + Authorization: "Bearer " + apiKey, + }, }) if(res.status == 200) { diff --git a/js/comfy/ui.js b/js/comfy/ui.js index 0a804ad..4905374 100644 --- a/js/comfy/ui.js +++ b/js/comfy/ui.js @@ -1,5 +1,5 @@ import { ComfyDialog, $el } from './comfy.js'; -import { getData, saveData } from '../utils.js'; +import { getData, setData } from '../store.js'; export class InfoDialog extends ComfyDialog { constructor() { @@ -33,7 +33,7 @@ export class InfoDialog extends ComfyDialog { this.textElement.replaceChildren(html); } this.element.style.display = "flex"; - this.element.style.zIndex = 1001; + this.element.style.zIndex = 1002; } showMessage(title, message) { @@ -281,7 +281,7 @@ export class ConfigDialog extends ComfyDialog { //localStorage.setItem("comfy_cloud_env", deployOption); const endpoint = this.endpoint - saveData({ + setData({ endpoint, apiKey: api_key, displayName, diff --git a/js/constants.js b/js/constants.js index a092894..e8e79ef 100644 --- a/js/constants.js +++ b/js/constants.js @@ -1,2 +1,3 @@ -export const endpoint = "https://comfycloud.vercel.app" -//export const endpoint = "http://localhost:3000" +//export const endpoint = "https://comfycloud.vercel.app" +//export const endpoint = "http://localhost:4000" +export const endpoint = "https://comfyui-cloud-a0cf78bd8c3d.herokuapp.com" diff --git a/js/logger.js b/js/logger.js index 20826dd..02dd766 100644 --- a/js/logger.js +++ b/js/logger.js @@ -13,6 +13,7 @@ import { class Logger { constructor() { this.currentLogs = {} + this.newLog() } newLog() { diff --git a/js/node/auth.ui.js b/js/node/auth.ui.js new file mode 100644 index 0000000..a351c08 --- /dev/null +++ b/js/node/auth.ui.js @@ -0,0 +1,195 @@ +import { headerHtml, loadingIcon } from '../ui.js'; +import { loginUser, registerUser } from '../client.js'; +import { ComfyDialog, $el } from '../comfy/comfy.js'; +import { infoDialog } from '../comfy/ui.js'; + +class AuthDialog extends ComfyDialog { + container = null; + + constructor() { + super(); + this.element.classList.add("comfy-normal-modal"); + this.element.style.paddingBottom = "20px"; + + this.container = document.createElement("div"); + this.container.style.color = "white"; + + this.header = document.createElement("div"); + this.header.innerHTML = headerHtml + this.element.querySelector(".comfy-modal-content").prepend(this.container); + this.element.querySelector(".comfy-modal-content").prepend(this.header); + } + + createButtons() { + return [ + $el( + "div", + [ + $el("button", { + type: "button", + textContent: "Close", + onclick: () => this.close(), + }), + ], + ), + ]; + } + + close() { + this.element.style.display = "none"; + } + + show() { + this.showRegister(); + } + + showLogin() { + this.container.innerHTML = loginPageHtml; + const form = this.container.querySelector("#auth-form"); + form.onsubmit = async () => { + event.preventDefault(); + this.clearErrorMessage(); + + // Retrieve values from the input fields + const email = this.container.querySelector('#email').value; + const password = this.container.querySelector('#password').value; + + this.setButtonLoading(true) + + const data = await loginUser({ email, password }) + if(data.status !== 200 && data.message) { + this.setErrorMessage(data.message) + } + + this.setButtonLoading(false, "Login") + + console.log(data) + + if(data.status == 200) { + infoDialog.show(); + infoDialog.showMessage( + "Authenticated", + "You are now logged in", + ); + this.close() + } + } + const link = this.container.querySelector("#go-to-register"); + link.onclick = () => { + this.showRegister() + }; + + this.element.style.display = "flex"; + this.element.style.zIndex = 1001; + } + + showRegister() { + this.container.innerHTML = registerPageHtml; + const form = this.container.querySelector("#auth-form"); + form.onsubmit = async () => { + event.preventDefault(); + this.clearErrorMessage() + + // Retrieve values from the input fields + const email = this.container.querySelector('#email').value; + const password = this.container.querySelector('#password').value; + const confirmPassword = this.container.querySelector('#password2').value; + + if(password !== confirmPassword) { + throw new Error("Passwords don't match") + } + + this.setButtonLoading(true) + + const data = await registerUser({ email, password }) + if(data.status !== 200 && data.message) { + this.setErrorMessage(data.message) + } + this.setButtonLoading(false, "Sign up") + + if(data.status == 201) { + infoDialog.show(); + infoDialog.showMessage( + "Authenticated", + "You are now logged in", + ); + this.close() + } + } + const link = this.container.querySelector("#go-to-login"); + link.onclick = () =>{ + this.showLogin(); + } + + this.element.style.display = "flex"; + this.element.style.zIndex = 1001; + + } + + clearErrorMessage() { + this.container.querySelector('#error-message').innerHTML = ""; + } + setErrorMessage(message) { + this.container.querySelector('#error-message').innerHTML = message; + } + + setButtonLoading(isLoading, text) { + const btn = this.container.querySelector('#auth-button') + if(isLoading) { + btn.innerHTML = loadingIcon; + btn.disabled = true; + } else { + btn.innerHTML = text; + btn.disabled = false; + } + } +} +export const authDialog = new AuthDialog() + +const loginPageHtml = ` +
+

Login

+

+ No account? Sign up here +

+
+ + + + + + + + +

+ +

+
+
+`; + +const registerPageHtml = ` +
+

Sign up for an account

+

+ Already have an account? Log in here +

+ +
+ + + + + + + + + + +

+ +

+
+
+`; + diff --git a/js/node/index.js b/js/node/index.js index 9daaeb5..198af3c 100644 --- a/js/node/index.js +++ b/js/node/index.js @@ -1,5 +1,7 @@ import { ComfyNode } from '../comfy/comfy.js'; import { cloudIconWhite } from '../ui.js'; +import { workflowTableDialog } from './workflows.ui.js'; + export class ComfyCloud extends ComfyNode { color = LGraphCanvas.node_colors.blue.color; @@ -31,9 +33,13 @@ export class ComfyCloud extends ComfyNode { this.logo.src = URL.createObjectURL(new Blob([cloudIconWhite], { type: 'image/svg+xml' })); this.menuButton = this.addButton("Menu", {}, async () => { + //authDialog.show() + workflowTableDialog.show() + // Timeout is added as a hotfix. // Without waiting 100ms the node gets // stuck as selected on the user's mouse + /* setTimeout(() => { if(this.properties?.workflow_id?.length > 0) { window.open(`https://comfycloud.vercel.app/workflows/${this.properties.workflow_id}`, '_blank'); @@ -41,6 +47,7 @@ export class ComfyCloud extends ComfyNode { window.open("https://comfycloud.vercel.app/workflows", '_blank'); } }, 100) + */ }) this.menuButton.x = 4 this.menuButton.y = 52 diff --git a/js/node/workflows.ui.js b/js/node/workflows.ui.js new file mode 100644 index 0000000..36798d1 --- /dev/null +++ b/js/node/workflows.ui.js @@ -0,0 +1,187 @@ +import { headerHtml } from '../ui.js'; +import { ComfyDialog, $el } from '../comfy/comfy.js'; +import { getCloudWorkflow, getWorkflowRunOutput } from '../client.js'; + +class WorkflowTableDialog extends ComfyDialog { + container = null; + + constructor() { + super(); + + this.element.classList.add("comfy-normal-modal"); + this.element.style.paddingBottom = "20px"; + this.element.style.overflowY = "auto"; + + this.container = document.createElement("div"); + this.container.style.color = "white"; + this.container.style.width = "720px"; + this.container.style.minHeight = "540px"; + + this.header = document.createElement("div"); + this.header.innerHTML = headerHtml + this.element.querySelector(".comfy-modal-content").prepend(this.container); + this.element.querySelector(".comfy-modal-content").prepend(this.header); + } + + createButtons() { + return [ + $el( + "div", + [ + $el("button", { + type: "button", + textContent: "Close", + onclick: () => this.close(), + }), + ], + ), + ]; + } + + close() { + this.element.style.display = "none"; + } + + async show() { + await this.showWorkflowsTable(); + } + + async showWorkflowDetails(id) { + const data = await getWorkflowRunOutput(id) + + this.container.innerHTML = workflowDetailsHtml(data); + + const link = this.container.querySelector("#back-button"); + link.onclick = async () => { + await this.showWorkflowsTable() + }; + + this.element.style.display = "flex"; + this.element.style.zIndex = 1001; + } + + async showWorkflowsTable() { + // @todo - call api to set data + const workflow = await getCloudWorkflow() + this.runs = workflow.runs || []; + + this.container.innerHTML = tableHtml(this.runs); + const workflowRows = this.container.querySelectorAll(".workflow-row"); + workflowRows.forEach((row, i) => { + row.onclick = async () => { + const rowId = this.runs[i].id + await this.showWorkflowDetails(rowId) + } + }); + + this.element.style.display = "flex"; + this.element.style.zIndex = 1001; + } +} +export const workflowTableDialog = new WorkflowTableDialog() + + + + +// Function to handle row click +const workflowDetailsHtml = (rowDetails) => { + //const imageData = rowDetails.outputs[0]?.data.images[0].filename; + + return` +
+

Row Details

+

Status: ${rowDetails.status}

+ ${generateOutputs(rowDetails.outputs)} + +
+ `; +} + +const generateOutputs = (outputs) => { + return outputs?.map((run) => { + const fileName = run.data.images?.[0].filename || + run.data.files?.[0].filename || + run.data.gifs?.[0].filename; + + if (!fileName) { + return` + + Output + + + + + `; + } + + // const filePath + return ` + + ${fileName} + + ${generateOutputDisplay(run.run_id, fileName)} + + + `; + }).join(''); +} + +const generateOutputDisplay = (run_id, filename) => { + const url = `https://comfyui-output.nyc3.digitaloceanspaces.com/comfyui-output/outputs/runs/${run_id}/${filename}` + if (filename.endsWith(".mp4") || filename.endsWith(".webm")) { + return` + + `; + } + + if ( + filename.endsWith(".png") || + filename.endsWith(".gif") || + filename.endsWith(".jpg") || + filename.endsWith(".jpeg") + ) { + return `${filename}`; + } else { + return `${filename}`; + } +} + +// Function to generate HTML rows based on the array of objects +function generateRows(data) { + return data.map((item, i) => ` + + ${i+1} + ${item.created_at} + 0.0s + ${item.status} + + `).join(''); +} + +// Generate the table HTML with dynamic rows +const tableHtml = (data) => { + return` + + + + + + + + + + + ${generateRows(data)} + +
NumberTimeDurationStatus
+ `; +} + + diff --git a/js/store.js b/js/store.js new file mode 100644 index 0000000..5d4d77d --- /dev/null +++ b/js/store.js @@ -0,0 +1,23 @@ + +const LOCAL_STORAGE_KEY = "comfy_cloud_store" + +export function getData() { + const data = localStorage.getItem(LOCAL_STORAGE_KEY); + + if (!data) { + return { + apiKey: "", + }; + } + + return { + ...JSON.parse(data), + }; +} + +export function setData(data) { + localStorage.setItem( + LOCAL_STORAGE_KEY, + JSON.stringify(data), + ); +} diff --git a/js/ui.js b/js/ui.js index 5a2b2de..b960fef 100644 --- a/js/ui.js +++ b/js/ui.js @@ -11,5 +11,13 @@ export const cloudIconWhite = ` ` - - +export const headerHtml = ` +
+
+ ${cloudIcon} +
+

+ Comfy Cloud +

+
+` diff --git a/js/utils.js b/js/utils.js index 4ded844..650e3cc 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,7 +1,7 @@ import _ from 'https://cdn.jsdelivr.net/npm/@esm-bundle/lodash@4.17.21/+esm' import { app } from './comfy/comfy.js'; import { inputDialog } from './comfy/ui.js'; -import { endpoint } from './constants.js'; +import { getData } from './store.js'; /** * HELPERS @@ -18,35 +18,6 @@ export function generateUUID() { return uuid; } -/** - * Retrieves deployment data from local storage or defaults. - * @param {string} [environment] - The environment to get the data for. - * @returns {{endpoint: string, apiKey: string, displayName: string, environment?: string}} The deployment data. - */ - -export function getData() { - const deployOption = 'cloud'; - const data = localStorage.getItem("comfy_cloud_env_data_" + deployOption); - - if (!data) { - return { - endpoint: endpoint, - apiKey: "", - }; - } - return { - ...JSON.parse(data), - endpoint: endpoint, - environment: deployOption, - }; -} - -export function saveData(data) { - localStorage.setItem( - "comfy_cloud_env_data_" + data.environment, - JSON.stringify(data), - ); -} export const createMetaNode = async () => { const text = await inputDialog.input( @@ -102,6 +73,7 @@ export const getWorkflowId = () => { // @todo // handle no deployMetaNode + console.log(deployMetaNode) const { workflow_id } = deployMetaNode.properties; //const workflow_id = deployMetaNode.widgets[1].value; return workflow_id; diff --git a/python/custom_routes.py b/python/custom_routes.py index 8a39482..6e6a0f5 100644 --- a/python/custom_routes.py +++ b/python/custom_routes.py @@ -240,10 +240,18 @@ async def upload_task_execution(task_id, json_response, workflow_id, models_dep, async def upload_dependencies(request): # Make a request to localhost try: + authorization = request.headers.get('Authorization') json_data = await request.json() + endpoint = json_data["endpoint"] workflow_id = json_data["workflow_id"] + headers = None + if authorization: + headers = { + "Authorization": authorization + } + models_dep = json_data["modelsToUpload"] nodes_dep = json_data["nodesToUpload"] files = json_data["filesToUpload"] @@ -253,8 +261,10 @@ async def upload_dependencies(request): "token": current_datetime.strftime("%Y-%m-%d %H:%M:%S") } - url = f"{endpoint}/api/e" - response = make_post_request_with_retry(url, data=body) + + url = f"{endpoint}/e" + response = make_post_request_with_retry(url, data=body, headers=headers) + # Check if the request was successful (status code 200) if response.status_code == 200: