major refactoring of js
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
export function createApiClient(baseUrl) {
|
||||
const request = async (endpoint, options = {}, retries = 3) => {
|
||||
let lastError;
|
||||
let delay = 1000; // Initial delay for exponential backoff
|
||||
|
||||
for (let attempt = 0; attempt < retries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${endpoint}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.log(`API Error (attempt ${attempt + 1}/${retries}): ${endpoint} - ${error.message}`);
|
||||
if (attempt < retries - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
delay *= 2; // Exponential backoff
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
};
|
||||
|
||||
return {
|
||||
// Config endpoints
|
||||
async getConfig() {
|
||||
return request('/distributed/config');
|
||||
},
|
||||
|
||||
async updateWorker(workerId, data) {
|
||||
return request('/distributed/config/update_worker', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ worker_id: workerId, ...data })
|
||||
});
|
||||
},
|
||||
|
||||
async deleteWorker(workerId) {
|
||||
return request('/distributed/config/delete_worker', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ worker_id: workerId })
|
||||
});
|
||||
},
|
||||
|
||||
async updateSetting(key, value) {
|
||||
return request('/distributed/config/update_setting', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
},
|
||||
|
||||
async updateMaster(data) {
|
||||
return request('/distributed/config/update_master', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
},
|
||||
|
||||
// Worker management endpoints
|
||||
async launchWorker(workerId) {
|
||||
return request('/distributed/launch_worker', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ worker_id: workerId })
|
||||
});
|
||||
},
|
||||
|
||||
async stopWorker(workerId) {
|
||||
return request('/distributed/stop_worker', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ worker_id: workerId })
|
||||
});
|
||||
},
|
||||
|
||||
async getManagedWorkers() {
|
||||
return request('/distributed/managed_workers');
|
||||
},
|
||||
|
||||
async getWorkerLog(workerId, lines = 1000) {
|
||||
return request(`/distributed/worker_log/${workerId}?lines=${lines}`);
|
||||
},
|
||||
|
||||
async clearLaunchingFlag(workerId) {
|
||||
return request('/distributed/worker/clear_launching', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ worker_id: workerId })
|
||||
});
|
||||
},
|
||||
|
||||
// Job preparation
|
||||
async prepareJob(multiJobId) {
|
||||
return request('/distributed/prepare_job', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ multi_job_id: multiJobId })
|
||||
});
|
||||
},
|
||||
|
||||
// Image loading
|
||||
async loadImage(imagePath) {
|
||||
return request('/distributed/load_image', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ image_path: imagePath })
|
||||
});
|
||||
},
|
||||
|
||||
// Network info
|
||||
async getNetworkInfo() {
|
||||
return request('/distributed/network_info');
|
||||
},
|
||||
|
||||
// Status checking (with timeout)
|
||||
async checkStatus(url, timeout = 5000) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Batch status checking
|
||||
async checkMultipleStatuses(urls) {
|
||||
return Promise.allSettled(
|
||||
urls.map(url => this.checkStatus(url))
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export const BUTTON_STYLES = {
|
||||
base: "width: 100%; padding: 8px 12px; font-size: 12px; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s; font-weight: 500;",
|
||||
clearMemory: "background-color: #555;",
|
||||
success: "background-color: #3ca03c;",
|
||||
error: "background-color: #c04c4c;",
|
||||
launch: "background-color: #4a7c4a;",
|
||||
stop: "background-color: #7c4a4a;",
|
||||
grey: "background-color: #555;",
|
||||
};
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
DISABLED_GRAY: "#666",
|
||||
OFFLINE_RED: "#c04c4c",
|
||||
ONLINE_GREEN: "#3ca03c",
|
||||
PROCESSING_YELLOW: "#f0ad4e"
|
||||
};
|
||||
|
||||
export const PULSE_ANIMATION_CSS = `
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(240, 173, 78, 0.7);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.3);
|
||||
box-shadow: 0 0 0 6px rgba(240, 173, 78, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 0 rgba(240, 173, 78, 0);
|
||||
}
|
||||
}
|
||||
.status-pulsing {
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.distributed-button:hover:not(:disabled) {
|
||||
filter: brightness(1.2);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
.distributed-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Settings button animation */
|
||||
.settings-btn {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
/* Expanded settings panel */
|
||||
.worker-settings {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease, margin 0.3s ease;
|
||||
}
|
||||
.worker-settings.expanded {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
padding: 12px 0;
|
||||
}
|
||||
`;
|
||||
|
||||
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; background: #2a2a2a; border: 1px solid #444; color: white; font-size: 12px; border-radius: 4px; transition: border-color 0.2s;",
|
||||
workerCard: "margin-bottom: 12px; border-radius: 6px; overflow: hidden; display: flex; background: #2a2a2a;",
|
||||
checkboxColumn: "flex: 0 0 44px; display: flex; align-items: center; justify-content: center; border-right: 1px solid #3a3a3a; cursor: default; background: rgba(0,0,0,0.1);",
|
||||
contentColumn: "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;",
|
||||
infoRow: "display: flex; align-items: center; padding: 12px; cursor: pointer;",
|
||||
workerContent: "display: flex; align-items: center; gap: 10px; flex: 1;",
|
||||
settingsArrow: "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto; padding: 4px;",
|
||||
infoBox: "background-color: #333; color: #999; padding: 5px 14px; border-radius: 4px; font-size: 11px; text-align: center; flex: 1; font-weight: 500;",
|
||||
workerSettings: "margin: 0 12px; padding: 0 12px; background: #1e1e1e; border-radius: 4px; border: 1px solid #2a2a2a;"
|
||||
};
|
||||
|
||||
export const TIMEOUTS = {
|
||||
DEFAULT_FETCH: 5000, // ms for general API calls
|
||||
STATUS_CHECK: 2000, // ms for status checks
|
||||
LAUNCH: 90000, // ms for worker launch (longer for model loading)
|
||||
RETRY_DELAY: 1000, // initial delay for exponential backoff
|
||||
MAX_RETRIES: 3 // max retry attempts
|
||||
};
|
||||
@@ -0,0 +1,447 @@
|
||||
import { api } from "../../scripts/api.js";
|
||||
import { findNodesByClass, findImageReferences, hasUpstreamNode, pruneWorkflowForWorker } from './workerUtils.js';
|
||||
import { TIMEOUTS } from './constants.js';
|
||||
|
||||
export function setupInterceptor(extension) {
|
||||
api.queuePrompt = async (number, prompt) => {
|
||||
if (extension.isEnabled) {
|
||||
const hasCollector = findNodesByClass(prompt.output, "DistributedCollector").length > 0;
|
||||
const hasDistUpscale = findNodesByClass(prompt.output, "UltimateSDUpscaleDistributed").length > 0;
|
||||
|
||||
if (hasCollector || hasDistUpscale) {
|
||||
const result = await executeParallelDistributed(extension, prompt);
|
||||
// Check status after dispatching jobs
|
||||
setTimeout(() => extension.checkAllWorkerStatuses(), 500);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
return extension.originalQueuePrompt(number, prompt);
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeParallelDistributed(extension, promptWrapper) {
|
||||
try {
|
||||
const executionPrefix = "exec_" + Date.now(); // Unique ID for this specific execution
|
||||
const enabledWorkers = extension.enabledWorkers;
|
||||
|
||||
// Pre-flight health check on all enabled workers
|
||||
const activeWorkers = await performPreflightCheck(extension, enabledWorkers);
|
||||
|
||||
if (activeWorkers.length === 0 && enabledWorkers.length > 0) {
|
||||
extension.log("No active workers found. Running on master only.");
|
||||
// Fall back to master-only execution
|
||||
return extension.originalQueuePrompt(0, promptWrapper);
|
||||
}
|
||||
|
||||
extension.log(`Pre-flight check: ${activeWorkers.length} of ${enabledWorkers.length} workers are active`, "debug");
|
||||
|
||||
// Find all distributed nodes in the workflow
|
||||
const collectorNodes = findNodesByClass(promptWrapper.output, "DistributedCollector");
|
||||
const upscaleNodes = findNodesByClass(promptWrapper.output, "UltimateSDUpscaleDistributed");
|
||||
const allDistributedNodes = [...collectorNodes, ...upscaleNodes];
|
||||
|
||||
// Map original node IDs to truly unique job IDs for this specific run
|
||||
const job_id_map = new Map(allDistributedNodes.map(node => [node.id, `${executionPrefix}_${node.id}`]));
|
||||
|
||||
// Prepare a separate job queue on the backend for each unique job ID
|
||||
const preparePromises = Array.from(job_id_map.values()).map(uniqueId => prepareDistributedJob(extension, uniqueId));
|
||||
await Promise.all(preparePromises);
|
||||
|
||||
const jobs = [];
|
||||
// Use only active workers
|
||||
const participants = ['master', ...activeWorkers.map(w => w.id)];
|
||||
|
||||
for (const participantId of participants) {
|
||||
const options = {
|
||||
enabled_worker_ids: activeWorkers.map(w => w.id),
|
||||
workflow: promptWrapper.workflow,
|
||||
job_id_map: job_id_map // Pass the map of unique IDs
|
||||
};
|
||||
|
||||
const jobApiPrompt = prepareApiPromptForParticipant(
|
||||
extension, promptWrapper.output, participantId, options
|
||||
);
|
||||
|
||||
if (participantId === 'master') {
|
||||
jobs.push({ type: 'master', promptWrapper: { ...promptWrapper, output: jobApiPrompt } });
|
||||
} else {
|
||||
const worker = activeWorkers.find(w => w.id === participantId);
|
||||
if (worker) {
|
||||
const job = {
|
||||
type: 'worker',
|
||||
worker,
|
||||
prompt: jobApiPrompt,
|
||||
workflow: promptWrapper.workflow
|
||||
};
|
||||
|
||||
// Add image references if found for remote workers
|
||||
if (options._imageReferences) {
|
||||
job.imageReferences = options._imageReferences;
|
||||
}
|
||||
|
||||
jobs.push(job);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeJobs(extension, jobs);
|
||||
return result;
|
||||
} catch (error) {
|
||||
extension.log("Parallel execution failed: " + error.message, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareApiPromptForParticipant(extension, baseApiPrompt, participantId, options = {}) {
|
||||
let jobApiPrompt = JSON.parse(JSON.stringify(baseApiPrompt));
|
||||
const isMaster = participantId === 'master';
|
||||
|
||||
// Find all distributed nodes once (before pruning)
|
||||
const collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector");
|
||||
const upscaleNodes = findNodesByClass(jobApiPrompt, "UltimateSDUpscaleDistributed");
|
||||
const allDistributedNodes = [...collectorNodes, ...upscaleNodes];
|
||||
|
||||
// For workers, prune the workflow to only include distributed node dependencies
|
||||
if (!isMaster && allDistributedNodes.length > 0) {
|
||||
jobApiPrompt = pruneWorkflowForWorker(extension, jobApiPrompt, allDistributedNodes);
|
||||
}
|
||||
|
||||
// Handle image references for remote workers
|
||||
if (!isMaster && options.enabled_worker_ids) {
|
||||
// Check if this is a remote worker
|
||||
const workerId = participantId;
|
||||
const workerInfo = extension.config.workers.find(w => w.id === workerId);
|
||||
const isRemote = workerInfo && workerInfo.host;
|
||||
|
||||
if (isRemote) {
|
||||
// Find all image references in the pruned workflow
|
||||
const imageReferences = findImageReferences(extension, jobApiPrompt);
|
||||
if (imageReferences.size > 0) {
|
||||
extension.log(`Found ${imageReferences.size} image references for remote worker ${workerId}`, "debug");
|
||||
// Store image references for later processing
|
||||
options._imageReferences = imageReferences;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Distributed seed nodes
|
||||
const distributorNodes = findNodesByClass(jobApiPrompt, "DistributedSeed");
|
||||
if (distributorNodes.length > 0) {
|
||||
extension.log(`Found ${distributorNodes.length} seed node(s)`, "debug");
|
||||
}
|
||||
|
||||
for (const seedNode of distributorNodes) {
|
||||
const { inputs } = jobApiPrompt[seedNode.id];
|
||||
inputs.is_worker = !isMaster;
|
||||
if (!isMaster) {
|
||||
const workerIndex = options.enabled_worker_ids.indexOf(participantId);
|
||||
inputs.worker_id = `worker_${workerIndex}`;
|
||||
extension.log(`Set seed node ${seedNode.id} for worker ${workerIndex}`, "debug");
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Distributed collector nodes (already found above)
|
||||
for (const collector of collectorNodes) {
|
||||
const { inputs } = jobApiPrompt[collector.id];
|
||||
|
||||
// Check if this collector is downstream from a distributed upscaler
|
||||
const hasUpstreamDistributedUpscaler = hasUpstreamNode(
|
||||
jobApiPrompt,
|
||||
collector.id,
|
||||
'UltimateSDUpscaleDistributed'
|
||||
);
|
||||
|
||||
if (hasUpstreamDistributedUpscaler) {
|
||||
// Set pass_through mode for this collector
|
||||
inputs.pass_through = true;
|
||||
extension.log(`Collector ${collector.id} set to pass-through mode (downstream from distributed upscaler)`, "debug");
|
||||
} else {
|
||||
// Normal collector behavior
|
||||
// Get the unique job ID from the map created for this execution
|
||||
const uniqueJobId = options.job_id_map ? options.job_id_map.get(collector.id) : collector.id;
|
||||
|
||||
// Use the truly unique ID for this execution
|
||||
inputs.multi_job_id = uniqueJobId;
|
||||
inputs.is_worker = !isMaster;
|
||||
if (isMaster) {
|
||||
inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []);
|
||||
} else {
|
||||
inputs.master_url = extension.getMasterUrl();
|
||||
// Also make the worker_job_id unique to prevent potential caching issues
|
||||
inputs.worker_job_id = `${uniqueJobId}_worker_${participantId}`;
|
||||
inputs.worker_id = participantId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Ultimate SD Upscale Distributed nodes
|
||||
for (const upscaleNode of upscaleNodes) {
|
||||
const { inputs } = jobApiPrompt[upscaleNode.id];
|
||||
|
||||
// Get the unique job ID from the map
|
||||
const uniqueJobId = options.job_id_map ? options.job_id_map.get(upscaleNode.id) : upscaleNode.id;
|
||||
|
||||
inputs.multi_job_id = uniqueJobId;
|
||||
inputs.is_worker = !isMaster;
|
||||
|
||||
if (isMaster) {
|
||||
inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []);
|
||||
} else {
|
||||
inputs.master_url = extension.getMasterUrl();
|
||||
inputs.worker_id = participantId;
|
||||
// Workers also need the enabled_worker_ids to calculate tile distribution
|
||||
inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []);
|
||||
}
|
||||
}
|
||||
|
||||
return jobApiPrompt;
|
||||
}
|
||||
|
||||
export async function prepareDistributedJob(extension, multi_job_id) {
|
||||
try {
|
||||
await extension.api.prepareJob(multi_job_id);
|
||||
} catch (error) {
|
||||
extension.log("Error preparing job: " + error.message, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeJobs(extension, jobs) {
|
||||
let masterPromptId = null;
|
||||
|
||||
// Pre-load all unique images before dispatching to workers
|
||||
const allImageReferences = new Map();
|
||||
for (const job of jobs) {
|
||||
if (job.type === 'worker' && job.imageReferences) {
|
||||
for (const [filename, info] of job.imageReferences) {
|
||||
allImageReferences.set(filename, info);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allImageReferences.size > 0) {
|
||||
extension.log(`Pre-loading ${allImageReferences.size} unique image(s) for all workers`, "debug");
|
||||
await loadImagesForWorker(extension, allImageReferences);
|
||||
}
|
||||
|
||||
// Now dispatch jobs in parallel
|
||||
const promises = jobs.map(job => {
|
||||
if (job.type === 'master') {
|
||||
return extension.originalQueuePrompt(0, job.promptWrapper).then(result => {
|
||||
masterPromptId = result;
|
||||
return result;
|
||||
});
|
||||
} else {
|
||||
return dispatchToWorker(extension, job.worker, job.prompt, job.workflow, job.imageReferences);
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
return masterPromptId || { "prompt_id": "distributed-job-dispatched" };
|
||||
}
|
||||
|
||||
async function dispatchToWorker(extension, worker, prompt, workflow, imageReferences) {
|
||||
const workerUrl = extension.getWorkerUrl(worker);
|
||||
|
||||
// Debug logging - always log to console for debugging
|
||||
extension.log(`[Distributed] === Dispatching to ${worker.name} (${worker.id}) ===`, "debug");
|
||||
extension.log('[Distributed] Worker URL: ' + workerUrl, "debug");
|
||||
|
||||
// Handle image uploads for remote workers
|
||||
if (imageReferences && imageReferences.size > 0) {
|
||||
extension.log(`[Distributed] Processing ${imageReferences.size} image(s) for remote worker`, "debug");
|
||||
|
||||
try {
|
||||
// Load images from master
|
||||
const images = await loadImagesForWorker(extension, imageReferences);
|
||||
|
||||
// Upload images to worker
|
||||
if (images.length > 0) {
|
||||
await uploadImagesToWorker(extension, workerUrl, images);
|
||||
extension.log(`[Distributed] Successfully uploaded ${images.length} image(s) to worker`, "debug");
|
||||
}
|
||||
} catch (error) {
|
||||
extension.log(`Failed to process images for worker ${worker.name}: ${error.message}`, "error");
|
||||
// Continue with workflow execution even if image upload fails
|
||||
}
|
||||
}
|
||||
|
||||
const promptToSend = {
|
||||
prompt,
|
||||
extra_data: { extra_pnginfo: { workflow } },
|
||||
client_id: api.clientId
|
||||
};
|
||||
|
||||
extension.log('[Distributed] Prompt data: ' + JSON.stringify(promptToSend), "debug");
|
||||
|
||||
try {
|
||||
await fetch(`${workerUrl}/prompt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
mode: 'cors',
|
||||
body: JSON.stringify(promptToSend)
|
||||
});
|
||||
} catch (e) {
|
||||
extension.log(`Failed to connect to worker ${worker.name} at ${workerUrl}: ${e.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadImagesForWorker(extension, imageReferences) {
|
||||
const images = [];
|
||||
|
||||
// Use a cache to avoid loading the same image multiple times
|
||||
if (!extension._imageCache) {
|
||||
extension._imageCache = new Map();
|
||||
}
|
||||
|
||||
for (const [filename, info] of imageReferences) {
|
||||
try {
|
||||
// Check cache first
|
||||
if (extension._imageCache.has(filename)) {
|
||||
images.push(extension._imageCache.get(filename));
|
||||
extension.log(`Using cached image: ${filename}`, "debug");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Limit cache size
|
||||
if (extension._imageCache.size >= 10) {
|
||||
const oldestKey = extension._imageCache.keys().next().value;
|
||||
extension._imageCache.delete(oldestKey);
|
||||
extension.log(`Evicted oldest cache entry: ${oldestKey} (cache limit reached)`, "debug");
|
||||
}
|
||||
|
||||
// Load image from master's filesystem via API
|
||||
try {
|
||||
const data = await extension.api.loadImage(filename);
|
||||
const imageData = {
|
||||
name: filename,
|
||||
image: data.image_data
|
||||
};
|
||||
images.push(imageData);
|
||||
|
||||
// Cache the image for future use
|
||||
extension._imageCache.set(filename, imageData);
|
||||
extension.log(`Loaded and cached image: ${filename}`, "debug");
|
||||
} catch (loadError) {
|
||||
extension.log(`Failed to load image ${filename}: ${loadError.message}`, "error");
|
||||
throw loadError;
|
||||
}
|
||||
} catch (error) {
|
||||
extension.log(`Error loading image ${filename}: ${error.message}`, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Clear cache after a reasonable time to avoid memory issues
|
||||
setTimeout(() => {
|
||||
if (extension._imageCache && extension._imageCache.size > 0) {
|
||||
extension.log(`Clearing image cache (${extension._imageCache.size} images)`, "debug");
|
||||
extension._imageCache.clear();
|
||||
}
|
||||
}, 30000); // Clear after 30 seconds
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
export async function uploadImagesToWorker(extension, workerUrl, images) {
|
||||
// Upload images to worker's ComfyUI instance
|
||||
for (const imageData of images) {
|
||||
const formData = new FormData();
|
||||
|
||||
// Detect MIME type from base64 header
|
||||
const mimeMatch = imageData.image.match(/^data:(image\/\w+);base64,/);
|
||||
const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'; // Default to PNG if not detected
|
||||
|
||||
// Convert base64 to blob
|
||||
const base64Data = imageData.image.replace(/^data:image\/\w+;base64,/, '');
|
||||
const byteCharacters = atob(base64Data);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], { type: mimeType });
|
||||
|
||||
// Use original filename without heavy cleaning
|
||||
let cleanName = imageData.name;
|
||||
let subfolder = '';
|
||||
|
||||
// Extract subfolder if present
|
||||
if (cleanName.includes('/')) {
|
||||
const parts = cleanName.split('/');
|
||||
subfolder = parts.slice(0, -1).join('/');
|
||||
cleanName = parts[parts.length - 1];
|
||||
}
|
||||
|
||||
formData.append('image', blob, cleanName);
|
||||
formData.append('type', 'input');
|
||||
formData.append('subfolder', subfolder);
|
||||
formData.append('overwrite', 'true');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${workerUrl}/upload/image`, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
extension.log(`Uploaded image to worker: ${imageData.name} -> ${subfolder}/${cleanName}`, "debug");
|
||||
} catch (error) {
|
||||
extension.log(`Failed to upload ${imageData.name}: ${error.message}`, "error");
|
||||
// Continue with other images
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function performPreflightCheck(extension, workers) {
|
||||
if (workers.length === 0) return [];
|
||||
|
||||
extension.log(`Performing pre-flight health check on ${workers.length} workers...`, "debug");
|
||||
const startTime = Date.now();
|
||||
|
||||
const checkPromises = workers.map(async (worker) => {
|
||||
const url = extension.getWorkerUrl(worker, '/prompt');
|
||||
|
||||
extension.log(`Pre-flight checking ${worker.name} at: ${url}`, "debug");
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
signal: AbortSignal.timeout(TIMEOUTS.STATUS_CHECK)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
extension.log(`Worker ${worker.name} is active`, "debug");
|
||||
return { worker, active: true };
|
||||
} else {
|
||||
extension.log(`Worker ${worker.name} returned ${response.status}`, "debug");
|
||||
return { worker, active: false };
|
||||
}
|
||||
} catch (error) {
|
||||
extension.log(`Worker ${worker.name} is offline or unreachable: ${error.message}`, "debug");
|
||||
return { worker, active: false };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(checkPromises);
|
||||
const activeWorkers = results.filter(r => r.active).map(r => r.worker);
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
extension.log(`Pre-flight check completed in ${elapsed}ms. Active workers: ${activeWorkers.length}/${workers.length}`, "debug");
|
||||
|
||||
// Update UI status indicators for inactive workers
|
||||
results.filter(r => !r.active).forEach(r => {
|
||||
const statusDot = document.getElementById(`status-${r.worker.id}`);
|
||||
if (statusDot) {
|
||||
// Remove pulsing animation once status is determined
|
||||
statusDot.classList.remove('status-pulsing');
|
||||
statusDot.style.backgroundColor = "#c04c4c"; // Red for offline
|
||||
statusDot.title = "Offline - Cannot connect";
|
||||
}
|
||||
});
|
||||
|
||||
return activeWorkers;
|
||||
}
|
||||
-3521
File diff suppressed because it is too large
Load Diff
+1178
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,273 @@
|
||||
import { BUTTON_STYLES } from './constants.js';
|
||||
|
||||
export async function renderSidebarContent(extension, el) {
|
||||
// Panel is being opened/rendered
|
||||
extension.log("Panel opened", "debug");
|
||||
|
||||
if (!el) {
|
||||
extension.log("No element provided to renderSidebarContent", "debug");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent infinite recursion
|
||||
if (extension._isRendering) {
|
||||
extension.log("Already rendering, skipping", "debug");
|
||||
return;
|
||||
}
|
||||
extension._isRendering = true;
|
||||
|
||||
try {
|
||||
// Store reference to the panel element
|
||||
extension.panelElement = 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 = `<div style="font-size: 24px;">◐</div>`;
|
||||
el.appendChild(loadingDiv);
|
||||
|
||||
// Add rotation animation
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
loadingDiv.querySelector('div').style.animation = 'rotate 1s linear infinite';
|
||||
|
||||
// Preload data outside render
|
||||
await extension.loadConfig();
|
||||
await extension.loadManagedWorkers();
|
||||
|
||||
el.innerHTML = '';
|
||||
|
||||
// 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;";
|
||||
|
||||
const toolbarStart = document.createElement("div");
|
||||
toolbarStart.className = "p-toolbar-start";
|
||||
toolbarStart.style.cssText = "display: flex; align-items: center;";
|
||||
|
||||
const titleSpan = document.createElement("span");
|
||||
titleSpan.className = "text-xs 2xl:text-sm truncate";
|
||||
titleSpan.textContent = "COMFYUI DISTRIBUTED";
|
||||
titleSpan.title = "ComfyUI Distributed";
|
||||
|
||||
toolbarStart.appendChild(titleSpan);
|
||||
toolbar.appendChild(toolbarStart);
|
||||
|
||||
const toolbarCenter = document.createElement("div");
|
||||
toolbarCenter.className = "p-toolbar-center";
|
||||
toolbar.appendChild(toolbarCenter);
|
||||
|
||||
const toolbarEnd = document.createElement("div");
|
||||
toolbarEnd.className = "p-toolbar-end";
|
||||
toolbar.appendChild(toolbarEnd);
|
||||
|
||||
el.appendChild(toolbar);
|
||||
|
||||
// Main container with adjusted padding
|
||||
const container = document.createElement("div");
|
||||
container.style.cssText = "padding: 15px; display: flex; flex-direction: column; height: calc(100% - 32px);";
|
||||
|
||||
// Detect master info on panel open (in case CUDA info wasn't available at startup)
|
||||
extension.log(`Panel opened. CUDA device count: ${extension.cudaDeviceCount}, Workers: ${extension.config?.workers?.length || 0}`, "debug");
|
||||
if (!extension.cudaDeviceCount) {
|
||||
await extension.detectMasterIP();
|
||||
}
|
||||
|
||||
|
||||
// Now render with guaranteed up-to-date config
|
||||
// Master Node Section
|
||||
const masterDiv = extension.ui.renderEntityCard('master', extension.config?.master, extension);
|
||||
container.appendChild(masterDiv);
|
||||
|
||||
// Workers Section (no heading)
|
||||
const gpuSection = document.createElement("div");
|
||||
gpuSection.style.cssText = "flex: 1; overflow-y: auto; margin-bottom: 15px;";
|
||||
|
||||
const gpuList = document.createElement("div");
|
||||
const workers = extension.config?.workers || [];
|
||||
|
||||
// If no workers exist, show a full blueprint placeholder first
|
||||
if (workers.length === 0) {
|
||||
const blueprintDiv = extension.ui.renderEntityCard('blueprint', { onClick: () => extension.addNewWorker() }, extension);
|
||||
gpuList.appendChild(blueprintDiv);
|
||||
}
|
||||
|
||||
// Show existing workers
|
||||
workers.forEach(worker => {
|
||||
const gpuDiv = extension.ui.renderEntityCard('worker', worker, extension);
|
||||
gpuList.appendChild(gpuDiv);
|
||||
});
|
||||
gpuSection.appendChild(gpuList);
|
||||
|
||||
// Only show the minimal "Add Worker" box if there are existing workers
|
||||
if (workers.length > 0) {
|
||||
const addWorkerDiv = extension.ui.renderEntityCard('add', { onClick: () => extension.addNewWorker() }, extension);
|
||||
gpuSection.appendChild(addWorkerDiv);
|
||||
}
|
||||
|
||||
container.appendChild(gpuSection);
|
||||
|
||||
const actionsSection = document.createElement("div");
|
||||
actionsSection.style.cssText = "padding-top: 10px; margin-bottom: 15px; border-top: 1px solid #444;";
|
||||
|
||||
// Create a row for both buttons
|
||||
const buttonRow = document.createElement("div");
|
||||
buttonRow.style.cssText = "display: flex; gap: 8px;";
|
||||
|
||||
const clearMemButton = extension.ui.createButtonHelper(
|
||||
"Clear Worker VRAM",
|
||||
(e) => extension._handleClearMemory(e.target),
|
||||
BUTTON_STYLES.clearMemory
|
||||
);
|
||||
clearMemButton.title = "Clear VRAM on all enabled worker GPUs (not master)";
|
||||
clearMemButton.style.cssText += " flex: 1;";
|
||||
|
||||
const interruptButton = extension.ui.createButtonHelper(
|
||||
"Interrupt Workers",
|
||||
(e) => extension._handleInterruptWorkers(e.target),
|
||||
BUTTON_STYLES.clearMemory
|
||||
);
|
||||
interruptButton.title = "Cancel/interrupt execution on all enabled worker GPUs";
|
||||
interruptButton.style.cssText += " flex: 1;";
|
||||
|
||||
buttonRow.appendChild(clearMemButton);
|
||||
buttonRow.appendChild(interruptButton);
|
||||
actionsSection.appendChild(buttonRow);
|
||||
|
||||
container.appendChild(actionsSection);
|
||||
|
||||
// Settings section
|
||||
const settingsSection = document.createElement("div");
|
||||
settingsSection.style.cssText = "border-top: 1px solid #444; padding-top: 10px; margin-bottom: 10px;";
|
||||
|
||||
// Settings header with toggle
|
||||
const settingsHeader = document.createElement("div");
|
||||
settingsHeader.style.cssText = "display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none;";
|
||||
|
||||
const workerSettingsTitle = document.createElement("h4");
|
||||
workerSettingsTitle.textContent = "Settings";
|
||||
workerSettingsTitle.style.cssText = "margin: 0; font-size: 14px;";
|
||||
|
||||
const workerSettingsToggle = document.createElement("span");
|
||||
workerSettingsToggle.textContent = "▶"; // Right arrow when collapsed
|
||||
workerSettingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;";
|
||||
|
||||
settingsHeader.appendChild(workerSettingsTitle);
|
||||
settingsHeader.appendChild(workerSettingsToggle);
|
||||
|
||||
// Hover effect for header
|
||||
settingsHeader.onmouseover = () => {
|
||||
workerSettingsToggle.style.color = "#fff";
|
||||
};
|
||||
settingsHeader.onmouseout = () => {
|
||||
workerSettingsToggle.style.color = "#888";
|
||||
};
|
||||
|
||||
// Collapsible settings content
|
||||
const settingsContent = document.createElement("div");
|
||||
settingsContent.style.cssText = "max-height: 0; overflow: hidden; transition: max-height 0.3s ease;";
|
||||
|
||||
const settingsDiv = document.createElement("div");
|
||||
settingsDiv.style.cssText = "display: flex; flex-direction: column; gap: 8px; padding-top: 10px;";
|
||||
|
||||
// Toggle functionality
|
||||
let settingsExpanded = false;
|
||||
settingsHeader.onclick = () => {
|
||||
settingsExpanded = !settingsExpanded;
|
||||
if (settingsExpanded) {
|
||||
settingsContent.style.maxHeight = "200px";
|
||||
workerSettingsToggle.style.transform = "rotate(90deg)";
|
||||
} else {
|
||||
settingsContent.style.maxHeight = "0";
|
||||
workerSettingsToggle.style.transform = "rotate(0deg)";
|
||||
}
|
||||
};
|
||||
|
||||
// Debug mode setting
|
||||
const debugGroup = document.createElement("div");
|
||||
debugGroup.style.cssText = "display: flex; align-items: center; gap: 8px;";
|
||||
|
||||
const debugCheckbox = document.createElement("input");
|
||||
debugCheckbox.type = "checkbox";
|
||||
debugCheckbox.id = "setting-debug";
|
||||
debugCheckbox.checked = extension.config?.settings?.debug || false;
|
||||
debugCheckbox.onchange = (e) => extension._updateSetting('debug', e.target.checked);
|
||||
|
||||
const debugLabel = document.createElement("label");
|
||||
debugLabel.htmlFor = "setting-debug";
|
||||
debugLabel.textContent = "Debug Mode";
|
||||
debugLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;";
|
||||
|
||||
debugGroup.appendChild(debugCheckbox);
|
||||
debugGroup.appendChild(debugLabel);
|
||||
|
||||
// Auto-launch workers setting
|
||||
const autoLaunchGroup = document.createElement("div");
|
||||
autoLaunchGroup.style.cssText = "display: flex; align-items: center; gap: 8px;";
|
||||
|
||||
const autoLaunchCheckbox = document.createElement("input");
|
||||
autoLaunchCheckbox.type = "checkbox";
|
||||
autoLaunchCheckbox.id = "setting-auto-launch";
|
||||
autoLaunchCheckbox.checked = extension.config?.settings?.auto_launch_workers || false;
|
||||
autoLaunchCheckbox.onchange = (e) => extension._updateSetting('auto_launch_workers', e.target.checked);
|
||||
|
||||
const autoLaunchLabel = document.createElement("label");
|
||||
autoLaunchLabel.htmlFor = "setting-auto-launch";
|
||||
autoLaunchLabel.textContent = "Auto-launch Local Workers on Startup";
|
||||
autoLaunchLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;";
|
||||
|
||||
autoLaunchGroup.appendChild(autoLaunchCheckbox);
|
||||
autoLaunchGroup.appendChild(autoLaunchLabel);
|
||||
|
||||
// Stop workers on exit setting
|
||||
const stopOnExitGroup = document.createElement("div");
|
||||
stopOnExitGroup.style.cssText = "display: flex; align-items: center; gap: 8px;";
|
||||
|
||||
const stopOnExitCheckbox = document.createElement("input");
|
||||
stopOnExitCheckbox.type = "checkbox";
|
||||
stopOnExitCheckbox.id = "setting-stop-on-exit";
|
||||
stopOnExitCheckbox.checked = extension.config?.settings?.stop_workers_on_master_exit !== false; // Default true
|
||||
stopOnExitCheckbox.onchange = (e) => extension._updateSetting('stop_workers_on_master_exit', e.target.checked);
|
||||
|
||||
const stopOnExitLabel = document.createElement("label");
|
||||
stopOnExitLabel.htmlFor = "setting-stop-on-exit";
|
||||
stopOnExitLabel.textContent = "Stop Local Workers on Master Exit";
|
||||
stopOnExitLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;";
|
||||
|
||||
stopOnExitGroup.appendChild(stopOnExitCheckbox);
|
||||
stopOnExitGroup.appendChild(stopOnExitLabel);
|
||||
|
||||
settingsDiv.appendChild(debugGroup);
|
||||
settingsDiv.appendChild(autoLaunchGroup);
|
||||
settingsDiv.appendChild(stopOnExitGroup);
|
||||
settingsContent.appendChild(settingsDiv);
|
||||
|
||||
settingsSection.appendChild(settingsHeader);
|
||||
settingsSection.appendChild(settingsContent);
|
||||
container.appendChild(settingsSection);
|
||||
|
||||
const summarySection = document.createElement("div");
|
||||
summarySection.style.cssText = "border-top: 1px solid #444; padding-top: 10px;";
|
||||
const summary = document.createElement("div");
|
||||
summary.id = "distributed-summary";
|
||||
summary.style.cssText = "font-size: 11px; color: #888;";
|
||||
summarySection.appendChild(summary);
|
||||
container.appendChild(summarySection);
|
||||
el.appendChild(container);
|
||||
extension.updateSummary();
|
||||
|
||||
// Start checking worker statuses immediately in parallel
|
||||
setTimeout(() => extension.checkAllWorkerStatuses(), 0);
|
||||
} finally {
|
||||
// Always reset the rendering flag
|
||||
extension._isRendering = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export function createStateManager() {
|
||||
const state = {
|
||||
workers: new Map(), // Unified worker state: { status, managed, launching, expanded, ... }
|
||||
masterStatus: 'online',
|
||||
};
|
||||
|
||||
return {
|
||||
// Worker state management
|
||||
getWorker(workerId) {
|
||||
return state.workers.get(String(workerId)) || {};
|
||||
},
|
||||
|
||||
updateWorker(workerId, updates) {
|
||||
const id = String(workerId);
|
||||
const current = state.workers.get(id) || {};
|
||||
state.workers.set(id, { ...current, ...updates });
|
||||
return state.workers.get(id);
|
||||
},
|
||||
|
||||
setWorkerStatus(workerId, status) {
|
||||
return this.updateWorker(workerId, { status });
|
||||
},
|
||||
|
||||
setWorkerManaged(workerId, info) {
|
||||
return this.updateWorker(workerId, { managed: info });
|
||||
},
|
||||
|
||||
setWorkerLaunching(workerId, launching) {
|
||||
return this.updateWorker(workerId, { launching });
|
||||
},
|
||||
|
||||
setWorkerExpanded(workerId, expanded) {
|
||||
return this.updateWorker(workerId, { expanded });
|
||||
},
|
||||
|
||||
isWorkerLaunching(workerId) {
|
||||
return this.getWorker(workerId).launching || false;
|
||||
},
|
||||
|
||||
isWorkerExpanded(workerId) {
|
||||
return this.getWorker(workerId).expanded || false;
|
||||
},
|
||||
|
||||
isWorkerManaged(workerId) {
|
||||
return !!this.getWorker(workerId).managed;
|
||||
},
|
||||
|
||||
getWorkerStatus(workerId) {
|
||||
return this.getWorker(workerId).status || {};
|
||||
},
|
||||
|
||||
// Master state
|
||||
setMasterStatus(status) {
|
||||
state.masterStatus = status;
|
||||
},
|
||||
|
||||
getMasterStatus() {
|
||||
return state.masterStatus;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,997 @@
|
||||
import { BUTTON_STYLES, UI_STYLES, STATUS_COLORS } from './constants.js';
|
||||
|
||||
const cardConfigs = {
|
||||
master: {
|
||||
checkbox: {
|
||||
enabled: false,
|
||||
checked: true,
|
||||
disabled: true,
|
||||
opacity: 0.6,
|
||||
title: "Master node is always enabled"
|
||||
},
|
||||
statusDot: {
|
||||
color: STATUS_COLORS.ONLINE_GREEN,
|
||||
title: 'Online',
|
||||
id: 'master-status',
|
||||
dynamic: true
|
||||
},
|
||||
infoText: (data, extension) => {
|
||||
const cudaDevice = extension.config?.master?.cuda_device ?? extension.masterCudaDevice;
|
||||
const cudaInfo = cudaDevice !== undefined ? `CUDA ${cudaDevice} • ` : '';
|
||||
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
|
||||
return `<strong id="master-name-display">${data?.name || extension.config?.master?.name || "Master"}</strong><br><small style="color: #888;"><span id="master-cuda-info">${cudaInfo}Port ${port}</span></small>`;
|
||||
},
|
||||
controls: {
|
||||
type: 'info',
|
||||
text: 'Master',
|
||||
style: "background-color: #333; color: #999;"
|
||||
},
|
||||
settings: {
|
||||
formType: 'master',
|
||||
id: 'master-settings',
|
||||
expandedTracker: 'masterSettingsExpanded'
|
||||
},
|
||||
hover: true,
|
||||
expand: true,
|
||||
border: 'solid'
|
||||
},
|
||||
worker: {
|
||||
checkbox: {
|
||||
enabled: true,
|
||||
title: "Enable/disable this worker"
|
||||
},
|
||||
statusDot: {
|
||||
dynamic: true,
|
||||
initialColor: (data) => data.enabled ? STATUS_COLORS.OFFLINE_RED : STATUS_COLORS.DISABLED_GRAY,
|
||||
initialTitle: (data) => data.enabled ? "Checking status..." : "Disabled",
|
||||
pulsing: (data) => data.enabled,
|
||||
id: (data) => `status-${data.id}`
|
||||
},
|
||||
infoText: (data, extension) => {
|
||||
const isRemote = extension.isRemoteWorker(data);
|
||||
if (isRemote) {
|
||||
return `<strong>${data.name}</strong><br><small style="color: #888;">${data.host}:${data.port}</small>`;
|
||||
} else {
|
||||
const cudaInfo = data.cuda_device !== undefined ? `CUDA ${data.cuda_device} • ` : '';
|
||||
return `<strong>${data.name}</strong><br><small style="color: #888;">${cudaInfo}Port ${data.port}</small>`;
|
||||
}
|
||||
},
|
||||
controls: {
|
||||
dynamic: true
|
||||
},
|
||||
settings: {
|
||||
formType: 'worker',
|
||||
id: (data) => `settings-${data.id}`,
|
||||
expandedId: (data) => data?.id
|
||||
},
|
||||
hover: true,
|
||||
expand: true,
|
||||
border: 'solid'
|
||||
},
|
||||
blueprint: {
|
||||
checkbox: {
|
||||
type: 'icon',
|
||||
content: '+',
|
||||
width: 42,
|
||||
style: 'border-right: 2px dashed #555; color: #777; font-size: 24px; font-weight: 500;'
|
||||
},
|
||||
statusDot: {
|
||||
color: 'transparent',
|
||||
border: '1px solid #555'
|
||||
},
|
||||
infoText: () => `<strong style="color: #aaa; font-size: 16px;">Add New Worker</strong><br><small style="color: #555;">[CUDA] • [Port]</small>`,
|
||||
controls: {
|
||||
type: 'ghost',
|
||||
text: 'Configure',
|
||||
style: 'border: 1px solid #444; background: transparent; color: #555;'
|
||||
},
|
||||
hover: 'placeholder',
|
||||
expand: false,
|
||||
border: 'dashed'
|
||||
},
|
||||
add: {
|
||||
checkbox: {
|
||||
type: 'icon',
|
||||
content: '+',
|
||||
width: 43,
|
||||
style: 'border-right: 1px dashed #444; color: #555; font-size: 18px;'
|
||||
},
|
||||
statusDot: {
|
||||
color: 'transparent',
|
||||
border: '1px solid #555'
|
||||
},
|
||||
infoText: () => `<span style="color: #666; font-weight: bold; font-size: 13px;">Add New Worker</span>`,
|
||||
controls: null,
|
||||
hover: 'placeholder',
|
||||
expand: false,
|
||||
border: 'dashed',
|
||||
minHeight: '48px'
|
||||
}
|
||||
};
|
||||
|
||||
export class DistributedUI {
|
||||
constructor() {
|
||||
// UI element styles
|
||||
this.styles = UI_STYLES;
|
||||
}
|
||||
|
||||
createStatusDot(id, color = "#666", title = "Status") {
|
||||
const dot = document.createElement("span");
|
||||
if (id) dot.id = id;
|
||||
dot.style.cssText = this.styles.statusDot + ` background-color: ${color};`;
|
||||
dot.title = title;
|
||||
return dot;
|
||||
}
|
||||
|
||||
createButton(text, onClick, customStyle = "") {
|
||||
const button = document.createElement("button");
|
||||
button.textContent = text;
|
||||
button.className = "distributed-button";
|
||||
button.style.cssText = BUTTON_STYLES.base + customStyle;
|
||||
if (onClick) button.onclick = onClick;
|
||||
return button;
|
||||
}
|
||||
|
||||
createButtonGroup(buttons, style = "") {
|
||||
const group = document.createElement("div");
|
||||
group.style.cssText = "display: flex; gap: 4px; margin-top: 10px;" + style;
|
||||
buttons.forEach(button => group.appendChild(button));
|
||||
return group;
|
||||
}
|
||||
|
||||
createWorkerControls(workerId, handlers = {}) {
|
||||
const controlsDiv = document.createElement("div");
|
||||
controlsDiv.id = `controls-${workerId}`;
|
||||
controlsDiv.style.cssText = this.styles.controlsDiv;
|
||||
|
||||
const buttons = [];
|
||||
|
||||
if (handlers.launch) {
|
||||
const launchBtn = this.createButton('Launch', handlers.launch);
|
||||
launchBtn.id = `launch-${workerId}`;
|
||||
launchBtn.title = "Launch this worker instance";
|
||||
buttons.push(launchBtn);
|
||||
}
|
||||
|
||||
if (handlers.stop) {
|
||||
const stopBtn = this.createButton('Stop', handlers.stop);
|
||||
stopBtn.id = `stop-${workerId}`;
|
||||
stopBtn.title = "Stop this worker instance";
|
||||
buttons.push(stopBtn);
|
||||
}
|
||||
|
||||
if (handlers.viewLog) {
|
||||
const logBtn = this.createButton('View Log', handlers.viewLog);
|
||||
logBtn.id = `log-${workerId}`;
|
||||
logBtn.title = "View worker log file";
|
||||
buttons.push(logBtn);
|
||||
}
|
||||
|
||||
buttons.forEach(btn => controlsDiv.appendChild(btn));
|
||||
return controlsDiv;
|
||||
}
|
||||
|
||||
createFormGroup(label, value, id, type = "text", placeholder = "") {
|
||||
const group = document.createElement("div");
|
||||
group.style.cssText = this.styles.formGroup;
|
||||
|
||||
const labelEl = document.createElement("label");
|
||||
labelEl.textContent = label;
|
||||
labelEl.htmlFor = id;
|
||||
labelEl.style.cssText = this.styles.formLabel;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = type;
|
||||
input.id = id;
|
||||
input.value = value;
|
||||
input.placeholder = placeholder;
|
||||
input.style.cssText = this.styles.formInput;
|
||||
|
||||
group.appendChild(labelEl);
|
||||
group.appendChild(input);
|
||||
return { group, input };
|
||||
}
|
||||
|
||||
|
||||
createInfoBox(text) {
|
||||
const box = document.createElement("div");
|
||||
box.style.cssText = this.styles.infoBox;
|
||||
box.textContent = text;
|
||||
return box;
|
||||
}
|
||||
|
||||
addHoverEffect(element, onHover, onLeave) {
|
||||
element.onmouseover = onHover;
|
||||
element.onmouseout = onLeave;
|
||||
}
|
||||
|
||||
createCard(type = 'worker', options = {}) {
|
||||
const card = document.createElement("div");
|
||||
const baseStyle = "margin-bottom: 12px; border-radius: 6px; overflow: hidden; display: flex;";
|
||||
|
||||
switch(type) {
|
||||
case 'master':
|
||||
case 'worker':
|
||||
card.style.cssText = baseStyle + "background: #2a2a2a;";
|
||||
break;
|
||||
case 'blueprint':
|
||||
card.style.cssText = baseStyle + "border: 2px dashed #555; cursor: pointer; transition: all 0.2s ease; background: rgba(255, 255, 255, 0.02);";
|
||||
if (options.onClick) card.onclick = options.onClick;
|
||||
if (options.title) card.title = options.title;
|
||||
break;
|
||||
case 'add':
|
||||
card.style.cssText = baseStyle + "border: 1px dashed #444; cursor: pointer; transition: all 0.2s ease; background: transparent;";
|
||||
if (options.onClick) card.onclick = options.onClick;
|
||||
if (options.title) card.title = options.title;
|
||||
break;
|
||||
}
|
||||
|
||||
if (options.onMouseEnter) {
|
||||
card.addEventListener('mouseenter', options.onMouseEnter);
|
||||
}
|
||||
if (options.onMouseLeave) {
|
||||
card.addEventListener('mouseleave', options.onMouseLeave);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
createCardColumn(type = 'checkbox', options = {}) {
|
||||
const column = document.createElement("div");
|
||||
const baseStyle = "display: flex; align-items: center; justify-content: center;";
|
||||
|
||||
switch(type) {
|
||||
case 'checkbox':
|
||||
column.style.cssText = baseStyle + "flex: 0 0 44px; border-right: 1px solid #3a3a3a; cursor: default; background: rgba(0,0,0,0.1);";
|
||||
if (options.title) column.title = options.title;
|
||||
break;
|
||||
case 'icon':
|
||||
column.style.cssText = baseStyle + "width: 44px; flex-shrink: 0; font-size: 20px; color: #666;";
|
||||
break;
|
||||
case 'content':
|
||||
column.style.cssText = "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;";
|
||||
break;
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
createInfoRow(options = {}) {
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display: flex; align-items: center; padding: 12px; cursor: pointer; min-height: 64px;";
|
||||
if (options.onClick) row.onclick = options.onClick;
|
||||
return row;
|
||||
}
|
||||
|
||||
createWorkerContent() {
|
||||
const content = document.createElement("div");
|
||||
content.style.cssText = "display: flex; align-items: center; gap: 10px; flex: 1;";
|
||||
return content;
|
||||
}
|
||||
|
||||
createSettingsForm(fields = [], options = {}) {
|
||||
const form = document.createElement("div");
|
||||
form.style.cssText = "display: flex; flex-direction: column; gap: 10px;";
|
||||
|
||||
fields.forEach(field => {
|
||||
if (field.type === 'checkbox') {
|
||||
const group = document.createElement("div");
|
||||
group.style.cssText = "display: flex; align-items: center; gap: 8px; margin: 5px 0;";
|
||||
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.id = field.id;
|
||||
checkbox.checked = field.checked || false;
|
||||
if (field.onChange) checkbox.onchange = field.onChange;
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = field.id;
|
||||
label.textContent = field.label;
|
||||
label.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;";
|
||||
|
||||
group.appendChild(checkbox);
|
||||
group.appendChild(label);
|
||||
form.appendChild(group);
|
||||
} else {
|
||||
const result = this.createFormGroup(field.label, field.value, field.id, field.type, field.placeholder);
|
||||
if (field.groupId) result.group.id = field.groupId;
|
||||
if (field.display) result.group.style.display = field.display;
|
||||
form.appendChild(result.group);
|
||||
}
|
||||
});
|
||||
|
||||
if (options.buttons) {
|
||||
const buttonGroup = this.createButtonGroup(options.buttons, options.buttonStyle || " margin-top: 8px;");
|
||||
form.appendChild(buttonGroup);
|
||||
}
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
|
||||
createButtonHelper(text, onClick, style) {
|
||||
return this.createButton(text, onClick, style);
|
||||
}
|
||||
|
||||
updateMasterDisplay(extension) {
|
||||
// Use persistent config value as fallback
|
||||
const cudaDevice = extension?.config?.master?.cuda_device ?? extension?.masterCudaDevice;
|
||||
|
||||
// Update CUDA info if element exists
|
||||
const cudaInfo = document.getElementById('master-cuda-info');
|
||||
if (cudaInfo) {
|
||||
const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
|
||||
if (cudaDevice !== undefined && cudaDevice !== null) {
|
||||
cudaInfo.textContent = `CUDA ${cudaDevice} • Port ${port}`;
|
||||
} else {
|
||||
cudaInfo.textContent = `Port ${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update name if changed
|
||||
const nameDisplay = document.getElementById('master-name-display');
|
||||
if (nameDisplay && extension?.config?.master?.name) {
|
||||
nameDisplay.textContent = extension.config.master.name;
|
||||
}
|
||||
}
|
||||
|
||||
showToast(app, severity, summary, detail, life = 3000) {
|
||||
if (app.extensionManager?.toast?.add) {
|
||||
app.extensionManager.toast.add({ severity, summary, detail, life });
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusDot(workerId, color, title, pulsing = false) {
|
||||
const statusDot = document.getElementById(`status-${workerId}`);
|
||||
if (!statusDot) return;
|
||||
|
||||
statusDot.style.backgroundColor = color;
|
||||
statusDot.title = title;
|
||||
statusDot.classList.toggle('status-pulsing', pulsing);
|
||||
}
|
||||
|
||||
showLogModal(extension, workerId, logData) {
|
||||
// Remove any existing modal
|
||||
const existingModal = document.getElementById('distributed-log-modal');
|
||||
if (existingModal) {
|
||||
existingModal.remove();
|
||||
}
|
||||
|
||||
const worker = extension.config.workers.find(w => w.id === workerId);
|
||||
const workerName = worker?.name || `Worker ${workerId}`;
|
||||
|
||||
// Create modal container
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'distributed-log-modal';
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
`;
|
||||
|
||||
// Create modal content
|
||||
const content = document.createElement('div');
|
||||
content.style.cssText = `
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
width: 90%;
|
||||
max-width: 1200px;
|
||||
height: 80%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #444;
|
||||
`;
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.style.cssText = `
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #444;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const title = document.createElement('h3');
|
||||
title.textContent = `${workerName} - Log Viewer`;
|
||||
title.style.cssText = 'margin: 0; color: #fff;';
|
||||
|
||||
const headerButtons = document.createElement('div');
|
||||
headerButtons.style.cssText = 'display: flex; gap: 20px; align-items: center;';
|
||||
|
||||
// Auto-refresh container
|
||||
const refreshContainer = document.createElement('div');
|
||||
refreshContainer.style.cssText = 'display: flex; align-items: center; gap: 4px;';
|
||||
|
||||
// Auto-refresh checkbox
|
||||
const refreshCheckbox = document.createElement('input');
|
||||
refreshCheckbox.type = 'checkbox';
|
||||
refreshCheckbox.id = 'log-auto-refresh';
|
||||
refreshCheckbox.checked = true; // Enabled by default
|
||||
refreshCheckbox.style.cssText = 'cursor: pointer;';
|
||||
refreshCheckbox.onchange = (e) => {
|
||||
if (e.target.checked) {
|
||||
extension.startLogAutoRefresh(workerId);
|
||||
} else {
|
||||
extension.stopLogAutoRefresh();
|
||||
}
|
||||
};
|
||||
|
||||
const refreshLabel = document.createElement('label');
|
||||
refreshLabel.htmlFor = 'log-auto-refresh';
|
||||
refreshLabel.style.cssText = 'font-size: 12px; color: #ccc; cursor: pointer; white-space: nowrap;';
|
||||
refreshLabel.textContent = 'Auto-refresh';
|
||||
|
||||
// Add checkbox and label to container
|
||||
refreshContainer.appendChild(refreshCheckbox);
|
||||
refreshContainer.appendChild(refreshLabel);
|
||||
|
||||
// Close button
|
||||
const closeBtn = this.createButton('✕',
|
||||
() => {
|
||||
extension.stopLogAutoRefresh();
|
||||
modal.remove();
|
||||
},
|
||||
'background-color: #c04c4c;');
|
||||
closeBtn.style.cssText += ' padding: 5px 10px; font-size: 14px; font-weight: bold;';
|
||||
|
||||
headerButtons.appendChild(refreshContainer);
|
||||
headerButtons.appendChild(closeBtn);
|
||||
|
||||
header.appendChild(title);
|
||||
header.appendChild(headerButtons);
|
||||
|
||||
// Log content area
|
||||
const logContainer = document.createElement('div');
|
||||
logContainer.style.cssText = `
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 15px;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #ddd;
|
||||
background: #0d0d0d;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
`;
|
||||
logContainer.id = 'distributed-log-content';
|
||||
logContainer.textContent = logData.content;
|
||||
|
||||
// Auto-scroll to bottom
|
||||
setTimeout(() => {
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}, 0);
|
||||
|
||||
// Status bar
|
||||
const statusBar = document.createElement('div');
|
||||
statusBar.style.cssText = `
|
||||
padding: 10px 20px;
|
||||
border-top: 1px solid #444;
|
||||
font-size: 11px;
|
||||
color: #888;
|
||||
`;
|
||||
statusBar.textContent = `Log file: ${logData.log_file}`;
|
||||
if (logData.truncated) {
|
||||
statusBar.textContent += ` (showing last ${logData.lines_shown} lines of ${this.formatFileSize(logData.file_size)})`;
|
||||
}
|
||||
|
||||
// Assemble modal
|
||||
content.appendChild(header);
|
||||
content.appendChild(logContainer);
|
||||
content.appendChild(statusBar);
|
||||
modal.appendChild(content);
|
||||
|
||||
// Close on background click
|
||||
modal.onclick = (e) => {
|
||||
if (e.target === modal) {
|
||||
extension.stopLogAutoRefresh();
|
||||
modal.remove();
|
||||
}
|
||||
};
|
||||
|
||||
// Close on Escape key
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
extension.stopLogAutoRefresh();
|
||||
modal.remove();
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Start auto-refresh
|
||||
extension.startLogAutoRefresh(workerId);
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
createWorkerSettingsForm(extension, worker) {
|
||||
const form = document.createElement("div");
|
||||
form.style.cssText = "display: flex; flex-direction: column; gap: 8px;";
|
||||
|
||||
// Name field
|
||||
const nameGroup = this.createFormGroup("Name:", worker.name, `name-${worker.id}`);
|
||||
form.appendChild(nameGroup.group);
|
||||
|
||||
// Remote checkbox
|
||||
const remoteGroup = document.createElement("div");
|
||||
remoteGroup.style.cssText = "display: flex; align-items: center; gap: 8px; margin: 5px 0;";
|
||||
|
||||
const remoteCheckbox = document.createElement("input");
|
||||
remoteCheckbox.type = "checkbox";
|
||||
remoteCheckbox.id = `remote-${worker.id}`;
|
||||
remoteCheckbox.checked = extension.isRemoteWorker(worker);
|
||||
remoteCheckbox.onchange = (e) => {
|
||||
const isRemote = e.target.checked;
|
||||
// Show/hide relevant fields
|
||||
const hostGroup = document.getElementById(`host-group-${worker.id}`);
|
||||
const cudaGroup = document.getElementById(`cuda-group-${worker.id}`);
|
||||
const argsGroup = document.getElementById(`args-group-${worker.id}`);
|
||||
|
||||
if (isRemote) {
|
||||
hostGroup.style.display = "flex";
|
||||
cudaGroup.style.display = "none";
|
||||
argsGroup.style.display = "none";
|
||||
// If switching to remote and host is localhost, clear it
|
||||
const hostInput = document.getElementById(`host-${worker.id}`);
|
||||
if (hostInput.value === "localhost" || hostInput.value === "127.0.0.1") {
|
||||
hostInput.value = "";
|
||||
}
|
||||
} else {
|
||||
hostGroup.style.display = "none";
|
||||
cudaGroup.style.display = "flex";
|
||||
argsGroup.style.display = "flex";
|
||||
}
|
||||
};
|
||||
|
||||
const remoteLabel = document.createElement("label");
|
||||
remoteLabel.htmlFor = `remote-${worker.id}`;
|
||||
remoteLabel.textContent = "Remote Worker";
|
||||
remoteLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;";
|
||||
|
||||
remoteGroup.appendChild(remoteCheckbox);
|
||||
remoteGroup.appendChild(remoteLabel);
|
||||
form.appendChild(remoteGroup);
|
||||
|
||||
// Host field (only for remote workers)
|
||||
const hostGroup = this.createFormGroup("Host:", worker.host || "", `host-${worker.id}`, "text", "e.g., 192.168.1.100");
|
||||
hostGroup.group.id = `host-group-${worker.id}`;
|
||||
hostGroup.group.style.display = extension.isRemoteWorker(worker) ? "flex" : "none";
|
||||
form.appendChild(hostGroup.group);
|
||||
|
||||
// Port field
|
||||
const portGroup = this.createFormGroup("Port:", worker.port, `port-${worker.id}`, "number");
|
||||
form.appendChild(portGroup.group);
|
||||
|
||||
// CUDA Device field (only for local workers)
|
||||
const cudaGroup = this.createFormGroup("CUDA Device:", worker.cuda_device || 0, `cuda-${worker.id}`, "number");
|
||||
cudaGroup.group.id = `cuda-group-${worker.id}`;
|
||||
cudaGroup.group.style.display = extension.isRemoteWorker(worker) ? "none" : "flex";
|
||||
form.appendChild(cudaGroup.group);
|
||||
|
||||
// Extra Args field (only for local workers)
|
||||
const argsGroup = this.createFormGroup("Extra Args:", worker.extra_args || "", `args-${worker.id}`);
|
||||
argsGroup.group.id = `args-group-${worker.id}`;
|
||||
argsGroup.group.style.display = extension.isRemoteWorker(worker) ? "none" : "flex";
|
||||
form.appendChild(argsGroup.group);
|
||||
|
||||
// Buttons
|
||||
const saveBtn = this.createButton("Save",
|
||||
() => extension.saveWorkerSettings(worker.id),
|
||||
"background-color: #4a7c4a;");
|
||||
saveBtn.style.cssText += " padding: 6px 14px; font-size: 12px; font-weight: 500;";
|
||||
|
||||
const cancelBtn = this.createButton("Cancel",
|
||||
() => extension.cancelWorkerSettings(worker.id),
|
||||
"background-color: #555;");
|
||||
cancelBtn.style.cssText += " padding: 6px 14px; font-size: 12px; font-weight: 500;";
|
||||
|
||||
const deleteBtn = this.createButton("Delete",
|
||||
() => extension.deleteWorker(worker.id),
|
||||
"background-color: #7c4a4a;");
|
||||
deleteBtn.style.cssText += " padding: 6px 14px; font-size: 12px; font-weight: 500; margin-left: auto;";
|
||||
|
||||
const buttonGroup = this.createButtonGroup([saveBtn, cancelBtn, deleteBtn], " margin-top: 8px;");
|
||||
form.appendChild(buttonGroup);
|
||||
|
||||
return form;
|
||||
}
|
||||
|
||||
createSettingsToggle() {
|
||||
const settingsRow = document.createElement("div");
|
||||
settingsRow.style.cssText = "display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; user-select: none;";
|
||||
|
||||
const settingsTitle = document.createElement("h4");
|
||||
settingsTitle.textContent = "Settings";
|
||||
settingsTitle.style.cssText = "margin: 0; font-size: 14px;";
|
||||
|
||||
const settingsToggle = document.createElement("span");
|
||||
settingsToggle.textContent = "▶"; // Right arrow when collapsed
|
||||
settingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;";
|
||||
|
||||
settingsRow.appendChild(settingsToggle);
|
||||
settingsRow.appendChild(settingsTitle);
|
||||
|
||||
return { settingsRow, settingsToggle };
|
||||
}
|
||||
|
||||
|
||||
createCheckboxOrIconColumn(config, data, extension) {
|
||||
const column = this.createCardColumn('checkbox');
|
||||
|
||||
if (config?.type === 'icon') {
|
||||
column.style.flex = `0 0 ${config.width || 44}px`;
|
||||
column.innerHTML = config.content || '+';
|
||||
if (config.style) {
|
||||
const styles = config.style.split(';').filter(s => s.trim());
|
||||
styles.forEach(style => {
|
||||
const [prop, value] = style.split(':').map(s => s.trim());
|
||||
if (prop && value) {
|
||||
column.style[prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase())] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.id = `gpu-${data?.id || 'master'}`;
|
||||
checkbox.checked = config?.checked !== undefined ? config.checked : data?.enabled;
|
||||
checkbox.disabled = config?.disabled || false;
|
||||
checkbox.style.cssText = `cursor: ${config?.disabled ? 'default' : 'pointer'}; width: 16px; height: 16px;`;
|
||||
|
||||
if (config?.opacity) checkbox.style.opacity = config.opacity;
|
||||
if (config?.title) column.title = config.title;
|
||||
|
||||
if (config?.enabled && !config?.disabled && data?.id) {
|
||||
checkbox.style.pointerEvents = "none";
|
||||
column.style.cursor = "pointer";
|
||||
column.onclick = async () => {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
await extension.updateWorkerEnabled(data.id, checkbox.checked);
|
||||
extension.updateSummary();
|
||||
};
|
||||
}
|
||||
|
||||
column.appendChild(checkbox);
|
||||
}
|
||||
|
||||
return column;
|
||||
}
|
||||
|
||||
createStatusDotHelper(config, data, extension) {
|
||||
let color = config.color || "#666";
|
||||
let title = config.title || "Status";
|
||||
let id = config.id;
|
||||
|
||||
if (typeof config.initialColor === 'function') {
|
||||
color = config.initialColor(data);
|
||||
}
|
||||
if (typeof config.initialTitle === 'function') {
|
||||
title = config.initialTitle(data);
|
||||
}
|
||||
if (typeof config.id === 'function') {
|
||||
id = config.id(data);
|
||||
}
|
||||
|
||||
const dot = this.createStatusDot(id, color, title);
|
||||
|
||||
if (config.border) {
|
||||
dot.style.border = config.border;
|
||||
}
|
||||
|
||||
if (config.pulsing && (typeof config.pulsing !== 'function' || config.pulsing(data))) {
|
||||
dot.classList.add('status-pulsing');
|
||||
}
|
||||
|
||||
return dot;
|
||||
}
|
||||
|
||||
createSettingsToggleHelper(expandedId, extension) {
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "settings-arrow";
|
||||
arrow.innerHTML = "▶";
|
||||
arrow.style.cssText = this.styles.settingsArrow;
|
||||
|
||||
const isExpanded = typeof expandedId === 'function' ?
|
||||
extension.state.isWorkerExpanded(expandedId(extension)) :
|
||||
(expandedId === 'master' ? false : extension.state.isWorkerExpanded(expandedId));
|
||||
|
||||
if (isExpanded) {
|
||||
arrow.style.transform = "rotate(90deg)";
|
||||
}
|
||||
|
||||
return arrow;
|
||||
}
|
||||
|
||||
createControlsSection(config, data, extension, isRemote) {
|
||||
if (!config) return null;
|
||||
|
||||
const controlsDiv = document.createElement("div");
|
||||
controlsDiv.id = `controls-${data?.id || 'master'}`;
|
||||
controlsDiv.style.cssText = this.styles.controlsDiv;
|
||||
|
||||
// Always create a wrapper div for consistent layout
|
||||
const controlsWrapper = document.createElement("div");
|
||||
controlsWrapper.style.cssText = "display: flex; gap: 6px; align-items: stretch; width: 100%;";
|
||||
|
||||
if (config.dynamic && data) {
|
||||
if (isRemote) {
|
||||
const remoteInfo = this.createButton("Remote worker", null, "background-color: #333;");
|
||||
remoteInfo.style.cssText += " flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; color: #999; cursor: default;";
|
||||
remoteInfo.disabled = true;
|
||||
controlsWrapper.appendChild(remoteInfo);
|
||||
} else {
|
||||
const controls = this.createWorkerControls(data.id, {
|
||||
launch: () => extension.launchWorker(data.id),
|
||||
stop: () => extension.stopWorker(data.id),
|
||||
viewLog: () => extension.viewWorkerLog(data.id)
|
||||
});
|
||||
|
||||
const launchBtn = controls.querySelector(`#launch-${data.id}`);
|
||||
const stopBtn = controls.querySelector(`#stop-${data.id}`);
|
||||
const logBtn = controls.querySelector(`#log-${data.id}`);
|
||||
|
||||
launchBtn.style.cssText += ` flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; ${BUTTON_STYLES.launch}`;
|
||||
launchBtn.title = "Launch worker (runs in background with logging)";
|
||||
|
||||
stopBtn.style.cssText += ` flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; ${BUTTON_STYLES.stop} display: none;`;
|
||||
stopBtn.title = "Stop worker";
|
||||
|
||||
logBtn.style.cssText += ' flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; display: none; background-color: #685434;';
|
||||
|
||||
while (controls.firstChild) {
|
||||
controlsWrapper.appendChild(controls.firstChild);
|
||||
}
|
||||
}
|
||||
} else if (config.type === 'info') {
|
||||
const infoBtn = this.createButton(config.text, null, config.style || "");
|
||||
infoBtn.style.cssText += " flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; cursor: default;";
|
||||
infoBtn.disabled = true;
|
||||
controlsWrapper.appendChild(infoBtn);
|
||||
} else if (config.type === 'ghost') {
|
||||
const ghostBtn = document.createElement("button");
|
||||
ghostBtn.style.cssText = `flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; border-radius: 4px; cursor: default; ${config.style || ""}`;
|
||||
ghostBtn.textContent = config.text;
|
||||
ghostBtn.disabled = true;
|
||||
controlsWrapper.appendChild(ghostBtn);
|
||||
}
|
||||
|
||||
controlsDiv.appendChild(controlsWrapper);
|
||||
return controlsDiv;
|
||||
}
|
||||
|
||||
createSettingsSection(config, data, extension) {
|
||||
const settingsDiv = document.createElement("div");
|
||||
const settingsId = typeof config.id === 'function' ? config.id(data) : config.id;
|
||||
settingsDiv.id = settingsId;
|
||||
settingsDiv.className = "worker-settings";
|
||||
|
||||
const expandedId = typeof config.expandedId === 'function' ? config.expandedId(data) : config.expandedId;
|
||||
const isExpanded = expandedId === 'master-settings' ? false : extension.state.isWorkerExpanded(expandedId);
|
||||
|
||||
settingsDiv.style.cssText = this.styles.workerSettings;
|
||||
|
||||
if (isExpanded) {
|
||||
settingsDiv.classList.add("expanded");
|
||||
settingsDiv.style.padding = "12px";
|
||||
settingsDiv.style.marginTop = "8px";
|
||||
settingsDiv.style.marginBottom = "8px";
|
||||
}
|
||||
|
||||
let settingsForm;
|
||||
if (config.formType === 'master') {
|
||||
settingsForm = this.createMasterSettingsForm(extension, data);
|
||||
} else if (config.formType === 'worker') {
|
||||
settingsForm = this.createWorkerSettingsForm(extension, data);
|
||||
}
|
||||
|
||||
if (settingsForm) {
|
||||
settingsDiv.appendChild(settingsForm);
|
||||
}
|
||||
|
||||
return settingsDiv;
|
||||
}
|
||||
|
||||
createMasterSettingsForm(extension, data) {
|
||||
const settingsForm = document.createElement("div");
|
||||
settingsForm.style.cssText = "display: flex; flex-direction: column; gap: 8px;";
|
||||
|
||||
const nameResult = this.createFormGroup("Name:", extension.config?.master?.name || "Master", "master-name");
|
||||
settingsForm.appendChild(nameResult.group);
|
||||
|
||||
const hostResult = this.createFormGroup("Host:", extension.config?.master?.host || "", "master-host", "text", "Auto-detect if empty");
|
||||
settingsForm.appendChild(hostResult.group);
|
||||
|
||||
const saveBtn = this.createButton("Save", async () => {
|
||||
const nameInput = document.getElementById('master-name');
|
||||
const hostInput = document.getElementById('master-host');
|
||||
|
||||
if (!extension.config.master) extension.config.master = {};
|
||||
extension.config.master.name = nameInput.value.trim() || "Master";
|
||||
|
||||
await extension.api.updateMaster({
|
||||
host: hostInput.value.trim(),
|
||||
name: extension.config.master.name
|
||||
});
|
||||
|
||||
// Reload config to refresh any updated values
|
||||
await extension.loadConfig();
|
||||
|
||||
document.getElementById('master-name-display').textContent = extension.config.master.name;
|
||||
this.updateMasterDisplay(extension);
|
||||
|
||||
saveBtn.textContent = "Saved!";
|
||||
setTimeout(() => { saveBtn.textContent = "Save"; }, 2000);
|
||||
}, "background-color: #4a7c4a;");
|
||||
saveBtn.style.cssText += " padding: 6px 12px; font-size: 12px;";
|
||||
|
||||
const cancelBtn = this.createButton("Cancel", () => {
|
||||
document.getElementById('master-name').value = extension.config?.master?.name || "Master";
|
||||
document.getElementById('master-host').value = extension.config?.master?.host || "";
|
||||
}, "background-color: #555;");
|
||||
cancelBtn.style.cssText += " padding: 6px 12px; font-size: 12px;";
|
||||
|
||||
const buttonGroup = this.createButtonGroup([saveBtn, cancelBtn], " margin-top: 8px;");
|
||||
settingsForm.appendChild(buttonGroup);
|
||||
|
||||
return settingsForm;
|
||||
}
|
||||
|
||||
addPlaceholderHover(card, leftColumn, entityType) {
|
||||
card.onmouseover = () => {
|
||||
if (entityType === 'blueprint') {
|
||||
card.style.borderColor = "#777";
|
||||
card.style.backgroundColor = "rgba(255, 255, 255, 0.05)";
|
||||
leftColumn.style.color = "#999";
|
||||
} else {
|
||||
card.style.borderColor = "#666";
|
||||
card.style.backgroundColor = "rgba(255, 255, 255, 0.02)";
|
||||
leftColumn.style.color = "#888";
|
||||
leftColumn.style.borderColor = "#666";
|
||||
}
|
||||
};
|
||||
|
||||
card.onmouseout = () => {
|
||||
if (entityType === 'blueprint') {
|
||||
card.style.borderColor = "#555";
|
||||
card.style.backgroundColor = "rgba(255, 255, 255, 0.02)";
|
||||
leftColumn.style.color = "#777";
|
||||
} else {
|
||||
card.style.borderColor = "#444";
|
||||
card.style.backgroundColor = "transparent";
|
||||
leftColumn.style.color = "#555";
|
||||
leftColumn.style.borderColor = "#444";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
renderEntityCard(entityType, data, extension) {
|
||||
const config = cardConfigs[entityType] || {};
|
||||
const isPlaceholder = entityType === 'blueprint' || entityType === 'add';
|
||||
const isWorker = entityType === 'worker';
|
||||
const isMaster = entityType === 'master';
|
||||
const isRemote = isWorker && extension.isRemoteWorker(data);
|
||||
|
||||
const cardOptions = {
|
||||
onClick: isPlaceholder ? data?.onClick : null
|
||||
};
|
||||
if (isPlaceholder) {
|
||||
cardOptions.title = entityType === 'blueprint' ? "Click to add your first worker" : "Click to add a new worker";
|
||||
}
|
||||
const card = this.createCard(entityType, cardOptions);
|
||||
|
||||
const leftColumn = this.createCheckboxOrIconColumn(config.checkbox, data, extension);
|
||||
card.appendChild(leftColumn);
|
||||
|
||||
const rightColumn = this.createCardColumn('content');
|
||||
|
||||
const infoRow = this.createInfoRow();
|
||||
if (config.infoRowPadding) {
|
||||
infoRow.style.padding = config.infoRowPadding;
|
||||
}
|
||||
if (config.minHeight === 'auto') {
|
||||
infoRow.style.minHeight = 'auto';
|
||||
} else if (config.minHeight) {
|
||||
infoRow.style.minHeight = config.minHeight;
|
||||
}
|
||||
if (config.expand) {
|
||||
infoRow.title = "Click to expand settings";
|
||||
infoRow.onclick = () => {
|
||||
if (isMaster) {
|
||||
const masterSettingsExpanded = !extension.masterSettingsExpanded;
|
||||
extension.masterSettingsExpanded = masterSettingsExpanded;
|
||||
const masterSettingsDiv = document.getElementById("master-settings");
|
||||
const arrow = infoRow.querySelector('.settings-arrow');
|
||||
if (masterSettingsExpanded) {
|
||||
masterSettingsDiv.classList.add("expanded");
|
||||
masterSettingsDiv.style.padding = "12px";
|
||||
masterSettingsDiv.style.marginTop = "8px";
|
||||
masterSettingsDiv.style.marginBottom = "8px";
|
||||
arrow.style.transform = "rotate(90deg)";
|
||||
} else {
|
||||
masterSettingsDiv.classList.remove("expanded");
|
||||
masterSettingsDiv.style.padding = "0 12px";
|
||||
masterSettingsDiv.style.marginTop = "0";
|
||||
masterSettingsDiv.style.marginBottom = "0";
|
||||
arrow.style.transform = "rotate(0deg)";
|
||||
}
|
||||
} else {
|
||||
extension.toggleWorkerExpanded(data.id);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const workerContent = this.createWorkerContent();
|
||||
if (entityType === 'add') {
|
||||
workerContent.style.alignItems = "center";
|
||||
}
|
||||
|
||||
const statusDot = this.createStatusDotHelper(config.statusDot, data, extension);
|
||||
workerContent.appendChild(statusDot);
|
||||
|
||||
const infoSpan = document.createElement("span");
|
||||
infoSpan.innerHTML = config.infoText(data, extension);
|
||||
workerContent.appendChild(infoSpan);
|
||||
|
||||
infoRow.appendChild(workerContent);
|
||||
|
||||
let settingsArrow;
|
||||
if (config.expand) {
|
||||
const expandedId = config.settings?.expandedId || (isMaster ? 'master' : data?.id);
|
||||
settingsArrow = this.createSettingsToggleHelper(expandedId, extension);
|
||||
if (isMaster && !extension.masterSettingsExpanded) {
|
||||
settingsArrow.style.transform = "rotate(0deg)";
|
||||
}
|
||||
infoRow.appendChild(settingsArrow);
|
||||
}
|
||||
|
||||
rightColumn.appendChild(infoRow);
|
||||
|
||||
if (config.hover === true) {
|
||||
rightColumn.onmouseover = () => {
|
||||
rightColumn.style.backgroundColor = "#333";
|
||||
if (settingsArrow) settingsArrow.style.color = "#fff";
|
||||
};
|
||||
rightColumn.onmouseout = () => {
|
||||
rightColumn.style.backgroundColor = "transparent";
|
||||
if (settingsArrow) settingsArrow.style.color = "#888";
|
||||
};
|
||||
}
|
||||
|
||||
const controlsDiv = this.createControlsSection(config.controls, data, extension, isRemote);
|
||||
if (controlsDiv) {
|
||||
rightColumn.appendChild(controlsDiv);
|
||||
}
|
||||
|
||||
if (config.settings) {
|
||||
const settingsDiv = this.createSettingsSection(config.settings, data, extension);
|
||||
rightColumn.appendChild(settingsDiv);
|
||||
}
|
||||
|
||||
card.appendChild(rightColumn);
|
||||
|
||||
if (config.hover === 'placeholder') {
|
||||
this.addPlaceholderHover(card, leftColumn, entityType);
|
||||
}
|
||||
|
||||
if (isWorker && !isRemote) {
|
||||
extension.updateWorkerControls(data.id);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { BUTTON_STYLES } from './constants.js';
|
||||
|
||||
export async function handleWorkerOperation(extension, button, operation, successText, errorText) {
|
||||
const originalText = button.textContent;
|
||||
const originalStyle = button.style.cssText;
|
||||
|
||||
button.textContent = operation.loadingText;
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
const urlsToProcess = extension.enabledWorkers.map(w => ({
|
||||
name: w.name,
|
||||
url: extension.getWorkerUrl(w)
|
||||
}));
|
||||
|
||||
if (urlsToProcess.length === 0) {
|
||||
button.textContent = "No Workers";
|
||||
button.style.backgroundColor = "#c04c4c";
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.cssText = originalStyle;
|
||||
button.disabled = false;
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = urlsToProcess.map(target =>
|
||||
fetch(`${target.url}${operation.endpoint}`, {
|
||||
method: 'POST',
|
||||
mode: 'cors'
|
||||
})
|
||||
.then(response => ({ ok: response.ok, name: target.name }))
|
||||
.catch(() => ({ ok: false, name: target.name }))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const failures = results.filter(r => !r.ok);
|
||||
|
||||
if (failures.length === 0) {
|
||||
button.textContent = successText;
|
||||
button.style.backgroundColor = BUTTON_STYLES.success.split(':')[1].trim().replace(';', '');
|
||||
if (operation.onSuccess) operation.onSuccess();
|
||||
} else {
|
||||
button.textContent = errorText;
|
||||
button.style.backgroundColor = BUTTON_STYLES.error.split(':')[1].trim().replace(';', '');
|
||||
extension.log(`${operation.name} failed on: ${failures.map(f => f.name).join(", ")}`, "error");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.cssText = originalStyle;
|
||||
}, 3000);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleInterruptWorkers(extension, button) {
|
||||
return handleWorkerOperation(extension, button, {
|
||||
name: "Interrupt",
|
||||
endpoint: "/interrupt",
|
||||
loadingText: "Interrupting...",
|
||||
onSuccess: () => setTimeout(() => extension.checkAllWorkerStatuses(), 500)
|
||||
}, "Interrupted!", "Error! See Console");
|
||||
}
|
||||
|
||||
export async function handleClearMemory(extension, button) {
|
||||
return handleWorkerOperation(extension, button, {
|
||||
name: "Clear memory",
|
||||
endpoint: "/distributed/clear_memory",
|
||||
loadingText: "Clearing..."
|
||||
}, "Success!", "Error! See Console");
|
||||
}
|
||||
|
||||
export function findNodesByClass(apiPrompt, className) {
|
||||
return Object.entries(apiPrompt)
|
||||
.filter(([, nodeData]) => nodeData.class_type === className)
|
||||
.map(([nodeId, nodeData]) => ({ id: nodeId, data: nodeData }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all image references in the workflow
|
||||
* Looks for inputs named "image" that contain filename strings
|
||||
*/
|
||||
export function findImageReferences(extension, apiPrompt) {
|
||||
const images = new Map();
|
||||
// Updated regex to handle:
|
||||
// - Standard files: "image.png"
|
||||
// - Subfolder files: "subfolder/image.png"
|
||||
// - ComfyUI special format: "clipspace/file.png [input]"
|
||||
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|bmp)(\s*\[\w+\])?$/i;
|
||||
|
||||
for (const [nodeId, node] of Object.entries(apiPrompt)) {
|
||||
if (node.inputs && node.inputs.image) {
|
||||
const imageValue = node.inputs.image;
|
||||
if (typeof imageValue === 'string') {
|
||||
// Clean special suffixes like [input] or [output]
|
||||
const cleanValue = imageValue.replace(/\s*\[\w+\]$/, '').trim();
|
||||
if (imageExtensions.test(cleanValue)) {
|
||||
images.set(cleanValue, {
|
||||
nodeId,
|
||||
nodeType: node.class_type,
|
||||
inputName: 'image'
|
||||
});
|
||||
extension.log(`Found image reference: ${cleanValue} in node ${nodeId} (${node.class_type})`, "debug");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find only upstream nodes (inputs) for distributed collector nodes
|
||||
* This is used for workers to avoid executing downstream nodes like SaveImage
|
||||
* @param {Object} apiPrompt - The API prompt containing the workflow
|
||||
* @param {Array<string>} collectorIds - Array of collector node IDs
|
||||
* @returns {Set<string>} Set of node IDs that feed into collectors
|
||||
*/
|
||||
export function findCollectorUpstreamNodes(apiPrompt, collectorIds) {
|
||||
const connected = new Set(collectorIds); // Include all collectors
|
||||
const toProcess = [...collectorIds];
|
||||
|
||||
// Only traverse upstream (inputs)
|
||||
while (toProcess.length > 0) {
|
||||
const nodeId = toProcess.pop();
|
||||
const node = apiPrompt[nodeId];
|
||||
|
||||
// Traverse upstream (inputs) only
|
||||
if (node && node.inputs) {
|
||||
for (const [inputName, inputValue] of Object.entries(node.inputs)) {
|
||||
if (Array.isArray(inputValue) && inputValue.length === 2) {
|
||||
const sourceNodeId = String(inputValue[0]);
|
||||
if (!connected.has(sourceNodeId)) {
|
||||
connected.add(sourceNodeId);
|
||||
toProcess.push(sourceNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune workflow to only include nodes connected to distributed nodes
|
||||
* @param {Object} apiPrompt - The full workflow API prompt
|
||||
* @param {Array} distributedNodes - Array of distributed nodes (optional, will find if not provided)
|
||||
* @returns {Object} Pruned API prompt with only required nodes
|
||||
*/
|
||||
export function pruneWorkflowForWorker(extension, apiPrompt, distributedNodes = null) {
|
||||
// Find all distributed nodes if not provided
|
||||
if (!distributedNodes) {
|
||||
const collectorNodes = findNodesByClass(apiPrompt, "DistributedCollector");
|
||||
const upscaleNodes = findNodesByClass(apiPrompt, "UltimateSDUpscaleDistributed");
|
||||
distributedNodes = [...collectorNodes, ...upscaleNodes];
|
||||
}
|
||||
|
||||
if (distributedNodes.length === 0) {
|
||||
// No distributed nodes, return full workflow
|
||||
return apiPrompt;
|
||||
}
|
||||
|
||||
// Get all nodes connected to distributed nodes
|
||||
const distributedIds = distributedNodes.map(node => node.id);
|
||||
|
||||
// For workers, only include upstream nodes (this removes ALL downstream nodes after collectors)
|
||||
const connectedNodes = findCollectorUpstreamNodes(apiPrompt, distributedIds);
|
||||
|
||||
extension.log(`Pruning workflow: keeping ${connectedNodes.size} of ${Object.keys(apiPrompt).length} nodes (removed all downstream nodes)`, "debug");
|
||||
|
||||
// Create pruned prompt with only required nodes
|
||||
const prunedPrompt = {};
|
||||
for (const nodeId of connectedNodes) {
|
||||
prunedPrompt[nodeId] = JSON.parse(JSON.stringify(apiPrompt[nodeId]));
|
||||
}
|
||||
|
||||
// Check if any distributed node has downstream SaveImage nodes that were removed
|
||||
// If so, add a PreviewImage node after the collector
|
||||
for (const distNode of distributedNodes) {
|
||||
const distNodeId = distNode.id;
|
||||
|
||||
// Check if this distributed node had any downstream nodes in the original workflow
|
||||
const originalOutputMap = new Map();
|
||||
for (const [nodeId, node] of Object.entries(apiPrompt)) {
|
||||
if (node.inputs) {
|
||||
for (const [inputName, inputValue] of Object.entries(node.inputs)) {
|
||||
if (Array.isArray(inputValue) && inputValue.length === 2 && String(inputValue[0]) === distNodeId) {
|
||||
if (!originalOutputMap.has(distNodeId)) {
|
||||
originalOutputMap.set(distNodeId, []);
|
||||
}
|
||||
originalOutputMap.get(distNodeId).push({nodeId, inputName});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this distributed node had downstream nodes that were removed, add a PreviewImage
|
||||
if (originalOutputMap.has(distNodeId) && originalOutputMap.get(distNodeId).length > 0) {
|
||||
// Generate unique numeric ID: max existing numeric key +1
|
||||
const existingIds = Object.keys(prunedPrompt)
|
||||
.filter(k => !isNaN(parseInt(k)))
|
||||
.map(k => parseInt(k));
|
||||
const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 0;
|
||||
const previewNodeId = String(maxId + 1);
|
||||
|
||||
// Add PreviewImage node connected to the distributed node
|
||||
prunedPrompt[previewNodeId] = {
|
||||
inputs: {
|
||||
images: [distNodeId, 0] // Connect to first output of distributed node
|
||||
},
|
||||
class_type: "PreviewImage",
|
||||
_meta: {
|
||||
title: "Preview Image (auto-added)"
|
||||
}
|
||||
};
|
||||
|
||||
extension.log(`Added PreviewImage node ${previewNodeId} after distributed node ${distNodeId} for worker`, "debug");
|
||||
}
|
||||
}
|
||||
|
||||
return prunedPrompt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node has an upstream node of a specific type
|
||||
* @param {Object} apiPrompt - The workflow API prompt
|
||||
* @param {string} nodeId - The node to check
|
||||
* @param {string} upstreamType - The class_type to look for upstream
|
||||
* @returns {boolean} True if an upstream node of the specified type exists
|
||||
*/
|
||||
export function hasUpstreamNode(apiPrompt, nodeId, upstreamType) {
|
||||
const visited = new Set();
|
||||
const toProcess = [nodeId];
|
||||
|
||||
while (toProcess.length > 0) {
|
||||
const currentId = toProcess.pop();
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const node = apiPrompt[currentId];
|
||||
if (!node) continue;
|
||||
|
||||
// Check inputs for upstream connections
|
||||
if (node.inputs) {
|
||||
for (const [inputName, inputValue] of Object.entries(node.inputs)) {
|
||||
if (Array.isArray(inputValue) && inputValue.length === 2) {
|
||||
const sourceNodeId = String(inputValue[0]);
|
||||
const sourceNode = apiPrompt[sourceNodeId];
|
||||
|
||||
if (sourceNode && sourceNode.class_type === upstreamType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!visited.has(sourceNodeId)) {
|
||||
toProcess.push(sourceNodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user