├── web ├── assets │ └── img │ │ └── logo.png ├── lib │ ├── nexus.user.manager.js │ ├── nexus.styles.js │ ├── nexus.api.js │ ├── nexus.sockets.js │ ├── nexus.chat.js │ ├── nexus.elements.js │ ├── nexus.mouse.js │ ├── nexus.workflow.panel.js │ ├── nexus.comfy.js │ ├── nexus.panel.js │ ├── nexus.workflow.js │ ├── nexus.litegraph.js │ └── nexus.cmd.js └── util.js ├── classes └── WebSocketManager.py ├── pyproject.toml ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── .gitignore ├── README.md └── __init__.py /web/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daxcay/ComfyUI-Nexus/HEAD/web/assets/img/logo.png -------------------------------------------------------------------------------- /classes/WebSocketManager.py: -------------------------------------------------------------------------------- 1 | class WebSocketManager: 2 | def __init__(self): 3 | self.sockets = {} 4 | 5 | def get(self, id): 6 | return self.sockets.get(id) 7 | 8 | def set(self, id, ws): 9 | self.sockets[id] = ws 10 | 11 | def delete(self, id): 12 | if id in self.sockets: 13 | del self.sockets[id] 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "comfyui-nexus" 3 | description = "Node to enable seamless multiuser workflow collaboration" 4 | version = "1.0.2" 5 | license = { file = "LICENSE" } 6 | dependencies = [] 7 | 8 | [project.urls] 9 | Repository = "https://github.com/daxcay/ComfyUI-Nexus" 10 | # Used by Comfy Registry https://comfyregistry.org 11 | 12 | [tool.comfy] 13 | PublisherId = "daxcay" 14 | DisplayName = "ComfyUI-Nexus" 15 | Icon = "https://raw.githubusercontent.com/daxcay/ComfyUI-Nexus/master/web/assets/img/logo.png" 16 | -------------------------------------------------------------------------------- /web/lib/nexus.user.manager.js: -------------------------------------------------------------------------------- 1 | export class UserManager { 2 | constructor() { 3 | this.users = {} 4 | } 5 | 6 | create(id) { 7 | if (!this.users[id]) { 8 | this.users[id] = {} 9 | } 10 | } 11 | 12 | set(id, key, data) { 13 | this.create(id) 14 | this.users[id][key] = data 15 | } 16 | 17 | get(id, key) { 18 | this.create(id) 19 | return this.users[id][key] 20 | } 21 | 22 | remove(id) { 23 | if(this.users[id]) 24 | delete this.users[id]; 25 | return true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Comfy registry 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - "pyproject.toml" 9 | 10 | jobs: 11 | publish-node: 12 | name: Publish Custom Node to registry 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Check out code 16 | uses: actions/checkout@v4 17 | - name: Publish Custom Node 18 | uses: Comfy-Org/publish-node-action@main 19 | with: 20 | personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }} ## Add your own personal access token to your Github Repository secrets and reference it here. 21 | -------------------------------------------------------------------------------- /web/util.js: -------------------------------------------------------------------------------- 1 | import { NexusSocket } from "./lib/nexus.sockets.js"; 2 | import { NexusMouseManager } from "./lib/nexus.mouse.js"; 3 | import { NexusWorkflowManager } from "./lib/nexus.workflow.js"; 4 | import { NexusPromptControl } from "./lib/nexus.comfy.js"; 5 | import { NexusCommands } from "./lib/nexus.cmd.js"; 6 | 7 | let app = window.comfyAPI.app.app; 8 | let api = window.comfyAPI.api.api; 9 | let nexusSocket = new NexusSocket() 10 | 11 | let nexus = { 12 | name: "ComfyUI-Nexus", 13 | async setup(app) { 14 | app = app 15 | let mouseManager = new NexusMouseManager(nexusSocket, app, 10) 16 | let workflowManager = new NexusWorkflowManager(app, api, nexusSocket) 17 | let nexusPromptControl = new NexusPromptControl(api, app, nexusSocket) 18 | new NexusCommands(app, api, nexusSocket, mouseManager.color, mouseManager, workflowManager, nexusPromptControl) 19 | nexusSocket.connect() 20 | }, 21 | async beforeConfigureGraph() { 22 | } 23 | } 24 | 25 | app.registerExtension(nexus); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 daxcay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /web/lib/nexus.styles.js: -------------------------------------------------------------------------------- 1 | export class StyleInjector { 2 | 3 | constructor({ styles }) { 4 | this.el = null 5 | this.styles = styles; 6 | this.create() 7 | } 8 | 9 | create() { 10 | this.el = document.createElement('style'); 11 | let cssString = ""; 12 | for (const selector in this.styles) { 13 | cssString += `${selector} { `; 14 | for (const property in this.styles[selector]) { 15 | cssString += `${property}: ${this.styles[selector][property]}; `; 16 | } 17 | cssString += `} `; 18 | } 19 | this.el.textContent = cssString; 20 | } 21 | 22 | softUpdate(selector, property, value) { 23 | let cssString = ""; 24 | for (const key in this.styles) { 25 | cssString += `${key} { `; 26 | for (const property in this.styles[key]) { 27 | cssString += `${property}: ${this.styles[key][property]}; `; 28 | } 29 | if(key == selector) { 30 | cssString += `${property}: ${value}; `; 31 | } 32 | cssString += `} `; 33 | } 34 | this.el.textContent = cssString; 35 | } 36 | 37 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *.pyc 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | # lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | *.pyc 154 | 155 | .idea 156 | /node_modules 157 | /frontend -------------------------------------------------------------------------------- /web/lib/nexus.api.js: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'nexus'; 2 | 3 | async function fetchWithHandling(url, options = {}) { 4 | try { 5 | // console.info("Calling: ", url) 6 | const response = await fetch(url, options); 7 | const data = await response.json(); 8 | if (!response.ok) { 9 | throw new Error(data.error || 'Something went wrong'); 10 | } 11 | return data; 12 | } catch (error) { 13 | console.error(`Fetch error: ${error.message}`); 14 | return { error: error.message }; 15 | } 16 | } 17 | 18 | export async function getUserSpecificWorkflows(userId) { 19 | const url = `${BASE_URL}/workflows`; 20 | const options = { 21 | method: 'POST', 22 | headers: { 'Content-Type': 'application/json' }, 23 | body: JSON.stringify({ user_id: userId }), 24 | }; 25 | return await fetchWithHandling(url, options); 26 | } 27 | 28 | export async function getUserSpecificWorkflow(userId, workflowId) { 29 | const url = `${BASE_URL}/workflows/${workflowId}`; 30 | const options = { 31 | method: 'POST', 32 | headers: { 'Content-Type': 'application/json' }, 33 | body: JSON.stringify({ user_id: userId }), 34 | }; 35 | return await fetchWithHandling(url, options); 36 | } 37 | 38 | export async function getUserSpecificWorkflowsMeta(userId) { 39 | const url = `${BASE_URL}/workflows/meta`; 40 | const options = { 41 | method: 'POST', 42 | headers: { 'Content-Type': 'application/json' }, 43 | body: JSON.stringify({ user_id: userId }), 44 | }; 45 | return await fetchWithHandling(url, options); 46 | } 47 | 48 | export async function postWorkflow(userId, workflowData) { 49 | const url = `${BASE_URL}/workflow`; 50 | const options = { 51 | method: 'POST', 52 | headers: { 'Content-Type': 'application/json' }, 53 | body: JSON.stringify({ user_id: userId, ...workflowData }), 54 | }; 55 | return await fetchWithHandling(url, options); 56 | } 57 | 58 | export async function postBackupWorkflow(userId, workflowData) { 59 | const url = `${BASE_URL}/workflow/backup`; 60 | const options = { 61 | method: 'POST', 62 | headers: { 'Content-Type': 'application/json' }, 63 | body: JSON.stringify({ user_id: userId, ...workflowData }), 64 | }; 65 | return await fetchWithHandling(url, options); 66 | } 67 | 68 | export async function getLatestWorkflow() { 69 | const url = `${BASE_URL}/workflow`; 70 | return await fetchWithHandling(url); 71 | } 72 | 73 | export async function getPermissionById(userId) { 74 | const url = `${BASE_URL}/permission/${userId}`; 75 | return await fetchWithHandling(url); 76 | } 77 | 78 | export async function postPermissionById(userId, adminId, data, token) { 79 | const url = `${BASE_URL}/permission/${userId}`; 80 | const options = { 81 | method: 'POST', 82 | headers: { 83 | 'Content-Type': 'application/json', 84 | 'Authorization': token, 85 | }, 86 | body: JSON.stringify({ admin_id: adminId, data }), 87 | }; 88 | return await fetchWithHandling(url, options); 89 | } 90 | 91 | export async function nexusLogin(uuid, account, password) { 92 | const url = `${BASE_URL}/login`; 93 | const options = { 94 | method: 'POST', 95 | headers: { 'Content-Type': 'application/json' }, 96 | body: JSON.stringify({ uuid, account, password }), 97 | }; 98 | return await fetchWithHandling(url, options); 99 | } 100 | 101 | export async function nexusVerify(token) { 102 | const url = `${BASE_URL}/verify`; 103 | const options = { 104 | method: 'POST', 105 | headers: { 'Content-Type': 'application/json' }, 106 | body: JSON.stringify({ token }), 107 | }; 108 | return await fetchWithHandling(url, options); 109 | } 110 | 111 | export async function getNameById(userId) { 112 | const url = `${BASE_URL}/name/${userId}`; 113 | return await fetchWithHandling(url); 114 | } 115 | 116 | -------------------------------------------------------------------------------- /web/lib/nexus.sockets.js: -------------------------------------------------------------------------------- 1 | import { UserManager } from "./nexus.user.manager.js"; 2 | 3 | export class NexusSocket extends EventTarget { 4 | 5 | getName() { 6 | let name = localStorage.getItem('nexus-socket-name'); 7 | if (!name) { 8 | localStorage.setItem('nexus-socket-name', "User"); 9 | name = "User" 10 | } 11 | this.userManager.set(this.uuid, "name", name) 12 | return name 13 | } 14 | 15 | getUUID() { 16 | let uuid = localStorage.getItem('nexus-socket-uuid'); 17 | if (!uuid) { 18 | uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => 19 | (Math.random() * 16 | 0).toString(16) 20 | ); 21 | localStorage.setItem('nexus-socket-uuid', uuid); 22 | } 23 | return uuid; 24 | } 25 | 26 | constructor() { 27 | 28 | super(); 29 | this.ws = null; 30 | this.userManager = new UserManager(); 31 | this.uuid = this.getUUID(); 32 | this.admin = false 33 | this.wsUrl = `ws${window.location.protocol === "https:" ? "s" : ""}://${location.host}/nexus?id=${this.uuid}`; 34 | 35 | this.onConnected = [this.onConnectedHandler.bind(this)]; 36 | this.onDisconnected = []; 37 | this.onMessageReceive = [this.onMessageReceiveHandler.bind(this)]; 38 | this.onMessageSend = []; 39 | this.onError = []; 40 | this.onClose = []; 41 | 42 | this.getName() 43 | 44 | } 45 | 46 | onConnectedHandler() { 47 | this.userManager.create(this.uuid); 48 | this.sendMessage('join', { name: this.getName() }, false); 49 | this.sendMessage("online", {}, false) 50 | } 51 | 52 | onMessageReceiveHandler(message) { 53 | let name = message.name; 54 | let from = message.from; 55 | switch (name) { 56 | case "join": 57 | this.userManager.create(from); 58 | this.sendMessage('hi', {}, false); 59 | break; 60 | case "hi": 61 | this.userManager.create(from); 62 | break; 63 | } 64 | } 65 | 66 | connect() { 67 | try { 68 | this.ws = new WebSocket(this.wsUrl); 69 | this.ws.onopen = () => { 70 | if (this.onConnected) { 71 | this.onConnected.forEach(f => { f(); }); 72 | } 73 | }; 74 | this.ws.onmessage = (msg) => { 75 | try { 76 | const message = JSON.parse(msg.data); 77 | if (this.onMessageReceive) { 78 | this.onMessageReceive.forEach(f => { f(message); }); 79 | } 80 | } catch (e) { 81 | console.error('Error handling message:', e); 82 | } 83 | }; 84 | this.ws.onerror = (error) => { 85 | if (this.onError) { 86 | this.onError.forEach(f => { f(error); }); 87 | } 88 | console.error('WebSocket error:', error); 89 | }; 90 | this.ws.onclose = () => { 91 | if (this.onDisconnected) { 92 | this.onDisconnected.forEach(f => { f(); }); 93 | } 94 | setTimeout(() => { 95 | this.connect(); 96 | }, 5000); 97 | }; 98 | } catch (e) { 99 | console.error('Error connecting to WebSocket:', e); 100 | } 101 | } 102 | 103 | closeWebSocket() { 104 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 105 | this.ws.close(); 106 | } else { 107 | console.log('WebSocket is not open.'); 108 | } 109 | } 110 | 111 | sendMessage(event_name, event_data, all = true) { 112 | try { 113 | if (this.ws && this.ws.readyState === WebSocket.OPEN) { 114 | let data = {} 115 | if(event_data.receiver) { 116 | data.receiver = event_data.receiver 117 | delete event_data.receiver 118 | } else if (event_data.exclude) { 119 | data.exclude = event_data.exclude 120 | delete event_data.exclude 121 | } 122 | data.from = this.uuid 123 | data.name = event_name 124 | data.data = event_data 125 | data.all = all 126 | this.ws.send(JSON.stringify(data)); 127 | if (this.onMessageSend) { 128 | this.onMessageSend.forEach(f => { f(message); }); 129 | } 130 | } 131 | } catch (e) { 132 | console.error(`Error sending ${event_name} message:`, e); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /web/lib/nexus.chat.js: -------------------------------------------------------------------------------- 1 | import { DynamicElement } from "./nexus.elements.js" 2 | import { StyleInjector } from "./nexus.styles.js"; 3 | 4 | let chatMain = new DynamicElement({ 5 | id: 'nexus-chat-main', 6 | tag: 'div', 7 | visible: true, 8 | styles: { 9 | width: '512px', 10 | minHeight: '170px', 11 | maxHeight: '170px', 12 | overflow: 'hidden', 13 | padding: '16px', 14 | borderRadius: '8px', 15 | border: '1px solid rgba(94,94,94,0.5)' 16 | }, 17 | hoverStyles: { 18 | overflowY: 'scroll' 19 | }, 20 | afterChildrenAppend: function (childrens, me) { 21 | if (childrens.length > 50) { 22 | childrens[0].removeElement() 23 | childrens.shift() 24 | } 25 | me.el.scrollTop = me.el.scrollHeight 26 | } 27 | }) 28 | 29 | let inputMain = new DynamicElement({ 30 | id: 'nexus-input-main', 31 | tag: 'div', 32 | visible: true, 33 | styles: { 34 | width: '100%', 35 | height: '32px', 36 | display: 'flex', 37 | alignItems: 'center', 38 | }, 39 | childrens: [ 40 | new DynamicElement({ 41 | id: 'nexus-input-label', 42 | tag: 'span', 43 | visible: false, 44 | styles: { 45 | fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 46 | display: 'block', 47 | fontSize: '14px', 48 | lineHeight: '14px', 49 | fontWeight: 600, 50 | color: 'white', 51 | padding: '0', 52 | margin: '0', 53 | textShadow: '1pt 1pt #000', 54 | }, 55 | childrens: ['Say:'] 56 | }), 57 | new DynamicElement({ 58 | id: 'nexus-input', 59 | tag: 'input', 60 | visible: false, 61 | styles: { 62 | fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 63 | fontSize: '14px', 64 | lineHeight: '14px', 65 | fontWeight: 600, 66 | width: '100%', 67 | color: 'white', 68 | padding: '0', 69 | margin: '0', 70 | border: 'none', 71 | background: "transparent", 72 | flexGrow: 1, 73 | outline: 'none', 74 | textIndent: '4pt', 75 | caretColor: 'transparent', 76 | textShadow: '1pt 1pt #000', 77 | } 78 | }) 79 | ] 80 | }) 81 | 82 | let chatWindow = new DynamicElement({ 83 | id: 'nexus-chat-window', 84 | tag: 'div', 85 | styles: { 86 | position: 'absolute', 87 | left: "16px", 88 | top: '100px', 89 | display: 'flex', 90 | flexDirection: 'column', 91 | zIndex: 1000 92 | }, 93 | childrens: [ 94 | new DynamicElement({ 95 | id: "nexus-total-players", 96 | tag: "div", 97 | visible: true, 98 | styles: { 99 | fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 100 | fontSize: '14px', 101 | lineHeight: '14px', 102 | fontWeight: 600, 103 | left: "16px", 104 | top: '80px', 105 | color: "#a5a5a5", 106 | width: "100%", 107 | paddingBottom: "12px", 108 | textAlign: "right" 109 | }, 110 | childrens: ['Users Online: ', new DynamicElement({ id: "nexus-total-players-count", visible: true, tag: "span", childrens:['0'] })] 111 | }), 112 | chatMain, 113 | inputMain, 114 | new StyleInjector({ 115 | styles: { 116 | '#nexus-chat-main::-webkit-scrollbar': { 117 | 'display': 'none', 118 | 'scrollbar-width': 'none', 119 | '-ms-overflow-style': 'none', 120 | }, 121 | "#nexus-chat-window" : { 122 | // for pointer events 123 | } 124 | } 125 | }) 126 | ] 127 | }) 128 | 129 | let addChatWindowMessage = function (subject, message, subject_color, message_color, merge) { 130 | let msg = "" 131 | if (merge) { 132 | msg = `${subject} ${message}` 133 | } 134 | else { 135 | msg = `${subject}: ${message}` 136 | } 137 | 138 | let chatElement = new DynamicElement({ 139 | tag: 'span', 140 | visible: true, 141 | styles: { 142 | fontFamily: 'Calibri, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 143 | display: 'block', 144 | fontSize: '14px', 145 | lineHeight: '14px', 146 | fontWeight: 600, 147 | width: '100%', 148 | color: 'white', 149 | padding: '0', 150 | margin: '0', 151 | textShadow: '1pt 1pt #000', 152 | marginBottom: '1pt', 153 | lineBreak: 'anywhere' 154 | }, 155 | childrens: [msg], 156 | }) 157 | chatMain.addChild(chatElement) 158 | } 159 | 160 | export { chatMain, inputMain, chatWindow, addChatWindowMessage } -------------------------------------------------------------------------------- /web/lib/nexus.elements.js: -------------------------------------------------------------------------------- 1 | import { StyleInjector } from "./nexus.styles.js"; 2 | 3 | export class DynamicElement extends EventTarget { 4 | id = ''; 5 | tag = ''; 6 | styles = {}; 7 | hoverStyles = {}; 8 | focusStyles = {}; 9 | activeStyles = {}; 10 | attrs = {}; 11 | childrens = []; 12 | events = {}; 13 | appendTo = document.body; 14 | visible = false; 15 | afterChildrenAppend = null; 16 | afterCreated = null; 17 | defaultDisplay = 'initial'; 18 | el = null; 19 | 20 | constructor({ 21 | id, 22 | tag, 23 | styles = {}, 24 | hoverStyles = {}, 25 | focusStyles = {}, 26 | activeStyles = {}, 27 | attrs = {}, 28 | childrens = [], 29 | events = {}, 30 | appendTo = document.body, 31 | afterChildrenAppend = null, 32 | afterCreated = null, 33 | visible = false 34 | } = {}) { 35 | 36 | super() 37 | 38 | this.id = id; 39 | this.tag = tag; 40 | this.styles = styles; 41 | this.hoverStyles = hoverStyles; 42 | this.focusStyles = focusStyles; 43 | this.activeStyles = activeStyles; 44 | this.attrs = attrs; 45 | this.childrens = childrens; 46 | this.events = events; 47 | this.appendTo = appendTo; 48 | this.afterChildrenAppend = afterChildrenAppend; 49 | this.afterCreated = afterCreated; 50 | this.visible = visible 51 | 52 | this.createElement(); 53 | } 54 | 55 | createElement() { 56 | if (!this.tag) { 57 | throw new Error('Tag is required to create an element.'); 58 | } 59 | 60 | this.el = document.createElement(this.tag); 61 | if (this.id) this.el.id = this.id; 62 | this.appendTo.appendChild(this.el); 63 | 64 | this.addStyles(); 65 | this.addAttrs(); 66 | this.addChildrens(); 67 | this.addEvents(); 68 | this.addStateStyles(); 69 | 70 | if (!this.visible) { 71 | this.hide() 72 | } 73 | 74 | if (this.afterCreated) { 75 | this.afterCreated(this) 76 | } 77 | } 78 | 79 | removeElement() { 80 | if (!this.el) return; 81 | this.removeAttrs(); 82 | this.removeEvents(); 83 | this.removeStyles(); 84 | this.removeChildrens(); 85 | this.el.remove(); 86 | this.el = null; 87 | } 88 | 89 | updateStyles(styles = {}) { 90 | this.styles = { ...this.styles, ...styles }; 91 | this.addStyles(); 92 | } 93 | 94 | addStyles() { 95 | if (!this.el) return; 96 | Object.entries(this.styles).forEach(([style, value]) => { 97 | if (style === 'display') { 98 | this.defaultDisplay = value; 99 | } 100 | this.el.style[style] = value; 101 | }); 102 | } 103 | 104 | removeStyles() { 105 | if (!this.el) return; 106 | Object.keys(this.styles).forEach(style => { 107 | this.el.style[style] = ''; 108 | }); 109 | this.styles = {}; 110 | } 111 | 112 | updateAttrs(attrs = {}) { 113 | this.attrs = { ...this.attrs, ...attrs }; 114 | this.addAttrs(); 115 | } 116 | 117 | addAttrs() { 118 | if (!this.el) return; 119 | Object.entries(this.attrs).forEach(([attr, value]) => { 120 | this.el.setAttribute(attr, value); 121 | }); 122 | } 123 | 124 | removeAttrs() { 125 | if (!this.el) return; 126 | Object.keys(this.attrs).forEach(attr => { 127 | this.el.removeAttribute(attr); 128 | }); 129 | this.attrs = {}; 130 | } 131 | 132 | addChildrens() { 133 | if (!this.el) return; 134 | this.childrens.forEach(child => { 135 | if (typeof child === 'string') { 136 | this.el.insertAdjacentHTML('beforeend', child); 137 | } else if (child instanceof Node) { 138 | this.el.appendChild(child); 139 | } else if (child instanceof StyleInjector) { 140 | this.el.appendChild(child.el); 141 | } else if (child instanceof DynamicElement) { 142 | this.el.appendChild(child.el); 143 | } 144 | }); 145 | if (this.afterChildrenAppend) { 146 | requestAnimationFrame(() => this.afterChildrenAppend(this.childrens, this)); 147 | } 148 | } 149 | 150 | addChild(child) { 151 | if (!this.el) return; 152 | if (typeof child === 'string') { 153 | this.el.insertAdjacentHTML('beforeend', child); 154 | } else if (child instanceof Node) { 155 | this.el.appendChild(child); 156 | } else if (child instanceof DynamicElement) { 157 | this.el.appendChild(child.el); 158 | } 159 | if(!this.childrens.includes(child)) { 160 | this.childrens.push(child) 161 | } 162 | if (this.afterChildrenAppend) { 163 | this.afterChildrenAppend(this.childrens, this); 164 | } 165 | } 166 | 167 | removeChildrens() { 168 | if (!this.el) return; 169 | this.el.innerHTML = ''; 170 | this.childrens = []; 171 | } 172 | 173 | addEvents() { 174 | if (!this.el) return; 175 | Object.entries(this.events).forEach(([event, handler]) => { 176 | this.el.addEventListener(event, (e) => { handler(e, this.parent) }); 177 | }); 178 | } 179 | 180 | removeEvents() { 181 | if (!this.el) return; 182 | Object.entries(this.events).forEach(([event, handler]) => { 183 | this.el.removeEventListener(event, handler); 184 | }); 185 | this.events = {}; 186 | } 187 | 188 | show() { 189 | if (!this.el) return; 190 | this.el.style.display = this.defaultDisplay; 191 | this.visible = true; 192 | } 193 | 194 | hide() { 195 | if (!this.el) return; 196 | this.el.style.display = 'none'; 197 | this.visible = false; 198 | } 199 | 200 | addStateStyles() { 201 | if (!this.el) return; 202 | 203 | this.el.addEventListener('mouseenter', () => this.applyStyles(this.hoverStyles)); 204 | this.el.addEventListener('mouseleave', () => this.applyStyles(this.styles)); 205 | this.el.addEventListener('focus', () => this.applyStyles(this.focusStyles), true); 206 | this.el.addEventListener('blur', () => this.applyStyles(this.styles), true); 207 | this.el.addEventListener('mousedown', () => this.applyStyles(this.activeStyles)); 208 | this.el.addEventListener('mouseup', () => this.applyStyles(this.styles)); 209 | } 210 | 211 | applyStyles(stateStyles) { 212 | if (!this.visible) return 213 | Object.entries(stateStyles).forEach(([style, value]) => { 214 | this.el.style[style] = value; 215 | }); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /web/lib/nexus.mouse.js: -------------------------------------------------------------------------------- 1 | export class NexusMouseManager { 2 | 3 | hexColors = ["#1abc9c","#2ecc71","#3498db","#9b59b6","#f1c40f","#e67e22","#e74c3c","#ecf0f1"] 4 | 5 | createMouseSvg(color) { 6 | return ` 9 | 12 | ` 13 | } 14 | 15 | svgStringToImage(svgString) { 16 | const blob = new Blob([svgString], { type: 'image/svg+xml' }); 17 | const url = URL.createObjectURL(blob); 18 | const image = new Image(); 19 | image.src = url; 20 | return image; 21 | } 22 | 23 | hex2rgb(hex) { 24 | const r = parseInt(hex.slice(1, 3), 16); 25 | const g = parseInt(hex.slice(3, 5), 16); 26 | const b = parseInt(hex.slice(5, 7), 16); 27 | return { r, g, b }; 28 | } 29 | 30 | getRandomHexColor() { 31 | return this.hexColors[Math.floor(Math.random() * this.hexColors.length)]; 32 | } 33 | 34 | constructor(nexusSocket, app, fps) { 35 | 36 | this.color = this.getRandomHexColor() 37 | 38 | this.app = app 39 | this.fps = fps 40 | 41 | this.canSendMouse = false 42 | 43 | this.nexusSocket = nexusSocket 44 | this.nexusSocket.onConnected.push(this.handleConnect.bind(this)) 45 | this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this)) 46 | 47 | document.addEventListener("visibilitychange", this.handleVisiblityChange.bind(this)) 48 | document.addEventListener('mousemove', this.handleSendMouseControl.bind(this)); 49 | this.app.graph.list_of_graphcanvas[0].onDrawForeground = this.handlePointerDraw.bind(this) 50 | 51 | setInterval(this.handleSendMouse.bind(this), 1000 / this.fps) 52 | 53 | } 54 | 55 | throttle(func, limit) { 56 | let lastFunc; 57 | let lastRan; 58 | return function (...args) { 59 | const context = this; 60 | if (!lastRan) { 61 | func.apply(context, args); 62 | lastRan = Date.now(); 63 | } else { 64 | clearTimeout(lastFunc); 65 | lastFunc = setTimeout(function () { 66 | if ((Date.now() - lastRan) >= limit) { 67 | func.apply(context, args); 68 | lastRan = Date.now(); 69 | } 70 | }, limit - (Date.now() - lastRan)); 71 | } 72 | }; 73 | } 74 | 75 | handleCanvasDraw() { 76 | if (this.app.graph && this.app.graph.list_of_graphcanvas && this.app.graph.list_of_graphcanvas.length > 0) { 77 | this.app.graph.list_of_graphcanvas[0].draw(true, true); 78 | } 79 | } 80 | 81 | handleConnect() { 82 | this.drawTimer = setInterval(this.handleCanvasDraw.bind(this), 1000 / this.fps) 83 | } 84 | 85 | handleMessageReceive(message) { 86 | 87 | let name = message.name; 88 | let from = message.from; 89 | let data = message.data 90 | 91 | if (name == "mouse") { 92 | let mouse = this.nexusSocket.userManager.get(from, "mouse"); 93 | let pointer = this.nexusSocket.userManager.get(from, "pointer"); 94 | if (!pointer) { 95 | this.nexusSocket.userManager.set(from, "pointer", this.svgStringToImage(this.createMouseSvg(data.mouse[3]))); 96 | } else if (pointer && mouse && mouse[3] != data.mouse[3]) { 97 | this.nexusSocket.userManager.set(from, "pointer", this.svgStringToImage(this.createMouseSvg(data.mouse[3]))); 98 | } 99 | this.nexusSocket.userManager.set(from, "mouse", data.mouse) 100 | } else if (name == "hide_mouse") { 101 | this.nexusSocket.userManager.set(from, "mouse", null) 102 | } else if (name== "mouse_view_toggle") { 103 | this.nexusSocket.userManager.set(from, "mouse_view", data.on) 104 | if(data.on == 0){ 105 | this.nexusSocket.sendMessage("hide_mouse", { receiver: from }, false) 106 | } 107 | } 108 | 109 | } 110 | 111 | handleVisiblityChange() { 112 | if (document.visibilityState === "hidden") { 113 | clearInterval(this.drawTimer) 114 | this.nexusSocket.sendMessage("hide_mouse", {}, false) 115 | this.nexusSocket.sendMessage("afk", {}, false) 116 | } else { 117 | this.handleConnect() 118 | this.nexusSocket.sendMessage("online", {}, false) 119 | } 120 | } 121 | 122 | handleSendMouse = function () { 123 | if (this.app.graph && this.app.graph.list_of_graphcanvas && this.app.graph.list_of_graphcanvas.length > 0) { 124 | this.mouse = this.app.graph.list_of_graphcanvas[0].canvas_mouse 125 | this.scale = this.app.graph.list_of_graphcanvas[0].ds.scale 126 | } 127 | this.mouse[3] = this.color 128 | this.mouse[4] = this.scale / 10 129 | 130 | if (this.canSendMouse) { 131 | let users = Object.keys(this.nexusSocket.userManager.users) 132 | for (let index = 0; index < users.length; index++) { 133 | let user_id = users[index] 134 | let view = this.nexusSocket.userManager.get(user_id, "mouse_view"); 135 | if(view != null) { 136 | if(view == 1) { 137 | this.nexusSocket.sendMessage('mouse', { mouse: this.mouse, receiver: user_id }, false) 138 | } 139 | } 140 | else { 141 | this.nexusSocket.sendMessage('mouse', { mouse: this.mouse, receiver: user_id }, false) 142 | } 143 | } 144 | this.canSendMouse = false 145 | } 146 | 147 | } 148 | 149 | handleSendMouseControl() { 150 | this.canSendMouse = true 151 | } 152 | 153 | handlePointerDraw = function (ctx) { 154 | Object.keys(this.nexusSocket.userManager.users).forEach(id => { 155 | if (id != this.nexusSocket.uuid) { 156 | 157 | let mouse = this.nexusSocket.userManager.get(id, "mouse"); 158 | let name = this.nexusSocket.userManager.get(id, "name") || "User"; 159 | let pointerImage = this.nexusSocket.userManager.get(id, "pointer"); 160 | let hidden = this.nexusSocket.userManager.get(id, "mouse_hidden"); 161 | 162 | if(hidden == 0) { 163 | mouse = null 164 | } 165 | 166 | if (mouse && mouse.length > 0) { 167 | 168 | let mouseX = mouse[0] - 4; 169 | let mouseY = mouse[1] - 4; 170 | let scale = 1 - mouse[4]; 171 | 172 | ctx.drawImage(pointerImage, mouseX, mouseY, 18, 18); 173 | ctx.font = '14px Arial'; 174 | ctx.fillStyle = mouse[3]; 175 | ctx.fillText(name, mouseX + 22, mouseY + 16); 176 | 177 | let color = this.hex2rgb(mouse[3]) 178 | 179 | ctx.beginPath(); 180 | ctx.arc(mouseX, mouseY, scale * 600, 0, 2 * Math.PI, false); 181 | ctx.strokeStyle = `rgba(${color.r},${color.g},${color.b},0.1)`; 182 | ctx.lineWidth = 2; 183 | ctx.stroke(); 184 | 185 | } 186 | } 187 | 188 | }); 189 | } 190 | } -------------------------------------------------------------------------------- /web/lib/nexus.workflow.panel.js: -------------------------------------------------------------------------------- 1 | import { DynamicElement } from "./nexus.elements.js" 2 | import { StyleInjector } from "./nexus.styles.js"; 3 | 4 | export class WorkflowPanel { 5 | 6 | constructor() { 7 | 8 | this.active = 0 9 | 10 | this.panel = new DynamicElement({ 11 | id: 'nexus-workflow-panel', 12 | tag: 'div', 13 | visible: false, 14 | styles: { 15 | "width": "100%", 16 | "height": "100vh", 17 | "display": "flex", 18 | "align-items": "center", 19 | "justify-content": "center", 20 | "box-sizing": "border-box", 21 | "position": "absolute", 22 | "left": "0", 23 | "top": "0", 24 | "z-index": "10000" 25 | }, 26 | childrens: [ 27 | new DynamicElement({ 28 | id: 'nexus-workflow-panel-table', 29 | tag: 'div', 30 | visible: true, 31 | styles: { 32 | "width": "512px", 33 | "display": "flex", 34 | "flex-direction": "column", 35 | "border-radius": "6px", 36 | "box-sizing": "border-box" 37 | }, 38 | childrens: [ 39 | new DynamicElement({ 40 | id: 'nexus-workflow-panel-table-body', 41 | tag: 'div', 42 | visible: true, 43 | styles: { 44 | "width": "100%", 45 | "display": "flex", 46 | "flex-direction": "column", 47 | "align-items": "center", 48 | "justify-content": "flex-start", 49 | "color": "#a3a3a3", 50 | "font-family": "Calibri", 51 | "font-weight": "600", 52 | "gap": "12px", 53 | "padding-bottom": "12px", 54 | "box-sizing": "border-box", 55 | "background": "rgba(54,54,54,0.8)", 56 | "border-radius": "6px", 57 | "box-shadow": "4px 8px 8px rgba(0,0,0,0.5)", 58 | "max-height": "540px", 59 | "overflowY": "auto" 60 | } 61 | }), 62 | new StyleInjector({ 63 | styles: { 64 | "#nexus-workflow-panel .table-row": { 65 | "width": "100%", 66 | "display": "flex", 67 | "align-items": "center", 68 | "justify-content": "center", 69 | }, 70 | "#nexus-workflow-panel .table-row:first-child": { 71 | "width": "100%", 72 | "color": "#a3a3a3", 73 | "padding": "16px 0", 74 | "background": "rgb(0,0,0,0.6)", 75 | "border-top-left-radius": "6px", 76 | "border-top-right-radius": "6px", 77 | "position": "sticky", 78 | "top": "0", 79 | }, 80 | "#nexus-workflow-panel .table-row .table-row-child": { 81 | "display": "flex", 82 | "justify-content": "flex-start", 83 | "gap": "12px", 84 | "padding-left": "12px", 85 | "width": "100%" 86 | }, 87 | "#nexus-workflow-panel .table-row-child button": { 88 | "border": "none", 89 | "padding": "6px 12px", 90 | "border-radius": "3px", 91 | "cursor": "pointer", 92 | "background": "rgba(94,94,94)", 93 | "color": "#a1a1a1", 94 | "width": "100%" 95 | }, 96 | "#nexus-workflow-panel .table-row-child:first-child": { 97 | "width":"50%" 98 | }, 99 | "#nexus-workflow-panel .table-row-child:last-child": { 100 | "padding-right":"12px" 101 | }, 102 | "#nexus-workflow-panel .table-row-child button:active": { 103 | "opacity": "0.8" 104 | }, 105 | "#nexus-workflow-panel .table-row-child button[label='load']:active": { 106 | "background": "#218c74", 107 | "color": "#fafafa" 108 | }, 109 | "#nexus-workflow-panel .table-row-child button[label='loadforall']:active": { 110 | "background": "#474787", 111 | "color": "#fafafa" 112 | } 113 | } 114 | }) 115 | ] 116 | }) 117 | ], 118 | }) 119 | this.onCommandReceived = null 120 | this.buttons = [] 121 | this.columns = [] 122 | } 123 | 124 | addColumnsToPanel(columns) { 125 | 126 | this.columns = columns 127 | 128 | } 129 | 130 | addRowsToPanel(rows, callbacks, values, data) { 131 | 132 | let tbody = this.panel.childrens[0].childrens[0]; 133 | tbody.removeChildrens(); 134 | this.buttons = [] 135 | 136 | const tr = document.createElement('div'); 137 | tr.className = "table-row"; 138 | 139 | this.columns.forEach(heading => { 140 | const th = document.createElement('span'); 141 | th.className = "table-row-child" 142 | th.textContent = heading; 143 | tr.appendChild(th) 144 | }); 145 | 146 | tbody.addChild(tr) 147 | 148 | rows.forEach((row, i) => { 149 | const tr = document.createElement('div'); 150 | tr.className = "table-row"; 151 | 152 | row.forEach((cell, j) => { 153 | 154 | let td = document.createElement('span'); 155 | td.className = "table-row-child" 156 | 157 | let hascomma = cell.indexOf(",") > -1 158 | let hascolon = cell.indexOf(":") > -1 159 | 160 | let content = [] 161 | 162 | if (hascomma) { 163 | content = cell.split(',') 164 | content.forEach((elm, k) => { 165 | let elmcontent = elm.split(':') 166 | this.addChildToParent(elmcontent, td, callbacks, values, data, i, j, k) 167 | }) 168 | } 169 | else if (hascolon) { 170 | content = cell.split(':') 171 | this.addChildToParent(content, td, callbacks, values, data, i, j, -1) 172 | } else { 173 | cell = cell.split("|") 174 | cell = cell.join(":") 175 | td.textContent = cell 176 | } 177 | tr.appendChild(td); 178 | 179 | }); 180 | tbody.addChild(tr) 181 | }); 182 | } 183 | 184 | addChildToParent(content, td, callbacks, values, data, i, j, k) { 185 | switch (content[0]) { 186 | case 'button': 187 | let btn = document.createElement('button'); 188 | if (content[1] == 'none') { 189 | btn.className = "none" 190 | } 191 | if (!this.buttons.includes(btn)) { 192 | this.buttons.push(btn) 193 | } 194 | btn.textContent = content[1] 195 | if (callbacks[i] && callbacks[i][j] && values[i][j] && data[i][j]) { 196 | let inner_values = values[i][j].split(',') 197 | let inner_data = data[i][j].split(',') 198 | if (k != -1) { 199 | btn.setAttribute('data', inner_data[k]); 200 | btn.setAttribute('active', inner_values[k]); 201 | } else { 202 | btn.setAttribute('data', data[i][j]); 203 | btn.setAttribute('active', values[i][j]); 204 | } 205 | btn.setAttribute('kind', content[0]); 206 | btn.setAttribute('label', content[1]); 207 | if (content[1] != 'none') { 208 | btn.onclick = callbacks[i][j].bind(this); 209 | } 210 | } 211 | td.appendChild(btn) 212 | break; 213 | } 214 | 215 | } 216 | 217 | onclick(e) { 218 | 219 | let kind = e.target.getAttribute('kind') 220 | let data = e.target.getAttribute('data') 221 | let label = e.target.getAttribute('label') 222 | let active = parseInt(e.target.getAttribute('active')) 223 | if (kind == "button") { 224 | if (active == 0) { 225 | e.target.setAttribute('active', 1) 226 | } else if (active == 1) { 227 | e.target.setAttribute('active', 0) 228 | } 229 | } 230 | active = parseInt(e.target.getAttribute('active')) 231 | //console.log(kind, label, data, active) 232 | if (this.onCommandReceived) { 233 | this.onCommandReceived(kind, label, data, active) 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /web/lib/nexus.comfy.js: -------------------------------------------------------------------------------- 1 | export class NexusPromptControl extends EventTarget { 2 | 3 | constructor(api, app, nexusSocket) { 4 | 5 | super() 6 | 7 | this.app = app 8 | this.api = api 9 | this.runningNodeId = null 10 | this.nexusSocket = nexusSocket 11 | this.comfy_events = ['b_preview', 'progress', 'executing', 'executed', 'execution_start', 'execution_error', 'execution_cached']; 12 | 13 | let oqp = api.queuePrompt; 14 | this.apiWrapper = { 15 | oqp: oqp, 16 | enableApi: false, 17 | api: api, 18 | qp: async function (number, data) { 19 | if (!this.enableApi) { 20 | return { 21 | "node_errors": {} 22 | }; 23 | } 24 | return this.oqp.call(this.api, number, { output: data.output, workflow: data.workflow }); 25 | } 26 | }; 27 | 28 | this.api.queuePrompt = this.apiWrapper.qp.bind(this.apiWrapper); 29 | this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this)) 30 | 31 | this.addEventListeners(); 32 | this.comfy_events.forEach((event) => { 33 | this.api.addEventListener(event, this.handleComfyEvents.bind(this)); 34 | }); 35 | 36 | this.handleProgressDraw() 37 | this.handleControl(false) 38 | this.handleEditor(false) 39 | 40 | } 41 | 42 | handleProgressDraw() { 43 | 44 | const origDrawNodeShape = LGraphCanvas.prototype.drawNodeShape; 45 | const self = this.app; 46 | const self2 = this; 47 | 48 | LGraphCanvas.prototype.drawNodeShape = function (node, ctx, size, fgcolor, bgcolor, selected, mouse_over) { 49 | 50 | const res = origDrawNodeShape.apply(this, arguments); 51 | const nodeErrors = self.lastNodeErrors?.[node.id]; 52 | 53 | let color = null; 54 | let lineWidth = 1; 55 | 56 | if (node.id === +self2.runningNodeId) { 57 | color = "#0f0"; 58 | } else if (self.dragOverNode && node.id === self.dragOverNode.id) { 59 | color = "dodgerblue"; 60 | } 61 | else if (nodeErrors?.errors) { 62 | color = "red"; 63 | lineWidth = 2; 64 | } 65 | else if (self.lastExecutionError && self.lastExecutionError.node_id === node.id) { 66 | color = "#f0f"; 67 | lineWidth = 2; 68 | } 69 | 70 | if (color) { 71 | const shape = node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE; 72 | ctx.lineWidth = lineWidth; 73 | ctx.globalAlpha = 0.8; 74 | ctx.beginPath(); 75 | if (shape == LiteGraph.BOX_SHAPE) 76 | ctx.rect(-6, -6 - LiteGraph.NODE_TITLE_HEIGHT, 12 + size[0] + 1, 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT); 77 | else if (shape == LiteGraph.ROUND_SHAPE || (shape == LiteGraph.CARD_SHAPE && node.flags.collapsed)) 78 | ctx.roundRect( 79 | -6, 80 | -6 - LiteGraph.NODE_TITLE_HEIGHT, 81 | 12 + size[0] + 1, 82 | 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, 83 | this.round_radius * 2 84 | ); 85 | else if (shape == LiteGraph.CARD_SHAPE) 86 | ctx.roundRect( 87 | -6, 88 | -6 - LiteGraph.NODE_TITLE_HEIGHT, 89 | 12 + size[0] + 1, 90 | 12 + size[1] + LiteGraph.NODE_TITLE_HEIGHT, 91 | [this.round_radius * 2, this.round_radius * 2, 2, 2] 92 | ); 93 | else if (shape == LiteGraph.CIRCLE_SHAPE) 94 | ctx.arc(size[0] * 0.5, size[1] * 0.5, size[0] * 0.5 + 6, 0, Math.PI * 2); 95 | ctx.strokeStyle = color; 96 | ctx.stroke(); 97 | ctx.strokeStyle = fgcolor; 98 | ctx.globalAlpha = 1; 99 | } 100 | 101 | if (self.progress && node.id === +self2.runningNodeId) { 102 | ctx.fillStyle = "green"; 103 | ctx.fillRect(0, 0, size[0] * (self.progress.value / self.progress.max), 6); 104 | ctx.fillStyle = bgcolor; 105 | } 106 | 107 | // Highlight inputs that failed validation 108 | if (nodeErrors) { 109 | ctx.lineWidth = 2; 110 | ctx.strokeStyle = "red"; 111 | for (const error of nodeErrors.errors) { 112 | if (error.extra_info && error.extra_info.input_name) { 113 | const inputIndex = node.findInputSlot(error.extra_info.input_name) 114 | if (inputIndex !== -1) { 115 | let pos = node.getConnectionPos(true, inputIndex); 116 | ctx.beginPath(); 117 | ctx.arc(pos[0] - node.pos[0], pos[1] - node.pos[1], 12, 0, 2 * Math.PI, false) 118 | ctx.stroke(); 119 | } 120 | } 121 | } 122 | } 123 | 124 | return res; 125 | } 126 | 127 | } 128 | 129 | handleMessageReceive(message) { 130 | try { 131 | this.dispatchEvent(new CustomEvent(message.name, { detail: message })); 132 | } catch (e) { 133 | console.error('Error handling message:', e); 134 | } 135 | } 136 | 137 | handleComfyEvents(evt) { 138 | if (evt.type === "b_preview") { 139 | this.blobToBase64(evt.detail).then((result) => { 140 | this.nexusSocket.sendMessage(evt.type, { detail: result }, false) 141 | }); 142 | } else { 143 | this.nexusSocket.sendMessage(evt.type, { detail: evt.detail }, false) 144 | } 145 | } 146 | 147 | addEventListeners() { 148 | 149 | this.addEventListener("progress", ({ detail }) => { 150 | detail = detail.data.detail 151 | this.app.progress = detail; 152 | this.app.graph.setDirtyCanvas(true, false); 153 | }); 154 | 155 | this.addEventListener("executing", ({ detail }) => { 156 | detail = detail.data.detail 157 | this.app.progress = null; 158 | this.runningNodeId = detail; 159 | this.app.graph.setDirtyCanvas(true, false); 160 | delete this.app.nodePreviewImages[this.runningNodeId]; 161 | }); 162 | 163 | this.addEventListener("executed", ({ detail }) => { 164 | detail = detail.data.detail 165 | const output = this.app.nodeOutputs[detail.node]; 166 | if (detail.merge && output) { 167 | for (const k in detail.output ?? {}) { 168 | const v = output[k]; 169 | if (v instanceof Array) { 170 | output[k] = v.concat(detail.output[k]); 171 | } else { 172 | output[k] = detail.output[k]; 173 | } 174 | } 175 | } else { 176 | this.app.nodeOutputs[detail.node] = detail.output; 177 | } 178 | const node = this.app.graph.getNodeById(detail.node); 179 | if (node && node.onExecuted) { 180 | node.onExecuted(detail.output); 181 | } 182 | }); 183 | 184 | this.addEventListener("execution_start", ({ detail }) => { 185 | detail = detail.data.detail 186 | this.runningNodeId = null; 187 | this.app.lastExecutionError = null; 188 | this.app.graph._nodes.forEach((node) => { 189 | if (node.onExecutionStart) { 190 | node.onExecutionStart(); 191 | } 192 | }); 193 | }); 194 | 195 | this.addEventListener("execution_error", ({ detail }) => { 196 | detail = detail.data.detail 197 | this.app.lastExecutionError = detail; 198 | const formattedError = this.formatExecutionError(detail); 199 | this.app.ui.dialog.show(formattedError); 200 | this.app.canvas.draw(true, true); 201 | }); 202 | 203 | this.addEventListener("b_preview", ({ detail }) => { 204 | detail = detail.data.detail 205 | const id = this.runningNodeId; 206 | if (id == null) return; 207 | const blob = this.base64ToBlob(detail); 208 | const blobUrl = URL.createObjectURL(blob); 209 | this.app.nodePreviewImages[id] = [blobUrl]; 210 | }); 211 | } 212 | 213 | blobToBase64(blob) { 214 | return new Promise((resolve, reject) => { 215 | const reader = new FileReader(); 216 | reader.onloadend = () => { 217 | resolve(reader.result); 218 | }; 219 | reader.onerror = reject; 220 | reader.readAsDataURL(blob); 221 | }); 222 | } 223 | 224 | base64ToBlob(dataUrl) { 225 | const [header, base64] = dataUrl.split(','); 226 | const mime = header.match(/:(.*?);/)[1]; 227 | const byteCharacters = atob(base64); 228 | const byteNumbers = new Array(byteCharacters.length); 229 | for (let i = 0; i < byteCharacters.length; i++) { 230 | byteNumbers[i] = byteCharacters.charCodeAt(i); 231 | } 232 | const byteArray = new Uint8Array(byteNumbers); 233 | return new Blob([byteArray], { type: mime }); 234 | } 235 | 236 | formatExecutionError(error) { 237 | if (error == null) { 238 | return "(unknown error)"; 239 | } 240 | 241 | const traceback = error.traceback.join(""); 242 | const nodeId = error.node_id; 243 | const nodeType = error.node_type; 244 | 245 | return `Error occurred when executing ${nodeType}:\n\n${error.exception_message}\n\n${traceback}`; 246 | } 247 | 248 | handleEditor(oN) { 249 | 250 | this.app.canvas.allow_dragnodes = oN; 251 | this.app.canvas.allow_interaction = oN; 252 | this.app.canvas.allow_reconnect_links = oN; 253 | this.app.canvas.allow_searchbox = oN; 254 | 255 | } 256 | 257 | handleControl(oN) { 258 | this.apiWrapper.enableApi = oN; 259 | } 260 | 261 | } -------------------------------------------------------------------------------- /web/lib/nexus.panel.js: -------------------------------------------------------------------------------- 1 | import { DynamicElement } from "./nexus.elements.js" 2 | import { StyleInjector } from "./nexus.styles.js"; 3 | 4 | export class UserPanel { 5 | 6 | constructor() { 7 | 8 | this.active = 0 9 | 10 | this.panel = new DynamicElement({ 11 | id: 'nexus-player-panel', 12 | tag: 'div', 13 | visible: false, 14 | styles: { 15 | "width": "100%", 16 | "height": "100vh", 17 | "display": "flex", 18 | "align-items": "center", 19 | "justify-content": "center", 20 | "box-sizing": "border-box", 21 | "position": "absolute", 22 | "left": "0", 23 | "top": "0", 24 | "z-index": "10000" 25 | }, 26 | childrens: [ 27 | new DynamicElement({ 28 | id: 'nexus-player-panel-table', 29 | tag: 'div', 30 | visible: true, 31 | styles: { 32 | "width": "832px", 33 | "display": "flex", 34 | "flex-direction": "column", 35 | "border-radius": "6px", 36 | "box-sizing": "border-box" 37 | }, 38 | childrens: [ 39 | new DynamicElement({ 40 | id: 'nexus-player-panel-table-body', 41 | tag: 'div', 42 | visible: true, 43 | styles: { 44 | "width": "100%", 45 | "display": "flex", 46 | "flex-direction": "column", 47 | "align-items": "center", 48 | "justify-content": "flex-start", 49 | "color": "#a3a3a3", 50 | "font-family": "Calibri", 51 | "font-weight": "600", 52 | "gap": "12px", 53 | "padding-bottom": "12px", 54 | "box-sizing": "border-box", 55 | "background": "rgba(54,54,54,0.8)", 56 | "border-radius": "6px", 57 | "box-shadow": "4px 8px 8px rgba(0,0,0,0.5)", 58 | "max-height": "540px", 59 | "overflowY": "auto" 60 | } 61 | }), 62 | new StyleInjector({ 63 | styles: { 64 | "#nexus-player-panel .table-row": { 65 | "width": "100%", 66 | "display": "grid", 67 | "align-items": "center", 68 | "grid-template-columns": "20% 60% 20%", 69 | "justify-content": "space-between", 70 | }, 71 | "#nexus-player-panel .table-row:first-child": { 72 | "width": "100%", 73 | "color": "#a3a3a3", 74 | "padding": "16px 0", 75 | "background": "rgb(0,0,0,0.6)", 76 | "border-top-left-radius": "6px", 77 | "border-top-right-radius": "6px", 78 | "position": "sticky", 79 | "top": "0", 80 | }, 81 | "#nexus-player-panel .table-row .table-row-child": { 82 | "display": "flex", 83 | "justify-content": "flex-start", 84 | "gap": "12px", 85 | "padding-left": "12px", 86 | "width": "100%" 87 | }, 88 | "#nexus-player-panel .table-row-child button": { 89 | "border": "none", 90 | "padding": "6px 12px", 91 | "border-radius": "3px", 92 | "cursor": "pointer", 93 | "background": "rgba(94,94,94)", 94 | "color": "#a1a1a1", 95 | }, 96 | "#nexus-player-panel .table-row-child button:active": { 97 | "opacity": "0.8" 98 | }, 99 | "#nexus-player-panel .table-row-child button[active='1'][label='spectate']": { 100 | "background": "#218c74", 101 | "color": "#fafafa" 102 | }, 103 | "#nexus-player-panel .table-row-child button[active='1'][label='editor']": { 104 | "background": "#474787", 105 | "color": "#fafafa" 106 | }, 107 | "#nexus-player-panel .table-row-child button[active='1'][label='mouse']": { 108 | "background": "#cd6133", 109 | "color": "#fafafa" 110 | }, 111 | "#nexus-player-panel .table-row-child button[active='1'][label='queue']": { 112 | "background": "#6F1E51", 113 | "color": "#fafafa" 114 | } 115 | } 116 | }) 117 | ] 118 | }) 119 | ], 120 | }) 121 | this.onCommandReceived = null 122 | this.buttons = [] 123 | this.columns = [] 124 | } 125 | 126 | addColumnsToPanel(columns) { 127 | 128 | this.columns = columns 129 | 130 | } 131 | 132 | addRowsToPanel(rows, callbacks, values, data) { 133 | 134 | let tbody = this.panel.childrens[0].childrens[0]; 135 | tbody.removeChildrens(); 136 | this.buttons = [] 137 | 138 | const tr = document.createElement('div'); 139 | tr.className = "table-row"; 140 | 141 | this.columns.forEach(heading => { 142 | const th = document.createElement('span'); 143 | th.className = "table-row-child" 144 | th.textContent = heading; 145 | tr.appendChild(th) 146 | }); 147 | 148 | tbody.addChild(tr) 149 | 150 | rows.forEach((row, i) => { 151 | const tr = document.createElement('div'); 152 | tr.className = "table-row"; 153 | 154 | row.forEach((cell, j) => { 155 | 156 | let td = document.createElement('span'); 157 | td.className = "table-row-child" 158 | 159 | let hascomma = cell.indexOf(",") > -1 160 | let hascolon = cell.indexOf(":") > -1 161 | 162 | let content = [] 163 | 164 | if (hascomma) { 165 | content = cell.split(',') 166 | content.forEach((elm, k) => { 167 | let elmcontent = elm.split(':') 168 | this.addChildToParent(elmcontent, td, callbacks, values, data, i, j, k) 169 | }) 170 | } 171 | else if (hascolon) { 172 | content = cell.split(':') 173 | this.addChildToParent(content, td, callbacks, values, data, i, j, -1) 174 | } else { 175 | td.textContent = cell 176 | } 177 | tr.appendChild(td); 178 | 179 | }); 180 | tbody.addChild(tr) 181 | }); 182 | } 183 | 184 | addChildToParent(content, td, callbacks, values, data, i, j, k) { 185 | //console.log(content, td, callbacks, values, data, i, j, k) 186 | switch (content[0]) { 187 | case 'button': 188 | let btn = document.createElement('button'); 189 | if (content[1] == 'none') { 190 | btn.className = "none" 191 | } 192 | if (!this.buttons.includes(btn)) { 193 | this.buttons.push(btn) 194 | } 195 | btn.textContent = content[1] 196 | if (callbacks[i] && callbacks[i][j] && values[i][j] && data[i][j]) { 197 | let inner_values = values[i][j].split(',') 198 | let inner_data = data[i][j].split(',') 199 | if (k != -1) { 200 | btn.setAttribute('data', inner_data[k]); 201 | btn.setAttribute('active', inner_values[k]); 202 | } else { 203 | btn.setAttribute('data', data[i][j]); 204 | btn.setAttribute('active', values[i][j]); 205 | } 206 | btn.setAttribute('kind', content[0]); 207 | btn.setAttribute('label', content[1]); 208 | if (content[1] != 'none') { 209 | btn.onclick = callbacks[i][j].bind(this); 210 | } 211 | } 212 | td.appendChild(btn) 213 | break; 214 | } 215 | 216 | } 217 | 218 | disableButtons(label) { 219 | this.buttons.forEach(btn => { 220 | let btn_kind = btn.getAttribute('kind') 221 | let btn_data = btn.getAttribute('data') 222 | let btn_active = parseInt(btn.getAttribute('active')) 223 | let btn_label = btn.getAttribute('label') 224 | if (btn_label == label) { 225 | if (btn_active == 1) { 226 | btn.setAttribute('active', 0) 227 | //console.log(btn_kind, btn_label, btn_data, 0) 228 | if (this.onCommandReceived) { 229 | this.onCommandReceived(btn_kind, btn_label, btn_data, 0) 230 | } 231 | } 232 | } 233 | }) 234 | } 235 | 236 | onclick(e) { 237 | 238 | let kind = e.target.getAttribute('kind') 239 | let data = e.target.getAttribute('data') 240 | let label = e.target.getAttribute('label') 241 | let active = parseInt(e.target.getAttribute('active')) 242 | if (kind == "button") { 243 | if (label == "spectate") { 244 | this.disableButtons(label) 245 | } 246 | if (active == 0) { 247 | e.target.setAttribute('active', 1) 248 | } else if (active == 1) { 249 | e.target.setAttribute('active', 0) 250 | } 251 | } 252 | active = parseInt(e.target.getAttribute('active')) 253 | //console.log(kind, label, data, active) 254 | if (this.onCommandReceived) { 255 | this.onCommandReceived(kind, label, data, active) 256 | } 257 | } 258 | } -------------------------------------------------------------------------------- /web/lib/nexus.workflow.js: -------------------------------------------------------------------------------- 1 | import "./nexus.litegraph.js" 2 | import { postWorkflow, postBackupWorkflow, getLatestWorkflow } from "./nexus.api.js"; 3 | 4 | export class NexusWorkflowManager { 5 | 6 | constructor(app, api, nexusSocket) { 7 | 8 | this.app = app; 9 | this.api = api; 10 | 11 | this.nexusSocket = nexusSocket; 12 | this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this)) 13 | 14 | this.init() 15 | 16 | this.spectators = [] 17 | this.saveTime = 0 18 | setInterval(this.handleSpectators.bind(this), 1000 / 24) // 24 fps 19 | 20 | } 21 | 22 | async init() { 23 | let workflow = (await this.app.graphToPrompt()).workflow; 24 | await postBackupWorkflow(this.nexusSocket.uuid, workflow) 25 | this.app.graph.clear() 26 | } 27 | 28 | removeSaveManager() { 29 | if (this.saveTimer) { 30 | clearInterval(this.saveTimer) 31 | } 32 | // document.removeEventListener("visibilitychange", this.handleSaveWorkflowManager.bind(this)) 33 | } 34 | 35 | async saveManager(time) { 36 | if (this.nexusSocket.admin) { 37 | this.nexusSocket.sendMessage('workflow:timer', { time: time - 2 }, false) 38 | } 39 | this.removeSaveManager() 40 | this.saveTime = time 41 | this.saveTimer = setInterval(this.sendWorkFlow.bind(this), time * 1000) 42 | console.log("Auto Backup Timer Setup:",time) 43 | // document.addEventListener("visibilitychange", this.handleSaveWorkflowManager.bind(this)) 44 | } 45 | 46 | async sendWorkFlow() { 47 | let workflow = (await this.app.graphToPrompt()).workflow; 48 | await postWorkflow(this.nexusSocket.uuid, workflow) 49 | } 50 | 51 | async loadLatestWorkflow() { 52 | let workflow = await getLatestWorkflow() 53 | let links = workflow.links || []; 54 | let nodes = workflow.nodes || []; 55 | let groups = workflow.groups || []; 56 | this.app.graph.updateNodes(nodes); 57 | this.app.graph.updateLinks(links); 58 | this.app.graph.updateGroups(groups); 59 | this.app.graph.updateExecutionOrder(); 60 | this.app.graph.setDirtyCanvas(true, true); 61 | } 62 | 63 | async handleSaveWorkflowManager() { 64 | this.sendWorkFlow() 65 | // if (document.visibilityState === "hidden") { 66 | // if (this.saveTimer) { 67 | // clearInterval(this.saveTimer) 68 | // } 69 | // } 70 | // else { 71 | // this.saveTimer = setInterval(this.sendWorkFlow.bind(this), this.saveTime * 1000) 72 | // } 73 | } 74 | 75 | async handleSpectators() { 76 | if (this.spectators.length > 0) { 77 | let workflow = (await this.app.graphToPrompt()).workflow; 78 | let wfe = workflow.extra 79 | for (let index = 0; index < this.spectators.length; index++) { 80 | const id = this.spectators[index]; 81 | this.nexusSocket.sendMessage('workflow:spectate', { workflow: wfe, receiver: id }, false) 82 | } 83 | } 84 | } 85 | 86 | async handleMessageReceive(message) { 87 | 88 | let from = message.from; 89 | let data = message.data 90 | let name = message.name; 91 | 92 | switch (name) { 93 | case "spectate": 94 | if (data.on == 0) { 95 | if (this.spectators.includes(from)) { 96 | this.spectators.splice(this.spectators.indexOf(from), 1) 97 | } 98 | } else if (data.on == 1) { 99 | if (!this.spectators.includes(from)) { 100 | this.spectators.push(from) 101 | } 102 | } 103 | case "join": 104 | // this.sendWorkFlow() 105 | this.saveManager(this.saveTime || 60) 106 | break; 107 | case "workflow:spectate": 108 | let wfe = data.workflow 109 | if (wfe?.ds) { 110 | this.app.canvas.ds.offset = wfe.ds.offset; 111 | this.app.canvas.ds.scale = wfe.ds.scale; 112 | } 113 | break; 114 | case "workflow:timer": 115 | let time = data.time 116 | this.saveManager(time) 117 | break; 118 | case "workflow:update": 119 | this.handleWorkflowUpdate(from, data) 120 | break; 121 | case "workflow": 122 | let workflow = data.workflow 123 | if (workflow.clear) { 124 | await this.app.graph.clear() 125 | } 126 | let links = workflow.links || []; 127 | let nodes = workflow.nodes || []; 128 | let groups = workflow.groups || []; 129 | this.app.graph.updateNodes(nodes); 130 | this.app.graph.updateLinks(links); 131 | this.app.graph.updateGroups(groups); 132 | this.app.graph.updateExecutionOrder(); 133 | this.app.graph.setDirtyCanvas(true, true); 134 | } 135 | 136 | } 137 | 138 | handleWorkflowEvents(oN) { 139 | if (oN) { 140 | this.app.graph.onNodeAdded = this.onNodeAdded.bind(this); 141 | this.app.graph.onNodeAddedNexus = this.onNodeAddedNexus.bind(this); 142 | this.app.graph.onNodeRemoved = this.onNodeRemoved.bind(this); 143 | this.app.canvas.onSelectionChange = this.onSelectionChange.bind(this); 144 | this.app.canvas.onNodeDeselected = this.onNodeDeselected.bind(this); 145 | this.app.canvas.onNodeMoved = this.onNodeMoved.bind(this); 146 | this.app.graph.onGroupAdded = this.onGroupAdded.bind(this); 147 | this.app.graph.onGroupRemoved = this.onGroupRemoved.bind(this); 148 | this.app.graph._groups.forEach(group => { 149 | group.onGroupMoved = this.onGroupMoved.bind(this, group); 150 | group.onGroupNodeMoved = this.onNodeMoved.bind(this); 151 | }); 152 | this.app.graph._nodes.forEach(node => { 153 | node.onWidgetChanged = this.onWidgetChanged.bind(this, node); 154 | node.onConnectionsChange = this.onNodeConnectionChange.bind(this, node); 155 | }); 156 | } 157 | else { 158 | this.app.graph.onNodeAddedNexus = null; 159 | this.app.graph.onNodeAdded = null; 160 | this.app.graph.onNodeRemoved = null; 161 | this.app.canvas.onSelectionChange = null; 162 | this.app.canvas.onNodeDeselected = null; 163 | this.app.canvas.onNodeMoved = null; 164 | this.app.graph.onGroupAdded = null; 165 | this.app.graph.onGroupRemoved = null; 166 | this.app.graph._groups.forEach(group => { 167 | group.onGroupMoved = null; 168 | group.onGroupNodeMoved = null; 169 | }); 170 | this.app.graph._nodes.forEach(node => { 171 | node.onWidgetChanged = null; 172 | node.onConnectionsChange = null; 173 | }); 174 | } 175 | } 176 | 177 | handleWorkflowUpdate(from, detail) { 178 | 179 | let graph = this.app.graph; 180 | let canvas = this.app.canvas; 181 | let update = detail.update; 182 | let data = detail.data; 183 | 184 | let existingNode; 185 | 186 | switch (update) { 187 | case "node-added": 188 | graph.updateNode(data); 189 | break; 190 | case "node-removed": 191 | graph.removeNode(data); 192 | break; 193 | case "node-moved": 194 | graph.updateNode(data); 195 | break; 196 | case "node-widget-changed": 197 | graph.updateNode(data); 198 | break; 199 | case "node-selection": 200 | let nodes = [] 201 | data.forEach(node => { 202 | existingNode = graph.getNodeById(node.id); 203 | if (existingNode) { 204 | nodes.push(existingNode) 205 | } 206 | }) 207 | canvas.selectNodesNexus(nodes, true) 208 | break; 209 | case "node-deselection": 210 | existingNode = graph.getNodeById(data.id); 211 | if (existingNode) 212 | canvas.deselectNodeNexus(existingNode) 213 | break; 214 | case "group-added": 215 | graph.updateGroup(data); 216 | break; 217 | case "group-moved": 218 | graph.updateGroup(data); 219 | break; 220 | case "group-removed": 221 | graph.removeGroup(data); 222 | break; 223 | case "node-connection": 224 | graph.updateLinkManual(detail.change, detail.link, detail.node, graph); 225 | break; 226 | default: 227 | //console.log(update); 228 | break; 229 | } 230 | 231 | } 232 | 233 | onNodeAddedNexus(node) { 234 | node.onWidgetChanged = this.onWidgetChanged.bind(this, node); 235 | node.onConnectionsChange = this.onNodeConnectionChange.bind(this, node); 236 | } 237 | 238 | onNodeAdded(node) { 239 | let data = node.serialize(); 240 | node.onWidgetChanged = this.onWidgetChanged.bind(this, node); 241 | node.onConnectionsChange = this.onNodeConnectionChange.bind(this, node); 242 | this.nexusSocket.sendMessage('workflow:update', { update: "node-added", data }, false); 243 | } 244 | 245 | onWidgetChanged(node) { 246 | let data = node.serialize(); 247 | this.nexusSocket.sendMessage('workflow:update', { update: "node-widget-changed", data }, false); 248 | } 249 | 250 | onNodeRemoved(node) { 251 | let data = node.serialize(); 252 | this.nexusSocket.sendMessage('workflow:update', { update: "node-removed", data }, false); 253 | } 254 | 255 | async onNodeConnectionChange(node, a, b, c, d, e) { 256 | let change = c ? "add" : "remove"; 257 | let link = d; 258 | this.graphData = (await this.app.graphToPrompt()).workflow; 259 | let data = this.graphData.links; 260 | //console.log(data) 261 | node = this.app.graph.getNodeById(node.id); 262 | node = node ? node.serialize() : null; 263 | this.nexusSocket.sendMessage('workflow:update', { node, update: "node-connection", link, change, data }, false); 264 | } 265 | 266 | onSelectionChange(nodes) { 267 | let data = [] 268 | Object.keys(nodes).forEach(node_id => { 269 | data.push(nodes[node_id].serialize()) 270 | }) 271 | this.nexusSocket.sendMessage('workflow:update', { update: "node-selection", data }, false); 272 | } 273 | 274 | onNodeDeselected(node) { 275 | let data = node.serialize() 276 | this.nexusSocket.sendMessage('workflow:update', { update: "node-deselection", data }, false); 277 | } 278 | 279 | onNodeMoved(node) { 280 | let data = node.serialize(); 281 | this.nexusSocket.sendMessage('workflow:update', { update: "node-moved", data }, false); 282 | } 283 | 284 | onGroupAdded(group) { 285 | let data = group.serialize(); 286 | let uuid = localStorage.getItem('nexus-socket-uuid'); 287 | if (uuid) { 288 | data.owner = uuid 289 | group.configure(data); 290 | } 291 | group.onGroupMoved = this.onGroupMoved.bind(this, group); 292 | group.onGroupNodeMoved = this.onNodeMoved.bind(this); 293 | this.nexusSocket.sendMessage('workflow:update', { update: "group-added", data }, false); 294 | } 295 | 296 | onGroupRemoved(group) { 297 | let data = group.serialize(); 298 | this.nexusSocket.sendMessage('workflow:update', { update: "group-removed", data }, false); 299 | } 300 | 301 | onGroupMoved(group) { 302 | let data = group.serialize(); 303 | this.nexusSocket.sendMessage('workflow:update', { update: "group-moved", data }, false); 304 | } 305 | 306 | } 307 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ![COMFYUI-NEXUS](https://github.com/user-attachments/assets/6548c010-649b-4e6c-8ae1-f05e3f523f31) 3 | 4 | # ComfyUI-Nexus 5 | 6 | ![Version](https://img.shields.io/badge/version-1.0.2-green) ![Last Update](https://img.shields.io/badge/last_update-Sept_2024-green) 7 | 8 | A ComfyUI node designed to enable seamless multi-user workflow collaboration. 9 | 10 | ![Untitled design (2)](https://github.com/user-attachments/assets/1d1f4b2b-0999-461e-b43c-719d107c54df) 11 | 12 | **Features Video**: https://www.youtube.com/watch?v=RnYIUG59oTM 13 | 14 |
15 | 16 | # Important Notes 17 | 18 |
19 | 20 | - **Install/Maintain on Server Only**: This node should only be installed on the server machine. 21 | - **No Installation Needed for Others**: Other users don’t need to install this node. 22 | - **URL for Connection**: Other users only need the URL to connect locally/remotely. 23 | 24 | - **Security**: 25 | - ComfyUI menu and features are for admins only. 26 | - ComfyUI shortcuts are for admins only. 27 | - Prompt Queue shortcut `CTRL+Enter` is for users with queue permission only. 28 | 29 | - **Editor Permissions**: 30 | - Editors can only edit the graph (create/update/delete/move). 31 | - If an editor has queue permission, they can queue prompts in the workflow. 32 | 33 | - **All Admin Server (not recommended)**: 34 | - One can create a server with all admins to resolve permission issues. 35 | - Refer to the `Admin Account Setup` section for more details. 36 | 37 |
38 | 39 | > [!WARNING] 40 | > When opening the ComfyUI workspace for the first time, it will be locked. Login as admin to enable editing. 41 | 42 | > [!WARNING] 43 | > Move or disable the ComfyUI-Nexus nodes from the custom nodes folder if you want to return to your normal ComfyUI setup. 44 | 45 |
46 | 47 | > [!CAUTION] 48 | > Enable the old `litegraph(legacy)` node search box. (New node search box is under development and has bugs) 49 | 50 | ![Untitled design (4)](https://github.com/user-attachments/assets/336a29e8-f6fb-4730-bd6d-f6b94947941b) 51 | 52 |
53 | 54 | ### Location of Nexus folder 55 | 56 | #### ComfyUI Folder 57 | - `Drive:/ComfyUI_windows_portable/nexus` 58 | 59 | #### Stable Matrix 60 | - **Full Version**: `Drive:/StabilityMatrix/Packages/ComfyUI/nexus` 61 | - **Portable Version**: `Drive:/StabilityMatrix/Data/Packages/ComfyUI/nexus` 62 | 63 |
64 | 65 | ## Disabling ComfyUI-Nexus 66 | 67 |
68 | 69 | - Stop ComfyUI and go to `ComfyUI\custom_nodes` folder 70 | - Rename `ComfyUI-Nexus` like this `ComfyUI-Nexus.disabled` to disable. 71 | - Restart ComfyUI again. 72 | 73 |
74 | 75 | ## Key Features 76 | 77 |
78 | 79 | - **Multiuser collaboration**: enable multiple users to work on the same workflow simultaneously. 80 | - **Local and Remote access**: use tools like ngrok or other tunneling software to facilitate remote collaboration. A local IP address on WiFi will also work 😎. 81 | - **Enhanced teamwork**: streamline your team's workflow management and collaboration process. 82 | - **Real-time chat**: communicate directly within the platform to ensure smooth and efficient collaboration. 83 | - **Spectate mode**: allow team members to observe the workflow in real-time without interfering—perfect for training or monitoring progress. 84 | - **Admin permissions**: admins can control who can edit the workflow and who can queue prompts, ensuring the right level of access for each team member. 85 | - **Workflow backup**: in case of any mishap, you can reload an old backup. The node saves 5 workflows, each 60 seconds apart. 86 | 87 |
88 | 89 | ## Key Binds 90 | 91 | - **Activate chat**: press **`t`** 92 | - **Show/hide users panel**: press **`LAlt + p`** 93 | - **Show/hide backups panel**: press **`LAlt + o`** (for user with editor permission only) 94 | - **Queue promt**: press **`CTRL+Enter`** (for user with queue permission only) 95 | 96 |
97 | 98 | ## Chat Commands 99 | 100 | - `/nick `: changes your nickname 101 | - `/login account password`: this command is used to become admin. 102 | - `/logout`: logout the admin. 103 | 104 |
105 | 106 | # Node Installation 107 | 108 | - ### Installing Using `comfy-cli` 109 | - `comfy node registry-install ComfyUI-Nexus` 110 | - https://registry.comfy.org/publishers/daxcay/nodes/comfyui-nexus 111 | 112 | - ### Manual Method 113 | - Go to your `ComfyUI\custom_nodes` and Run CMD. 114 | - Copy and paste this command: `git clone https://github.com/daxcay/ComfyUI-Nexus.git` 115 | 116 | - ### Automatic Method with [Comfy Manager](https://github.com/ltdrdata/ComfyUI-Manager) 117 | - Inside ComfyUI > Click the Manager Button on the side. 118 | - Click `Custom Nodes Manager` and search for `ComfyUI-Nexus`, then install this node. 119 | 120 |
121 | 122 | >[!IMPORTANT] 123 | > #### **Restart ComfyUI before proceeding to next step** 124 | 125 |
126 | 127 | # Server Setup 128 | 129 |
130 | 131 | ### Knowing ComfyUI Port 132 | 133 | - Open Comfyui in your browser: 134 | 135 | ![image](https://github.com/user-attachments/assets/b430d5b7-dcb9-4a7f-948f-d257147b597a) 136 | 137 | - In your url tab, digits after colon (:) is your port. 138 | 139 | **Example:** 140 | 141 | ![image](https://github.com/user-attachments/assets/82ff2d9e-9eb6-4846-97c6-e3e321101fef) 142 | 143 | The port for the above URL will be **8188** 144 | 145 |
146 | 147 | ### Admin Account Setup 148 | 149 | - Open the file `ComfyUI\nexus\admins.json` in notepad. 150 | 151 | ![image](https://github.com/user-attachments/assets/2c0f3e6b-8bea-4378-8390-1bb377514e0c) 152 | 153 | - **"epic"** is the account name and **"comfynexus"** is password 154 | - Replace account and password with your own liking, but make sure not to use spaces. 155 | 156 | ### More than 1 Admin Account Setup 157 | 158 | - Open the file `ComfyUI\nexus\admins.json` in notepad. add another account(s) and password(s) like this. 159 | 160 | ![image](https://github.com/user-attachments/assets/35461ce1-b1a6-4ddb-8333-5dcf7d6acf55) 161 | 162 | - Make sure every password is different, and make sure not to use spaces. 163 | 164 |
165 | 166 | ### Setting run_nvidia_gpu.bat (Local setup if not using Tunneling Software) 167 | 168 | - Open *run_nvidia_gpu.bat* and write the following and save it: 169 | 170 | ```.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --disable-auto-launch --enable-cors-header "*"``` 171 | 172 |
173 | 174 | - For a host machine in another IP Address write and save it: 175 | 176 | ```.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --disable-auto-launch --enable-cors-header "*" --listen 0.0.0.0``` 177 | 178 |
179 | 180 | >[!IMPORTANT] 181 | >Don't leave the password as "comfynexus" as anyone can login. 182 | 183 | >[!NOTE] 184 | >**DO NOT SHARE ACCOUNT AND PASSWORD IN PUBLIC** 185 | 186 | >[!IMPORTANT] 187 | > #### Save file and **Restart ComfyUI before proceeding to next step** 188 | 189 |
190 | 191 | # Hosting Setup 192 | 193 | - One can use Ngrok or any other tunneling software supporting http/https to host a comfyui server remotely. 194 | - Also you can host locally over WiFi/Lan. 195 | 196 | ### Using Ngrok: 197 | 198 | - Go to this https://dashboard.ngrok.com/signup?ref=home-hero to sign up. 199 | - After signing up and logging in, go to this https://dashboard.ngrok.com/get-started/setup/windows to set up ngrok. 200 | - After installing and setting up ngrok, 201 | - Run CMD and enter this command: `ngrok http ` 202 | 203 | ![Ngrok Output Example](https://github.com/user-attachments/assets/66f9b4a4-1d63-4756-8d57-64420fdc151a) 204 | ![image](https://github.com/user-attachments/assets/e3ca3d23-a388-4879-8b45-23591a05833c) 205 | 206 | - **Forwarding** is the Remote URL, Share this URL with your peers. 207 | 208 |
209 | 210 | ### Using Local IP 211 | 212 | - Open a cmd and write `ipconfig`. 213 | 214 | ![image](https://github.com/user-attachments/assets/56c4c17d-b1dc-40e1-acbc-1e62e8e15b70) 215 | 216 | - Now copy IPv4 address ad add comfy port to it. For example, if it's `http://192.168.1.45:`, the final URL will be: `http://192.168.1.45:5000` 217 | - Share this URL with your peers. 218 | 219 |
220 | 221 | >[!NOTE] 222 | > **Ngrok and WiFi address might change if you restart the machine. Follow above steps again to get the new URL.** 223 | 224 |
225 | 226 | ## Permissions in ComfyUI-Nexus 227 | 228 | - **viewer**: default permission given to a person joining the server. 229 | - **editor**: person having editor permission cad edit the workflow. 230 | - **queue prompt**: person having queue permission can queue the workflow. 231 |
232 | 233 | >[!NOTE] 234 | > Admin has all permissions by default. 235 | 236 |
237 | 238 | ## Real-Time Chat Window 239 | 240 | When you join ComfyUI for the first time, you will see this chat window in the top left corner: 241 | 242 | ![Chat Message Example](https://github.com/user-attachments/assets/6b908ade-cd01-43d4-831c-6af2c6c461cf) 243 | 244 |
245 | 246 | To chat, press `t`, then write the message and press 'Enter'. 247 | 248 |
249 | 250 | ### Chat Commands 251 | 252 | - `/nick `: changes your nickname 253 | - `/login `: this command is used to become admin. **( account name and password saved in `admins.json` above )** 254 | - `/logout`: logout the admin. 255 | 256 |
257 | 258 | ## User Panel 259 | 260 | To show/hide the user panel, press `LShift+ LAlt + p`. 261 | 262 | **For users, the user panel will look like this:** 263 | 264 | ![User Panel Example](https://github.com/user-attachments/assets/eae8791c-40a8-48d6-b72d-f4f7875d1653) 265 | 266 | Users can perform the following actions on a joined user: 267 | 268 | ![image](https://github.com/user-attachments/assets/94ae776c-b96e-4d3e-8cc3-f01ec9cb4ee2) 269 | 270 | - **mouse**: show/hide the mouse of other players. 271 | - **spectate**: enable/disable spectate mode. Main use case: when you want to see or learn something from another user. 272 | 273 | **For admins, the user panel will look like this:** 274 | 275 | ![Admin Panel Example](https://github.com/user-attachments/assets/0ce11918-4890-4202-a2d0-2df6f3a1fae0) 276 | 277 | Admins can perform the following actions on a joined user: 278 | 279 | ![Admin Panel Actions Example](https://github.com/user-attachments/assets/ff147ca6-a51c-4eea-9e87-6c8db6322311) 280 | 281 | - **spectate**: enable/disable spectate mode. Main use case: when you want to see or learn something from another user. 282 | - **editor**: give/revoke editor permission to/from that user. Anyone with this permission can edit the workflow. 283 | - **queue**: give/revoke queue permission to/from that user. Anyone with this permission can queue the workflow. 284 | - **mouse**: show/hide the mouse of other players. 285 | 286 |
287 | 288 | ## Backup Panel (For Admins and Editors Only) 289 | 290 | To show/hide the backup panel, press `LShift + LAlt + o`. 291 | 292 | **The backup panel looks like this:** 293 | 294 | ## OLD 295 | ![Backup Panel Example](https://github.com/user-attachments/assets/27af5386-b848-4081-a88d-e8d4967a72f0) 296 | 297 | ## NEW 298 | ![image](https://github.com/user-attachments/assets/423c9e23-b13a-4b50-998c-712ec7f08f51) 299 | 300 | - **load**: load the backup on ComfyUI. If the admin presses it, it will load for all users. 301 | 302 |
303 | 304 | Backups are now divided into **'Short Term'** and **'Long Term'** 305 | 306 | **Short Term**: These are only 5 backups and are saved 60 seconds apart. 307 | 308 | **Long Term**: These backups are created every reload and they are never overwritten. 309 | 310 | > Backups are saved 60 seconds apart. To load a workflow dragged by an admin, the admin will have to wait 60 seconds to let the server make a backup, then load it for all users. 311 | 312 |
313 | 314 | ## Inspiration 315 | 316 | - Greatly Inspired by https://multitheftauto.com/ a GTA:SA multiplayer mod I spent years playing ❤️. 317 | 318 | ## Future Updates 319 | 320 | - Based on feedback, I will add/update features. 321 | - Multi-room collaboration. 322 | - Users can set their own color for names and mouse cursors. 323 | 324 |
325 | 326 | ### Daxton Caylor - ComfyUI Node Developer 327 | 328 | - ### Contact 329 | - **Email** - daxtoncaylor+Github@gmail.com 330 | - **Discord Server**: https://discord.gg/UyGkJycvyW 331 | 332 | - ### Support 333 | - **Patreon**: https://patreon.com/daxtoncaylor 334 | - **Buy me a coffee**: https://buymeacoffee.com/daxtoncaylor 335 | 336 | 337 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import os 4 | import shutil 5 | import datetime 6 | import time 7 | import uuid 8 | from aiohttp import web 9 | from server import PromptServer 10 | import folder_paths 11 | 12 | from .classes.WebSocketManager import WebSocketManager 13 | 14 | DATA_FOLDER = f"{folder_paths.base_path}/nexus" 15 | 16 | PERMISSIONS_FILE = os.path.join(DATA_FOLDER, 'permissions.json') 17 | ADMINS_FILE = os.path.join(DATA_FOLDER, 'admins.json') 18 | TOKENS_FILE = os.path.join(DATA_FOLDER, 'tokens.json') 19 | NAMES_FILE = os.path.join(DATA_FOLDER, 'names.json') 20 | 21 | SERVER_FILE = os.path.join(DATA_FOLDER, 'server.json') 22 | WORKFLOWS_DIR = os.path.join(DATA_FOLDER, 'workflows') 23 | 24 | if not os.path.exists(DATA_FOLDER): 25 | os.makedirs(DATA_FOLDER) 26 | 27 | if not os.path.exists(ADMINS_FILE): 28 | with open(ADMINS_FILE, 'w') as f: 29 | admins_data = {'epic': 'comfynexus'} 30 | json.dump(admins_data, f, indent=4) 31 | 32 | for file_path in [PERMISSIONS_FILE, ADMINS_FILE, TOKENS_FILE, NAMES_FILE]: 33 | if not os.path.exists(file_path): 34 | with open(file_path, 'w') as f: 35 | json.dump({}, f) 36 | 37 | if not os.path.exists(WORKFLOWS_DIR): 38 | os.makedirs(WORKFLOWS_DIR) 39 | 40 | if not os.path.exists(SERVER_FILE): 41 | with open(SERVER_FILE, 'w') as f: 42 | default_config = { 43 | "name": "Default Nexus Server", 44 | "max_user": 15, 45 | "config": { 46 | "last_saved_user_id": "id" 47 | } 48 | } 49 | json.dump(default_config, f, indent=4) 50 | 51 | 52 | if hasattr(PromptServer, 'instance'): 53 | server = PromptServer.instance 54 | routes = web.RouteTableDef() 55 | socket_manager = WebSocketManager() 56 | 57 | async def broadcast_message(message, sender): 58 | for id, socket in socket_manager.sockets.items(): 59 | if id != sender: 60 | try: 61 | await socket.send_str(json.dumps(message)) 62 | except Exception as e: 63 | logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}") 64 | 65 | async def broadcast_message_except(message, excluder): 66 | for id, socket in socket_manager.sockets.items(): 67 | if id != excluder: 68 | try: 69 | await socket.send_str(json.dumps(message)) 70 | except Exception as e: 71 | logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}") 72 | 73 | async def broadcast_message_all(message): 74 | for id, socket in socket_manager.sockets.items(): 75 | try: 76 | await socket.send_str(json.dumps(message)) 77 | except Exception as e: 78 | logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}") 79 | 80 | async def send_message(message, sender, receiver): 81 | for id, socket in socket_manager.sockets.items(): 82 | if id == receiver and sender != receiver: 83 | try: 84 | await socket.send_str(json.dumps(message)) 85 | except Exception as e: 86 | logging.error(f"[ComfyUI-Nexus]: Error sending message to {id}: {e}") 87 | 88 | @routes.get("/nexus") 89 | async def websocket_handler(request): 90 | id = request.query.get("id") 91 | socket = web.WebSocketResponse() 92 | await socket.prepare(request) 93 | 94 | socket_manager.set(id, socket) 95 | logging.info(f"[ComfyUI-Nexus]: Socket {id} connected") 96 | 97 | try: 98 | async for msg in socket: 99 | if msg.type == web.WSMsgType.TEXT: 100 | 101 | data = json.loads(msg.data) 102 | event_name = data.get('name') 103 | event_data = data.get('data') 104 | 105 | sender = data.get("from") 106 | all = data.get('all') 107 | receiver = data.get('receiver') 108 | exclude = data.get('exclude') 109 | 110 | # logging.info(f"[ComfyUI-Nexus]: Received {sender} {all} {receiver} {exclude}") 111 | 112 | if event_name == "chat-join": 113 | user_id = data.get("from") 114 | user_name = event_data.get("name") 115 | 116 | with open(NAMES_FILE, 'r+') as f: 117 | names = json.load(f) 118 | names[user_id] = user_name 119 | f.seek(0) 120 | json.dump(names, f, indent=4) 121 | f.truncate() 122 | 123 | if event_name == "nick": 124 | user_id = data.get("from") 125 | user_name = event_data.get("new_name") 126 | 127 | with open(NAMES_FILE, 'r+') as f: 128 | names = json.load(f) 129 | names[user_id] = user_name 130 | f.seek(0) 131 | json.dump(names, f, indent=4) 132 | f.truncate() 133 | 134 | if all: 135 | await broadcast_message_all(data) 136 | elif receiver: 137 | await send_message(data, sender, receiver) 138 | elif exclude: 139 | await broadcast_message_except(data, exclude) 140 | else: 141 | await broadcast_message(data, sender) 142 | 143 | elif msg.type == web.WSMsgType.ERROR: 144 | logging.error(f"[ComfyUI-Nexus]: WebSocket error: {msg.exception()}") 145 | except Exception as e: 146 | logging.error(f"[ComfyUI-Nexus]: WebSocket error: {e}") 147 | finally: 148 | logging.info(f"[ComfyUI-Nexus]: Socket {id} disconnected") 149 | socket_manager.delete(id) 150 | 151 | return socket 152 | 153 | @routes.post("/nexus/workflows") 154 | async def get_user_specific_workflows(request): 155 | 156 | user_id = (await request.json()).get('user_id') 157 | if not user_id: 158 | return web.json_response({'error': 'User ID required'}, status=400) 159 | 160 | with open(PERMISSIONS_FILE, 'r') as f: 161 | permissions = json.load(f) 162 | 163 | user_permissions = permissions.get(user_id, {}) 164 | if not user_permissions.get('editor', False): 165 | return web.json_response({'error': 'Forbidden'}, status=403) 166 | 167 | user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id) 168 | if not os.path.exists(user_workflow_dir): 169 | return web.json_response({'error': 'User not found'}, status=404) 170 | 171 | workflows = {} 172 | for file_name in sorted([f for f in os.listdir(user_workflow_dir) if f.isdigit()], key=int): 173 | file_path = os.path.join(user_workflow_dir, file_name) 174 | with open(file_path, 'r') as f: 175 | workflows[file_name] = json.load(f) 176 | 177 | for file_name in sorted([f for f in os.listdir(user_workflow_dir) if not f.isdigit()]): 178 | file_path = os.path.join(user_workflow_dir, file_name) 179 | with open(file_path, 'r') as f: 180 | workflows[file_name] = json.load(f) 181 | 182 | return web.json_response(workflows) 183 | 184 | @routes.post("/nexus/workflows/meta") 185 | async def get_user_specific_workflows_meta(request): 186 | 187 | user_id = (await request.json()).get('user_id') 188 | if not user_id: 189 | return web.json_response({'error': 'User ID required'}, status=400) 190 | with open(PERMISSIONS_FILE, 'r') as f: 191 | permissions = json.load(f) 192 | 193 | user_permissions = permissions.get(user_id, {}) 194 | if not user_permissions.get('editor', False): 195 | return web.json_response({'error': 'Forbidden'}, status=403) 196 | 197 | user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id) 198 | if not os.path.exists(user_workflow_dir): 199 | return web.json_response({'error': 'User not found'}, status=404) 200 | 201 | meta_data = [] 202 | for file_name in sorted([f for f in os.listdir(user_workflow_dir) if f.isdigit()], key=int): 203 | file_path = os.path.join(user_workflow_dir, file_name) 204 | last_modified_time = os.path.getmtime(file_path) 205 | last_modified_readable = datetime.datetime.fromtimestamp(last_modified_time).strftime('%Y-%m-%d %H:%M:%S') 206 | 207 | meta_data.append({ 208 | 'file_name': file_name, 209 | 'last_saved': last_modified_readable 210 | }) 211 | 212 | for file_name in sorted([f for f in os.listdir(user_workflow_dir) if not f.isdigit()]): 213 | file_path = os.path.join(user_workflow_dir, file_name) 214 | last_modified_time = os.path.getmtime(file_path) 215 | last_modified_readable = datetime.datetime.fromtimestamp(last_modified_time).strftime('%Y-%m-%d %H:%M:%S') 216 | 217 | meta_data.append({ 218 | 'file_name': file_name, 219 | 'last_saved': last_modified_readable 220 | }) 221 | 222 | return web.json_response(meta_data) 223 | 224 | @routes.post("/nexus/workflows/{id}") 225 | async def get_user_specific_workflow(request): 226 | user_id = (await request.json()).get('user_id') 227 | workflow_id = request.match_info.get('id') 228 | 229 | if not user_id: 230 | return web.json_response({'error': 'User ID required'}, status=400) 231 | if not workflow_id: 232 | return web.json_response({'error': 'Workflow ID required'}, status=400) 233 | 234 | with open(PERMISSIONS_FILE, 'r') as f: 235 | permissions = json.load(f) 236 | 237 | user_permissions = permissions.get(user_id, {}) 238 | if not user_permissions.get('editor', False): 239 | return web.json_response({'error': 'Forbidden'}, status=403) 240 | 241 | user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id) 242 | if not os.path.exists(user_workflow_dir): 243 | return web.json_response({'error': 'User not found'}, status=404) 244 | 245 | workflow_file_path = os.path.join(user_workflow_dir, workflow_id) 246 | if not os.path.exists(workflow_file_path): 247 | return web.json_response({'error': 'Workflow not found'}, status=404) 248 | 249 | with open(workflow_file_path, 'r') as f: 250 | workflow = json.load(f) 251 | 252 | return web.json_response(workflow) 253 | 254 | @routes.post("/nexus/workflow") 255 | async def post_workflow(request): 256 | user_id = (await request.json()).get('user_id') 257 | if not user_id: 258 | return web.json_response({'error': 'User ID required'}, status=400) 259 | 260 | with open(PERMISSIONS_FILE, 'r') as f: 261 | permissions = json.load(f) 262 | 263 | user_permissions = permissions.get(user_id, {}) 264 | if not user_permissions.get('editor', False): 265 | return web.json_response({'error': 'Forbidden'}, status=403) 266 | 267 | new_data = await request.json() 268 | 269 | user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id) 270 | if not os.path.exists(user_workflow_dir): 271 | os.makedirs(user_workflow_dir) 272 | 273 | existing_files = sorted([f for f in os.listdir(user_workflow_dir) if f.isdigit()], key=int) 274 | 275 | if len(existing_files) == 5: 276 | os.remove(os.path.join(user_workflow_dir, existing_files[0])) 277 | existing_files.pop(0) 278 | 279 | for i, file_name in enumerate(existing_files): 280 | new_name = str(i + 1) 281 | shutil.move(os.path.join(user_workflow_dir, file_name), os.path.join(user_workflow_dir, new_name)) 282 | 283 | new_file_path = os.path.join(user_workflow_dir, '5') 284 | with open(new_file_path, 'w') as f: 285 | json.dump(new_data, f, indent=4) 286 | 287 | # Update the server.json file with the last saved user_id 288 | with open(SERVER_FILE, 'r+') as f: 289 | server_config = json.load(f) 290 | server_config['config']['last_saved_user_id'] = user_id 291 | f.seek(0) 292 | json.dump(server_config, f, indent=4) 293 | f.truncate() 294 | 295 | return web.json_response({'status': 'success'}, status=200) 296 | 297 | @routes.post("/nexus/workflow/backup") 298 | async def post_workflow(request): 299 | 300 | user_id = (await request.json()).get('user_id') 301 | if not user_id: 302 | return web.json_response({'error': 'User ID required'}, status=400) 303 | 304 | with open(PERMISSIONS_FILE, 'r') as f: 305 | permissions = json.load(f) 306 | 307 | user_permissions = permissions.get(user_id, {}) 308 | if not user_permissions.get('editor', False): 309 | return web.json_response({'error': 'Forbidden'}, status=403) 310 | 311 | new_data = await request.json() 312 | 313 | user_workflow_dir = os.path.join(WORKFLOWS_DIR, user_id) 314 | if not os.path.exists(user_workflow_dir): 315 | os.makedirs(user_workflow_dir) 316 | 317 | new_file_path = os.path.join(user_workflow_dir, f'lt_{round(time.time() * 1000)}') 318 | with open(new_file_path, 'w') as f: 319 | json.dump(new_data, f, indent=4) 320 | 321 | return web.json_response({'status': 'success'}, status=200) 322 | 323 | @routes.get("/nexus/workflow") 324 | async def get_latest_workflow(request): 325 | with open(SERVER_FILE, 'r') as f: 326 | server_config = json.load(f) 327 | 328 | last_saved_user_id = server_config['config'].get('last_saved_user_id') 329 | if not last_saved_user_id: 330 | return web.json_response({'error': 'No workflow saved'}, status=404) 331 | 332 | user_workflow_dir = os.path.join(WORKFLOWS_DIR, last_saved_user_id) 333 | if not os.path.exists(user_workflow_dir): 334 | return web.json_response({'error': 'User not found'}, status=404) 335 | 336 | last_file_path = os.path.join(user_workflow_dir, '5') 337 | if not os.path.exists(last_file_path): 338 | return web.json_response({'error': 'Workflow not found'}, status=404) 339 | 340 | with open(last_file_path, 'r') as f: 341 | workflow_data = json.load(f) 342 | 343 | return web.json_response(workflow_data) 344 | 345 | @routes.get("/nexus/permission/{id}") 346 | async def get_permission_by_id(request): 347 | user_id = request.match_info['id'] 348 | with open(PERMISSIONS_FILE, 'r') as f: 349 | data = json.load(f) 350 | user_permission = data.get(user_id) 351 | if user_permission: 352 | return web.json_response(user_permission) 353 | else: 354 | with open(PERMISSIONS_FILE, 'r+') as f: 355 | permissions = json.load(f) 356 | permissions[user_id] = {'queue': False, 'editor': False, 'admin': False} 357 | f.seek(0) 358 | json.dump(permissions, f) 359 | f.truncate() 360 | user_permission = permissions[user_id] 361 | return web.json_response(user_permission) 362 | 363 | @routes.post("/nexus/permission/{id}") 364 | async def post_permission_by_id(request): 365 | user_id = request.match_info['id'] 366 | data = await request.json() 367 | admin_id = data.get('admin_id') 368 | if not admin_id: 369 | return web.json_response({'error': 'Admin ID required'}, status=400) 370 | 371 | token = request.headers.get('Authorization') 372 | if not token: 373 | return web.json_response({'error': 'Authorization token required'}, status=400) 374 | 375 | with open(TOKENS_FILE, 'r') as f: 376 | tokens = json.load(f) 377 | 378 | if tokens.get(token) != admin_id: 379 | return web.json_response({'error': 'Invalid or expired token'}, status=403) 380 | 381 | new_permission = data.get('data') 382 | if not new_permission: 383 | return web.json_response({'error': 'Permission data required'}, status=400) 384 | 385 | with open(PERMISSIONS_FILE, 'r+') as f: 386 | permissions = json.load(f) 387 | 388 | if not permissions.get(admin_id, {}).get('admin', False): 389 | return web.json_response({'error': 'Forbidden'}, status=403) 390 | 391 | # Merge the new permissions with the existing ones 392 | existing_permissions = permissions.get(user_id, {}) 393 | existing_permissions.update(new_permission) 394 | 395 | # Save the updated permissions 396 | permissions[user_id] = existing_permissions 397 | f.seek(0) 398 | json.dump(permissions, f, indent=4) 399 | f.truncate() 400 | 401 | return web.json_response({'status': 'success'}, status=200) 402 | 403 | @routes.post("/nexus/login") 404 | async def post_login(request): 405 | 406 | credentials = await request.json() 407 | user_id = credentials.get('uuid') 408 | account = credentials.get('account') 409 | password = credentials.get('password') 410 | 411 | with open(ADMINS_FILE, 'r') as f: 412 | admins = json.load(f) 413 | 414 | if admins.get(account) == password: 415 | token = str(uuid.uuid4()) 416 | 417 | with open(TOKENS_FILE, 'r+') as f: 418 | tokens = json.load(f) 419 | tokens[token] = user_id 420 | f.seek(0) 421 | json.dump(tokens, f, indent=4) 422 | f.truncate() 423 | 424 | with open(PERMISSIONS_FILE, 'r+') as f: 425 | permissions = json.load(f) 426 | permissions[user_id] = {'editor': True, 'admin': True} 427 | f.seek(0) 428 | json.dump(permissions, f, indent=4) 429 | f.truncate() 430 | 431 | return web.json_response({'token': token}, status=200) 432 | 433 | else: 434 | return web.json_response({'error': 'Invalid credentials'}, status=401) 435 | 436 | @routes.post("/nexus/verify") 437 | async def post_verify(request): 438 | credentials = await request.json() 439 | token = credentials.get('token') 440 | 441 | with open(TOKENS_FILE, 'r') as f: 442 | tokens = json.load(f) 443 | 444 | user_id = tokens.get(token) 445 | if user_id: 446 | with open(PERMISSIONS_FILE, 'r') as f: 447 | permissions = json.load(f) 448 | 449 | user_permissions = permissions.get(user_id, {}) 450 | return web.json_response({'user_id': user_id, 'permissions': user_permissions}, status=200) 451 | else: 452 | return web.json_response({'error': 'Invalid token'}, status=401) 453 | 454 | @routes.get("/nexus/name/{id}") 455 | async def get_name_by_id(request): 456 | user_id = request.match_info['id'] 457 | with open(NAMES_FILE, 'r') as f: 458 | names = json.load(f) 459 | user_name = names.get(user_id) 460 | if user_name: 461 | return web.json_response({'name': user_name}) 462 | else: 463 | return web.json_response({'error': 'User not found'}, status=404) 464 | 465 | server.app.add_routes(routes) 466 | 467 | NODE_CLASS_MAPPINGS = {} 468 | NODE_DISPLAY_NAME_MAPPINGS = {} 469 | WEB_DIRECTORY = "./web" 470 | -------------------------------------------------------------------------------- /web/lib/nexus.litegraph.js: -------------------------------------------------------------------------------- 1 | LiteGraph.use_uuids = true 2 | LiteGraph.uuidv4 = function() { return Date.now() } 3 | 4 | LGraph.prototype.getGroupByUUID = function (uuid) { 5 | let groups = this._groups 6 | let group = null 7 | for (let index = 0; index < groups.length; index++) { 8 | let tgroup = groups[index]; 9 | if (tgroup.uuid && tgroup.uuid == uuid) { 10 | group = tgroup 11 | break 12 | } 13 | } 14 | return group 15 | } 16 | 17 | LGraph.prototype.getGroupIndexByUUID = function (uuid) { 18 | let groups = this._groups 19 | let at = -1 20 | for (let index = 0; index < groups.length; index++) { 21 | let tgroup = groups[index]; 22 | if (tgroup.uuid && tgroup.uuid == uuid) { 23 | at = index 24 | break 25 | } 26 | } 27 | return at 28 | } 29 | 30 | 31 | LGraph.prototype.add = function (node, skip_compute_order) { 32 | 33 | if (!node) { 34 | return; 35 | } 36 | 37 | //groups 38 | if (node.constructor === LGraphGroup) { 39 | this._groups.push(node); 40 | this.setDirtyCanvas(true); 41 | this.change(); 42 | if(!node.uuid) { 43 | node.uuid = LiteGraph.uuidv4() 44 | } 45 | node.graph = this; 46 | this._version++; 47 | if (this.onGroupAdded) 48 | this.onGroupAdded(node) 49 | return; 50 | } 51 | 52 | //nodes 53 | if (node.id != -1 && this._nodes_by_id[node.id] != null) { 54 | console.warn( 55 | "Nexus LiteGraph: there is already a node with this ID, will reuse it" 56 | ); 57 | if (LiteGraph.use_uuids) { 58 | node.id = LiteGraph.uuidv4() 59 | } 60 | else { 61 | node.id = ++this.last_node_id; 62 | } 63 | } 64 | 65 | if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) { 66 | throw "LiteGraph: max number of nodes in a graph reached"; 67 | } 68 | 69 | //give him an id 70 | if (LiteGraph.use_uuids) { 71 | if (node.id == null || node.id == -1) 72 | node.id = LiteGraph.uuidv4(); 73 | 74 | let uuid = localStorage.getItem('nexus-socket-uuid'); 75 | if (uuid) { 76 | node.properties.owner = uuid 77 | } 78 | this.last_node_id = node.id; 79 | } 80 | else { 81 | if (node.id == null || node.id == -1) { 82 | node.id = ++this.last_node_id; 83 | } else if (this.last_node_id < node.id) { 84 | this.last_node_id = node.id; 85 | } 86 | } 87 | 88 | node.graph = this; 89 | this._version++; 90 | 91 | this._nodes.push(node); 92 | this._nodes_by_id[node.id] = node; 93 | 94 | if (node.onAdded) { 95 | node.onAdded(this); 96 | } 97 | 98 | if (this.config.align_to_grid) { 99 | node.alignToGrid(); 100 | } 101 | 102 | if (!skip_compute_order) { 103 | this.updateExecutionOrder(); 104 | } 105 | 106 | if (this.onNodeAdded) { 107 | this.onNodeAdded(node); 108 | } 109 | 110 | this.setDirtyCanvas(true); 111 | this.change(); 112 | 113 | return node; //to chain actions 114 | }; 115 | 116 | LGraph.prototype.addNexus = function (node, skip_compute_order) { 117 | 118 | if (!node) { 119 | return; 120 | } 121 | 122 | //groups 123 | if (node.constructor === LGraphGroup) { 124 | this._groups.push(node); 125 | this.setDirtyCanvas(true); 126 | this.change(); 127 | // node.id = LiteGraph.uuidv4(); 128 | node.graph = this; 129 | this._version++; 130 | return; 131 | } 132 | 133 | // //nodes 134 | // if (node.id != -1 && this._nodes_by_id[node.id] != null) { 135 | // console.warn( 136 | // "Nexus LiteGraph: there is already a node with this ID, will reuse it" 137 | // ); 138 | // if (LiteGraph.use_uuids) { 139 | // node.id = LiteGraph.uuidv4(); 140 | // } 141 | // else { 142 | // node.id = ++this.last_node_id; 143 | // } 144 | // } 145 | 146 | if (this._nodes.length >= LiteGraph.MAX_NUMBER_OF_NODES) { 147 | throw "LiteGraph: max number of nodes in a graph reached"; 148 | } 149 | 150 | //give him an id 151 | // if (LiteGraph.use_uuids) { 152 | // if (node.id == null || node.id == -1) 153 | // let uuid = localStorage.getItem('nexus-socket-uuid'); 154 | // if (uuid) { 155 | // node.properties.owner = uuid 156 | // } 157 | // node.id = LiteGraph.uuidv4(); 158 | // } 159 | // else { 160 | // if (node.id == null || node.id == -1) { 161 | // node.id = ++this.last_node_id; 162 | // } else if (this.last_node_id < node.id) { 163 | this.last_node_id = node.id; 164 | // } 165 | // } 166 | 167 | node.graph = this; 168 | this._version++; 169 | 170 | this._nodes.push(node); 171 | this._nodes_by_id[node.id] = node; 172 | 173 | if (this.config.align_to_grid) { 174 | node.alignToGrid(); 175 | } 176 | 177 | if (!skip_compute_order) { 178 | this.updateExecutionOrder(); 179 | } 180 | 181 | if (this.onNodeAddedNexus) { 182 | this.onNodeAddedNexus(node); 183 | } 184 | 185 | this.setDirtyCanvas(true); 186 | this.change(); 187 | 188 | return node; //to chain actions 189 | }; 190 | 191 | LGraph.prototype.remove = function (node) { 192 | if (node.constructor === LiteGraph.LGraphGroup) { 193 | var index = this._groups.indexOf(node); 194 | if (index != -1) { 195 | this._groups.splice(index, 1); 196 | } 197 | node.graph = null; 198 | this._version++; 199 | this.setDirtyCanvas(true, true); 200 | this.change(); 201 | if (this.onGroupRemoved) 202 | this.onGroupRemoved(node) 203 | return; 204 | } 205 | 206 | if (this._nodes_by_id[node.id] == null) { 207 | return; 208 | } //not found 209 | 210 | if (node.ignore_remove) { 211 | return; 212 | } //cannot be removed 213 | 214 | this.beforeChange(); //sure? - almost sure is wrong 215 | 216 | //disconnect inputs 217 | if (node.inputs) { 218 | for (var i = 0; i < node.inputs.length; i++) { 219 | var slot = node.inputs[i]; 220 | if (slot.link != null) { 221 | node.disconnectInput(i); 222 | } 223 | } 224 | } 225 | 226 | //disconnect outputs 227 | if (node.outputs) { 228 | for (var i = 0; i < node.outputs.length; i++) { 229 | var slot = node.outputs[i]; 230 | if (slot.links != null && slot.links.length) { 231 | node.disconnectOutput(i); 232 | } 233 | } 234 | } 235 | 236 | //node.id = -1; //why? 237 | 238 | //callback 239 | if (node.onRemoved) { 240 | node.onRemoved(); 241 | } 242 | 243 | node.graph = null; 244 | this._version++; 245 | 246 | //remove from canvas render 247 | if (this.list_of_graphcanvas) { 248 | for (var i = 0; i < this.list_of_graphcanvas.length; ++i) { 249 | var canvas = this.list_of_graphcanvas[i]; 250 | if (canvas.selected_nodes[node.id]) { 251 | delete canvas.selected_nodes[node.id]; 252 | } 253 | if (canvas.node_dragged == node) { 254 | canvas.node_dragged = null; 255 | } 256 | } 257 | } 258 | 259 | //remove from containers 260 | var pos = this._nodes.indexOf(node); 261 | if (pos != -1) { 262 | this._nodes.splice(pos, 1); 263 | } 264 | delete this._nodes_by_id[node.id]; 265 | 266 | if (this.onNodeRemoved) { 267 | this.onNodeRemoved(node); 268 | } 269 | 270 | //close panels 271 | this.sendActionToCanvas("checkPanels"); 272 | 273 | this.setDirtyCanvas(true, true); 274 | this.afterChange(); //sure? - almost sure is wrong 275 | this.change(); 276 | 277 | this.updateExecutionOrder(); 278 | }; 279 | 280 | LGraph.prototype.removeNexus = function (node) { 281 | 282 | if (node.constructor === LiteGraph.LGraphGroup) { 283 | var index = this._groups.indexOf(node); 284 | if (index != -1) { 285 | this._groups.splice(index, 1); 286 | } 287 | node.graph = null; 288 | this._version++; 289 | this.setDirtyCanvas(true, true); 290 | this.change(); 291 | return; 292 | } 293 | 294 | if (this._nodes_by_id[node.id] == null) { 295 | return; 296 | } //not found 297 | 298 | if (node.ignore_remove) { 299 | return; 300 | } //cannot be removed 301 | 302 | this.beforeChange(); //sure? - almost sure is wrong 303 | 304 | //disconnect inputs 305 | if (node.inputs) { 306 | for (var i = 0; i < node.inputs.length; i++) { 307 | var slot = node.inputs[i]; 308 | if (slot.link != null) { 309 | node.disconnectInput(i); 310 | } 311 | } 312 | } 313 | 314 | //disconnect outputs 315 | if (node.outputs) { 316 | for (var i = 0; i < node.outputs.length; i++) { 317 | var slot = node.outputs[i]; 318 | if (slot.links != null && slot.links.length) { 319 | node.disconnectOutput(i); 320 | } 321 | } 322 | } 323 | 324 | //node.id = -1; //why? 325 | 326 | //callback 327 | if (node.onRemoved) { 328 | node.onRemoved(); 329 | } 330 | 331 | node.graph = null; 332 | this._version++; 333 | 334 | //remove from canvas render 335 | if (this.list_of_graphcanvas) { 336 | for (var i = 0; i < this.list_of_graphcanvas.length; ++i) { 337 | var canvas = this.list_of_graphcanvas[i]; 338 | if (canvas.selected_nodes[node.id]) { 339 | delete canvas.selected_nodes[node.id]; 340 | } 341 | if (canvas.node_dragged == node) { 342 | canvas.node_dragged = null; 343 | } 344 | } 345 | } 346 | 347 | //remove from containers 348 | var pos = this._nodes.indexOf(node); 349 | if (pos != -1) { 350 | this._nodes.splice(pos, 1); 351 | } 352 | delete this._nodes_by_id[node.id]; 353 | 354 | //close panels 355 | this.sendActionToCanvas("checkPanels"); 356 | 357 | this.setDirtyCanvas(true, true); 358 | this.afterChange(); //sure? - almost sure is wrong 359 | this.change(); 360 | 361 | this.updateExecutionOrder(); 362 | }; 363 | 364 | LGraphNode.prototype.configureNexus = function (info) { 365 | if (this.graph) { 366 | this.graph._version++; 367 | } 368 | for (var j in info) { 369 | if (j == "properties") { 370 | //i don't want to clone properties, I want to reuse the old container 371 | for (var k in info.properties) { 372 | this.properties[k] = info.properties[k]; 373 | } 374 | continue; 375 | } 376 | 377 | if (info[j] == null) { 378 | continue; 379 | } else if (typeof info[j] == "object") { 380 | //object 381 | if (this[j] && this[j].configure) { 382 | this[j].configure(info[j]); 383 | } else { 384 | this[j] = LiteGraph.cloneObject(info[j], this[j]); 385 | } 386 | } //value 387 | else { 388 | this[j] = info[j]; 389 | } 390 | } 391 | 392 | if (!info.title) { 393 | this.title = this.constructor.title; 394 | } 395 | 396 | if (this.inputs) { 397 | for (var i = 0; i < this.inputs.length; ++i) { 398 | var input = this.inputs[i]; 399 | if (this.onInputAdded) 400 | this.onInputAdded(input); 401 | } 402 | } 403 | 404 | if (this.outputs) { 405 | for (var i = 0; i < this.outputs.length; ++i) { 406 | var output = this.outputs[i]; 407 | if (!output.links) { 408 | continue; 409 | } 410 | if (this.onOutputAdded) 411 | this.onOutputAdded(output); 412 | } 413 | } 414 | 415 | if (this.widgets) { 416 | for (var i = 0; i < this.widgets.length; ++i) { 417 | var w = this.widgets[i]; 418 | if (!w) 419 | continue; 420 | if (w.options && w.options.property && (this.properties[w.options.property] != undefined)) 421 | w.value = JSON.parse(JSON.stringify(this.properties[w.options.property])); 422 | } 423 | if (info.widgets_values) { 424 | for (var i = 0; i < info.widgets_values.length; ++i) { 425 | if (this.widgets[i]) { 426 | this.widgets[i].value = info.widgets_values[i]; 427 | } 428 | } 429 | } 430 | } 431 | 432 | if (this.onConfigure) { 433 | this.onConfigure(info); 434 | } 435 | }; 436 | 437 | LGraphCanvas.prototype.selectNodesNexus = function (nodes, add_to_current_selection) { 438 | if (!add_to_current_selection) { 439 | this.deselectAllNodes(); 440 | } 441 | 442 | nodes = nodes || this.graph._nodes; 443 | if (typeof nodes == "string") nodes = [nodes]; 444 | for (var i in nodes) { 445 | var node = nodes[i]; 446 | if (node.is_selected) { 447 | this.deselectNode(node); 448 | continue; 449 | } 450 | 451 | if (!node.is_selected && node.onSelected) { 452 | node.onSelected(); 453 | } 454 | node.is_selected = true; 455 | this.selected_nodes[node.id] = node; 456 | 457 | if (node.inputs) { 458 | for (var j = 0; j < node.inputs.length; ++j) { 459 | this.highlighted_links[node.inputs[j].link] = true; 460 | } 461 | } 462 | if (node.outputs) { 463 | for (var j = 0; j < node.outputs.length; ++j) { 464 | var out = node.outputs[j]; 465 | if (out.links) { 466 | for (var k = 0; k < out.links.length; ++k) { 467 | this.highlighted_links[out.links[k]] = true; 468 | } 469 | } 470 | } 471 | } 472 | } 473 | 474 | this.setDirty(true); 475 | }; 476 | 477 | LGraphCanvas.prototype.deselectNodeNexus = function(node) { 478 | 479 | if (!node.is_selected) { 480 | return; 481 | } 482 | 483 | if (node.onDeselected) { 484 | node.onDeselected(); 485 | } 486 | 487 | node.is_selected = false; 488 | 489 | //remove highlighted 490 | if (node.inputs) { 491 | for (var i = 0; i < node.inputs.length; ++i) { 492 | delete this.highlighted_links[node.inputs[i].link]; 493 | } 494 | } 495 | if (node.outputs) { 496 | for (var i = 0; i < node.outputs.length; ++i) { 497 | var out = node.outputs[i]; 498 | if (out.links) { 499 | for (var j = 0; j < out.links.length; ++j) { 500 | delete this.highlighted_links[out.links[j]]; 501 | } 502 | } 503 | } 504 | } 505 | }; 506 | 507 | LGraphGroup.prototype.move = function (deltax, deltay, ignore_nodes) { 508 | this._pos[0] += deltax; 509 | this._pos[1] += deltay; 510 | if (ignore_nodes) { 511 | return; 512 | } 513 | for (var i = 0; i < this._nodes.length; ++i) { 514 | var node = this._nodes[i]; 515 | node.pos[0] += deltax; 516 | node.pos[1] += deltay; 517 | if (this.onGroupNodeMoved) 518 | this.onGroupNodeMoved(node) 519 | } 520 | if (this.onGroupMoved) 521 | this.onGroupMoved(this) 522 | }; 523 | 524 | LGraphGroup.prototype.configure = function (o) { 525 | this.title = o.title; 526 | this.owner = o.owner; 527 | this.uuid = o.uuid; 528 | this._bounding.set(o.bounding); 529 | this.color = o.color; 530 | if (o.font_size) { 531 | this.font_size = o.font_size; 532 | } 533 | }; 534 | 535 | LGraphGroup.prototype.serialize = function () { 536 | var b = this._bounding; 537 | return { 538 | title: this.title, 539 | bounding: [ 540 | Math.round(b[0]), 541 | Math.round(b[1]), 542 | Math.round(b[2]), 543 | Math.round(b[3]) 544 | ], 545 | uuid: this.uuid, 546 | color: this.color, 547 | font_size: this.font_size 548 | }; 549 | }; 550 | 551 | LGraph.prototype.addNode = function (nodeData) { 552 | let node = LiteGraph.createNode(nodeData.type, nodeData.title); 553 | if (!node) { 554 | console.warn("Node type not found: " + nodeData.type); 555 | return false; 556 | } 557 | node.id = nodeData.id; 558 | node.configureNexus(nodeData); 559 | this.addNexus(node); 560 | return true; 561 | }; 562 | 563 | LGraph.prototype.removeNode = function (data) { 564 | let node = this.getNodeById(data.id); 565 | if (!node) { 566 | console.warn("Node ID not found: " + data); 567 | return false; 568 | } 569 | this.removeNexus(node); 570 | return true; 571 | }; 572 | 573 | LGraph.prototype.addLink = function (linkData) { 574 | let link = new LiteGraph.LLink(); 575 | link.configure(linkData); 576 | this.links[link.id] = link; 577 | return; 578 | }; 579 | 580 | LGraph.prototype.addGroup = function (data) { 581 | var group = new LiteGraph.LGraphGroup(); 582 | group.configure(data); 583 | this._groups.push(group); 584 | this.setDirtyCanvas(true); 585 | this.change(); 586 | group.graph = this; 587 | this._version++; 588 | if (this.onGroupAdded) 589 | this.onGroupAdded(group) 590 | return; 591 | }; 592 | 593 | LGraph.prototype.removeGroup = function (group) { 594 | let existingGroupIndex = this.getGroupIndexByUUID(group.uuid); 595 | if (existingGroupIndex != -1) { 596 | this._groups.splice(existingGroupIndex, 1); 597 | } 598 | group.graph = null; 599 | this._version++; 600 | this.setDirtyCanvas(true, true); 601 | this.change(); 602 | return; 603 | }; 604 | 605 | LGraph.prototype.updateNode = function (nodeData, type = "all") { 606 | let existingNode = this.getNodeById(nodeData.id); 607 | if (existingNode) { 608 | if (type == "all") { 609 | existingNode.configureNexus(nodeData); 610 | } else if (type == "widgets") { 611 | if (existingNode.widgets) { 612 | for (var i = 0; i < existingNode.widgets.length; ++i) { 613 | var w = existingNode.widgets[i]; 614 | if (!w) 615 | continue; 616 | if (w.options && w.options.property && (existingNode.properties[w.options.property] != undefined)) 617 | w.value = JSON.parse(JSON.stringify(existingNode.properties[w.options.property])); 618 | } 619 | if (nodeData.widgets_values) { 620 | for (var i = 0; i < nodeData.widgets_values.length; ++i) { 621 | if (existingNode.widgets[i]) { 622 | existingNode.widgets[i].value = nodeData.widgets_values[i]; 623 | } 624 | } 625 | } 626 | } 627 | } 628 | } else { 629 | this.addNode(nodeData); 630 | } 631 | }; 632 | 633 | LGraph.prototype.updateLink = function (linkData) { 634 | 635 | if (linkData) { 636 | 637 | let id 638 | 639 | if (linkData instanceof Object) { 640 | id = linkData.id 641 | } else { 642 | id = linkData[0] 643 | } 644 | 645 | let existingLink = this.links[id]; 646 | if (existingLink) { 647 | existingLink.configure(linkData); 648 | } else { 649 | this.addLink(linkData); 650 | } 651 | 652 | } 653 | 654 | }; 655 | 656 | LGraph.prototype.updateLinkManual = function (change, linkData, node, graph) { 657 | if (change == "add") { 658 | this.updateLink(linkData) 659 | } else if (change == "remove") { 660 | let existingLink = this.links[linkData[0]]; 661 | if (existingLink) { 662 | this.removeLink(linkData[0]) 663 | delete this.links[linkData[0]] 664 | } 665 | } 666 | if (node) { 667 | this.updateNode(node) 668 | } 669 | graph.updateExecutionOrder(); 670 | graph._version++; 671 | graph.setDirtyCanvas(true, true); 672 | }; 673 | 674 | LGraph.prototype.updateGroup = function (groupData) { 675 | let existingGroup = this.getGroupByUUID(groupData.uuid); 676 | if (existingGroup) { 677 | existingGroup.configure(groupData); 678 | } else { 679 | this.addGroup(groupData); 680 | } 681 | }; 682 | 683 | LGraph.prototype.updateNodes = function (newNodes) { 684 | newNodes.forEach(nodeData => this.updateNode(nodeData)); 685 | }; 686 | 687 | LGraph.prototype.updateLinks = function (newLinks) { 688 | newLinks.forEach(linkData => this.updateLink(linkData)); 689 | }; 690 | 691 | LGraph.prototype.updateGroups = function (newGroups) { 692 | newGroups.forEach(groupData => this.updateGroup(groupData)); 693 | }; -------------------------------------------------------------------------------- /web/lib/nexus.cmd.js: -------------------------------------------------------------------------------- 1 | import { nexusLogin, nexusVerify, getPermissionById, postPermissionById, getUserSpecificWorkflowsMeta, getUserSpecificWorkflow } from "./nexus.api.js" 2 | import { addChatWindowMessage, inputMain, chatWindow } from "./nexus.chat.js" 3 | import { UserPanel } from "./nexus.panel.js" 4 | import { WorkflowPanel } from "./nexus.workflow.panel.js" 5 | 6 | export class NexusCommands { 7 | 8 | constructor(app, api, nexusSocket, color, mouseManger, workflowManager, promptControl) { 9 | 10 | this.color = color 11 | 12 | this.colors = { 13 | "join": "#ff6e4d", 14 | "info": "#fba510", 15 | "personal": "#fba510", 16 | "name": "#00fa00", 17 | "none": "#ffffff" 18 | } 19 | 20 | this.inputMain = inputMain 21 | this.nexusSocket = nexusSocket 22 | 23 | this.app = app 24 | this.api = api 25 | 26 | this.workflowManager = workflowManager 27 | this.promptControl = promptControl 28 | this.mouseManger = mouseManger 29 | 30 | this.nexusSocket.onConnected.push(this.handleConnect.bind(this)) 31 | this.nexusSocket.onMessageReceive.push(this.handleMessageReceive.bind(this)) 32 | 33 | this.inputMain.addEventListener('message', this.handleMessage.bind(this)) 34 | 35 | document.addEventListener('keydown', this.handleChatWindow.bind(this)); 36 | 37 | this.disableComfyThings = this.disableComfyThings.bind(this); 38 | this.enableComfyThings = this.enableComfyThings.bind(this); 39 | this.disableGraphSelectionWithExtremePrejudice = this.disableGraphSelectionWithExtremePrejudice.bind(this); 40 | 41 | this.comfyThings = [ 42 | '#extraOptions', 43 | '#queue-button', 44 | '#comfy-save-button', 45 | '#comfy-file-input', 46 | '#comfy-clear-button', 47 | '#comfy-load-default-button', 48 | '.queue-tab-button.side-bar-button', 49 | '.queue-tab-button.side-bar-button', 50 | '#comfy-refresh-button', 51 | '#queue-front-button', 52 | '#comfy-view-queue-button', 53 | '#comfy-view-history-button' 54 | ]; 55 | 56 | chatWindow.childrens[3].softUpdate('#nexus-chat-window', 'pointer-events', 'none') 57 | chatWindow.show() 58 | 59 | setInterval((chatWindow) => { 60 | chatWindow.childrens[0].childrens[1].el.textContent = Object.keys(this.nexusSocket.userManager.users).length 61 | }, 1000, chatWindow); 62 | 63 | this.up = new UserPanel() 64 | this.up.onCommandReceived = this.onPanelCommandReceived.bind(this) 65 | 66 | this.wp = new WorkflowPanel() 67 | this.wp.onCommandReceived = this.onWFPanelCommandReceived.bind(this) 68 | 69 | this.spectating = null 70 | this.lastkey = null 71 | 72 | } 73 | 74 | timeAgo(timeString) { 75 | const now = new Date(); // current time 76 | const time = new Date(timeString); // input time 77 | 78 | const diff = Math.floor((now - time) / 1000); // difference in seconds 79 | 80 | const minutes = Math.floor(diff / 60); 81 | const hours = Math.floor(minutes / 60); 82 | const days = Math.floor(hours / 24); 83 | 84 | if (minutes < 1) return "just now"; 85 | if (minutes === 1) return "1 minute ago"; 86 | if (minutes < 60) return `${minutes} minutes ago`; 87 | if (hours === 1) return "1 hour ago"; 88 | if (hours < 24) return `${hours} hours ago`; 89 | if (days === 1) return "1 day ago"; 90 | 91 | return `${days} days ago`; 92 | } 93 | 94 | async verifyLogin() { 95 | 96 | this.disableComfyThings() 97 | 98 | let token = localStorage.getItem('nexus-socket-token'); 99 | if (token) { 100 | let user = await nexusVerify(token) 101 | if (user && this.nexusSocket.uuid == user.user_id) { 102 | this.enableComfyThings() 103 | this.promptControl.handleControl(true) 104 | this.promptControl.handleEditor(true) 105 | this.workflowManager.handleWorkflowEvents(true) 106 | this.nexusSocket.admin = true 107 | addChatWindowMessage("", "You are now an admin!", this.color.info, this.colors.personal, true) 108 | this.workflowManager.saveManager(60) 109 | } 110 | else { 111 | this.promptControl.handleControl(false) 112 | this.promptControl.handleEditor(false) 113 | this.workflowManager.handleWorkflowEvents(false) 114 | this.workflowManager.removeSaveManager() 115 | addChatWindowMessage("", "You are a viewer!", this.color.info, this.colors.info, true) 116 | } 117 | } 118 | else { 119 | 120 | let permissions = await getPermissionById(this.nexusSocket.uuid) 121 | 122 | if (permissions) { 123 | 124 | let editor = permissions.editor ? permissions.editor : false 125 | let queue = permissions.queue ? permissions.queue : false 126 | 127 | this.workflowManager.handleWorkflowEvents(editor) 128 | 129 | this.promptControl.handleEditor(editor) 130 | this.promptControl.handleControl(queue) 131 | 132 | this.nexusSocket.queue = queue 133 | this.nexusSocket.editor = editor 134 | 135 | if (editor) { 136 | this.workflowManager.saveManager(60) 137 | } 138 | else { 139 | this.workflowManager.removeSaveManager() 140 | } 141 | 142 | addChatWindowMessage("", `Permission: Workflow Editor ${editor ? "Yes" : "No"} | Queue Prompt ${queue ? "Yes" : "No"} `, this.color.info, this.colors.personal, true) 143 | 144 | if (queue) { 145 | addChatWindowMessage("", `You can now queue prompt press CTRL+Enter`, this.color.info, this.colors.personal, true) 146 | } 147 | 148 | } 149 | 150 | } 151 | 152 | this.workflowManager.loadLatestWorkflow() 153 | } 154 | 155 | handleConnect() { 156 | 157 | let name = this.nexusSocket.userManager.get(this.nexusSocket.uuid, "name") 158 | this.nexusSocket.sendMessage('chat-join', { name }, false) 159 | this.verifyLogin() 160 | 161 | } 162 | 163 | findChildrenAfterHr(parentClass) { 164 | const parentDiv = document.querySelector(`.${parentClass}`); 165 | const childElementsAfterHr = []; 166 | if (parentDiv) { 167 | const hr = parentDiv.querySelector('hr'); 168 | if (hr) { 169 | let sibling = hr.nextElementSibling; 170 | while (sibling) { 171 | childElementsAfterHr.push(sibling); 172 | sibling = sibling.nextElementSibling; 173 | } 174 | } 175 | } 176 | return childElementsAfterHr; 177 | } 178 | 179 | disableGraphSelectionWithExtremePrejudice() { 180 | if (!this.nexusSocket.admin && !this.nexusSocket.editor) { 181 | if (this.app.graph && this.app.graph.list_of_graphcanvas && this.app.graph.list_of_graphcanvas.length > 0) { 182 | this.app.graph.list_of_graphcanvas[0].deselectAllNodes() 183 | } 184 | } 185 | } 186 | 187 | disableComfyThings() { 188 | 189 | this.comfyThings.forEach((selector) => { 190 | const elements = document.querySelectorAll(selector); 191 | elements.forEach((element) => { 192 | element.disabled = true; 193 | element.style.pointerEvents = 'none'; 194 | }); 195 | }); 196 | 197 | document.querySelectorAll('.comfy-settings-btn').forEach(button => { 198 | button.disabled = true; 199 | button.style.pointerEvents = 'none'; 200 | }); 201 | 202 | const children = this.findChildrenAfterHr('comfy-menu'); 203 | 204 | children.forEach(element => { 205 | if (element.tagName === 'BUTTON' || element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') { 206 | element.disabled = true; 207 | } 208 | }); 209 | 210 | document.querySelector(`.comfy-menu`).style.display = 'none'; 211 | document.querySelector(`.comfy-menu`).style.pointerEvents = 'none'; 212 | 213 | 214 | clearInterval(this.disableComfyThingsTimer) 215 | 216 | this.disableComfyThingsTimer = setInterval(() => { 217 | 218 | 219 | this.comfyThings.forEach((selector) => { 220 | const elements = document.querySelectorAll(selector); 221 | elements.forEach((element) => { 222 | element.disabled = true; 223 | element.style.pointerEvents = 'none'; 224 | }); 225 | }); 226 | 227 | document.querySelectorAll('.comfy-settings-btn').forEach(button => { 228 | button.disabled = true; 229 | button.style.pointerEvents = 'none'; 230 | }); 231 | 232 | const children = this.findChildrenAfterHr('comfy-menu'); 233 | 234 | children.forEach(element => { 235 | if (element.tagName === 'BUTTON' || element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') { 236 | element.disabled = true; 237 | } 238 | }); 239 | 240 | document.querySelector(`.comfy-menu`).style.display = 'none'; 241 | document.querySelector(`.comfy-menu`).style.pointerEvents = 'none'; 242 | 243 | this.promptControl.handleEditor(this.nexusSocket.editor ? this.nexusSocket.editor : false) 244 | this.promptControl.handleControl(this.nexusSocket.queue ? this.nexusSocket.queue : false) 245 | 246 | }, 500); 247 | 248 | this.disableGraphSelectionWithExtremePrejudiceTimer = setInterval(this.disableGraphSelectionWithExtremePrejudice, 50) 249 | 250 | } 251 | 252 | enableComfyThings() { 253 | 254 | clearInterval(this.disableComfyThingsTimer) 255 | clearInterval(this.disableGraphSelectionWithExtremePrejudiceTimer) 256 | 257 | this.comfyThings.forEach((selector) => { 258 | const elements = document.querySelectorAll(selector); 259 | elements.forEach((element) => { 260 | element.disabled = false; 261 | element.style.pointerEvents = 'all'; 262 | }); 263 | }); 264 | 265 | document.querySelectorAll('.comfy-settings-btn').forEach(button => { 266 | button.disabled = false; 267 | button.style.pointerEvents = 'all'; 268 | }); 269 | 270 | const children = this.findChildrenAfterHr('comfy-menu'); 271 | 272 | children.forEach(element => { 273 | if (element.tagName === 'BUTTON' || element.tagName === 'INPUT' || element.tagName === 'SELECT' || element.tagName === 'TEXTAREA') { 274 | element.disabled = false; 275 | } 276 | }); 277 | 278 | document.querySelector(`.comfy-menu`).style.display = 'flex'; 279 | document.querySelector(`.comfy-menu`).style.pointerEvents = 'all'; 280 | 281 | } 282 | 283 | 284 | handleChatWindow(event) { 285 | 286 | let key = event.key 287 | let label = inputMain.childrens[0] 288 | let input = inputMain.childrens[1] 289 | 290 | let active = { 291 | tag: document.activeElement.tagName, 292 | el: document.activeElement, 293 | chat: document.activeElement == input.el 294 | } 295 | 296 | function activateChat() { 297 | let allowedTags = ['INPUT', 'TEXTAREA'] 298 | if (!allowedTags.includes(active.tag) && input.visible == false) { 299 | event.preventDefault(); 300 | label.show() 301 | input.show() 302 | input.el.focus() 303 | input.el.value = "" 304 | input.focusTimer = setInterval((parent, input) => { 305 | if (document.activeElement != input && parent.visible == true) 306 | input.focus() 307 | }, 500, input, input.el); 308 | chatWindow.childrens[3].softUpdate('#nexus-chat-window', 'pointer-events', 'all') 309 | } 310 | } 311 | 312 | function deactivateChat() { 313 | if (!active.chat) return; 314 | label.hide() 315 | input.el.blur(); 316 | input.el.value = "" 317 | input.hide() 318 | clearTimeout(input.focusTimer) 319 | chatWindow.childrens[3].softUpdate('#nexus-chat-window', 'pointer-events', 'none') 320 | } 321 | 322 | function sendMessage() { 323 | if (!active.chat) return; 324 | let message = input.el.value 325 | inputMain.dispatchEvent(new CustomEvent('message', { detail: { message } })) 326 | deactivateChat() 327 | } 328 | 329 | switch (key) { 330 | case 't': 331 | case 'T': 332 | activateChat() 333 | this.togglePlayerTab(true) 334 | this.toggleWFTab(true) 335 | break; 336 | case 'Escape': 337 | deactivateChat() 338 | break; 339 | case 'Enter': 340 | sendMessage() 341 | break; 342 | case 'p': 343 | case 'P': 344 | if (event.altKey && event.shiftKey) { 345 | if (!active.chat) 346 | this.togglePlayerTab() 347 | } 348 | break; 349 | case 'o': 350 | case 'O': 351 | if (event.altKey && event.shiftKey) { 352 | if (!active.chat) 353 | this.toggleWFTab() 354 | } 355 | break; 356 | 357 | } 358 | 359 | return active 360 | } 361 | 362 | toggleWFTab(forceHide, forceReload) { 363 | if (this.nexusSocket.admin || this.nexusSocket.editor) { 364 | 365 | if (forceHide) { 366 | this.wp.active = 0 367 | this.wp.panel.hide() 368 | } else if (forceReload) { 369 | this.prepareWFPanel() 370 | } 371 | else { 372 | if (this.wp.active == 0) { 373 | this.wp.active = 1 374 | this.wp.panel.show() 375 | this.prepareWFPanel() 376 | } else if (this.wp.active == 1) { 377 | this.wp.active = 0 378 | this.wp.panel.hide() 379 | } 380 | } 381 | 382 | if (this.wp.active == 1) { 383 | this.workflowManager.removeSaveManager() 384 | } 385 | else { 386 | this.workflowManager.saveManager(this.workflowManager.saveTime || 60) 387 | } 388 | 389 | } 390 | } 391 | 392 | togglePlayerTab(forceHide, forceReload) { 393 | 394 | if (forceHide) { 395 | this.up.active = 0 396 | this.up.panel.hide() 397 | } else if (forceReload) { 398 | this.preparePanel(this.nexusSocket.admin) 399 | } else { 400 | if (this.up.active == 0) { 401 | this.up.active = 1 402 | this.up.panel.show() 403 | this.preparePanel(this.nexusSocket.admin) 404 | } else if (this.up.active == 1) { 405 | this.up.active = 0 406 | this.up.panel.hide() 407 | } 408 | } 409 | 410 | } 411 | 412 | async onWFPanelCommandReceived(kind, label, data, active) { 413 | 414 | switch (kind) { 415 | case 'button': 416 | if (label == "load") { 417 | let wfid = data // workflowid is data 418 | let wf = await getUserSpecificWorkflow(this.nexusSocket.uuid, wfid) 419 | if (wf) { 420 | wf.clear = true 421 | let message = { 422 | name: "workflow", 423 | from: this.nexusSocket.uuid, 424 | data: { workflow: wf } 425 | } 426 | this.nexusSocket.onMessageReceive.forEach(f => { f(message); }); 427 | if (this.nexusSocket.admin) { 428 | this.nexusSocket.sendMessage('workflow', { workflow: wf }, false) 429 | } 430 | } 431 | } 432 | break; 433 | } 434 | 435 | } 436 | 437 | async onPanelCommandReceived(kind, label, data, active) { 438 | 439 | switch (kind) { 440 | case 'button': 441 | if (label == "spectate") { 442 | this.nexusSocket.sendMessage("spectate", { on: active, receiver: data }, false) 443 | if (active == 1) { 444 | this.spectating = data // userid is data 445 | } 446 | else { 447 | this.spectating = null 448 | } 449 | } else if (label == "editor") { 450 | if (this.nexusSocket.admin) { 451 | let token = localStorage.getItem('nexus-socket-token'); 452 | await postPermissionById(data, this.nexusSocket.uuid, { editor: active ? true : false }, token) // userid is data 453 | this.nexusSocket.sendMessage("permission", { receiver: data }, false) 454 | } 455 | } else if (label == "queue") { 456 | if (this.nexusSocket.admin) { 457 | let token = localStorage.getItem('nexus-socket-token'); 458 | await postPermissionById(data, this.nexusSocket.uuid, { queue: active ? true : false }, token) // userid is data 459 | this.nexusSocket.sendMessage("permission", { receiver: data }, false) 460 | } 461 | } 462 | else if (label == "mouse") { 463 | this.nexusSocket.sendMessage("mouse_view_toggle", { on: active, receiver: data }, false) 464 | this.nexusSocket.userManager.set(data, 'hide_mouse', active) 465 | } 466 | break; 467 | } 468 | 469 | } 470 | 471 | async prepareWFPanel() { 472 | 473 | const columns = ['Name', 'Created', 'Action']; 474 | 475 | this.wp.addColumnsToPanel(columns); 476 | 477 | let meta = await getUserSpecificWorkflowsMeta(this.nexusSocket.uuid) 478 | 479 | const rows = []; 480 | const callbacks = []; 481 | const values = []; 482 | const data = []; 483 | 484 | for (let id = 0; id < meta.length; id++) { 485 | 486 | const file = meta[id]; 487 | 488 | let fn = file.file_name + "" 489 | 490 | let name = (fn.includes('lt') ? 'Long Term ' : 'Short Term ') 491 | // let time = file.last_saved.split(':') 492 | // time = time.join("|") 493 | 494 | // console.log(time) 495 | let time = this.timeAgo(file.last_saved) 496 | 497 | rows.push([name, time, 'button:load']) 498 | callbacks.push([null, null, this.wp.onclick]) 499 | values.push([null, null, '0']) 500 | data.push([null, null, file.file_name]) 501 | 502 | } 503 | 504 | this.wp.addRowsToPanel(rows, callbacks, values, data); 505 | } 506 | 507 | async preparePanel(admin) { 508 | 509 | const columns = ['Name', 'Actions', 'Online']; 510 | this.up.addColumnsToPanel(columns); 511 | 512 | const users = this.nexusSocket.userManager.users; 513 | const usersIds = Object.keys(users); 514 | 515 | const rows = []; 516 | const callbacks = []; 517 | const values = []; 518 | const data = []; 519 | 520 | for (const id of usersIds) { 521 | 522 | const user = users[id]; 523 | const name = id === this.nexusSocket.uuid ? `${user.name} (You)` : user.name; 524 | const self = id === this.nexusSocket.uuid; 525 | 526 | const status = user.status === "online" || self ? '🟢' : '🔴'; 527 | const hide_mouse = user.hide_mouse ? 0 : 1; 528 | const permissions = await getPermissionById(id); 529 | const spectating = id == this.spectating ? 1 : 0 530 | 531 | callbacks.push([null, self ? 'none' : this.up.onclick, null]); 532 | 533 | let powers = 'button:spectate,button:editor,button:queue,button:mouse' 534 | 535 | if (!admin) { 536 | powers = 'button:spectate,button:mouse' 537 | } 538 | 539 | rows.push([name, self ? 'button:none' : powers, status]); 540 | 541 | let row_values = `${spectating},${permissions.editor ? 1 : 0},${permissions.queue ? 1 : 0},${hide_mouse}` 542 | if (!admin) { 543 | row_values = `${spectating},${hide_mouse}` 544 | } 545 | 546 | values.push([null, row_values, null]); 547 | 548 | let row_data = `${id},${id},${id},${id}` 549 | if (!admin) { 550 | row_data = `${id},${id}` 551 | } 552 | 553 | data.push([null, row_data, null]); 554 | 555 | } 556 | 557 | this.up.addRowsToPanel(rows, callbacks, values, data); 558 | } 559 | 560 | handleMessageReceive(message) { 561 | 562 | let name = message.name; 563 | let from = message.from; 564 | let data = message.data 565 | 566 | // console.log(name, data, from) 567 | 568 | if (name == "chat") { 569 | 570 | let user_name = this.nexusSocket.userManager.get(from, "name") || "User"; 571 | let mouse = this.nexusSocket.userManager.get(from, "mouse") || "#0f0"; 572 | let message = data.message 573 | addChatWindowMessage(user_name, message, mouse[3], this.colors.none) 574 | 575 | } else if (name == "nick") { 576 | 577 | let old_name = data.old_name 578 | let new_name = data.new_name 579 | 580 | addChatWindowMessage(old_name + " is now known as ", new_name, this.colors.personal, this.colors.personal, true) 581 | this.nexusSocket.userManager.set(from, "name", new_name) 582 | 583 | } else if (name == "chat-join") { 584 | 585 | let name = data.name 586 | addChatWindowMessage(name + " joined the server", "", this.colors.personal, this.colors.personal, true) 587 | this.nexusSocket.userManager.set(from, "name", name) 588 | 589 | name = this.nexusSocket.userManager.get(this.nexusSocket.uuid, "name") 590 | this.nexusSocket.sendMessage('name', { name }, false) 591 | 592 | if (this.up.active) { 593 | this.togglePlayerTab(false, true) 594 | } 595 | 596 | } else if (name == "name") { 597 | 598 | let name = data.name 599 | this.nexusSocket.userManager.set(from, "name", name) 600 | //console.log("Received name:", name) 601 | 602 | if (this.up.active) { 603 | this.togglePlayerTab(false, true) 604 | } 605 | 606 | } else if (name == 'afk' || name == 'online') { 607 | 608 | this.nexusSocket.userManager.set(from, "status", name) 609 | 610 | if (this.up.active) { 611 | this.togglePlayerTab(false, true) 612 | } 613 | 614 | } else if (name == 'permission') { 615 | 616 | 617 | // window.location.reload() 618 | 619 | this.verifyLogin() 620 | 621 | if (this.up.active) { 622 | this.togglePlayerTab(false, true) 623 | } 624 | 625 | } 626 | 627 | } 628 | 629 | async handleMessage(evt) { 630 | 631 | let message = evt.detail.message 632 | message = this.cleanMessage(message) 633 | 634 | if (message) { 635 | 636 | let startsWithCommand = this.startsWithCommand(message) 637 | 638 | if (startsWithCommand) { 639 | let parts = this.extractCommandAndValue(message) 640 | let cmds = { 'nick': true, 'login': true, 'logout': true } 641 | let cmd = parts.command 642 | let values = parts.values 643 | 644 | if (cmds[cmd]) { 645 | switch (cmd) { 646 | case 'nick': 647 | let new_name = this.cleanMessage(values[0], 16) 648 | let old_name = this.nexusSocket.userManager.get(this.nexusSocket.uuid, "name") || "User" 649 | if (!new_name) { 650 | addChatWindowMessage("", "Name is invalid", this.colors.info, this.colors.info, true) 651 | } 652 | else { 653 | localStorage.setItem('nexus-socket-name', new_name); 654 | this.nexusSocket.sendMessage('nick', { old_name, new_name }) 655 | } 656 | break; 657 | case 'login': 658 | 659 | let account = this.cleanMessage(values[0], 16) 660 | let password = this.cleanMessage(values[1], 16) 661 | let uuid = this.nexusSocket.uuid 662 | let token = await nexusLogin(uuid, account, password) 663 | if (token) { 664 | localStorage.setItem('nexus-socket-token', token.token); 665 | // addChatWindowMessage("", "You are now an admin!", this.color.info, this.colors.info, true) 666 | this.verifyLogin() 667 | // window.location.reload() 668 | } 669 | else { 670 | addChatWindowMessage("", "Admin account/password invalid!", this.color.info, this.colors.info, true) 671 | } 672 | break; 673 | case 'logout': 674 | let usertoken = localStorage.getItem('nexus-socket-token') 675 | if (this.nexusSocket.admin) { 676 | await postPermissionById(this.nexusSocket.uuid, this.nexusSocket.uuid, { admin: false, queue: false, editor: false }, usertoken) 677 | localStorage.removeItem('nexus-socket-token'); 678 | window.location.reload() 679 | } 680 | break; 681 | 682 | default: 683 | break; 684 | } 685 | } 686 | 687 | } 688 | else { 689 | addChatWindowMessage("You", message, this.color, this.colors.none) 690 | this.nexusSocket.sendMessage('chat', { message }, false) 691 | } 692 | 693 | } 694 | 695 | } 696 | 697 | cleanMessage(message, length = 512) { 698 | message = message.substr(0, length); 699 | if (message.length === 0) { 700 | return null; 701 | } else { 702 | let msg = ""; 703 | const nonAsciiRe = /[^\x00-\x7F]/; 704 | const emojiRe = /[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F700}-\u{1F77F}]|[\u{1F780}-\u{1F7FF}]|[\u{1F800}-\u{1F8FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1FA00}-\u{1FA6F}]|[\u{1FA70}-\u{1FAFF}]/u; 705 | const htmlTagRe = /<\/?[^>]+(>|$)/g; 706 | message = message.replace(htmlTagRe, ''); 707 | for (let char of message) { 708 | if (!(nonAsciiRe.test(char) && !emojiRe.test(char))) { 709 | msg += char; 710 | } 711 | } 712 | return msg; 713 | } 714 | } 715 | 716 | startsWithCommand(str) { 717 | const commandPattern = /^\/\w+(\s+\S+)?/; 718 | return commandPattern.test(str); 719 | } 720 | 721 | extractCommandAndValue(str) { 722 | const commandPattern = /^\/(\w+)\s*(.*)/; 723 | const match = str.match(commandPattern); 724 | if (match) { 725 | const command = match[1]; 726 | const values = match[2].trim() ? match[2].split(/\s+/) : []; 727 | return { 728 | command, 729 | values 730 | }; 731 | } else { 732 | return null; 733 | } 734 | } 735 | 736 | 737 | } --------------------------------------------------------------------------------