// ==UserScript==
// @name SillyTavern ComfyUI/WebUI 生图助手
// @namespace http://tampermonkey.net/
// @version 4.9.1
// @description ComfyUI和WebUI双端整合,用于SillyTavern的生图插件
// @author 白大瑾
// @match http://127.0.0.1:*/*
// @match http://localhost:*/*
// @connect 127.0.0.1
// @connect localhost
// @grant GM_addStyle
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @require https://code.jquery.com/jquery-3.6.0.min.js
// @require https://code.jquery.com/ui/1.13.2/jquery-ui.min.js
// ==/UserScript==
(function () {
'use strict';
// 设备检测
const DeviceDetector = {
isMobile: () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(window.innerWidth <= 768) ||
('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0);
},
isTablet: () => {
return /iPad|Android/i.test(navigator.userAgent) && window.innerWidth >= 768 && window.innerWidth <= 1024;
},
isTouchDevice: () => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0;
},
getDeviceType: () => {
if (DeviceDetector.isMobile()) return 'mobile';
if (DeviceDetector.isTablet()) return 'tablet';
return 'desktop';
}
};
// --- 配置常量 ---
const BUTTON_ID = 'comfyui-launcher-button';
const PANEL_ID = 'comfyui-panel';
const POLLING_TIMEOUT_MS = 3600000;
const POLLING_INTERVAL_MS = 2000;
const STORAGE_KEY_IMAGES = 'comfyui_generated_images';
const STORAGE_KEY_WORKFLOWS = 'comfyui_saved_workflows';
const STORAGE_KEY_SHORTCUTS = 'comfyui_keyboard_shortcuts';
const STORAGE_KEY_MODE = 'generation_mode';
const STORAGE_KEY_PROMPT_PRESETS = 'comfyui_prompt_presets';
const STORAGE_KEY_COMFYUI_LORA_PRESETS = 'comfyui_lora_presets';
// 生成模式枚举
const MODES = {
COMFYUI: 'comfyui',
WEBUI: 'webui'
};
const DEFAULT_SETTINGS = {
mode: MODES.COMFYUI,
url: 'http://127.0.0.1:8188',
webuiUrl: 'http://127.0.0.1:7860',
workflow: '',
startTag: '开始生成',
genWidth: 512,
genHeight: 768,
displayWidth: 400,
displayHeight: 0,
autoGenerate: false,
model: '',
unetModel: '',
webuiModel: '',
selectedLoras: [],
sampler: 'euler',
scheduler: 'normal',
steps: 20,
cfg: 7.0,
positivePrompt: '',
negativePrompt: '',
webuiSampler: 'Euler a',
webuiScheduler: 'Automatic',
denoisingStrength: 0.7,
enableHires: false,
hiresUpscaler: 'Latent',
hiresSteps: 0,
hiresUpscale: 2.0,
hiresDenoising: 0.5
};
// 默认快捷键配置
const DEFAULT_SHORTCUTS = {
togglePanel: 'Ctrl+Shift+C',
saveWorkflow: 'Ctrl+Shift+S',
newWorkflow: 'Ctrl+Shift+N',
quickGenerate: 'Ctrl+Shift+G',
convertPlaceholders: 'Ctrl+Shift+P',
testConnection: 'Ctrl+Shift+T',
closePanel: 'Escape',
saveEdit: 'Ctrl+S',
switchMode: 'Ctrl+Shift+M'
};
// --- 全局状态 ---
let currentEditingWorkflow = null;
let isEditMode = false;
let currentShortcuts = { ...DEFAULT_SHORTCUTS };
let currentMode = MODES.COMFYUI;
let availableLoras = [];
let availableEmbeddings = [];
let availableComfyUILoras = [];
// --- 样式注入 ---
GM_addStyle(`
:root {
--vp-bg-color: rgba(10, 15, 25, 0.9);
--vp-accent-color: #00d1ff;
--vp-text-color: #e0e5f0;
--vp-border-color: rgba(0, 209, 255, 0.3);
--vp-glow-color: rgba(0, 209, 255, 0.6);
--vp-error-color: #ff4747;
--vp-success-color: #00ff9c;
--vp-font: 'Segoe UI', 'Roboto', system-ui, sans-serif;
--vp-warning-color: #ffa500;
--vp-comfyui-color: #00d1ff;
--vp-webui-color: #ff6b35;
}
#${PANEL_ID} {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 750px;
z-index: 9999;
color: var(--vp-text-color);
background: var(--vp-bg-color);
border: 1px solid var(--vp-border-color);
border-radius: 12px;
box-shadow: 0 0 25px rgba(0,0,0,0.5), 0 0 15px var(--vp-glow-color) inset;
padding: 20px;
box-sizing: border-box;
backdrop-filter: blur(12px);
font-family: var(--vp-font);
flex-direction: column;
max-height: 90vh;
}
#${PANEL_ID}.dragging {
transform: none !important;
}
#${PANEL_ID} .panel-control-bar {
cursor: move;
padding-bottom: 15px;
margin-bottom: 20px;
border-bottom: 1px solid var(--vp-border-color);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
text-shadow: 0 0 5px var(--vp-accent-color);
position: relative;
}
#${PANEL_ID} .panel-control-bar b {
font-size: 1.4em;
margin-left: 10px;
font-weight: 600;
}
#${PANEL_ID} .floating_panel_close {
cursor: pointer;
font-size: 1.6em;
transition: color 0.3s, text-shadow 0.3s;
}
#${PANEL_ID} .floating_panel_close:hover {
color: var(--vp-accent-color);
text-shadow: 0 0 8px var(--vp-accent-color);
}
#${PANEL_ID} .panel-reset-position {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
font-size: 1.2em;
color: var(--vp-text-color);
opacity: 0.6;
transition: opacity 0.3s, color 0.3s;
padding: 5px;
}
#${PANEL_ID} .panel-reset-position:hover {
opacity: 1;
color: var(--vp-accent-color);
}
#${PANEL_ID} .comfyui-panel-content {
overflow-y: auto;
flex-grow: 1;
padding-right: 10px;
}
#${PANEL_ID} input[type="text"],
#${PANEL_ID} input[type="number"],
#${PANEL_ID} select,
#${PANEL_ID} textarea {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--vp-border-color);
background-color: rgba(0,0,0,0.3);
color: var(--vp-text-color);
font-family: var(--vp-font);
transition: border-color 0.3s, box-shadow 0.3s;
}
#${PANEL_ID} input:focus,
#${PANEL_ID} select:focus,
#${PANEL_ID} textarea:focus {
outline: none;
border-color: var(--vp-accent-color);
box-shadow: 0 0 10px var(--vp-glow-color);
}
#${PANEL_ID} textarea {
min-height: 80px;
resize: vertical;
margin-top: 5px;
}
#${PANEL_ID} .workflow-info {
font-size: 0.9em;
color: #aaa;
margin-top: 5px;
margin-bottom: 15px;
padding-left: 5px;
border-left: 2px solid var(--vp-border-color);
}
.comfy-button {
padding: 10px 15px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
transition: all 0.3s ease;
flex-shrink: 0;
font-size: 14px;
background: transparent;
color: var(--vp-accent-color);
border: 1px solid var(--vp-accent-color);
text-shadow: 0 0 2px var(--vp-accent-color);
}
.comfy-button:hover:not(:disabled) {
background: var(--vp-accent-color);
color: var(--vp-bg-color);
box-shadow: 0 0 12px var(--vp-glow-color);
text-shadow: none;
}
.comfy-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.comfy-button.testing {
color: #fff;
border-color: #f39c12;
background: rgba(243, 156, 18, 0.2);
}
.comfy-button.success {
color: #fff;
border-color: var(--vp-success-color);
background: rgba(0, 255, 156, 0.2);
}
.comfy-button.error {
color: #fff;
border-color: var(--vp-error-color);
background: rgba(255, 71, 71, 0.2);
}
.comfy-button.warning {
color: #fff;
border-color: var(--vp-warning-color);
background: rgba(255, 165, 0, 0.2);
}
.comfy-input-group {
display: flex;
gap: 10px;
align-items: center;
}
#${PANEL_ID} label {
display: block;
margin-bottom: 8px;
font-weight: 500;
font-size: 0.9em;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--vp-accent-color);
opacity: 0.8;
}
#options > .options-content > a#${BUTTON_ID} {
display: flex;
align-items: center;
gap: 10px;
}
#${PANEL_ID} .comfy-auto-generate-container {
margin: 20px 0;
}
#${PANEL_ID} .comfy-auto-generate-label {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
padding: 10px;
border-radius: 6px;
border: 1px dashed var(--vp-border-color);
transition: background-color 0.3s, border-color 0.3s;
}
#${PANEL_ID} .comfy-auto-generate-label:hover {
background-color: rgba(0, 209, 255, 0.05);
border-color: var(--vp-accent-color);
}
#${PANEL_ID} .comfy-auto-generate-label input[type="checkbox"] {
transform: scale(1.3);
}
#${PANEL_ID} .comfy-auto-generate-label span {
font-weight: normal;
font-size: 0.9em;
text-transform: none;
letter-spacing: 0;
}
#${PANEL_ID} .comfy-auto-generate-label b {
color: var(--vp-text-color);
text-transform: none;
letter-spacing: 0;
font-size: 1.1em;
}
.comfy-settings-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px 20px;
margin-bottom: 20px;
}
.comfy-prompt-area {
margin-bottom: 20px;
}
#comfyui-refresh-models,
#comfyui-refresh-unets,
#webui-refresh-models,
#webui-refresh-loras {
padding: 8px;
line-height: 1;
min-width: 40px;
}
.comfy-button-group {
display: inline-flex;
align-items: center;
gap: 8px;
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(--vp-border-color);
background: rgba(0,0,0,0.2);
box-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.workflow-action-row {
display: flex;
gap: 10px;
margin-top: 15px;
margin-bottom: 25px;
}
.workflow-action-row .comfy-button {
flex: 1;
}
.workflow-selector-container {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.workflow-search-container {
margin-bottom: 15px;
}
.workflow-search-input {
width: 100%;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--vp-border-color);
background-color: rgba(0,0,0,0.3);
color: var(--vp-text-color);
}
.workflow-item {
display: flex;
padding: 10px;
border-radius: 6px;
margin-bottom: 8px;
justify-content: space-between;
align-items: center;
background: rgba(0,0,0,0.2);
border: 1px solid var(--vp-border-color);
transition: all 0.3s;
}
.workflow-item:hover {
background: rgba(0, 209, 255, 0.05);
}
.workflow-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 10px;
cursor: pointer;
}
.workflow-item-title:hover {
color: var(--vp-accent-color);
}
.workflow-item-actions {
display: flex;
gap: 8px;
}
.workflow-item-actions button {
padding: 5px 10px;
font-size: 12px;
}
.workflow-item.active {
background: rgba(0, 209, 255, 0.1);
border-color: var(--vp-accent-color);
}
.workflow-item.editing .workflow-item-title {
display: none;
}
.workflow-item.editing .workflow-edit-input {
display: block;
}
.workflow-edit-input {
display: none;
flex: 1;
margin-right: 10px;
}
.workflow-save-modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10000;
background: var(--vp-bg-color);
border: 1px solid var(--vp-border-color);
border-radius: 12px;
padding: 20px;
width: 400px;
box-shadow: 0 0 25px rgba(0,0,0,0.5);
}
.workflow-save-modal h3 {
margin-top: 0;
color: var(--vp-accent-color);
}
.workflow-save-modal .modal-actions {
display: flex;
gap: 10px;
margin-top: 20px;
justify-content: flex-end;
}
.workflow-save-modal input {
margin-bottom: 10px;
}
.workflow-save-modal .overwrite-warning {
background: rgba(255, 165, 0, 0.1);
border: 1px solid var(--vp-warning-color);
border-radius: 6px;
padding: 10px;
margin: 10px 0;
color: var(--vp-warning-color);
}
.empty-workflows-message {
text-align: center;
padding: 20px;
font-style: italic;
color: #888;
}
.tab-container {
margin-bottom: 20px;
}
.tab-buttons {
display: flex;
border-bottom: 1px solid var(--vp-border-color);
margin-bottom: 15px;
flex-wrap: wrap;
}
.tab-button {
padding: 10px 15px;
cursor: pointer;
background: transparent;
border: none;
color: var(--vp-text-color);
font-weight: 600;
border-bottom: 3px solid transparent;
transition: all 0.3s;
}
.tab-button.active {
color: var(--vp-accent-color);
border-bottom-color: var(--vp-accent-color);
}
.tab-button:hover:not(.active) {
border-bottom-color: rgba(0, 209, 255, 0.3);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block !important;
}
.tab-content:not(.active) {
display: none !important;
}
.tab-content.comfyui-settings.hidden {
display: none !important;
}
.tab-content.webui-settings:not(.active) {
display: none !important;
}
.workflow-tools {
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--vp-border-color);
border-radius: 8px;
background: rgba(0,0,0,0.1);
}
.workflow-tools h4 {
margin: 0 0 15px 0;
color: var(--vp-accent-color);
font-size: 1.1em;
}
.edit-mode-toolbar {
display: none;
margin-bottom: 15px;
padding: 10px;
background: rgba(0, 209, 255, 0.1);
border: 1px solid var(--vp-accent-color);
border-radius: 6px;
}
.edit-mode-toolbar.active {
display: block;
}
.edit-mode-toolbar .toolbar-title {
font-weight: 600;
color: var(--vp-accent-color);
margin-bottom: 10px;
}
.shortcut-config-grid {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 10px;
align-items: center;
margin-bottom: 15px;
}
.shortcut-config-item {
display: contents;
}
.shortcut-config-item label {
margin-bottom: 0;
text-transform: none;
font-size: 0.9em;
opacity: 1;
}
.shortcut-input {
width: 150px;
padding: 8px;
font-family: monospace;
text-align: center;
}
.shortcut-input.recording {
background: rgba(255, 165, 0, 0.2);
border-color: var(--vp-warning-color);
}
.shortcut-input.invalid {
background: rgba(255, 71, 71, 0.2);
border-color: var(--vp-error-color);
}
.shortcut-description {
font-size: 0.85em;
color: #aaa;
grid-column: 1 / -1;
margin-top: -5px;
margin-bottom: 10px;
}
.shortcut-status {
font-size: 0.8em;
padding: 5px 10px;
border-radius: 4px;
}
.shortcut-status.available {
background: rgba(0, 255, 156, 0.2);
color: var(--vp-success-color);
}
.shortcut-status.conflict {
background: rgba(255, 71, 71, 0.2);
color: var(--vp-error-color);
}
.shortcut-status.disabled {
background: rgba(128, 128, 128, 0.2);
color: #888;
}
/* 模式切换相关样式 */
.mode-switch-container {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 20px;
padding: 15px;
border: 1px solid var(--vp-border-color);
border-radius: 8px;
background: rgba(0,0,0,0.1);
}
.mode-switch {
display: flex;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--vp-border-color);
}
.mode-switch-option {
padding: 10px 20px;
cursor: pointer;
background: rgba(0,0,0,0.3);
color: var(--vp-text-color);
border: none;
font-weight: 600;
transition: all 0.3s;
font-size: 14px;
min-width: 100px;
}
.mode-switch-option.active.comfyui {
background: var(--vp-comfyui-color);
color: white;
}
.mode-switch-option.active.webui {
background: var(--vp-webui-color);
color: white;
}
.mode-switch-option:hover:not(.active) {
background: rgba(255,255,255,0.1);
}
.mode-status {
font-size: 0.9em;
color: #aaa;
flex: 1;
}
/* LoRA选择器样式 */
.lora-selector {
margin-bottom: 20px;
}
.lora-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--vp-border-color);
border-radius: 6px;
background: rgba(0,0,0,0.3);
}
.lora-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--vp-border-color);
}
.lora-item:last-child {
border-bottom: none;
}
.lora-item:hover {
background: rgba(255,255,255,0.05);
}
.lora-info {
flex: 1;
display: flex;
flex-direction: column;
}
.lora-name {
font-weight: 600;
font-size: 0.9em;
}
.lora-alias {
font-size: 0.8em;
color: #aaa;
}
.lora-controls {
display: flex;
align-items: center;
gap: 10px;
}
.lora-weight {
width: 60px;
padding: 4px 6px;
font-size: 0.8em;
}
.lora-checkbox {
transform: scale(1.2);
}
.selected-loras {
margin-top: 10px;
padding: 10px;
border: 1px solid var(--vp-border-color);
border-radius: 6px;
background: rgba(0,0,0,0.2);
}
.selected-loras h4 {
margin: 0 0 10px 0;
font-size: 0.9em;
color: var(--vp-accent-color);
}
.selected-lora-tag {
display: inline-block;
background: var(--vp-accent-color);
color: white;
padding: 4px 8px;
margin: 2px;
border-radius: 4px;
font-size: 0.8em;
}
.selected-lora-tag .remove {
margin-left: 5px;
cursor: pointer;
font-weight: bold;
}
.selected-lora-tag .remove:hover {
color: var(--vp-error-color);
}
/* Embedding选择器样式 */
.embedding-selector {
margin-bottom: 20px;
}
.embedding-list {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--vp-border-color);
border-radius: 6px;
background: rgba(0,0,0,0.3);
}
.embedding-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-bottom: 1px solid var(--vp-border-color);
}
.embedding-item:last-child {
border-bottom: none;
}
.embedding-item:hover {
background: rgba(255,255,255,0.05);
}
.embedding-info {
flex: 1;
display: flex;
flex-direction: column;
}
.embedding-name {
font-weight: 600;
font-size: 0.9em;
}
.embedding-controls {
display: flex;
align-items: center;
gap: 10px;
}
.embedding-weight {
width: 60px;
padding: 4px 6px;
font-size: 0.8em;
}
.embedding-checkbox {
transform: scale(1.2);
}
.selected-embeddings {
margin-top: 10px;
padding: 10px;
border: 1px solid var(--vp-border-color);
border-radius: 6px;
background: rgba(0,0,0,0.2);
}
.selected-embeddings h4 {
margin: 0 0 10px 0;
font-size: 0.9em;
color: var(--vp-accent-color);
}
.selected-embedding-tag {
display: inline-block;
background: #ff6b35;
color: white;
padding: 4px 8px;
margin: 2px;
border-radius: 4px;
font-size: 0.8em;
}
.selected-embedding-tag .remove {
margin-left: 5px;
cursor: pointer;
font-weight: bold;
}
.selected-embedding-tag .remove:hover {
color: var(--vp-error-color);
}
/* WebUI专用设置样式 */
.webui-settings {
display: none;
}
.webui-settings.active {
display: block;
}
.comfyui-settings {
display: block;
}
.comfyui-settings.hidden {
display: none;
}
/* 在现有CSS的 @media (max-width: 768px) 部分,修改以下规则: */
@media (max-width: 768px) {
#${PANEL_ID} {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
max-width: none !important;
max-height: none !important;
transform: none !important;
border-radius: 0;
padding: 5px; /* 减少padding */
z-index: 10000;
overflow: hidden; /* 防止面板本身滚动 */
}
#${PANEL_ID} .panel-control-bar {
padding: 10px 5px; /* 减少padding */
margin-bottom: 10px; /* 减少margin */
position: sticky;
top: 0;
background: var(--vp-bg-color);
z-index: 1;
flex-shrink: 0; /* 防止压缩 */
}
#${PANEL_ID} .comfyui-panel-content {
height: calc(100vh - 60px); /* 更精确的高度计算 */
overflow-y: auto;
overflow-x: hidden;
padding-right: 5px;
padding-bottom: 20px; /* 底部留出更多空间 */
box-sizing: border-box;
}
/* 减少各种元素的margin和padding */
fieldset {
margin-bottom: 15px; /* 减少 */
padding: 10px; /* 减少 */
}
.tab-buttons {
margin-bottom: 10px; /* 减少 */
padding-bottom: 5px;
}
.tab-button {
padding: 8px 12px; /* 减少 */
font-size: 0.85em;
margin: 2px; /* 添加小的margin */
}
.comfy-settings-grid {
gap: 10px; /* 减少gap */
margin-bottom: 15px; /* 减少 */
}
/* 优化工作流相关元素 */
.workflow-tools {
margin-bottom: 15px; /* 减少 */
padding: 10px; /* 减少 */
}
.workflow-action-row {
margin-top: 10px; /* 减少 */
margin-bottom: 15px; /* 减少 */
}
.workflow-selector-container {
margin-bottom: 15px; /* 减少 */
}
/* 优化缓存网格 */
.cache-grid {
max-height: 50vh; /* 限制最大高度 */
margin-bottom: 20px; /* 确保底部空间 */
}
/* 优化LoRA选择器 */
.lora-selector,
.embedding-selector {
margin-bottom: 15px; /* 减少 */
}
.lora-list,
.embedding-list,
.comfyui-lora-list {
max-height: 250px; /* 减少最大高度 */
}
/* 确保表单元素不会过大 */
#${PANEL_ID} input[type="text"],
#${PANEL_ID} input[type="number"],
#${PANEL_ID} select,
#${PANEL_ID} textarea {
padding: 8px 10px; /* 减少padding */
font-size: 14px; /* 减少字体大小 */
min-height: 36px; /* 减少最小高度 */
}
#${PANEL_ID} textarea {
min-height: 60px; /* 减少textarea最小高度 */
}
/* 优化按钮尺寸 */
.comfy-button {
padding: 8px 12px; /* 减少padding */
font-size: 0.9em;
min-height: 36px; /* 减少最小高度 */
}
/* 添加底部安全区域 */
.tab-content {
padding-bottom: env(safe-area-inset-bottom, 20px);
}
}
/* 增加对小屏幕的额外优化 */
@media (max-width: 480px) {
#${PANEL_ID} {
padding: 3px; /* 进一步减少 */
}
#${PANEL_ID} .panel-control-bar {
padding: 8px 3px;
margin-bottom: 8px;
}
#${PANEL_ID} .comfyui-panel-content {
height: calc(100vh - 50px); /* 更小的控制栏高度 */
padding-bottom: 30px; /* 更多底部空间 */
}
.tab-button {
padding: 6px 8px;
font-size: 0.8em;
margin: 1px;
}
fieldset {
margin-bottom: 10px;
padding: 8px;
}
.comfy-settings-grid {
gap: 8px;
margin-bottom: 10px;
}
}
/* 添加对横屏模式的优化 */
@media (max-width: 768px) and (orientation: landscape) {
#${PANEL_ID} .comfyui-panel-content {
height: calc(100vh - 45px); /* 横屏时减少控制栏高度 */
}
#${PANEL_ID} .panel-control-bar {
padding: 5px;
margin-bottom: 5px;
}
.cache-grid {
max-height: 40vh; /* 横屏时进一步限制高度 */
}
}
/* 图片缓存样式 */
.cache-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
max-height: 60vh;
overflow-y: auto;
padding: 10px 0;
}
.cache-item {
border: 1px solid var(--vp-border-color);
border-radius: 8px;
background: rgba(0,0,0,0.2);
overflow: hidden;
transition: transform 0.3s, box-shadow 0.3s;
}
.cache-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
}
.cache-item-image {
width: 100%;
height: 150px;
object-fit: cover;
cursor: pointer;
}
.cache-item-info {
padding: 10px;
}
.cache-item-prompt {
font-size: 0.8em;
color: var(--vp-text-color);
margin-bottom: 8px;
max-height: 40px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.cache-item-meta {
font-size: 0.7em;
color: #888;
margin-bottom: 10px;
}
.cache-item-actions {
display: flex;
gap: 5px;
}
.cache-item-actions .comfy-button {
flex: 1;
padding: 5px 8px;
font-size: 0.75em;
}
.cache-empty {
text-align: center;
padding: 40px 20px;
color: #888;
font-style: italic;
}
.cache-image-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
z-index: 10001;
justify-content: center;
align-items: center;
}
.cache-image-modal img {
max-width: 90%;
max-height: 90%;
border-radius: 8px;
}
.cache-modal-close {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 2em;
cursor: pointer;
z-index: 10002;
}
.prompt-preset-container {
margin-bottom: 15px;
padding: 15px;
border: 1px solid var(--vp-border-color);
border-radius: 8px;
background: rgba(0,0,0,0.1);
}
.prompt-preset-controls {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 10px;
}
.prompt-preset-controls select {
flex: 1;
}
.prompt-preset-controls button {
flex-shrink: 0;
min-width: 80px;
}
`);
// ================== LoRA预设管理 ================== //
/**
* 当前正在操作的LoRA类型 ('webui' 或 'comfyui')
*/
let currentLoraPresetType = 'webui';
/**
* 加载ComfyUI LoRA预设列表
*/
async function loadComfyUILoraPresets() {
const select = document.getElementById('comfyui-lora-preset-select');
const presets = await GM_getValue(STORAGE_KEY_COMFYUI_LORA_PRESETS, {});
select.innerHTML = '<option value="">选择LoRA预设...</option>';
Object.keys(presets).sort().forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
/**
* 保存当前ComfyUI LoRA配置为预设
*/
async function saveCurrentComfyUILoraAsPreset(presetName) {
const selectedLoras = getCurrentComfyUISelectedLoras();
const presets = await GM_getValue(STORAGE_KEY_COMFYUI_LORA_PRESETS, {});
presets[presetName] = {
loras: selectedLoras,
timestamp: Date.now()
};
await GM_setValue(STORAGE_KEY_COMFYUI_LORA_PRESETS, presets);
await loadComfyUILoraPresets();
}
/**
* 加载选中的ComfyUI LoRA预设
*/
async function loadSelectedComfyUILoraPreset(presetName) {
const presets = await GM_getValue(STORAGE_KEY_COMFYUI_LORA_PRESETS, {});
const preset = presets[presetName];
if (preset && preset.loras) {
// 清除当前选择
localStorage.setItem('comfyui_selected_loras', JSON.stringify([]));
// 应用预设
localStorage.setItem('comfyui_selected_loras', JSON.stringify(preset.loras));
// 重新渲染界面
renderComfyUILoraList();
updateComfyUISelectedLorasDisplay();
if (typeof toastr !== 'undefined') {
toastr.success(`已加载ComfyUI LoRA预设"${presetName}"`);
}
}
}
/**
* 删除选中的ComfyUI LoRA预设
*/
async function deleteSelectedComfyUILoraPreset(presetName) {
const presets = await GM_getValue(STORAGE_KEY_COMFYUI_LORA_PRESETS, {});
delete presets[presetName];
await GM_setValue(STORAGE_KEY_COMFYUI_LORA_PRESETS, presets);
await loadComfyUILoraPresets();
if (typeof toastr !== 'undefined') {
toastr.success(`ComfyUI LoRA预设"${presetName}"已删除`);
}
}
/**
* 显示LoRA预设保存模态框
*/
function showLoraPresetSaveModal(type) {
currentLoraPresetType = type;
const modal = document.getElementById('lora-preset-save-modal');
const nameInput = document.getElementById('lora-preset-name-input');
nameInput.value = '';
modal.style.display = 'block';
setTimeout(() => nameInput.focus(), 100);
}
// ================== 工具函数 ================== //
/**
* 检查并修正面板位置
* @param {HTMLElement} panel - 面板元素
*/
function checkAndFixPanelPosition(panel) {
const rect = panel.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let needsAdjustment = false;
let newLeft = parseInt(panel.style.left) || 0;
let newTop = parseInt(panel.style.top) || 0;
if (rect.top < 0) {
newTop = 10;
needsAdjustment = true;
}
if (rect.left < -rect.width + 50) {
newLeft = 10;
needsAdjustment = true;
}
if (rect.right > viewportWidth + rect.width - 50) {
newLeft = viewportWidth - rect.width - 10;
needsAdjustment = true;
}
if (rect.bottom > viewportHeight + rect.height - 50) {
newTop = viewportHeight - rect.height - 10;
needsAdjustment = true;
}
if (needsAdjustment) {
panel.style.left = `${Math.max(10, newLeft)}px`;
panel.style.top = `${Math.max(10, newTop)}px`;
panel.style.transform = 'none';
panel.classList.add('dragging');
}
}
/**
* 重置面板位置到屏幕中央
* @param {HTMLElement} panel - 面板元素
*/
function resetPanelPosition(panel) {
const deviceType = DeviceDetector.getDeviceType();
if (deviceType === 'mobile') {
panel.style.left = '0';
panel.style.top = '0';
panel.style.transform = 'none';
panel.classList.add('mobile-fullscreen');
} else {
panel.style.left = '50%';
panel.style.top = '50%';
panel.style.transform = 'translate(-50%, -50%)';
panel.classList.remove('dragging', 'mobile-fullscreen');
}
if (typeof toastr !== 'undefined') {
toastr.info('面板位置已重置');
}
}
/**
* HTML转义
* @param {string} str - 输入字符串
* @returns {string} - 转义后的字符串
*/
function escapeHTML(str) {
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
/**
* 封装GM_xmlhttpRequest为Promise
* @param {Object} options - 请求选项
* @returns {Promise} - 返回请求Promise
*/
function makeRequest(options) {
return new Promise((resolve, reject) => {
// -- 新增代码开始 --
// 自动清理URL,防止双斜杠问题
let cleanedUrl = options.url;
try {
const urlObj = new URL(cleanedUrl);
// 使用URL对象重建路径,可以有效去除多余的斜杠
urlObj.pathname = urlObj.pathname.replace(/\/+/g, '/');
cleanedUrl = urlObj.toString();
} catch (e) {
// 如果URL格式不正确,则不处理,让它在后续请求中自然报错
console.warn("URL格式无法解析,跳过清理:", options.url, e);
}
// -- 新增代码结束 --
const requestOptions = {
method: options.method || 'GET',
url: cleanedUrl, // <-- 使用清理后的URL
headers: options.headers || {},
data: options.data,
timeout: options.timeout || 3600000,
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response);
} else {
reject(new Error(`API错误: ${response.statusText || response.status}`));
}
},
onerror: (error) => {
reject(new Error(`网络错误: ${error.message || '未知错误,可能是ComfyUI或WebUI未启动或权限不足'}`)); // <-- 修改了错误信息
},
ontimeout: () => {
reject(new Error('请求超时'));
}
};
// 如果需要blob响应,添加responseType
if (options.responseType) {
requestOptions.responseType = options.responseType;
}
GM_xmlhttpRequest(requestOptions);
});
}
/**
* 生成简单的哈希值
* @param {string} str - 输入字符串
* @returns {string} - 生成的哈希ID
*/
function simpleHash(str) {
let h = 0;
for (let i = 0; i < str.length; i++) {
h = (h << 5) - h + str.charCodeAt(i);
h |= 0;
}
return 'comfy-id-' + Math.abs(h).toString(36);
}
/**
* 转义正则表达式特殊字符
* @param {string} str - 输入字符串
* @returns {string} - 转义后的字符串
*/
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ================== 核心功能 ================== //
/**
* 切换生成模式
* @param {string} mode - 目标模式
*/
async function switchMode(mode) {
currentMode = mode;
await GM_setValue(STORAGE_KEY_MODE, mode);
updateModeUI();
if (typeof toastr !== 'undefined') {
toastr.success(`已切换到 ${mode === MODES.COMFYUI ? 'ComfyUI' : 'WebUI'} 模式`);
}
}
/**
* 更新模式相关的UI
*/
function updateModeUI() {
document.querySelectorAll('.mode-switch-option').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.mode === currentMode) {
btn.classList.add('active');
btn.classList.add(currentMode);
}
});
const statusElement = document.querySelector('.mode-status');
if (statusElement) {
statusElement.textContent = `当前模式: ${currentMode === MODES.COMFYUI ? 'ComfyUI' : 'WebUI'}`;
}
const comfySettings = document.querySelectorAll('.comfyui-settings');
const webuiSettings = document.querySelectorAll('.webui-settings');
if (currentMode === MODES.COMFYUI) {
comfySettings.forEach(el => {
el.classList.remove('hidden');
// 只对非标签页内容设置display样式
if (!el.classList.contains('tab-content')) {
el.style.display = 'block';
}
});
webuiSettings.forEach(el => {
el.classList.remove('active');
// 只对非标签页内容设置display样式
if (!el.classList.contains('tab-content')) {
el.style.display = 'none';
}
});
} else {
comfySettings.forEach(el => {
el.classList.add('hidden');
// 只对非标签页内容设置display样式
if (!el.classList.contains('tab-content')) {
el.style.display = 'none';
}
});
webuiSettings.forEach(el => {
el.classList.add('active');
// 只对非标签页内容设置display样式
if (!el.classList.contains('tab-content')) {
el.style.display = 'block';
}
});
}
const workflowTab = document.querySelector('[data-tab="workflows"]');
const lorasTab = document.querySelector('[data-tab="loras"]');
const comfyLorasTab = document.querySelector('[data-tab="comfy-loras"]');
if (workflowTab) {
workflowTab.style.display = currentMode === MODES.COMFYUI ? 'block' : 'none';
}
if (lorasTab) {
lorasTab.style.display = currentMode === MODES.WEBUI ? 'block' : 'none';
}
if (comfyLorasTab) {
comfyLorasTab.style.display = currentMode === MODES.COMFYUI ? 'block' : 'none';
}
const activeTab = document.querySelector('.tab-button.active');
if (activeTab && activeTab.style.display === 'none') {
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
const generalTab = document.querySelector('[data-tab="general"]');
const generalContent = document.getElementById('tab-general');
if (generalTab && generalContent) {
generalTab.classList.add('active');
generalContent.classList.add('active');
}
}
}
/**
* 创建ComfyUI/WebUI控制面板
*/
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-home panel-reset-position" title="重置位置 (双击标题栏也可重置)"></i>
<i class="fa-fw fa-solid fa-grip drag-grabber"></i><b>AI图像生成器 v4.9</b>
<i class="fa-fw fa-solid fa-circle-xmark floating_panel_close"></i>
</div>
<div class="comfyui-panel-content">
<!-- 模式切换器 -->
<div class="mode-switch-container">
<div class="mode-switch">
<button class="mode-switch-option active comfyui" data-mode="${MODES.COMFYUI}">ComfyUI</button>
<button class="mode-switch-option" data-mode="${MODES.WEBUI}">WebUI</button>
</div>
<div class="mode-status">当前模式: ComfyUI</div>
</div>
<div class="tab-container">
<div class="tab-buttons">
<button class="tab-button active" data-tab="general">基本设置</button>
<button class="tab-button" data-tab="advanced">高级参数</button>
<button class="tab-button comfyui-settings" data-tab="workflows">工作流管理</button>
<button class="tab-button webui-settings" data-tab="loras" style="display: none;">LoRA管理</button>
<button class="tab-button comfyui-settings" data-tab="comfy-loras">ComfyUI LoRA</button>
<button class="tab-button" data-tab="shortcuts">快捷键设置</button>
<button class="tab-button" data-tab="cache">图片缓存</button>
</div>
<div id="tab-general" class="tab-content active">
<!-- ComfyUI 设置 -->
<div class="comfyui-settings">
<div class="comfy-settings-grid" style="grid-template-columns: 1fr;">
<div><label for="comfyui-url">ComfyUI URL</label>
<div class="comfy-input-group"><input id="comfyui-url" type="text" placeholder="http://127.0.0.1:8188"><button id="comfyui-test-conn" class="comfy-button">Test</button></div>
</div>
<div><label for="comfyui-model-select">模型选择 (Checkpoint)</label>
<div class="comfy-input-group"><select id="comfyui-model-select"></select><button id="comfyui-refresh-models" class="comfy-button" title="Refresh Models"><i class="fa-solid fa-arrows-rotate"></i></button></div>
</div>
<div><label for="comfyui-unet-select">UNet模型选择</label>
<div class="comfy-input-group"><select id="comfyui-unet-select"></select><button id="comfyui-refresh-unets" class="comfy-button" title="Refresh UNet Models"><i class="fa-solid fa-arrows-rotate"></i></button></div>
</div>
</div>
</div>
<!-- WebUI 设置 -->
<div class="webui-settings" style="display: none;">
<div class="comfy-settings-grid" style="grid-template-columns: 1fr;">
<div><label for="webui-url">WebUI URL</label>
<div class="comfy-input-group"><input id="webui-url" type="text" placeholder="http://127.0.0.1:7860"><button id="webui-test-conn" class="comfy-button">Test</button></div>
</div>
<div><label for="webui-model-select">模型选择</label>
<div class="comfy-input-group"><select id="webui-model-select"></select><button id="webui-refresh-models" class="comfy-button" title="Refresh Models"><i class="fa-solid fa-arrows-rotate"></i></button></div>
</div>
</div>
</div>
<div class="comfy-auto-generate-container"><label class="comfy-auto-generate-label"><input id="comfyui-auto-generate" type="checkbox"><b>自动生图</b><span>- 仅对最新消息的"开始生成"有效</span></label></div>
<fieldset style="border: 1px solid var(--vp-border-color); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<legend style="color: var(--vp-accent-color); padding: 0 10px; font-weight: 600;">尺寸设置</legend>
<div class="comfy-settings-grid" style="margin-bottom: 0;">
<div><label for="comfyui-gen-width">生成宽度 (Width)</label><input id="comfyui-gen-width" type="number" placeholder="512" min="64" step="8"></div>
<div><label for="comfyui-gen-height">生成高度 (Height)</label><input id="comfyui-gen-height" type="number" placeholder="768" min="64" step="8"></div>
<div><label for="comfyui-display-width">显示宽度 (0=自动)</label><input id="comfyui-display-width" type="number" placeholder="400" min="0"></div>
<div><label for="comfyui-display-height">显示高度 (0=自动)</label><input id="comfyui-display-height" type="number" placeholder="0" min="0"></div>
</div>
<button id="comfyui-apply-dims" class="comfy-button" style="width:100%; margin-top: 15px;">应用显示尺寸到所有图片</button>
</fieldset>
<fieldset style="border: 1px solid var(--vp-border-color); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<legend style="color: var(--vp-accent-color); padding: 0 10px; font-weight: 600;">内容捕获标记</legend>
<div class="comfy-settings-grid">
<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>
<button id="comfyui-apply-tags" class="comfy-button" style="width:100%; margin-top: 15px;">应用标记</button>
</fieldset>
<button id="comfyui-clear-cache" class="comfy-button error" style="margin-top: 20px; width: 100%;">删除所有图片缓存</button>
</div>
<div id="tab-advanced" class="tab-content">
<!-- ComfyUI 高级设置 -->
<div class="comfyui-settings">
<fieldset style="border: 1px solid var(--vp-border-color); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<legend style="color: var(--vp-accent-color); padding: 0 10px; font-weight: 600;">ComfyUI 生成参数</legend>
<div class="comfy-settings-grid">
<div><label for="comfyui-sampler">采样器</label><select id="comfyui-sampler">
<option>euler</option>
<option>euler_ancestral</option>
<option>dpmpp_2m</option>
<option>dpmpp_sde</option>
<option>dpmpp_2s_ancestral</option>
<option>dpmpp_2m_sde</option>
<option>dpmpp_2m_sde_gpu</option>
<option>dpmpp_3m_sde</option>
<option>dpmpp_3m_sde_gpu</option>
<option>uni_pc</option>
<option>uni_pc_bh2</option>
<option>lcm</option>
</select></div>
<div><label for="comfyui-scheduler">调度器</label><select id="comfyui-scheduler">
<option>normal</option>
<option>karras</option>
<option>exponential</option>
<option>sgm_uniform</option>
<option>simple</option>
<option>ddim_uniform</option>
</select></div>
<div><label for="comfyui-steps">步数</label><input id="comfyui-steps" type="number" min="1" max="100" step="1"></div>
<div><label for="comfyui-cfg">CFG</label><input id="comfyui-cfg" type="number" min="1.0" max="20.0" step="0.5"></div>
</div>
<button id="comfyui-apply-gen-params" class="comfy-button" style="width:100%; margin-top: 15px;">应用生成参数</button>
</fieldset>
</div>
<!-- WebUI 高级设置 -->
<div class="webui-settings" style="display: none;">
<fieldset style="border: 1px solid var(--vp-border-color); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<legend style="color: var(--vp-accent-color); padding: 0 10px; font-weight: 600;">WebUI 生成参数</legend>
<div class="comfy-settings-grid">
<div><label for="webui-sampler">采样器</label><select id="webui-sampler">
<option>Euler a</option>
<option>Euler</option>
<option>LMS</option>
<option>Heun</option>
<option>DPM2</option>
<option>DPM2 a</option>
<option>DPM++ 2S a</option>
<option>DPM++ 2M</option>
<option>DPM++ SDE</option>
<option>DPM fast</option>
<option>DPM adaptive</option>
<option>LMS Karras</option>
<option>DPM2 Karras</option>
<option>DPM2 a Karras</option>
<option>DPM++ 2S a Karras</option>
<option>DPM++ 2M Karras</option>
<option>DPM++ SDE Karras</option>
<option>UniPC</option>
<option>DDIM</option>
<option>PLMS</option>
</select></div>
<div><label for="webui-scheduler">调度器</label><select id="webui-scheduler">
<option>Automatic</option>
<option>Uniform</option>
<option>Karras</option>
<option>Exponential</option>
<option>Polyexponential</option>
<option>SGM Uniform</option>
<option>KL Optimal</option>
<option>Align Your Steps</option>
<option>Simple</option>
<option>Normal</option>
<option>DDIM</option>
<option>Beta</option>
</select></div>
<div><label for="webui-steps">步数</label><input id="webui-steps" type="number" min="1" max="100" step="1" value="20"></div>
<div><label for="webui-cfg">CFG Scale</label><input id="webui-cfg" type="number" min="1.0" max="20.0" step="0.5" value="7.0"></div>
<div><label for="webui-denoising">降噪强度</label><input id="webui-denoising" type="number" min="0.0" max="1.0" step="0.05" value="0.7"></div>
</div>
<div class="comfy-auto-generate-container">
<label class="comfy-auto-generate-label">
<input id="webui-enable-hires" type="checkbox">
<b>启用高分辨率修复</b>
<span>- 提升图片质量和细节</span>
</label>
</div>
<div class="comfy-settings-grid" id="hires-settings" style="display: none;">
<div><label for="webui-hires-upscaler">高清修复算法</label><select id="webui-hires-upscaler">
<option>Latent</option>
<option>Latent (antialiased)</option>
<option>Latent (bicubic)</option>
<option>Latent (bicubic antialiased)</option>
<option>Latent (nearest)</option>
<option>None</option>
<option>Lanczos</option>
<option>Nearest</option>
<option>LDSR</option>
<option>BSRGAN</option>
<option>ESRGAN_4x</option>
<option>R-ESRGAN 4x+</option>
<option>R-ESRGAN 4x+ Anime6B</option>
<option>ScuNET GAN</option>
<option>ScuNET PSNR</option>
<option>SwinIR 4x</option>
</select></div>
<div><label for="webui-hires-steps">高清修复步数</label><input id="webui-hires-steps" type="number" min="0" max="100" step="1" value="0"></div>
<div><label for="webui-hires-upscale">放大倍数</label><input id="webui-hires-upscale" type="number" min="1.0" max="4.0" step="0.1" value="2.0"></div>
<div><label for="webui-hires-denoising">高清修复重绘强度</label><input id="webui-hires-denoising" type="number" min="0.0" max="1.0" step="0.05" value="0.5"></div>
</div>
<button id="webui-apply-gen-params" class="comfy-button" style="width:100%; margin-top: 15px;">应用生成参数</button>
</fieldset>
</div>
<fieldset style="border: 1px solid var(--vp-border-color); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<legend style="color: var(--vp-accent-color); padding: 0 10px; font-weight: 600;">提示词预设管理</legend>
<div class="comfy-settings-grid" style="grid-template-columns: 1fr auto auto auto;">
<div>
<label for="prompt-preset-select">选择预设</label>
<select id="prompt-preset-select">
<option value="">选择预设...</option>
</select>
</div>
<div style="display: flex; align-items: end;">
<button id="prompt-preset-load" class="comfy-button" title="加载选中的预设">加载</button>
</div>
<div style="display: flex; align-items: end;">
<button id="prompt-preset-save" class="comfy-button" title="保存当前提示词为预设">保存</button>
</div>
<div style="display: flex; align-items: end;">
<button id="prompt-preset-delete" class="comfy-button error" title="删除选中的预设">删除</button>
</div>
</div>
</fieldset>
<div class="comfy-prompt-area">
<label for="comfyui-positive-prompt">固定正向提示词 (会自动加在生成内容的前面)</label>
<textarea id="comfyui-positive-prompt" placeholder="例如: best quality, masterpiece..."></textarea>
</div>
<div class="comfy-prompt-area">
<label for="comfyui-negative-prompt">固定负向提示词</label>
<textarea id="comfyui-negative-prompt" placeholder="例如: worst quality, low quality, bad hands..."></textarea>
</div>
</div>
<div id="tab-workflows" class="tab-content comfyui-settings">
<div class="edit-mode-toolbar" id="edit-mode-toolbar">
<div class="toolbar-title">编辑模式</div>
<div class="workflow-action-row">
<button id="workflow-save-edit" class="comfy-button success">保存修改</button>
<button id="workflow-cancel-edit" class="comfy-button error">取消编辑</button>
</div>
</div>
<div class="workflow-tools">
<h4>工作流工具</h4>
<div class="workflow-action-row">
<button id="workflow-to-placeholders" class="comfy-button warning">转换为占位符</button>
<button id="workflow-create-new" class="comfy-button">创建新工作流</button>
<button id="workflow-save-current" class="comfy-button">保存当前工作流</button>
</div>
<div class="workflow-action-row">
<button id="workflow-export-all" class="comfy-button">导出工作流</button>
<button id="workflow-import" class="comfy-button">导入工作流</button>
<button id="workflow-edit-mode" class="comfy-button warning">编辑模式</button>
</div>
</div>
<div class="workflow-selector-container">
<div class="workflow-search-container">
<input type="text" id="workflow-search" class="workflow-search-input" placeholder="搜索工作流...">
</div>
<div id="workflow-list">
<!-- 工作流列表将在这里动态生成 -->
</div>
</div>
<label for="comfyui-workflow">当前工作流 (JSON)</label>
<p class="workflow-info">占位符: <b>%prompt%</b> (正向), <b>%negative_prompt%</b> (反向), <b>%width%</b>, <b>%height%</b>, <b>%model%</b>, <b>%unet_model%</b>, <b>%seed%</b>, <b>%steps%</b>, <b>%cfg%</b>, <b>%sampler%</b>, <b>%scheduler%</b></p>
<textarea id="comfyui-workflow" placeholder="在此处粘贴您的ComfyUI工作流JSON..."></textarea>
</div>
<div id="tab-loras" class="tab-content webui-settings" style="display: none;">
<div class="lora-selector">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h4 style="margin: 0; color: var(--vp-accent-color);">LoRA 模型管理</h4>
<button id="webui-refresh-loras" class="comfy-button" title="刷新LoRA列表">
<i class="fa-solid fa-arrows-rotate"></i> 刷新 </button>
</div>
<div class="comfy-settings-grid" style="grid-template-columns: 1fr 120px auto; align-items: end; gap: 10px;">
<div>
<label for="webui-lora-select">选择 LoRA 模型</label>
<select id="webui-lora-select"></select>
</div>
<div>
<label for="webui-lora-weight-input">权重</label>
<input id="webui-lora-weight-input" type="number" value="1.0" step="0.1">
</div>
<div>
<button id="webui-lora-add-button" class="comfy-button" style="width: 100%;">添加至提示词</button>
</div>
</div>
<p style="font-size: 0.85em; color: #aaa; margin-top: 15px;">
点击“添加”按钮,会将LoRA以 <lora:模型名:权重> 的格式插入到“高级参数”标签页下的“固定正向提示词”输入框中。
</p>
</div>
<div class="embedding-selector" style="margin-top: 30px; border-top: 1px solid var(--vp-border-color); padding-top: 20px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h4 style="margin: 0; color: var(--vp-accent-color);">Embedding 模型管理</h4>
<button id="webui-refresh-embeddings" class="comfy-button" title="刷新Embedding列表">
<i class="fa-solid fa-arrows-rotate"></i> 刷新 </button>
</div>
<div class="embedding-list" id="embedding-list">
<!-- Embedding列表将在这里动态生成 -->
</div>
<div class="selected-embeddings" id="selected-embeddings">
<h4>已选中的Embedding</h4>
<div id="selected-embeddings-container">
<!-- 已选中的Embedding标签将在这里显示 -->
</div>
</div>
</div>
</div>
<div id="tab-comfy-loras" class="tab-content comfyui-settings">
<div class="lora-selector">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
<h4 style="margin: 0; color: var(--vp-accent-color);">ComfyUI LoRA 模型管理</h4>
<button id="comfyui-refresh-loras-list" class="comfy-button" title="刷新LoRA列表">
<i class="fa-solid fa-arrows-rotate"></i> 刷新 </button>
</div>
<!-- ComfyUI LoRA预设管理 -->
<fieldset style="border: 1px solid var(--vp-border-color); padding: 15px; border-radius: 8px; margin-bottom: 20px;">
<legend style="color: var(--vp-accent-color); padding: 0 10px; font-weight: 600;">LoRA预设管理</legend>
<div class="comfy-settings-grid" style="grid-template-columns: 1fr auto auto auto;">
<div>
<label for="comfyui-lora-preset-select">选择预设</label>
<select id="comfyui-lora-preset-select">
<option value="">选择LoRA预设...</option>
</select>
</div>
<div style="display: flex; align-items: end;">
<button id="comfyui-lora-preset-load" class="comfy-button" title="加载选中的LoRA预设">加载</button>
</div>
<div style="display: flex; align-items: end;">
<button id="comfyui-lora-preset-save" class="comfy-button" title="保存当前LoRA配置为预设">保存</button>
</div>
<div style="display: flex; align-items: end;">
<button id="comfyui-lora-preset-delete" class="comfy-button error" title="删除选中的预设">删除</button>
</div>
</div>
</fieldset>
<div class="lora-list" id="comfyui-lora-list">
<!-- ComfyUI LoRA列表将在这里动态生成 -->
</div>
<div class="selected-loras" id="comfyui-selected-loras">
<h4>已选中的LoRA</h4>
<div id="comfyui-selected-loras-container">
<!-- 已选中的LoRA标签将在这里显示 -->
</div>
</div>
</div>
</div>
<!-- LoRA预设保存模态框 -->
<div id="lora-preset-save-modal" class="workflow-save-modal">
<h3>保存LoRA预设</h3>
<label for="lora-preset-name-input">预设名称</label>
<input type="text" id="lora-preset-name-input" placeholder="输入LoRA预设名称...">
<div id="lora-preset-overwrite-warning" class="overwrite-warning" style="display: none;"> ⚠️ 该名称的预设已存在,保存将覆盖现有预设 </div>
<div class="modal-actions">
<button id="lora-preset-save-cancel" class="comfy-button error">取消</button>
<button id="lora-preset-save-confirm" class="comfy-button success">保存</button>
</div>
</div>
<div id="tab-cache" class="tab-content">
<div class="cache-toolbar">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h4 style="margin: 0; color: var(--vp-accent-color);">图片缓存管理</h4>
<div>
<button id="cache-refresh" class="comfy-button">刷新</button>
<button id="cache-clear-all" class="comfy-button error">清空所有</button>
</div>
</div>
<div class="cache-stats" id="cache-stats" style="margin-bottom: 15px; color: #aaa; font-size: 0.9em;">
<!-- 统计信息将在这里显示 -->
</div>
</div>
<div class="cache-grid" id="cache-grid">
<!-- 缓存图片将在这里显示 -->
</div>
</div>
<div id="tab-shortcuts" class="tab-content">
<div style="margin-bottom: 20px;">
<h4 style="color: var(--vp-accent-color); margin: 0 0 15px 0;">快捷键配置</h4>
<p style="color: #aaa; font-size: 0.9em; margin-bottom: 20px;">点击快捷键输入框并按下想要的键组合来设置快捷键。支持 Ctrl、Alt、Shift 组合键。</p>
</div>
<div id="shortcuts-config-container">
<!-- 快捷键配置将在这里动态生成 -->
</div>
<div style="margin-top: 20px; padding: 15px; border: 1px solid var(--vp-border-color); border-radius: 8px; background: rgba(0,0,0,0.1);">
<div style="display: flex; gap: 10px;">
<button id="shortcuts-reset" class="comfy-button warning">重置为默认</button>
<button id="shortcuts-disable-all" class="comfy-button error">禁用所有快捷键</button>
<button id="shortcuts-save" class="comfy-button success">保存配置</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 保存工作流模态框 -->
<div id="workflow-save-modal" class="workflow-save-modal">
<h3>保存工作流</h3>
<label for="workflow-name-input">工作流名称</label>
<input type="text" id="workflow-name-input" placeholder="输入工作流名称...">
<div id="overwrite-warning" class="overwrite-warning" style="display: none;"> ⚠️ 该名称的工作流已存在,保存将覆盖现有工作流 </div>
<div class="modal-actions">
<button id="workflow-save-cancel" class="comfy-button error">取消</button>
<button id="workflow-save-confirm" class="comfy-button success">保存</button>
</div>
</div>
<!-- 保存提示词预设模态框 -->
<div id="prompt-preset-save-modal" class="workflow-save-modal">
<h3>保存提示词预设</h3>
<label for="prompt-preset-name-input">预设名称</label>
<input type="text" id="prompt-preset-name-input" placeholder="输入预设名称...">
<div id="prompt-preset-overwrite-warning" class="overwrite-warning" style="display: none;"> ⚠️ 该名称的预设已存在,保存将覆盖现有预设 </div>
<div class="modal-actions">
<button id="prompt-preset-save-cancel" class="comfy-button error">取消</button>
<button id="prompt-preset-save-confirm" class="comfy-button success">保存</button>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', panelHTML);
initPanelLogic();
initKeyboardShortcuts();
}
/**
* 初始化面板逻辑
*/
function initPanelLogic() {
const panel = document.getElementById(PANEL_ID);
// 获取所有输入元素引用
const inputs = {
url: document.getElementById('comfyui-url'),
webuiUrl: document.getElementById('webui-url'),
workflow: document.getElementById('comfyui-workflow'),
startTag: document.getElementById('comfyui-start-tag'),
endTag: document.getElementById('comfyui-end-tag'),
genWidth: document.getElementById('comfyui-gen-width'),
genHeight: document.getElementById('comfyui-gen-height'),
displayWidth: document.getElementById('comfyui-display-width'),
displayHeight: document.getElementById('comfyui-display-height'),
autoGen: document.getElementById('comfyui-auto-generate'),
modelSelect: document.getElementById('comfyui-model-select'),
unetSelect: document.getElementById('comfyui-unet-select'),
webuiModelSelect: document.getElementById('webui-model-select'),
sampler: document.getElementById('comfyui-sampler'),
scheduler: document.getElementById('comfyui-scheduler'),
steps: document.getElementById('comfyui-steps'),
cfg: document.getElementById('comfyui-cfg'),
webuiSampler: document.getElementById('webui-sampler'),
webuiScheduler: document.getElementById('webui-scheduler'),
webuiSteps: document.getElementById('webui-steps'),
webuiCfg: document.getElementById('webui-cfg'),
webuiDenoising: document.getElementById('webui-denoising'),
webuiEnableHires: document.getElementById('webui-enable-hires'),
webuiHiresUpscaler: document.getElementById('webui-hires-upscaler'),
webuiHiresSteps: document.getElementById('webui-hires-steps'),
webuiHiresUpscale: document.getElementById('webui-hires-upscale'),
webuiHiresDenoising: document.getElementById('webui-hires-denoising'),
positivePrompt: document.getElementById('comfyui-positive-prompt'),
negativePrompt: document.getElementById('comfyui-negative-prompt'),
};
// 获取所有按钮元素引用
const buttons = {
close: panel.querySelector('.floating_panel_close'),
test: document.getElementById('comfyui-test-conn'),
webuiTest: document.getElementById('webui-test-conn'),
clearCache: document.getElementById('comfyui-clear-cache'),
applyDims: document.getElementById('comfyui-apply-dims'),
refreshModels: document.getElementById('comfyui-refresh-models'),
refreshUnets: document.getElementById('comfyui-refresh-unets'),
webuiRefreshModels: document.getElementById('webui-refresh-models'),
webuiRefreshLoras: document.getElementById('webui-refresh-loras'),
comfyuiRefreshLoras: document.getElementById('comfyui-refresh-loras-list'),
applyTags: document.getElementById('comfyui-apply-tags'),
applyGenParams: document.getElementById('comfyui-apply-gen-params'),
webuiApplyGenParams: document.getElementById('webui-apply-gen-params'),
toPlaceholders: document.getElementById('workflow-to-placeholders'),
createWorkflow: document.getElementById('workflow-create-new'),
saveWorkflow: document.getElementById('workflow-save-current'),
exportWorkflows: document.getElementById('workflow-export-all'),
importWorkflows: document.getElementById('workflow-import'),
editMode: document.getElementById('workflow-edit-mode'),
saveEdit: document.getElementById('workflow-save-edit'),
cancelEdit: document.getElementById('workflow-cancel-edit'),
saveModalConfirm: document.getElementById('workflow-save-confirm'),
saveModalCancel: document.getElementById('workflow-save-cancel'),
resetPosition: panel.querySelector('.panel-reset-position'),
};
// 设备适配初始化
const deviceType = DeviceDetector.getDeviceType();
panel.classList.add(`device-${deviceType}`);
// 移动端特殊处理
if (deviceType === 'mobile') {
// 禁用拖拽功能
panel.style.position = 'fixed';
panel.style.top = '0';
panel.style.left = '0';
panel.style.transform = 'none';
panel.classList.add('mobile-fullscreen');
// 添加移动端关闭手势
let startY = 0;
let currentY = 0;
let isDragging = false;
const handleTouchStart = (e) => {
startY = e.touches[0].clientY;
isDragging = true;
};
const handleTouchMove = (e) => {
if (!isDragging) return;
currentY = e.touches[0].clientY;
const diffY = currentY - startY;
if (diffY > 50 && startY < 100) { // 从顶部向下滑动超过50px
e.preventDefault();
}
};
const handleTouchEnd = (e) => {
if (!isDragging) return;
isDragging = false;
const diffY = currentY - startY;
if (diffY > 100 && startY < 100) { // 从顶部向下滑动超过100px
panel.style.display = 'none';
}
};
const controlBar = panel.querySelector('.panel-control-bar');
controlBar.addEventListener('touchstart', handleTouchStart, { passive: false });
controlBar.addEventListener('touchmove', handleTouchMove, { passive: false });
controlBar.addEventListener('touchend', handleTouchEnd, { passive: false });
// 确保面板初始化时,滚动指示器就能正常工作。
const panelContent = document.querySelector('.comfyui-panel-content');
// 添加滚动指示器
const scrollIndicator = document.createElement('div');
scrollIndicator.id = 'scroll-indicator';
scrollIndicator.innerHTML = '<i class="fa-solid fa-chevron-down"></i>';
scrollIndicator.style.cssText = `
position: fixed;
bottom: 20px;
right: 20px;
background: var(--vp-accent-color);
color: white;
width: 40px;
height: 40px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
`;
panel.appendChild(scrollIndicator);
// 滚动监听
panelContent.addEventListener('scroll', () => {
const { scrollTop, scrollHeight, clientHeight } = panelContent;
const isNearBottom = scrollTop + clientHeight >= scrollHeight - 50;
scrollIndicator.style.opacity = isNearBottom ? '0' : '0.7';
});
// 点击指示器滚动到底部
scrollIndicator.addEventListener('click', () => {
panelContent.scrollTo({ top: panelContent.scrollHeight, behavior: 'smooth' });
});
scrollIndicator.style.pointerEvents = 'auto';
// 初始检查
setTimeout(() => {
const { scrollHeight, clientHeight } = panelContent;
if (scrollHeight > clientHeight) {
scrollIndicator.style.opacity = '0.7';
}
}, 500);
}
// 模式切换按钮
document.querySelectorAll('.mode-switch-option').forEach(btn => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
switchMode(mode);
});
});
// WebUI高分辨率修复选项切换
inputs.webuiEnableHires.addEventListener('change', () => {
const hiresSettings = document.getElementById('hires-settings');
hiresSettings.style.display = inputs.webuiEnableHires.checked ? 'block' : 'none';
});
// 搜索输入框
const workflowSearch = document.getElementById('workflow-search');
// 初始化选项卡功能
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', () => {
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
button.classList.add('active');
const tabId = button.getAttribute('data-tab');
document.getElementById(`tab-${tabId}`).classList.add('active');
if (tabId === 'shortcuts') {
initShortcutsConfig();
}
if (DeviceDetector.isMobile()) {
const panelContent = document.querySelector('.comfyui-panel-content');
if (panelContent) {
panelContent.scrollTop = 0;
// 延迟重新计算滚动指示器
setTimeout(() => {
const scrollIndicator = document.getElementById('scroll-indicator');
if (scrollIndicator) {
const { scrollHeight, clientHeight } = panelContent;
scrollIndicator.style.opacity = scrollHeight > clientHeight ? '0.7' : '0';
}
}, 100);
}
}
if (tabId === 'loras') {
updateSelectedLorasDisplay();
updateSelectedEmbeddingsDisplay();
}
if (tabId === 'comfy-loras') {
updateComfyUISelectedLorasDisplay();
loadComfyUILoraPresets();
}
if (tabId === 'cache') {
loadImageCache();
}
});
});
// 关闭按钮事件
buttons.close.addEventListener('click', () => {
panel.style.display = 'none';
});
// 重置位置按钮事件
buttons.resetPosition.addEventListener('click', () => {
resetPanelPosition(panel);
});
// 双击标题栏重置位置
const controlBar = panel.querySelector('.panel-control-bar');
let lastClickTime = 0;
controlBar.addEventListener('click', (e) => {
if (e.target.classList.contains('floating_panel_close') ||
e.target.classList.contains('panel-reset-position')) {
return;
}
const currentTime = Date.now();
if (currentTime - lastClickTime < 300) {
resetPanelPosition(panel);
}
lastClickTime = currentTime;
});
// 初始化拖拽功能(仅桌面端)
if (deviceType === 'desktop' && typeof $ !== 'undefined' && typeof $.fn.draggable !== 'undefined') {
$(`#${PANEL_ID}`).draggable({
handle: ".panel-control-bar",
containment: "document",
start: function () {
panel.style.transform = 'none';
panel.classList.add('dragging');
},
drag: function (event, ui) {
const panelWidth = panel.offsetWidth;
const panelHeight = panel.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
if (ui.position.left < -(panelWidth - 50)) {
ui.position.left = -(panelWidth - 50);
}
if (ui.position.left > viewportWidth - 50) {
ui.position.left = viewportWidth - 50;
}
if (ui.position.top < 0) {
ui.position.top = 0;
}
if (ui.position.top > viewportHeight - 50) {
ui.position.top = viewportHeight - 50;
}
},
stop: function () {
setTimeout(() => {
checkAndFixPanelPosition(panel);
}, 10);
}
});
}
// 窗口大小改变时检查面板位置
window.addEventListener('resize', () => {
if (panel.classList.contains('dragging')) {
setTimeout(() => {
checkAndFixPanelPosition(panel);
}, 100);
}
});
// 工作流搜索功能
workflowSearch.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
filterWorkflows(searchTerm);
});
// 编辑模式按钮事件
buttons.editMode.addEventListener('click', () => {
toggleEditMode();
});
buttons.saveEdit.addEventListener('click', () => {
saveEditedWorkflow();
});
buttons.cancelEdit.addEventListener('click', () => {
cancelEditMode();
});
// 测试连接按钮事件(ComfyUI)
buttons.test.addEventListener('click', async () => {
let url = inputs.url.value.trim();
if (!url) return;
if (!url.startsWith('http')) url = 'http://' + url;
if (url.endsWith('/')) url = url.slice(0, -1);
inputs.url.value = url;
if (typeof toastr !== 'undefined') {
toastr.info('正在尝试连接ComfyUI...');
}
buttons.test.className = 'comfy-button testing';
buttons.test.disabled = true;
try {
const response = await makeRequest({
method: "GET",
url: `${url}/system_stats`,
timeout: 3600000
});
buttons.test.disabled = false;
buttons.test.classList.remove('testing');
const success = response.status === 200;
buttons.test.classList.add(success ? 'success' : 'error');
if (success) {
await fetchAndPopulateModels(url, inputs.modelSelect);
await fetchAndPopulateUNetModels(url, inputs.unetSelect);
await fetchAndPopulateComfyUILoras(url);
}
} catch (error) {
buttons.test.disabled = false;
buttons.test.classList.remove('testing');
buttons.test.classList.add('error');
if (typeof toastr !== 'undefined') {
toastr.error(`ComfyUI连接失败: ${error.message}`);
}
}
});
// 测试连接按钮事件(WebUI)
buttons.webuiTest.addEventListener('click', async () => {
let url = inputs.webuiUrl.value.trim();
if (!url) return;
if (!url.startsWith('http')) url = 'http://' + url;
if (url.endsWith('/')) url = url.slice(0, -1);
inputs.webuiUrl.value = url;
if (typeof toastr !== 'undefined') {
toastr.info('正在尝试连接WebUI...');
}
buttons.webuiTest.className = 'comfy-button testing';
buttons.webuiTest.disabled = true;
try {
const response = await makeRequest({
method: "GET",
url: `${url}/sdapi/v1/sd-models`,
timeout: 3600000
});
buttons.webuiTest.disabled = false;
buttons.webuiTest.classList.remove('testing');
const success = response.status === 200;
buttons.webuiTest.classList.add(success ? 'success' : 'error');
if (success) {
await fetchAndPopulateWebUIModels(url, inputs.webuiModelSelect);
await fetchAndPopulateWebUILoras(url);
}
} catch (error) {
buttons.webuiTest.disabled = false;
buttons.webuiTest.classList.remove('testing');
buttons.webuiTest.classList.add('error');
if (typeof toastr !== 'undefined') {
toastr.error(`WebUI连接失败: ${error.message}`);
}
}
});
// 辅助函数,用于处理需要URL的API调用
const handleApiAction = (urlInput, action, warningMessage) => {
return async () => {
const url = urlInput.value.trim();
if (url) {
await action(url);
} else if (typeof toastr !== 'undefined') {
toastr.warning(warningMessage);
}
};
};
// 刷新模型按钮事件
buttons.refreshModels.addEventListener('click', handleApiAction(inputs.url, (url) => fetchAndPopulateModels(url, inputs.modelSelect), '请先输入ComfyUI URL'));
// 刷新UNet模型按钮事件
buttons.refreshUnets.addEventListener('click', handleApiAction(inputs.url, (url) => fetchAndPopulateUNetModels(url, inputs.unetSelect), '请先输入ComfyUI URL'));
// 刷新WebUI模型按钮事件
buttons.webuiRefreshModels.addEventListener('click', handleApiAction(inputs.webuiUrl, (url) => fetchAndPopulateWebUIModels(url, inputs.webuiModelSelect), '请先输入WebUI URL'));
// 刷新WebUI LoRA按钮事件
buttons.webuiRefreshLoras.addEventListener('click', handleApiAction(inputs.webuiUrl, fetchAndPopulateWebUILoras, '请先输入WebUI URL'));
// 刷新WebUI Embedding按钮事件
buttons.webuiRefreshEmbeddings = document.getElementById('webui-refresh-embeddings');
buttons.webuiRefreshEmbeddings.addEventListener('click', handleApiAction(inputs.webuiUrl, fetchAndPopulateWebUIEmbeddings, '请先输入WebUI URL'));
// 刷新ComfyUI LoRA按钮事件 (合并了两个按钮的逻辑)
buttons.comfyuiRefreshLorasList = document.getElementById('comfyui-refresh-loras-list');
const refreshComfyLorasAction = handleApiAction(inputs.url, fetchAndPopulateComfyUILoras, '请先输入ComfyUI URL');
if (buttons.comfyuiRefreshLoras) {
buttons.comfyuiRefreshLoras.addEventListener('click', refreshComfyLorasAction);
}
if (buttons.comfyuiRefreshLorasList) {
buttons.comfyuiRefreshLorasList.addEventListener('click', refreshComfyLorasAction);
}
// 清除缓存按钮事件
buttons.clearCache.addEventListener('click', () => {
if (confirm('您确定要删除所有已生成的图片缓存吗?')) {
GM_setValue(STORAGE_KEY_IMAGES, {});
document.querySelectorAll('.comfy-image-container').forEach(el => el.remove());
document.querySelectorAll('.comfy-button-group').forEach(group => {
group.querySelector('.comfy-delete-button')?.remove();
const genBtn = group.querySelector('.comfy-chat-generate-button');
if (genBtn) {
genBtn.textContent = '开始生成';
genBtn.disabled = false;
genBtn.className = 'comfy-button comfy-chat-generate-button';
}
});
if (typeof toastr !== 'undefined') {
toastr.success('图片缓存已清空');
}
}
});
// 应用显示尺寸按钮事件
async function applyDisplayDimensionsToAll() {
const displayWidth = await GM_getValue('comfyui_display_width', DEFAULT_SETTINGS.displayWidth);
const displayHeight = await GM_getValue('comfyui_display_height', DEFAULT_SETTINGS.displayHeight);
document.querySelectorAll('.comfy-image-container img').forEach(img => {
img.style.maxWidth = displayWidth > 0 ? `${displayWidth}px` : '100%';
img.style.maxHeight = displayHeight > 0 ? `${displayHeight}px` : '';
img.style.width = displayWidth > 0 ? 'auto' : '';
img.style.height = 'auto';
});
if (typeof toastr !== 'undefined') {
toastr.success(`显示尺寸已应用`);
}
}
buttons.applyDims.addEventListener('click', applyDisplayDimensionsToAll);
// 应用标记按钮事件
buttons.applyTags.addEventListener('click', async () => {
await saveSettings(inputs);
if (typeof toastr !== 'undefined') {
toastr.success('捕获标记已更新!将立即对新消息生效。');
}
});
// 应用生成参数按钮事件
buttons.applyGenParams.addEventListener('click', async () => {
await saveSettings(inputs);
if (typeof toastr !== 'undefined') {
toastr.success('ComfyUI生成参数已保存!');
}
});
// 应用WebUI生成参数按钮事件
buttons.webuiApplyGenParams.addEventListener('click', async () => {
await saveSettings(inputs);
if (typeof toastr !== 'undefined') {
toastr.success('WebUI生成参数已保存!');
}
});
// 转换为占位符按钮事件
buttons.toPlaceholders.addEventListener('click', () => {
const workflowText = inputs.workflow.value;
if (!workflowText.trim()) {
if (typeof toastr !== 'undefined') {
toastr.error('工作流内容为空');
}
return;
}
try {
const convertedWorkflow = convertWorkflowToPlaceholders(workflowText);
inputs.workflow.value = convertedWorkflow;
if (typeof toastr !== 'undefined') {
toastr.success('工作流已转换为占位符格式(不一定完全正确,务必再自行检测一遍!)');
}
} catch (error) {
if (typeof toastr !== 'undefined') {
toastr.error(`转换失败: ${error.message}`);
}
}
});
// 创建新工作流按钮事件
buttons.createWorkflow.addEventListener('click', () => {
inputs.workflow.value = '';
showWorkflowSaveModal('新工作流');
});
// 保存当前工作流按钮事件
buttons.saveWorkflow.addEventListener('click', () => {
if (!inputs.workflow.value.trim()) {
if (typeof toastr !== 'undefined') {
toastr.error('工作流内容不能为空');
}
return;
}
showWorkflowSaveModal('');
});
// 导出工作流按钮事件
buttons.exportWorkflows.addEventListener('click', async () => {
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
if (Object.keys(workflows).length === 0) {
if (typeof toastr !== 'undefined') {
toastr.warning('没有工作流可导出');
}
return;
}
// 创建一个新对象,用于存放解析后的工作流
const parsedWorkflows = {};
for (const [name, workflowString] of Object.entries(workflows)) {
try {
// 将每个字符串化的工作流解析回JSON对象
parsedWorkflows[name] = JSON.parse(workflowString);
} catch (e) {
console.warn(`跳过格式错误的工作流: ${name}`, e);
// 如果某个工作流格式错误,可以选择跳过或保留原始字符串
// 这里我们选择跳过,以保证导出文件的规范性
}
}
// 对包含真实JSON对象的总对象进行字符串化
const exportData = JSON.stringify(parsedWorkflows, null, 2);
const blob = new Blob([exportData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `comfyui_workflows_${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (typeof toastr !== 'undefined') {
toastr.success('已导出工作流');
}
});
// 导入工作流按钮事件
buttons.importWorkflows.addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (event) => {
try {
const importedData = JSON.parse(event.target.result);
if (typeof importedData !== 'object' || importedData === null) {
throw new Error('无效的工作流文件格式');
}
const existingWorkflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
const mergedWorkflows = { ...existingWorkflows };
let importCount = 0;
let overwriteCount = 0;
// 检查是否为单个工作流JSON(直接的工作流对象)
if (importedData.hasOwnProperty('workflow') ||
importedData.hasOwnProperty('nodes') ||
(typeof importedData === 'object' &&
Object.keys(importedData).some(key =>
typeof importedData[key] === 'object' &&
importedData[key] !== null &&
(importedData[key].hasOwnProperty('class_type') ||
importedData[key].hasOwnProperty('inputs'))
))) {
// 这是一个单个工作流JSON (这部分逻辑保持不变)
const workflowName = prompt('请为导入的工作流命名:', `导入的工作流_${new Date().toLocaleString()}`);
if (workflowName && workflowName.trim()) {
const trimmedName = workflowName.trim();
if (existingWorkflows[trimmedName]) {
if (confirm(`工作流"${trimmedName}"已存在,是否覆盖?`)) {
mergedWorkflows[trimmedName] = JSON.stringify(importedData, null, 2);
overwriteCount++;
}
} else {
mergedWorkflows[trimmedName] = JSON.stringify(importedData, null, 2);
importCount++;
}
}
} else {
// 这是多个工作流的集合(工作流管理器导出的格式)
for (const [name, workflowData] of Object.entries(importedData)) {
let workflowString;
// 检查导入的工作流数据是对象还是字符串(为了兼容旧的错误导出格式)
if (typeof workflowData === 'object' && workflowData !== null) {
workflowString = JSON.stringify(workflowData, null, 2);
} else if (typeof workflowData === 'string') {
try {
JSON.parse(workflowData);
workflowString = workflowData;
} catch (e) {
console.warn(`跳过无效的字符串工作流: ${name}`);
continue;
}
} else {
console.warn(`跳过格式无法识别的工作流: ${name}`);
continue;
}
if (existingWorkflows[name]) {
overwriteCount++;
} else {
importCount++;
}
mergedWorkflows[name] = workflowString;
}
}
await GM_setValue(STORAGE_KEY_WORKFLOWS, mergedWorkflows);
updateWorkflowList();
if (typeof toastr !== 'undefined') {
if (importCount > 0 && overwriteCount > 0) {
toastr.success(`成功导入${importCount}个新工作流,覆盖${overwriteCount}个已有工作流`);
} else if (importCount > 0) {
toastr.success(`成功导入${importCount}个工作流`);
} else if (overwriteCount > 0) {
toastr.success(`成功覆盖${overwriteCount}个工作流`);
} else {
toastr.info('没有导入任何工作流');
}
}
} catch (error) {
console.error('导入工作流失败:', error);
if (typeof toastr !== 'undefined') {
toastr.error(`导入失败: ${error.message}`);
}
}
};
reader.readAsText(file);
};
input.click();
});
// 工作流保存模态框确认按钮事件
buttons.saveModalConfirm.addEventListener('click', async () => {
const nameInput = document.getElementById('workflow-name-input');
const workflowName = nameInput.value.trim();
if (!workflowName) {
if (typeof toastr !== 'undefined') {
toastr.error('请输入工作流名称');
}
return;
}
const workflowContent = inputs.workflow.value.trim();
if (!workflowContent) {
if (typeof toastr !== 'undefined') {
toastr.error('工作流内容不能为空');
}
return;
}
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
const isNew = !workflows[workflowName];
workflows[workflowName] = workflowContent;
await GM_setValue(STORAGE_KEY_WORKFLOWS, workflows);
updateWorkflowList();
document.getElementById('workflow-save-modal').style.display = 'none';
if (typeof toastr !== 'undefined') {
toastr.success(isNew ? `工作流"${workflowName}"已创建` : `工作流"${workflowName}"已更新`);
}
});
// 工作流保存模态框取消按钮事件
buttons.saveModalCancel.addEventListener('click', () => {
document.getElementById('workflow-save-modal').style.display = 'none';
});
// 工作流名称输入框监听
const nameInput = document.getElementById('workflow-name-input');
nameInput.addEventListener('input', async () => {
const workflowName = nameInput.value.trim();
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
const warning = document.getElementById('overwrite-warning');
if (workflowName && workflows[workflowName]) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
});
// ComfyUI LoRA预设相关按钮事件
const comfyuiLoraPresetButtons = {
load: document.getElementById('comfyui-lora-preset-load'),
save: document.getElementById('comfyui-lora-preset-save'),
delete: document.getElementById('comfyui-lora-preset-delete')
};
// ComfyUI LoRA加载预设按钮事件
comfyuiLoraPresetButtons.load.addEventListener('click', async () => {
const select = document.getElementById('comfyui-lora-preset-select');
const selectedPreset = select.value;
if (!selectedPreset) {
if (typeof toastr !== 'undefined') {
toastr.warning('请先选择一个LoRA预设');
}
return;
}
await loadSelectedComfyUILoraPreset(selectedPreset);
});
// ComfyUI LoRA保存预设按钮事件
comfyuiLoraPresetButtons.save.addEventListener('click', () => {
const selectedLoras = getCurrentComfyUISelectedLoras();
if (selectedLoras.length === 0) {
if (typeof toastr !== 'undefined') {
toastr.warning('请先选择一些ComfyUI LoRA模型');
}
return;
}
showLoraPresetSaveModal('comfyui');
});
// ComfyUI LoRA删除预设按钮事件
comfyuiLoraPresetButtons.delete.addEventListener('click', async () => {
const select = document.getElementById('comfyui-lora-preset-select');
const selectedPreset = select.value;
if (!selectedPreset) {
if (typeof toastr !== 'undefined') {
toastr.warning('请先选择一个LoRA预设');
}
return;
}
if (confirm(`确定要删除ComfyUI LoRA预设"${selectedPreset}"吗?此操作不可撤销。`)) {
await deleteSelectedComfyUILoraPreset(selectedPreset);
}
});
// LoRA预设保存模态框相关按钮事件
const loraPresetModalButtons = {
saveConfirm: document.getElementById('lora-preset-save-confirm'),
saveCancel: document.getElementById('lora-preset-save-cancel')
};
// LoRA预设保存模态框确认按钮事件
loraPresetModalButtons.saveConfirm.addEventListener('click', async () => {
const nameInput = document.getElementById('lora-preset-name-input');
const presetName = nameInput.value.trim();
if (!presetName) {
if (typeof toastr !== 'undefined') {
toastr.error('请输入预设名称');
}
return;
}
// 之前可能存在webui的预设逻辑,这里明确指定为comfyui
if (currentLoraPresetType === 'comfyui') {
await saveCurrentComfyUILoraAsPreset(presetName);
}
document.getElementById('lora-preset-save-modal').style.display = 'none';
if (typeof toastr !== 'undefined') {
toastr.success(`LoRA预设"${presetName}"已保存`);
}
});
// LoRA预设保存模态框取消按钮事件
loraPresetModalButtons.saveCancel.addEventListener('click', () => {
document.getElementById('lora-preset-save-modal').style.display = 'none';
});
// LoRA预设名称输入框监听
const loraPresetNameInput = document.getElementById('lora-preset-name-input');
loraPresetNameInput.addEventListener('input', async () => {
const presetName = loraPresetNameInput.value.trim();
// 假设只处理comfyui
const storageKey = STORAGE_KEY_COMFYUI_LORA_PRESETS;
const presets = await GM_getValue(storageKey, {});
const warning = document.getElementById('lora-preset-overwrite-warning');
if (presetName && presets[presetName]) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
});
// 缓存管理按钮事件
document.getElementById('cache-refresh')?.addEventListener('click', () => {
loadImageCache();
});
document.getElementById('cache-clear-all')?.addEventListener('click', () => {
clearAllCache();
});
// 提示词预设相关按钮事件
const presetButtons = {
load: document.getElementById('prompt-preset-load'),
save: document.getElementById('prompt-preset-save'),
delete: document.getElementById('prompt-preset-delete'),
saveModalConfirm: document.getElementById('prompt-preset-save-confirm'),
saveModalCancel: document.getElementById('prompt-preset-save-cancel')
};
// 加载预设按钮事件
presetButtons.load.addEventListener('click', async () => {
const select = document.getElementById('prompt-preset-select');
const selectedPreset = select.value;
if (!selectedPreset) {
if (typeof toastr !== 'undefined') {
toastr.warning('请先选择一个预设');
}
return;
}
await loadSelectedPreset(selectedPreset);
});
// 保存预设按钮事件
presetButtons.save.addEventListener('click', () => {
showPromptPresetSaveModal();
});
// 删除预设按钮事件
presetButtons.delete.addEventListener('click', async () => {
const select = document.getElementById('prompt-preset-select');
const selectedPreset = select.value;
if (!selectedPreset) {
if (typeof toastr !== 'undefined') {
toastr.warning('请先选择一个预设');
}
return;
}
if (confirm(`确定要删除预设"${selectedPreset}"吗?此操作不可撤销。`)) {
await deleteSelectedPreset(selectedPreset);
}
});
// 预设保存模态框确认按钮事件
presetButtons.saveModalConfirm.addEventListener('click', async () => {
const nameInput = document.getElementById('prompt-preset-name-input');
const presetName = nameInput.value.trim();
if (!presetName) {
if (typeof toastr !== 'undefined') {
toastr.error('请输入预设名称');
}
return;
}
await saveCurrentAsPreset(presetName);
document.getElementById('prompt-preset-save-modal').style.display = 'none';
if (typeof toastr !== 'undefined') {
toastr.success(`预设"${presetName}"已保存`);
}
});
// 预设保存模态框取消按钮事件
presetButtons.saveModalCancel.addEventListener('click', () => {
document.getElementById('prompt-preset-save-modal').style.display = 'none';
});
// 预设名称输入框监听
const presetNameInput = document.getElementById('prompt-preset-name-input');
presetNameInput.addEventListener('input', async () => {
const presetName = presetNameInput.value.trim();
const presets = await GM_getValue(STORAGE_KEY_PROMPT_PRESETS, {});
const warning = document.getElementById('prompt-preset-overwrite-warning');
if (presetName && presets[presetName]) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
});
const addLoraButton = document.getElementById('webui-lora-add-button');
if (addLoraButton) {
addLoraButton.addEventListener('click', () => {
const loraSelect = document.getElementById('webui-lora-select');
const weightInput = document.getElementById('webui-lora-weight-input');
const positivePromptTextarea = document.getElementById('comfyui-positive-prompt');
const loraName = loraSelect.value;
const loraWeight = weightInput.value || '1.0';
if (!loraName) {
if (typeof toastr !== 'undefined') toastr.warning('请先从下拉列表中选择一个LoRA模型');
return;
}
const loraTag = `<lora:${loraName}:${loraWeight}>`;
// 智能地将 LoRA Tag 添加到正向提示词输入框
if (positivePromptTextarea.value.trim().length > 0) {
// 如果末尾不是逗号或空格,则先添加
if (!positivePromptTextarea.value.endsWith(',') && !positivePromptTextarea.value.endsWith(', ')) {
positivePromptTextarea.value += ', ';
} else if (positivePromptTextarea.value.endsWith(',')) {
positivePromptTextarea.value += ' ';
}
}
positivePromptTextarea.value += loraTag;
// 触发 input 事件,以便其他依赖此输入的逻辑(如自动保存)能够正确触发
positivePromptTextarea.dispatchEvent(new Event('input', { bubbles: true }));
if (typeof toastr !== 'undefined') toastr.success(`已添加LoRA: ${loraName}`);
});
}
// 加载设置
loadSettings(inputs);
// 加载当前模式
loadCurrentMode();
// 加载快捷键配置并更新按钮标题
loadShortcuts().then(() => {
updateButtonTitles();
});
// 加载工作流列表
updateWorkflowList();
// 加载LoRA预设列表
loadComfyUILoraPresets();
// 加载提示词预设列表
loadPromptPresets();
// 自动保存其他设置
Object.entries(inputs).forEach(([key, input]) => {
if (['startTag', 'endTag', 'sampler', 'scheduler', 'steps', 'cfg', 'webuiSampler', 'webuiScheduler', 'webuiSteps', 'webuiCfg', 'webuiDenoising', 'webuiEnableHires', 'webuiHiresUpscaler', 'webuiHiresSteps', 'webuiHiresUpscale', 'webuiHiresDenoising'].includes(key)) {
return;
}
const eventType = (input.tagName === 'SELECT' || input.type === 'checkbox') ? 'change' : 'input';
input.addEventListener(eventType, async () => {
if (input === inputs.url) {
buttons.test.className = 'comfy-button';
} else if (input === inputs.webuiUrl) {
buttons.webuiTest.className = 'comfy-button';
}
await saveSettings(inputs);
});
});
}
/**
* 加载当前模式
*/
async function loadCurrentMode() {
currentMode = await GM_getValue(STORAGE_KEY_MODE, DEFAULT_SETTINGS.mode);
updateModeUI();
}
/**
* 切换编辑模式
*/
function toggleEditMode() {
isEditMode = !isEditMode;
const toolbar = document.getElementById('edit-mode-toolbar');
const editModeBtn = document.getElementById('workflow-edit-mode');
if (isEditMode) {
toolbar.classList.add('active');
editModeBtn.textContent = '退出编辑';
editModeBtn.classList.add('error');
if (typeof toastr !== 'undefined') {
toastr.info('已进入编辑模式,点击工作流名称可直接编辑');
}
} else {
toolbar.classList.remove('active');
editModeBtn.textContent = '编辑模式';
editModeBtn.classList.remove('error');
document.querySelectorAll('.workflow-item.editing').forEach(item => {
item.classList.remove('editing');
});
}
}
/**
* 保存编辑的工作流
*/
async function saveEditedWorkflow() {
if (!currentEditingWorkflow) return;
const editInput = currentEditingWorkflow.querySelector('.workflow-edit-input');
const newName = editInput.value.trim();
const oldName = currentEditingWorkflow.dataset.workflowName;
if (!newName) {
if (typeof toastr !== 'undefined') {
toastr.error('工作流名称不能为空');
}
return;
}
if (newName === oldName) {
currentEditingWorkflow.classList.remove('editing');
currentEditingWorkflow = null;
return;
}
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
if (workflows[newName] && newName !== oldName) {
if (!confirm(`工作流"${newName}"已存在,是否覆盖?`)) {
return;
}
}
workflows[newName] = workflows[oldName];
if (newName !== oldName) {
delete workflows[oldName];
}
await GM_setValue(STORAGE_KEY_WORKFLOWS, workflows);
const currentWorkflow = await GM_getValue('comfyui_workflow', '');
if (currentWorkflow === workflows[newName]) {
await GM_setValue('comfyui_workflow', workflows[newName]);
}
updateWorkflowList();
currentEditingWorkflow = null;
if (typeof toastr !== 'undefined') {
toastr.success(`工作流已重命名为"${newName}"`);
}
}
/**
* 取消编辑模式
*/
function cancelEditMode() {
if (currentEditingWorkflow) {
currentEditingWorkflow.classList.remove('editing');
currentEditingWorkflow = null;
}
if (isEditMode) {
toggleEditMode();
}
}
/**
* 过滤工作流
*/
function filterWorkflows(searchTerm) {
const workflowItems = document.querySelectorAll('.workflow-item');
workflowItems.forEach(item => {
const title = item.querySelector('.workflow-item-title').textContent.toLowerCase();
if (title.includes(searchTerm)) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
}
/**
* 转换工作流为占位符格式
* @param {string} workflowString - 原始工作流字符串
* @returns {string} - 转换后的工作流字符串
*/
function convertWorkflowToPlaceholders(workflowString) {
try {
const workflow = JSON.parse(workflowString);
let modified = false;
// 首先分析节点连接关系,识别正负提示词节点
const nodeConnections = analyzeNodeConnections(workflow);
console.log('节点连接分析结果:', nodeConnections);
// 遍历每个节点
for (const [nodeId, nodeData] of Object.entries(workflow)) {
if (nodeData && typeof nodeData === 'object') {
const result = processNode(nodeData, nodeId, nodeConnections, workflow);
if (result) {
modified = true;
}
}
}
if (!modified) {
throw new Error('未找到可替换的值,工作流可能已经是占位符格式');
}
return JSON.stringify(workflow, null, 2);
} catch (error) {
throw new Error(`解析工作流失败: ${error.message}`);
}
}
/**
* 处理节点对象
* @param {Object} obj - 要处理的对象
* @param {string} nodeId - 节点ID
* @param {Object} connections - 节点连接分析结果
* @param {Object} workflow - 完整的工作流对象
* @returns {boolean} - 是否有修改
*/
function processNode(obj, nodeId, connections, workflow) {
let hasModified = false;
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// 递归处理嵌套对象
const result = processNode(value, nodeId, connections, workflow);
if (result) {
hasModified = true;
}
} else if (typeof value === 'string' || typeof value === 'number') {
const replacement = getPlaceholder(key, value, nodeId, connections, workflow);
if (replacement && replacement !== value) {
obj[key] = replacement;
hasModified = true;
console.log(`替换: 节点${nodeId}, ${key}: "${value}" -> "${replacement}"`);
}
}
}
return hasModified;
}
/**
* 分析节点连接关系,识别正负提示词节点
* @param {Object} workflow - 工作流对象
* @returns {Object} - 节点连接分析结果
*/
function analyzeNodeConnections(workflow) {
const connections = {
positivePromptNodes: new Set(),
negativePromptNodes: new Set(),
samplerNodes: new Set()
};
// 找到所有采样器节点
for (const [nodeId, nodeData] of Object.entries(workflow)) {
if (nodeData.class_type &&
(nodeData.class_type === 'KSampler' ||
nodeData.class_type === 'KSamplerAdvanced' ||
nodeData.class_type.toLowerCase().includes('sampler'))) {
connections.samplerNodes.add(nodeId);
console.log(`找到采样器节点: ${nodeId}`);
}
}
// 分析采样器的positive和negative连接
for (const samplerId of connections.samplerNodes) {
const samplerNode = workflow[samplerId];
if (samplerNode && samplerNode.inputs) {
// 查找positive连接
if (samplerNode.inputs.positive && Array.isArray(samplerNode.inputs.positive)) {
const positiveNodeId = samplerNode.inputs.positive[0];
if (positiveNodeId) {
connections.positivePromptNodes.add(positiveNodeId.toString());
console.log(`识别正向提示词节点: ${positiveNodeId}`);
}
}
// 查找negative连接
if (samplerNode.inputs.negative && Array.isArray(samplerNode.inputs.negative)) {
const negativeNodeId = samplerNode.inputs.negative[0];
if (negativeNodeId) {
connections.negativePromptNodes.add(negativeNodeId.toString());
console.log(`识别负向提示词节点: ${negativeNodeId}`);
}
}
}
}
return connections;
}
/**
* 根据键名和值获取对应的占位符(修复版)
* @param {string} key - 键名
* @param {any} value - 值
* @param {string} nodeId - 节点ID
* @param {Object} connections - 节点连接分析结果
* @param {Object} workflow - 完整的工作流对象
* @returns {string|null} - 占位符或null
*/
function getPlaceholder(key, value, nodeId, connections, workflow) {
const keyLower = key.toLowerCase();
// Checkpoint模型处理
if ((keyLower.includes('ckpt') || keyLower === 'ckpt_name') &&
typeof value === 'string' && value.length > 0) {
return '%model%';
}
// UNet模型处理
if ((keyLower.includes('unet') || keyLower === 'unet_name') &&
typeof value === 'string' && value.length > 0) {
return '%unet_model%';
}
// 修复:改进提示词处理逻辑
if (keyLower === 'text' && typeof value === 'string') {
console.log(`检查text字段: 节点${nodeId}, 值: "${value}", 长度: ${value.length}`);
// 首先通过连接关系判断
if (connections.positivePromptNodes && connections.positivePromptNodes.has(nodeId)) {
console.log(`节点${nodeId}通过连接关系识别为正向提示词`);
return '%prompt%';
} else if (connections.negativePromptNodes && connections.negativePromptNodes.has(nodeId)) {
console.log(`节点${nodeId}通过连接关系识别为负向提示词`);
return '%negative_prompt%';
}
// 通过节点标题判断
const currentNode = workflow[nodeId];
if (currentNode && currentNode._meta && currentNode._meta.title) {
const title = currentNode._meta.title.toLowerCase();
console.log(`检查节点标题: "${currentNode._meta.title}"`);
if (title.includes('负') || title.includes('negative')) {
console.log(`通过标题识别为负向提示词: ${currentNode._meta.title}`);
return '%negative_prompt%';
} else if (title.includes('正') || title.includes('positive')) {
console.log(`通过标题识别为正向提示词: ${currentNode._meta.title}`);
return '%prompt%';
}
}
// 修复:CLIPTextEncode节点的处理逻辑
if (currentNode && currentNode.class_type === 'CLIPTextEncode') {
// 如果无法明确判断,根据值的内容进行智能推断
if (value.trim() === '') {
// 空字符串更可能是正向提示词占位符
console.log(`CLIPTextEncode节点${nodeId}包含空字符串,推断为正向提示词`);
return '%prompt%';
} else {
// 非空字符串,尝试通过内容特征判断
const negativeKeywords = ['worst', 'bad', 'ugly', 'blurry', 'low quality', 'nsfw'];
const hasNegativeKeywords = negativeKeywords.some(keyword =>
value.toLowerCase().includes(keyword)
);
if (hasNegativeKeywords) {
console.log(`CLIPTextEncode节点${nodeId}包含负向关键词,推断为负向提示词`);
return '%negative_prompt%';
} else {
console.log(`CLIPTextEncode节点${nodeId}推断为正向提示词`);
return '%prompt%';
}
}
}
return null;
}
// 采样器相关
if ((keyLower === 'sampler_name' || keyLower === 'sampler') &&
typeof value === 'string' && value.length > 0) {
return '%sampler%';
}
if (keyLower === 'scheduler' && typeof value === 'string' && value.length > 0) {
return '%scheduler%';
}
// 尺寸参数
if (keyLower === 'width' && typeof value === 'number' && value > 0) {
return '%width%';
}
if (keyLower === 'height' && typeof value === 'number' && value > 0) {
return '%height%';
}
// 生成参数
if (keyLower === 'seed' && typeof value === 'number') {
return '%seed%';
}
if (keyLower === 'steps' && typeof value === 'number' && value > 0) {
return '%steps%';
}
if (keyLower === 'cfg' && typeof value === 'number' && value > 0) {
return '%cfg%';
}
return null;
}
/**
* 更新工作流列表显示
*/
async function updateWorkflowList() {
const listContainer = document.getElementById('workflow-list');
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
const currentWorkflow = await GM_getValue('comfyui_workflow', '');
listContainer.innerHTML = '';
if (Object.keys(workflows).length === 0) {
listContainer.innerHTML = '<div class="empty-workflows-message">暂无保存的工作流,请创建或导入工作流</div>';
return;
}
const sortedNames = Object.keys(workflows).sort();
for (const name of sortedNames) {
const workflowData = workflows[name];
const isActive = workflowData === currentWorkflow;
const workflowItem = document.createElement('div');
workflowItem.className = `workflow-item${isActive ? ' active' : ''}`;
workflowItem.dataset.workflowName = name;
workflowItem.innerHTML = `
<div class="workflow-item-title">${escapeHTML(name)}</div>
<input type="text" class="workflow-edit-input" value="${escapeHTML(name)}">
<div class="workflow-item-actions">
<button class="comfy-button workflow-load-btn">加载</button>
<button class="comfy-button workflow-clone-btn">克隆</button>
<button class="comfy-button workflow-rename-btn">重命名</button>
<button class="comfy-button error workflow-delete-btn">删除</button>
</div>
`;
const titleElement = workflowItem.querySelector('.workflow-item-title');
titleElement.addEventListener('click', () => {
if (isEditMode) {
document.querySelectorAll('.workflow-item.editing').forEach(item => {
if (item !== workflowItem) {
item.classList.remove('editing');
}
});
workflowItem.classList.toggle('editing');
currentEditingWorkflow = workflowItem.classList.contains('editing') ? workflowItem : null;
if (currentEditingWorkflow) {
const editInput = workflowItem.querySelector('.workflow-edit-input');
setTimeout(() => {
editInput.focus();
editInput.select();
}, 50);
}
} else {
loadWorkflow(name, workflowData, workflowItem);
}
});
const editInput = workflowItem.querySelector('.workflow-edit-input');
editInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
saveEditedWorkflow();
} else if (e.key === 'Escape') {
workflowItem.classList.remove('editing');
currentEditingWorkflow = null;
}
});
workflowItem.querySelector('.workflow-load-btn').addEventListener('click', () => {
loadWorkflow(name, workflowData, workflowItem);
});
workflowItem.querySelector('.workflow-clone-btn').addEventListener('click', async () => {
const baseName = name;
let cloneName = `${baseName} - 副本`;
let counter = 2;
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
while (workflows[cloneName]) {
cloneName = `${baseName} - 副本${counter}`;
counter++;
}
workflows[cloneName] = workflowData;
await GM_setValue(STORAGE_KEY_WORKFLOWS, workflows);
updateWorkflowList();
if (typeof toastr !== 'undefined') {
toastr.success(`工作流已克隆为"${cloneName}"`);
}
});
workflowItem.querySelector('.workflow-rename-btn').addEventListener('click', async () => {
const newName = prompt(`请输入"${name}"的新名称:`, name);
if (newName && newName.trim() && newName !== name) {
const trimmedNewName = newName.trim();
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
if (workflows[trimmedNewName]) {
if (!confirm(`工作流"${trimmedNewName}"已存在,是否覆盖?`)) {
return;
}
}
workflows[trimmedNewName] = workflows[name];
delete workflows[name];
await GM_setValue(STORAGE_KEY_WORKFLOWS, workflows);
updateWorkflowList();
if (typeof toastr !== 'undefined') {
toastr.success(`工作流已重命名为"${trimmedNewName}"`);
}
}
});
workflowItem.querySelector('.workflow-delete-btn').addEventListener('click', async () => {
if (confirm(`确定要删除工作流"${name}"吗?此操作不可撤销。`)) {
const workflows = await GM_getValue(STORAGE_KEY_WORKFLOWS, {});
delete workflows[name];
await GM_setValue(STORAGE_KEY_WORKFLOWS, workflows);
updateWorkflowList();
if (typeof toastr !== 'undefined') {
toastr.success(`工作流"${name}"已删除`);
}
}
});
listContainer.appendChild(workflowItem);
}
}
/**
* 加载工作流
*/
async function loadWorkflow(name, workflowData, workflowItem) {
document.getElementById('comfyui-workflow').value = workflowData;
await GM_setValue('comfyui_workflow', workflowData);
document.querySelectorAll('.workflow-item').forEach(item => item.classList.remove('active'));
workflowItem.classList.add('active');
if (typeof toastr !== 'undefined') {
toastr.success(`已加载工作流"${name}"`);
}
}
/**
* 显示保存工作流模态框
* @param {string} defaultName - 默认工作流名称
*/
function showWorkflowSaveModal(defaultName = '') {
const modal = document.getElementById('workflow-save-modal');
const nameInput = document.getElementById('workflow-name-input');
nameInput.value = defaultName;
modal.style.display = 'block';
setTimeout(() => nameInput.focus(), 100);
nameInput.dispatchEvent(new Event('input'));
}
/**
* 获取并填充模型列表
* @param {string} url - ComfyUI服务器URL
* @param {HTMLSelectElement} selectElement - 模型选择下拉框元素
* @returns {Promise<void>}
*/
async function fetchAndPopulateModels(url, selectElement) {
selectElement.innerHTML = '<option>正在加载模型...</option>';
selectElement.disabled = true;
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/object_info`,
timeout: 3600000
});
const data = JSON.parse(response.responseText);
const models = data?.CheckpointLoaderSimple?.input?.required?.ckpt_name?.[0];
if (!models || models.length === 0) {
throw new Error("未找到模型");
}
selectElement.innerHTML = '';
models.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
selectElement.appendChild(option);
});
const savedModel = await GM_getValue('comfyui_model');
if (savedModel) selectElement.value = savedModel;
if (typeof toastr !== 'undefined') {
toastr.success('ComfyUI Checkpoint模型列表加载成功');
}
} catch (e) {
selectElement.innerHTML = `<option>加载失败: ${e.message}</option>`;
if (typeof toastr !== 'undefined') {
toastr.error(`加载ComfyUI Checkpoint模型列表失败: ${e.message}`);
}
} finally {
selectElement.disabled = false;
}
}
/**
* 获取并填充UNet模型列表
* @param {string} url - ComfyUI服务器URL
* @param {HTMLSelectElement} selectElement - UNet模型选择下拉框元素
* @returns {Promise<void>}
*/
async function fetchAndPopulateUNetModels(url, selectElement) {
selectElement.innerHTML = '<option>正在加载UNet模型...</option>';
selectElement.disabled = true;
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/object_info`,
timeout: 3600000
});
const data = JSON.parse(response.responseText);
let unetModels = null;
const possibleNodeTypes = ['UNETLoader', 'UnetLoader', 'DiffusionModelLoader', 'UNetLoader'];
for (const nodeType of possibleNodeTypes) {
if (data[nodeType]?.input?.required?.unet_name?.[0]) {
unetModels = data[nodeType].input.required.unet_name[0];
break;
}
}
if (!unetModels || unetModels.length === 0) {
selectElement.innerHTML = '<option value="">无UNet模型可用</option>';
if (typeof toastr !== 'undefined') {
toastr.info('未找到UNet模型,可能此ComfyUI版本不支持或未安装相关节点');
}
return;
}
selectElement.innerHTML = '<option value="">选择UNet模型...</option>';
unetModels.forEach(model => {
const option = document.createElement('option');
option.value = model;
option.textContent = model;
selectElement.appendChild(option);
});
const savedUnetModel = await GM_getValue('comfyui_unet_model');
if (savedUnetModel) selectElement.value = savedUnetModel;
if (typeof toastr !== 'undefined') {
toastr.success('ComfyUI UNet模型列表加载成功');
}
} catch (e) {
selectElement.innerHTML = `<option>加载失败: ${e.message}</option>`;
if (typeof toastr !== 'undefined') {
toastr.error(`加载ComfyUI UNet模型列表失败: ${e.message}`);
}
} finally {
selectElement.disabled = false;
}
}
/**
* 获取并填充WebUI模型列表
* @param {string} url - WebUI服务器URL
* @param {HTMLSelectElement} selectElement - 模型选择下拉框元素
* @returns {Promise<void>}
*/
async function fetchAndPopulateWebUIModels(url, selectElement) {
selectElement.innerHTML = '<option>正在加载模型...</option>';
selectElement.disabled = true;
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/sdapi/v1/sd-models`,
timeout: 3600000
});
const models = JSON.parse(response.responseText);
if (!models || models.length === 0) {
throw new Error("未找到模型");
}
selectElement.innerHTML = '';
models.forEach(model => {
const option = document.createElement('option');
option.value = model.model_name || model.title;
option.textContent = model.model_name || model.title;
selectElement.appendChild(option);
});
const savedModel = await GM_getValue('webui_model');
if (savedModel) {
selectElement.value = savedModel;
} else if (models.length > 0) {
selectElement.value = models[0].model_name || models[0].title;
await GM_setValue('webui_model', selectElement.value);
}
selectElement.dispatchEvent(new Event('change'));
if (typeof toastr !== 'undefined') {
toastr.success('WebUI模型列表加载成功');
}
} catch (e) {
selectElement.innerHTML = `<option>加载失败: ${e.message}</option>`;
if (typeof toastr !== 'undefined') {
toastr.error(`加载WebUI模型列表失败: ${e.message}`);
}
} finally {
selectElement.disabled = false;
}
}
/**
* 获取并填充WebUI LoRA列表到下拉菜单
* @param {string} url - WebUI服务器URL
* @returns {Promise<void>}
*/
async function fetchAndPopulateWebUILoras(url) {
const loraSelect = document.getElementById('webui-lora-select');
if (!loraSelect) return;
// 修正:先显示加载状态,再禁用控件
loraSelect.innerHTML = '<option>正在加载LoRA...</option>';
loraSelect.disabled = true;
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/sdapi/v1/loras`,
timeout: 3600000
});
const loras = JSON.parse(response.responseText);
availableLoras = loras;
if (!loras || loras.length === 0) {
loraSelect.innerHTML = '<option value="">未找到LoRA模型</option>';
return;
}
loraSelect.innerHTML = '<option value="">--- 请选择一个LoRA ---</option>';
loras.forEach(lora => {
const option = document.createElement('option');
option.value = lora.name;
option.textContent = lora.alias || lora.name;
loraSelect.appendChild(option);
});
if (typeof toastr !== 'undefined') {
toastr.success(`已加载 ${loras.length} 个LoRA模型`);
}
} catch (e) {
loraSelect.innerHTML = `<option value="">加载失败</option>`;
if (typeof toastr !== 'undefined') {
toastr.error(`加载LoRA列表失败: ${e.message}`);
}
} finally {
loraSelect.disabled = false;
}
}
/**
* 获取并填充WebUI Embedding列表
* @param {string} url - WebUI服务器URL
* @returns {Promise<void>}
*/
async function fetchAndPopulateWebUIEmbeddings(url) {
const embeddingList = document.getElementById('embedding-list');
embeddingList.innerHTML = '<div style="padding: 20px; text-align: center;">正在加载Embedding...</div>';
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/sdapi/v1/embeddings`,
timeout: 3600000
});
const embeddings = JSON.parse(response.responseText);
availableEmbeddings = Object.keys(embeddings.loaded || {}).map(name => ({ name }));
if (!availableEmbeddings || availableEmbeddings.length === 0) {
embeddingList.innerHTML = '<div style="padding: 20px; text-align: center; color: #888;">未找到Embedding模型</div>';
return;
}
renderEmbeddingList();
if (typeof toastr !== 'undefined') {
toastr.success(`已加载 ${availableEmbeddings.length} 个Embedding模型`);
}
} catch (e) {
embeddingList.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--vp-error-color);">加载失败: ${e.message}</div>`;
if (typeof toastr !== 'undefined') {
toastr.error(`加载Embedding列表失败: ${e.message}`);
}
}
}
/**
* 渲染Embedding列表
*/
function renderEmbeddingList() {
const embeddingList = document.getElementById('embedding-list');
const selectedEmbeddings = getCurrentSelectedEmbeddings();
embeddingList.innerHTML = '';
availableEmbeddings.forEach(embedding => {
const isSelected = selectedEmbeddings.some(selected => selected.name === embedding.name);
const selectedEmbedding = selectedEmbeddings.find(selected => selected.name === embedding.name);
const weight = selectedEmbedding ? selectedEmbedding.weight : 1.0;
const type = selectedEmbedding ? selectedEmbedding.type : 'positive';
const embeddingItem = document.createElement('div');
embeddingItem.className = 'embedding-item';
embeddingItem.innerHTML = `
<div class="embedding-info">
<div class="embedding-name">${escapeHTML(embedding.name)}</div>
</div>
<div class="embedding-controls">
<div class="embedding-type-controls" ${!isSelected ? 'style="display: none;"' : ''}>
<label style="font-size: 0.8em; margin: 0 5px 0 0; color: var(--vp-text-color);">
<input type="radio" class="embedding-type-radio" name="type-${escapeHTML(embedding.name)}" value="positive" ${type === 'positive' ? 'checked' : ''}> 正向
</label>
<label style="font-size: 0.8em; margin: 0 10px 0 0; color: var(--vp-text-color);">
<input type="radio" class="embedding-type-radio" name="type-${escapeHTML(embedding.name)}" value="negative" ${type === 'negative' ? 'checked' : ''}> 负向
</label>
</div>
<input type="number" class="embedding-weight" min="0" max="2" step="0.1" value="${weight}" ${!isSelected ? 'disabled' : ''}>
<input type="checkbox" class="embedding-checkbox" ${isSelected ? 'checked' : ''}>
</div>
`;
const checkbox = embeddingItem.querySelector('.embedding-checkbox');
const weightInput = embeddingItem.querySelector('.embedding-weight');
const typeControls = embeddingItem.querySelector('.embedding-type-controls');
const typeRadios = embeddingItem.querySelectorAll('.embedding-type-radio');
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
const selectedType = embeddingItem.querySelector('.embedding-type-radio:checked').value;
addSelectedEmbedding(embedding.name, parseFloat(weightInput.value), selectedType);
weightInput.disabled = false;
typeControls.style.display = 'block';
} else {
removeSelectedEmbedding(embedding.name);
weightInput.disabled = true;
typeControls.style.display = 'none';
}
updateSelectedEmbeddingsDisplay();
});
weightInput.addEventListener('input', () => {
if (checkbox.checked) {
const selectedType = embeddingItem.querySelector('.embedding-type-radio:checked').value;
updateSelectedEmbeddingWeight(embedding.name, parseFloat(weightInput.value), selectedType);
updateSelectedEmbeddingsDisplay();
}
});
typeRadios.forEach(radio => {
radio.addEventListener('change', () => {
if (checkbox.checked) {
updateSelectedEmbeddingWeight(embedding.name, parseFloat(weightInput.value), radio.value);
updateSelectedEmbeddingsDisplay();
}
});
});
embeddingList.appendChild(embeddingItem);
});
}
/**
* 获取当前选中的Embedding
*/
function getCurrentSelectedEmbeddings() {
return JSON.parse(localStorage.getItem('selected_embeddings') || '[]');
}
/**
* 添加选中的Embedding
*/
function addSelectedEmbedding(name, weight, type = 'positive') {
const selectedEmbeddings = getCurrentSelectedEmbeddings();
const existing = selectedEmbeddings.find(embedding => embedding.name === name);
if (!existing) {
selectedEmbeddings.push({ name, weight, type });
localStorage.setItem('selected_embeddings', JSON.stringify(selectedEmbeddings));
}
}
/**
* 移除选中的Embedding
*/
function removeSelectedEmbedding(name) {
const selectedEmbeddings = getCurrentSelectedEmbeddings();
const filtered = selectedEmbeddings.filter(embedding => embedding.name !== name);
localStorage.setItem('selected_embeddings', JSON.stringify(filtered));
}
/**
* 更新选中的Embedding权重
*/
function updateSelectedEmbeddingWeight(name, weight, type) {
const selectedEmbeddings = getCurrentSelectedEmbeddings();
const embedding = selectedEmbeddings.find(embedding => embedding.name === name);
if (embedding) {
embedding.weight = weight;
if (type !== undefined) {
embedding.type = type;
}
localStorage.setItem('selected_embeddings', JSON.stringify(selectedEmbeddings));
}
}
/**
* 更新选中Embedding的显示
*/
function updateSelectedEmbeddingsDisplay() {
const container = document.getElementById('selected-embeddings-container');
const selectedEmbeddings = getCurrentSelectedEmbeddings();
if (selectedEmbeddings.length === 0) {
container.innerHTML = '<div style="color: #888; font-style: italic;">暂未选择Embedding</div>';
return;
}
const positiveEmbeddings = selectedEmbeddings.filter(emb => emb.type === 'positive');
const negativeEmbeddings = selectedEmbeddings.filter(emb => emb.type === 'negative');
let html = '';
if (positiveEmbeddings.length > 0) {
html += '<div style="margin-bottom: 10px;"><span style="color: var(--vp-success-color); font-weight: 600; font-size: 0.9em;">正向:</span><br>';
html += positiveEmbeddings.map(embedding => `
<span class="selected-embedding-tag" style="background: var(--vp-success-color);">
${escapeHTML(embedding.name)} (${embedding.weight})
<span class="remove" data-name="${escapeHTML(embedding.name)}">×</span>
</span>
`).join('');
html += '</div>';
}
if (negativeEmbeddings.length > 0) {
html += '<div><span style="color: var(--vp-error-color); font-weight: 600; font-size: 0.9em;">负向:</span><br>';
html += negativeEmbeddings.map(embedding => `
<span class="selected-embedding-tag" style="background: var(--vp-error-color);">
${escapeHTML(embedding.name)} (${embedding.weight})
<span class="remove" data-name="${escapeHTML(embedding.name)}">×</span>
</span>
`).join('');
html += '</div>';
}
container.innerHTML = html;
container.querySelectorAll('.remove').forEach(btn => {
btn.addEventListener('click', () => {
const name = btn.dataset.name;
removeSelectedEmbedding(name);
renderEmbeddingList();
updateSelectedEmbeddingsDisplay();
});
});
}
/**
* 生成Embedding提示词字符串
*/
function generateEmbeddingPromptString(type = 'positive') {
const selectedEmbeddings = getCurrentSelectedEmbeddings().filter(emb => emb.type === type);
return selectedEmbeddings.map(embedding =>
embedding.weight === 1.0 ? embedding.name : `(${embedding.name}:${embedding.weight})`
).join(', ');
}
/**
* 获取并填充ComfyUI LoRA列表
* @param {string} url - ComfyUI服务器URL
* @returns {Promise<void>}
*/
async function fetchAndPopulateComfyUILoras(url) {
const loraList = document.getElementById('comfyui-lora-list');
loraList.innerHTML = '<div style="padding: 20px; text-align: center;">正在加载ComfyUI LoRA...</div>';
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/object_info`,
timeout: 3600000
});
const data = JSON.parse(response.responseText);
// 查找LoRA节点类型
let loraNodeTypes = ['LoraLoader', 'LoRALoader', 'Lora Loader'];
let loras = null;
for (const nodeType of loraNodeTypes) {
if (data[nodeType]?.input?.required?.lora_name?.[0]) {
loras = data[nodeType].input.required.lora_name[0];
break;
}
}
if (!loras || loras.length === 0) {
loraList.innerHTML = '<div style="padding: 20px; text-align: center; color: #888;">未找到LoRA模型</div>';
return;
}
availableComfyUILoras = loras.map(name => ({ name, alias: name }));
renderComfyUILoraList();
if (typeof toastr !== 'undefined') {
toastr.success(`已加载 ${loras.length} 个ComfyUI LoRA模型`);
}
} catch (e) {
loraList.innerHTML = `<div style="padding: 20px; text-align: center; color: var(--vp-error-color);">加载失败: ${e.message}</div>`;
if (typeof toastr !== 'undefined') {
toastr.error(`加载ComfyUI LoRA列表失败: ${e.message}`);
}
}
}
/**
* 渲染ComfyUI LoRA列表
*/
function renderComfyUILoraList() {
const loraList = document.getElementById('comfyui-lora-list');
const selectedLoras = getCurrentComfyUISelectedLoras();
loraList.innerHTML = '';
availableComfyUILoras.forEach(lora => {
const isSelected = selectedLoras.some(selected => selected.name === lora.name);
const selectedLora = selectedLoras.find(selected => selected.name === lora.name);
const weight = selectedLora ? selectedLora.weight : 1.0;
const loraItem = document.createElement('div');
loraItem.className = 'lora-item';
loraItem.innerHTML = `
<div class="lora-info">
<div class="lora-name">${escapeHTML(lora.name)}</div>
<div class="lora-alias">ComfyUI LoRA</div>
</div>
<div class="lora-controls">
<input type="number" class="lora-weight" min="0" max="2" step="0.1" value="${weight}" ${!isSelected ? 'disabled' : ''}>
<input type="checkbox" class="lora-checkbox" ${isSelected ? 'checked' : ''}>
</div>
`;
const checkbox = loraItem.querySelector('.lora-checkbox');
const weightInput = loraItem.querySelector('.lora-weight');
checkbox.addEventListener('change', () => {
if (checkbox.checked) {
addComfyUISelectedLora(lora.name, parseFloat(weightInput.value));
weightInput.disabled = false;
} else {
removeComfyUISelectedLora(lora.name);
weightInput.disabled = true;
}
updateComfyUISelectedLorasDisplay();
});
weightInput.addEventListener('input', () => {
if (checkbox.checked) {
updateComfyUISelectedLoraWeight(lora.name, parseFloat(weightInput.value));
updateComfyUISelectedLorasDisplay();
}
});
loraList.appendChild(loraItem);
});
}
/**
* 获取当前选中的ComfyUI LoRA
*/
function getCurrentComfyUISelectedLoras() {
return JSON.parse(localStorage.getItem('comfyui_selected_loras') || '[]');
}
/**
* 添加选中的ComfyUI LoRA
*/
function addComfyUISelectedLora(name, weight) {
const selectedLoras = getCurrentComfyUISelectedLoras();
const existing = selectedLoras.find(lora => lora.name === name);
if (!existing) {
selectedLoras.push({ name, weight });
localStorage.setItem('comfyui_selected_loras', JSON.stringify(selectedLoras));
}
}
/**
* 移除选中的ComfyUI LoRA
*/
function removeComfyUISelectedLora(name) {
const selectedLoras = getCurrentComfyUISelectedLoras();
const filtered = selectedLoras.filter(lora => lora.name !== name);
localStorage.setItem('comfyui_selected_loras', JSON.stringify(filtered));
}
/**
* 更新选中的ComfyUI LoRA权重
*/
function updateComfyUISelectedLoraWeight(name, weight) {
const selectedLoras = getCurrentComfyUISelectedLoras();
const lora = selectedLoras.find(lora => lora.name === name);
if (lora) {
lora.weight = weight;
localStorage.setItem('comfyui_selected_loras', JSON.stringify(selectedLoras));
}
}
/**
* 更新选中ComfyUI LoRA的显示
*/
function updateComfyUISelectedLorasDisplay() {
const container = document.getElementById('comfyui-selected-loras-container');
const selectedLoras = getCurrentComfyUISelectedLoras();
if (selectedLoras.length === 0) {
container.innerHTML = '<div style="color: #888; font-style: italic;">暂未选择ComfyUI LoRA</div>';
return;
}
container.innerHTML = selectedLoras.map(lora => `
<span class="selected-lora-tag">
${escapeHTML(lora.name)} (${lora.weight})
<span class="remove" data-name="${escapeHTML(lora.name)}">×</span>
</span>
`).join('');
container.querySelectorAll('.remove').forEach(btn => {
btn.addEventListener('click', () => {
const name = btn.dataset.name;
removeComfyUISelectedLora(name);
renderComfyUILoraList();
updateComfyUISelectedLorasDisplay();
});
});
}
/**
* 加载提示词预设列表
*/
async function loadPromptPresets() {
const select = document.getElementById('prompt-preset-select');
const presets = await GM_getValue(STORAGE_KEY_PROMPT_PRESETS, {});
select.innerHTML = '<option value="">选择预设...</option>';
Object.keys(presets).sort().forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
});
}
/**
* 保存当前提示词为预设
*/
async function saveCurrentAsPreset(presetName) {
const positivePrompt = document.getElementById('comfyui-positive-prompt').value;
const negativePrompt = document.getElementById('comfyui-negative-prompt').value;
const presets = await GM_getValue(STORAGE_KEY_PROMPT_PRESETS, {});
presets[presetName] = {
positive: positivePrompt,
negative: negativePrompt,
timestamp: Date.now()
};
await GM_setValue(STORAGE_KEY_PROMPT_PRESETS, presets);
await loadPromptPresets();
}
/**
* 加载选中的预设
*/
async function loadSelectedPreset(presetName) {
const presets = await GM_getValue(STORAGE_KEY_PROMPT_PRESETS, {});
const preset = presets[presetName];
if (preset) {
document.getElementById('comfyui-positive-prompt').value = preset.positive || '';
document.getElementById('comfyui-negative-prompt').value = preset.negative || '';
// 触发保存设置
const inputs = {
positivePrompt: document.getElementById('comfyui-positive-prompt'),
negativePrompt: document.getElementById('comfyui-negative-prompt')
};
await saveSettings(inputs);
if (typeof toastr !== 'undefined') {
toastr.success(`已加载预设"${presetName}"`);
}
}
}
/**
* 删除选中的预设
*/
async function deleteSelectedPreset(presetName) {
const presets = await GM_getValue(STORAGE_KEY_PROMPT_PRESETS, {});
delete presets[presetName];
await GM_setValue(STORAGE_KEY_PROMPT_PRESETS, presets);
await loadPromptPresets();
if (typeof toastr !== 'undefined') {
toastr.success(`预设"${presetName}"已删除`);
}
}
/**
* 显示保存预设模态框
*/
function showPromptPresetSaveModal() {
const modal = document.getElementById('prompt-preset-save-modal');
const nameInput = document.getElementById('prompt-preset-name-input');
nameInput.value = '';
modal.style.display = 'block';
setTimeout(() => nameInput.focus(), 100);
}
/**
* 自动向ComfyUI工作流注入LoRA节点
* @param {Object} workflow - 工作流对象
* @param {Array} selectedLoras - 选中的LoRA列表
*/
function injectLoraNodes(workflow, selectedLoras) {
if (!selectedLoras || selectedLoras.length === 0) return;
// 找到所有现有节点的最大ID
const existingIds = Object.keys(workflow).map(id => parseInt(id)).filter(id => !isNaN(id));
let maxId = existingIds.length > 0 ? Math.max(...existingIds) : 0;
// 查找主要的模型加载器节点
let modelLoaderNode = null;
let modelLoaderNodeId = null;
for (const [nodeId, nodeData] of Object.entries(workflow)) {
if (nodeData.class_type === 'CheckpointLoaderSimple' ||
nodeData.class_type === 'CheckpointLoader') {
modelLoaderNode = nodeData;
modelLoaderNodeId = nodeId;
break;
}
}
if (!modelLoaderNode) {
console.warn('未找到模型加载器节点,无法注入LoRA');
return;
}
// 查找所有使用模型输出的节点
const modelConsumers = [];
for (const [nodeId, nodeData] of Object.entries(workflow)) {
if (nodeData.inputs) {
for (const [inputKey, inputValue] of Object.entries(nodeData.inputs)) {
if (Array.isArray(inputValue) && inputValue[0] === modelLoaderNodeId) {
modelConsumers.push({
nodeId,
inputKey,
outputIndex: inputValue[1]
});
}
}
}
}
let previousNodeId = modelLoaderNodeId;
let previousOutputIndices = { model: 0, clip: 1 }; // 默认输出索引
// 逐个添加LoRA节点
selectedLoras.forEach((lora, index) => {
const loraNodeId = (++maxId).toString();
workflow[loraNodeId] = {
class_type: 'LoraLoader',
inputs: {
lora_name: lora.name,
strength_model: lora.weight,
strength_clip: lora.weight,
model: [previousNodeId, previousOutputIndices.model],
clip: [previousNodeId, previousOutputIndices.clip]
}
};
previousNodeId = loraNodeId;
// LoraLoader输出: 0=model, 1=clip
previousOutputIndices = { model: 0, clip: 1 };
});
// 更新所有消费者节点的输入,指向最后一个LoRA节点
modelConsumers.forEach(consumer => {
const consumerNode = workflow[consumer.nodeId];
if (consumerNode && consumerNode.inputs) {
if (consumer.outputIndex === 0 || consumer.outputIndex === 1) {
// model或clip输出
consumerNode.inputs[consumer.inputKey] = [
previousNodeId,
consumer.outputIndex
];
} else {
// 其他输出(如VAE)保持原来的连接
consumerNode.inputs[consumer.inputKey] = [
modelLoaderNodeId,
consumer.outputIndex
];
}
}
});
console.log(`已自动注入 ${selectedLoras.length} 个LoRA节点到ComfyUI工作流`);
}
/**
* 加载设置到UI
* @param {Object} inputs - 包含所有输入元素引用的对象
*/
async function loadSettings(inputs) {
inputs.url.value = await GM_getValue('comfyui_url', DEFAULT_SETTINGS.url);
inputs.webuiUrl.value = await GM_getValue('webui_url', DEFAULT_SETTINGS.webuiUrl);
inputs.workflow.value = await GM_getValue('comfyui_workflow', DEFAULT_SETTINGS.workflow);
inputs.startTag.value = await GM_getValue('comfyui_start_tag', DEFAULT_SETTINGS.startTag);
inputs.endTag.value = await GM_getValue('comfyui_end_tag', DEFAULT_SETTINGS.endTag);
inputs.genWidth.value = await GM_getValue('comfyui_gen_width', DEFAULT_SETTINGS.genWidth);
inputs.genHeight.value = await GM_getValue('comfyui_gen_height', DEFAULT_SETTINGS.genHeight);
inputs.displayWidth.value = await GM_getValue('comfyui_display_width', DEFAULT_SETTINGS.displayWidth);
inputs.displayHeight.value = await GM_getValue('comfyui_display_height', DEFAULT_SETTINGS.displayHeight);
inputs.autoGen.checked = await GM_getValue('comfyui_auto_generate', DEFAULT_SETTINGS.autoGenerate);
inputs.sampler.value = await GM_getValue('comfyui_sampler', DEFAULT_SETTINGS.sampler);
inputs.scheduler.value = await GM_getValue('comfyui_scheduler', DEFAULT_SETTINGS.scheduler);
inputs.steps.value = await GM_getValue('comfyui_steps', DEFAULT_SETTINGS.steps);
inputs.cfg.value = await GM_getValue('comfyui_cfg', DEFAULT_SETTINGS.cfg);
inputs.webuiSampler.value = await GM_getValue('webui_sampler', DEFAULT_SETTINGS.webuiSampler);
inputs.webuiScheduler.value = await GM_getValue('webui_scheduler', DEFAULT_SETTINGS.webuiScheduler);
inputs.webuiSteps.value = await GM_getValue('webui_steps', DEFAULT_SETTINGS.steps);
inputs.webuiCfg.value = await GM_getValue('webui_cfg', DEFAULT_SETTINGS.cfg);
inputs.webuiDenoising.value = await GM_getValue('webui_denoising', DEFAULT_SETTINGS.denoisingStrength);
inputs.webuiEnableHires.checked = await GM_getValue('webui_enable_hires', DEFAULT_SETTINGS.enableHires);
inputs.webuiHiresUpscaler.value = await GM_getValue('webui_hires_upscaler', DEFAULT_SETTINGS.hiresUpscaler);
inputs.webuiHiresSteps.value = await GM_getValue('webui_hires_steps', DEFAULT_SETTINGS.hiresSteps);
inputs.webuiHiresUpscale.value = await GM_getValue('webui_hires_upscale', DEFAULT_SETTINGS.hiresUpscale);
inputs.webuiHiresDenoising.value = await GM_getValue('webui_hires_denoising', DEFAULT_SETTINGS.hiresDenoising);
inputs.positivePrompt.value = await GM_getValue('comfyui_positive_prompt', DEFAULT_SETTINGS.positivePrompt);
inputs.negativePrompt.value = await GM_getValue('comfyui_negative_prompt', DEFAULT_SETTINGS.negativePrompt);
const hiresSettings = document.getElementById('hires-settings');
hiresSettings.style.display = inputs.webuiEnableHires.checked ? 'block' : 'none';
if (inputs.url.value) {
fetchAndPopulateModels(inputs.url.value, inputs.modelSelect).catch(error => {
console.warn('初始ComfyUI Checkpoint模型加载失败:', error);
});
fetchAndPopulateUNetModels(inputs.url.value, inputs.unetSelect).catch(error => {
console.warn('初始ComfyUI UNet模型加载失败:', error);
});
}
if (inputs.webuiUrl.value) {
fetchAndPopulateWebUIModels(inputs.webuiUrl.value, inputs.webuiModelSelect).catch(error => {
console.warn('初始WebUI模型加载失败:', error);
});
fetchAndPopulateWebUILoras(inputs.webuiUrl.value).catch(error => {
console.warn('初始WebUI LoRA加载失败:', error);
});
fetchAndPopulateWebUIEmbeddings(inputs.webuiUrl.value).catch(error => {
console.warn('初始WebUI Embedding加载失败:', error);
});
fetchAndPopulateComfyUILoras(inputs.url.value).catch(error => {
console.warn('初始ComfyUI LoRA加载失败:', error);
});
}
}
/**
* 保存设置
* @param {Object} inputs - 包含所有输入元素引用的对象
*/
async function saveSettings(inputs) {
try {
await GM_setValue('comfyui_url', inputs.url.value);
await GM_setValue('webui_url', inputs.webuiUrl.value);
await GM_setValue('comfyui_workflow', inputs.workflow.value);
await GM_setValue('comfyui_start_tag', inputs.startTag.value);
await GM_setValue('comfyui_end_tag', inputs.endTag.value);
await GM_setValue('comfyui_gen_width', parseInt(inputs.genWidth.value, 10) || DEFAULT_SETTINGS.genWidth);
await GM_setValue('comfyui_gen_height', parseInt(inputs.genHeight.value, 10) || DEFAULT_SETTINGS.genHeight);
await GM_setValue('comfyui_display_width', parseInt(inputs.displayWidth.value, 10) || DEFAULT_SETTINGS.displayWidth);
await GM_setValue('comfyui_display_height', parseInt(inputs.displayHeight.value, 10) || DEFAULT_SETTINGS.displayHeight);
await GM_setValue('comfyui_auto_generate', inputs.autoGen.checked);
if (inputs.modelSelect.value) {
await GM_setValue('comfyui_model', inputs.modelSelect.value);
}
if (inputs.unetSelect.value) {
await GM_setValue('comfyui_unet_model', inputs.unetSelect.value);
}
if (inputs.webuiModelSelect && inputs.webuiModelSelect.value) {
await GM_setValue('webui_model', inputs.webuiModelSelect.value);
}
await GM_setValue('comfyui_sampler', inputs.sampler.value);
await GM_setValue('comfyui_scheduler', inputs.scheduler.value);
await GM_setValue('comfyui_steps', parseInt(inputs.steps.value, 10) || DEFAULT_SETTINGS.steps);
await GM_setValue('comfyui_cfg', parseFloat(inputs.cfg.value) || DEFAULT_SETTINGS.cfg);
await GM_setValue('webui_sampler', inputs.webuiSampler.value);
await GM_setValue('webui_scheduler', inputs.webuiScheduler.value);
await GM_setValue('webui_steps', parseInt(inputs.webuiSteps.value, 10) || DEFAULT_SETTINGS.steps);
await GM_setValue('webui_cfg', parseFloat(inputs.webuiCfg.value) || DEFAULT_SETTINGS.cfg);
await GM_setValue('webui_denoising', parseFloat(inputs.webuiDenoising.value) || DEFAULT_SETTINGS.denoisingStrength);
await GM_setValue('webui_enable_hires', inputs.webuiEnableHires.checked);
await GM_setValue('webui_hires_upscaler', inputs.webuiHiresUpscaler.value);
await GM_setValue('webui_hires_steps', parseInt(inputs.webuiHiresSteps.value, 10) || DEFAULT_SETTINGS.hiresSteps);
await GM_setValue('webui_hires_upscale', parseFloat(inputs.webuiHiresUpscale.value) || DEFAULT_SETTINGS.hiresUpscale);
await GM_setValue('webui_hires_denoising', parseFloat(inputs.webuiHiresDenoising.value) || DEFAULT_SETTINGS.hiresDenoising);
await GM_setValue('comfyui_positive_prompt', inputs.positivePrompt.value);
await GM_setValue('comfyui_negative_prompt', inputs.negativePrompt.value);
return true;
} catch (error) {
console.error('保存设置失败:', error);
return false;
}
}
/**
* 增强的图片缓存存储
* @param {string} generationId - 生成ID
* @param {string} imageUrl - 图片URL
* @param {string} prompt - 提示词
* @param {Object} metadata - 元数据
*/
async function saveImageToCache(generationId, imageUrl, prompt, metadata = {}) {
try {
// 将图片转换为base64
let base64Data;
if (imageUrl.startsWith('blob:')) {
// 对于blob URL,需要转换为base64
const response = await fetch(imageUrl);
const blob = await response.blob();
const reader = new FileReader();
base64Data = await new Promise((resolve, reject) => {
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
} else if (imageUrl.startsWith('data:')) {
// 已经是base64
base64Data = imageUrl;
} else {
// 对于HTTP URL,通过GM_xmlhttpRequest获取
const response = await makeRequest({
method: 'GET',
url: imageUrl,
responseType: 'blob'
});
const reader = new FileReader();
base64Data = await new Promise((resolve, reject) => {
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(response.response);
});
}
const images = await GM_getValue(STORAGE_KEY_IMAGES, {});
images[generationId] = {
url: base64Data, // 保存base64数据
prompt: prompt,
timestamp: Date.now(),
mode: currentMode,
metadata: metadata
};
await GM_setValue(STORAGE_KEY_IMAGES, images);
} catch (error) {
console.error('保存图片到缓存失败:', error);
// 降级处理:至少保存URL
const images = await GM_getValue(STORAGE_KEY_IMAGES, {});
images[generationId] = {
url: imageUrl,
prompt: prompt,
timestamp: Date.now(),
mode: currentMode,
metadata: metadata
};
await GM_setValue(STORAGE_KEY_IMAGES, images);
}
}
/**
* 加载图片缓存
*/
async function loadImageCache() {
const cacheGrid = document.getElementById('cache-grid');
const cacheStats = document.getElementById('cache-stats');
const images = await GM_getValue(STORAGE_KEY_IMAGES, {});
const imageCount = Object.keys(images).length;
cacheStats.textContent = `共 ${imageCount} 张缓存图片`;
if (imageCount === 0) {
cacheGrid.innerHTML = '<div class="cache-empty">暂无缓存图片</div>';
return;
}
cacheGrid.innerHTML = '';
// 按时间戳倒序排列
const sortedEntries = Object.entries(images).sort((a, b) =>
(b[1].timestamp || 0) - (a[1].timestamp || 0)
);
sortedEntries.forEach(([id, data]) => {
const cacheItem = document.createElement('div');
cacheItem.className = 'cache-item';
const timestamp = data.timestamp ? new Date(data.timestamp).toLocaleString() : '未知时间';
const prompt = (typeof data === 'string') ? '历史图片' : (data.prompt || '无提示词');
const imageUrl = (typeof data === 'string') ? data : data.url;
const mode = data.mode || '未知模式';
cacheItem.innerHTML = `
<img class="cache-item-image" src="${imageUrl}" alt="缓存图片" data-full-url="${imageUrl}">
<div class="cache-item-info">
<div class="cache-item-prompt" title="${escapeHTML(prompt)}">${escapeHTML(prompt)}</div>
<div class="cache-item-meta">${mode} • ${timestamp}</div>
<div class="cache-item-actions">
<button class="comfy-button cache-view-btn" data-url="${imageUrl}">查看</button>
<button class="comfy-button cache-download-btn" data-url="${imageUrl}" data-prompt="${escapeHTML(prompt)}">下载</button>
<button class="comfy-button error cache-delete-btn" data-id="${id}">删除</button>
</div>
</div>
`;
cacheGrid.appendChild(cacheItem);
});
// 添加事件监听
attachCacheEventListeners();
}
/**
* 添加缓存事件监听器
*/
function attachCacheEventListeners() {
// 查看大图
document.querySelectorAll('.cache-view-btn, .cache-item-image').forEach(btn => {
btn.addEventListener('click', (e) => {
const imageUrl = e.target.dataset.url || e.target.dataset.fullUrl;
showImageModal(imageUrl);
});
});
// 下载图片
document.querySelectorAll('.cache-download-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const imageUrl = e.target.dataset.url;
const prompt = e.target.dataset.prompt || 'ai_generated';
await downloadImage(imageUrl, prompt);
});
});
// 删除图片
document.querySelectorAll('.cache-delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const imageId = e.target.dataset.id;
if (confirm('确定要删除这张图片吗?')) {
await deleteImageFromCache(imageId);
loadImageCache(); // 重新加载
if (typeof toastr !== 'undefined') {
toastr.success('图片已删除');
}
}
});
});
// 修复:将复制链接功能移出删除按钮的循环
document.querySelectorAll('.cache-item-actions').forEach(container => {
if (!container.querySelector('.cache-copy-btn')) {
const copyBtn = document.createElement('button');
copyBtn.className = 'comfy-button cache-copy-btn';
copyBtn.textContent = '复制链接';
const downloadBtn = container.querySelector('.cache-download-btn');
if (downloadBtn) {
copyBtn.dataset.url = downloadBtn.dataset.url;
copyBtn.addEventListener('click', async (e) => {
try {
await navigator.clipboard.writeText(e.target.dataset.url);
if (typeof toastr !== 'undefined') {
toastr.success('链接已复制到剪贴板');
}
} catch (error) {
if (typeof toastr !== 'undefined') {
toastr.error('复制失败');
}
}
});
container.appendChild(copyBtn);
}
}
});
}
/**
* 显示图片模态框
*/
function showImageModal(imageUrl) {
let modal = document.getElementById('cache-image-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'cache-image-modal';
modal.className = 'cache-image-modal';
modal.innerHTML = `
<span class="cache-modal-close">×</span>
<img src="" alt="查看图片">
`;
document.body.appendChild(modal);
modal.querySelector('.cache-modal-close').addEventListener('click', () => {
modal.style.display = 'none';
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.style.display = 'none';
}
});
}
modal.querySelector('img').src = imageUrl;
modal.style.display = 'flex';
}
/**
* 下载图片(优化版)
*/
async function downloadImage(imageUrl, prompt) {
const fileName = `${prompt.replace(/[^a-zA-Z0-9\u4e00-\u9fa5\-_]/g, '_')}_${Date.now()}.png`;
if (typeof toastr !== 'undefined') {
toastr.info('正在准备下载图片...');
}
try {
let blobUrl;
// 如果是 base64 数据, 直接转换
if (imageUrl.startsWith('data:')) {
const response = await fetch(imageUrl);
const blob = await response.blob();
blobUrl = URL.createObjectURL(blob);
}
// 如果是 blob url, 直接使用
else if (imageUrl.startsWith('blob:')) {
blobUrl = imageUrl;
}
// 否则, 通过网络请求获取
else {
const response = await makeRequest({
method: 'GET',
url: imageUrl,
responseType: 'blob'
});
const blob = new Blob([response.response], { type: 'image/png' });
blobUrl = URL.createObjectURL(blob);
}
// 创建并点击下载链接
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// 如果不是已存在的blob url,则释放内存
if (!imageUrl.startsWith('blob:')) {
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
}
if (typeof toastr !== 'undefined') {
toastr.success('下载已开始');
}
} catch (error) {
console.error('下载失败:', error);
if (typeof toastr !== 'undefined') {
toastr.error('下载失败,请尝试右键复制图片链接手动下载。');
}
// 最后的备用方案:在新窗口打开图片
window.open(imageUrl, '_blank');
}
}
/**
* 从缓存删除图片
*/
async function deleteImageFromCache(imageId) {
const images = await GM_getValue(STORAGE_KEY_IMAGES, {});
delete images[imageId];
await GM_setValue(STORAGE_KEY_IMAGES, images);
}
/**
* 清空所有缓存
*/
async function clearAllCache() {
if (confirm('确定要清空所有缓存图片吗?此操作不可撤销。')) {
await GM_setValue(STORAGE_KEY_IMAGES, {});
loadImageCache();
if (typeof toastr !== 'undefined') {
toastr.success('所有缓存已清空');
}
}
}
// ================== 快捷键管理 ================== //
/**
* 初始化快捷键配置
*/
function initShortcutsConfig() {
const container = document.getElementById('shortcuts-config-container');
const shortcutDescriptions = {
togglePanel: { name: '打开/关闭面板', desc: '在任何地方切换面板显示' },
saveWorkflow: { name: '保存工作流', desc: '保存当前工作流' },
newWorkflow: { name: '新建工作流', desc: '创建新的工作流' },
quickGenerate: { name: '快速生成', desc: '在聊天中触发最新的生成按钮' },
convertPlaceholders: { name: '转换占位符', desc: '将工作流转换为占位符格式' },
testConnection: { name: '测试连接', desc: '测试服务器连接' },
closePanel: { name: '关闭面板', desc: '关闭面板或退出编辑模式' },
saveEdit: { name: '保存编辑', desc: '在编辑模式下保存修改' },
switchMode: { name: '切换模式', desc: '在ComfyUI和WebUI之间切换' }
};
container.innerHTML = '';
const gridHeader = document.createElement('div');
gridHeader.className = 'shortcut-config-grid';
gridHeader.innerHTML = `
<div style="font-weight: 600; color: var(--vp-accent-color);">功能</div>
<div style="font-weight: 600; color: var(--vp-accent-color);">快捷键</div>
<div style="font-weight: 600; color: var(--vp-accent-color);">状态</div>
`;
container.appendChild(gridHeader);
Object.entries(currentShortcuts).forEach(([key, combination]) => {
const config = shortcutDescriptions[key];
if (!config) return;
const configItem = document.createElement('div');
configItem.className = 'shortcut-config-item';
const status = getShortcutStatus(combination, key);
const statusClass = status.available ? 'available' : (status.conflict ? 'conflict' : 'disabled');
configItem.innerHTML = `
<label>${config.name}</label>
<input type="text" class="shortcut-input" data-key="${key}" value="${combination}" readonly>
<div class="shortcut-status ${statusClass}">${status.message}</div>
<div class="shortcut-description">${config.desc}</div>
`;
container.appendChild(configItem);
});
container.querySelectorAll('.shortcut-input').forEach(input => {
input.addEventListener('focus', () => startRecording(input));
input.addEventListener('blur', () => stopRecording(input));
});
document.getElementById('shortcuts-reset').addEventListener('click', resetShortcuts);
document.getElementById('shortcuts-disable-all').addEventListener('click', disableAllShortcuts);
document.getElementById('shortcuts-save').addEventListener('click', saveShortcuts);
}
/**
* 开始录制快捷键
*/
function startRecording(input) {
input.classList.add('recording');
input.value = '按下快捷键...';
const recordHandler = (e) => {
e.preventDefault();
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) {
return;
}
const parts = [];
if (e.ctrlKey) parts.push('Ctrl');
if (e.altKey) parts.push('Alt');
if (e.shiftKey) parts.push('Shift');
let key = e.key;
if (key === ' ') key = 'Space';
if (key.length === 1) key = key.toUpperCase();
if (key !== 'Control' && key !== 'Alt' && key !== 'Shift' && key !== 'Meta') {
parts.push(key);
}
const combination = parts.join('+');
if (parts.length > 1 || ['Escape', 'Space', 'Enter', 'Tab'].includes(key)) {
currentShortcuts[input.dataset.key] = combination;
input.value = combination;
updateShortcutStatus(input, combination);
stopRecording(input);
}
};
input.recordHandler = recordHandler;
document.addEventListener('keydown', recordHandler, true);
}
/**
* 停止录制快捷键
*/
function stopRecording(input) {
if (input.recordHandler) {
document.removeEventListener('keydown', input.recordHandler, true);
delete input.recordHandler;
}
input.classList.remove('recording');
if (input.value === '按下快捷键...') {
input.value = currentShortcuts[input.dataset.key];
}
}
/**
* 更新快捷键状态显示
*/
function updateShortcutStatus(input, combination) {
const statusElement = input.parentElement.querySelector('.shortcut-status');
const status = getShortcutStatus(combination, input.dataset.key);
statusElement.className = `shortcut-status ${status.available ? 'available' : (status.conflict ? 'conflict' : 'disabled')}`;
statusElement.textContent = status.message;
}
/**
* 获取快捷键状态
*/
function getShortcutStatus(combination, currentKey) {
if (!combination || combination === '禁用') {
return { available: false, conflict: false, message: '已禁用' };
}
const conflicts = Object.entries(currentShortcuts).filter(([key, combo]) =>
key !== currentKey && combo === combination && combo !== '禁用'
);
if (conflicts.length > 0) {
return { available: false, conflict: true, message: `与 ${conflicts[0][0]} 冲突` };
}
const browserShortcuts = [
'Ctrl+T', 'Ctrl+W', 'Ctrl+N', 'Ctrl+R', 'Ctrl+F', 'Ctrl+L',
'Ctrl+D', 'Ctrl+H', 'Ctrl+J', 'Ctrl+U', 'Ctrl+Shift+I',
'Ctrl+Shift+J', 'Ctrl+Shift+C', 'F12', 'F5', 'Ctrl+F5'
];
if (browserShortcuts.includes(combination)) {
return { available: false, conflict: true, message: '浏览器冲突' };
}
return { available: true, conflict: false, message: '可用' };
}
/**
* 重置快捷键
*/
function resetShortcuts() {
if (confirm('确定要重置所有快捷键为默认值吗?')) {
currentShortcuts = { ...DEFAULT_SHORTCUTS };
initShortcutsConfig();
if (typeof toastr !== 'undefined') {
toastr.success('快捷键已重置为默认值');
}
}
}
/**
* 禁用所有快捷键
*/
function disableAllShortcuts() {
if (confirm('确定要禁用所有快捷键吗?')) {
Object.keys(currentShortcuts).forEach(key => {
currentShortcuts[key] = '禁用';
});
initShortcutsConfig();
if (typeof toastr !== 'undefined') {
toastr.info('所有快捷键已禁用');
}
}
}
/**
* 保存快捷键配置
*/
async function saveShortcuts() {
try {
await GM_setValue(STORAGE_KEY_SHORTCUTS, currentShortcuts);
updateButtonTitles();
if (typeof toastr !== 'undefined') {
toastr.success('快捷键配置已保存');
}
} catch (error) {
console.error('保存快捷键配置失败:', error);
if (typeof toastr !== 'undefined') {
toastr.error('保存失败');
}
}
}
/**
* 加载快捷键配置
*/
async function loadShortcuts() {
try {
const savedShortcuts = await GM_getValue(STORAGE_KEY_SHORTCUTS, DEFAULT_SHORTCUTS);
currentShortcuts = { ...DEFAULT_SHORTCUTS, ...savedShortcuts };
} catch (error) {
console.error('加载快捷键配置失败:', error);
currentShortcuts = { ...DEFAULT_SHORTCUTS };
}
}
/**
* 更新按钮标题以显示快捷键
*/
function updateButtonTitles() {
const titleMappings = {
'comfyui-test-conn': 'testConnection',
'webui-test-conn': 'testConnection',
'workflow-save-current': 'saveWorkflow',
'workflow-create-new': 'newWorkflow',
'workflow-to-placeholders': 'convertPlaceholders'
};
Object.entries(titleMappings).forEach(([elementId, shortcutKey]) => {
const element = document.getElementById(elementId);
if (element && currentShortcuts[shortcutKey] !== '禁用') {
const originalTitle = element.title || element.textContent;
const shortcut = currentShortcuts[shortcutKey];
element.title = `${originalTitle} (${shortcut})`;
}
});
}
/**
* 检查快捷键是否匹配
*/
function matchesShortcut(event, shortcutKey) {
const combination = currentShortcuts[shortcutKey];
if (!combination || combination === '禁用') return false;
const parts = combination.split('+');
const expectedModifiers = {
Ctrl: parts.includes('Ctrl'),
Alt: parts.includes('Alt'),
Shift: parts.includes('Shift')
};
const expectedKey = parts[parts.length - 1];
return event.ctrlKey === expectedModifiers.Ctrl &&
event.altKey === expectedModifiers.Alt &&
event.shiftKey === expectedModifiers.Shift &&
(event.key === expectedKey || event.code === `Key${expectedKey}` ||
(expectedKey === 'Space' && event.key === ' ') ||
(expectedKey === 'Escape' && event.key === 'Escape'));
}
/**
* 初始化键盘快捷键
*/
function initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (matchesShortcut(e, 'togglePanel')) {
e.preventDefault();
const panel = document.getElementById(PANEL_ID);
if (panel.style.display === 'none' || !panel.style.display) {
panel.style.display = 'flex';
} else {
panel.style.display = 'none';
}
return;
}
if (matchesShortcut(e, 'switchMode')) {
e.preventDefault();
const newMode = currentMode === MODES.COMFYUI ? MODES.WEBUI : MODES.COMFYUI;
switchMode(newMode);
return;
}
if (matchesShortcut(e, 'quickGenerate')) {
e.preventDefault();
triggerQuickGenerate();
return;
}
const panel = document.getElementById(PANEL_ID);
if (panel.style.display === 'none' || !panel.style.display) {
return;
}
if (matchesShortcut(e, 'closePanel')) {
e.preventDefault();
if (isEditMode) {
cancelEditMode();
} else {
panel.style.display = 'none';
}
} else if (matchesShortcut(e, 'saveWorkflow')) {
e.preventDefault();
if (isEditMode) {
saveEditedWorkflow();
} else if (currentMode === MODES.COMFYUI) {
document.getElementById('workflow-save-current')?.click();
}
} else if (matchesShortcut(e, 'newWorkflow')) {
e.preventDefault();
if (currentMode === MODES.COMFYUI) {
document.getElementById('workflow-create-new')?.click();
}
} else if (matchesShortcut(e, 'convertPlaceholders')) {
e.preventDefault();
if (currentMode === MODES.COMFYUI) {
document.getElementById('workflow-to-placeholders')?.click();
}
} else if (matchesShortcut(e, 'testConnection')) {
e.preventDefault();
const testButton = currentMode === MODES.COMFYUI ?
document.getElementById('comfyui-test-conn') :
document.getElementById('webui-test-conn');
testButton?.click();
} else if (matchesShortcut(e, 'saveEdit')) {
e.preventDefault();
if (isEditMode) {
saveEditedWorkflow();
}
}
});
}
/**
* 快速生成功能
*/
async function triggerQuickGenerate() {
const lastMessage = document.querySelector('.mes.last_mes');
if (!lastMessage) return;
const generateButton = lastMessage.querySelector('.comfy-chat-generate-button');
if (generateButton && generateButton.textContent === '开始生成') {
generateButton.click();
if (typeof toastr !== 'undefined') {
toastr.info('快速生成已触发');
}
}
}
// ================== 图像生成核心逻辑 ================== //
/**
* 检查是否处于发送状态
*/
function checkSendingStatus() {
const sendButton = document.getElementById('send_but');
const stopButton = document.getElementById('mes_stop');
return (sendButton && sendButton.style.display === 'none') ||
(stopButton && stopButton.style.display !== 'none');
}
/**
* 处理消息并添加生成按钮
* @param {HTMLElement} messageNode - 消息DOM节点
*/
async function processMessageForComfyButton(messageNode) {
if (messageNode.dataset.comfyProcessed === 'true') {
return;
}
const mesText = messageNode.querySelector('.mes_text');
if (!mesText) {
messageNode.dataset.comfyProcessed = 'true';
return;
}
const startTag = await GM_getValue('comfyui_start_tag', DEFAULT_SETTINGS.startTag);
const endTag = await GM_getValue('comfyui_end_tag', DEFAULT_SETTINGS.endTag);
if (!startTag || !endTag) return;
const regex = new RegExp(escapeRegex(startTag) + '([\\s\\S]*?)' + escapeRegex(endTag), 'g');
let hasTags = false;
// 仅当包含标记且未被处理时,才进行替换
if (mesText.innerHTML.match(regex) && !mesText.querySelector('.comfy-button-group')) {
mesText.innerHTML = mesText.innerHTML.replace(regex, (_match, prompt) => {
hasTags = true;
const cleanPrompt = prompt.replace(/<[^>]*>/g, "").trim();
const encodedPrompt = escapeHTML(cleanPrompt);
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>`;
});
} else if (mesText.querySelector('.comfy-button-group')) {
// 如果已经有按钮组了,说明可能只是需要恢复状态
hasTags = true;
}
// 如果这条消息里有(或曾经有)我们的标记,就进行状态恢复
if (hasTags) {
const savedImages = await GM_getValue(STORAGE_KEY_IMAGES, {});
const autoGen = await GM_getValue('comfyui_auto_generate', DEFAULT_SETTINGS.autoGenerate);
for (const group of mesText.querySelectorAll('.comfy-button-group')) {
if (group.dataset.listenerAttached) continue;
group.dataset.listenerAttached = 'true';
const id = group.dataset.generationId;
const btn = group.querySelector('.comfy-chat-generate-button');
if (savedImages[id]) {
// 缓存命中:恢复图片和按钮状态
await displayImage(group, savedImages[id]);
setupGeneratedState(btn, id);
} else {
// 缓存未命中:绑定生成事件
btn.addEventListener('click', onGenerateButtonClick);
// 自动生成逻辑
if (autoGen && messageNode.classList.contains('last_mes') && !checkSendingStatus() && !btn.dataset.autoTriggered) {
btn.dataset.autoTriggered = 'true';
setTimeout(() => btn.click(), 500);
}
}
}
}
// 无论如何,最后都标记此消息节点为已处理
messageNode.dataset.comfyProcessed = 'true';
}
/**
* 生成按钮点击处理
* @param {Event} event - 点击事件
*/
async function onGenerateButtonClick(event) {
const button = event.target.closest('.comfy-chat-generate-button');
const group = button.closest('.comfy-button-group');
const promptFromChat = button.dataset.prompt;
const generationId = group.dataset.generationId;
if (button.disabled || button.dataset.processing === 'true') {
return;
}
button.dataset.processing = 'true';
button.dataset.autoTriggered = 'true';
button.textContent = '生成中...';
button.disabled = true;
button.className = 'comfy-button comfy-chat-generate-button testing';
const deleteButton = group.querySelector('.comfy-delete-button');
if (deleteButton) {
deleteButton.style.setProperty('display', 'none');
}
group.nextElementSibling?.remove();
try {
let imageUrl;
if (currentMode === MODES.COMFYUI) {
imageUrl = await generateWithComfyUI(promptFromChat);
} else {
imageUrl = await generateWithWebUI(promptFromChat);
}
await displayImage(group, imageUrl);
await saveImageToCache(generationId, imageUrl, promptFromChat, {
width: await GM_getValue('comfyui_gen_width', DEFAULT_SETTINGS.genWidth),
height: await GM_getValue('comfyui_gen_height', DEFAULT_SETTINGS.genHeight),
model: currentMode === MODES.COMFYUI ?
await GM_getValue('comfyui_model') :
await GM_getValue('webui_model')
});
button.className = 'comfy-button comfy-chat-generate-button success';
button.textContent = '成功';
setTimeout(() => {
setupGeneratedState(button, generationId);
if (deleteButton) {
deleteButton.style.removeProperty('display');
}
}, 2000);
} catch (error) {
console.error('生成图片失败:', error);
if (typeof toastr !== 'undefined') {
toastr.error(error.message);
}
button.className = 'comfy-button comfy-chat-generate-button error';
button.textContent = '失败';
setTimeout(() => {
const wasRegen = !!group.querySelector('.comfy-delete-button');
if (wasRegen) {
setupGeneratedState(button, generationId);
if (deleteButton) {
deleteButton.style.removeProperty('display');
}
} else {
button.textContent = '开始生成';
button.disabled = false;
button.className = 'comfy-button comfy-chat-generate-button';
delete button.dataset.processing;
delete button.dataset.autoTriggered;
}
}, 3000);
} finally {
delete button.dataset.processing;
}
}
/**
* 设置按钮的生成后状态
* @param {HTMLButtonElement} btn - 按钮元素
* @param {string} id - 生成ID
*/
function setupGeneratedState(btn, id) {
btn.textContent = '重新生成';
btn.disabled = false;
btn.className = 'comfy-button comfy-chat-generate-button';
delete btn.dataset.autoTriggered;
delete btn.dataset.processing;
if (!btn.dataset.regenerateListener) {
btn.addEventListener('click', onGenerateButtonClick);
btn.dataset.regenerateListener = 'true';
}
const group = btn.closest('.comfy-button-group');
if (!group.querySelector('.comfy-delete-button')) {
const delBtn = document.createElement('button');
delBtn.textContent = '删除';
delBtn.className = 'comfy-button error comfy-delete-button';
delBtn.addEventListener('click', async () => {
const images = await GM_getValue(STORAGE_KEY_IMAGES, {});
delete images[id];
await GM_setValue(STORAGE_KEY_IMAGES, images);
group.nextElementSibling?.remove();
delBtn.remove();
btn.textContent = '开始生成';
delete btn.dataset.autoTriggered;
delete btn.dataset.processing;
});
btn.insertAdjacentElement('afterend', delBtn);
}
}
/**
* 递归遍历工作流对象并替换占位符。
* @param {object} obj - 当前遍历的JavaScript对象或数组。
* @param {object} params - 包含所有占位符及其值的对象。
*/
function replacePlaceholdersInWorkflow(obj, params) {
for (const key in obj) {
if (typeof obj[key] === 'object' && obj[key] !== null) {
// 递归进入子对象或子数组
replacePlaceholdersInWorkflow(obj[key], params);
} else if (typeof obj[key] === 'string') {
// 检查并替换字符串中的占位符
switch (obj[key]) {
case '%model%':
obj[key] = params.model;
break;
case '%unet_model%':
obj[key] = params.unet_model;
break;
case '%prompt%':
obj[key] = params.positive_prompt;
break;
case '%negative_prompt%':
obj[key] = params.negative_prompt;
break;
case '%sampler%':
obj[key] = params.sampler;
break;
case '%scheduler%':
obj[key] = params.scheduler;
break;
case '%width%':
obj[key] = params.width;
break;
case '%height%':
obj[key] = params.height;
break;
case '%seed%':
obj[key] = params.seed;
break;
case '%steps%':
obj[key] = params.steps;
break;
case '%cfg%':
obj[key] = params.cfg;
break;
}
}
}
}
/**
* 使用ComfyUI生成图片 (安全版本)
* @param {string} promptFromChat - 聊天中的提示词
* @returns {Promise<string>} - 图片URL
*/
async function generateWithComfyUI(promptFromChat) {
const url = (await GM_getValue('comfyui_url', DEFAULT_SETTINGS.url)).trim();
let workflowString = await GM_getValue('comfyui_workflow', DEFAULT_SETTINGS.workflow);
if (!url || !workflowString) {
throw new Error('ComfyUI URL或工作流未配置');
}
const fixedPositivePrompt = await GM_getValue('comfyui_positive_prompt', DEFAULT_SETTINGS.positivePrompt);
const fixedNegativePrompt = await GM_getValue('comfyui_negative_prompt', DEFAULT_SETTINGS.negativePrompt);
const finalPositivePrompt = [fixedPositivePrompt, promptFromChat].filter(Boolean).join(', ');
const params = {
model: await GM_getValue('comfyui_model'),
unet_model: await GM_getValue('comfyui_unet_model') || "", // 确保有默认值
positive_prompt: finalPositivePrompt,
negative_prompt: fixedNegativePrompt,
seed: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
steps: await GM_getValue('comfyui_steps', DEFAULT_SETTINGS.steps),
cfg: await GM_getValue('comfyui_cfg', DEFAULT_SETTINGS.cfg),
sampler: await GM_getValue('comfyui_sampler', DEFAULT_SETTINGS.sampler),
scheduler: await GM_getValue('comfyui_scheduler', DEFAULT_SETTINGS.scheduler),
width: await GM_getValue('comfyui_gen_width', DEFAULT_SETTINGS.genWidth),
height: await GM_getValue('comfyui_gen_height', DEFAULT_SETTINGS.genHeight),
};
if (!params.model) {
throw new Error('ComfyUI Checkpoint模型未选择');
}
let workflow;
try {
workflow = JSON.parse(workflowString);
} catch (e) {
throw new Error(`工作流JSON格式错误,无法解析: ${e.message}`);
}
// 使用新的辅助函数安全地替换占位符
replacePlaceholdersInWorkflow(workflow, params);
const selectedLoras = getCurrentComfyUISelectedLoras();
if (selectedLoras.length > 0) {
injectLoraNodes(workflow, selectedLoras);
}
const promptResponse = await makeRequest({
method: 'POST',
url: `${url}/prompt`,
headers: { 'Content-Type': 'application/json' },
// 发送修正后的对象
data: JSON.stringify({ prompt: workflow }),
timeout: 3600000
});
const promptResponseData = JSON.parse(promptResponse.responseText);
const promptId = promptResponseData.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结果中找到图片');
}
return imageUrl;
}
/**
* 使用WebUI生成图片
* @param {string} promptFromChat - 聊天中的提示词
* @returns {Promise<string>} - 图片URL
*/
async function generateWithWebUI(promptFromChat) {
const url = (await GM_getValue('webui_url', DEFAULT_SETTINGS.webuiUrl)).trim();
if (!url) {
throw new Error('WebUI URL未配置');
}
const selectedModel = await GM_getValue('webui_model');
if (!selectedModel) {
throw new Error('WebUI模型未选择');
}
try {
const currentOptionsResponse = await makeRequest({
method: 'GET',
url: `${url}/sdapi/v1/options`,
timeout: 3600000
});
const currentOptions = JSON.parse(currentOptionsResponse.responseText);
if (currentOptions.sd_model_checkpoint !== selectedModel) {
if (typeof toastr !== 'undefined') {
toastr.info(`正在切换到模型: ${selectedModel}`);
}
await makeRequest({
method: 'POST',
url: `${url}/sdapi/v1/options`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({
sd_model_checkpoint: selectedModel
}),
timeout: 3600000
});
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (error) {
console.warn('模型切换失败,使用当前模型:', error);
}
const fixedPositivePrompt = await GM_getValue('comfyui_positive_prompt', DEFAULT_SETTINGS.positivePrompt);
const fixedNegativePrompt = await GM_getValue('comfyui_negative_prompt', DEFAULT_SETTINGS.negativePrompt);
const positiveEmbeddingString = generateEmbeddingPromptString('positive');
const negativeEmbeddingString = generateEmbeddingPromptString('negative');
const finalPositivePrompt = [fixedPositivePrompt, positiveEmbeddingString, promptFromChat].filter(Boolean).join(', ');
const finalNegativePrompt = [fixedNegativePrompt, negativeEmbeddingString].filter(Boolean).join(', ');
const params = {
prompt: finalPositivePrompt,
negative_prompt: finalNegativePrompt,
steps: await GM_getValue('webui_steps', DEFAULT_SETTINGS.steps),
cfg_scale: await GM_getValue('webui_cfg', DEFAULT_SETTINGS.cfg),
width: await GM_getValue('comfyui_gen_width', DEFAULT_SETTINGS.genWidth),
height: await GM_getValue('comfyui_gen_height', DEFAULT_SETTINGS.genHeight),
sampler_name: await GM_getValue('webui_sampler', DEFAULT_SETTINGS.webuiSampler),
scheduler: await GM_getValue('webui_scheduler', DEFAULT_SETTINGS.webuiScheduler),
seed: Math.floor(Math.random() * Number.MAX_SAFE_INTEGER),
restore_faces: false,
tiling: false,
n_iter: 1,
batch_size: 1,
enable_hr: await GM_getValue('webui_enable_hires', DEFAULT_SETTINGS.enableHires),
hr_upscaler: await GM_getValue('webui_hires_upscaler', DEFAULT_SETTINGS.hiresUpscaler),
hr_second_pass_steps: await GM_getValue('webui_hires_steps', DEFAULT_SETTINGS.hiresSteps),
hr_scale: await GM_getValue('webui_hires_upscale', DEFAULT_SETTINGS.hiresUpscale),
denoising_strength: await GM_getValue('webui_denoising', DEFAULT_SETTINGS.denoisingStrength)
};
if (!params.enable_hr) {
delete params.hr_upscaler;
delete params.hr_second_pass_steps;
delete params.hr_scale;
delete params.denoising_strength;
} else {
params.denoising_strength = await GM_getValue('webui_hires_denoising', DEFAULT_SETTINGS.hiresDenoising);
}
if (typeof toastr !== 'undefined') {
toastr.info('WebUI正在生成图片...');
}
const response = await makeRequest({
method: 'POST',
url: `${url}/sdapi/v1/txt2img`,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(params),
timeout: 3600000
});
const responseData = JSON.parse(response.responseText);
if (!responseData.images || responseData.images.length === 0) {
throw new Error('WebUI未返回图片');
}
const base64Image = responseData.images[0];
const binaryString = atob(base64Image);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: 'image/png' });
const imageUrl = URL.createObjectURL(blob);
return imageUrl;
}
/**
* 轮询生成结果
* @param {string} url - ComfyUI服务器URL
* @param {string} promptId - 提示ID
* @returns {Promise<Object>} - 返回历史记录
*/
function pollForResult(url, promptId) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const poller = setInterval(async () => {
if (Date.now() - startTime > POLLING_TIMEOUT_MS) {
clearInterval(poller);
reject(new Error('轮询超时'));
return;
}
try {
const response = await makeRequest({
method: 'GET',
url: `${url}/history/${promptId}`
});
const history = JSON.parse(response.responseText);
if (history[promptId]) {
clearInterval(poller);
resolve(history);
}
} catch (error) {
clearInterval(poller);
reject(error);
}
}, POLLING_INTERVAL_MS);
});
}
/**
* 在历史记录中查找图片URL
* @param {Object} history - 历史记录对象
* @param {string} promptId - 提示ID
* @param {string} baseUrl - 基础URL
* @returns {string|null} - 图片URL或null
*/
function findImageUrlInHistory(history, promptId, baseUrl) {
const outputs = history[promptId]?.outputs;
if (!outputs) return null;
for (const nodeId in outputs) {
if (outputs[nodeId].images) {
const image = outputs[nodeId].images[0];
if (image) {
return `${baseUrl}/view?${new URLSearchParams({
filename: image.filename,
subfolder: image.subfolder,
type: image.type
})}`;
}
}
}
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 = 'Generated by AI';
container.appendChild(img);
anchorElement.insertAdjacentElement('afterend', container);
}
const img = container.querySelector('img');
// 处理保存的图片数据
if (typeof imageUrl === 'object' && imageUrl.url) {
img.src = imageUrl.url;
} else {
img.src = imageUrl;
}
const displayWidth = await GM_getValue('comfyui_display_width', DEFAULT_SETTINGS.displayWidth);
const displayHeight = await GM_getValue('comfyui_display_height', DEFAULT_SETTINGS.displayHeight);
img.style.maxWidth = displayWidth > 0 ? `${displayWidth}px` : '100%';
img.style.maxHeight = displayHeight > 0 ? `${displayHeight}px` : '';
img.style.width = displayWidth > 0 ? 'auto' : '';
img.style.height = 'auto';
}
// ================== 主入口 ================== //
/**
* 添加主菜单按钮
* @param {number} retries - 重试次数
*/
function addMainButton(retries = 5) {
if (document.getElementById(BUTTON_ID) || retries <= 0) return;
const menuContent = document.querySelector('#options .options-content');
if (menuContent) {
const btn = document.createElement('a');
btn.id = BUTTON_ID;
btn.className = 'interactable';
btn.innerHTML = `<i class="fa-lg fa-solid fa-atom"></i><span>AI图像生成器</span>`;
btn.style.cursor = 'pointer';
btn.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById(PANEL_ID).style.display = 'flex';
document.getElementById('options').style.display = 'none';
});
menuContent.appendChild(btn);
} else {
setTimeout(() => addMainButton(retries - 1), 100);
}
}
/**
* 初始化脚本
*/
function initialize() {
createComfyUIPanel();
const mainChat = document.querySelector('#chat');
if (!mainChat) {
console.error("[AI生成器] 无法找到 #chat 元素,脚本无法启动。");
return;
}
const robustInitialScan = () => {
console.log("[AI生成器] 启动健壮的初始扫描...");
let lastProcessedCount = -1;
let stableCount = 0;
const maxChecks = 20; // 最多检查20次(10秒)
let checks = 0;
const scanInterval = setInterval(() => {
const allMessages = mainChat.querySelectorAll('.mes:not([data-comfy-processed="true"])');
if (allMessages.length > 0) {
// 如果找到未处理的消息,就处理它们
allMessages.forEach(processMessageForComfyButton);
// 重置稳定计数器,因为页面仍在变化
stableCount = 0;
} else {
// 如果没有找到未处理的消息,增加稳定计数
stableCount++;
}
const currentTotal = mainChat.querySelectorAll('.mes').length;
if (lastProcessedCount === currentTotal && stableCount >= 3) {
// 如果消息总数连续3次检查(1.5秒)没有变化,且没有发现未处理消息,我们认为加载稳定了
console.log("[AI生成器] 初始扫描完成,页面已稳定。");
clearInterval(scanInterval);
} else {
lastProcessedCount = currentTotal;
}
checks++;
if (checks >= maxChecks) {
// 达到最大检查次数,强制停止,防止无限循环
console.warn("[AI生成器] 初始扫描达到最大检查次数,停止扫描。");
clearInterval(scanInterval);
}
}, 500); // 每半秒检查一次
};
// 启动初始扫描
robustInitialScan();
// MutationObserver 逻辑保持,用于处理后续的新消息
const chatObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.matches('.mes')) {
processMessageForComfyButton(node);
}
node.querySelectorAll('.mes').forEach(processMessageForComfyButton);
}
});
}
const lastMessage = mainChat.querySelector('.mes:last-child');
if (lastMessage && lastMessage.dataset.comfyProcessed === 'true') {
delete lastMessage.dataset.comfyProcessed;
processMessageForComfyButton(lastMessage);
}
});
chatObserver.observe(mainChat, {
childList: true,
subtree: true,
characterData: true,
});
const optionsObserver = new MutationObserver(() => {
const menu = document.getElementById('options');
if (menu && menu.style.display !== 'none') {
addMainButton();
}
});
const body = document.querySelector('body');
if (body) {
optionsObserver.observe(body, {
attributes: true,
subtree: true,
attributeFilter: ['style']
});
}
console.log("[AI生成器] 脚本已成功初始化,支持ComfyUI和WebUI双引擎");
setTimeout(() => {
if (typeof toastr !== 'undefined') {
toastr.info('AI图像生成器已启动');
}
}, 1000);
}
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
window.AI_Generator = {
switchMode,
currentMode: () => currentMode,
generateWithComfyUI,
generateWithWebUI,
updateModeUI,
MODES
};
})();