// ==UserScript==
// @name 酒馆ComfyUI插图脚本
// @namespace http://tampermonkey.net/
// @version 126
// @license GPL
// @description 用于酒馆SillyTavern的ai插图脚本,使用酒馆世界书生成的prompt通过ComfyUI API生图
// @author soulostar
// @match *://*/*
// @require https://code.jquery.com/jquery-3.4.1.min.js
// @grant unsafeWindow
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_xmlhttpRequest
// @connect *
// ==/UserScript==
(function() {
'use strict';
let ster = null; // UI injection timer
let scanSter = null; // Button scanning timer
let comfyuiPanel = null; // Settings panel DOM element
let panelListenersAttached = false; // Settings panel event listener flag
let isGenerating = {}; // Generation lock per prompt
const CACHE_PREFIX = 'comfyui_image_cache_'; // GM storage key prefix
// --- Default Settings ---
const defaultSettings = {
url: 'http://127.0.0.1:8188',
workflow: `{ /* Default Workflow JSON */
"comment": "默认工作流示例,请确保包含 '%prompt%' 和可选的 '%seed%' 占位符",
"3": { "inputs": { "seed": "%seed%", "steps": 20, "cfg": 8, "sampler_name": "euler", "scheduler": "normal", "denoise": 1, "model": ["4", 0], "positive": ["6", 0], "negative": ["7", 0], "latent_image": ["5", 0] }, "class_type": "KSampler" },
"4": { "inputs": { "ckpt_name": "model.safetensors" }, "class_type": "CheckpointLoaderSimple" },
"5": { "inputs": { "width": 512, "height": 512, "batch_size": 1 }, "class_type": "EmptyLatentImage" },
"6": { "inputs": { "text": "%prompt%", "clip": ["4", 1] }, "class_type": "CLIPTextEncode" },
"7": { "inputs": { "text": "bad quality", "clip": ["4", 1] }, "class_type": "CLIPTextEncode" },
"8": { "inputs": { "samples": ["3", 0], "vae": ["4", 2] }, "class_type": "VAEDecode" },
"9": { "inputs": { "filename_prefix": "ComfyUI", "images": ["8", 0] }, "class_type": "SaveImage" }
}`, // Example now includes %seed%
startTag: 'image###',
endTag: '###',
targetSelector: '.mes_text',
imageMaxWidth: '300px',
autoGenerate: false // Auto-generate toggle
};
// --- Load Settings ---
let comfyuiSettings = {};
for (const key in defaultSettings) {
comfyuiSettings[key] = GM_getValue(`comfyui_${key}`, defaultSettings[key]);
}
// Ensure boolean type for autoGenerate
if (typeof comfyuiSettings.autoGenerate !== 'boolean') {
comfyuiSettings.autoGenerate = defaultSettings.autoGenerate;
GM_setValue('comfyui_autoGenerate', comfyuiSettings.autoGenerate);
}
// Ensure workflow is string
if (typeof comfyuiSettings.workflow !== 'string') {
comfyuiSettings.workflow = defaultSettings.workflow;
GM_setValue('comfyui_workflow', comfyuiSettings.workflow);
}
// Ensure imageMaxWidth is loaded correctly
if (typeof comfyuiSettings.imageMaxWidth !== 'string' || !comfyuiSettings.imageMaxWidth) {
comfyuiSettings.imageMaxWidth = defaultSettings.imageMaxWidth;
GM_setValue('comfyui_imageMaxWidth', comfyuiSettings.imageMaxWidth);
}
// --- Helper Functions (escapeRegExp, generateUniqueId, getCacheKey, blobToDataURL) ---
function escapeRegExp(string) { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function generateUniqueId(prefix = 'id') { return `${prefix}-${Math.random().toString(36).substr(2, 9)}`; }
function getCacheKey(prompt) {
const maxLength = 100;
const truncatedPrompt = prompt.length > maxLength ? prompt.substring(0, maxLength) : prompt;
const safePromptPart = truncatedPrompt.replace(/[^a-zA-Z0-9]/g, '_');
return `${CACHE_PREFIX}${safePromptPart}`;
}
function blobToDataURL(blob) { /* ... (implementation remains the same) ... */
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
reader.readAsDataURL(blob);
});
}
// --- Helper: Generate Random Seed ---
function generateRandomSeed() {
// Generate a large random integer (adjust range as needed for ComfyUI)
return Math.floor(Math.random() * 0xFFFFFFFFFFFFF); // Max safe integer is not needed here
}
// --- Create Settings Panel (Add Auto Generate Toggle) ---
function createComfyUIPanel() {
if (document.getElementById('comfyui-settings-panel')) return document.getElementById('comfyui-settings-panel');
console.log("创建 ComfyUI 设置面板...");
const panel = document.createElement('div');
panel.id = 'comfyui-settings-panel';
// --- Styles (remain mostly the same) ---
panel.style.position = 'fixed';
// **Mobile Adjustments:**
// Start closer to the top instead of exact vertical center
panel.style.top = '20px';
panel.style.left = '50%';
// Only translate horizontally to center it
panel.style.transform = 'translateX(-50%)';
// Adjust width clamp for smaller screens (e.g., min 300px, use 90% of viewport width)
panel.style.width = 'clamp(300px, 90vw, 750px)';
// Ensure max height leaves some space, combined with top: 20px
panel.style.maxHeight = 'calc(100vh - 40px)'; // 20px top margin + 20px bottom margin
panel.style.overflowY = 'auto'; // Allow scrolling if content overflows
// --- Standard Styles (mostly unchanged) ---
panel.style.backgroundColor = '#333';
panel.style.color = 'white';
panel.style.padding = '20px'; // Slightly reduced padding for smaller screens
panel.style.border = '1px solid #555';
panel.style.borderRadius = '8px';
panel.style.boxShadow = '0 5px 15px rgba(0,0,0,0.5)';
panel.style.zIndex = '10001'; // Ensure it's on top
panel.style.display = 'none'; // Initially hidden
panel.style.fontFamily = 'sans-serif';
panel.style.boxSizing = 'border-box';
panel.innerHTML = `
<h3 style="text-align: center; margin-top: 0; margin-bottom: 20px; border-bottom: 1px solid #555; padding-bottom: 10px;">ComfyUI 设置</h3>
<div style="margin-bottom: 15px;">
<label for="comfyui-url-input" style="display: block; margin-bottom: 5px; font-weight: bold;">ComfyUI URL:</label>
<div style="display: flex; gap: 10px; align-items: center;">
<input type="text" id="comfyui-url-input" style="flex-grow: 1; padding: 8px; border-radius: 4px; border: 1px solid #666; background-color: #444; color: white;">
<button id="test-comfyui-connection" style="padding: 8px 12px; border: none; border-radius: 4px; background-color: #555; color: white; cursor: pointer; transition: background-color 0.3s; white-space: nowrap;">测试连接</button>
</div>
<div id="connection-status" style="font-size: 0.8em; margin-top: 5px; height: 1.2em;"></div>
</div>
<div style="margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 15px;">
<div style="flex: 1 1 150px;">
<label for="comfyui-starttag-input" style="display: block; margin-bottom: 5px; font-weight: bold;">开始标记:</label>
<input type="text" id="comfyui-starttag-input" style="width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #666; background-color: #444; color: white;">
</div>
<div style="flex: 1 1 150px;">
<label for="comfyui-endtag-input" style="display: block; margin-bottom: 5px; font-weight: bold;">结束标记:</label>
<input type="text" id="comfyui-endtag-input" style="width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #666; background-color: #444; color: white;">
</div>
<div style="flex: 1 1 150px;">
<label for="comfyui-imageMaxWidth-input" style="display: block; margin-bottom: 5px; font-weight: bold;">图片最大宽度:</label>
<input type="text" id="comfyui-imageMaxWidth-input" style="width: 95%; padding: 8px; border-radius: 4px; border: 1px solid #666; background-color: #444; color: white;" placeholder="例如: 300px, 50%">
</div>
</div>
<div style="margin-bottom: 15px; display: flex; flex-wrap: wrap; gap: 15px; align-items: center;">
<div style="flex: 2 1 300px;">
<label for="comfyui-targetselector-input" style="display: block; margin-bottom: 5px; font-weight: bold;">扫描目标选择器:</label>
<input type="text" id="comfyui-targetselector-input" style="width: 98%; padding: 8px; border-radius: 4px; border: 1px solid #666; background-color: #444; color: white;" placeholder="例如: .chat-message, .mes_text">
</div>
<div style="flex: 1 1 150px; padding-top: 20px;"> <label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="comfyui-autogenerate-toggle" style="margin-right: 8px; transform: scale(1.2);">
<span style="font-weight: bold;">自动生成图片</span>
</label>
</div>
</div>
<div style="margin-bottom: 20px;">
<label for="comfyui-workflow-input" style="display: block; margin-bottom: 5px; font-weight: bold;">工作流 (JSON - 支持 "%prompt%" 和 "%seed%" 占位符):</label>
<textarea id="comfyui-workflow-input" style="width: 98%; height: 250px; padding: 8px; border-radius: 4px; border: 1px solid #666; background-color: #444; color: white; resize: vertical; font-family: monospace;"></textarea>
</div>
<div style="margin-top: 20px; border-top: 1px solid #555; padding-top: 15px; display: flex; justify-content: space-between; align-items: center; gap: 10px;">
<button id="clear-comfyui-cache" style="padding: 10px 15px; border: none; border-radius: 4px; background-color: #ff9800; color: white; cursor: pointer; font-weight: bold;">清除图片缓存</button>
<div>
<button id="save-comfyui-settings" style="padding: 10px 15px; border: none; border-radius: 4px; background-color: #4CAF50; color: white; cursor: pointer; font-weight: bold; margin-left: 10px;">保存设置</button>
<button id="close-comfyui-panel" style="padding: 10px 15px; border: none; border-radius: 4px; background-color: #f44336; color: white; cursor: pointer; font-weight: bold; margin-left: 10px;">关闭</button>
</div>
</div>
`;
document.body.appendChild(panel);
comfyuiPanel = panel;
if (!panelListenersAttached) {
console.log("为面板按钮附加事件监听器...");
try {
document.getElementById('test-comfyui-connection').addEventListener('click', testComfyuiConnection);
document.getElementById('save-comfyui-settings').addEventListener('click', saveComfyUISettings);
document.getElementById('close-comfyui-panel').addEventListener('click', hideComfyUIPanel);
document.getElementById('clear-comfyui-cache').addEventListener('click', clearComfyUIImageCache);
panelListenersAttached = true;
} catch (error) { console.error("添加面板监听器时出错:", error); }
}
return panel;
}
// --- Show Settings Panel (Load Auto Generate State) ---
function showComfyUIPanel() {
const panel = comfyuiPanel || createComfyUIPanel();
if (!panel) { console.error("无法创建或找到设置面板。"); return; }
try {
document.getElementById('comfyui-url-input').value = comfyuiSettings.url;
document.getElementById('comfyui-workflow-input').value = comfyuiSettings.workflow;
document.getElementById('comfyui-starttag-input').value = comfyuiSettings.startTag;
document.getElementById('comfyui-endtag-input').value = comfyuiSettings.endTag;
document.getElementById('comfyui-targetselector-input').value = comfyuiSettings.targetSelector;
document.getElementById('comfyui-imageMaxWidth-input').value = comfyuiSettings.imageMaxWidth;
document.getElementById('comfyui-autogenerate-toggle').checked = comfyuiSettings.autoGenerate; // Set checkbox state
const testButton = document.getElementById('test-comfyui-connection');
const statusDiv = document.getElementById('connection-status');
if (testButton) testButton.style.backgroundColor = '#555';
if (statusDiv) statusDiv.textContent = '';
panel.style.display = 'block';
console.log("ComfyUI 设置面板已显示。");
} catch (error) { console.error("填充设置面板时出错:", error); alert("无法加载设置面板,请检查控制台。"); }
}
// --- Hide Settings Panel (Remains the same) ---
function hideComfyUIPanel() { /* ... */
if (comfyuiPanel) { comfyuiPanel.style.display = 'none'; console.log("ComfyUI 设置面板已隐藏。"); }
}
// --- Test Connection (Remains the same) ---
function testComfyuiConnection() { /* ... */
const urlInput = document.getElementById('comfyui-url-input');
const testButton = document.getElementById('test-comfyui-connection');
const statusDiv = document.getElementById('connection-status');
let url = urlInput.value.trim();
if (!url) { statusDiv.textContent = '请输入URL'; statusDiv.style.color = 'orange'; return; }
if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'http://' + url; }
if (url.endsWith('/')) { url = url.slice(0, -1); }
const testUrl = url + '/system_stats';
console.log(`尝试连接到: ${testUrl}`);
statusDiv.textContent = '正在连接...'; statusDiv.style.color = 'white';
testButton.style.backgroundColor = '#555'; testButton.disabled = true;
GM_xmlhttpRequest({
method: "GET", url: testUrl, timeout: 5000,
onload: (res) => { /* ... */
testButton.disabled = false;
if (res.status === 200) { testButton.style.backgroundColor = 'green'; statusDiv.textContent = '连接成功!'; statusDiv.style.color = 'lightgreen'; }
else { testButton.style.backgroundColor = 'orange'; statusDiv.textContent = `连接失败 (状态: ${res.status})`; statusDiv.style.color = 'orange'; }
},
onerror: (err) => { /* ... */ testButton.disabled = false; testButton.style.backgroundColor = 'orange'; statusDiv.textContent = '连接错误 (请检查URL或网络)'; statusDiv.style.color = 'orange'; },
ontimeout: () => { /* ... */ testButton.disabled = false; testButton.style.backgroundColor = 'orange'; statusDiv.textContent = '连接超时'; statusDiv.style.color = 'orange'; }
});
}
// --- Save Settings (Add Auto Generate) ---
function saveComfyUISettings() {
const urlInput = document.getElementById('comfyui-url-input');
const workflowInput = document.getElementById('comfyui-workflow-input');
const startTagInput = document.getElementById('comfyui-starttag-input');
const endTagInput = document.getElementById('comfyui-endtag-input');
const targetSelectorInput = document.getElementById('comfyui-targetselector-input');
const imageMaxWidthInput = document.getElementById('comfyui-imageMaxWidth-input');
const autoGenerateToggle = document.getElementById('comfyui-autogenerate-toggle'); // Get toggle element
const newUrl = urlInput.value.trim();
const newWorkflow = workflowInput.value;
const newStartTag = startTagInput.value.trim();
const newEndTag = endTagInput.value.trim();
const newTargetSelector = targetSelectorInput.value.trim();
const newImageMaxWidth = imageMaxWidthInput.value.trim();
const newAutoGenerate = autoGenerateToggle.checked; // Get toggle state
if (!newUrl || !newStartTag || !newEndTag || !newTargetSelector || !newImageMaxWidth) {
alert("保存失败: URL, 开始/结束标记, 目标选择器, 和图片最大宽度不能为空。");
return;
}
try {
// Basic JSON validation (doesn't guarantee workflow validity)
JSON.parse(newWorkflow);
comfyuiSettings.url = newUrl;
comfyuiSettings.workflow = newWorkflow;
comfyuiSettings.startTag = newStartTag;
comfyuiSettings.endTag = newEndTag;
comfyuiSettings.targetSelector = newTargetSelector;
comfyuiSettings.imageMaxWidth = newImageMaxWidth;
comfyuiSettings.autoGenerate = newAutoGenerate; // Save new setting
GM_setValue('comfyui_url', newUrl);
GM_setValue('comfyui_workflow', newWorkflow);
GM_setValue('comfyui_startTag', newStartTag);
GM_setValue('comfyui_endTag', newEndTag);
GM_setValue('comfyui_targetSelector', newTargetSelector);
GM_setValue('comfyui_imageMaxWidth', newImageMaxWidth);
GM_setValue('comfyui_autoGenerate', newAutoGenerate); // Store new setting
console.log("ComfyUI 设置已保存:", comfyuiSettings);
alert("设置已保存!");
hideComfyUIPanel();
updateImageStyles();
startScanning();
} catch (e) {
alert(`保存失败: 工作流 JSON 格式无效。\n错误: ${e.message}`);
console.error("无效的 JSON 工作流:", e);
}
}
// --- Clear Image Cache (Remains the same) ---
function clearComfyUIImageCache() { /* ... */
if (!confirm("确定要清除所有缓存的 ComfyUI 图片吗?此操作无法撤销。")) { return; }
let clearedCount = 0; const keys = GM_listValues();
keys.forEach(key => { if (key.startsWith(CACHE_PREFIX)) { GM_deleteValue(key); clearedCount++; } });
alert(`已清除 ${clearedCount} 张缓存图片。`); console.log(`Cleared ${clearedCount} cached images.`);
scanAndInjectButtons();
}
// --- Toggle Image Visibility (Remains the same) ---
function toggleImageVisibility(buttonElement) { /* ... */
const imageSpanId = buttonElement.dataset.imageSpanId;
const imageSpan = document.getElementById(imageSpanId);
if (imageSpan) {
const img = imageSpan.querySelector('img');
if (img) {
if (img.style.display === 'none') {
img.style.display = 'block';
buttonElement.textContent = '隐藏';
} else {
img.style.display = 'none';
buttonElement.textContent = '显示';
}
}
}
}
// --- Delete Image and Cache (Update button states) ---
function deleteImageAndCache(buttonElement) {
const prompt = buttonElement.dataset.prompt; // Get prompt from button
if (!prompt) {
console.warn("Delete button missing prompt data.");
return; // Should not happen if created correctly
}
const cacheKey = getCacheKey(prompt);
const imageSpanId = buttonElement.dataset.imageSpanId;
const imageSpan = document.getElementById(imageSpanId);
const buttonContainer = buttonElement.parentElement;
if (confirm(`确定要删除提示为 "${prompt.substring(0, 30)}..." 的图片及其缓存吗?`)) {
if (imageSpan) { imageSpan.innerHTML = ''; } // Clear image display
GM_deleteValue(cacheKey); // Delete from storage
console.log(`Image cache deleted for key: ${cacheKey}`);
// Update main button state
const mainButton = buttonContainer.querySelector('button[id^="comfy-button-generate-"]');
if (mainButton) {
mainButton.textContent = '生成图片';
mainButton.disabled = false; // Ensure it's enabled
}
// Disable Hide and Delete buttons as there's nothing to hide/delete
const hideButton = buttonContainer.querySelector('button[id^="comfy-button-hide-"]');
const deleteBtn = buttonContainer.querySelector('button[id^="comfy-button-delete-"]'); // Use different name
if (hideButton) {
hideButton.disabled = true;
hideButton.textContent = '隐藏'; // Reset text
}
if (deleteBtn) {
deleteBtn.disabled = true;
}
}
}
// --- ComfyUI Image Generation (Replace Seed, Enable controls) ---
async function generateComfyUIImage(buttonElement) {
const prompt = buttonElement.dataset.prompt;
const cacheKey = getCacheKey(prompt);
if (isGenerating[cacheKey]) { alert("当前正在为该提示生成图片,请稍候..."); return; }
isGenerating[cacheKey] = true;
buttonElement.textContent = '生成中...'; buttonElement.disabled = true;
const buttonContainer = buttonElement.parentElement;
const hideButton = buttonContainer.querySelector('button[id^="comfy-button-hide-"]');
const deleteButton = buttonContainer.querySelector('button[id^="comfy-button-delete-"]');
if(hideButton) hideButton.disabled = true;
if(deleteButton) deleteButton.disabled = true;
const imageSpanId = buttonElement.dataset.imageSpanId;
const imageSpan = document.getElementById(imageSpanId);
if (imageSpan) imageSpan.innerHTML = '<i>正在加载...</i>';
let workflowString = comfyuiSettings.workflow; // Start with the template string
const url = comfyuiSettings.url.endsWith('/') ? comfyuiSettings.url.slice(0, -1) : comfyuiSettings.url;
// 1. Replace Prompt Placeholder
const escapedPrompt = JSON.stringify(prompt).slice(1, -1); // Escape prompt for JSON string
workflowString = workflowString.replace(/"%prompt%"/g, `"${escapedPrompt}"`);
// 2. Replace Seed Placeholder (if exists)
if (workflowString.includes('"%seed%"')) {
const randomSeed = generateRandomSeed();
console.log(`Using random seed: ${randomSeed}`);
// Replace "%seed%" (including quotes) with the number
workflowString = workflowString.replace(/"%seed%"/g, randomSeed.toString());
} else {
console.log("Workflow does not contain '%seed%', using workflow's default seed.");
}
const clientId = generateUniqueId('client');
let payload;
try {
// 3. Parse the modified workflow string
payload = { client_id: clientId, prompt: JSON.parse(workflowString) };
}
catch (e) {
console.error("解析工作流 JSON 失败:", e, workflowString); // Log the processed string
alert(`工作流 JSON 格式错误 (替换占位符后): ${e.message}`);
buttonElement.textContent = '生成图片'; buttonElement.disabled = false;
if(hideButton) hideButton.disabled = false; if(deleteButton) deleteButton.disabled = false;
if (imageSpan) imageSpan.innerHTML = '<i style="color:red;">工作流错误</i>';
delete isGenerating[cacheKey]; return;
}
console.log("发送到 ComfyUI 的 Payload:", JSON.stringify(payload, null, 2));
try {
// --- Steps 4-7: /prompt request, /history polling, extract info, get blob (remain the same) ---
const response = await new Promise((resolve, reject) => { /* /prompt request */
GM_xmlhttpRequest({
method: "POST", url: `${url}/prompt`, headers: { "Content-Type": "application/json" }, data: JSON.stringify(payload), timeout: 10000,
onload: (res) => res.status >= 200 && res.status < 300 ? resolve(JSON.parse(res.responseText)) : reject(new Error(`/prompt 请求失败: ${res.status} ${res.statusText} - ${res.responseText}`)),
onerror: (err) => reject(new Error(`网络错误: ${err}`)), ontimeout: () => reject(new Error("/prompt 超时")) }); });
const promptId = response.prompt_id; if (!promptId) throw new Error("未能获取 prompt_id"); buttonElement.textContent = '等待生成...';
let historyData = null, attempts = 0, maxAttempts = 60; while (attempts < maxAttempts) { /* /history polling */
await new Promise(resolve => setTimeout(resolve, 2000)); attempts++; buttonElement.textContent = `等待生成... (${attempts})`; try { const historyResponse = await new Promise((resolve) => { GM_xmlhttpRequest({ method: "GET", url: `${url}/history/${promptId}`, timeout: 5000, onload: (res) => res.status === 200 ? resolve(JSON.parse(res.responseText)) : resolve(null), onerror: (err) => { console.error(`/history error:`, err); resolve(null); }, ontimeout: () => { console.warn(`/history timeout`); resolve(null); } }); }); if (historyResponse && historyResponse[promptId]) { historyData = historyResponse[promptId]; break; } } catch (e) { console.warn("Polling /history error:", e); } } if (!historyData) throw new Error(`未能获取生成结果 (prompt_id: ${promptId})`);
const outputs = historyData.outputs; let imageFilename = null, imageType = 'output', imageSubfolder = ''; for (const nodeId in outputs) { /* Find image output */ if (outputs[nodeId].images && outputs[nodeId].images.length > 0) { const imgInfo = outputs[nodeId].images[0]; imageFilename = imgInfo.filename; imageType = imgInfo.type || 'output'; imageSubfolder = imgInfo.subfolder || ''; break; } } if (!imageFilename) throw new Error("未能找到图片文件名"); buttonElement.textContent = '加载图片...';
const imageUrl = `${url}/view?filename=${encodeURIComponent(imageFilename)}&type=${imageType}&subfolder=${encodeURIComponent(imageSubfolder)}`; const imageBlob = await new Promise((resolve, reject) => { /* Fetch image blob */ GM_xmlhttpRequest({ method: "GET", url: imageUrl, responseType: 'blob', timeout: 15000, onload: (res) => res.status === 200 ? resolve(res.response) : reject(new Error(`无法加载图片: ${res.status}`)), onerror: (err) => reject(new Error(`加载图片网络错误: ${err}`)), ontimeout: () => reject(new Error("加载图片超时")) }); });
// --- Step 8: Convert and Cache ---
const imageDataUrl = await blobToDataURL(imageBlob);
GM_setValue(cacheKey, imageDataUrl);
console.log(`图片已缓存 (Key: ${cacheKey})`);
// --- Step 9: Display Image and Enable Controls ---
if (imageSpan) {
const imgElement = document.createElement('img');
imgElement.src = imageDataUrl;
imageSpan.innerHTML = '';
imageSpan.appendChild(imgElement);
}
buttonElement.textContent = '重新生成';
if (hideButton) { hideButton.disabled = false; hideButton.textContent = '隐藏'; }
if (deleteButton) { deleteButton.disabled = false; }
} catch (error) { /* ... (error handling remains the same) ... */
console.error("ComfyUI 图片生成失败:", error); alert(`图片生成失败: ${error.message}`);
buttonElement.textContent = '生成失败'; if (imageSpan) imageSpan.innerHTML = `<i style="color:red;">生成失败</i>`;
setTimeout(() => { buttonElement.textContent = '生成图片'; buttonElement.disabled = false; if(hideButton) hideButton.disabled = false; if(deleteButton) deleteButton.disabled = false; }, 3000);
} finally {
buttonElement.disabled = false;
if(hideButton) hideButton.disabled = false;
if(deleteButton) deleteButton.disabled = false;
delete isGenerating[cacheKey];
}
}
// --- Create Control Buttons (Remains the same) ---
function createControlButton(type, prompt, imageSpanId) { /* ... */
const button = document.createElement('button');
button.id = generateUniqueId(`comfy-button-${type}-`);
button.classList.add('button_image', `button_${type}`);
button.dataset.prompt = prompt; // Add prompt data to delete button
button.dataset.imageSpanId = imageSpanId;
button.style.marginLeft = '5px';
button.style.fontSize = '10px';
button.style.padding = '2px 4px';
if (type === 'hide') {
button.textContent = '隐藏';
// Event listener is added in scanAndInjectButtons
} else if (type === 'delete') {
button.textContent = '删除';
button.style.backgroundColor = '#f44336';
// Event listener is added in scanAndInjectButtons
}
return button;
}
// --- Scan and Inject Buttons (Always add controls, check auto-gen) ---
function scanAndInjectButtons() {
const { startTag, endTag, targetSelector, autoGenerate } = comfyuiSettings; // Get autoGenerate setting
if (!startTag || !endTag || !targetSelector) { return; }
const regex = new RegExp(escapeRegExp(startTag) + '([\\s\\S]*?)' + escapeRegExp(endTag), 'g');
// Select elements that haven't been processed OR elements that were processed but might need updates (e.g., if content changed)
// A more robust approach might involve checking content hash, but this is simpler.
const targetElements = document.querySelectorAll(`${targetSelector}`); // Scan all target elements
targetElements.forEach(element => {
// Only process if content seems to contain tags and hasn't been fully processed with buttons
const needsProcessing = element.textContent.includes(startTag) && !element.querySelector('.comfy-button-container');
if (!needsProcessing && element.dataset.comfyuiProcessed === 'true') {
// Already processed and no tags found, skip.
return;
}
// Reset processed flag if we are reprocessing
element.dataset.comfyuiProcessed = 'false';
const originalHTML = element.innerHTML;
let newHTML = '';
let lastIndex = 0;
let hasMatches = false;
let autoGeneratedPrompts = []; // Track prompts to auto-generate
// Use a temporary div to parse and manipulate nodes safely
const tempDiv = document.createElement('div');
tempDiv.innerHTML = originalHTML;
// Find text nodes containing the start tag
const walker = document.createTreeWalker(tempDiv, NodeFilter.SHOW_TEXT);
let node;
const nodesToReplace = [];
while(node = walker.nextNode()) {
let nodeContent = node.nodeValue;
let match;
let nodeLastIndex = 0;
const replacements = []; // Replacements for this specific text node
regex.lastIndex = 0; // Reset regex index for each text node
while ((match = regex.exec(nodeContent)) !== null) {
hasMatches = true;
const promptText = match[1].trim();
const fullMatchText = match[0];
const cacheKey = getCacheKey(promptText);
const cachedImage = GM_getValue(cacheKey, null);
const hasCache = !!cachedImage;
// Part before the match
replacements.push(document.createTextNode(nodeContent.substring(nodeLastIndex, match.index)));
// --- Create Buttons and Image Span ---
const generateButtonId = generateUniqueId('comfy-button-generate-');
const hideButtonId = generateUniqueId('comfy-button-hide-');
const deleteButtonId = generateUniqueId('comfy-button-delete-');
const spanId = generateUniqueId('comfy-image-span');
const buttonContainer = document.createElement('span');
buttonContainer.classList.add('comfy-button-container');
buttonContainer.style.display = 'inline-block'; // Use inline-block
const generateButton = document.createElement('button');
generateButton.id = generateButtonId; generateButton.classList.add('button_image');
generateButton.textContent = hasCache ? '重新生成' : '生成图片';
generateButton.dataset.prompt = promptText; generateButton.dataset.imageSpanId = spanId;
generateButton.style.margin = '0 3px 0 0';
buttonContainer.appendChild(generateButton);
const hideButton = createControlButton('hide', promptText, spanId);
hideButton.id = hideButtonId; // Assign ID here
hideButton.disabled = !hasCache;
buttonContainer.appendChild(hideButton);
const deleteButton = createControlButton('delete', promptText, spanId);
deleteButton.id = deleteButtonId; // Assign ID here
deleteButton.disabled = !hasCache;
buttonContainer.appendChild(deleteButton);
const imageSpan = document.createElement('span');
imageSpan.id = spanId; imageSpan.classList.add('comfy-image-container');
imageSpan.style.display = 'block'; imageSpan.style.marginTop = '5px';
if (hasCache) {
const imgElement = document.createElement('img'); imgElement.src = cachedImage;
imageSpan.appendChild(imgElement);
} else if (autoGenerate && !isGenerating[cacheKey]) {
autoGeneratedPrompts.push(generateButtonId);
}
// Add the button container and image span to replacements
replacements.push(buttonContainer);
replacements.push(imageSpan);
nodeLastIndex = regex.lastIndex;
}
// Add the remaining part of the text node
replacements.push(document.createTextNode(nodeContent.substring(nodeLastIndex)));
// Store the original node and its replacements
if(replacements.length > 1 || hasMatches) { // Only if actual replacements happened in this node
nodesToReplace.push({ original: node, replacements: replacements });
}
} // End of TreeWalker loop
// Perform replacements after iterating
if (nodesToReplace.length > 0) {
nodesToReplace.forEach(item => {
item.replacements.forEach(newNode => {
item.original.parentNode.insertBefore(newNode, item.original);
});
item.original.parentNode.removeChild(item.original);
});
// Update the element's content with the modified HTML from tempDiv
element.innerHTML = tempDiv.innerHTML;
element.dataset.comfyuiProcessed = 'true'; // Mark as processed
// Re-bind events specifically for the buttons within this element
element.querySelectorAll('button[id^="comfy-button-generate-"]').forEach(btn => {
btn.removeEventListener('click', handleGenerateButtonClick); btn.addEventListener('click', handleGenerateButtonClick); });
element.querySelectorAll('button[id^="comfy-button-hide-"]').forEach(btn => {
btn.removeEventListener('click', handleHideButtonClick); btn.addEventListener('click', handleHideButtonClick); });
element.querySelectorAll('button[id^="comfy-button-delete-"]').forEach(btn => {
btn.removeEventListener('click', handleDeleteButtonClick); btn.addEventListener('click', handleDeleteButtonClick); });
// Trigger auto-generation
if (autoGeneratedPrompts.length > 0) {
setTimeout(() => {
autoGeneratedPrompts.forEach(buttonId => {
const btnToClick = document.getElementById(buttonId); // Find by ID now
if (btnToClick && !isGenerating[getCacheKey(btnToClick.dataset.prompt)]) {
console.log(`Auto-generating for prompt: ${btnToClick.dataset.prompt}`);
handleGenerateButtonClick({ target: btnToClick });
}
});
}, 100);
}
} else if (!element.dataset.comfyuiProcessed) {
// If no matches were found in this scan and it wasn't previously marked, mark it now
// to avoid rescanning unchanged text.
element.dataset.comfyuiProcessed = 'true';
}
}); // End of targetElements.forEach
}
// --- Event Handlers ---
function handleGenerateButtonClick(event) { generateComfyUIImage(event.target); }
function handleHideButtonClick(event) { toggleImageVisibility(event.target); }
function handleDeleteButtonClick(event) { deleteImageAndCache(event.target); }
// --- Start/Restart Scanning ---
function startScanning() {
if (scanSter) { clearInterval(scanSter); }
scanAndInjectButtons(); // Run once immediately
scanSter = setInterval(scanAndInjectButtons, 3000); // Then scan periodically
}
// --- Inject Connect Button ---
function addConnectButton() { /* ... (remains the same) ... */
const targetAnchorSelector = '#option_toggle_AN'; const buttonId = 'connect_comfyui_settings_button';
const targetElement = document.querySelector(targetAnchorSelector);
if (targetElement && !document.getElementById(buttonId)) { if (ster) { clearInterval(ster); ster = null; } const newElement = document.createElement('a'); newElement.id = buttonId; newElement.href = '#'; newElement.classList.add('button_image'); newElement.style.textDecoration = 'none'; newElement.style.marginLeft = '10px'; newElement.style.padding = '5px 8px'; newElement.appendChild(document.createTextNode('连接到ComfyUI')); targetElement.parentNode.insertBefore(newElement, targetElement.nextSibling); newElement.addEventListener('click', (event) => { event.preventDefault(); showComfyUIPanel(); }); } else if (targetElement) { if (ster) { clearInterval(ster); ster = null; } }
}
// --- Update Image Styles ---
function updateImageStyles() { /* ... (remains the same) ... */
const styleId = 'comfyui-image-dynamic-style'; let styleElement = document.getElementById(styleId); if (!styleElement) { styleElement = document.createElement('style'); styleElement.id = styleId; document.head.appendChild(styleElement); } styleElement.textContent = ` .comfy-image-container img { max-width: ${comfyuiSettings.imageMaxWidth || '300px'} !important; height: auto !important; border: 1px solid #555; border-radius: 4px; background-color: #222; display: block; margin-top: 5px; } `; console.log("Image styles updated with max-width:", comfyuiSettings.imageMaxWidth);
}
// --- Script Initialization ---
$(document).ready(function() {
console.log("ComfyUI 图文生成脚本 (带缓存/控件/自动生成/随机种子) 启动...");
// 1. Inject Connect Button
if (!ster) { addConnectButton(); ster = setInterval(addConnectButton, 2000); }
// 2. Start Scanning
startScanning();
// 3. Add Styles
const baseStyle = document.createElement('style');
baseStyle.textContent = `
.button_image { /* Main and connect buttons */
padding: 3px 6px; font-size: 12px; font-weight: 600; color: #ffffff;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); border: none;
border-radius: 4px; cursor: pointer; transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(99, 102, 241, 0.2); display: inline-flex;
align-items: center; gap: 5px; white-space: nowrap; outline: none;
-webkit-appearance: none; -moz-appearance: none; text-decoration: none;
vertical-align: middle; margin: 0 1px;
}
.button_image:hover:not(:disabled) { opacity: 0.85; box-shadow: 0 4px 6px rgba(99, 102, 241, 0.3); }
.button_image:disabled { cursor: not-allowed; opacity: 0.5; background: #777 !important; box-shadow: none !important; } /* More prominent disabled style */
.button_hide, .button_delete { /* Control button specifics */
font-size: 10px !important; padding: 2px 5px !important; margin-left: 4px !important;
}
.button_delete { background: #f44336 !important; }
.button_delete:hover:not(:disabled) { background: #d32f2f !important; }
/* .button_hide:disabled, .button_delete:disabled { background: #777 !important; } /* Disabled style for controls */
#comfyui-settings-panel button { transition: background-color 0.3s, transform 0.1s; }
#comfyui-settings-panel button:hover { opacity: 0.9; }
#comfyui-settings-panel button:active { transform: scale(0.98); }
#test-comfyui-connection:disabled { cursor: not-allowed; opacity: 0.7; }
.comfy-image-container { /* Image container */
display: block; margin-top: 5px; position: relative;
}
/* Image styles are now in updateImageStyles */
.comfy-image-container i { color: #aaa; font-size: 0.9em; } /* Loading/error text */
.comfy-button-container { /* Button container */
display: inline-flex; align-items: center; gap: 3px; vertical-align: middle;
}
`;
document.head.appendChild(baseStyle);
updateImageStyles(); // Apply initial image width
});
})(); // End IIFE