// ==UserScript==
// @name 公益酒馆ComfyUI插图脚本
// @namespace http://tampermonkey.net/
// @version 28.0 // 版本号递增,功能重构
// @license GPL
// @description 移除直连模式,专注调度器并增加自定义缓存管理。
// @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;
const POLLING_INTERVAL_MS = 3000;
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 STORAGE_KEY_CACHE_LIMIT = 'comfyui_cache_limit'; // 新增:缓存上限的存储键
const COOLDOWN_DURATION_MS = 60000;
// --- Global Cooldown Variable ---
let globalCooldownEndTime = 0;
// --- Cached User Settings Variables ---
let cachedSettings = {
comfyuiUrl: '',
startTag: 'image###',
endTag: '###',
promptPrefix: '',
maxWidth: 600,
cacheLimit: 20 // 新增:缓存上限
};
// --- Inject Custom CSS Styles ---
GM_addStyle(`
/* ... 您的所有 CSS 样式代码保持不变 ... */
/* 新增:缓存状态显示样式 */
#comfyui-cache-status {
margin-top: 15px;
margin-bottom: 10px;
padding: 8px;
background-color: rgba(0,0,0,0.2);
border: 1px solid var(--SmartThemeBorderColor, #555);
border-radius: 4px;
text-align: center;
font-size: 0.9em;
color: #ccc;
}
/* 控制面板主容器样式 */
#${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;
// *** UI 修改:移除工作流文本域,替换为缓存管理 ***
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">调度器 URL</label>
<div class="comfy-url-container">
<input id="comfyui-url" type="text" placeholder="例如: http://127.0.0.1:5001">
<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>
<div>
<label for="comfyui-cache-limit">最大缓存数量:</label>
<input id="comfyui-cache-limit" type="number" placeholder="例如: 20" min="1" max="100">
</div>
<div id="comfyui-cache-status">当前缓存: ...</div>
<button id="comfyui-clear-cache" class="comfy-button error" style="margin-top: 15px; width: 100%;">删除所有图片缓存</button>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', panelHTML);
initPanelLogic();
}
async function updateCacheStatusDisplay() {
const display = document.getElementById('comfyui-cache-status');
if (!display) return;
const records = await GM_getValue(STORAGE_KEY_IMAGES, {});
const count = Object.keys(records).length;
display.textContent = `当前缓存: ${count} / ${cachedSettings.cacheLimit} 张`;
}
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 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');
const cacheLimitInput = document.getElementById('comfyui-cache-limit'); // 新增
closeButton.addEventListener('click', () => { panel.style.display = 'none'; });
testButton.addEventListener('click', () => {
let url = urlInput.value.trim();
if (!url) {
if (typeof toastr !== 'undefined') toastr.warning('请输入调度器的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.className = 'comfy-button testing';
testButton.disabled = true;
GM_xmlhttpRequest({
method: "GET", url: testUrl, timeout: 5000,
onload: (res) => {
testButton.disabled = false;
testButton.className = res.status === 200 ? 'comfy-button success' : 'comfy-button error';
if (typeof toastr !== 'undefined') {
if(res.status === 200) toastr.success('连接成功!');
else toastr.error(`连接失败!状态: ${res.status}`);
}
},
onerror: () => {
testButton.disabled = false; testButton.className = 'comfy-button error';
if (typeof toastr !== 'undefined') toastr.error('连接错误!');
},
ontimeout: () => {
testButton.disabled = false; testButton.className = 'comfy-button error';
if (typeof toastr !== 'undefined') toastr.error('连接超时!');
}
});
});
clearCacheButton.addEventListener('click', async () => {
if (confirm('您确定要删除所有已生成的图片缓存吗?')) {
await GM_setValue(STORAGE_KEY_IMAGES, {});
await updateCacheStatusDisplay(); // 更新显示
if (typeof toastr !== 'undefined') toastr.success('图片缓存已清空!');
}
});
loadSettings(urlInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput, cacheLimitInput).then(() => {
applyCurrentMaxWidthToAllImages();
});
[urlInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput, cacheLimitInput].forEach(input => {
input.addEventListener('input', async () => {
if (input === urlInput) testButton.className = 'comfy-button';
await saveSettings(urlInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput, cacheLimitInput);
if (input === maxWidthInput) applyCurrentMaxWidthToAllImages();
});
});
}
async function loadSettings(urlInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput, cacheLimitInput) {
cachedSettings.comfyuiUrl = await GM_getValue('comfyui_url', 'http://127.0.0.1:5001');
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);
cachedSettings.cacheLimit = await GM_getValue(STORAGE_KEY_CACHE_LIMIT, 20); // 新增
urlInput.value = cachedSettings.comfyuiUrl;
startTagInput.value = cachedSettings.startTag;
endTagInput.value = cachedSettings.endTag;
promptPrefixInput.value = cachedSettings.promptPrefix;
maxWidthInput.value = cachedSettings.maxWidth;
cacheLimitInput.value = cachedSettings.cacheLimit; // 新增
document.documentElement.style.setProperty('--comfy-image-max-width', (cachedSettings.maxWidth || 600) + 'px');
await updateCacheStatusDisplay(); // 加载设置后更新显示
}
async function saveSettings(urlInput, startTagInput, endTagInput, promptPrefixInput, maxWidthInput, cacheLimitInput) {
cachedSettings.comfyuiUrl = urlInput.value.trim();
cachedSettings.startTag = startTagInput.value;
cachedSettings.endTag = endTagInput.value;
cachedSettings.promptPrefix = promptPrefixInput.value.trim();
const newMaxWidth = parseInt(maxWidthInput.value);
cachedSettings.maxWidth = isNaN(newMaxWidth) ? 600 : newMaxWidth;
const newCacheLimit = parseInt(cacheLimitInput.value); // 新增
cachedSettings.cacheLimit = isNaN(newCacheLimit) ? 20 : newCacheLimit; // 新增
await GM_setValue('comfyui_url', cachedSettings.comfyuiUrl);
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);
await GM_setValue(STORAGE_KEY_CACHE_LIMIT, cachedSettings.cacheLimit); // 新增
document.documentElement.style.setProperty('--comfy-image-max-width', cachedSettings.maxWidth + 'px');
await updateCacheStatusDisplay(); // 保存设置后更新显示
}
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();
document.getElementById(PANEL_ID).style.display = 'flex';
document.getElementById('options').style.display = 'none';
});
continueButton.parentNode.insertBefore(comfyButton, continueButton.nextSibling);
}
}
}
// --- Helper and Cache Management Functions ---
// ... (Your existing helper functions: escapeRegex, generateClientId, simpleHash)
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);
}
function fetchImageAsBase64(imageUrl) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url: imageUrl, responseType: 'blob', timeout: 30000,
onload: (response) => {
if (response.status === 200) {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = (err) => reject(new Error('FileReader error: ' + err));
reader.readAsDataURL(response.response);
} else {
reject(new Error(`获取图片失败,状态: ${response.status}`));
}
},
onerror: (err) => reject(new Error('网络错误,无法下载图片: ' + err)),
ontimeout: () => reject(new Error('下载图片超时。'))
});
});
}
async function saveImageRecord(generationId, imageBase64Data) {
let records = await GM_getValue(STORAGE_KEY_IMAGES, {});
if (records.hasOwnProperty(generationId)) {
delete records[generationId];
}
records[generationId] = imageBase64Data;
const keys = Object.keys(records);
// *** 使用用户自定义的缓存上限 ***
if (keys.length > cachedSettings.cacheLimit) {
const keysToDelete = keys.slice(0, keys.length - cachedSettings.cacheLimit);
keysToDelete.forEach(key => { delete records[key]; });
console.log(`缓存已满,删除了 ${keysToDelete.length} 条旧记录。`);
if (typeof toastr !== 'undefined') toastr.info(`缓存已更新,旧记录已清理。`);
}
await GM_setValue(STORAGE_KEY_IMAGES, records);
await updateCacheStatusDisplay(); // 更新显示
}
async function deleteImageRecord(generationId) {
const records = await GM_getValue(STORAGE_KEY_IMAGES, {});
delete records[generationId];
await GM_setValue(STORAGE_KEY_IMAGES, records);
await updateCacheStatusDisplay(); // 更新显示
}
// --- Chat Message Processing and Image Generation ---
// ... (Your existing chat processing functions: handleComfyButtonClick, processMessageForComfyButton, etc.)
function handleComfyButtonClick(event, isTouch = false) {
const button = event.target.closest('.comfy-chat-generate-button');
if (!button) return;
if (isTouch) {
event.preventDefault();
const now = Date.now();
if (now - lastTapTimestamp < TAP_THRESHOLD) return;
lastTapTimestamp = now;
onGenerateButtonClickLogic(button);
} else {
if (Date.now() - lastTapTimestamp < TAP_THRESHOLD) return;
onGenerateButtonClickLogic(button);
}
}
async function processMessageForComfyButton(messageNode, savedImagesCache) {
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>`;
});
}
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]) {
displayImage(group, savedImagesCache[generationId]);
setupGeneratedState(generateButton, generationId);
}
group.dataset.listenerAttached = 'true';
});
}
function setupGeneratedState(generateButton, generationId) {
generateButton.textContent = '重新生成';
generateButton.disabled = false;
generateButton.classList.remove('testing', 'success', 'error');
delete generateButton.dataset.cooldownEnd;
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.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) return;
if (Date.now() < globalCooldownEndTime) {
const remainingTime = Math.ceil((globalCooldownEndTime - Date.now()) / 1000);
if (typeof toastr !== 'undefined') toastr.warning(`请稍候,冷却中 (${remainingTime}s)。`);
return;
}
button.textContent = '生成中...';
button.disabled = true;
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;
const promptPrefix = cachedSettings.promptPrefix;
if (!url) throw new Error('调度器 URL 未配置。');
if (promptPrefix) prompt = promptPrefix + ' ' + prompt;
const clientId = generateClientId();
// *** 逻辑简化:总是使用调度器模式 ***
if (typeof toastr !== 'undefined') toastr.info('正在向调度器发送请求...');
const promptResponse = await sendPromptRequestToScheduler(url, {
client_id: clientId,
positive_prompt: prompt
});
if (promptResponse.assigned_instance_name) {
if (typeof toastr !== 'undefined') toastr.success(`任务已分配到: ${promptResponse.assigned_instance_name} (队列: ${promptResponse.assigned_instance_queue_size})`);
}
const promptId = promptResponse.prompt_id;
if (!promptId) throw new Error('调度器未返回有效的任务 ID。');
const finalHistory = await pollForResult(url, promptId);
const imageUrl = findImageUrlInHistory(finalHistory, promptId, url);
if (!imageUrl) throw new Error('未在结果中找到图片 URL。');
if (typeof toastr !== 'undefined') toastr.info('正在获取图片数据并缓存...');
const imageBase64Data = await fetchImageAsBase64(imageUrl);
if (!imageBase64Data) throw new Error('无法获取图片数据。');
if (oldImageContainer) oldImageContainer.remove();
displayImage(group, imageBase64Data);
await saveImageRecord(generationId, imageBase64Data);
button.textContent = '生成成功';
button.classList.remove('testing', 'success');
setTimeout(() => {
setupGeneratedState(button, generationId);
if (deleteButton) deleteButton.style.display = 'inline-flex';
}, 2000);
} catch (e) {
console.error('ComfyUI 生图脚本错误:', e);
let displayMessage = '图片生成失败,请检查服务。';
let isRateLimitError = false;
let actualCooldownSeconds = COOLDOWN_DURATION_MS / 1000;
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 {
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) {
const newCooldownEndTime = Date.now() + (actualCooldownSeconds * 1000);
globalCooldownEndTime = newCooldownEndTime;
applyGlobalCooldown(newCooldownEndTime);
} else {
button.textContent = '生成失败';
button.classList.add('error');
setTimeout(() => {
const wasRegenerating = !!group.querySelector('.comfy-delete-button');
button.classList.remove('error');
if (wasRegenerating) {
setupGeneratedState(button, generationId);
} else {
button.textContent = '开始生成';
button.disabled = false;
}
}, 3000);
}
}
}
// ... (Your other functions like applyGlobalCooldown, startCooldownCountdown, sendPromptRequestToScheduler, pollForResult, findImageUrlInHistory, displayImage remain the same)
function applyGlobalCooldown(endTime) {
const allGenerateButtons = document.querySelectorAll('.comfy-chat-generate-button');
allGenerateButtons.forEach(button => {
button.dataset.cooldownEnd = endTime.toString();
startCooldownCountdown(button, endTime);
});
}
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();
}
// --- API Request Functions (sendPromptRequestToScheduler, sendPromptRequestDirect, etc.) ---
// ... (Your existing API functions remain largely unchanged)
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 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;
}
async function displayImage(anchorElement, imageBase64Data) {
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 生成的图片';
container.appendChild(img);
anchorElement.insertAdjacentElement('afterend', container);
}
const imgElement = container.querySelector('img');
imgElement.src = imageBase64Data;
imgElement.style.maxWidth = (cachedSettings.maxWidth || 600) + 'px';
}
// --- Main Execution Logic ---
// ... (Main logic functions: createComfyUIPanel, chatObserver, observeChat, etc.)
// ... (Your existing API functions remain largely unchanged)
// --- Main Execution Logic ---
createComfyUIPanel();
const chatObserver = new MutationObserver(async (mutations) => {
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));
}
});
if (mutation.target.nodeType === Node.ELEMENT_NODE && mutation.target.closest('.mes')) {
nodesToProcess.add(mutation.target.closest('.mes'));
}
}
if (nodesToProcess.size > 0) {
const savedImages = await GM_getValue(STORAGE_KEY_IMAGES, {});
await loadSettingsFromStorageAndApplyToCache();
nodesToProcess.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, savedImages);
});
}
});
async function loadSettingsFromStorageAndApplyToCache() {
cachedSettings.comfyuiUrl = await GM_getValue('comfyui_url', 'http://127.0.0.1:5001');
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);
cachedSettings.cacheLimit = await GM_getValue(STORAGE_KEY_CACHE_LIMIT, 20); // 新增
document.documentElement.style.setProperty('--comfy-image-max-width', (cachedSettings.maxWidth || 600) + 'px');
}
function observeChat() {
const chatElement = document.getElementById('chat');
if (chatElement) {
loadSettingsFromStorageAndApplyToCache().then(async () => {
const initialSavedImages = await GM_getValue(STORAGE_KEY_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);
});
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'] });
}
});
})();