// ==UserScript==
// @name 酒馆ComfyUI插图脚本
// @namespace http://tampermonkey.net/
// @version 6
// @license GPL
// @description 用于酒馆SillyTavern的ai插图脚本,替换特定字符为图片,并使用特定字符内生成的prompt通过ComfyUI API生图。需要打开浏览器扩展开发者模式。
// @author soulostar
// @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
// @downloadURL https://update.greasyfork.icu/scripts/535811/%E9%85%92%E9%A6%86ComfyUI%E6%8F%92%E5%9B%BE%E8%84%9A%E6%9C%AC.user.js
// @updateURL https://update.greasyfork.icu/scripts/535811/%E9%85%92%E9%A6%86ComfyUI%E6%8F%92%E5%9B%BE%E8%84%9A%E6%9C%AC.meta.js
// ==/UserScript==
(function() {
'use strict';
// --- 配置常量 ---
const BUTTON_ID = 'comfyui-launcher-button';
const PANEL_ID = 'comfyui-panel';
const POLLING_TIMEOUT_MS = 60000; // 轮询超时时间 (60秒)
const POLLING_INTERVAL_MS = 2000; // 轮询间隔 (2秒)
const STORAGE_KEY_IMAGES = 'comfyui_generated_images';
// --- 注入自定义CSS样式 ---
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} input[type="number"],
#${PANEL_ID} textarea {
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;
background: linear-gradient(135deg, #171717);
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, #comfyui-apply-width { position: relative; top: -5px; }
.comfy-input-group { display: flex; gap: 10px; align-items: center; margin-bottom: 10px; }
.comfy-input-group 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; }
/* --- 新增:自动生图开关样式 --- */
#${PANEL_ID} .comfy-auto-generate-container {
margin-bottom: 15px;
padding-top: 5px;
}
#${PANEL_ID} .comfy-auto-generate-label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: bold;
}
#${PANEL_ID} .comfy-auto-generate-label input[type="checkbox"] {
width: auto;
margin-bottom: 0;
transform: scale(1.2);
}
#${PANEL_ID} .comfy-auto-generate-label span { font-weight: normal; font-size: 0.9em; opacity: 0.9;}
/* 聊天内按钮组容器 */
.comfy-button-group {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 5px 4px;
}
/* 生成的图片容器样式 */
.comfy-image-container {
margin-top: 10px;
max-width: 100%;
}
.comfy-image-container img {
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;
}
}
`);
async function applyImageWidthToAll() {
const width = await GM_getValue('comfyui_image_width', 400);
const allImages = document.querySelectorAll('.comfy-image-container img');
allImages.forEach(img => {
img.style.maxWidth = `${width}px`;
});
if (typeof toastr !== 'undefined') toastr.success(`图片宽度已应用为 ${width}px`);
}
function createComfyUIPanel() {
if (document.getElementById(PANEL_ID)) return;
// --- 修改:在HTML中添加自动生图开关 ---
const panelHTML = `
`;
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 widthInput = document.getElementById('comfyui-image-width');
const applyWidthButton = document.getElementById('comfyui-apply-width');
// --- 新增:获取自动生图复选框元素 ---
const autoGenerateCheckbox = document.getElementById('comfyui-auto-generate');
closeButton.addEventListener('click', () => { panel.style.display = 'none'; });
if (typeof $ !== 'undefined' && typeof $.fn.draggable !== 'undefined') {
$(`#${PANEL_ID}`).draggable({ handle: ".panel-control-bar", containment: "window" });
}
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('正在尝试连接 ComfyUI...');
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('连接成功!ComfyUI服务可用。');
} 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('连接超时!ComfyUI服务可能没有响应。');
}
});
});
clearCacheButton.addEventListener('click', () => {
if (confirm('您确定要删除所有已生成的图片缓存吗?\n此操作不可撤销,将立即从界面移除所有已生成的图片。')) {
GM_setValue(STORAGE_KEY_IMAGES, {});
document.querySelectorAll('.comfy-button-group').forEach(group => {
const imageContainer = group.nextElementSibling;
if (imageContainer && imageContainer.classList.contains('comfy-image-container')) {
imageContainer.remove();
}
const deleteButton = group.querySelector('.comfy-delete-button');
if (deleteButton) deleteButton.remove();
const generateButton = group.querySelector('.comfy-chat-generate-button');
if (generateButton) {
generateButton.textContent = '开始生成';
generateButton.disabled = false;
generateButton.classList.remove('testing', 'success', 'error');
}
});
if (typeof toastr !== 'undefined') toastr.success('所有图片缓存已删除!');
}
});
applyWidthButton.addEventListener('click', applyImageWidthToAll);
// --- 修改:将autoGenerateCheckbox加入设置加载和保存的逻辑中 ---
loadSettings(urlInput, workflowInput, startTagInput, endTagInput, widthInput, autoGenerateCheckbox);
[urlInput, workflowInput, startTagInput, endTagInput, widthInput].forEach(input => {
input.addEventListener('input', () => {
if(input === urlInput) testButton.classList.remove('success', 'error', 'testing');
saveSettings(urlInput, workflowInput, startTagInput, endTagInput, widthInput, autoGenerateCheckbox);
});
});
// 复选框使用 change 事件
autoGenerateCheckbox.addEventListener('change', () => {
saveSettings(urlInput, workflowInput, startTagInput, endTagInput, widthInput, autoGenerateCheckbox);
});
}
// --- 修改:loadSettings函数增加autoGenerateCheckbox参数 ---
async function loadSettings(urlInput, workflowInput, startTagInput, endTagInput, widthInput, autoGenerateCheckbox) {
urlInput.value = await GM_getValue('comfyui_url', 'http://127.0.0.1:8188');
workflowInput.value = await GM_getValue('comfyui_workflow', '');
startTagInput.value = await GM_getValue('comfyui_start_tag', 'image###');
endTagInput.value = await GM_getValue('comfyui_end_tag', '###');
widthInput.value = await GM_getValue('comfyui_image_width', 400);
autoGenerateCheckbox.checked = await GM_getValue('comfyui_auto_generate', false); // 加载自动生图设置
}
// --- 修改:saveSettings函数增加autoGenerateCheckbox参数 ---
async function saveSettings(urlInput, workflowInput, startTagInput, endTagInput, widthInput, autoGenerateCheckbox) {
await GM_setValue('comfyui_url', urlInput.value);
await GM_setValue('comfyui_workflow', workflowInput.value);
await GM_setValue('comfyui_start_tag', startTagInput.value);
await GM_setValue('comfyui_end_tag', endTagInput.value);
await GM_setValue('comfyui_image_width', parseInt(widthInput.value, 10) || 400);
await GM_setValue('comfyui_auto_generate', autoGenerateCheckbox.checked); // 保存自动生图设置
}
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 = `ComfyUI生图`;
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 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);
}
// --- 修改:processMessageForComfyButton 加入自动生成逻辑 ---
async function processMessageForComfyButton(messageNode) {
const mesText = messageNode.querySelector('.mes_text');
if (!mesText) return;
const startTag = await GM_getValue('comfyui_start_tag', 'image###');
const endTag = await GM_getValue('comfyui_end_tag', '###');
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 `
`;
});
}
const savedImages = await GM_getValue(STORAGE_KEY_IMAGES, {});
const buttonGroups = mesText.querySelectorAll('.comfy-button-group');
// 获取一次自动生成设置,在循环外,提高效率
const autoGenerateEnabled = await GM_getValue('comfyui_auto_generate', false);
for (const group of buttonGroups) {
if (group.dataset.listenerAttached) continue;
const generationId = group.dataset.generationId;
const generateButton = group.querySelector('.comfy-chat-generate-button');
if (savedImages[generationId]) {
await displayImage(group, savedImages[generationId]);
setupGeneratedState(generateButton, generationId);
} else {
generateButton.addEventListener('click', onGenerateButtonClick);
// --- 新增:自动生成核心逻辑 ---
// 检查是否满足所有自动生成条件
if (autoGenerateEnabled && // 1. 开关已打开
messageNode.classList.contains('last_mes') && // 2. 是最新的一条消息
generateButton.textContent === '开始生成' // 3. 按钮是“开始生成”,而不是“重新生成”
)
{
console.log(`[ComfyUI] 自动为最新消息触发生成: ${generateButton.dataset.prompt.substring(0, 30)}...`);
// 使用 setTimeout 延迟执行,确保UI渲染完成,避免潜在的竞争问题
setTimeout(() => generateButton.click(), 100);
}
}
group.dataset.listenerAttached = 'true';
}
}
function setupGeneratedState(generateButton, generationId) {
generateButton.textContent = '重新生成';
generateButton.disabled = false;
generateButton.classList.remove('testing', 'success', 'error');
if (!generateButton.dataset.regenerateListener) {
generateButton.addEventListener('click', onGenerateButtonClick);
generateButton.dataset.regenerateListener = 'true';
}
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 onGenerateButtonClick(event) {
const button = event.target.closest('.comfy-chat-generate-button');
const group = button.closest('.comfy-button-group');
const prompt = button.dataset.prompt;
const generationId = group.dataset.generationId;
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;
if (oldImageContainer && oldImageContainer.classList.contains('comfy-image-container')) {
oldImageContainer.remove();
}
try {
const url = (await GM_getValue('comfyui_url', '')).trim();
let workflowString = await GM_getValue('comfyui_workflow', '');
if (!url || !workflowString) throw new Error('ComfyUI URL 或工作流未配置。');
if (!workflowString.includes('%prompt%')) throw new Error('工作流中未找到必需的 %prompt% 占位符。');
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);
const promptResponse = await sendPromptRequest(url, workflow);
const promptId = promptResponse.prompt_id;
if (!promptId) throw new Error('ComfyUI 未返回有效的 Prompt ID。');
const finalHistory = await pollForResult(url, promptId);
const imageUrl = findImageUrlInHistory(finalHistory, promptId, url);
if (!imageUrl) throw new Error('在ComfyUI返回结果中未找到图片。');
await 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) {
if (typeof toastr !== 'undefined') toastr.error(e.message);
console.error('ComfyUI生图脚本错误:', e);
button.textContent = '生成失败';
button.classList.remove('testing');
button.classList.add('error');
setTimeout(() => {
const wasRegenerating = !!group.querySelector('.comfy-delete-button');
if (wasRegenerating) {
setupGeneratedState(button, generationId);
if (deleteButton) deleteButton.style.display = 'inline-flex';
} else {
button.textContent = '开始生成';
button.disabled = false;
button.classList.remove('error');
}
}, 3000);
}
}
function sendPromptRequest(url, workflow) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${url}/prompt`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ prompt: workflow }),
timeout: 10000,
onload: (res) => {
if (res.status === 200) {
if (typeof toastr !== 'undefined') toastr.info('请求已发送至ComfyUI,排队中...');
resolve(JSON.parse(res.responseText));
} else {
reject(new Error(`ComfyUI API 错误 (Prompt): ${res.statusText || res.status}`));
}
},
onerror: () => reject(new Error('无法连接到 ComfyUI API。')),
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('轮询ComfyUI结果超时。'));
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `${url}/history/${promptId}`,
onload: (res) => {
if (res.status === 200) {
const history = JSON.parse(res.responseText);
if (history[promptId]) {
clearInterval(poller);
resolve(history);
}
} else {
clearInterval(poller);
reject(new Error(`轮询ComfyUI结果时出错: ${res.statusText || res.status}`));
}
},
onerror: () => {
clearInterval(poller);
reject(new Error('轮询ComfyUI结果时网络错误。'));
}
});
}, 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
});
return `${baseUrl}/view?${params.toString()}`;
}
}
}
return null;
}
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 = 'Generated by ComfyUI';
container.appendChild(img);
anchorElement.insertAdjacentElement('afterend', container);
}
const img = container.querySelector('img');
img.src = imageUrl;
const width = await GM_getValue('comfyui_image_width', 400);
img.style.maxWidth = `${width}px`;
}
// --- 主执行逻辑 ---
createComfyUIPanel();
const chatObserver = new MutationObserver((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.closest) {
const mesNode = mutation.target.closest('.mes');
if (mesNode) nodesToProcess.add(mesNode);
}
}
// 使用 for...of 循环来处理 async/await
for (const node of nodesToProcess) {
processMessageForComfyButton(node);
}
});
function observeChat() {
const chatElement = document.getElementById('chat');
if (chatElement) {
chatElement.querySelectorAll('.mes').forEach(processMessageForComfyButton);
chatObserver.observe(chatElement, { childList: true, subtree: true, characterData: 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'] });
}
});
})();