diff --git a/nodes/api/__init__.py b/nodes/api/__init__.py index 85c565e9..22e017c1 100644 --- a/nodes/api/__init__.py +++ b/nodes/api/__init__.py @@ -8,6 +8,7 @@ import aiohttp from ..server_manager import LocalComfyStreamServer from .. import settings_storage +import subprocess routes = None server_manager = None @@ -215,3 +216,45 @@ async def manage_configuration(request): logging.error(f"Error managing configuration: {str(e)}") return web.json_response({"error": str(e)}, status=500) + @routes.post('/comfystream/settings/manage') + async def manage_comfystream(request): + """Manage ComfyStream server settings""" + #check if server is running + server_status = server_manager.get_status() + if not server_status["running"]: + return web.json_response({"error": "ComfyStream Server is not running"}, status=503) + + try: + data = await request.json() + action_type = data.get("action_type") + action = data.get("action") + payload = data.get("payload") + url_host = server_status.get("host", "localhost") + url_port = server_status.get("port", "8889") + mgmt_url = f"http://{url_host}:{url_port}/settings/{action_type}/{action}" + + async with aiohttp.ClientSession() as session: + async with session.post( + mgmt_url, + json=payload, + headers={"Content-Type": "application/json"} + ) as response: + if not response.ok: + return web.json_response( + {"error": f"Server error: {response.status}"}, + status=response.status + ) + return web.json_response(await response.json()) + except Exception as e: + logging.error(f"Error managing ComfyStream: {str(e)}") + return web.json_response({"error": str(e)}, status=500) + + @routes.post('/comfyui/restart') + async def manage_configuration(request): + server_status = server_manager.get_status() + if server_status["running"]: + await server_manager.stop() + logging.info("Restarting ComfyUI...") + subprocess.run(["supervisorctl", "restart", "comfyui"]) + logging.info("Restarting ComfyUI...in process") + return web.json_response({"success": True}, status=200) diff --git a/nodes/web/js/launcher.js b/nodes/web/js/launcher.js index 7caf6e4f..a2a5e58a 100644 --- a/nodes/web/js/launcher.js +++ b/nodes/web/js/launcher.js @@ -98,6 +98,38 @@ document.addEventListener('comfy-extension-registered', (event) => { } }); +async function restartComfyUI() { + try { + const response = await fetch('/comfyui/restart', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: "" // No body needed + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("[ComfyStream] ComfyUI restart returned error response:", response.status, errorText); + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.error || `Server error: ${response.status}`); + } catch (e) { + throw new Error(`Server error: ${response.status} - ${errorText}`); + } + } + + const data = await response.json(); + + return data; + } catch (error) { + console.error('[ComfyStream] Error restarting ComfyUI:', error); + app.ui.dialog.show('Error', error.message || 'Failed to restart ComfyUI'); + throw error; + } +} + async function controlServer(action) { try { // Get settings from the settings manager @@ -256,6 +288,12 @@ const extension = { icon: "pi pi-cog", label: "Server Settings", function: openSettings + }, + { + id: "ComfyStream.RestartComfyUI", + icon: "pi pi-refresh", + label: "Restart ComfyUI", + function: restartComfyUI } ], @@ -270,7 +308,9 @@ const extension = { "ComfyStream.StopServer", "ComfyStream.RestartServer", null, // Separator - "ComfyStream.Settings" + "ComfyStream.Settings", + null, // Separator + "ComfyStream.RestartComfyUI" ] } ], @@ -300,6 +340,8 @@ const extension = { comfyStreamMenu.addItem("Restart Server", () => controlServer('restart'), { icon: "pi pi-refresh" }); comfyStreamMenu.addSeparator(); comfyStreamMenu.addItem("Server Settings", openSettings, { icon: "pi pi-cog" }); + comfyStreamMenu.addSeparator(); + comfyStreamMenu.addItem("Restart ComfyUI", () => restartComfyUI(), { icon: "pi pi-refresh" }); } // New menu system is handled automatically by the menuCommands registration diff --git a/nodes/web/js/settings.js b/nodes/web/js/settings.js index 888917e8..34ae539e 100644 --- a/nodes/web/js/settings.js +++ b/nodes/web/js/settings.js @@ -1,5 +1,6 @@ // ComfyStream Settings Manager console.log("[ComfyStream Settings] Initializing settings module"); +const app = window.comfyAPI?.app?.app; const DEFAULT_SETTINGS = { host: "0.0.0.0", @@ -12,6 +13,7 @@ class ComfyStreamSettings { constructor() { this.settings = DEFAULT_SETTINGS; this.loadSettings(); + } async loadSettings() { @@ -235,6 +237,32 @@ class ComfyStreamSettings { } return null; } + + async manageComfystream(action_type, action, data) { + try { + const response = await fetch('/comfystream/settings/manage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action_type: action_type, + action: action, + payload: data + }) + }); + + const result = await response.json(); + + if (response.ok) { + return result; + } else { + throw new Error(`${result.error}`); + } + } catch (error) { + throw error; + } + } } // Create a single instance of the settings manager @@ -257,7 +285,7 @@ async function showSettingsModal() { const style = document.createElement("style"); style.id = styleId; style.textContent = ` - #comfystream-settings-modal { + .comfystream-settings-modal { position: fixed; z-index: 10000; left: 0; @@ -506,6 +534,73 @@ async function showSettingsModal() { 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } } + + .cs-help-text { + display: none; + width: 400px; + padding-left: 80px; + padding-bottom: 10px; + padding-top: 0px; + margin-top: 0px; + font-size: 0.75em; + overflow-wrap: break-word; + font-style: italic; + } + + .loader { + width: 20px; + height: 20px; + border-radius: 50%; + display: inline-block; + position: relative; + border: 2px solid; + border-color: #FFF #FFF transparent transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + .loader::after, + .loader::before { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + border: 2px solid; + border-color: transparent transparent #FF3D00 #FF3D00; + width: 16px; + height: 16px; + border-radius: 50%; + box-sizing: border-box; + animation: rotationBack 0.5s linear infinite; + transform-origin: center center; + } + .loader::before { + width: 13px; + height: 13px; + border-color: #FFF #FFF transparent transparent; + animation: rotation 1.5s linear infinite; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + @keyframes rotationBack { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(-360deg); + } + } + `; document.head.appendChild(style); } @@ -513,6 +608,7 @@ async function showSettingsModal() { // Create modal container const modal = document.createElement("div"); modal.id = "comfystream-settings-modal"; + modal.className = "comfystream-settings-modal"; // Create modal content const modalContent = document.createElement("div"); @@ -586,6 +682,90 @@ async function showSettingsModal() { portGroup.appendChild(portLabel); portGroup.appendChild(portInput); + // Comfystream mgmt api actions + // Nodes management group + const nodesGroup = document.createElement("div"); + nodesGroup.className = "cs-input-group"; + + const nodesLabel = document.createElement("label"); + nodesLabel.textContent = "Nodes:"; + nodesLabel.className = "cs-label"; + + const installNodeButton = document.createElement("button"); + installNodeButton.textContent = "Install"; + installNodeButton.className = "cs-button"; + + const updateNodeButton = document.createElement("button"); + updateNodeButton.textContent = "Update"; + updateNodeButton.className = "cs-button"; + + const deleteNodeButton = document.createElement("button"); + deleteNodeButton.textContent = "Delete"; + deleteNodeButton.className = "cs-button"; + + const toggleNodeButton = document.createElement("button"); + toggleNodeButton.textContent = "Enable/Disable"; + toggleNodeButton.className = "cs-button"; + + const loadingNodes = document.createElement("span"); + loadingNodes.id = "comfystream-loading-nodes-spinner"; + loadingNodes.className = "loader"; + loadingNodes.style.display = "none"; // Initially hidden + + nodesGroup.appendChild(nodesLabel); + nodesGroup.appendChild(installNodeButton); + nodesGroup.appendChild(updateNodeButton); + nodesGroup.appendChild(deleteNodeButton); + nodesGroup.appendChild(toggleNodeButton); + nodesGroup.appendChild(loadingNodes); + + // Models management group + const modelsGroup = document.createElement("div"); + modelsGroup.className = "cs-input-group"; + + const modelsLabel = document.createElement("label"); + modelsLabel.textContent = "Models:"; + modelsLabel.className = "cs-label"; + + const addModelButton = document.createElement("button"); + addModelButton.textContent = "Add"; + addModelButton.className = "cs-button"; + + const deleteModelButton = document.createElement("button"); + deleteModelButton.textContent = "Delete"; + deleteModelButton.className = "cs-button"; + + const loadingModels = document.createElement("span"); + loadingModels.id = "comfystream-loading-models-spinner"; + loadingModels.className = "loader"; + loadingModels.style.display = "none"; // Initially hidden + + modelsGroup.appendChild(modelsLabel); + modelsGroup.appendChild(addModelButton); + modelsGroup.appendChild(deleteModelButton); + modelsGroup.appendChild(loadingModels); + + // turn server creds group + const turnServerCredsGroup = document.createElement("div"); + turnServerCredsGroup.className = "cs-input-group"; + + const turnServerCredsLabel = document.createElement("label"); + turnServerCredsLabel.textContent = "TURN Creds:"; + turnServerCredsLabel.className = "cs-label"; + + const setButton = document.createElement("button"); + setButton.textContent = "Set"; + setButton.className = "cs-button"; + + const turnServerCredsLoading = document.createElement("span"); + turnServerCredsLoading.id = "comfystream-loading-turn-server-creds-spinner"; + turnServerCredsLoading.className = "loader"; + turnServerCredsLoading.style.display = "none"; // Initially hidden + + turnServerCredsGroup.appendChild(turnServerCredsLabel); + turnServerCredsGroup.appendChild(setButton); + turnServerCredsGroup.appendChild(turnServerCredsLoading); + // Configurations section const configsSection = document.createElement("div"); configsSection.className = "cs-section"; @@ -659,6 +839,10 @@ async function showSettingsModal() { modal.remove(); }; + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-msg-txt"; + msgTxt.className = "cs-msg-text"; + footer.appendChild(cancelButton); footer.appendChild(saveButton); @@ -666,18 +850,107 @@ async function showSettingsModal() { form.appendChild(currentConfigDiv); form.appendChild(hostGroup); form.appendChild(portGroup); + form.appendChild(nodesGroup); + form.appendChild(modelsGroup); + form.appendChild(turnServerCredsGroup); form.appendChild(configsSection); modalContent.appendChild(closeButton); modalContent.appendChild(title); modalContent.appendChild(form); modalContent.appendChild(footer); + modalContent.appendChild(msgTxt); modal.appendChild(modalContent); // Add to document document.body.appendChild(modal); + async function manageNodes(action) { + //show the spinner to provide feedback + const loadingSpinner = document.getElementById("comfystream-loading-nodes-spinner"); + loadingSpinner.style.display = "inline-block"; + + try { + if (action === "install") { + await showInstallNodesModal(); + } else if (action === "update") { + await showUpdateNodesModal(); + } else if (action === "delete") { + await showDeleteNodesModal(); + } else if (action === "toggle") { + await showToggleNodesModal(); + } + + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } catch (error) { + console.error("[ComfyStream] Error installing node:", error); + app.ui.dialog.show('Error', `Failed to install node: ${error.message}`); + } + } + async function manageModels(action) { + //show the spinner to provide feedback + const loadingSpinner = document.getElementById("comfystream-loading-models-spinner"); + loadingSpinner.style.display = "inline-block"; + + try { + if (action === "add") { + await showAddModelsModal(); + } else if (action === "delete") { + await showDeleteModelsModal(); + } + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } catch (error) { + console.error("[ComfyStream] Error managing models:", error); + app.ui.dialog.show('Error', `Failed to manage models: ${error.message}`); + } + } + async function manageTurnServerCredentials(action) { + //show the spinner to provide feedback + const loadingSpinner = document.getElementById("comfystream-loading-turn-server-creds-spinner"); + loadingSpinner.style.display = "inline-block"; + + try { + if (action === "set") { + await showSetTurnServerCredsModal(); + } else if (action === "clear") { + await showClearTurnServerCredsModal(); + } + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } catch (error) { + console.error("[ComfyStream] Error managing TURN server credentials:", error); + app.ui.dialog.show('Error', `Failed to manage TURN server credentials: ${error.message}`); + } + + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } + // Add event listeners for nodes management buttons + installNodeButton.addEventListener("click", () => { + manageNodes("install"); + }); + updateNodeButton.addEventListener("click", () => { + manageNodes("update"); + }); + deleteNodeButton.addEventListener("click", () => { + manageNodes("delete"); + }); + toggleNodeButton.addEventListener("click", () => { + manageNodes("toggle"); + }); + // Add event listeners for models management buttons + addModelButton.addEventListener("click", () => { + manageModels("add"); + }); + deleteModelButton.addEventListener("click", () => { + manageModels("delete"); + }); + setButton.addEventListener("click", async () => { + await showSetTurnServerCredsModal(); + }); // Update configurations list async function updateConfigsList() { configsList.innerHTML = ""; @@ -850,11 +1123,1124 @@ async function showSettingsModal() { hostInput.focus(); } +async function showSetTurnServerCredsModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-set-turn-creds-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-set-turn-creds-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Set TURN Server Credentials"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + // account type + const accountTypeGroup = document.createElement("div"); + accountTypeGroup.className = "cs-input-group"; + + const accountTypeLabel = document.createElement("label"); + accountTypeLabel.textContent = "Account Type:"; + accountTypeLabel.className = "cs-label"; + + const accountTypeSelect = document.createElement("select"); + const accountItem = document.createElement("option"); + accountItem.value = "twilio"; + accountItem.textContent = "Twilio"; + accountTypeSelect.appendChild(accountItem); + accountTypeSelect.id = "comfystream-selected-turn-server-account-type"; + + const accountTypeHelpIcon = document.createElement("span"); + accountTypeHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + accountTypeHelpIcon.style.cursor = "pointer"; + accountTypeHelpIcon.style.marginLeft = "5px"; + accountTypeHelpIcon.title = "Click for help"; + + const accountTypeHelp = document.createElement("div"); + accountTypeHelp.textContent = "Specify the account type to use"; + accountTypeHelp.className = "cs-help-text"; + accountTypeHelp.style.display = "none"; + + accountTypeHelpIcon.addEventListener("click", () => { + if (accountTypeHelp.style.display == "none") { + accountTypeHelp.style.display = "block"; + } else { + accountTypeHelp.style.display = "none"; + } + }); + + accountTypeGroup.appendChild(accountTypeLabel); + accountTypeGroup.appendChild(accountTypeSelect); + accountTypeGroup.appendChild(accountTypeHelpIcon); + + // account id + const accountIdGroup = document.createElement("div"); + accountIdGroup.className = "cs-input-group"; + + const accountIdLabel = document.createElement("label"); + accountIdLabel.textContent = "Account ID:"; + accountIdLabel.className = "cs-label"; + + const accountIdInput = document.createElement("input"); + accountIdInput.id = "turn-server-creds-account-id"; + accountIdInput.className = "cs-input"; + + + const accountIdHelpIcon = document.createElement("span"); + accountIdHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + accountIdHelpIcon.style.cursor = "pointer"; + accountIdHelpIcon.style.marginLeft = "5px"; + accountIdHelpIcon.title = "Click for help"; + + const accountIdHelp = document.createElement("div"); + accountIdHelp.textContent = "Specify the account id for Twilio TURN server credentials"; + accountIdHelp.className = "cs-help-text"; + accountIdHelp.style.display = "none"; + + accountIdHelpIcon.addEventListener("click", () => { + if (accountIdHelp.style.display == "none") { + accountIdHelp.style.display = "block"; + } else { + accountIdHelp.style.display = "none"; + } + }); + + accountIdGroup.appendChild(accountIdLabel); + accountIdGroup.appendChild(accountIdInput); + accountIdGroup.appendChild(accountIdHelpIcon); + + // auth token + const accountAuthTokenGroup = document.createElement("div"); + accountAuthTokenGroup.className = "cs-input-group"; + + const accountAuthTokenLabel = document.createElement("label"); + accountAuthTokenLabel.textContent = "Auth Token:"; + accountAuthTokenLabel.className = "cs-label"; + + const accountAuthTokenInput = document.createElement("input"); + accountAuthTokenInput.id = "turn-server-creds-auth-token"; + accountAuthTokenInput.className = "cs-input"; + + const accountAuthTokenHelpIcon = document.createElement("span"); + accountAuthTokenHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + accountAuthTokenHelpIcon.style.cursor = "pointer"; + accountAuthTokenHelpIcon.style.marginLeft = "5px"; + accountAuthTokenHelpIcon.title = "Click for help"; + accountAuthTokenHelpIcon.style.position = "relative"; + + const accountAuthTokenHelp = document.createElement("div"); + accountAuthTokenHelp.textContent = "Specify the auth token provided by Twilio for TURN server credentials"; + accountAuthTokenHelp.className = "cs-help-text"; + accountAuthTokenHelp.style.display = "none"; + + accountAuthTokenHelpIcon.addEventListener("click", () => { + if (accountAuthTokenHelp.style.display == "none") { + accountAuthTokenHelp.style.display = "block"; + } else { + accountAuthTokenHelp.style.display = "none"; + } + }); + + accountAuthTokenGroup.appendChild(accountAuthTokenLabel); + accountAuthTokenGroup.appendChild(accountAuthTokenInput); + accountAuthTokenGroup.appendChild(accountAuthTokenHelpIcon); + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-turn-server-creds-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + const clearButton = document.createElement("button"); + clearButton.textContent = "Clear"; + clearButton.className = "cs-button"; + clearButton.onclick = () => { + const accountType = accountTypeSelect.options[accountTypeSelect.selectedIndex].value; + msgTxt.textContent = setTurnSeverCreds(accountType, "", ""); + }; + + const setButton = document.createElement("button"); + setButton.textContent = "Set"; + setButton.className = "cs-button primary"; + setButton.onclick = async () => { + const accountId = accountIdInput.value; + const authToken = accountAuthTokenInput.value; + const accountType = accountTypeSelect.options[accountTypeSelect.selectedIndex].value; + msgTxt.textContent = setTurnSeverCreds(accountType, accountId, authToken); + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(setButton); + + // Assemble the modal + form.appendChild(accountTypeGroup); + form.appendChild(accountTypeHelp); + form.appendChild(accountIdGroup); + form.appendChild(accountIdHelp); + form.appendChild(accountAuthTokenGroup); + form.appendChild(accountAuthTokenHelp); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function setTurnSeverCreds(accountType, accountId, authToken) { + try { + const payload = { + type: accountType, + account_id: accountId, + auth_token: authToken + } + + await settingsManager.manageComfystream( + "turn/server", + "set/account", + [payload] + ); + return "TURN server credentials updated successfully"; + } catch (error) { + console.error("[ComfyStream] Error adding model:", error); + msgTxt.textContent = error; + } +} + +async function showAddModelsModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-add-model-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-add-model-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Add Model"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + // URL of node to add + const modelUrlGroup = document.createElement("div"); + modelUrlGroup.className = "cs-input-group"; + + const modelUrlLabel = document.createElement("label"); + modelUrlLabel.textContent = "Url:"; + modelUrlLabel.className = "cs-label"; + + const modelUrlInput = document.createElement("input"); + modelUrlInput.id = "add-model-url"; + modelUrlInput.className = "cs-input"; + + + const modelUrlHelpIcon = document.createElement("span"); + modelUrlHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + modelUrlHelpIcon.style.cursor = "pointer"; + modelUrlHelpIcon.style.marginLeft = "5px"; + modelUrlHelpIcon.title = "Click for help"; + + const modelUrlHelp = document.createElement("div"); + modelUrlHelp.textContent = "Specify the url of the model download url"; + modelUrlHelp.className = "cs-help-text"; + modelUrlHelp.style.display = "none"; + + modelUrlHelpIcon.addEventListener("click", () => { + if (modelUrlHelp.style.display == "none") { + modelUrlHelp.style.display = "block"; + } else { + modelUrlHelp.style.display = "none"; + } + }); + + modelUrlGroup.appendChild(modelUrlLabel); + modelUrlGroup.appendChild(modelUrlInput); + modelUrlGroup.appendChild(modelUrlHelpIcon); + + // branch of node to add + const modelTypeGroup = document.createElement("div"); + modelTypeGroup.className = "cs-input-group"; + + const modelTypeLabel = document.createElement("label"); + modelTypeLabel.textContent = "Type:"; + modelTypeLabel.className = "cs-label"; + + const modelTypeInput = document.createElement("input"); + modelTypeInput.id = "add-node-branch"; + modelTypeInput.className = "cs-input"; + + const modelTypeHelpIcon = document.createElement("span"); + modelTypeHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + modelTypeHelpIcon.style.cursor = "pointer"; + modelTypeHelpIcon.style.marginLeft = "5px"; + modelTypeHelpIcon.title = "Click for help"; + modelTypeHelpIcon.style.position = "relative"; + + const modelTypeHelp = document.createElement("div"); + modelTypeHelp.textContent = "Specify the type of model that is the top level folder under 'models' folder (e.g. 'checkpoints' = models/checkpoints)"; + modelTypeHelp.className = "cs-help-text"; + modelTypeHelp.style.display = "none"; + + modelTypeHelpIcon.addEventListener("click", () => { + if (modelTypeHelp.style.display == "none") { + modelTypeHelp.style.display = "block"; + } else { + modelTypeHelp.style.display = "none"; + } + }); + + modelTypeGroup.appendChild(modelTypeLabel); + modelTypeGroup.appendChild(modelTypeInput); + modelTypeGroup.appendChild(modelTypeHelpIcon); + + + // dependencies of node to add + const modelPathGroup = document.createElement("div"); + modelPathGroup.className = "cs-input-group"; + + const modelPathLabel = document.createElement("label"); + modelPathLabel.textContent = "Path:"; + modelPathLabel.className = "cs-label"; + + const modelPathInput = document.createElement("input"); + modelPathInput.id = "add-model-path"; + modelPathInput.className = "cs-input"; + + const modelPathHelpIcon = document.createElement("span"); + modelPathHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + modelPathHelpIcon.style.cursor = "pointer"; + modelPathHelpIcon.style.marginLeft = "5px"; + modelPathHelpIcon.title = "Click for help"; + + const modelPathHelp = document.createElement("div"); + modelPathHelp.textContent = "Input the path of the model file (including file name, 'SD1.5/model.safetensors' = checkpoints/SD1.5/model.safetensors)"; + modelPathHelp.className = "cs-help-text"; + modelPathHelp.style.display = "none"; + + modelPathHelpIcon.addEventListener("click", () => { + if (modelPathHelp.style.display == "none") { + modelPathHelp.style.display = "block"; + } else { + modelPathHelp.style.display = "none"; + } + }); + + modelPathGroup.appendChild(modelPathLabel); + modelPathGroup.appendChild(modelPathInput); + modelPathGroup.appendChild(modelPathHelpIcon); + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-models-add-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const addButton = document.createElement("button"); + addButton.textContent = "Add"; + addButton.className = "cs-button primary"; + addButton.onclick = async () => { + const modelUrl = modelUrlInput.value; + const modelType = modelTypeInput.value; + const modelPath = modelPathInput.value; + const payload = { + url: modelUrl, + type: modelType, + path: modelPath + }; + + try { + await settingsManager.manageComfystream( + "models", + "add", + [payload] + ); + msgTxt.textContent = "Model added successfully"; + } catch (error) { + console.error("[ComfyStream] Error adding model:", error); + msgTxt.textContent = error; + } + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(addButton); + + // Assemble the modal + form.appendChild(modelUrlGroup); + form.appendChild(modelUrlHelp); + form.appendChild(modelTypeGroup); + form.appendChild(modelTypeHelp); + form.appendChild(modelPathGroup); + form.appendChild(modelPathHelp); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showDeleteModelsModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-delete-model-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-delete-model-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Delete Model"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-models-delete-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const modelSelect = document.createElement("select"); + modelSelect.id = "comfystream-selected-model"; + try { + const models = await settingsManager.manageComfystream( + "models", + "list", + "" + ); + for (const model_type in models.models) { + for (const model of models.models[model_type]) { + const modelItem = document.createElement("option"); + modelItem.setAttribute("model-type", model.type); + modelItem.value = model.path; + modelItem.textContent = model.type + " | " + model.path; + modelSelect.appendChild(modelItem); + } + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const deleteButton = document.createElement("button"); + deleteButton.textContent = "Delete"; + deleteButton.className = "cs-button primary"; + deleteButton.onclick = async () => { + const modelPath = modelSelect.options[modelSelect.selectedIndex].value; + const modelType = modelSelect.options[modelSelect.selectedIndex].getAttribute("model-type"); + const payload = { + type: modelType, + path: modelPath + }; + + try { + await settingsManager.manageComfystream( + "models", + "delete", + [payload] + ); + msgTxt.textContent = "Model deleted successfully"; + } catch (error) { + console.error("[ComfyStream] Error deleting model:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(deleteButton); + + // Assemble the modal + form.appendChild(modelSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showToggleNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-toggle-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-toggle-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Enable/Disable Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + const toggleNodesModalContent = document.createElement("div"); + toggleNodesModalContent.className = "cs-modal-content"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-toggle-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const nodeSelect = document.createElement("select"); + let initialAction = "Enable"; + nodeSelect.id = "comfystream-selected-node"; + try { + const nodes = await settingsManager.manageComfystream( + "nodes", + "list", + "" + ); + for (const node of nodes.nodes) { + const nodeItem = document.createElement("option"); + nodeItem.value = node.name; + nodeItem.textContent = node.name; + nodeItem.setAttribute("node-is-disabled", node.disabled); + if (!node.disabled) { + initialAction = "Disable"; + } + nodeSelect.appendChild(nodeItem); + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const toggleButton = document.createElement("button"); + toggleButton.textContent = initialAction; + toggleButton.className = "cs-button primary"; + toggleButton.onclick = async () => { + const nodeName = nodeSelect.options[nodeSelect.selectedIndex].value; + const payload = { + name: nodeName, + }; + const action = toggleButton.textContent === "Enable" ? "enable" : "disable"; + + try { + await settingsManager.manageComfystream( + "nodes", + "toggle", + [payload] + ); + msgTxt.textContent = `Node ${action} successfully`; + } catch (error) { + + } + + + }; + + //update the action based on if the node is disabled or not currently + nodeSelect.onchange = () => { + const selectedNode = nodeSelect.options[nodeSelect.selectedIndex]; + const isDisabled = selectedNode.getAttribute("node-is-disabled") === "true"; + if (isDisabled) { + toggleButton.textContent = "Enable"; + } else { + toggleButton.textContent = "Disable"; + } + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(toggleButton); + + // Assemble the modal + form.appendChild(nodeSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showDeleteNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-delete-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-delete-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Delete Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-delete-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const nodeSelect = document.createElement("select"); + nodeSelect.id = "comfystream-selected-node"; + try { + const nodes = await settingsManager.manageComfystream( + "nodes", + "list", + "" + ); + for (const node of nodes.nodes) { + const nodeItem = document.createElement("option"); + nodeItem.value = node.name; + nodeItem.textContent = node.name; + nodeSelect.appendChild(nodeItem); + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const deleteButton = document.createElement("button"); + deleteButton.textContent = "Delete"; + deleteButton.className = "cs-button primary"; + deleteButton.onclick = async () => { + const nodeName = nodeSelect.options[nodeSelect.selectedIndex].value; + const payload = { + name: nodeName, + }; + + try { + await settingsManager.manageComfystream( + "nodes", + "delete", + [payload] + ); + msgTxt.textContent = "Node deleted successfully"; + } catch (error) { + console.error("[ComfyStream] Error deleting node:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(deleteButton); + + // Assemble the modal + form.appendChild(nodeSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showUpdateNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-update-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-update-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Update Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-update-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const nodeSelect = document.createElement("select"); + nodeSelect.id = "comfystream-selected-node"; + try { + const nodes = await settingsManager.manageComfystream( + "nodes", + "list", + "" + ); + let updateAvailable = false; + for (const node of nodes.nodes) { + if (node.update_available && node.update_available != "unknown" && node.url != "unknown") { + updateAvailable = true; + const nodeItem = document.createElement("option"); + nodeItem.value = node.url; + nodeItem.textContent = node.name; + nodeSelect.appendChild(nodeItem); + } + } + if (!updateAvailable) { + msgTxt.textContent = "No updates available for any nodes."; + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const installButton = document.createElement("button"); + installButton.textContent = "Update"; + installButton.className = "cs-button primary"; + installButton.onclick = async () => { + const nodeUrl = nodeSelect.options[nodeSelect.selectedIndex].value; + const payload = { + url: nodeUrl, + }; + + try { + await settingsManager.manageComfystream( + "nodes", + "install", + [payload] + ); + msgTxt.textContent = "Node updated successfully"; + } catch (error) { + console.error("[ComfyStream] Error updating node:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(installButton); + + // Assemble the modal + form.appendChild(nodeSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showInstallNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-add-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-add-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Add Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + // URL of node to add + const nodeUrlGroup = document.createElement("div"); + nodeUrlGroup.className = "cs-input-group"; + + const nodeUrlLabel = document.createElement("label"); + nodeUrlLabel.textContent = "Url:"; + nodeUrlLabel.className = "cs-label"; + + const nodeUrlInput = document.createElement("input"); + nodeUrlInput.id = "add-node-url"; + nodeUrlInput.className = "cs-input"; + + + const nodeUrlHelpIcon = document.createElement("span"); + nodeUrlHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + nodeUrlHelpIcon.style.cursor = "pointer"; + nodeUrlHelpIcon.style.marginLeft = "5px"; + nodeUrlHelpIcon.title = "Click for help"; + + const nodeUrlHelp = document.createElement("div"); + nodeUrlHelp.textContent = "Specify the url of the github repo for the custom node want to install (can have .git at end of url)"; + nodeUrlHelp.className = "cs-help-text"; + nodeUrlHelp.style.display = "none"; + + nodeUrlHelpIcon.addEventListener("click", () => { + if (nodeUrlHelp.style.display == "none") { + nodeUrlHelp.style.display = "block"; + } else { + nodeUrlHelp.style.display = "none"; + } + }); + + nodeUrlGroup.appendChild(nodeUrlLabel); + nodeUrlGroup.appendChild(nodeUrlInput); + nodeUrlGroup.appendChild(nodeUrlHelpIcon); + + // branch of node to add + const nodeBranchGroup = document.createElement("div"); + nodeBranchGroup.className = "cs-input-group"; + + const nodeBranchLabel = document.createElement("label"); + nodeBranchLabel.textContent = "Branch:"; + nodeBranchLabel.className = "cs-label"; + + const nodeBranchInput = document.createElement("input"); + nodeBranchInput.id = "add-node-branch"; + nodeBranchInput.className = "cs-input"; + + const nodeBranchHelpIcon = document.createElement("span"); + nodeBranchHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + nodeBranchHelpIcon.style.cursor = "pointer"; + nodeBranchHelpIcon.style.marginLeft = "5px"; + nodeBranchHelpIcon.title = "Click for help"; + nodeBranchHelpIcon.style.position = "relative"; + + const nodeBranchHelp = document.createElement("div"); + nodeBranchHelp.textContent = "Specify the branch of the node you want to add. For example, 'main' or 'develop'."; + nodeBranchHelp.className = "cs-help-text"; + nodeBranchHelp.style.display = "none"; + + nodeBranchHelpIcon.addEventListener("click", () => { + if (nodeBranchHelp.style.display == "none") { + nodeBranchHelp.style.display = "block"; + } else { + nodeBranchHelp.style.display = "none"; + } + }); + + nodeBranchGroup.appendChild(nodeBranchLabel); + nodeBranchGroup.appendChild(nodeBranchInput); + nodeBranchGroup.appendChild(nodeBranchHelpIcon); + + + // dependencies of node to add + const nodeDepsGroup = document.createElement("div"); + nodeDepsGroup.className = "cs-input-group"; + + const nodeDepsLabel = document.createElement("label"); + nodeDepsLabel.textContent = "Deps:"; + nodeDepsLabel.className = "cs-label"; + + const nodeDepsInput = document.createElement("input"); + nodeDepsInput.id = "add-node-deps"; + nodeDepsInput.className = "cs-input"; + + const nodeDepsHelpIcon = document.createElement("span"); + nodeDepsHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + nodeDepsHelpIcon.style.cursor = "pointer"; + nodeDepsHelpIcon.style.marginLeft = "5px"; + nodeDepsHelpIcon.title = "Click for help"; + + const nodeDepsHelp = document.createElement("div"); + nodeDepsHelp.textContent = "Comma separated list of python packages to install with pip (required packages outside requirements.txt)"; + nodeDepsHelp.className = "cs-help-text"; + nodeDepsHelp.style.display = "none"; + + nodeDepsHelpIcon.addEventListener("click", () => { + if (nodeDepsHelp.style.display == "none") { + nodeDepsHelp.style.display = "block"; + } else { + nodeDepsHelp.style.display = "none"; + } + }); + + nodeDepsGroup.appendChild(nodeDepsLabel); + nodeDepsGroup.appendChild(nodeDepsInput); + nodeDepsGroup.appendChild(nodeDepsHelpIcon); + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-install-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const installButton = document.createElement("button"); + installButton.textContent = "Install"; + installButton.className = "cs-button primary"; + installButton.onclick = async () => { + const nodeUrl = nodeUrlInput.value; + const nodeBranch = nodeBranchInput.value; + const nodeDeps = nodeDepsInput.value; + const payload = { + url: nodeUrl, + branch: nodeBranch, + dependencies: nodeDeps + }; + + try { + await settingsManager.manageComfystream( + "nodes", + "install", + [payload] + ); + msgTxt.textContent = "Node installed successfully!"; + } catch (error) { + console.error("[ComfyStream] Error installing node:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(installButton); + + // Assemble the modal + form.appendChild(nodeUrlGroup); + form.appendChild(nodeUrlHelp); + form.appendChild(nodeBranchGroup); + form.appendChild(nodeBranchHelp); + form.appendChild(nodeDepsGroup); + form.appendChild(nodeDepsHelp); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} // Export for use in other modules -export { settingsManager, showSettingsModal }; +export { settingsManager, showSettingsModal, showInstallNodesModal, showUpdateNodesModal, showDeleteNodesModal, showToggleNodesModal, showAddModelsModal, showDeleteModelsModal, showSetTurnServerCredsModal }; // Also keep the global for backward compatibility window.comfyStreamSettings = { settingsManager, - showSettingsModal + showSettingsModal, + showInstallNodesModal, + showUpdateNodesModal, + showDeleteNodesModal, + showToggleNodesModal, + showAddModelsModal, + showDeleteModelsModal, + showSetTurnServerCredsModal }; \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 96fc96cf..3e8d67a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,8 @@ dependencies = [ "toml", "twilio", "prometheus_client", - "librosa" + "librosa", + "GitPython" ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index f94f3345..e708cd4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ toml twilio prometheus_client librosa +GitPython \ No newline at end of file diff --git a/server/api/__init__.py b/server/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/api.py b/server/api/api.py new file mode 100644 index 00000000..ac4a623f --- /dev/null +++ b/server/api/api.py @@ -0,0 +1,363 @@ +from aiohttp import web +import asyncio + +from api.nodes.nodes import list_nodes, install_node, delete_node, toggle_node +from api.models.models import list_models, add_model, delete_model +from api.settings.settings import set_twilio_account_info, restart_comfyui +from comfystream.pipeline import Pipeline + +from comfy.nodes.package import _comfy_nodes, import_all_nodes_in_workspace + +from api.nodes.nodes import force_import_all_nodes_in_workspace +#use a different node import +import_all_nodes_in_workspace = force_import_all_nodes_in_workspace + +def add_mgmt_api_routes(app): + app.router.add_get("/settings/nodes/list", nodes) + app.router.add_post("/settings/nodes/list", nodes) + app.router.add_post("/settings/nodes/install", install_nodes) + app.router.add_post("/settings/nodes/delete", delete_nodes) + app.router.add_post("/settings/nodes/toggle", toggle_nodes) + + app.router.add_get("/settings/models/list", models) + app.router.add_post("/settings/models/list", models) + app.router.add_post("/settings/models/add", add_models) + app.router.add_post("/settings/models/delete", delete_models) + + app.router.add_post("/settings/comfystream/reload", reload) + app.router.add_post("/settings/comfyui/restart", restart_comfyui_process) + app.router.add_post("/settings/turn/server/set/account", set_account_info) + + +async def reload(request): + ''' + Reload ComfyUI environment + + ''' + + #reset embedded client + await request.app["pipeline"].cleanup() + + #reset imports to clear imported nodes + global _comfy_nodes + import_all_nodes_in_workspace = force_import_all_nodes_in_workspace + _comfy_nodes = import_all_nodes_in_workspace() + + #reload pipeline + request.app["pipeline"] = Pipeline(cwd=request.app["workspace"], disable_cuda_malloc=True, gpu_only=True) + + #reset webrtc connections + pcs = request.app["pcs"] + coros = [pc.close() for pc in pcs] + await asyncio.gather(*coros) + pcs.clear() + + return web.json_response({"success": True, "error": None}) + +async def nodes(request): + ''' + List all custom nodes in the workspace + + # Example response: + { + "error": null, + "nodes": + [ + { + "name": ComfyUI-Custom-Node, + "version": "0.0.1", + "url": "https://github.com/custom-node-maker/ComfyUI-Custom-Node", + "branch": "main", + "commit": "uasfg98", + "update_available": false, + }, + { + ... + }, + { + ... + } + ] + } + + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await list_nodes(workspace_dir) + return web.json_response({"success": True, "error": None, "nodes": nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "nodes": nodes}, status=500) + +async def install_nodes(request): + ''' + Install ComfyUI custom node from git repository. + + Installs requirements.txt from repository if present + + # Parameters: + url: url of the git repository + branch: branch of the git repository + depdenencies: comma separated list of dependencies to install with pip (optional) + + # Example request: + [ + { + "url": "https://github.com/custom-node-maker/ComfyUI-Custom-Node", + "branch": "main" + }, + { + "url": "https://github.com/custom-node-maker/ComfyUI-Custom-Node", + "branch": "main", + "dependencies": "requests, numpy" + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await request.json() + installed_nodes = [] + for node in nodes: + await install_node(node, workspace_dir) + installed_nodes.append(node['url']) + + return web.json_response({"success": True, "error": None, "installed_nodes": installed_nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "installed_nodes": installed_nodes}, status=500) + +async def delete_nodes(request): + ''' + Delete ComfyUI custom node + + # Parameters: + name: name of the repository (e.g. ComfyUI-Custom-Node for url "https://github.com/custom-node-maker/ComfyUI-Custom-Node") + + # Example request: + [ + { + "name": "ComfyUI-Custom-Node" + }, + { + ... + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await request.json() + deleted_nodes = [] + for node in nodes: + await delete_node(node, workspace_dir) + deleted_nodes.append(node['name']) + return web.json_response({"success": True, "error": None, "deleted_nodes": deleted_nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "deleted_nodes": deleted_nodes}, status=500) + +async def toggle_nodes(request): + ''' + Enable/Disable ComfyUI custom node + + # Parameters: + name: name of the node (e.g. ComfyUI-Custom-Node) + + # Example request: + [ + { + "name": "ComfyUI-Custom-Node" + }, + { + ... + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await request.json() + toggled_nodes = [] + for node in nodes: + await toggle_node(node, workspace_dir) + toggled_nodes.append(node['name']) + return web.json_response({"success": True, "error": None, "toggled_nodes": toggled_nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "toggled_nodes": toggled_nodes}, status=500) + +async def models(request): + ''' + List all custom models in the workspace + + # Example response: + { + "error": null, + "models": + { + "checkpoints": [ + { + "name": "dreamshaper-8.safetensors", + "path": "SD1.5/dreamshaper-8.safetensors", + "type": "checkpoint", + "downloading": false" + } + ], + "controlnet": [ + { + "name": "controlnet.sd15.safetensors", + "path": "SD1.5/controlnet.sd15.safetensors", + "type": "controlnet", + "downloading": false" + } + ], + "unet": [ + { + "name": "unet.sd15.safetensors", + "path": "SD1.5/unet.sd15.safetensors", + "type": "unet", + "downloading": false" + } + ], + "vae": [ + { + "name": "vae.safetensors", + "path": "vae.safetensors", + "type": "vae", + "downloading": false" + } + ], + "tensorrt": [ + { + "name": "model.trt", + "path": "model.trt", + "type": "tensorrt", + "downloading": false" + } + ] + } + } + + ''' + workspace_dir = request.app["workspace"] + try: + models = await list_models(workspace_dir) + return web.json_response({"error": None, "models": models}) + except Exception as e: + return web.json_response({"error": str(e), "models": {}}, status=500) + +async def add_models(request): + ''' + Download models from url + + # Parameters: + url: url of the git repository + type: type of model (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt) + path: path of the model. supports up to 1 subfolder (e.g. SD1.5/newmodel.safetensors) + + # Example request: + [ + { + "url": "http://url.to/model.safetensors", + "type": "checkpoints" + }, + { + "url": "http://url.to/controlnet.super.safetensors", + "type": "controlnet", + "path": "SD1.5/controlnet.super.safetensors" + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + models = await request.json() + added_models = [] + for model in models: + await add_model(model, workspace_dir) + added_models.append(model['url']) + return web.json_response({"success": True, "error": None, "added_models": added_models}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "added_nodes": added_models}, status=500) + +async def delete_models(request): + ''' + Delete model + + # Parameters: + type: type of model (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt) + path: path of the model. supports up to 1 subfolder (e.g. SD1.5/newmodel.safetensors) + + # Example request: + [ + { + "type": "checkpoints", + "path": "model.safetensors" + }, + { + "type": "controlnet", + "path": "SD1.5/controlnet.super.safetensors" + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + models = await request.json() + deleted_models = [] + for model in models: + await delete_model(model, workspace_dir) + deleted_models.append(model['path']) + return web.json_response({"success": True, "error": None, "deleted_models": deleted_models}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "deleted_models": deleted_models}, status=500) + +async def set_account_info(request): + ''' + Set account info for ice server providers + + # Parameters: + type: account type (e.g. twilio) + account_id: account id from provider + auth_token: auth token from provider + + # Example request: + [ + { + "type": "twilio", + "account_id": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "auth_token": "your_auth_token" + }, + { + ... + } + ] + + ''' + try: + accounts = await request.json() + accounts_updated = [] + for account in accounts: + if 'type' in account: + if account['type'] == 'twilio': + await set_twilio_account_info(account) + accounts_updated.append(account['type']) + return web.json_response({"success": True, "error": None, "accounts_updated": accounts_updated}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "accounts_updated": accounts_updated}, status=500) + +async def restart_comfyui_process(request): + ''' + Restart comfyui process + + # Parameters: + None + + # Example request: + [ + { + "restart": "comfyui", + } + ] + + ''' + print("restarting comfyui process") + try: + restart_process = await request.json() + if restart_process["restart"] == "comfyui": + await restart_comfyui(request.app["workspace"]) + return web.json_response({"success": True, "error": None}) + except Exception as e: + return web.json_response({"success": False, "error": str(e)}, status=500) \ No newline at end of file diff --git a/server/api/models/__init__.py b/server/api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/models/models.py b/server/api/models/models.py new file mode 100644 index 00000000..e7d2e4a6 --- /dev/null +++ b/server/api/models/models.py @@ -0,0 +1,137 @@ +import asyncio +from pathlib import Path +import os +import logging +from aiohttp import ClientSession + +logger = logging.getLogger(__name__) + +async def list_models(workspace_dir): + models_path = Path(os.path.join(workspace_dir, "models")) + models_path.mkdir(parents=True, exist_ok=True) + os.chdir(models_path) + + model_types = ["checkpoints", "controlnet", "unet", "vae", "onnx", "tensorrt"] + + models = {} + try: + for model_type in models_path.iterdir(): + model_name = "" + model_subfolder = "" + model_type_name = model_type.name + if model_type.is_dir(): + models[model_type_name] = [] + for model in model_type.iterdir(): + if model.is_dir(): + #models in subfolders (e.g. checkpoints/sd1.5/model.safetensors) + for submodel in model.iterdir(): + if submodel.is_file(): + model_name = submodel.name + model_subfolder = model.name + else: + #models not in subfolders (e.g. checkpoints/model.safetensors) + logger.info(f"model: {model.name}") + model_name = model.name + model_subfolder = "" + + #add model to list + model_info = await create_model_info(model_name, model_subfolder, model_type_name) + models[model_type_name].append(model_info) + else: + if not model_type.name in model_types: + models["none"] = [] + + model_name = model_type_name + model_subfolder = "" + + #add model to list + model_info = await create_model_info(model_name, model_subfolder, model_type_name) + models[model_type_name].append(model_info) + except Exception as e: + logger.error(f"error listing models: {e}") + raise Exception(f"error listing models: {e}") + return models + +async def create_model_info(model, model_subfolder, model_type): + model_path = f"{model_subfolder}/{model}" if model_subfolder else model + logger.info(f"adding info for model: {model_type}/{model_path}") + model_info = { + "name": model, + "path": model_path, + "type": model_type, + "downloading": os.path.exists(f"{model_path}.downloading") + } + return model_info + +async def add_model(model, workspace_dir): + if not 'url' in model: + raise Exception("model url is required") + if not 'type' in model: + raise Exception("model type is required (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt)") + + try: + model_name = model['url'].split("/")[-1] + model_path = Path(os.path.join(workspace_dir, "models", model['type'], model_name)) + #if specified, use the model path from the model dict (e.g. sd1.5/model.safetensors will put model.safetensors in models/checkpoints/sd1.5) + if 'path' in model: + model_path = Path(os.path.join(workspace_dir, "models", model['type'], model['path'])) + logger.info(f"model path: {model_path}") + + # check path is in workspace_dir, raises value error if not + model_path.resolve().relative_to(Path(os.path.join(workspace_dir, "models"))) + os.makedirs(model_path.parent, exist_ok=True) + # start downloading the model in background without blocking + asyncio.create_task(download_model(model['url'], model_path)) + except Exception as e: + os.remove(model_path)+".downloading" + raise Exception(f"error downloading model: {e}") + +async def delete_model(model, workspace_dir): + if not 'type' in model: + raise Exception("model type is required (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt)") + if not 'path' in model: + raise Exception("model path is required") + try: + model_path = Path(os.path.join(workspace_dir, "models", model['type'], model['path'])) + #check path is in workspace_dir, raises value error if not + model_path.resolve().relative_to(Path(os.path.join(workspace_dir, "models"))) + + os.remove(model_path) + except Exception as e: + raise Exception(f"error deleting model: {e}") + +async def download_model(url: str, save_path: Path): + try: + temp_file = save_path.with_suffix(save_path.suffix + ".downloading") + print("downloading") + async with ClientSession() as session: + logger.info(f"downloading model from {url} to {save_path}") + # Create empty file to track download in process + model_name = os.path.basename(save_path) + + open(temp_file, "w").close() + + async with session.get(url) as response: + if response.status == 200: + total_size = int(response.headers.get('Content-Length', 0)) + total_downloaded = 0 + last_logged_percentage = -1 # Ensures first log at 1% + with open(save_path, "wb") as f: + while chunk := await response.content.read(4096): # Read in chunks of 1KB + f.write(chunk) + total_downloaded += len(chunk) + # Calculate percentage and log only if it has increased by 1% + percentage = (total_downloaded / total_size) * 100 + if int(percentage) > last_logged_percentage: + last_logged_percentage = int(percentage) + logger.info(f"Downloaded {total_downloaded} of {total_size} bytes ({percentage:.2f}%) of {model_name}") + + #remove download in process file + os.remove(temp_file) + logger.info(f"Model downloaded and saved to {save_path}") + else: + raise print(f"Failed to download model. HTTP Status: {response.status}") + except Exception as e: + #remove download in process file + logger.error(f"error downloading model: {str(e)}") + os.remove(temp_file) \ No newline at end of file diff --git a/server/api/nodes/__init__.py b/server/api/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/nodes/nodes.py b/server/api/nodes/nodes.py new file mode 100644 index 00000000..8e40984c --- /dev/null +++ b/server/api/nodes/nodes.py @@ -0,0 +1,227 @@ +from pathlib import Path +import os +import json +from git import Repo +import logging +import subprocess +import shutil + +logger = logging.getLogger(__name__) + +async def list_nodes(workspace_dir): + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + + nodes = [] + for node in custom_nodes_path.iterdir(): + if node.name == "__pycache__": + continue + + if node.is_dir(): + logger.info(f"getting info for node: { node.name}") + node_info = { + "name": node.name, + "version": "unknown", + "url": "unknown", + "branch": "unknown", + "commit": "unknown", + "update_available": "unknown", + "disabled": False, + } + if node.name.endswith(".disabled"): + node_info["disabled"] = True + node_info["name"] = node.name[:-9] + + #include VERSION if set in file + version_file = os.path.join(custom_nodes_path, node.name, "VERSION") + if os.path.exists(version_file): + node_info["version"] = json.dumps(open(version_file).readline().strip()) + + #include git info if available + try: + repo = Repo(node) + node_info["url"] = repo.remotes.origin.url.replace(".git","") + node_info["commit"] = repo.head.commit.hexsha[:7] + if not repo.head.is_detached: + node_info["branch"] = repo.active_branch.name + fetch_info = repo.remotes.origin.fetch(repo.active_branch.name) + node_info["update_available"] = repo.head.commit.hexsha[:7] != fetch_info[0].commit.hexsha[:7] + else: + node_info["branch"] = "detached" + + except Exception as e: + logger.info(f"error getting repo info for {node.name} {e}") + + nodes.append(node_info) + + return nodes + +async def install_node(node, workspace_dir): + ''' + install ComfyUI custom node in git repository. + + installs requirements.txt from repository if present + + # Paramaters + url: url of the git repository + branch: branch to install + dependencies: comma separated list of pip dependencies to install + ''' + + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + node_url = node.get("url", "") + if node_url == "": + raise ValueError("url is required") + + if not ".git" in node_url: + node_url = f"{node_url}.git" + + try: + dir_name = node_url.split("/")[-1].replace(".git", "") + node_path = custom_nodes_path / dir_name + if not node_path.exists(): + # Clone and install the repository if it doesn't already exist + logger.info(f"installing {dir_name}...") + repo = Repo.clone_from(node["url"], node_path, depth=1) + if "branch" in node and node["branch"] != "": + repo.git.checkout(node['branch']) + else: + # Update the repository if it already exists + logger.info(f"updating node {dir_name}") + repo = Repo(node_path) + repo.remotes.origin.fetch() + branch = node.get("branch", "") + if branch == "": + branch = repo.remotes.origin.refs[0].remote_head + + repo.git.checkout(branch) + + # Install requirements if present + requirements_file = node_path / "requirements.txt" + if requirements_file.exists(): + subprocess.run(["conda", "run", "-n", "comfystream", "pip", "install", "-r", str(requirements_file)], check=True) + + # Install additional dependencies if specified + if "dependencies" in node and node["dependencies"] != "": + for dep in node["dependencies"].split(','): + subprocess.run(["conda", "run", "-n", "comfystream", "pip", "install", dep.strip()], check=True) + + except Exception as e: + logger.error(f"Error installing {dir_name} {e}") + raise e + +async def delete_node(node, workspace_dir): + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + if "name" not in node: + raise ValueError("name is required") + + node_path = custom_nodes_path / node["name"] + if not node_path.exists(): + raise ValueError(f"node {node['name']} does not exist") + try: + #delete the folder and all its contents. ignore_errors allows readonly files to be deleted + logger.info(f"deleting node {node['name']}") + shutil.rmtree(node_path, ignore_errors=True) + except Exception as e: + logger.error(f"error deleting node {node['name']}") + raise Exception(f"error deleting node: {e}") + +async def toggle_node(node, workspace_dir): + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + if "name" not in node: + logger.error("name is required") + raise ValueError("name is required") + + node_path = custom_nodes_path / node["name"] + is_disabled = False + #confirm if enabled node exists + logger.info(f"toggling node { node['name'] }") + if not node_path.exists(): + #try with .disabled + node_path = custom_nodes_path / str(node['name']+".disabled") + logger.info(f"checking if node { node['name'] } is disabled") + if not node_path.exists(): + #node does not exist as enabled or disabled + logger.info(f"node { node['name'] }.disabled does not exist") + raise ValueError(f"node { node['name'] } does not exist") + else: + #node is disabled, so we need to enable it + logger.error(f"node { node['name'] } is disabled") + is_disabled = True + else: + logger.info(f"node { node['name'] } is enabled") + + try: + if is_disabled: + #rename the folder to remove .disabled + logger.info(f"enabling node {node['name']}") + new_name = node_path.with_name(node["name"]) + shutil.move(str(node_path), str(new_name)) + else: + #rename the folder to add .disabled + logger.info(f"disbling node {node['name']}") + new_name = node_path.with_name(node["name"]+".disabled") + shutil.move(str(node_path), str(new_name)) + except Exception as e: + logger.error(f"error {action} node {node['name']}: {e}") + raise Exception(f"error {action} node: {e}") + +from comfy.nodes.package import ExportedNodes +from comfy.nodes.package import _comfy_nodes, _import_and_enumerate_nodes_in_module +from functools import reduce +from importlib.metadata import entry_points +import types + +def force_import_all_nodes_in_workspace(vanilla_custom_nodes=True, raise_on_failure=False) -> ExportedNodes: + # now actually import the nodes, to improve control of node loading order + from comfy_extras import nodes as comfy_extras_nodes # pylint: disable=absolute-import-used + from comfy.cli_args import args + from comfy.nodes import base_nodes + from comfy.nodes.vanilla_node_importing import mitigated_import_of_vanilla_custom_nodes + + base_and_extra = reduce(lambda x, y: x.update(y), + map(lambda module_inner: _import_and_enumerate_nodes_in_module(module_inner, raise_on_failure=raise_on_failure), [ + # this is the list of default nodes to import + base_nodes, + comfy_extras_nodes + ]), + ExportedNodes()) + custom_nodes_mappings = ExportedNodes() + + if args.disable_all_custom_nodes: + logging.info("Loading custom nodes was disabled, only base and extra nodes were loaded") + _comfy_nodes.update(base_and_extra) + return _comfy_nodes + + # load from entrypoints + for entry_point in entry_points().select(group='comfyui.custom_nodes'): + # Load the module associated with the current entry point + try: + module = entry_point.load() + except ModuleNotFoundError as module_not_found_error: + logging.error(f"A module was not found while importing nodes via an entry point: {entry_point}. Please ensure the entry point in setup.py is named correctly", exc_info=module_not_found_error) + continue + + # Ensure that what we've loaded is indeed a module + if isinstance(module, types.ModuleType): + custom_nodes_mappings.update( + _import_and_enumerate_nodes_in_module(module, print_import_times=True)) + + # load the vanilla custom nodes last + if vanilla_custom_nodes: + custom_nodes_mappings += mitigated_import_of_vanilla_custom_nodes() + + # don't allow custom nodes to overwrite base nodes + custom_nodes_mappings -= base_and_extra + + _comfy_nodes.update(base_and_extra + custom_nodes_mappings) + + return _comfy_nodes + diff --git a/server/api/settings/__init__.py b/server/api/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/settings/settings.py b/server/api/settings/settings.py new file mode 100644 index 00000000..6cf40824 --- /dev/null +++ b/server/api/settings/settings.py @@ -0,0 +1,14 @@ +import os +from pathlib import Path +import psutil +import subprocess +import sys + +async def set_twilio_account_info(account_sid, auth_token): + if not account_sid is None: + os.environ["TWILIO_ACCOUNT_SID"] = account_sid + if not auth_token is None: + os.environ["TWILIO_AUTH_TOKEN"] = auth_token + +async def restart_comfyui(workspace_dir): + subprocess.run(["supervisorctl", "restart", "comfyui"], check=False) diff --git a/server/app.py b/server/app.py index a52f8061..e0921aae 100644 --- a/server/app.py +++ b/server/app.py @@ -468,6 +468,10 @@ async def on_shutdown(app: web.Application): ) app.router.add_get("/metrics", app["metrics_manager"].metrics_handler) + #add management api routes + from api.api import add_mgmt_api_routes + add_mgmt_api_routes(app) + # Add hosted platform route prefix. # NOTE: This ensures that the local and hosted experiences have consistent routes. add_prefix_to_app_routes(app, "/live") @@ -483,4 +487,5 @@ def force_print(*args, **kwargs): if args.comfyui_inference_log_level: app["comfui_inference_log_level"] = args.comfyui_inference_log_level + web.run_app(app, host=args.host, port=int(args.port), print=force_print) diff --git a/src/comfystream/server/utils/utils.py b/src/comfystream/server/utils/utils.py index c7a7ac30..716ce333 100644 --- a/src/comfystream/server/utils/utils.py +++ b/src/comfystream/server/utils/utils.py @@ -5,6 +5,7 @@ import types import logging from aiohttp import web + from typing import List, Tuple from contextlib import asynccontextmanager @@ -65,7 +66,6 @@ def add_prefix_to_app_routes(app: web.Application, prefix: str): new_path = prefix + route.resource.canonical app.router.add_route(route.method, new_path, route.handler) - @asynccontextmanager async def temporary_log_level(logger_name: str, level: int): """Temporarily set the log level of a logger. @@ -83,3 +83,4 @@ async def temporary_log_level(logger_name: str, level: int): finally: if level is not None: logger.setLevel(original_level) +