import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { queueManager } from "./queue_utils.js"; class GroupExecutorUI { static DOCK_MARGIN_X = 0; static DOCK_MARGIN_Y = 60; constructor() { this.container = null; this.isExecuting = false; this.isCancelling = false; this.groups = []; this.position = { x: 0, y: 0 }; this.isDragging = false; this.dragOffset = { x: 0, y: 0 }; this.DOCK_MARGIN_X = GroupExecutorUI.DOCK_MARGIN_X; this.DOCK_MARGIN_Y = GroupExecutorUI.DOCK_MARGIN_Y; this.createUI(); this.attachEvents(); this.container.instance = this; } createUI() { this.container = document.createElement('div'); this.container.className = 'group-executor-ui'; this.container.style.top = `${this.DOCK_MARGIN_Y}px`; this.container.style.right = `${this.DOCK_MARGIN_X}px`; this.container.innerHTML = `
组执行管理器
`; const style = document.createElement('style'); style.textContent = ` .group-executor-ui { position: fixed; top: 20px; right: 20px; width: 300px !important; min-width: 300px; max-width: 300px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 1000; font-family: Arial, sans-serif; color: #fff; user-select: none; } .ge-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 12px; background: #333; border-radius: 8px 8px 0 0; cursor: move; width: 100%; box-sizing: border-box; } .ge-controls button { background: none; border: none; color: #fff; margin-left: 8px; cursor: pointer; font-size: 16px; } .ge-content { padding: 12px; display: flex; flex-direction: column; max-height: calc(100vh - 100px); } .ge-row { display: flex; align-items: center; margin-bottom: 12px; } .ge-row label { flex: 1; margin-right: 12px; } .ge-row input { width: 100px; padding: 4px 8px; background: #333; border: 1px solid #444; color: #fff; border-radius: 4px; } .ge-groups-container, .ge-groups-list { max-height: calc(50vh - 180px); overflow-y: auto; margin-bottom: 12px; padding-right: 8px; } .ge-groups-container::-webkit-scrollbar, .ge-groups-list::-webkit-scrollbar { width: 6px; } .ge-groups-container::-webkit-scrollbar-track, .ge-groups-list::-webkit-scrollbar-track { background: #2a2a2a; border-radius: 3px; } .ge-groups-container::-webkit-scrollbar-thumb, .ge-groups-list::-webkit-scrollbar-thumb { background: #555; border-radius: 3px; } .ge-groups-container::-webkit-scrollbar-thumb:hover, .ge-groups-list::-webkit-scrollbar-thumb:hover { background: #666; } .ge-group-select { width: 100%; margin-bottom: 8px; padding: 4px 8px; background: #333; border: 1px solid #444; color: #fff; border-radius: 4px; } .ge-group-select:last-child { margin-bottom: 0; } .ge-group-item { display: flex; align-items: center; justify-content: space-between; padding: 10px; margin-bottom: 8px; background: #333; border-radius: 4px; } .ge-group-item:last-child { margin-bottom: 0; } .ge-group-name { flex: 1; margin-right: 8px; } .ge-group-controls { display: flex; gap: 10px; margin-left: auto; } .ge-buttons { display: flex; gap: 8px; } .ge-buttons button { flex: 1; padding: 8px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; } .ge-execute-btn { background: #4CAF50; color: white; } .ge-execute-btn:disabled { background: #2a5a2d; cursor: not-allowed; } .ge-cancel-btn { background: #f44336; color: white; } .ge-cancel-btn:disabled { background: #7a2520; cursor: not-allowed; } .ge-status { margin: 12px 0; padding: 8px; background: #333; border-radius: 4px; min-height: 20px; text-align: center; position: relative; overflow: hidden; } .ge-status::before { content: ''; position: absolute; left: 0; top: 0; height: 100%; width: var(--progress, 0%); background: rgba(36, 145, 235, 0.8); transition: width 0.3s ease; z-index: 0; } .ge-status span { position: relative; z-index: 1; } .ge-minimized { width: auto !important; min-width: auto; } .ge-minimized .ge-content { display: none; } .ge-dock-menu { position: absolute; background: #333; border: 1px solid #444; border-radius: 4px; padding: 4px 0; z-index: 1001; visibility: hidden; opacity: 0; transition: opacity 0.2s; } .ge-dock-menu.visible { visibility: visible; opacity: 1; } .ge-dock-menu button { display: block; width: 100%; padding: 4px 12px; background: none; border: none; color: #fff; text-align: left; cursor: pointer; } .ge-dock-menu button:hover { background: #444; } .ge-title { flex: 1; pointer-events: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .ge-config-row { display: flex; gap: 8px; margin-bottom: 12px; } .ge-config-select { flex: 1; padding: 4px 8px; background: #333; border: 1px solid #444; color: #fff; border-radius: 4px; } .ge-save-config, .ge-delete-config { background: #333; border: 1px solid #444; color: #fff; padding: 4px 8px; border-radius: 4px; cursor: pointer; } .ge-save-config:hover, .ge-delete-config:hover { background: #444; } .ge-delete-config:disabled { opacity: 0.5; cursor: not-allowed; } .ge-mode-switch { display: flex; margin-bottom: 12px; gap: 8px; } .ge-mode-btn { flex: 1; padding: 8px; background: #333; border: 1px solid #444; color: #fff; border-radius: 4px; cursor: pointer; } .ge-mode-btn.active { background: #4CAF50; border-color: #4CAF50; } .ge-execute-single-btn, .ge-cancel-single-btn { padding: 6px 12px; font-size: 14px; min-width: 60px; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; } .ge-execute-single-btn { background: #4CAF50; color: white; } .ge-cancel-single-btn { background: #f44336; color: white; display: none; } .ge-execute-single-btn:disabled, .ge-cancel-single-btn:disabled { opacity: 0.5; cursor: not-allowed; } .ge-execute-single-btn:hover:not(:disabled) { background: #45a049; } .ge-cancel-single-btn:hover:not(:disabled) { background: #d32f2f; } .ge-search-container { display: flex; align-items: center; margin-bottom: 12px; gap: 8px; } .ge-search-input { flex: 1; padding: 8px 12px; background: #333; border: 1px solid #444; color: #fff; border-radius: 4px; font-size: 14px; } .ge-search-input:focus { outline: none; border-color: #666; } .ge-search-clear { background: #444; border: none; color: #fff; padding: 6px 10px; border-radius: 4px; cursor: pointer; font-size: 16px; display: none; } .ge-search-clear:hover { background: #555; } `; document.head.appendChild(style); document.body.appendChild(this.container); } attachEvents() { const header = this.container.querySelector('.ge-header'); header.addEventListener('mousedown', (e) => { if (!e.target.matches('.ge-controls button')) { this.isDragging = true; const rect = this.container.getBoundingClientRect(); this.dragOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; } }); document.addEventListener('mousemove', (e) => { if (this.isDragging) { const x = e.clientX - this.dragOffset.x; const y = e.clientY - this.dragOffset.y; this.container.style.left = `${x}px`; this.container.style.top = `${y}px`; } }); document.addEventListener('mouseup', () => { this.isDragging = false; }); const dockBtn = this.container.querySelector('.ge-dock-btn'); dockBtn.addEventListener('click', () => { this.showDockMenu(dockBtn); }); const minimizeBtn = this.container.querySelector('.ge-minimize-btn'); minimizeBtn.addEventListener('click', () => { this.container.classList.toggle('ge-minimized'); minimizeBtn.textContent = this.container.classList.contains('ge-minimized') ? '+' : '-'; }); const closeBtn = this.container.querySelector('.ge-close-btn'); closeBtn.addEventListener('click', () => { this.container.remove(); }); const groupCountInput = this.container.querySelector('.ge-group-count'); groupCountInput.addEventListener('change', () => { this.updateGroupSelects(parseInt(groupCountInput.value)); }); const executeBtn = this.container.querySelector('.ge-execute-btn'); executeBtn.addEventListener('click', () => { this.executeGroups(); }); const cancelBtn = this.container.querySelector('.ge-cancel-btn'); cancelBtn.addEventListener('click', () => { this.cancelExecution(); }); this.updateGroupSelects(1); window.addEventListener('resize', () => { this.ensureInViewport(); }); const deleteConfigBtn = this.container.querySelector('.ge-delete-config'); const saveConfigBtn = this.container.querySelector('.ge-save-config'); const configSelect = this.container.querySelector('.ge-config-select'); const updateDeleteButton = () => { deleteConfigBtn.disabled = !configSelect.value; }; configSelect.addEventListener('change', () => { updateDeleteButton(); if (configSelect.value) { this.loadConfig(configSelect.value); } }); saveConfigBtn.addEventListener('click', () => { this.saveCurrentConfig(); }); deleteConfigBtn.addEventListener('click', () => { const configName = configSelect.value; if (configName) { this.deleteConfig(configName); } }); updateDeleteButton(); this.loadConfigs(); const modeBtns = this.container.querySelectorAll('.ge-mode-btn'); modeBtns.forEach(btn => { btn.addEventListener('click', () => { const mode = btn.dataset.mode; this.switchMode(mode); }); }); this.updateSingleModeList(); const searchInput = this.container.querySelector('.ge-search-input'); const clearButton = this.container.querySelector('.ge-search-clear'); searchInput.addEventListener('input', () => { clearButton.style.display = searchInput.value ? 'block' : 'none'; }); } showDockMenu(button) { const existingMenu = document.querySelector('.ge-dock-menu'); if (existingMenu) { existingMenu.remove(); return; } const menu = document.createElement('div'); menu.className = 'ge-dock-menu'; menu.innerHTML = ` `; this.container.appendChild(menu); const buttonRect = button.getBoundingClientRect(); const containerRect = this.container.getBoundingClientRect(); menu.style.left = `${buttonRect.left - containerRect.left}px`; menu.style.top = `${buttonRect.bottom - containerRect.top + 5}px`; requestAnimationFrame(() => { menu.classList.add('visible'); }); menu.addEventListener('click', (e) => { const position = e.target.dataset.position; if (position) { this.dockTo(position); menu.classList.remove('visible'); setTimeout(() => menu.remove(), 200); } }); const closeMenu = (e) => { if (!menu.contains(e.target) && e.target !== button) { menu.classList.remove('visible'); setTimeout(() => menu.remove(), 200); document.removeEventListener('click', closeMenu); } }; setTimeout(() => { document.addEventListener('click', closeMenu); }, 0); } dockTo(position) { const style = this.container.style; style.transition = 'all 0.3s ease'; const marginX = this.DOCK_MARGIN_X; const marginY = this.DOCK_MARGIN_Y; switch (position) { case 'top-left': style.top = `${marginY}px`; style.left = `${marginX}px`; style.right = 'auto'; style.bottom = 'auto'; break; case 'top-right': style.top = `${marginY}px`; style.right = `${marginX}px`; style.left = 'auto'; style.bottom = 'auto'; break; case 'bottom-left': style.bottom = `${marginY}px`; style.left = `${marginX}px`; style.right = 'auto'; style.top = 'auto'; break; case 'bottom-right': style.bottom = `${marginY}px`; style.right = `${marginX}px`; style.left = 'auto'; style.top = 'auto'; break; } setTimeout(() => { style.transition = ''; }, 300); } updateGroupSelects(count) { const container = this.container.querySelector('.ge-groups-container'); container.innerHTML = ''; const groupNames = this.getGroupNames(); for (let i = 0; i < count; i++) { const select = document.createElement('select'); select.className = 'ge-group-select'; select.innerHTML = ` ${groupNames.map(name => ``).join('')} `; container.appendChild(select); } } getGroupNames() { return [...app.graph._groups].map(g => g.title).sort(); } updateStatus(text, progress = null) { const status = this.container.querySelector('.ge-status'); status.innerHTML = `${text}`; if (progress !== null) { status.style.setProperty('--progress', `${progress}%`); } } async executeGroups() { if (this.isExecuting) { console.warn('[GroupExecutorUI] 已有执行任务在进行中'); return; } const executeBtn = this.container.querySelector('.ge-execute-btn'); const cancelBtn = this.container.querySelector('.ge-cancel-btn'); const groupSelects = [...this.container.querySelectorAll('.ge-group-select')]; const repeatCount = parseInt(this.container.querySelector('.ge-repeat-count').value); const delaySeconds = parseFloat(this.container.querySelector('.ge-delay').value); this.isExecuting = true; this.isCancelling = false; executeBtn.disabled = true; cancelBtn.disabled = false; const selectedGroups = groupSelects.map(select => select.value).filter(Boolean); const totalSteps = repeatCount * selectedGroups.length; let currentStep = 0; try { for (let repeat = 0; repeat < repeatCount; repeat++) { for (let i = 0; i < selectedGroups.length; i++) { if (this.isCancelling) { console.log('[GroupExecutorUI] 执行被用户取消'); await api.interrupt(); this.updateStatus("已取消"); break; } const groupName = selectedGroups[i]; currentStep++; const progress = (currentStep / totalSteps) * 100; this.updateStatus(`${currentStep}/${totalSteps} - ${groupName}`, progress); try { await this.executeGroup(groupName); if (i < selectedGroups.length - 1 && delaySeconds > 0) { this.updateStatus(`等待 ${delaySeconds}s...`); await this.delay(delaySeconds); } } catch (error) { throw new Error(`执行组 "${groupName}" 失败: ${error.message}`); } } if (repeat < repeatCount - 1 && !this.isCancelling) { await this.delay(delaySeconds); } } if (!this.isCancelling) { this.updateStatus("完成"); } } catch (error) { console.error('[GroupExecutorUI] 执行错误:', error); this.updateStatus(`错误: ${error.message}`); app.ui.dialog.show(`执行错误: ${error.message}`); } finally { this.isExecuting = false; this.isCancelling = false; executeBtn.disabled = false; cancelBtn.disabled = true; } } async executeGroup(groupName) { const group = app.graph._groups.find(g => g.title === groupName); if (!group) { throw new Error(`未找到名为 "${groupName}" 的组`); } const outputNodes = []; for (const node of app.graph._nodes) { if (!node || !node.pos) continue; if (LiteGraph.overlapBounding(group._bounding, node.getBounding())) { if (node.mode !== LiteGraph.NEVER && node.constructor.nodeData?.output_node === true) { outputNodes.push(node); } } } if (outputNodes.length === 0) { throw new Error(`组 "${groupName}" 中没有找到输出节点`); } const nodeIds = outputNodes.map(n => n.id); try { await queueManager.queueOutputNodes(nodeIds); await this.waitForQueue(); } catch (queueError) { console.warn(`[GroupExecutorUI] 队列执行失败,使用默认方式:`, queueError); for (const n of outputNodes) { if (this.isCancelling) return; if (n.triggerQueue) { await n.triggerQueue(); await this.waitForQueue(); } } } } async cancelExecution() { if (!this.isExecuting) { console.warn('[GroupExecutorUI] 没有正在执行的任务'); return; } try { this.isCancelling = true; this.updateStatus("已取消", 0); await api.interrupt(); } catch (error) { console.error('[GroupExecutorUI] 取消执行时出错:', error); this.updateStatus(`取消失败: ${error.message}`, 0); } } async getQueueStatus() { try { const response = await fetch('/queue'); const data = await response.json(); return { isRunning: data.queue_running.length > 0, isPending: data.queue_pending.length > 0, runningCount: data.queue_running.length, pendingCount: data.queue_pending.length, rawRunning: data.queue_running, rawPending: data.queue_pending }; } catch (error) { console.error('[GroupExecutor] 获取队列状态失败:', error); return { isRunning: false, isPending: false, runningCount: 0, pendingCount: 0, rawRunning: [], rawPending: [] }; } } async waitForQueue() { return new Promise((resolve, reject) => { const checkQueue = async () => { try { const status = await this.getQueueStatus(); if (!status.isRunning && !status.isPending) { setTimeout(resolve, 100); return; } setTimeout(checkQueue, 500); } catch (error) { console.warn(`[GroupExecutor] 检查队列状态失败:`, error); setTimeout(checkQueue, 500); } }; checkQueue(); }); } async delay(seconds) { if (seconds <= 0) return; return new Promise(resolve => setTimeout(resolve, seconds * 1000)); } ensureInViewport() { const rect = this.container.getBoundingClientRect(); const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; if (this.container.style.right !== 'auto') { this.container.style.right = `${this.DOCK_MARGIN_X}px`; } if (this.container.style.left !== 'auto') { this.container.style.left = `${this.DOCK_MARGIN_X}px`; } if (this.container.style.top !== 'auto') { this.container.style.top = `${this.DOCK_MARGIN_Y}px`; } if (this.container.style.bottom !== 'auto') { this.container.style.bottom = `${this.DOCK_MARGIN_Y}px`; } } async loadConfigs() { try { const response = await api.fetchApi('/group_executor/configs', { method: 'GET' }); const result = await response.json(); if (result.status === "error") { throw new Error(result.message); } const select = this.container.querySelector('.ge-config-select'); select.innerHTML = ` ${result.configs.map(config => ``).join('')} `; } catch (error) { console.error('[GroupExecutor] 加载配置失败:', error); app.ui.dialog.show('加载配置失败: ' + error.message); } } async saveCurrentConfig() { const configName = prompt('请输入配置名称:', '新配置'); if (!configName) return; const config = { name: configName, groups: [...this.container.querySelectorAll('.ge-group-select')] .map(select => select.value) .filter(Boolean), repeatCount: parseInt(this.container.querySelector('.ge-repeat-count').value), delay: parseFloat(this.container.querySelector('.ge-delay').value) }; try { const jsonString = JSON.stringify(config); JSON.parse(jsonString); const response = await api.fetchApi('/group_executor/configs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: jsonString }); const result = await response.json(); if (result.status === "error") { throw new Error(result.message); } await this.loadConfigs(); app.ui.dialog.show('配置保存成功'); } catch (error) { console.error('[GroupExecutor] 保存配置失败:', error); app.ui.dialog.show('保存配置失败: ' + error.message); } } async loadConfig(configName) { try { const response = await api.fetchApi(`/group_executor/configs/${configName}`, { method: 'GET', cache: 'no-store' }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const config = await response.json(); const groupCountInput = this.container.querySelector('.ge-group-count'); groupCountInput.value = config.groups.length; await this.updateGroupSelects(config.groups.length); const selects = this.container.querySelectorAll('.ge-group-select'); config.groups.forEach((group, index) => { if (selects[index]) selects[index].value = group; }); this.container.querySelector('.ge-repeat-count').value = config.repeatCount; this.container.querySelector('.ge-delay').value = config.delay; } catch (error) { console.error('加载配置失败:', error); app.ui.dialog.show('加载配置失败: ' + error.message); } } async deleteConfig(configName) { if (!configName) return; if (!confirm(`确定要删除配置 "${configName}" 吗?`)) { return; } try { const response = await api.fetchApi(`/group_executor/configs/${configName}`, { method: 'DELETE' }); const result = await response.json(); if (result.status === "error") { throw new Error(result.message); } await this.loadConfigs(); app.ui.dialog.show('配置已删除'); } catch (error) { console.error('[GroupExecutor] 删除配置失败:', error); app.ui.dialog.show('删除配置失败: ' + error.message); } } switchMode(mode) { const multiMode = this.container.querySelector('.ge-multi-mode'); const singleMode = this.container.querySelector('.ge-single-mode'); const modeBtns = this.container.querySelectorAll('.ge-mode-btn'); modeBtns.forEach(btn => { btn.classList.toggle('active', btn.dataset.mode === mode); }); if (mode === 'multi') { multiMode.style.display = ''; singleMode.style.display = 'none'; } else { multiMode.style.display = 'none'; singleMode.style.display = ''; this.updateSingleModeList(); } } updateSingleModeList() { const container = this.container.querySelector('.ge-groups-list'); const searchInput = this.container.querySelector('.ge-search-input'); const clearButton = this.container.querySelector('.ge-search-clear'); const groupNames = this.getGroupNames(); const filterGroups = (searchText) => { const normalizedSearch = searchText.toLowerCase(); return groupNames.filter(name => name.toLowerCase().includes(normalizedSearch) ); }; const renderGroups = (filteredGroups) => { container.innerHTML = filteredGroups.map(name => `
${name}
`).join(''); container.querySelectorAll('.ge-group-item').forEach(item => { const groupName = item.dataset.group; const executeBtn = item.querySelector('.ge-execute-single-btn'); const cancelBtn = item.querySelector('.ge-cancel-single-btn'); executeBtn.addEventListener('click', async () => { executeBtn.disabled = true; cancelBtn.disabled = false; cancelBtn.style.display = 'block'; this.isExecuting = true; this.isCancelling = false; try { await this.executeGroup(groupName); this.updateStatus(`组 "${groupName}" 执行完成`); } catch (error) { this.updateStatus(`执行失败: ${error.message}`); console.error(error); } finally { this.isExecuting = false; this.isCancelling = false; executeBtn.disabled = false; cancelBtn.disabled = true; cancelBtn.style.display = 'none'; } }); cancelBtn.addEventListener('click', async () => { if (!this.isExecuting) return; try { this.isCancelling = true; this.updateStatus("正在取消...", 0); await api.interrupt(); this.updateStatus("已取消", 0); } catch (error) { console.error('[GroupExecutorUI] 取消执行时出错:', error); this.updateStatus(`取消失败: ${error.message}`, 0); } }); }); }; renderGroups(groupNames); searchInput.addEventListener('input', (e) => { const searchText = e.target.value; clearButton.style.display = searchText ? 'block' : 'none'; const filteredGroups = filterGroups(searchText); renderGroups(filteredGroups); }); clearButton.addEventListener('click', () => { searchInput.value = ''; clearButton.style.display = 'none'; renderGroups(groupNames); }); } } app.registerExtension({ name: "GroupExecutorUI", async setup() { await app.ui.settings.setup; // 添加右键菜单选项 const origMenu = LGraphCanvas.prototype.getCanvasMenuOptions; LGraphCanvas.prototype.getCanvasMenuOptions = function() { const options = origMenu.call(this); // 在菜单顶部添加组执行器选项(在第一个选项之后) options.splice(1, 0, null); // 在第一个选项后添加分隔线 options.splice(2, 0, { content: "⚡ 打开组执行器", callback: () => { new GroupExecutorUI(); } }); return options; } } });