Files
co5dt-ComfyUI-Persistent-Queue/web/pqueue.js
T

624 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Minimal frontend extension for ComfyUI Persistent Queue
// Loaded automatically from WEB_DIRECTORY by ComfyUI and served at /extensions/<name>/pqueue.js
(function() {
const API = {
getQueue: () => fetch('/api/pqueue').then(r => r.json()),
getHistory: (limit=50) => fetch(`/api/pqueue/history?limit=${limit}`).then(r => r.json()),
pause: () => fetch('/api/pqueue/pause', { method: 'POST' }),
resume: () => fetch('/api/pqueue/resume', { method: 'POST' }),
reorder: (order) => fetch('/api/pqueue/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order }) }),
setPriority: (prompt_id, priority) => fetch('/api/pqueue/priority', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt_id, priority }) }),
del: (prompt_ids) => fetch('/api/pqueue/delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt_ids }) }),
};
const state = {
paused: false,
queue_running: [],
queue_pending: [],
db_pending: [],
history: [],
running_progress: {},
container: null,
listPending: null,
selectedPending: new Set(),
};
const Progress = {
computeAggregateProgress(nodes) {
let totalMax = 0;
let totalVal = 0;
Object.values(nodes ?? {}).forEach((st) => {
const mv = Math.max(1, Number(st?.max ?? 1));
const vv = Math.max(0, Math.min(Number(st?.value ?? 0), mv));
totalMax += mv;
totalVal += vv;
});
return totalMax > 0 ? Math.max(0, Math.min(1, totalVal / totalMax)) : 0;
},
};
const UI = {
el(tag, attrs = {}, children = []) {
const e = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === 'class') {
e.className = v;
} else if (k === 'text') {
e.textContent = v;
} else {
e.setAttribute(k, v);
}
}
for (const c of children) {
e.appendChild(c);
}
return e;
},
createButton({ id, text, icon, classes, onClick }) {
const button = UI.el('button', { id, class: `p-button p-component ${classes}` });
if (icon) {
button.appendChild(UI.el('span', { class: `p-button-icon pi ${icon}` }));
}
if (text) {
button.appendChild(UI.el('span', { class: 'p-button-label', text }));
}
if (onClick) {
button.onclick = onClick;
}
return button;
},
createCard(title, content) {
return UI.el('div', { class: 'p-card pqueue-card' }, [
UI.el('div', { class: 'p-card-body' }, [
UI.el('div', { class: 'p-card-title', text: title }),
content,
]),
]);
},
updateProgressBarsFromState() {
if (!state.container) return;
Object.entries(state.running_progress).forEach(([pid, f]) => {
const bar = state.container.querySelector(`.pqueue-item[data-id="${pid}"] .pqueue-progress-bar`);
if (bar) bar.style.width = `${(Number(f) * 100).toFixed(2)}%`;
});
},
ensureThemeLink() {
if (!document.getElementById('pqueue-style-link')) {
const link = document.createElement('link');
link.id = 'pqueue-style-link';
link.rel = 'stylesheet';
link.href = '/extensions/ComfyUI-PersistentQueue/css/queue_style.css';
document.head.appendChild(link);
}
},
renderToolbar() {
const pauseBtn = UI.createButton({
id: 'pqueue-toggle',
text: state.paused ? 'Resume' : 'Pause',
icon: state.paused ? 'pi-play' : 'pi-pause',
classes: state.paused ? 'p-button-success' : 'p-button-warning',
});
const refreshBtn = UI.createButton({
id: 'pqueue-refresh',
text: 'Refresh',
icon: 'pi-refresh',
classes: 'p-button-text',
});
const clearBtn = UI.createButton({
id: 'pqueue-clear',
text: 'Clear Pending',
icon: 'pi-stop',
classes: 'p-button-text p-button-danger',
});
return UI.el('div', { class: 'pqueue-toolbar' }, [pauseBtn, refreshBtn, clearBtn]);
},
renderRunningSection() {
const runningList = UI.el('ul', { class: 'pqueue-list' });
if (!state.queue_running.length) {
runningList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No running job' }));
} else {
state.queue_running.forEach(item => {
const pid = item[1];
const frac = Math.max(0, Math.min(1, Number(state.running_progress?.[pid]) || 0));
const progressBar = UI.el('div', { class: 'pqueue-progress-bar' });
progressBar.style.width = `${(frac * 100).toFixed(2)}%`;
const li = UI.el('li', { class: 'pqueue-item', 'data-id': pid }, [
UI.el('div', { class: 'pqueue-left' }, [
UI.el('div', { class: 'pqueue-meta' }, [
UI.el('i', { class: 'pi pi-spin pi-spinner' }),
UI.el('span', { text: pid })
]),
UI.el('div', { class: 'pqueue-progress-wrap' }, [progressBar]),
]),
UI.el('div', { class: 'pqueue-actions' }, [])
]);
runningList.appendChild(li);
});
}
return UI.createCard('Running', runningList);
},
renderPendingSection() {
const pendingList = UI.el('ul', { id: 'pqueue-pending', class: 'pqueue-list' });
if (!state.queue_pending.length) {
pendingList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No pending items' }));
} else {
state.queue_pending.forEach(item => {
const checked = state.selectedPending.has(item[1]);
const checkbox = UI.el('input', { type: 'checkbox', class: 'p-checkbox-input pqueue-select' });
if (checked) checkbox.checked = true;
const priorityInput = UI.el('input', { type: 'number', value: '0', class: 'p-inputtext pqueue-priority', min: '0', style: 'width:5rem' });
const savePriorityBtn = UI.createButton({
icon: 'pi-sort-amount-up-alt',
classes: 'p-button-text pqueue-priority-save',
});
const deleteBtn = UI.createButton({
icon: 'pi-trash',
classes: 'p-button-text p-button-danger pqueue-delete',
});
const li = UI.el('li', { draggable: 'true', 'data-id': item[1], class: 'pqueue-item' }, [
UI.el('div', { class: 'pqueue-meta' }, [
UI.el('i', { class: 'pi pi-bars', style: 'cursor:grab' }),
checkbox,
UI.el('span', { text: item[1] })
]),
UI.el('div', { class: 'pqueue-actions' }, [
priorityInput,
savePriorityBtn,
deleteBtn,
])
]);
pendingList.appendChild(li);
});
}
return UI.createCard(`Pending (${state.queue_pending.length})`, pendingList);
},
renderHistorySection() {
const historyList = UI.el('ul', { class: 'pqueue-list' });
if (!state.history.length) {
historyList.appendChild(UI.el('li', { class: 'pqueue-empty', text: 'No history' }));
} else {
state.history.forEach(row => {
const ok = (row.status ?? 'success') === 'success';
const thumbs = UI.buildResultThumbs(row);
const time = UI.buildDuration(row);
const li = UI.el('li', { class: 'pqueue-item' }, [
UI.el('div', { class: 'pqueue-meta' }, [
UI.el('i', { class: `pi ${ok ? 'pi-check pqueue-status-success' : 'pi-times pqueue-status-error'}` }),
(time ? UI.el('span', { class: 'task-time', text: time }) : UI.el('span', {}))
]),
UI.el('div', { class: 'pqueue-thumbs-grid' }, thumbs.children.length ? Array.from(thumbs.children) : [])
]);
historyList.appendChild(li);
});
}
return UI.createCard('History', historyList);
},
render() {
if (!state.container) return;
UI.ensureThemeLink();
state.container.innerHTML = '';
const root = UI.el('div', { class: 'pqueue-root' });
const toolbar = UI.renderToolbar();
const sections = UI.el('div', { class: 'pqueue-sections' });
sections.appendChild(UI.renderRunningSection());
sections.appendChild(UI.el('div', { class: 'pqueue-divider' }));
sections.appendChild(UI.renderPendingSection());
sections.appendChild(UI.el('div', { class: 'pqueue-divider' }));
sections.appendChild(UI.renderHistorySection());
root.appendChild(toolbar);
root.appendChild(sections);
state.container.appendChild(root);
Events.init();
UI.updateProgressBarsFromState();
},
buildResultThumbs(row) {
try {
const container = UI.el('div', { class: 'flex gap-1 flex-wrap' });
const images = UI.extractImages(row);
if (!images.length) return container;
const first = images[0];
const url = new URL('/view', window.location.origin);
url.searchParams.set('filename', first.filename);
url.searchParams.set('type', first.type);
if (first.subfolder) url.searchParams.set('subfolder', first.subfolder);
url.searchParams.set('preview', 'webp;50');
const thumbWrap = UI.el('div', { class: 'pqueue-thumb-wrap' });
const img = UI.el('img', { src: url.href, class: 'pqueue-thumb', title: first.filename });
thumbWrap.appendChild(img);
if (images.length > 1) {
const badge = UI.el('div', { class: 'pqueue-batch-badge', text: `${images.length}` });
thumbWrap.appendChild(badge);
}
thumbWrap.onclick = () => UI.openGallery(images, 0);
container.appendChild(thumbWrap);
return container;
} catch(e) { return UI.el('span', {}); }
},
// Collect normalized image descriptors for a row
extractImages(row) {
try {
let outputs = row.outputs ?? {};
if (typeof outputs === 'string') {
try { outputs = JSON.parse(outputs); } catch(e) { outputs = {}; }
}
const images = [];
Object.values(outputs).forEach(v => {
if (v && typeof v === 'object') {
const imgs = v.images ?? v.ui?.images ?? [];
if (Array.isArray(imgs)) imgs.forEach(i => {
const filename = i.filename ?? i.name;
if (filename) images.push({ filename, type: i.type ?? 'output', subfolder: i.subfolder ?? '' });
});
} else if (Array.isArray(v)) {
v.forEach(i => {
const filename = i?.filename ?? i?.name;
if (filename) images.push({ filename, type: i.type ?? 'output', subfolder: i.subfolder ?? '' });
});
}
});
return images;
} catch(e) { return []; }
},
buildDuration(row) {
try {
const dur = Number(row.duration_seconds);
if (!Number.isNaN(dur) && dur > 0) {
return `${dur.toFixed(2)}s`;
}
const started = row.created_at ?? row.started_at;
const completed = row.completed_at;
if (!started || !completed) return '';
const t1 = new Date(started).getTime();
const t2 = new Date(completed).getTime();
if (!isFinite(t1) || !isFinite(t2)) return '';
const sec = Math.max(0, (t2 - t1) / 1000);
return `${sec.toFixed(2)}s`;
} catch(e) { return ''; }
},
// Lightbox-style gallery for image batches
openGallery(images, startIndex = 0) {
try {
UI.ensureGalleryElements();
UI._galleryState.images = images || [];
UI._galleryState.index = Math.max(0, Math.min(startIndex, UI._galleryState.images.length - 1));
UI._galleryOverlay.style.display = 'flex';
UI.showGalleryImage(UI._galleryState.index);
UI._bindGalleryKeys(true);
} catch (e) { /* noop */ }
},
closeGallery() {
if (UI._galleryOverlay) UI._galleryOverlay.style.display = 'none';
UI._bindGalleryKeys(false);
},
showGalleryImage(index) {
const imgs = UI._galleryState.images || [];
if (!imgs.length) return;
UI._galleryState.index = (index + imgs.length) % imgs.length;
const imgDesc = imgs[UI._galleryState.index];
const url = new URL('/view', window.location.origin);
url.searchParams.set('filename', imgDesc.filename);
url.searchParams.set('type', imgDesc.type);
if (imgDesc.subfolder) url.searchParams.set('subfolder', imgDesc.subfolder);
UI._galleryImg.src = url.href;
UI._galleryCounter.textContent = `${UI._galleryState.index + 1} / ${imgs.length}`;
},
nextImage() { UI.showGalleryImage(UI._galleryState.index + 1); },
prevImage() { UI.showGalleryImage(UI._galleryState.index - 1); },
_bindGalleryKeys(enabled) {
const handler = (e) => {
if (UI._galleryOverlay?.style.display !== 'flex') return;
if (e.key === 'Escape') UI.closeGallery();
else if (e.key === 'ArrowRight') UI.nextImage();
else if (e.key === 'ArrowLeft') UI.prevImage();
};
if (enabled) {
UI._galleryKeyHandler = handler;
document.addEventListener('keydown', handler);
} else if (UI._galleryKeyHandler) {
document.removeEventListener('keydown', UI._galleryKeyHandler);
UI._galleryKeyHandler = null;
}
},
ensureGalleryElements() {
if (UI._galleryOverlay) return;
UI._galleryState = { images: [], index: 0 };
const overlay = UI.el('div', { class: 'pqueue-gallery-overlay' });
const box = UI.el('div', { class: 'pqueue-gallery' });
const closeBtn = UI.el('button', { class: 'pqueue-gallery-close', title: 'Close' });
closeBtn.textContent = '×';
const img = UI.el('img', { class: 'pqueue-gallery-img' });
const counter = UI.el('div', { class: 'pqueue-gallery-counter' });
const prev = UI.el('button', { class: 'pqueue-gallery-nav pqueue-gallery-prev', title: 'Previous' });
prev.textContent = '‹';
const next = UI.el('button', { class: 'pqueue-gallery-nav pqueue-gallery-next', title: 'Next' });
next.textContent = '›';
closeBtn.onclick = () => UI.closeGallery();
prev.onclick = () => UI.prevImage();
next.onclick = () => UI.nextImage();
overlay.onclick = (e) => { if (e.target === overlay) UI.closeGallery(); };
box.appendChild(closeBtn);
box.appendChild(img);
box.appendChild(counter);
box.appendChild(prev);
box.appendChild(next);
overlay.appendChild(box);
document.body.appendChild(overlay);
UI._galleryOverlay = overlay;
UI._galleryImg = img;
UI._galleryCounter = counter;
},
ensureStyles() {
if (document.getElementById('pqueue-styles')) return;
const style = document.createElement('style');
style.id = 'pqueue-styles';
style.textContent = `
#pqueue-fab{position:fixed;right:16px;bottom:16px;z-index:99999;background:#2d2f34;color:#fff;border:none;border-radius:20px;padding:8px 12px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,0.35)}
#pqueue-panel{position:fixed;right:16px;top:64px;z-index:99998;width:420px;max-width:90vw;height:65vh;background:#1c1d20;color:#ddd;border:1px solid #333;border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,0.5);display:none;flex-direction:column}
#pqueue-panel header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #333}
#pqueue-panel .pqueue-content{padding:8px 12px;overflow:auto;height:100%}
#pqueue-panel ul{list-style:none;margin:0;padding:0}
#pqueue-panel li{display:flex;gap:8px;align-items:center;padding:6px 4px;border-bottom:1px dashed #2c2c2c}
#pqueue-panel .pqueue-item{cursor:grab}
#pqueue-panel .pqueue-section{margin-bottom:10px}
`;
document.head.appendChild(style);
},
mount() {
if (state.container) return;
UI.ensureStyles();
// Floating button
const fab = document.createElement('button');
fab.id = 'pqueue-fab';
fab.textContent = 'Persistent Queue';
fab.onclick = () => {
const panel = document.getElementById('pqueue-panel');
if (!panel) return;
const visible = panel.style.display !== 'none';
panel.style.display = visible ? 'none' : 'flex';
if (!visible) refresh();
};
document.body.appendChild(fab);
const panel = UI.el('div', { id: 'pqueue-panel', class: 'pqueue-panel' });
const header = document.createElement('header');
const title = document.createElement('div');
title.textContent = 'Persistent Queue';
const close = document.createElement('button');
close.textContent = '×';
close.onclick = () => { panel.style.display = 'none'; };
header.appendChild(title);
header.appendChild(close);
panel.appendChild(header);
state.container = UI.el('div', { class: 'pqueue-content' });
panel.appendChild(state.container);
document.body.appendChild(panel);
refresh();
}
};
const Events = {
async onPauseClick() {
if (state.paused) await API.resume(); else await API.pause();
await refresh();
},
async onClearClick() {
const ids = Array.from(document.querySelectorAll('#pqueue-pending li[data-id]')).map(li => li.dataset.id);
if (ids.length) {
await API.del(ids);
await refresh();
}
},
onPendingListItemCheckboxChange(e) {
const li = e.target.closest('li');
const id = li.dataset.id;
if (e.target.checked) state.selectedPending.add(id); else state.selectedPending.delete(id);
},
async onPendingListItemPrioritySaveClick(e) {
const li = e.target.closest('li');
const id = li.dataset.id;
const val = parseInt(li.querySelector('.pqueue-priority').value || '0', 10);
await API.setPriority(id, val);
await refresh();
},
async onPendingListItemDeleteClick(e) {
const li = e.target.closest('li');
const id = li.dataset.id;
await API.del([id]);
await refresh();
},
init() {
document.getElementById('pqueue-toggle').onclick = Events.onPauseClick;
document.getElementById('pqueue-refresh').onclick = refresh;
const clearBtn = document.getElementById('pqueue-clear');
if (clearBtn) {
clearBtn.onclick = Events.onClearClick;
}
const list = document.getElementById('pqueue-pending');
if (!list) return;
let dragged;
list.addEventListener('dragstart', (e) => {
const li = e.target.closest('li');
dragged = li;
e.dataTransfer.setData('text/plain', li.dataset.id);
});
list.addEventListener('dragover', (e) => e.preventDefault());
list.addEventListener('drop', async (e) => {
e.preventDefault();
const target = e.target.closest('li');
if (!dragged || !target || dragged === target) return;
const rect = target.getBoundingClientRect();
const before = (e.clientY - rect.top) < rect.height / 2;
if (before) list.insertBefore(dragged, target); else list.insertBefore(dragged, target.nextSibling);
const order = Array.from(list.querySelectorAll('li')).map(li => li.dataset.id);
await API.reorder(order);
await refresh();
});
list.addEventListener('change', (e) => {
if (e.target.classList.contains('pqueue-select')) {
Events.onPendingListItemCheckboxChange(e);
}
});
list.addEventListener('click', (e) => {
if (e.target.closest('.pqueue-priority-save')) {
Events.onPendingListItemPrioritySaveClick(e);
} else if (e.target.closest('.pqueue-delete')) {
Events.onPendingListItemDeleteClick(e);
}
});
}
};
async function refresh() {
const q = await API.getQueue();
state.paused = !!q.paused;
state.queue_running = q.queue_running || [];
state.queue_pending = q.queue_pending || [];
state.db_pending = q.db_pending || [];
state.running_progress = q.running_progress || {};
const h = await API.getHistory(50);
state.history = h.history || [];
UI.render();
}
function setupSocketListeners() {
try {
const api = window.app?.api;
if (!api || typeof api.addEventListener !== 'function') {
console.warn('pqueue: WebSocket API not found, falling back to polling.');
return false;
}
const onProgressState = (ev) => {
try {
const payload = ev?.detail;
if (!payload?.prompt_id || !payload.nodes) return;
state.running_progress[payload.prompt_id] = Progress.computeAggregateProgress(payload.nodes);
UI.updateProgressBarsFromState();
} catch (e) { /* ignore */ }
};
const onQueueOrExecChange = () => refresh();
api.addEventListener('progress_state', onProgressState);
api.addEventListener('status', onQueueOrExecChange);
api.addEventListener('executing', onQueueOrExecChange);
api.addEventListener('executed', onQueueOrExecChange);
api.addEventListener('execution_start', onQueueOrExecChange);
api.addEventListener('execution_success', onQueueOrExecChange);
api.addEventListener('execution_error', onQueueOrExecChange);
api.addEventListener('execution_interrupted', onQueueOrExecChange);
return true;
} catch (e) {
console.error('pqueue: Error setting up WebSocket listeners.', e);
return false;
}
}
function setupFallbackPolling() {
window.addEventListener('focus', refresh);
setInterval(refresh, 3000);
}
function initialize() {
const renderAndSetup = (el) => {
state.container = el;
UI.render();
if (!setupSocketListeners()) {
setupFallbackPolling();
}
};
const extension = {
name: 'ComfyUI-PersistentQueue',
sidebarTabs: [
{
id: 'persistent_queue',
icon: 'pi pi-history',
title: 'Persistent Queue',
tooltip: 'Persistent Queue',
type: 'custom',
render: renderAndSetup
}
],
async setup(app) {
// Fallback for older versions that don't recognize sidebarTabs
if (!state.container) {
console.warn('pqueue: sidebarTabs not recognized, falling back to manual registration.');
if (app?.extensionManager?.registerSidebarTab) {
app.extensionManager.registerSidebarTab(this.sidebarTabs[0]);
} else {
console.warn('pqueue: No sidebar registration method found, falling back to floating panel.');
UI.mount();
renderAndSetup(state.container);
}
}
}
};
if (window.app?.registerExtension) {
window.app.registerExtension(extension);
} else {
// Fallback for very old versions or standalone execution
const onReady = () => {
if (window.app?.extensionManager?.registerSidebarTab) {
window.app.extensionManager.registerSidebarTab(extension.sidebarTabs[0]);
} else {
UI.mount();
renderAndSetup(state.container);
}
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onReady);
} else {
onReady();
}
}
}
initialize();
})();