您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
基于原作者@soulostar修改
// ==UserScript== // @name 公益酒馆ComfyUI插图脚本 // @namespace http://tampermonkey.net/ // @version 26.2 // @license GPL // @description 基于原作者@soulostar修改 // @author feng zheng // @match *://*/* // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @require https://code.jquery.com/ui/1.13.2/jquery-ui.min.js // ==/UserScript== (function() { 'use strict'; // --- Configuration Constants --- const BUTTON_ID = 'comfyui-launcher-button'; const PANEL_ID = 'comfyui-panel'; const POLLING_TIMEOUT_MS = 120000; // 轮询超时时间 (2分钟), 增加超时以适应调度器异步处理 const POLLING_INTERVAL_MS = 3000; // 轮询间隔 (3秒), 略微增加间隔 const STORAGE_KEY_IMAGES = 'comfyui_generated_images'; const STORAGE_KEY_PROMPT_PREFIX = 'comfyui_prompt_prefix'; // 提示词前缀的存储键 const STORAGE_KEY_MAX_WIDTH = 'comfyui_image_max_width'; // 最大图片宽度的存储键 const COOLDOWN_DURATION_MS = 60000; // 前端冷却时间 (60秒),作为默认值或当调度器未返回具体秒数时使用 // --- Global Cooldown Variable (default no cooldown) --- let globalCooldownEndTime = 0; // --- Cached User Settings Variables --- let cachedSettings = { comfyuiUrl: '', workflow: '', startTag: 'image###', endTag: '###', promptPrefix: '', maxWidth: 600 }; // --- Inject Custom CSS Styles --- GM_addStyle(` /* 控制面板主容器样式 */ #${PANEL_ID} { display: none; /* 默认隐藏 */ position: fixed; /* 浮动窗口 */ top: 50%; left: 50%; transform: translate(-50%, -50%); /* 默认居中显示 */ width: 90vw; /* 移动设备上宽度 */ max-width: 500px; /* 桌面设备上最大宽度 */ z-index: 9999; /* 确保在顶层 */ color: var(--SmartThemeBodyColor, #dcdcd2); background-color: var(--SmartThemeBlurTintColor, rgba(23, 23, 23, 0.9)); border: 1px solid var(--SmartThemeBorderColor, rgba(0, 0, 0, 0.5)); border-radius: 8px; box-shadow: 0 4px 15px var(--SmartThemeShadowColor, rgba(0, 0, 0, 0.5)); padding: 15px; box-sizing: border-box; backdrop-filter: blur(var(--blurStrength, 10px)); flex-direction: column; } /* 面板标题栏 */ #${PANEL_ID} .panel-control-bar { /* 移除了 cursor: move; 使其不可拖动 */ padding-bottom: 10px; margin-bottom: 15px; border-bottom: 1px solid var(--SmartThemeBorderColor, rgba(0, 0, 0, 0.5)); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; } #${PANEL_ID} .panel-control-bar b { font-size: 1.2em; margin-left: 10px; } #${PANEL_ID} .floating_panel_close { cursor: pointer; font-size: 1.5em; } #${PANEL_ID} .floating_panel_close:hover { opacity: 0.7; } #${PANEL_ID} .comfyui-panel-content { overflow-y: auto; flex-grow: 1; padding-right: 5px; } /* 输入框和文本域样式 */ #${PANEL_ID} input[type="text"], #${PANEL_ID} textarea, #${PANEL_ID} input[type="number"] { /* 包含数字输入框 */ width: 100%; box-sizing: border-box; padding: 8px; border-radius: 4px; border: 1px solid var(--SmartThemeBorderColor, #555); background-color: rgba(0,0,0,0.2); color: var(--SmartThemeBodyColor, #dcdcd2); margin-bottom: 10px; } #${PANEL_ID} textarea { min-height: 150px; resize: vertical; } #${PANEL_ID} .workflow-info { font-size: 0.9em; color: #aaa; margin-top: -5px; margin-bottom: 10px;} /* 通用按钮样式 (用于测试连接和聊天内生成按钮) */ .comfy-button { padding: 8px 12px; border: 1px solid black; border-radius: 4px; cursor: pointer; /* Modified: Changed button background to a gradient sky blue */ background: linear-gradient(135deg, #87CEEB 0%, #00BFFF 100%); /* 天蓝色到深天蓝色渐变 */ color: white; font-weight: 600; transition: opacity 0.3s, background 0.3s; flex-shrink: 0; font-size: 14px; } .comfy-button:disabled { opacity: 0.5; cursor: not-allowed; } .comfy-button:hover:not(:disabled) { opacity: 0.85; } /* 按钮状态样式 */ .comfy-button.testing { background: #555; } .comfy-button.success { background: linear-gradient(135deg, #28a745 0%, #218838 100%); } .comfy-button.error { background: linear-gradient(135deg, #dc3545 0%, #c82333 100%); } /* 特殊布局样式 */ #comfyui-test-conn { position: relative; top: -5px; } .comfy-url-container { display: flex; gap: 10px; align-items: center; } .comfy-url-container input { flex-grow: 1; margin-bottom: 0; } #${PANEL_ID} label { display: block; margin-bottom: 5px; font-weight: bold; } #options > .options-content > a#${BUTTON_ID} { display: flex; align-items: center; gap: 10px; } /* 标记输入框容器样式 */ #${PANEL_ID} .comfy-tags-container { display: flex; gap: 10px; align-items: flex-end; margin-top: 10px; margin-bottom: 10px; } #${PANEL_ID} .comfy-tags-container div { flex-grow: 1; } /* 聊天内按钮组容器 */ .comfy-button-group { display: inline-flex; align-items: center; gap: 5px; margin: 5px 4px; } /* 生成的图片容器样式 */ .comfy-image-container { margin-top: 10px; max-width: 100%; /* 默认允许图片最大宽度为容器的100% */ } .comfy-image-container img { /* 注意:这里的max-width将由JavaScript直接设置,CSS变量作为备用或默认值 */ max-width: var(--comfy-image-max-width, 100%); height: auto; /* 保持图片纵横比 */ border-radius: 8px; border: 1px solid var(--SmartThemeBorderColor, #555); } /* 移动端适配 */ @media (max-width: 1000px) { #${PANEL_ID} { top: 20px; left: 50%; transform: translateX(-50%); max-height: calc(100vh - 40px); width: 95vw; } } /* 定义一个CSS变量,用于动态控制图片最大宽度 */ :root { --comfy-image-max-width: 600px; /* 默认图片最大宽度 */ } `); // A flag to prevent duplicate execution from touchstart and click let lastTapTimestamp = 0; const TAP_THRESHOLD = 300; // milliseconds to prevent double taps/clicks function createComfyUIPanel() { if (document.getElementById(PANEL_ID)) return; const panelHTML = ` <div id="${PANEL_ID}"> <div class="panel-control-bar"> <i class="fa-fw fa-solid fa-grip drag-grabber"></i> <b>ComfyUI 生成设置</b> <i class="fa-fw fa-solid fa-circle-xmark floating_panel_close"></i> </div> <div class="comfyui-panel-content"> <label for="comfyui-url">调度器/ComfyUI URL</label> <div class="comfy-url-container"> <input id="comfyui-url" type="text" placeholder="例如: http://127.0.0.1:5001 或 http://127.0.0.1:8188"> <button id="comfyui-test-conn" class="comfy-button">测试连接</button> </div> <div class="comfy-tags-container"> <div> <label for="comfyui-start-tag">开始标记</label> <input id="comfyui-start-tag" type="text"> </div> <div> <label for="comfyui-end-tag">结束标记</label> <input id="comfyui-end-tag" type="text"> </div> </div> <div> <label for="comfyui-prompt-prefix">提示词固定前缀 (LoRA等):</label> <input id="comfyui-prompt-prefix" type="text" placeholder="例如: <lora:cool_style:0.8> "> </div> <!-- 新增:最大图片宽度输入框 --> <div> <label for="comfyui-max-width">最大图片宽度 (px):</label> <input id="comfyui-max-width" type="number" placeholder="例如: 600" min="100"> </div> <p class="workflow-info">当使用调度器时,此处的工作流设置将被忽略。工作流由调度器决定。</p> <label for="comfyui-workflow">工作流 (当不使用调度器时生效)</label> <textarea id="comfyui-workflow" placeholder="在此处粘贴您的ComfyUI工作流JSON..."></textarea> <button id="comfyui-clear-cache" class="comfy-button error" style="margin-top: 15px; width: 100%;">删除所有图片缓存</button> </div> </div> `; document.body.insertAdjacentHTML('beforeend', panelHTML); initPanelLogic(); } function initPanelLogic() { const panel = document.getElementById(PANEL_ID); const closeButton = panel.querySelector('.floating_panel_close'); const testButton = document.getElementById('comfyui-test-conn'); const clearCacheButton = document.getElementById('comfyui-clear-cache'); const urlInput = document.getElementById('comfyui-url'); const workflowInput = document.getElementById('comfyui-workflow'); const startTagInput = document.getElementById('comfyui-start-tag'); const endTagInput = document.getElementById('comfyui-end-tag'); const promptPrefixInput = document.getElementById('comfyui-prompt-prefix'); const maxWidthInput = document.getElementById('comfyui-max-width'); closeButton.addEventListener('click', () => { panel.style.display = 'none'; }); testButton.addEventListener('click', () => { let url = urlInput.value.trim(); if (!url) { if (typeof toastr !== 'undefined') toastr.warning('请输入调度器或ComfyUI的URL。'); return; } if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'http://' + url; } if (url.endsWith('/')) { url = url.slice(0, -1); } urlInput.value = url; const testUrl = url + '/system_stats'; if (typeof toastr !== 'undefined') toastr.info('正在尝试连接服务...'); testButton.classList.remove('success', 'error'); testButton.classList.add('testing'); testButton.disabled = true; GM_xmlhttpRequest({ method: "GET", url: testUrl, timeout: 5000, onload: (res) => { testButton.disabled = false; testButton.classList.remove('testing'); if (res.status === 200) { testButton.classList.add('success'); if (typeof toastr !== 'undefined') toastr.success('连接成功!服务可用。'); } else { testButton.classList.add('error'); if (typeof toastr !== 'undefined') toastr.error(`连接失败!服务器响应状态: ${res.status}`); } }, onerror: () => { testButton.disabled = false; testButton.classList.remove('testing'); testButton.classList.add('error'); if (typeof toastr !== 'undefined') toastr.error('连接错误!请检查URL、网络或CORS设置。'); }, ontimeout: () => { testButton.disabled = false; testButton.classList.remove('testing'); testButton.classList.add('error'); if (typeof toastr !== 'undefined') toastr.error('连接超时!服务可能没有响应。'); } }); }); clearCacheButton.addEventListener('click', () => { if (confirm('您确定要删除所有已生成的图片缓存吗?\n此操作不可撤销,但不会删除您本地ComfyUI输出文件夹中的文件。')) { GM_setValue(STORAGE_KEY_IMAGES, {}); if (typeof toastr !== 'undefined') toastr.success('所有图片缓存已成功删除!请刷新页面以更新显示。'); } }); // 异步加载设置并应用 loadSettings(urlInput, workflowInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput).then(() => { // 在设置加载完成后,将当前图片最大宽度应用到所有已存在的图片上 applyCurrentMaxWidthToAllImages(); }); // 为所有输入框添加事件监听器 [urlInput, workflowInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput].forEach(input => { input.addEventListener('input', async () => { // 修改为 async if(input === urlInput) testButton.classList.remove('success', 'error', 'testing'); await saveSettings(urlInput, workflowInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput); // 每次保存设置时,也立即应用到所有已存在的图片 applyCurrentMaxWidthToAllImages(); }); }); } async function loadSettings(urlInput, workflowInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput) { cachedSettings.comfyuiUrl = await GM_getValue('comfyui_url', 'http://127.0.0.1:5001'); cachedSettings.workflow = await GM_getValue('comfyui_workflow', ''); cachedSettings.startTag = await GM_getValue('comfyui_start_tag', 'image###'); cachedSettings.endTag = await GM_getValue('comfyui_end_tag', '###'); cachedSettings.promptPrefix = await GM_getValue(STORAGE_KEY_PROMPT_PREFIX, ''); cachedSettings.maxWidth = await GM_getValue(STORAGE_KEY_MAX_WIDTH, 600); urlInput.value = cachedSettings.comfyuiUrl; workflowInput.value = cachedSettings.workflow; startTagInput.value = cachedSettings.startTag; endTagInput.value = cachedSettings.endTag; promptPrefixInput.value = cachedSettings.promptPrefix; maxWidthInput.value = cachedSettings.maxWidth; document.documentElement.style.setProperty('--comfy-image-max-width', (cachedSettings.maxWidth || 600) + 'px'); } async function saveSettings(urlInput, workflowInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput) { cachedSettings.comfyuiUrl = urlInput.value.trim(); cachedSettings.workflow = workflowInput.value; cachedSettings.startTag = startTagInput.value; cachedSettings.endTag = endTagInput.value; cachedSettings.promptPrefix = promptPrefixInput.value.trim(); const newMaxWidth = parseInt(maxWidthInput.value); cachedSettings.maxWidth = isNaN(newMaxWidth) ? 600 : newMaxWidth; await GM_setValue('comfyui_url', cachedSettings.comfyuiUrl); await GM_setValue('comfyui_workflow', cachedSettings.workflow); await GM_setValue('comfyui_start_tag', cachedSettings.startTag); await GM_setValue('comfyui_end_tag', cachedSettings.endTag); await GM_setValue(STORAGE_KEY_PROMPT_PREFIX, cachedSettings.promptPrefix); await GM_setValue(STORAGE_KEY_MAX_WIDTH, cachedSettings.maxWidth); document.documentElement.style.setProperty('--comfy-image-max-width', cachedSettings.maxWidth + 'px'); } /** * 将当前设置的最大宽度动态应用到所有已存在的图片元素上。 * 这样做是为了确保图片大小能够即时更新,无需刷新页面。 */ async function applyCurrentMaxWidthToAllImages() { const images = document.querySelectorAll('.comfy-image-container img'); const maxWidthPx = (cachedSettings.maxWidth || 600) + 'px'; images.forEach(img => { img.style.maxWidth = maxWidthPx; }); } function addMainButton() { if (document.getElementById(BUTTON_ID)) return; const optionsMenuContent = document.querySelector('#options .options-content'); if (optionsMenuContent) { const continueButton = optionsMenuContent.querySelector('#option_continue'); if (continueButton) { const comfyButton = document.createElement('a'); comfyButton.id = BUTTON_ID; comfyButton.className = 'interactable'; comfyButton.innerHTML = `<i class="fa-lg fa-solid fa-image"></i><span>ComfyUI生图</span>`; comfyButton.style.cursor = 'pointer'; comfyButton.addEventListener('click', (event) => { event.preventDefault(); const panel = document.getElementById(PANEL_ID); if (panel) { panel.style.display = 'flex'; } document.getElementById('options').style.display = 'none'; }); continueButton.parentNode.insertBefore(comfyButton, continueButton.nextSibling); } } } // --- 聊天消息处理与图片生成 --- function escapeRegex(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function generateClientId() { return 'client-' + Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); } function simpleHash(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = (hash << 5) - hash + char; hash |= 0; } return 'comfy-id-' + Math.abs(hash).toString(36); } async function saveImageRecord(generationId, imageUrl) { const records = await GM_getValue(STORAGE_KEY_IMAGES, {}); records[generationId] = imageUrl; await GM_setValue(STORAGE_KEY_IMAGES, records); } async function deleteImageRecord(generationId) { const records = await GM_getValue(STORAGE_KEY_IMAGES, {}); delete records[generationId]; await GM_setValue(STORAGE_KEY_IMAGES, records); } // 通用事件处理函数 function handleComfyButtonClick(event, isTouch = false) { const button = event.target.closest('.comfy-chat-generate-button'); if (!button) return; // 确保点击的是我们的按钮 if (isTouch) { event.preventDefault(); // 阻止 300ms 延迟和潜在的双击缩放 const now = Date.now(); if (now - lastTapTimestamp < TAP_THRESHOLD) { // 这可能是快速双击或重复点击,忽略 console.log('触摸事件被忽略:太快了。'); return; } lastTapTimestamp = now; console.log('触摸事件触发:执行生成逻辑。'); onGenerateButtonClickLogic(button); } else { // 这是点击事件路径 // 如果最近的触摸事件已经触发了操作,则忽略此点击事件以防止重复执行 if (Date.now() - lastTapTimestamp < TAP_THRESHOLD) { console.log('点击事件被忽略:最近由触摸事件触发。'); return; } console.log('点击事件触发:执行生成逻辑。'); onGenerateButtonClickLogic(button); } } async function processMessageForComfyButton(messageNode, savedImagesCache) { // 接收缓存的 savedImages const mesText = messageNode.querySelector('.mes_text'); if (!mesText) return; // 使用缓存的设置 const startTag = cachedSettings.startTag; const endTag = cachedSettings.endTag; if (!startTag || !endTag) return; const escapedStartTag = escapeRegex(startTag); const escapedEndTag = escapeRegex(endTag); const regex = new RegExp(escapedStartTag + '([\\s\\S]*?)' + escapedEndTag, 'g'); const currentHtml = mesText.innerHTML; if (regex.test(currentHtml) && !mesText.querySelector('.comfy-button-group')) { mesText.innerHTML = currentHtml.replace(regex, (match, prompt) => { const cleanPrompt = prompt.trim(); const encodedPrompt = cleanPrompt.replace(/"/g, '"'); const generationId = simpleHash(cleanPrompt); return `<span class="comfy-button-group" data-generation-id="${generationId}"> <button class="comfy-button comfy-chat-generate-button" data-prompt="${encodedPrompt}">开始生成</button> </span>`; }); } // 使用传入的缓存 savedImagesCache const buttonGroups = mesText.querySelectorAll('.comfy-button-group'); buttonGroups.forEach(group => { if (group.dataset.listenerAttached) return; const generationId = group.dataset.generationId; const generateButton = group.querySelector('.comfy-chat-generate-button'); // 检查全局冷却时间,如果有,则立即启动倒计时显示 if (Date.now() < globalCooldownEndTime) { generateButton.dataset.cooldownEnd = globalCooldownEndTime.toString(); // 应用全局冷却时间 startCooldownCountdown(generateButton, globalCooldownEndTime); } else if (savedImagesCache[generationId]) { // 使用 savedImagesCache displayImage(group, savedImagesCache[generationId]); // 使用 savedImagesCache setupGeneratedState(generateButton, generationId); } // 移除了直接的 addEventListener,改为依赖 chatObserver 中的事件委托 group.dataset.listenerAttached = 'true'; // 标记已处理,防止重复修改 DOM }); } function setupGeneratedState(generateButton, generationId) { generateButton.textContent = '重新生成'; generateButton.disabled = false; generateButton.classList.remove('testing', 'success', 'error'); delete generateButton.dataset.cooldownEnd; // 清除冷却标记 // 确保重新生成事件已绑定 // 移除了直接的 addEventListener,改为依赖 chatObserver 中的事件委托 generateButton.dataset.regenerateListener = 'true'; // 标记已处理 // 注意:这里不需要再添加事件监听器,因为我们将使用委托模式 // 只要 handleComfyButtonClick 绑定在 .mes_text 上,它就会捕获所有内部按钮的事件 const group = generateButton.closest('.comfy-button-group'); let deleteButton = group.querySelector('.comfy-delete-button'); if (!deleteButton) { deleteButton = document.createElement('button'); deleteButton.textContent = '删除'; deleteButton.className = 'comfy-button error comfy-delete-button'; deleteButton.addEventListener('click', async () => { await deleteImageRecord(generationId); // 移除图片和删除按钮 const imageContainer = group.nextElementSibling; if (imageContainer && imageContainer.classList.contains('comfy-image-container')) { imageContainer.remove(); } deleteButton.remove(); // 恢复生成按钮初始状态 generateButton.textContent = '开始生成'; generateButton.disabled = false; generateButton.classList.remove('testing', 'success', 'error'); }); generateButton.insertAdjacentElement('afterend', deleteButton); } } // 核心生成逻辑函数 async function onGenerateButtonClickLogic(button) { const group = button.closest('.comfy-button-group'); let prompt = button.dataset.prompt; const generationId = group.dataset.generationId; // 在执行核心逻辑前再次检查是否被禁用,防止竞态条件 if (button.disabled) { console.log('按钮已禁用,跳过生成逻辑。'); return; } // --- GLOBAL: Cooldown Check at click time --- if (Date.now() < globalCooldownEndTime) { const remainingTime = Math.ceil((globalCooldownEndTime - Date.now()) / 1000); if (typeof toastr !== 'undefined') toastr.warning(`请稍候,图片生成功能正在冷却中 (${remainingTime}s)。`); return; // 阻止发送请求 } // --- END GLOBAL Cooldown Check --- button.textContent = '生成中...'; button.disabled = true; button.classList.remove('success', 'error'); button.classList.add('testing'); // 暂时隐藏删除按钮(如果存在) const deleteButton = group.querySelector('.comfy-delete-button'); if (deleteButton) deleteButton.style.display = 'none'; // 获取旧图片的容器,但不要立即移除它 const oldImageContainer = group.nextElementSibling; try { // 使用缓存的设置 const url = cachedSettings.comfyuiUrl; let workflowString = cachedSettings.workflow; const promptPrefix = cachedSettings.promptPrefix; if (!url) throw new Error('调度器/ComfyUI URL 未配置。'); if (promptPrefix) { prompt = promptPrefix + ' ' + prompt; } const clientId = generateClientId(); let promptResponse; // 判断是否需要向调度器发送简化请求 // 如果 URL 是调度器地址,并且用户没有在工作流字段填写内容,则认为是调度器模式 const isScheduler = url.includes(':5001') || workflowString.trim() === ''; if (isScheduler) { if (typeof toastr !== 'undefined') toastr.info('检测到调度器模式,正在发送简化请求...'); // 修改此处:sendPromptRequestToScheduler 现在将处理 202 响应 promptResponse = await sendPromptRequestToScheduler(url, { client_id: clientId, positive_prompt: prompt }); // *** 新增:显示调度器分配的实例和队列长度 *** if (promptResponse.assigned_instance_name && typeof promptResponse.assigned_instance_queue_size !== 'undefined') { if (typeof toastr !== 'undefined') { toastr.success(`任务已分配到实例: ${promptResponse.assigned_instance_name},当前队列长度: ${promptResponse.assigned_instance_queue_size}`); } } } else { if (!workflowString.includes('%prompt%')) throw new Error('工作流中未找到必需的 %prompt% 占位符。'); if (typeof toastr !== 'undefined') toastr.info('检测到直连ComfyUI模式,正在发送完整工作流...'); const seed = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); workflowString = workflowString.replace(/%prompt%/g, JSON.stringify(prompt).slice(1, -1)); workflowString = workflowString.replace(/%seed%/g, seed); const workflow = JSON.parse(workflowString); promptResponse = await sendPromptRequestDirect(url, workflow, clientId); } // 对于调度器模式,promptResponse 应该包含 prompt_id const promptId = promptResponse.prompt_id; // 从调度器返回的 202 响应中获取 prompt_id if (!promptId) { throw new Error('调度器未返回有效的 Prompt ID。'); } // 使用从调度器获得的 promptId 进行轮询 const finalHistory = await pollForResult(url, promptId); const imageUrl = findImageUrlInHistory(finalHistory, promptId, url); if (!imageUrl) throw new Error('在服务返回结果中未找到图片。'); // --- 成功生成新图片后,才移除旧图片并显示新图片 --- if (oldImageContainer) { oldImageContainer.remove(); } displayImage(group, imageUrl); // 显示新图片 await saveImageRecord(generationId, imageUrl); // 保存新图片记录 button.textContent = '生成成功'; button.classList.remove('testing'); button.classList.add('success'); setTimeout(() => { setupGeneratedState(button, generationId); if (deleteButton) deleteButton.style.display = 'inline-flex'; // 恢复删除按钮 }, 2000); } catch (e) { // 记录详细错误到控制台 console.error('ComfyUI 生图脚本错误(详细信息 - 仅供调试):', e); let displayMessage = '图片生成失败,请检查调度器或ComfyUI服务。'; let isRateLimitError = false; let actualCooldownSeconds = COOLDOWN_DURATION_MS / 1000; // 默认冷却时间为 60 秒 // 尝试从错误消息中提取具体的速率限制信息 // 调度器返回的错误信息格式: "请求频率过高,请稍后再试。限制: 1/60秒,请在 X 秒后重试。" const rateLimitMatch = e.message.match(/请在 (\d+) 秒后重试。/); if (rateLimitMatch && rateLimitMatch[1]) { actualCooldownSeconds = parseInt(rateLimitMatch[1], 10); displayMessage = `一分钟只能生成一张图片哦,请在 ${actualCooldownSeconds} 秒后重试。`; isRateLimitError = true; } else if (e.message.includes('请求频率过高')) { // 如果没有找到具体的秒数,但有“请求频率过高”的字样,也认为是速率限制 displayMessage = '一分钟只能生成一张图片哦,请求频率过高,请稍后再试。'; // 给出通用提示 isRateLimitError = true; } else if (e.message.includes('轮询结果超时')) { displayMessage = '生成任务超时,可能仍在处理或已失败。'; } else if (e.message.includes('无法连接到调度器 API')) { displayMessage = '无法连接到调度器服务,请检查URL和网络。'; } else if (e.message.includes('连接调度器 API 超时')) { displayMessage = '连接调度器服务超时,请检查网络。'; } else if (e.message.includes('任务 ID 未找到或已过期')) { displayMessage = '生成任务ID无效或已过期,请尝试重新生成。'; } else { // 对于其他未知错误,尝试提取更友好的信息 const backendErrorMatch = e.message.match(/error:\s*"(.*?)"/); if (backendErrorMatch && backendErrorMatch[1]) { displayMessage = `调度器错误: ${backendErrorMatch[1]}`; } } if (typeof toastr !== 'undefined') toastr.error(displayMessage); // --- 错误发生时,保持旧图片可见,并恢复删除按钮(如果存在) --- if (deleteButton) deleteButton.style.display = 'inline-flex'; // 恢复删除按钮 // 检查是否为调度器速率限制错误,如果是,则启动全局冷却倒计时 if (isRateLimitError) { // 使用 isRateLimitError 标志 const newCooldownEndTime = Date.now() + (actualCooldownSeconds * 1000); // 使用实际的冷却秒数 globalCooldownEndTime = newCooldownEndTime; // 设置全局冷却时间 applyGlobalCooldown(newCooldownEndTime); // 将冷却状态应用到所有按钮 } else { // 非速率限制错误,按钮显示“生成失败”并短时间后恢复 button.textContent = '生成失败'; button.classList.remove('testing'); button.classList.add('error'); setTimeout(() => { const wasRegenerating = !!group.querySelector('.comfy-delete-button'); if (wasRegenerating) { setupGeneratedState(button, generationId); } else { button.textContent = '开始生成'; button.disabled = false; button.classList.remove('error'); } }, 3000); } } } /** * 将冷却状态应用到所有图片生成按钮。 * @param {number} endTime - 冷却结束的时间戳 (ms)。 */ function applyGlobalCooldown(endTime) { const allGenerateButtons = document.querySelectorAll('.comfy-chat-generate-button'); allGenerateButtons.forEach(button => { button.dataset.cooldownEnd = endTime.toString(); // 在每个按钮上设置冷却结束时间 startCooldownCountdown(button, endTime); // 启动单个按钮的倒计时 }); } /** * 启动按钮的冷却倒计时。 * @param {HTMLElement} button - 要冷却的按钮元素。 * @param {number} endTime - 冷却结束的时间戳 (ms)。 */ function startCooldownCountdown(button, endTime) { button.disabled = true; // 确保按钮在冷却期间被禁用 button.classList.remove('success', 'error', 'testing'); // 清除其他状态 const updateCountdown = () => { const remainingTime = Math.max(0, endTime - Date.now()); const seconds = Math.ceil(remainingTime / 1000); if (seconds > 0) { button.textContent = `冷却中 (${seconds}s)`; setTimeout(updateCountdown, 1000); // 每秒更新一次 } else { // 冷却结束,恢复按钮状态 button.disabled = false; delete button.dataset.cooldownEnd; // 移除冷却标记 const group = button.closest('.comfy-button-group'); const generationId = group.dataset.generationId; const deleteButtonPresent = group.querySelector('.comfy-delete-button'); if (deleteButtonPresent) { setupGeneratedState(button, generationId); // 恢复到“重新生成”状态 } else { button.textContent = '开始生成'; // 恢复到“开始生成”状态 } } }; updateCountdown(); // 立即执行一次以显示初始倒计时 } function sendPromptRequestToScheduler(url, payload) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${url}/generate`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(payload), timeout: 10000, // 从 30s 减少到 10s onload: (res) => { // *** 关键修改: 处理 202 Accepted 状态码 *** if (res.status === 202) { if (typeof toastr !== 'undefined') toastr.info('请求已发送至调度器,任务已在后台排队。'); let responseData = {}; try { responseData = JSON.parse(res.responseText); } catch (e) { console.warn('调度器 202 响应不是有效的 JSON 或为空。继续使用空响应数据。', e); } // 调度器 202 响应中现在应该包含 prompt_id, assigned_instance_name, assigned_instance_queue_size resolve({ prompt_id: responseData.prompt_id, message: responseData.message, assigned_instance_name: responseData.assigned_instance_name, // 新增 assigned_instance_queue_size: responseData.assigned_instance_queue_size // 新增 }); } else if (res.status === 200) { // 兼容旧版调度器或同步返回 prompt_id 的情况 if (typeof toastr !== 'undefined') toastr.info('请求已发送至调度器,排队中...'); resolve(JSON.parse(res.responseText)); } else { let errorMessage = ''; try { const errorJson = JSON.parse(res.responseText); if (errorJson && errorJson.error) { // 如果是JSON错误,直接使用其error字段 errorMessage = errorJson.error; } else { // 否则,使用状态码和原始响应文本 errorMessage = `调度器 API 错误: ${res.statusText || res.status} - ${res.responseText}`; } } catch (parseError) { // 如果响应文本不是JSON,直接作为错误信息 errorMessage = `调度器 API 错误: ${res.statusText || res.status} - ${res.responseText}`; } reject(new Error(errorMessage)); } }, onerror: (e) => reject(new Error('无法连接到调度器 API。请检查URL和网络连接。详细错误:' + (e.responseText || e.statusText || e.status))), ontimeout: () => reject(new Error('连接调度器 API 超时。请检查网络。')), }); }); } function sendPromptRequestDirect(url, workflow, clientId) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: `${url}/prompt`, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ prompt: workflow, client_id: clientId }), timeout: 10000, // 从 30s 减少到 10s onload: (res) => { if (res.status === 200) { if (typeof toastr !== 'undefined') toastr.info('请求已发送至ComfyUI,排队中...'); resolve(JSON.parse(res.responseText)); } else { // 对于直连ComfyUI的错误,保留更多细节,因为它可能不会返回统一的JSON错误格式 reject(new Error(`ComfyUI API 错误 (提示词): ${res.statusText || res.status} - ${res.responseText}`)); } }, onerror: (e) => reject(new Error('无法连接到 ComfyUI API。请检查URL和网络连接。详细错误:' + (e.responseText || e.statusText || e.status))), ontimeout: () => reject(new Error('连接 ComfyUI API 超时。')), }); }); } function pollForResult(url, promptId) { return new Promise((resolve, reject) => { const startTime = Date.now(); const poller = setInterval(() => { if (Date.now() - startTime > POLLING_TIMEOUT_MS) { clearInterval(poller); // 隐藏敏感信息 reject(new Error('轮询结果超时。任务可能仍在处理中或已失败。请查看调度器日志了解更多信息。')); return; } GM_xmlhttpRequest({ method: 'GET', url: `${url}/history/${promptId}`, timeout: 65000, // 显式设置超时时间,略大于调度器的代理超时 onload: (res) => { if (res.status === 200) { const history = JSON.parse(res.responseText); // 检查历史记录中是否存在该 promptId 并且其 outputs 不为空 if (history[promptId] && Object.keys(history[promptId].outputs).length > 0) { clearInterval(poller); resolve(history); } else { // 即使 200,如果 outputs 为空,也可能意味着任务仍在进行中 console.info(`轮询历史记录 ${promptId}: 任务仍在进行中。`); // 使用 console.info 避免频繁弹窗 } } else if (res.status === 404) { clearInterval(poller); // 隐藏敏感信息 reject(new Error(`轮询结果失败: 任务 ID ${promptId} 未找到或已过期。请查看调度器日志了解更多信息。`)); } else { clearInterval(poller); // 隐藏敏感信息,只显示状态码和通用信息 reject(new Error(`轮询结果失败: 后端服务返回状态码 ${res.status}。请查看调度器日志了解更多信息。`)); } }, onerror: (e) => { clearInterval(poller); // 隐藏敏感信息,提供通用网络错误提示 reject(new Error('轮询结果网络错误或调度器/ComfyUI无响应。请检查网络连接或调度器日志。详细错误:' + (e.responseText || e.statusText || e.status))); }, ontimeout: () => { // 处理单个轮询请求的超时 clearInterval(poller); // 隐藏敏感信息 reject(new Error(`单个轮询请求超时。调度器在历史记录接口处无响应。请检查调度器日志了解更多信息。`)); } }); }, POLLING_INTERVAL_MS); }); } function findImageUrlInHistory(history, promptId, baseUrl) { const outputs = history[promptId]?.outputs; if (!outputs) return null; for (const nodeId in outputs) { if (outputs.hasOwnProperty(nodeId) && outputs[nodeId].images) { const image = outputs[nodeId].images[0]; if (image) { const params = new URLSearchParams({ filename: image.filename, subfolder: image.subfolder, type: image.type, prompt_id: promptId // 传递 prompt_id 给 /view 路由 }); return `${baseUrl}/view?${params.toString()}`; } } } return null; } /** * 显示图片,并确保其最大宽度设置被应用。 * @param {HTMLElement} anchorElement - 图片按钮所在的父元素或相邻元素。 * @param {string} imageUrl - 要显示的图片URL。 */ async function displayImage(anchorElement, imageUrl) { let container = anchorElement.nextElementSibling; if (!container || !container.classList.contains('comfy-image-container')) { container = document.createElement('div'); container.className = 'comfy-image-container'; const img = document.createElement('img'); img.alt = 'ComfyUI 生成的图片'; // 更新 alt 文本 container.appendChild(img); anchorElement.insertAdjacentElement('afterend', container); } const imgElement = container.querySelector('img'); imgElement.src = imageUrl; // 直接从缓存中获取当前的最大宽度设置并应用到图片元素 imgElement.style.maxWidth = (cachedSettings.maxWidth || 600) + 'px'; } // --- 主执行逻辑 --- createComfyUIPanel(); const chatObserver = new MutationObserver(async (mutations) => { // Make the callback async const nodesToProcess = new Set(); for (const mutation of mutations) { // 只处理新添加的节点和它们的子树变化,不监听字符数据变化 mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches('.mes')) nodesToProcess.add(node); node.querySelectorAll('.mes').forEach(mes => nodesToProcess.add(mes)); } }); // 确保如果修改发生在 .mes 元素内部,也能被捕捉到 if (mutation.target.nodeType === Node.ELEMENT_NODE && mutation.target.closest('.mes')) { nodesToProcess.add(mutation.target.closest('.mes')); } } if (nodesToProcess.size > 0) { // Fetch savedImages once per batch of mutations const savedImages = await GM_getValue(STORAGE_KEY_IMAGES, {}); // Load settings into cache if not already loaded (or if forced refresh) // This ensures cachedSettings is always up-to-date for new messages await loadSettingsFromStorageAndApplyToCache(); // Ensure cachedSettings are current nodesToProcess.forEach(node => { // 在处理每个消息节点时,为其内部的 .mes_text 元素添加事件委托 const mesTextElement = node.querySelector('.mes_text'); if (mesTextElement && !mesTextElement.dataset.listenersAttached) { mesTextElement.addEventListener('touchstart', (event) => handleComfyButtonClick(event, true), { passive: false }); mesTextElement.addEventListener('click', (event) => handleComfyButtonClick(event, false)); mesTextElement.dataset.listenersAttached = 'true'; // 标记已附加监听器 } processMessageForComfyButton(node, savedImages); // Pass savedImages }); } }); // 独立函数,用于从存储中加载设置并更新缓存 async function loadSettingsFromStorageAndApplyToCache() { const currentUrl = await GM_getValue('comfyui_url', 'http://127.0.0.1:5001'); const currentWorkflow = await GM_getValue('comfyui_workflow', ''); const currentStartTag = await GM_getValue('comfyui_start_tag', 'image###'); const currentEndTag = await GM_getValue('comfyui_end_tag', '###'); const currentPromptPrefix = await GM_getValue(STORAGE_KEY_PROMPT_PREFIX, ''); const currentMaxWidth = await GM_getValue(STORAGE_KEY_MAX_WIDTH, 600); cachedSettings.comfyuiUrl = currentUrl; cachedSettings.workflow = currentWorkflow; cachedSettings.startTag = currentStartTag; cachedSettings.endTag = currentEndTag; cachedSettings.promptPrefix = currentPromptPrefix; cachedSettings.maxWidth = currentMaxWidth; // Apply max width to CSS variable immediately after loading/updating cache document.documentElement.style.setProperty('--comfy-image-max-width', (cachedSettings.maxWidth || 600) + 'px'); } function observeChat() { const chatElement = document.getElementById('chat'); if (chatElement) { // On initial load, ensure settings are loaded and then process existing messages loadSettingsFromStorageAndApplyToCache().then(async () => { const initialSavedImages = await GM_getValue(STORAGE_KEY_IMAGES, {}); // Initial fetch of saved images chatElement.querySelectorAll('.mes').forEach(node => { // 为已存在的聊天消息添加事件委托 const mesTextElement = node.querySelector('.mes_text'); if (mesTextElement && !mesTextElement.dataset.listenersAttached) { mesTextElement.addEventListener('touchstart', (event) => handleComfyButtonClick(event, true), { passive: false }); mesTextElement.addEventListener('click', (event) => handleComfyButtonClick(event, false)); mesTextElement.dataset.listenersAttached = 'true'; } processMessageForComfyButton(node, initialSavedImages) }); // Adjust MutationObserver options, remove characterData: true chatObserver.observe(chatElement, { childList: true, subtree: true }); }); } else { setTimeout(observeChat, 500); } } const optionsObserver = new MutationObserver(() => { const optionsMenu = document.getElementById('options'); if (optionsMenu && optionsMenu.style.display !== 'none') { addMainButton(); } }); window.addEventListener('load', () => { observeChat(); const body = document.querySelector('body'); if (body) { optionsObserver.observe(body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); } }); })();