From 2cf3d717d182752743040c5be02b49c331f6f093 Mon Sep 17 00:00:00 2001 From: Robert Wojciechowski Date: Sat, 28 Feb 2026 03:00:23 +0000 Subject: [PATCH] Refine worker UI theme behavior and remote fallback add flow --- web/constants.js | 15 +- web/distributed.css | 174 +++++++++++++---- web/distributedValue.js | 316 +++++++++++++++++++++++++++++++ web/main.js | 102 ++++++++++ web/sidebar/actionsSection.js | 3 +- web/sidebar/settingsSection.js | 15 +- web/sidebarRenderer.js | 9 +- web/tests/workerSettings.test.js | 122 ++++++++++++ web/ui.js | 35 ++-- web/ui/buttonHelpers.js | 7 +- web/ui/entityCard.js | 5 +- web/ui/logModal.js | 5 +- web/ui/settingsForm.js | 5 +- web/workerSettings.js | 99 ++++++++-- 14 files changed, 824 insertions(+), 88 deletions(-) create mode 100644 web/distributedValue.js create mode 100644 web/tests/workerSettings.test.js diff --git a/web/constants.js b/web/constants.js index 3ce53c3..af2400b 100644 --- a/web/constants.js +++ b/web/constants.js @@ -133,8 +133,9 @@ export const UI_STYLES = { statusDot: "display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px;", controlsDiv: "padding: 0 12px 12px 12px; display: flex; gap: 6px;", formGroup: "display: flex; flex-direction: column; gap: 5px;", - formLabel: "font-size: 12px; color: #ccc; font-weight: 500;", - formInput: "padding: 6px 10px; color: white; font-size: 12px; transition: border-color 0.2s;", + formLabel: "font-size: 12px; color: var(--dist-label-text, #ccc); font-weight: 500;", + formInput: + "padding: 6px 10px; color: var(--dist-input-text, white); background: var(--dist-input-bg, transparent); font-size: 12px; transition: border-color 0.2s;", // Card styles cardBase: "margin-bottom: 12px; overflow: hidden; display: flex;", @@ -146,7 +147,7 @@ export const UI_STYLES = { columnBase: "display: flex; align-items: center; justify-content: center;", checkboxColumn: "flex: 0 0 44px; display: flex; align-items: center; justify-content: center; cursor: default;", contentColumn: "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;", - iconColumn: "width: 44px; flex-shrink: 0; font-size: 20px; color: #666;", + iconColumn: "width: 44px; flex-shrink: 0; font-size: 20px; color: var(--dist-placeholder-add-color, #666);", // Row and content styles infoRow: "display: flex; align-items: center; padding: 12px; cursor: pointer; min-height: 64px;", @@ -156,13 +157,15 @@ export const UI_STYLES = { buttonGroup: "display: flex; gap: 4px; margin-top: 10px;", settingsForm: "display: flex; flex-direction: column; gap: 10px;", checkboxGroup: "display: flex; align-items: center; gap: 8px; margin: 5px 0;", - formLabelClickable: "font-size: 12px; color: #ccc; cursor: pointer;", + formLabelClickable: "font-size: 12px; color: var(--dist-label-text, #ccc); cursor: pointer;", settingsToggle: "display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; user-select: none;", controlsWrapper: "display: flex; gap: 6px; align-items: stretch; width: 100%;", // Existing styles - settingsArrow: "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto; padding: 4px;", - infoBox: "color: #999; padding: 5px 14px; font-size: 11px; text-align: center; flex: 1; font-weight: 500;", + settingsArrow: + "font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease; margin-left: auto; padding: 4px;", + infoBox: + "color: var(--dist-info-box-text, #999); padding: 5px 14px; font-size: 11px; text-align: center; flex: 1; font-weight: 500;", workerSettings: "margin: 0 12px; padding: 0 12px;" }; diff --git a/web/distributed.css b/web/distributed.css index 39e19d8..b6b3352 100644 --- a/web/distributed.css +++ b/web/distributed.css @@ -12,6 +12,90 @@ --master-badge-fallback-border: #335533; --master-badge-delegate-bg: #3a3a3a; --master-badge-delegate-text: #ffcc66; + --dist-divider: #444; + --dist-muted-text: #888; + --dist-label-text: #ccc; + --dist-settings-arrow: #888; + --dist-settings-arrow-hover: #fff; + --dist-card-bg: #2a2a2a; + --dist-card-title: #f4f5f7; + --dist-card-subtext: #a9afb9; + --dist-card-placeholder-title: #aaa; + --dist-left-col-border: #3a3a3a; + --dist-left-col-bg: rgba(0, 0, 0, 0.1); + --dist-info-box-bg: #333; + --dist-info-box-text: #999; + --dist-input-bg: #2a2a2a; + --dist-input-border: #444; + --dist-input-text: #fff; + --dist-settings-bg: #1e1e1e; + --dist-settings-border: #2a2a2a; + --dist-hover-bg: #333; + --dist-placeholder-blueprint-border: #555; + --dist-placeholder-blueprint-hover-border: #777; + --dist-placeholder-add-border: #444; + --dist-placeholder-add-hover-border: #666; + --dist-placeholder-blueprint-bg: rgba(255, 255, 255, 0.02); + --dist-placeholder-blueprint-hover-bg: rgba(255, 255, 255, 0.05); + --dist-placeholder-add-hover-bg: rgba(255, 255, 255, 0.02); + --dist-placeholder-blueprint-color: #777; + --dist-placeholder-blueprint-hover-color: #999; + --dist-placeholder-add-color: #555; + --dist-placeholder-add-hover-color: #888; + --dist-log-modal-bg: #1e1e1e; + --dist-log-modal-border: #444; + --dist-log-modal-header-border: #444; + --dist-log-modal-title: #fff; + --dist-log-modal-label: #ccc; + --dist-log-modal-body-bg: #0d0d0d; + --dist-log-modal-body-text: #ddd; + --dist-log-modal-status: #888; +} + +.distributed-panel--light { + --dist-divider: #c7ced9; + --dist-muted-text: #5b6472; + --dist-label-text: #2f3a4a; + --dist-settings-arrow: #6b7483; + --dist-settings-arrow-hover: #253040; + --dist-card-bg: #f6f8fb; + --dist-card-title: #1e293b; + --dist-card-subtext: #4b5565; + --dist-card-placeholder-title: #3f4a5a; + --dist-left-col-border: #d8deea; + --dist-left-col-bg: #eef2f8; + --dist-info-box-bg: #e8edf5; + --dist-info-box-text: #4f5b6c; + --dist-input-bg: #ffffff; + --dist-input-border: #b8c2d3; + --dist-input-text: #1f2937; + --dist-settings-bg: #eef2f8; + --dist-settings-border: #d5dcea; + --dist-hover-bg: #e6ebf3; + --dist-placeholder-blueprint-border: #aeb8c9; + --dist-placeholder-blueprint-hover-border: #8e9ab0; + --dist-placeholder-add-border: #b7c1d3; + --dist-placeholder-add-hover-border: #919eb4; + --dist-placeholder-blueprint-bg: rgba(80, 100, 140, 0.05); + --dist-placeholder-blueprint-hover-bg: rgba(80, 100, 140, 0.08); + --dist-placeholder-add-hover-bg: rgba(80, 100, 140, 0.05); + --dist-placeholder-blueprint-color: #5f6c82; + --dist-placeholder-blueprint-hover-color: #47556e; + --dist-placeholder-add-color: #6a7588; + --dist-placeholder-add-hover-color: #4c586f; + --master-badge-delegate-bg: #efe7ce; + --master-badge-delegate-text: #6a4f00; + --master-badge-fallback-bg: #dff1df; + --master-badge-fallback-text: #1f5f1f; + --master-badge-fallback-border: #9fc79f; + --dist-log-modal-bg: #f9fbff; + --dist-log-modal-border: #bcc7db; + --dist-log-modal-header-border: #c7d1e3; + --dist-log-modal-title: #1f2937; + --dist-log-modal-label: #4a5568; + --dist-log-modal-body-bg: #f2f5fb; + --dist-log-modal-body-text: #1f2937; + --dist-log-modal-status: #5c6779; } .btn--stop { @@ -50,45 +134,47 @@ } .entity-card-content--hovered { - background-color: #333 !important; + background-color: var(--dist-hover-bg) !important; } .placeholder-card--blueprint { - border-color: #555 !important; - background-color: rgba(255, 255, 255, 0.02) !important; + border-color: var(--dist-placeholder-blueprint-border) !important; + background-color: var(--dist-placeholder-blueprint-bg) !important; } .placeholder-card--blueprint.is-hovered { - border-color: #777 !important; - background-color: rgba(255, 255, 255, 0.05) !important; + border-color: var(--dist-placeholder-blueprint-hover-border) !important; + background-color: var(--dist-placeholder-blueprint-hover-bg) !important; } .placeholder-card--add { - border-color: #444 !important; + border-color: var(--dist-placeholder-add-border) !important; background-color: transparent !important; } .placeholder-card--add.is-hovered { - border-color: #666 !important; - background-color: rgba(255, 255, 255, 0.02) !important; + border-color: var(--dist-placeholder-add-hover-border) !important; + background-color: var(--dist-placeholder-add-hover-bg) !important; } .placeholder-column--blueprint { - color: #777 !important; + color: var(--dist-placeholder-blueprint-color) !important; + border-right-color: var(--dist-placeholder-blueprint-border) !important; } .placeholder-column--blueprint.is-hovered { - color: #999 !important; + color: var(--dist-placeholder-blueprint-hover-color) !important; } .placeholder-column--add { - color: #555 !important; - border-color: #444 !important; + color: var(--dist-placeholder-add-color) !important; + border-color: var(--dist-placeholder-add-border) !important; + border-right-color: var(--dist-placeholder-add-border) !important; } .placeholder-column--add.is-hovered { - color: #888 !important; - border-color: #666 !important; + color: var(--dist-placeholder-add-hover-color) !important; + border-color: var(--dist-placeholder-add-hover-border) !important; } .tunnel-button--enable { @@ -110,42 +196,62 @@ /* ---- Themeable card/column/input defaults (Classic) ---- */ .dist-card { - background: #2a2a2a; + background: var(--dist-card-bg); + color: var(--dist-card-title); border-radius: 6px; } .dist-card--blueprint { - border: 2px dashed #555; - background: rgba(255, 255, 255, 0.02); + border: 2px dashed var(--dist-placeholder-blueprint-border); + background: var(--dist-placeholder-blueprint-bg); } .dist-card--add { - border: 1px dashed #444; + border: 1px dashed var(--dist-placeholder-add-border); background: transparent; } .dist-card__left-col { - border-right: 1px solid #3a3a3a; - background: rgba(0, 0, 0, 0.1); + border-right: 1px solid var(--dist-left-col-border); + background: var(--dist-left-col-bg); } .dist-info-box { - background-color: #333; + background-color: var(--dist-info-box-bg); + color: var(--dist-info-box-text); border-radius: 4px; } .dist-form-input { - background: #2a2a2a; - border: 1px solid #444; + background: var(--dist-input-bg); + border: 1px solid var(--dist-input-border); + color: var(--dist-input-text); border-radius: 4px; } .worker-settings { - background: #1e1e1e; - border: 1px solid #2a2a2a; + background: var(--dist-settings-bg); + border: 1px solid var(--dist-settings-border); border-radius: 4px; } +.dist-worker-info__title { + color: var(--dist-card-title) !important; + font-size: 1.03em; + font-weight: 700; +} + +.dist-worker-info__meta { + color: var(--dist-card-subtext) !important; + font-size: 0.88em; +} + +.dist-worker-info__fallback { + color: var(--master-badge-fallback-text) !important; + font-size: 0.86em; + font-weight: 600; +} + /* ---- Nodes 2.0 Theme ---- */ .distributed-panel--nodes2 .dist-card { @@ -235,19 +341,19 @@ } .log-modal__content { - background: #1e1e1e; + background: var(--dist-log-modal-bg); border-radius: 8px; width: 90%; max-width: 1200px; height: 80%; display: flex; flex-direction: column; - border: 1px solid #444; + border: 1px solid var(--dist-log-modal-border); } .log-modal__header { padding: 15px 20px; - border-bottom: 1px solid #444; + border-bottom: 1px solid var(--dist-log-modal-header-border); display: flex; justify-content: space-between; align-items: center; @@ -255,7 +361,7 @@ .log-modal__title { margin: 0; - color: #fff; + color: var(--dist-log-modal-title); } .log-modal__header-buttons { @@ -276,7 +382,7 @@ .log-modal__refresh-label { font-size: 12px; - color: #ccc; + color: var(--dist-log-modal-label); cursor: pointer; white-space: nowrap; } @@ -296,15 +402,15 @@ font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 12px; line-height: 1.4; - color: #ddd; - background: #0d0d0d; + color: var(--dist-log-modal-body-text); + background: var(--dist-log-modal-body-bg); white-space: pre-wrap; word-wrap: break-word; } .log-modal__status { padding: 10px 20px; - border-top: 1px solid #444; + border-top: 1px solid var(--dist-log-modal-header-border); font-size: 11px; - color: #888; + color: var(--dist-log-modal-status); } diff --git a/web/distributedValue.js b/web/distributedValue.js new file mode 100644 index 0000000..7e9d536 --- /dev/null +++ b/web/distributedValue.js @@ -0,0 +1,316 @@ +import { app } from "/scripts/app.js"; +import { ENDPOINTS } from "./constants.js"; + +const NODE_CLASS = "DistributedValue"; +const WORKER_WIDGET_PREFIX = "_dv_worker_"; + +// --------------------------------------------------------------------------- +// Config helpers +// --------------------------------------------------------------------------- + +async function fetchWorkers() { + try { + const resp = await fetch(ENDPOINTS.CONFIG); + if (!resp.ok) return []; + const config = await resp.json(); + return config.workers || []; + } catch { + return []; + } +} + +// --------------------------------------------------------------------------- +// Hidden widget accessors (worker_values JSON storage) +// --------------------------------------------------------------------------- + +function getWorkerValuesWidget(node) { + return node.widgets?.find((w) => w.name === "worker_values"); +} + +function getHiddenWidget(node) { + return node._dvHiddenWidget || getWorkerValuesWidget(node); +} + +function getWorkerWidgets(node) { + return (node.widgets || []).filter((w) => w.name.startsWith(WORKER_WIDGET_PREFIX)); +} + +function readStore(node) { + const w = getHiddenWidget(node); + if (!w) return {}; + try { + const parsed = JSON.parse(w.value || "{}"); + return typeof parsed === "object" && parsed !== null ? parsed : {}; + } catch { + return {}; + } +} + +function writeStore(node, patch) { + const w = getHiddenWidget(node); + if (!w) return; + const store = readStore(node); + Object.assign(store, patch); + w.value = JSON.stringify(store); +} + +function serializeWorkerValues(node) { + const store = readStore(node); + // Keep metadata keys (_type, _options) but overwrite worker values + const newStore = { _type: store._type || "STRING" }; + if (store._options) newStore._options = store._options; + for (const w of getWorkerWidgets(node)) { + const key = w.name.slice(WORKER_WIDGET_PREFIX.length); + const val = w.value; + if (val !== undefined && val !== null && val !== "") { + newStore[key] = String(val); + } + } + const hidden = getHiddenWidget(node); + if (hidden) hidden.value = JSON.stringify(newStore); +} + +// --------------------------------------------------------------------------- +// Hide the raw worker_values widget +// --------------------------------------------------------------------------- + +function hideRawWidget(node) { + const w = getWorkerValuesWidget(node); + if (!w) return; + + // Hide any DOM elements (multiline creates a textarea) + if (w.element) w.element.style.display = "none"; + if (w.inputEl) w.inputEl.style.display = "none"; + + // Remove from visible widgets array + const idx = node.widgets.indexOf(w); + if (idx !== -1) node.widgets.splice(idx, 1); + + // Keep reference + node._dvHiddenWidget = w; +} + +// --------------------------------------------------------------------------- +// Connection type detection +// --------------------------------------------------------------------------- + +function detectTargetType(node) { + // Find what our output (slot 0) is connected to + if (!node.outputs || !node.outputs[0] || !node.outputs[0].links) { + return { type: "STRING", options: null }; + } + const linkIds = node.outputs[0].links; + if (!linkIds || linkIds.length === 0) { + return { type: "STRING", options: null }; + } + + const graph = node.graph || app.graph; + if (!graph) return { type: "STRING", options: null }; + + // Use the first connection to determine type + const linkId = linkIds[0]; + const link = graph.links?.[linkId] || graph._links?.get?.(linkId); + if (!link) return { type: "STRING", options: null }; + + const targetNode = graph.getNodeById(link.target_id); + if (!targetNode) return { type: "STRING", options: null }; + + const targetInputName = targetNode.inputs?.[link.target_slot]?.name; + if (!targetInputName) return { type: "STRING", options: null }; + + // Find the corresponding widget on the target node to get type info + const targetWidget = targetNode.widgets?.find((w) => w.name === targetInputName); + if (!targetWidget) { + // Check node definition for input type + const nodeDef = targetNode.constructor?.nodeData; + if (nodeDef) { + const inputDef = + nodeDef.input?.required?.[targetInputName] || + nodeDef.input?.optional?.[targetInputName]; + if (inputDef) { + if (Array.isArray(inputDef[0])) { + return { type: "COMBO", options: inputDef[0] }; + } + const typeName = inputDef[0]; + if (typeName === "INT") return { type: "INT", options: null }; + if (typeName === "FLOAT") return { type: "FLOAT", options: null }; + } + } + return { type: "STRING", options: null }; + } + + // Detect type from widget + if (targetWidget.type === "combo") { + const options = targetWidget.options?.values || []; + return { type: "COMBO", options }; + } + if (targetWidget.type === "number") { + // Distinguish INT vs FLOAT from the widget options + const step = targetWidget.options?.step; + if (step !== undefined && step % 1 === 0 && (!targetWidget.options?.precision || targetWidget.options.precision === 0)) { + return { type: "INT", options: null }; + } + return { type: "FLOAT", options: null }; + } + + return { type: "STRING", options: null }; +} + +// --------------------------------------------------------------------------- +// Worker widget creation +// --------------------------------------------------------------------------- + +function removeWorkerWidgets(node) { + for (let i = node.widgets.length - 1; i >= 0; i--) { + if (node.widgets[i].name.startsWith(WORKER_WIDGET_PREFIX)) { + node.widgets.splice(i, 1); + } + } +} + +function createWorkerWidgets(node, workers, inputType, comboOptions) { + removeWorkerWidgets(node); + + const store = readStore(node); + + for (let i = 0; i < workers.length; i++) { + const worker = workers[i]; + const key = String(i + 1); + const widgetName = `${WORKER_WIDGET_PREFIX}${key}`; + const label = worker.name || worker.id || `Worker ${key}`; + const savedValue = store[key] || ""; + + let widget; + if (inputType === "COMBO" && Array.isArray(comboOptions) && comboOptions.length > 0) { + widget = node.addWidget( + "combo", + widgetName, + savedValue || comboOptions[0], + (value) => { + widget.value = value; + serializeWorkerValues(node); + }, + { values: comboOptions } + ); + } else if (inputType === "INT") { + widget = node.addWidget( + "number", + widgetName, + savedValue ? Number(savedValue) : 0, + (value) => { + widget.value = value; + serializeWorkerValues(node); + }, + { min: -Infinity, max: Infinity, step: 1, precision: 0 } + ); + } else if (inputType === "FLOAT") { + widget = node.addWidget( + "number", + widgetName, + savedValue ? Number(savedValue) : 0.0, + (value) => { + widget.value = value; + serializeWorkerValues(node); + }, + { min: -Infinity, max: Infinity, step: 0.1, precision: 3 } + ); + } else { + // STRING (default) + widget = node.addWidget( + "string", + widgetName, + savedValue, + (value) => { + widget.value = value; + serializeWorkerValues(node); + }, + {} + ); + } + widget.label = label; + } + + // Persist type metadata + const patch = { _type: inputType }; + if (comboOptions) patch._options = comboOptions; + writeStore(node, patch); + + // Re-serialize worker values after creating widgets + serializeWorkerValues(node); + + // Resize node to fit + const size = node.computeSize(); + size[0] = Math.max(size[0], node.size?.[0] || 0); + node.setSize(size); + if (node.setDirtyCanvas) node.setDirtyCanvas(true, true); +} + +// --------------------------------------------------------------------------- +// Rebuild widgets based on current connection +// --------------------------------------------------------------------------- + +function rebuildWidgets(node, workers) { + const { type, options } = detectTargetType(node); + createWorkerWidgets(node, workers, type, options); +} + +// --------------------------------------------------------------------------- +// Extension registration +// --------------------------------------------------------------------------- + +app.registerExtension({ + name: "Distributed.DistributedValue", + async nodeCreated(node) { + if (node.comfyClass !== NODE_CLASS) return; + + try { + const workers = await fetchWorkers(); + node._dvWorkers = workers; + + // Hide the raw worker_values widget + hideRawWidget(node); + + // Read stored type or detect from connection + const store = readStore(node); + const storedType = store._type || "STRING"; + const storedOptions = store._options || null; + + if (workers.length > 0) { + createWorkerWidgets(node, workers, storedType, storedOptions); + } + + // Listen for connection changes to adapt widget type + const originalOnConnectionsChange = node.onConnectionsChange; + node.onConnectionsChange = function (side, slotIndex, connected, linkInfo, ioSlot) { + if (originalOnConnectionsChange) { + originalOnConnectionsChange.call(this, side, slotIndex, connected, linkInfo, ioSlot); + } + // side 2 = output; slot 0 = our "value" output + if (slotIndex === 0 && this._dvWorkers?.length > 0) { + // Small delay to let litegraph finish link setup + setTimeout(() => rebuildWidgets(this, this._dvWorkers), 50); + } + }; + + // Override configure for workflow load + const originalConfigure = node.configure; + node.configure = function (data) { + const result = originalConfigure + ? originalConfigure.call(this, data) + : undefined; + + setTimeout(() => { + hideRawWidget(this); + if (this._dvWorkers?.length > 0) { + const s = readStore(this); + createWorkerWidgets(this, this._dvWorkers, s._type || "STRING", s._options || null); + } + }, 100); + + return result; + }; + } catch (error) { + console.error("Error in DistributedValue extension:", error); + } + }, +}); diff --git a/web/main.js b/web/main.js index 08ff795..0de3dcd 100644 --- a/web/main.js +++ b/web/main.js @@ -37,6 +37,7 @@ class DistributedExtension { // Initialize abort controller for status checks this.statusCheckAbortController = null; + this.themeMutationObserver = null; // Inject CSS for pulsing animation this.injectStyles(); @@ -264,6 +265,7 @@ class DistributedExtension { this.onPanelOpen(); renderSidebarContent(this, el); this._applyNodes2Style(); + this._applyThemeToneClass(); }, destroy: () => { this.onPanelClose(); @@ -276,6 +278,8 @@ class DistributedExtension { if (!this.statusCheckTimeout) { checkAllWorkerStatuses(this); } + this._startThemeObserver(); + this._applyThemeToneClass(); } onPanelClose() { @@ -292,6 +296,7 @@ class DistributedExtension { clearTimeout(this.statusCheckTimeout); this.statusCheckTimeout = null; } + this._stopThemeObserver(); this.panelElement = null; } @@ -302,11 +307,108 @@ class DistributedExtension { this.panelElement.classList.toggle('distributed-panel--nodes2', Boolean(enabled)); } + _parseColorToRgba(colorValue) { + if (!colorValue || typeof colorValue !== "string") { + return null; + } + + const color = colorValue.trim().toLowerCase(); + if (!color || color === "transparent") { + return null; + } + + const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/); + if (rgbMatch) { + const parts = rgbMatch[1].split(",").map((part) => Number(part.trim())); + if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) { + return null; + } + const alpha = parts.length >= 4 && Number.isFinite(parts[3]) ? parts[3] : 1; + return { + r: Math.max(0, Math.min(255, parts[0])), + g: Math.max(0, Math.min(255, parts[1])), + b: Math.max(0, Math.min(255, parts[2])), + a: Math.max(0, Math.min(1, alpha)), + }; + } + + const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i); + if (hexMatch) { + const value = hexMatch[1]; + const expanded = value.length === 3 + ? value.split("").map((c) => `${c}${c}`).join("") + : value; + const r = parseInt(expanded.slice(0, 2), 16); + const g = parseInt(expanded.slice(2, 4), 16); + const b = parseInt(expanded.slice(4, 6), 16); + return { r, g, b, a: 1 }; + } + + return null; + } + + _isPanelLightTheme() { + const fallbackLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches || false; + if (!this.panelElement) { + return fallbackLight; + } + + let current = this.panelElement; + while (current) { + const bg = getComputedStyle(current).backgroundColor; + const rgba = this._parseColorToRgba(bg); + if (rgba && rgba.a > 0.02) { + // Relative luminance approximation (0..1) + const luminance = (0.2126 * rgba.r + 0.7152 * rgba.g + 0.0722 * rgba.b) / 255; + return luminance > 0.58; + } + current = current.parentElement; + } + + return fallbackLight; + } + + _applyThemeToneClass() { + if (!this.panelElement) { + return; + } + this.panelElement.classList.toggle("distributed-panel--light", this._isPanelLightTheme()); + } + + _startThemeObserver() { + if (this.themeMutationObserver) { + return; + } + + this.themeMutationObserver = new MutationObserver(() => { + this._applyThemeToneClass(); + }); + this.themeMutationObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style"], + }); + if (document.body) { + this.themeMutationObserver.observe(document.body, { + attributes: true, + attributeFilter: ["class", "style"], + }); + } + } + + _stopThemeObserver() { + if (!this.themeMutationObserver) { + return; + } + this.themeMutationObserver.disconnect(); + this.themeMutationObserver = null; + } + _setupNodes2Listener() { app.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", (e) => { const enabled = e.detail?.value ?? false; if (this.panelElement) { this.panelElement.classList.toggle('distributed-panel--nodes2', Boolean(enabled)); + this._applyThemeToneClass(); } }); } diff --git a/web/sidebar/actionsSection.js b/web/sidebar/actionsSection.js index 307f6c7..26953b8 100644 --- a/web/sidebar/actionsSection.js +++ b/web/sidebar/actionsSection.js @@ -2,7 +2,8 @@ import { BUTTON_STYLES } from "../constants.js"; export function renderActionsSection(extension) { const actionsSection = document.createElement("div"); - actionsSection.style.cssText = "padding-top: 10px; margin-bottom: 15px; border-top: 1px solid #444;"; + actionsSection.style.cssText = + "padding-top: 10px; margin-bottom: 15px; border-top: 1px solid var(--dist-divider, #444);"; const buttonRow = document.createElement("div"); buttonRow.style.cssText = "display: flex; gap: 8px;"; diff --git a/web/sidebar/settingsSection.js b/web/sidebar/settingsSection.js index 48d8112..0b15233 100644 --- a/web/sidebar/settingsSection.js +++ b/web/sidebar/settingsSection.js @@ -2,7 +2,7 @@ import { createCheckboxSetting, createNumberSetting } from "../ui/buttonHelpers. export function renderSettingsSection(extension) { const settingsSection = document.createElement("div"); - settingsSection.style.cssText = "border-top: 1px solid #444; margin-bottom: 10px;"; + settingsSection.style.cssText = "border-top: 1px solid var(--dist-divider, #444); margin-bottom: 10px;"; const settingsToggleArea = document.createElement("div"); settingsToggleArea.style.cssText = "padding: 16.5px 0; cursor: pointer; user-select: none;"; @@ -16,21 +16,22 @@ export function renderSettingsSection(extension) { const workerSettingsToggle = document.createElement("span"); workerSettingsToggle.textContent = "▶"; - workerSettingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;"; + workerSettingsToggle.style.cssText = + "font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease;"; settingsHeader.appendChild(workerSettingsTitle); settingsHeader.appendChild(workerSettingsToggle); settingsToggleArea.appendChild(settingsHeader); settingsToggleArea.onmouseover = () => { - workerSettingsToggle.style.color = "#fff"; + workerSettingsToggle.style.color = "var(--dist-settings-arrow-hover, #fff)"; }; settingsToggleArea.onmouseout = () => { - workerSettingsToggle.style.color = "#888"; + workerSettingsToggle.style.color = "var(--dist-settings-arrow, #888)"; }; const settingsSeparator = document.createElement("div"); - settingsSeparator.style.cssText = "border-bottom: 1px solid #444; margin: 0;"; + settingsSeparator.style.cssText = "border-bottom: 1px solid var(--dist-divider, #444); margin: 0;"; const settingsContent = document.createElement("div"); settingsContent.style.cssText = @@ -59,12 +60,12 @@ export function renderSettingsSection(extension) { const generalLabel = document.createElement("div"); generalLabel.textContent = "GENERAL"; generalLabel.style.cssText = - "grid-column: 1 / span 2; font-size: 11px; color: #888; letter-spacing: 0.06em; padding-top: 2px;"; + "grid-column: 1 / span 2; font-size: 11px; color: var(--dist-muted-text, #888); letter-spacing: 0.06em; padding-top: 2px;"; const timeoutsLabel = document.createElement("div"); timeoutsLabel.textContent = "TIMEOUTS"; timeoutsLabel.style.cssText = - "grid-column: 1 / span 2; font-size: 11px; color: #888; letter-spacing: 0.06em; padding-top: 4px;"; + "grid-column: 1 / span 2; font-size: 11px; color: var(--dist-muted-text, #888); letter-spacing: 0.06em; padding-top: 4px;"; settingsDiv.appendChild(generalLabel); settingsDiv.appendChild( diff --git a/web/sidebarRenderer.js b/web/sidebarRenderer.js index de74ab9..d59cb5d 100644 --- a/web/sidebarRenderer.js +++ b/web/sidebarRenderer.js @@ -52,8 +52,9 @@ export async function renderSidebarContent(extension, el) { // Show loading indicator el.innerHTML = ''; const loadingDiv = document.createElement("div"); - loadingDiv.style.cssText = "display: flex; align-items: center; justify-content: center; height: calc(100vh - 100px); color: #888;"; - loadingDiv.innerHTML = ` + loadingDiv.style.cssText = + "display: flex; align-items: center; justify-content: center; height: calc(100vh - 100px); color: var(--dist-muted-text, #888);"; + loadingDiv.innerHTML = ` `; el.appendChild(loadingDiv); @@ -74,7 +75,8 @@ export async function renderSidebarContent(extension, el) { // Create toolbar header to match ComfyUI style const toolbar = document.createElement("div"); toolbar.className = "p-toolbar p-component border-x-0 border-t-0 rounded-none px-2 py-1 min-h-8"; - toolbar.style.cssText = "border-bottom: 1px solid #444; background: transparent; display: flex; align-items: center;"; + toolbar.style.cssText = + "border-bottom: 1px solid var(--dist-divider, #444); background: transparent; display: flex; align-items: center;"; const toolbarStart = document.createElement("div"); toolbarStart.className = "p-toolbar-start"; toolbarStart.style.cssText = "display: flex; align-items: center;"; @@ -107,6 +109,7 @@ export async function renderSidebarContent(extension, el) { container.appendChild(renderActionsSection(extension)); container.appendChild(renderSettingsSection(extension)); el.appendChild(container); + extension._applyThemeToneClass?.(); // Start checking worker statuses immediately in parallel setTimeout(() => checkAllWorkerStatuses(extension), 0); } finally { diff --git a/web/tests/workerSettings.test.js b/web/tests/workerSettings.test.js new file mode 100644 index 0000000..baf087a --- /dev/null +++ b/web/tests/workerSettings.test.js @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { addNewWorker, isRemoteWorker } from "../workerSettings.js"; + + +describe("workerSettings remote classification", () => { + let originalWindow; + + beforeEach(() => { + originalWindow = globalThis.window; + globalThis.window = { + location: { + hostname: "127.0.0.1", + }, + }; + }); + + afterEach(() => { + globalThis.window = originalWindow; + }); + + it("treats explicit local worker type as local even with non-local host", () => { + const worker = { type: "local", host: "192.168.1.50" }; + expect(isRemoteWorker({}, worker)).toBe(false); + }); + + it("treats explicit remote worker type as remote", () => { + const worker = { type: "remote", host: "127.0.0.1" }; + expect(isRemoteWorker({}, worker)).toBe(true); + }); + + it("treats cloud worker type as remote", () => { + const worker = { type: "cloud", host: "worker.example.com" }; + expect(isRemoteWorker({}, worker)).toBe(true); + }); + + it("falls back to host heuristic for legacy workers", () => { + expect(isRemoteWorker({}, { host: "127.0.0.1" })).toBe(false); + expect(isRemoteWorker({}, { host: "worker.example.com" })).toBe(true); + }); +}); + +describe("addNewWorker GPU availability guard", () => { + it("falls back to a disabled remote worker when no local CUDA device is available", async () => { + const toastAdd = vi.fn(); + const updateWorker = vi.fn().mockResolvedValue({}); + const stateUpdateWorker = vi.fn(); + const extension = { + cudaDeviceCount: 1, + masterCudaDevice: 0, + panelElement: null, + config: { + workers: [], + master: { cuda_device: 0 }, + }, + api: { updateWorker }, + state: { updateWorker: stateUpdateWorker, setWorkerExpanded: vi.fn() }, + app: { extensionManager: { toast: { add: toastAdd } } }, + }; + + await addNewWorker(extension); + + expect(updateWorker).toHaveBeenCalledTimes(1); + expect(updateWorker.mock.calls[0][1]).toEqual( + expect.objectContaining({ + type: "remote", + enabled: false, + cuda_device: null, + host: "", + }) + ); + expect(extension.config.workers).toHaveLength(1); + expect(extension.config.workers[0]).toEqual( + expect.objectContaining({ + type: "remote", + enabled: false, + cuda_device: null, + host: "", + }) + ); + expect(stateUpdateWorker).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ enabled: false }) + ); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + severity: "warn", + summary: "Remote Worker Added", + }) + ); + }); + + it("assigns the first free local CUDA device when adding a worker", async () => { + const toastAdd = vi.fn(); + const updateWorker = vi.fn().mockResolvedValue({}); + const stateUpdateWorker = vi.fn(); + const setWorkerExpanded = vi.fn(); + const extension = { + cudaDeviceCount: 3, + masterCudaDevice: 0, + panelElement: null, + config: { + workers: [ + { id: "w-existing", type: "local", port: 8189, cuda_device: 1, enabled: true }, + ], + master: { cuda_device: 0 }, + }, + api: { updateWorker }, + state: { updateWorker: stateUpdateWorker, setWorkerExpanded }, + app: { extensionManager: { toast: { add: toastAdd } } }, + }; + + await addNewWorker(extension); + + expect(updateWorker).toHaveBeenCalledTimes(1); + expect(updateWorker.mock.calls[0][1]).toEqual( + expect.objectContaining({ + cuda_device: 2, + }) + ); + }); +}); diff --git a/web/ui.js b/web/ui.js index f5af377..2f992a1 100644 --- a/web/ui.js +++ b/web/ui.js @@ -27,12 +27,10 @@ const cardConfigs = { const participationEnabled = extension.isMasterParticipationEnabled(); const fallbackActive = extension.isMasterFallbackActive(); let delegateBadge = ''; - if (!participationEnabled) { - delegateBadge = fallbackActive - ? `
Fallback active • Master executing` - : `
Orchestrator-only mode`; + if (!participationEnabled && fallbackActive) { + delegateBadge = `
Fallback active • Master executing`; } - return `${data?.name || extension.config?.master?.name || "Master"}
${cudaInfo}Port ${port}${delegateBadge}`; + return `${data?.name || extension.config?.master?.name || "Master"}
${cudaInfo}Port ${port}${delegateBadge}`; }, controls: { type: 'master' @@ -63,12 +61,15 @@ const cardConfigs = { if (isCloud) { // For cloud workers, don't show port (it's always 443) - return `${data.name}
${data.host}`; + return `${data.name}
${data.host}`; } else if (isRemote) { - return `${data.name}
${data.host}:${data.port}`; + const hostLabel = data.host + ? `${data.host}:${data.port}` + : `Unconfigured remote worker • Port ${data.port}`; + return `${data.name}
${hostLabel}`; } else { const cudaInfo = data.cuda_device !== undefined ? `CUDA ${data.cuda_device} • ` : ''; - return `${data.name}
${cudaInfo}Port ${data.port}`; + return `${data.name}
${cudaInfo}Port ${data.port}`; } }, controls: { @@ -401,10 +402,15 @@ export class DistributedUI { const modal = createLogModal(); this._logModal = modal; + const themeClass = + extension.panelElement?.classList.contains("distributed-panel--light") + ? "distributed-panel--light" + : ""; modal.mount(document.body, { workerName, logData, fetchLog: fetchLog || (async () => extension.api.getWorkerLog(workerId, 1000)), + themeClass, onClose: () => { if (this._logModal === modal) { this._logModal = null; @@ -427,7 +433,8 @@ export class DistributedUI { const settingsToggle = document.createElement("span"); settingsToggle.textContent = "▶"; // Right arrow when collapsed - settingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;"; + settingsToggle.style.cssText = + "font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease;"; settingsRow.appendChild(settingsToggle); settingsRow.appendChild(settingsTitle); @@ -574,16 +581,22 @@ export class DistributedUI { badge.textContent = message; badge.classList.add("master-info-badge--fallback"); } else if (!participationEnabled) { - message = "Master disabled: running as orchestrator only."; + message = "Master disabled: running as orchestrator only"; badge.textContent = message; badge.classList.add("master-info-badge--delegate"); } else { - message = "Master participating in workflows."; + message = "Master participating in workflows"; badge.textContent = message; } controlsWrapper.appendChild(badge); } else if (config.dynamic && data) { if (isRemote) { + const isCloud = data.type === 'cloud'; + const workerTypeText = isCloud ? "Cloud worker" : "Remote worker"; + const workerTypeBadge = this.createInfoBox(workerTypeText); + workerTypeBadge.title = "Worker is externally hosted"; + controlsWrapper.appendChild(workerTypeBadge); + const logBtn = this.createButton('View Log', () => viewWorkerLog(extension, data.id, true)); logBtn.id = `log-${data.id}`; logBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.workerControl; diff --git a/web/ui/buttonHelpers.js b/web/ui/buttonHelpers.js index 9cdd239..28f589d 100644 --- a/web/ui/buttonHelpers.js +++ b/web/ui/buttonHelpers.js @@ -15,7 +15,7 @@ export function createCheckboxSetting(id, label, tooltip, checked, onChange) { const lbl = document.createElement("label"); lbl.htmlFor = id; lbl.textContent = label; - lbl.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; + lbl.style.cssText = "font-size: 12px; color: var(--dist-label-text, #ccc); cursor: pointer;"; if (tooltip) { lbl.title = tooltip; } @@ -32,7 +32,7 @@ export function createNumberSetting(id, label, tooltip, value, min, step, onChan const lbl = document.createElement("label"); lbl.htmlFor = id; lbl.textContent = label; - lbl.style.cssText = "font-size: 12px; color: #ccc;"; + lbl.style.cssText = "font-size: 12px; color: var(--dist-label-text, #ccc);"; if (tooltip) { lbl.title = tooltip; } @@ -42,7 +42,8 @@ export function createNumberSetting(id, label, tooltip, value, min, step, onChan input.id = id; input.min = String(min); input.step = String(step); - input.style.cssText = "width: 80px; padding: 2px 6px; background: #222; color: #ddd; border: 1px solid #333; border-radius: 3px;"; + input.style.cssText = + "width: 80px; padding: 2px 6px; background: var(--dist-input-bg, #222); color: var(--dist-input-text, #ddd); border: 1px solid var(--dist-input-border, #333); border-radius: 3px;"; input.value = value; input.onchange = onChange; diff --git a/web/ui/entityCard.js b/web/ui/entityCard.js index 5d76983..69bcd14 100644 --- a/web/ui/entityCard.js +++ b/web/ui/entityCard.js @@ -70,6 +70,7 @@ export function renderEntityCard(ui, cardConfigs, entityType, data, extension) { workerContent.appendChild(statusDot); const infoSpan = document.createElement("span"); + infoSpan.classList.add("dist-worker-info"); infoSpan.innerHTML = config.infoText(data, extension); workerContent.appendChild(infoSpan); @@ -92,13 +93,13 @@ export function renderEntityCard(ui, cardConfigs, entityType, data, extension) { rightColumn.onmouseover = () => { rightColumn.classList.add("entity-card-content--hovered"); if (settingsArrow) { - settingsArrow.style.color = "#fff"; + settingsArrow.style.color = "var(--dist-settings-arrow-hover, #fff)"; } }; rightColumn.onmouseout = () => { rightColumn.classList.remove("entity-card-content--hovered"); if (settingsArrow) { - settingsArrow.style.color = "#888"; + settingsArrow.style.color = "var(--dist-settings-arrow, #888)"; } }; } diff --git a/web/ui/logModal.js b/web/ui/logModal.js index 12ad3da..742c415 100644 --- a/web/ui/logModal.js +++ b/web/ui/logModal.js @@ -95,13 +95,16 @@ export function createLogModal() { } }; - const mount = (container, { workerName, logData, onClose, fetchLog }) => { + const mount = (container, { workerName, logData, onClose, fetchLog, themeClass = "" }) => { _onClose = onClose; _fetchLog = fetchLog; const modal = document.createElement('div'); modal.id = 'distributed-log-modal'; modal.className = 'log-modal'; + if (themeClass) { + modal.classList.add(themeClass); + } const content = document.createElement('div'); content.className = 'log-modal__content'; diff --git a/web/ui/settingsForm.js b/web/ui/settingsForm.js index d712039..41ac7c2 100644 --- a/web/ui/settingsForm.js +++ b/web/ui/settingsForm.js @@ -14,11 +14,12 @@ export function createWorkerSettingsForm(ui, extension, worker) { const typeLabel = document.createElement("label"); typeLabel.htmlFor = `worker-type-${worker.id}`; typeLabel.textContent = "Worker Type:"; - typeLabel.style.cssText = "font-size: 12px; color: #ccc;"; + typeLabel.style.cssText = "font-size: 12px; color: var(--dist-label-text, #ccc);"; const typeSelect = document.createElement("select"); typeSelect.id = `worker-type-${worker.id}`; - typeSelect.style.cssText = "padding: 4px 8px; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px; font-size: 12px;"; + typeSelect.style.cssText = + "padding: 4px 8px; background: var(--dist-input-bg, #333); color: var(--dist-input-text, #fff); border: 1px solid var(--dist-input-border, #555); border-radius: 4px; font-size: 12px;"; const localOption = document.createElement("option"); localOption.value = "local"; diff --git a/web/workerSettings.js b/web/workerSettings.js index 42e7cf9..591ccf6 100644 --- a/web/workerSettings.js +++ b/web/workerSettings.js @@ -4,14 +4,22 @@ import { parseHostInput } from './urlUtils.js'; import { toggleWorkerExpanded } from './workerLifecycle.js'; export function isRemoteWorker(extension, worker) { - // Check if explicitly marked as cloud worker - if (worker.type === "cloud") { + const workerType = String(worker?.type || "").toLowerCase(); + + // Explicit type always wins over host heuristics. + if (workerType === "cloud" || workerType === "remote") { return true; } + if (workerType === "local") { + return false; + } + // Otherwise check by host (backward compatibility) - const parsed = parseHostInput(worker.host || window.location.hostname); - const host = parsed.host || window.location.hostname; - return host !== "localhost" && host !== "127.0.0.1" && host !== window.location.hostname; + const parsed = parseHostInput(worker?.host || window.location.hostname); + const host = String(parsed.host || window.location.hostname || "").toLowerCase(); + const currentHost = String(parseHostInput(window.location.hostname).host || window.location.hostname || "").toLowerCase(); + const localHosts = new Set(["", "localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]); + return !(localHosts.has(host) || host === currentHost); } export function isCloudWorker(extension, worker) { @@ -32,6 +40,7 @@ export async function saveWorkerSettings(extension, workerId) { const rawHost = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname; const parsedHost = isRemote ? parseHostInput(rawHost) : { host: window.location.hostname, port: null }; const host = isRemote ? parsedHost.host : window.location.hostname; + const hostTrimmed = (host || "").trim(); let port = parseInt(document.getElementById(`port-${workerId}`).value); const cudaDevice = isRemote ? undefined : parseInt(document.getElementById(`cuda-${workerId}`).value); const extraArgs = isRemote ? undefined : document.getElementById(`args-${workerId}`).value; @@ -51,7 +60,7 @@ export async function saveWorkerSettings(extension, workerId) { return; } - if ((workerType === "remote" || workerType === "cloud") && !host.trim()) { + if ((workerType === "remote" || workerType === "cloud") && !hostTrimmed) { extension.app.extensionManager.toast.add({ severity: "error", summary: "Validation Error", @@ -102,7 +111,7 @@ export async function saveWorkerSettings(extension, workerId) { } } else { // For remote workers, only check conflicts with other workers on the same host - const sameHostConflict = extension.config.workers.some((w) => w.id !== workerId && w.port === port && w.host === host.trim()); + const sameHostConflict = extension.config.workers.some((w) => w.id !== workerId && w.port === port && w.host === hostTrimmed); if (sameHostConflict) { extension.app.extensionManager.toast.add({ @@ -115,21 +124,28 @@ export async function saveWorkerSettings(extension, workerId) { } } + const wasUnconfiguredRemote = + (worker.type === "remote" || worker.type === "cloud") && + (!String(worker.host || "").trim()) && + !worker.enabled; + const nextEnabled = isRemote && hostTrimmed && wasUnconfiguredRemote ? true : worker.enabled; + try { await extension.api.updateWorker(workerId, { name: name.trim(), type: workerType, - host: isRemote ? host.trim() : null, + host: isRemote ? hostTrimmed : null, port, cuda_device: isRemote ? null : cudaDevice, extra_args: isRemote ? null : extraArgs ? extraArgs.trim() : "", + enabled: nextEnabled, }); // Update local config worker.name = name.trim(); worker.type = workerType; if (isRemote) { - worker.host = host.trim(); + worker.host = hostTrimmed; delete worker.cuda_device; delete worker.extra_args; } else { @@ -138,14 +154,17 @@ export async function saveWorkerSettings(extension, workerId) { worker.extra_args = extraArgs ? extraArgs.trim() : ""; } worker.port = port; + worker.enabled = nextEnabled; // Sync to state - extension.state.updateWorker(workerId, { enabled: worker.enabled }); + extension.state.updateWorker(workerId, { enabled: nextEnabled }); extension.app.extensionManager.toast.add({ severity: "success", summary: "Settings Saved", - detail: `Worker ${name} settings updated`, + detail: nextEnabled && wasUnconfiguredRemote + ? `Worker ${name} configured and enabled` + : `Worker ${name} settings updated`, life: 3000, }); @@ -229,6 +248,44 @@ export async function deleteWorker(extension, workerId) { } export async function addNewWorker(extension) { + const toInt = (value) => { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : null; + }; + + const totalCudaDevices = toInt(extension.cudaDeviceCount); + const masterCudaDevice = toInt(extension.masterCudaDevice ?? extension.config?.master?.cuda_device); + const localWorkers = (extension.config?.workers || []).filter((w) => !isRemoteWorker(extension, w)); + + let selectedCudaDevice = extension.config.workers.length; + let fallbackToRemote = false; + if (totalCudaDevices !== null && totalCudaDevices > 0) { + const usedCudaDevices = new Set(); + for (const worker of localWorkers) { + const cudaIdx = toInt(worker.cuda_device); + if (cudaIdx !== null) { + usedCudaDevices.add(cudaIdx); + } + } + if (masterCudaDevice !== null) { + usedCudaDevices.add(masterCudaDevice); + } + + const availableCudaDevices = []; + for (let i = 0; i < totalCudaDevices; i++) { + if (!usedCudaDevices.has(i)) { + availableCudaDevices.push(i); + } + } + + if (availableCudaDevices.length === 0) { + fallbackToRemote = true; + selectedCudaDevice = null; + } else { + selectedCudaDevice = availableCudaDevices[0]; + } + } + // Generate new worker ID using UUID (fallback for non-secure contexts) const newId = generateUUID(); @@ -244,8 +301,10 @@ export async function addNewWorker(extension) { id: newId, name: `Worker ${extension.config.workers.length + 1}`, port: nextPort, - cuda_device: extension.config.workers.length, - enabled: true, // Default to enabled for convenience + type: fallbackToRemote ? "remote" : "local", + host: fallbackToRemote ? "" : null, + cuda_device: selectedCudaDevice, + enabled: fallbackToRemote ? false : true, // Remote fallback starts disabled until configured extra_args: "", }; @@ -260,16 +319,20 @@ export async function addNewWorker(extension) { cuda_device: newWorker.cuda_device, extra_args: newWorker.extra_args, enabled: newWorker.enabled, + host: newWorker.host, + type: newWorker.type, }); // Sync to state - extension.state.updateWorker(newId, { enabled: true }); + extension.state.updateWorker(newId, { enabled: newWorker.enabled }); extension.app.extensionManager.toast.add({ - severity: "success", - summary: "Worker Added", - detail: `New worker created on port ${nextPort}`, - life: 3000, + severity: fallbackToRemote ? "warn" : "success", + summary: fallbackToRemote ? "Remote Worker Added" : "Worker Added", + detail: fallbackToRemote + ? `No local GPU available, so a disabled remote worker was added on port ${nextPort}. Configure host and enable it.` + : `New worker created on port ${nextPort}`, + life: fallbackToRemote ? 5000 : 3000, }); // Refresh UI and expand the new worker