// ==UserScript==
// @name 酒馆文生图插件
// @namespace http://tampermonkey.net/
// @version 1.9
// @description 使用sd、nai或comfyui 配合ai在酒馆生成插图,支持动态加载模型/采样器/调度器/Hires修复算法/Lora,并为NAI添加Vibe Transfer功能,为ComfyUI添加工作流管理和动态节点连接。新增标签搜索功能。1.8增加sd一致化。
// @author 茶蘼
// @grant unsafeWindow
// @match *://*/*
// @require https://code.jquery.com/jquery-3.4.1.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @grant GM_xmlhttpRequest
// @connect 127.0.0.1
// @connect novelai.net
// @connect vagrantup.com
// @connect danbooru.donmai.us
// @connect *
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_listValues
// ==/UserScript==
( function() {
'use strict';
let ster="";
let globalImageCounter = 0;
let locationToImageIdMap = {};
let db=""
let objectStorereadwrite="";
let objectStorereadonly="";
let xiancheng=true;
let isAutoClicking = false;
$(document).ready(async function() {
// 1. 等待缓存初始化完成
await initializeCache();
// 2. 缓存加载完毕后,再执行所有UI和定时器设置
console.log("文生图插件:缓存已就绪,开始初始化UI和监听器...");
ster = setInterval(addNewElement, 2000);
const style1 = document.createElement('style');
style1.textContent = `
.button_image {
/* 基础样式 */
padding: 4px 8px;
font-size: 13px;
font-weight: 600;
color: #c0caf5;
background: #292e42;
border: 1px solid #414868;
border-radius: 5px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/* 文本和图标布局 */
display: inline-flex;
align-items: center;
gap: 8px;
/* 防止文本换行 */
white-space: nowrap;
/* 去除默认按钮样式 */
outline: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.button_image:hover {
background: #3b4261;
color: #fff;
}
`;
document.head.appendChild(style1);
const setupObserver = () => {
const sendButton = document.getElementById('send_but');
const stopButton = document.getElementById('mes_stop');
if (sendButton && stopButton) {
const observer = new MutationObserver((mutations) => {
// 使用 requestIdleCallback 或 setTimeout(0) 避免阻塞主线程
requestIdleCallback(() => {
replaceSpansWithImagesst();
});
});
const config = { attributes: true, attributeFilter: ['style'] };
observer.observe(sendButton, config);
observer.observe(stopButton, config);
console.log("文生图插件:已附加状态变化观察器,实现即时响应。");
} else {
// 如果按钮还没找到,1秒后重试
setTimeout(setupObserver, 1000);
}
};
// 启动观察器设置
setupObserver();
// 将主循环也放在这里,确保它在缓存加载后启动
setInterval(replaceSpansWithImagesst, 2000);
// 页面加载完成后,立即执行一次检查,以处理已存在的标签
replaceSpansWithImagesst();
});
const dbName = 'image';
const dbVersion = 1;
const objectStoreName = 'Image_caching';
function getDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(dbName, dbVersion);
request.onerror = event => reject(`Database error: ${event.target.error}`);
request.onsuccess = event => resolve(event.target.result);
request.onupgradeneeded = event => {
const db = event.target.result;
if (!db.objectStoreNames.contains(objectStoreName)) {
db.createObjectStore(objectStoreName, { keyPath: 'id' });
}
};
});
}
async function Storereadwrite(data) {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([objectStoreName], 'readwrite');
const store = transaction.objectStore(objectStoreName);
const request = store.put(data);
request.onsuccess = () => resolve();
request.onerror = event => reject(`Write error: ${event.target.error}`);
transaction.oncomplete = () => db.close();
});
}
async function Storereadonly(id) {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([objectStoreName], 'readonly');
const store = transaction.objectStore(objectStoreName);
const request = store.get(id);
request.onsuccess = event => resolve(event.target.result);
request.onerror = event => reject(`Read error: ${event.target.error}`);
transaction.oncomplete = () => db.close();
});
}
async function StoreGetAll() {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([objectStoreName], 'readonly');
const store = transaction.objectStore(objectStoreName);
const request = store.getAll();
request.onsuccess = event => resolve(event.target.result || []);
request.onerror = event => reject(`GetAll error: ${event.target.error}`);
transaction.oncomplete = () => db.close();
});
}
async function Storedelete(id) {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([objectStoreName], 'readwrite');
const store = transaction.objectStore(objectStoreName);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = event => reject(`Delete error: ${event.target.error}`);
transaction.oncomplete = () => db.close();
});
}
async function StoreClear() {
const db = await getDB();
return new Promise((resolve, reject) => {
const transaction = db.transaction([objectStoreName], 'readwrite');
const store = transaction.objectStore(objectStoreName);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = event => reject(`Clear error: ${event.target.error}`);
transaction.oncomplete = () => db.close();
});
}
async function getNextImageId() {
if (globalImageCounter === 0) {
globalImageCounter = await GM_getValue('globalImageCounter', 0);
}
globalImageCounter++;
await GM_setValue('globalImageCounter', globalImageCounter);
return globalImageCounter;
}
//
const defaultComfyWorkflow = {
3: {
"inputs": {
"filename_prefix": "ComfyUI",
"images": [ "14", 0 ]
},
"class_type": "SaveImage",
"_meta": { "title": "保存图像" }
},
4: {
"inputs": {
"model_name": "sam_vit_b_01ec64.pth",
"device_mode": "AUTO"
},
"class_type": "SAMLoader",
"_meta": { "title": "SAMLoader (Impact)" }
},
5: {
"inputs": {
"model_name": "bbox/face_yolov8s.pt"
},
"class_type": "UltralyticsDetectorProvider",
"_meta": { "title": "UltralyticsDetectorProvider" }
},
6: {
"inputs": {
"lora_01": "None", "strength_01": 1, "lora_02": "None", "strength_02": 1,
"lora_03": "None", "strength_03": 1, "lora_04": "None", "strength_04": 1,
"model": [ "10", 0 ],
"clip": [ "10", 1 ]
},
"class_type": "Lora Loader Stack (rgthree)",
"_meta": { "title": "Lora Loader Stack (rgthree)" }
},
8: {
"inputs": {
"text": "positive prompt",
"clip": [ "6", 1 ]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "正面提示词" }
},
9: {
"inputs": {
"text": "negative prompt",
"clip": [ "10", 1 ]
},
"class_type": "CLIPTextEncode",
"_meta": { "title": "负面提示词" }
},
10: {
"inputs": {
"ckpt_name": "anything-v5-PrtRE.safetensors"
},
"class_type": "CheckpointLoaderSimple",
"_meta": { "title": "主模型 (Checkpoint)" }
},
12: {
"inputs": {
"width": 832, "height": 1216, "batch_size": 1
},
"class_type": "EmptyLatentImage",
"_meta": { "title": "分辨率 (Empty Latent)" }
},
13: {
"inputs": {
"seed": 466166474766921, "steps": 28, "cfg": 7, "sampler_name": "euler_ancestral",
"scheduler": "karras", "denoise": 1,
"model": [ "6", 0 ],
"positive": [ "8", 0 ],
"negative": [ "9", 0 ],
"latent_image": [ "12", 0 ]
},
"class_type": "KSampler",
"_meta": { "title": "采样器 (KSampler)" }
},
14: {
"inputs": {
"guide_size": 512, "guide_size_for": true, "max_size": 1024, "seed": 780524262703529,
"steps": 12, "cfg": 4.5, "sampler_name": "euler_ancestral", "scheduler": "karras", "denoise": 0.5,
"feather": 5, "noise_mask": true, "force_inpaint": true, "bbox_threshold": 0.5, "bbox_dilation": 10,
"bbox_crop_factor": 3, "sam_detection_hint": "center-1", "sam_dilation": 0, "sam_threshold": 0.93,
"sam_bbox_expansion": 0, "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False", "drop_size": 10,
"wildcard": "", "cycle": 1, "inpaint_model": false, "noise_mask_feather": 20, "tiled_encode": false, "tiled_decode": false,
"image": [ "15", 0 ],
"model": [ "10", 0 ],
"clip": [ "10", 1 ],
"vae": [ "10", 2 ],
"positive": [ "8", 0 ],
"negative": [ "9", 0 ],
"bbox_detector": [ "5", 0 ],
"sam_model_opt": [ "4", 0 ]
},
"class_type": "FaceDetailer",
"_meta": { "title": "FaceDetailer" }
},
15: {
"inputs": {
"samples": [ "13", 0 ],
"vae": [ "10", 2 ]
},
"class_type": "VAEDecode",
"_meta": { "title": "VAE解码" }
}
};
const simpleWorkflow = {
3: { "inputs": { "filename_prefix": "ComfyUI", "images": ["15", 0] }, "class_type": "SaveImage", "_meta": { "title": "保存图像" } },
6: { "inputs": { "lora_01": "None", "strength_01": 1, "lora_02": "None", "strength_02": 1, "lora_03": "None", "strength_03": 1, "lora_04": "None", "strength_04": 1, "model": ["10", 0], "clip": ["10", 1] }, "class_type": "Lora Loader Stack (rgthree)", "_meta": { "title": "Lora Loader Stack (rgthree)" } },
8: { "inputs": { "text": "", "clip": ["6", 1] }, "class_type": "CLIPTextEncode", "_meta": { "title": "正面提示词" } },
9: { "inputs": { "text": "", "clip": ["10", 1] }, "class_type": "CLIPTextEncode", "_meta": { "title": "负面提示词" } },
10: { "inputs": { "ckpt_name": "日系二次元.safetensors" }, "class_type": "CheckpointLoaderSimple", "_meta": { "title": "主模型 (Checkpoint)" } },
12: { "inputs": { "width": 832, "height": 1216, "batch_size": 1 }, "class_type": "EmptyLatentImage", "_meta": { "title": "分辨率 (Empty Latent)" } },
13: { "inputs": { "seed": 466166474766921, "steps": 22, "cfg": 5.5, "sampler_name": "euler_ancestral", "scheduler": "karras", "denoise": 1, "model": ["6", 0], "positive": ["8", 0], "negative": ["9", 0], "latent_image": ["12", 0] }, "class_type": "KSampler", "_meta": { "title": "采样器 (KSampler)" } },
15: { "inputs": { "samples": ["13", 0], "vae": ["10", 2] }, "class_type": "VAEDecode", "_meta": { "title": "VAE解码" } }
};
const hiresWorkflow = {
3: { "inputs": { "filename_prefix": "ComfyUI", "images": [ "17", 0 ] }, "class_type": "SaveImage", "_meta": { "title": "保存图像" } },
4: { "inputs": { "model_name": "sam_vit_b_01ec64.pth", "device_mode": "AUTO" }, "class_type": "SAMLoader", "_meta": { "title": "SAMLoader (Impact)" } },
5: { "inputs": { "model_name": "bbox/face_yolov8s.pt" }, "class_type": "UltralyticsDetectorProvider", "_meta": { "title": "UltralyticsDetectorProvider" } },
6: { "inputs": { "lora_01": "None", "strength_01": 1, "lora_02": "None", "strength_02": 1, "lora_03": "None", "strength_03": 1, "lora_04": "None", "strength_04": 1, "model": [ "10", 0 ], "clip": [ "10", 1 ] }, "class_type": "Lora Loader Stack (rgthree)", "_meta": { "title": "Lora Loader Stack (rgthree)" } },
7: { "inputs": { "samples": [ "16", 0 ], "vae": [ "10", 2 ] }, "class_type": "VAEDecode", "_meta": { "title": "VAE解码" } },
8: { "inputs": { "text": "", "clip": [ "6", 1 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "正面提示词" } },
9: { "inputs": { "text": "", "clip": [ "10", 1 ] }, "class_type": "CLIPTextEncode", "_meta": { "title": "负面提示词" } },
10: { "inputs": { "ckpt_name": "日系二次元.safetensors" }, "class_type": "CheckpointLoaderSimple", "_meta": { "title": "Checkpoint加载器(简易)" } },
13: { "inputs": { "upscale_by": 1.3, "seed": 1125258729553273, "steps": 15, "cfg": 5, "sampler_name": "euler_ancestral", "scheduler": "karras", "denoise": 0.2, "mode_type": "Linear", "tile_width": 512, "tile_height": 512, "mask_blur": 8, "tile_padding": 32, "seam_fix_mode": "None", "seam_fix_denoise": 1, "seam_fix_width": 64, "seam_fix_mask_blur": 8, "seam_fix_padding": 16, "force_uniform_tiles": true, "tiled_decode": false, "image": [ "7", 0 ], "model": [ "6", 0 ], "positive": [ "8", 0 ], "negative": [ "9", 0 ], "vae": [ "10", 2 ], "upscale_model": [ "14", 0 ] }, "class_type": "UltimateSDUpscale", "_meta": { "title": "Ultimate SD Upscale" } },
14: { "inputs": { "model_name": "RealESRGAN_x4plus_anime_6B.pth" }, "class_type": "UpscaleModelLoader", "_meta": { "title": "加载放大模型" } },
15: { "inputs": { "width": 832, "height": 1216, "batch_size": 1 }, "class_type": "EmptyLatentImage", "_meta": { "title": "空Latent图像" } },
16: { "inputs": { "seed": 466166474766921, "steps": 22, "cfg": 5.5, "sampler_name": "euler_ancestral", "scheduler": "karras", "denoise": 1, "model": [ "6", 0 ], "positive": [ "8", 0 ], "negative": [ "9", 0 ], "latent_image": [ "15", 0 ] }, "class_type": "KSampler", "_meta": { "title": "K采样器" } },
17: { "inputs": { "guide_size": 512, "guide_size_for": true, "max_size": 1024, "seed": 329726187282802, "steps": 12, "cfg": 4.5, "sampler_name": "euler_ancestral", "scheduler": "karras", "denoise": 0.5, "feather": 5, "noise_mask": true, "force_inpaint": true, "bbox_threshold": 0.5, "bbox_dilation": 10, "bbox_crop_factor": 3, "sam_detection_hint": "center-1", "sam_dilation": 0, "sam_threshold": 0.93, "sam_bbox_expansion": 0, "sam_mask_hint_threshold": 0.7, "sam_mask_hint_use_negative": "False", "drop_size": 10, "wildcard": "", "cycle": 1, "inpaint_model": false, "noise_mask_feather": 20, "tiled_encode": false, "tiled_decode": false, "image": [ "13", 0 ], "model": [ "10", 0 ], "clip": [ "10", 1 ], "vae": [ "10", 2 ], "positive": [ "8", 0 ], "negative": [ "9", 0 ], "bbox_detector": [ "5", 0 ], "sam_model_opt": [ "4", 0 ] }, "class_type": "FaceDetailer", "_meta": { "title": "FaceDetailer" } }
};
const defaultSettings = {
scriptEnabled:false,
cache: 1,
startTag: 'image###',
endTag: '###',
size: '832x1216',
width: '832',
height: '1216',
negativePrompt: '',
zidongdianji:"true",
maxAutoClicks: '3',
dbclike:"false",
displayMode:"默认",
steps: '28',
currentMode: 'sd', // 'sd' or 'nai' or 'comfyui'
// 提示词预设 (通用)
yushe:{"默认":{"positivePrompt":'',"negativePrompt":''}},
yusheid:"默认",
fixedPrompt: '',
AQT:'',
UCP:'',
// SD 独有
sdUrl: 'http://127.0.0.1:7860',
sdCfgScale: '7',
seed: '-1',
restoreFaces: 'false',
samplerName: 'DPM++ 2M Karras',
sdScheduler: 'automatic',
sdModel: '',
adetailerEnabled: 'false',
adModel: 'face_yolov8n.pt',
adDenoisingStrength: '0.4',
adMaskBlur: '4',
adInpaintPadding: '32',
enableHr: 'false',
hrScale: '1.5',
hrDenoisingStrength: '0.4',
hrUpscaler: 'R-ESRGAN 4x+ Anime6B',
hrSecondPassSteps: '15',
controlNetEnabled: 'false',
controlNetUnits: '[]', // 存储所有ControlNet单元配置的JSON字符串
// NAI 独有
naiApiUrl: 'https://std.loliyc.com/generate',
naiToken: '111',
naiChannel: 'proxy', // <--- 新增:'proxy' 或 'official'
nai3sm: 'true',
nai3dyn: 'true',
nai3Variety: 'true',
nai3Deceisp: 'true',
AI_use_coords: 'false', // <--- 新增的行// ComfyUI 独有
naiPositivePrompt: 'best quality, amazing quality, very aesthetic, absurdres',
naiModel: 'nai-diffusion-3',
naiSampler: 'k_euler_ancestral',
naiScale: '5',
naiCfg: '0',
naiNoiseSchedule: 'karras',
naiVibeTransferEnabled: 'false',
naiVibeTransferImages: '[]',
// ComfyUI 独有
comfyuiUrl: 'http://127.0.0.1:8188',
comfyuiWorkflows: {
"默认脸部修复 (FaceDetailer)": JSON.stringify(defaultComfyWorkflow, null, 2),
"基础绘图 (无修复功能)": JSON.stringify(simpleWorkflow, null, 2),
"绘图(高清修复+脸部细节)": JSON.stringify(hiresWorkflow, null, 2)
},
comfyuiCurrentWorkflow: "默认脸部修复 (FaceDetailer)",
comfyuiModel: "",
comfyuiSampler: "euler_ancestral",
comfyuiScheduler: "karras",
comfyuiLora: "None",
comfyuiLoraStrength: '1.0',
comfyuiLora2: "None",
comfyuiLoraStrength2: '1.0',
comfyuiLora3: "None",
comfyuiLoraStrength3: '1.0',
comfyuiLora4: "None",
comfyuiLoraStrength4: '1.0',
faceDetailerSteps: '12',
faceDetailerGuideSize: '512',
faceDetailerMaxSize: '1024',
faceDetailerCfg: '4.5',
faceDetailerSampler: 'euler_ancestral',
faceDetailerScheduler: 'karras',
faceDetailerModel: 'bbox/face_yolov8s.pt', //脸部修复模型
comfyuiUseCustomWorkflowOnly: 'false',
// ComfyUI Ultimate SD Upscale 独有
comfyuiUpscaleModel: 'RealESRGAN_x4plus_anime_6B.pth',
comfyuiUltimateUpscaleBy: '1.3',
comfyuiUltimateSteps: '15',
comfyuiUltimateCfg: '5',
comfyuiUltimateSampler: 'euler_ancestral',
comfyuiUltimateScheduler: 'karras',
comfyuiUltimateDenoise: '0.2',
comfyuiUltimateModeType: 'Linear',
};
let settings = {};
let tempYushe = GM_getValue("yushe", defaultSettings.yushe);
if(typeof tempYushe === 'string') tempYushe = JSON.parse(tempYushe);
if(tempYushe?.["默认"]?.hasOwnProperty("fixedPrompt")){
for (const key in tempYushe) {
if (tempYushe[key].hasOwnProperty('fixedPrompt')) {
tempYushe[key].positivePrompt = tempYushe[key].fixedPrompt;
delete tempYushe[key].fixedPrompt;
}
}
GM_setValue("yushe", JSON.stringify(tempYushe));
}
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
let value = GM_getValue(key, defaultValue);
if (value === defaultValue) {
if(typeof defaultValue === 'object'){
GM_setValue(key, JSON.stringify(defaultValue));
} else {
GM_setValue(key, defaultValue);
}
}
if (typeof value === 'string' && (value.startsWith('{') || value.startsWith('['))) {
try {
settings[key] = JSON.parse(value);
} catch (error) {
console.error(`Failed to parse setting ${key}, using default.`);
settings[key] = defaultValue;
}
} else {
settings[key] = value;
}
}
function addNewElement() {
const targetElement = document.querySelector('#option_toggle_AN');
if (targetElement) {
clearInterval(ster);
const newElement = document.createElement('a');
newElement.id = 'option_toggle_AN2';
const icon = document.createElement('i');
icon.className = 'fa-lg fa-solid fa-note-sticky';
newElement.appendChild(icon);
const span = document.createElement('span');
span.setAttribute('data-i18n', "打开设置");
span.textContent = '打开文生图设置';
newElement.appendChild(span);
targetElement.parentNode.insertBefore(newElement, targetElement.nextSibling);
console.log("New element added successfully");
document.getElementById('option_toggle_AN2').addEventListener('click', showSettingsPanel);
}
}
// 【请删除旧的 openTagsSupermarketModal 函数,然后将下面的完整函数粘贴到原处】
function openTagsSupermarketModal() {
const TAGS_SUPERMARKET_PROMPT_KEY = 'tagsSupermarketPrompt';
// --- (开始) 验证数据源插件是否存在 ---
if (typeof unsafeWindow.ChatomiPlugins === 'undefined' || typeof unsafeWindow.ChatomiPlugins.tagSupermarketData === 'undefined') {
alert('错误:未找到“标签超市数据源”插件或数据加载失败。\n请确保您已安装并启用了数据源插件。');
return;
}
const tagSupermarketData = unsafeWindow.ChatomiPlugins.tagSupermarketData;
// --- (结束) 验证数据源插件是否存在 ---
// (核心新增) 为标签超市的预览区定义一个专属的、固定的位置哈希
const TAGS_SUPERMARKET_PREVIEW_HASH = 'tags_supermarket_preview_slot_v1';
const existingModal = document.querySelector('.tags-supermarket-modal-overlay');
if (existingModal) {
existingModal.remove();
}
const modalOverlay = document.createElement('div');
modalOverlay.className = 'tags-supermarket-modal-overlay';
// HTML 和 CSS 结构与上一个版本完全相同,无需修改
modalOverlay.innerHTML = `
<style>
/* --- 标签超市专属样式 --- */
.tags-supermarket-modal-overlay {
position: fixed; top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 10002;
display: flex; justify-content: center; align-items: center;
opacity: 0;
transition: opacity 0.2s ease;
}
.tags-supermarket-modal-overlay.visible {
opacity: 1;
}
.tags-supermarket-modal {
width: min(7680px, 90vw);
height: min(4320px, 85vh);
background-color: var(--bg-main, #1e1e2e);
color: var(--text-primary, #c0caf5);
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
border: 1px solid var(--border-color, #414868);
display: flex;
flex-direction: column;
transform: scale(0.95);
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.tags-supermarket-modal-overlay.visible .tags-supermarket-modal {
transform: scale(1);
opacity: 1;
}
.ts-header {
padding: 12px 20px;
border-bottom: 1px solid var(--border-color, #414868);
display: flex; justify-content: space-between; align-items: center;
background-color: var(--bg-sidebar, #1a1b26);
}
.ts-header h2 { margin: 0; font-size: 1.2em; }
.tag-supermarket-content {
padding: 15px;
overflow: hidden;
flex-grow: 1;
display: flex;
gap: 15px;
}
.ts-main-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 15px;
}
.ts-generation-area {
flex: 0 0 320px;
display: flex;
flex-direction: column;
gap: 15px;
border: 1px solid var(--border-color, #414868);
border-radius: 8px;
padding: 15px;
background-color: rgba(0,0,0,0.15);
}
#ts-image-preview {
flex-grow: 1;
background-color: var(--bg-input, #2a2e3e);
border: 1px solid var(--border-color, #414868);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
color: var(--text-secondary);
font-size: 0.9em;
overflow: hidden;
position: relative;
}
#ts-image-preview .loading-spinner {
font-size: 2em;
color: var(--accent-primary);
animation: spin 1.5s linear infinite;
}
@keyframes spin { 100% { transform: rotate(360deg); } }
#ts-image-preview img {
width: 100%;
height: 100%;
object-fit: contain;
cursor: pointer;
transition: transform 0.2s ease;
}
#ts-image-preview img:hover {
transform: scale(1.03);
}
#ts-generate-image-btn {
padding: 12px 20px;
font-size: 1.1em;
font-weight: bold;
background-color: #ffc107;
color: #1a1b26;
border-color: #f5b800;
transition: all 0.2s ease;
}
#ts-generate-image-btn:hover:not(:disabled) {
background-color: #ffd54f;
box-shadow: 0 4px 10px rgba(255, 193, 7, 0.3);
}
#ts-generate-image-btn:disabled {
background-color: #6c757d;
border-color: #5a6268;
color: #c0caf5;
cursor: not-allowed;
opacity: 0.7;
}
.ts-section {
padding: 12px;
border: 1px solid var(--border-color, #414868);
border-radius: 8px;
background-color: rgba(0,0,0,0.15);
}
#ts-prompt-area {
width: 100%;
height: 80px;
resize: vertical;
background-color: var(--bg-input, #2a2e3e);
color: var(--text-primary, #c0caf5);
border: 1px solid var(--border-color, #414868);
border-radius: 5px;
padding: 8px 10px;
box-sizing: border-box;
}
.ts-search-area { display: flex; gap: 10px; align-items: center; }
#ts-search-input {
flex-grow: 1;
margin-bottom: 0 !important;
background-color: var(--bg-input, #2a2e3e);
color: var(--text-primary, #c0caf5);
border: 1px solid var(--border-color, #414868);
border-radius: 5px;
padding: 8px 10px;
}
.ts-categories-container {
display: flex; flex-direction: column; gap: 10px;
}
.ts-main-categories, .ts-sub-categories {
display: flex; flex-wrap: wrap; gap: 8px;
}
.ts-categories-container .panel-button.active {
background-color: var(--accent-primary, #7aa2f7);
color: var(--bg-main, #1e1e2e);
font-weight: bold;
border-color: var(--accent-primary, #7aa2f7);
}
.ts-tags-display-area {
flex-grow: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
padding: 10px;
align-content: start;
}
.tag-card {
background-color: var(--bg-input, #2a2e3e);
border: 1px solid var(--border-color, #414868);
border-radius: 6px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
height: 55px;
box-sizing: border-box;
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
user-select: none;
gap: 4px;
overflow: hidden;
}
.tag-card:hover {
border-color: var(--accent-primary, #7aa2f7);
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}
.tag-card .tag-cn {
font-size: 0.9em;
color: var(--text-primary);
}
.tag-card .tag-en {
font-size: 0.8em;
color: var(--text-secondary);
}
.tag-card .tag-cn,
.tag-card .tag-en {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
.tag-card.selected {
background-color: var(--accent-primary, #7aa2f7);
border-color: var(--accent-primary, #7aa2f7);
transform: translateY(0);
}
.tag-card.selected:hover {
box-shadow: none;
}
.tag-card.selected .tag-cn,
.tag-card.selected .tag-en {
color: var(--bg-main, #1e1e2e);
font-weight: bold;
}
.ts-footer {
padding: 12px 20px;
border-top: 1px solid var(--border-color, #414868);
display: flex; justify-content: space-between; align-items: center;
background-color: var(--bg-sidebar, #1a1b26);
}
.ts-footer span { font-size: 0.85em; color: var(--text-secondary, #a9b1d6); }
@media screen and (max-width: 800px) {
.tags-supermarket-modal {
position: fixed !important;
top: 2.5vh !important;
left: 5vw !important;
right: 5vw !important;
bottom: 2.5vh !important;
width: 90vw !important;
height: 85vh !important;
transform: none !important;
transform-origin: top center !important;
overflow-y: auto; /* 让整个模态框可以滚动 */
}
.tag-supermarket-content {
flex-direction: column;
overflow: visible; /* 允许内容溢出,触发父级滚动条 */
height: auto; /* 高度自适应 */
flex-grow: 0; /* 不再填充空间 */
}
.ts-tags-display-area {
overflow: visible; /* 取消内部滚动 */
height: auto; /* 高度自适应 */
flex-grow: 0; /* 不再尝试填充空间 */
min-height: 150px; /* 避免内容过少时太扁 */
}
/* 1. 一行 4 个:把 120px 改小 */
.ts-tags-display-area {
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
}
/* 2. 卡片整体缩小 */
.tag-card {
height: 45px; /* 原来是 55px */
padding: 6px 8px; /* 可以略微再收一点内边距 */
}
.tag-card .tag-cn {
font-size: 0.8em; /* 原来是 0.9em */
}
.tag-card .tag-en {
font-size: 0.75em; /* 原来是 0.8em */
}
}
</style>
<div class="tags-supermarket-modal">
<div class="ts-header">
<h2><i class="fa-solid fa-tags" style="margin-right: 10px; color: var(--accent-green, #9ece6a);"></i>标签超市</h2>
<button id="close-ts-modal-btn" class="panel-button danger" style="padding: 6px 10px; font-size: 0.9em;"><i class="fa-solid fa-times"></i> 关闭</button>
</div>
<div class="tag-supermarket-content">
<div class="ts-main-panel">
<div class="ts-section">
<textarea id="ts-prompt-area" placeholder="点击下方的标签会自动添加到这里,将作为生图的正向提示词..."></textarea>
</div>
<div class="ts-section ts-search-area">
<input type="text" id="ts-search-input" placeholder="输入中文或英文关键词搜索...">
<button id="ts-fuzzy-search-btn" class="panel-button"><i class="fa-solid fa-search"></i> 模糊搜索</button>
<button id="ts-exact-search-btn" class="panel-button"><i class="fa-solid fa-binoculars"></i> 精确搜索</button>
</div>
<div class="ts-section ts-categories-container">
<div class="ts-main-categories"></div>
<div class="ts-sub-categories"></div>
</div>
<div class="ts-section ts-tags-display-area">
<p style="color: var(--text-secondary, #a9b1d6);">请先从上方选择分类</p>
</div>
</div>
<div class="ts-generation-area">
<div id="ts-image-preview">
<span>图片将在此处生成</span>
</div>
<button id="ts-generate-image-btn" class="panel-button">
<i class="fa-solid fa-wand-magic-sparkles" style="margin-right: 8px;"></i>
<span>生成图片</span>
</button>
</div>
</div>
<div class="ts-footer">
<span>提示: 点击标签添加/移除。双击生成的图片可放大。</span>
<button id="ts-copy-prompt-btn" class="panel-button primary"><i class="fa-solid fa-copy"></i> 复制提示词并关闭</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const modal = modalOverlay.querySelector('.tags-supermarket-modal');
const promptTextarea = document.getElementById('ts-prompt-area');
const mainCatContainer = modal.querySelector('.ts-main-categories');
const subCatContainer = modal.querySelector('.ts-sub-categories');
const tagsContainer = modal.querySelector('.ts-tags-display-area');
const generateBtn = document.getElementById('ts-generate-image-btn');
const imagePreviewArea = document.getElementById('ts-image-preview');
promptTextarea.value = GM_getValue(TAGS_SUPERMARKET_PROMPT_KEY, '');
let currentMainCategory = null;
let currentSubCategory = null;
let isGeneratingInModal = false;
const pinyinSorter = (a, b) => {
const collator = new Intl.Collator('zh-Hans-CN', { usage: 'sort' });
const pinyinResult = collator.compare(a, b);
if (pinyinResult !== 0) return pinyinResult;
return a.length - b.length;
};
function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* @param {string} fullPrompt - 完整的提示词字符串。
* @param {string} tag - 要检查的单个标签字符串。
* @returns {boolean} - 如果标签存在,则返回true。
*/
function isTagSelected(fullPrompt, tag) {
const escapedTag = escapeRegExp(tag);
const tagRegex = new RegExp(`(^|\\s*,\\s*)${escapedTag}(\\s*,\\s*|$)`, 'g');
return tagRegex.test(fullPrompt);
}
function updateAllTagSelections() {
const currentPrompt = promptTextarea.value;
const allTagCards = tagsContainer.querySelectorAll('.tag-card');
allTagCards.forEach(card => {
const tagValue = card.dataset.tagValue;
if (!tagValue) return;
if (isTagSelected(currentPrompt, tagValue)) {
card.classList.add('selected');
} else {
card.classList.remove('selected');
}
});
}
const displayImageInPreview = (imgSrc, imgId) => {
imagePreviewArea.innerHTML = '';
const img = document.createElement('img');
img.src = imgSrc;
img.alt = '标签超市生成的图片';
img.dataset.imageId = imgId;
img.addEventListener('dblclick', () => { showImageLightbox(imgSrc, imgId); });
imagePreviewArea.appendChild(img);
};
function renderMainCategories() {
mainCatContainer.innerHTML = '';
const sortedCategories = Object.keys(tagSupermarketData).sort(pinyinSorter);
for (const catName of sortedCategories) {
const btn = document.createElement('button');
btn.className = 'panel-button';
btn.textContent = catName;
btn.dataset.category = catName;
btn.addEventListener('click', () => {
currentMainCategory = catName;
currentSubCategory = null;
highlightActive(mainCatContainer, btn);
subCatContainer.innerHTML = '';
tagsContainer.innerHTML = `<p style="color: var(--text-secondary, #a9b1d6);">请选择子分类</p>`;
renderSubCategories();
});
mainCatContainer.appendChild(btn);
}
}
function renderSubCategories() {
subCatContainer.innerHTML = '';
if (!currentMainCategory) return;
const sortedSubCategories = Object.keys(tagSupermarketData[currentMainCategory]).sort(pinyinSorter);
for (const subCatName of sortedSubCategories) {
const btn = document.createElement('button');
btn.className = 'panel-button';
btn.textContent = subCatName;
btn.dataset.category = subCatName;
btn.addEventListener('click', () => {
currentSubCategory = subCatName;
highlightActive(subCatContainer, btn);
renderTags();
});
subCatContainer.appendChild(btn);
}
}
function renderTags(tagsToShow = null) {
tagsContainer.innerHTML = '';
let tagsObject = tagsToShow;
if (!tagsObject) {
if (currentMainCategory && currentSubCategory) {
tagsObject = tagSupermarketData[currentMainCategory][currentSubCategory];
}
}
if (!tagsObject || Object.keys(tagsObject).length === 0) {
tagsContainer.innerHTML = `<p style="color: var(--text-secondary, #a9b1d6);">${tagsToShow ? '没有找到匹配的标签。' : '没有标签,或请继续选择子分类。'}</p>`;
return;
}
const sortedTagEntries = Object.entries(tagsObject).sort(([cnA], [cnB]) => pinyinSorter(cnA, cnB));
sortedTagEntries.forEach(([cn, originalEn]) => {
// let en = originalEn.replace(/(?<!\\)([()])/g, '\\$1'); // 反斜杆添加
const card = document.createElement('div');
card.className = 'tag-card';
const cnSpan = document.createElement('span');
cnSpan.className = 'tag-cn';
cnSpan.textContent = cn;
const enSpan = document.createElement('span');
enSpan.className = 'tag-en';
enSpan.textContent = originalEn
card.appendChild(cnSpan);
card.appendChild(enSpan);
const tagValue = originalEn;
card.dataset.tagValue = tagValue;
card.addEventListener('click', () => {
const currentPrompt = promptTextarea.value;
if (isTagSelected(currentPrompt, tagValue)) {
const escapedTag = escapeRegExp(tagValue);
const regexToRemove = new RegExp(`(^|\\s*,\\s*)${escapedTag}(?=(\\s*,\\s*|$))`, 'g');
let newPrompt = currentPrompt.replace(regexToRemove, '');
newPrompt = newPrompt.trim().replace(/^,/, '').trim();
newPrompt = newPrompt.replace(/,$/, '').trim();
newPrompt = newPrompt.replace(/,\s*,/g, ',');
promptTextarea.value = newPrompt;
} else {
if (currentPrompt.length > 0 && !/,\s*$/.test(currentPrompt)) {
promptTextarea.value += ', ';
}
promptTextarea.value += tagValue;
}
promptTextarea.dispatchEvent(new Event('input', { bubbles: true }));
});
tagsContainer.appendChild(card);
});
updateAllTagSelections();
}
async function handleModalImageGeneration() {
if (isGeneratingInModal) return;
const promptFromTags = promptTextarea.value.trim();
if (!promptFromTags) {
alert('提示词区域为空,请输入或选择一些标签后再生成。');
return;
}
isGeneratingInModal = true;
generateBtn.disabled = true;
imagePreviewArea.innerHTML = `<i class="fa-solid fa-spinner loading-spinner"></i>`;
generateBtn.querySelector('span').textContent = '生成中...';
try {
const generatorFunction = {sd: sd, nai: naiGenerate, comfyui: comfyuiGenerate}[settings.currentMode];
if (!generatorFunction) throw new Error(`未知的生成模式: ${settings.currentMode}`);
const fakeButton = {
id: "image_modal_generator",
dataset: { link: promptFromTags, locationHash: TAGS_SUPERMARKET_PREVIEW_HASH },
isModalCall: true
};
let dataURL = null;
if (settings.currentMode === 'sd') {
dataURL = await generatorFunction(fakeButton, settings.width, settings.height);
} else {
dataURL = await generatorFunction(fakeButton);
}
if (!dataURL) throw new Error("生成图片失败或未获取到图片数据。请检查控制台。");
const finalImageId = locationToImageIdMap[TAGS_SUPERMARKET_PREVIEW_HASH];
displayImageInPreview(dataURL, finalImageId);
} catch (error) {
console.error('[标签超市] 图片生成失败:', error);
imagePreviewArea.innerHTML = `<span style="text-align:center; padding:10px;">生成失败<br><small>${error.message}</small></span>`;
alert(`图片生成失败: ${error.message}`);
} finally {
isGeneratingInModal = false;
generateBtn.disabled = false;
generateBtn.querySelector('span').textContent = '生成图片';
}
}
function highlightActive(container, activeButton) {
container.querySelectorAll('.panel-button').forEach(btn => btn.classList.remove('active'));
if(activeButton) activeButton.classList.add('active');
}
function handleSearch(isFuzzy) {
const searchTerm = document.getElementById('ts-search-input').value.toLowerCase().trim();
if (!searchTerm) return;
highlightActive(mainCatContainer, null);
highlightActive(subCatContainer, null);
currentMainCategory = null;
currentSubCategory = null;
const results = {};
for (const mainCat in tagSupermarketData) {
for (const subCat in tagSupermarketData[mainCat]) {
for (const [cn, en] of Object.entries(tagSupermarketData[mainCat][subCat])) {
const enLower = en.toLowerCase();
const cnLower = cn.toLowerCase();
let match = false;
if (isFuzzy) {
match = enLower.includes(searchTerm) || cnLower.includes(searchTerm);
} else {
match = enLower === searchTerm || cnLower === searchTerm;
}
if (match) {
results[cn] = en;
}
}
}
}
renderTags(results);
}
let escListener = null;
function closeModal() {
GM_setValue(TAGS_SUPERMARKET_PROMPT_KEY, promptTextarea.value);
if (escListener) document.removeEventListener('keydown', escListener);
modalOverlay.classList.remove('visible');
setTimeout(() => modalOverlay.remove(), 200);
}
escListener = (e) => { if (e.key === "Escape") closeModal(); };
document.addEventListener('keydown', escListener);
promptTextarea.addEventListener('input', updateAllTagSelections);
modalOverlay.querySelector('#close-ts-modal-btn').addEventListener('click', closeModal);
modalOverlay.addEventListener('click', (e) => { if (e.target === modalOverlay) closeModal(); });
modalOverlay.querySelector('#ts-fuzzy-search-btn').addEventListener('click', () => handleSearch(true));
modalOverlay.querySelector('#ts-exact-search-btn').addEventListener('click', () => handleSearch(false));
document.getElementById('ts-search-input').addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); handleSearch(true); } });
modalOverlay.querySelector('#ts-copy-prompt-btn').addEventListener('click', () => {
GM_setValue(TAGS_SUPERMARKET_PROMPT_KEY, promptTextarea.value);
const textToCopy = promptTextarea.value.trim().replace(/,\s*$/, '').trim(); // 更好的清理结尾逗号
navigator.clipboard.writeText(textToCopy).then(() => {
showToast('提示词已复制!');
closeModal();
}).catch(err => {
console.error('复制失败:', err);
showToast('复制失败!', 'error');
});
});
generateBtn.addEventListener('click', handleModalImageGeneration);
renderMainCategories();
const lastImageId = locationToImageIdMap[TAGS_SUPERMARKET_PREVIEW_HASH];
if (lastImageId) {
getItemImg(lastImageId).then(cachedImgData => { if (cachedImgData) displayImageInPreview(cachedImgData, lastImageId); });
}
updateAllTagSelections();
setTimeout(() => modalOverlay.classList.add('visible'), 20);
}
function createSettingsPanel() {
const decodeUnicodeBase64 = (b64) => {
try {
const binaryString = window.atob(b64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
} catch (e) {
console.error("", e);
return "";
}
};
const panel = document.createElement('div');
panel.id = 'settings-panel';
panel.className = 'settings-panel-reborn';
const encodedAuthor = 'QlnojLbomLw=';
const encodedQqLink = 'aHR0cHM6Ly9xbS5xcS5jb20vcS9YaVdHcURJclFJ';
const encodedQqText = 'UVHnvqQ=';
let styles = `
/* --- 全局与色彩设定 --- */
.settings-panel-reborn {
--bg-main: #1e1e2e;
--bg-sidebar: #1a1b26;
--bg-content: #1e1e2e;
--bg-input: #2a2e3e;
--border-color: #414868;
--text-primary: #c0caf5;
--text-secondary: #a9b1d6;
--accent-primary: #7aa2f7;
--accent-secondary: #f7768e;
--accent-green: #9ece6a;
--shadow-color: rgba(0, 0, 0, 0.3);
position: fixed; top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: min(900px, 95vw);
height: min(700px, 90vh);
background-color: var(--bg-main);
color: var(--text-primary);
border-radius: 12px;
box-shadow: 0 10px 30px var(--shadow-color);
z-index: 10000;
display: none;
flex-direction: column;
overflow: hidden;
}
.settings-panel-reborn.visible { display: flex; }
/* --- 头部 --- */
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
background-color: var(--bg-sidebar);
}
.panel-header h2 { margin: 0; font-size: 1.2em; color: var(--text-primary); }
.panel-header .controls { display: flex; align-items: center; gap: 20px; }
.panel-header label { display: flex; align-items: center; gap: 8px; font-size: 0.9em;}
/* --- 主体布局 (侧边栏 + 内容区) --- */
.panel-body { display: flex; flex-grow: 1; min-height: 0; }
.panel-sidebar {
width: 180px;
flex-shrink: 0;
background-color: var(--bg-sidebar);
padding: 15px 0;
border-right: 1px solid var(--border-color);
overflow-y: auto;
}
.nav-link {
display: block;
padding: 10px 20px;
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
border-left: 3px solid transparent;
transition: all 0.2s ease;
}
.nav-link:hover { background-color: rgba(122, 162, 247, 0.1); }
.nav-link.active {
color: var(--text-primary);
font-weight: 700;
background-color: rgba(122, 162, 247, 0.15);
border-left-color: var(--accent-primary);
}
/* --- 内容区 --- */
.panel-content { flex-grow: 1; padding: 25px; overflow-y: auto; }
.settings-pane { display: none; }
.settings-pane.active { display: block; animation: fadeIn 0.3s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.settings-group {
margin-bottom: 30px;
padding: 20px;
border: 1px solid var(--border-color);
border-radius: 8px;
background-color: rgba(0,0,0,0.1);
}
.settings-group-title {
font-size: 1.1em;
font-weight: 600;
margin-bottom: 15px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color);
color: var(--accent-green);
}
/* --- 表单元素 --- */
.settings-panel-reborn label { font-size: 0.95em; color: var(--text-secondary); margin-bottom: 5px; display: block; }
.settings-panel-reborn input[type="text"],
.settings-panel-reborn input[type="number"],
.settings-panel-reborn select,
.settings-panel-reborn textarea {
width: 100%;
background-color: var(--bg-input);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 5px;
padding: 8px 10px;
margin-bottom: 15px;
box-sizing: border-box;
transition: border-color 0.2s, box-shadow 0.2s;
}
.settings-panel-reborn input:focus, .settings-panel-reborn select:focus, .settings-panel-reborn textarea:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.3);
}
.settings-panel-reborn textarea { height: 70px; resize: vertical; }
#comfyuiWorkflowEditor {
height: 300px;
font-family: monospace;
font-size: 0.85em;
white-space: pre;
}
.settings-panel-reborn select { appearance: none; background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23c0caf5%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.4-5.4-12.8z%22/%3E%3C/svg%3E'); background-repeat: no-repeat; background-position: right 10px top 50%; background-size: 10px auto; padding-right: 30px; }
/* --- 按钮 --- */
.panel-button {
padding: 8px 15px;
font-size: 0.9em;
font-weight: 600;
color: var(--text-primary);
background-color: #292e42;
border: 1px solid var(--border-color);
border-radius: 5px;
cursor: pointer;
transition: all 0.2s ease;
}
.panel-button:hover { background-color: #3b4261; border-color: var(--accent-primary); }
.panel-button.primary { background-color: var(--accent-primary); color: var(--bg-main); border-color: var(--accent-primary); }
.panel-button.primary:hover { opacity: 0.9; }
.panel-button.danger { background-color: var(--accent-secondary); color: var(--bg-main); border-color: var(--accent-secondary); }
.panel-button.danger:hover { opacity: 0.9; }
/* --- 页脚 --- */
.panel-footer {
padding: 12px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
background-color: var(--bg-sidebar);
}
.panel-footer .footer-links a { color: var(--text-secondary); text-decoration: none; margin: 0 8px; font-size: 0.85em; }
.panel-footer .footer-links a:hover { color: var(--accent-primary); }
.panel-footer .footer-buttons button { margin-left: 10px; }
/* --- 特殊组件 --- */
.flex-row { display: flex; gap: 15px; align-items: flex-start; }
.flex-row > div { flex: 1; min-width: 0; }
.lora-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px 20px; }
.lora-item { display: contents; } /* 让 item 的子元素直接参与 grid 布局 */
.lora-item > label { grid-column: 1 / 2; }
.lora-item > .lora-strength-control { grid-column: 2 / 3; }
.lora-strength-control { display:flex; align-items:center; gap:10px; margin-top: -5px; }
.lora-strength-control input[type=range] { width:100%; padding:0; margin:0; accent-color: var(--accent-primary); }
.lora-strength-value { min-width: 35px; font-weight:bold; color: var(--text-primary); }
.button-group { display: flex; gap: 10px; align-items: center; margin-bottom: 20px; }
.button-group > * { margin-bottom: 0 !important; }
.button-group > label { flex-grow: 1; }
.switch { position: relative; display: inline-block; width: 44px; height: 24px; vertical-align: middle; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #3b4261; transition: .3s; border-radius: 24px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .3s; border-radius: 50%; }
input:checked + .slider { background-color: var(--accent-green); }
input:checked + .slider:before { transform: translateX(20px); }
/* NAI Vibe Transfer 样式 */
#naiVibeImageListContainer { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 15px; margin-top: 10px; }
#naiVibeImageListContainer .vibe-item { border: 1px solid var(--border-color); border-radius: 8px; padding: 10px; background-color: var(--bg-input); font-size: 0.9em; }
#naiVibeImageListContainer .vibe-item img { max-width: 100%; border-radius: 4px; margin-bottom: 10px; }
#naiVibeImageListContainer .vibe-item .controls .slider-label { display: flex; justify-content: space-between; font-size: 13px; margin-bottom: 4px; color: var(--text-secondary); }
#naiVibeImageListContainer .vibe-item .controls input[type="range"] { width: 100%; margin: 0; accent-color: var(--accent-primary); height: 4px; }
#naiVibeImageListContainer .vibe-item .vibe-delete-btn { float: right; padding: 3px 8px; font-size: 12px; background-color: var(--accent-secondary); color: var(--bg-main); margin-bottom: 5px; border-radius: 3px; border: none; cursor: pointer; }
#naiVibeStatus.error { color: var(--accent-secondary); } #naiVibeStatus.info { color: var(--accent-primary); }
.cn-image-area {
flex: 0 0 160px;
}
.cn-settings-area {
flex-grow: 1;
}
.cn-image-upload-wrapper {
width: 160px;
height: 240px; /* 保持常见竖图比例 */
border: 2px dashed var(--border-color);
border-radius: 6px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
background-color: var(--bg-input);
overflow: hidden;
position: relative;
}
.cn-image-upload-wrapper:hover {
border-color: var(--accent-primary);
}
.cn-upload-placeholder {
text-align: center;
color: var(--text-secondary);
}
.cn-upload-placeholder i {
font-size: 3em;
display: block;
margin-bottom: 10px;
}
.cn-preview-img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Danbooru 标签搜索样式*/
.tag-results-container {
min-height: 120px;
max-height: 120px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 5px;
padding: 5px;
background-color: var(--bg-input);
}
.tag-result-item {
padding: 6px 10px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
font-size: 0.9em;
display: flex;
justify-content: space-between;
align-items: center;
}
.tag-result-item:hover {
background-color: #3b4261;
}
.tag-result-item.selected {
background-color: var(--accent-primary);
color: var(--bg-main);
font-weight: bold;
}
.tag-result-item .tag-post-count {
color: var(--text-secondary);
font-size: 0.9em;
}
.tag-result-item.selected .tag-post-count {
color: var(--bg-sidebar);
}
/* --- 图片缓存查看器样式 --- */
#cache-info-text { font-size: 0.9em; color: var(--text-secondary); }
.cache-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 15px;
margin-top: 15px;
min-height: 150px;
}
.cache-item {
position: relative;
border: 1px solid var(--border-color);
border-radius: 6px;
overflow: hidden;
aspect-ratio: 1 / 1.5; /* 保持图片比例 */
cursor: pointer;
background-color: var(--bg-input);
}
.cache-item:hover .delete-cache-btn { opacity: 1; }
.cache-item img {
width: 100%; height: 100%;
object-fit: cover;
transition: transform 0.2s ease;
}
.cache-item:hover img { transform: scale(1.05); }
.delete-cache-btn {
position: absolute;
top: 4px; right: 4px;
background-color: rgba(247, 118, 142, 0.8); /* a bit transparent */
color: white;
border: none;
border-radius: 50%;
width: 24px; height: 24px;
font-size: 14px;
line-height: 24px;
text-align: center;
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease, background-color 0.2s ease;
z-index: 2;
}
.delete-cache-btn:hover { background-color: var(--accent-secondary); }
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin-top: 20px;
}
.pagination button { padding: 5px 12px; }
.pagination .page-info { font-size: 0.9em; color: var(--text-secondary); }
/* --- 图片查看灯箱 --- */
.lightbox-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.85);
z-index: 10002;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.lightbox-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
}
.lightbox-content img {
display: block;
max-width: 100%;
max-height: 100%;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
}
.lightbox-close, .lightbox-download {
position: absolute;
top: -35px;
color: white;
background-color: rgba(65, 72, 104, 0.8);
border: 1px solid #414868;
border-radius: 5px;
cursor: pointer;
padding: 6px 12px;
font-size: 0.9em;
text-decoration: none;
}
.lightbox-close { right: 85px; }
.lightbox-download { right: 0; }
.lightbox-close:hover, .lightbox-download:hover { background-color: #3b4261; }
/* 只在 <= 800 px 的屏幕上生效 */
@media screen and (max-width: 800px) {
/* 1. 让面板整体变成 80 % 大小 */
.settings-panel-reborn {
transform: translateX(-50%) scale(0.8); /* 关键:先位移再缩放 */
transform-origin: top center;
}
.settings-panel-reborn {
width: 98vw;
height: 95vh;
top: 2.5vh;
}
.panel-body { flex-direction: column; }
.panel-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--border-color);
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 5px;
}
.nav-link {
border-left: none;
border-bottom: 3px solid transparent;
padding: 8px 12px;
font-size: 0.85em;
}
.nav-link.active {
border-bottom-color: var(--accent-primary);
border-left-color: transparent;
}
.flex-row, .lora-grid { flex-direction: column; gap: 0; grid-template-columns: 1fr; }
.panel-footer { flex-direction: column; gap: 10px; }
}
.controlnet-modal-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.6);
z-index: 10001; /* 比主设置面板低一点 */
display: flex;
justify-content: center;
align-items: center;
}
.controlnet-modal {
width: min(850px, 90vw);
max-height: 85vh;
background-color: #1e1e2e;
color: var(--text-primary);
border-radius: 10px;
box-shadow: 0 8px 25px rgba(0,0,0,0.4);
border: 1px solid var(--border-color);
display: flex;
flex-direction: column;
z-index: 10002;
transform: scale(0.95);
opacity: 0;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.controlnet-modal.visible {
transform: scale(1);
opacity: 1;
}
@media screen and (max-width: 800px) {
/* 让 ControlNet 弹窗也继承主面板的移动端缩放/排列规则 */
.controlnet-modal.settings-panel-reborn {
position: fixed !important;
top: 2.5vh !important;
left: 1vw !important;
right: 1vw !important;
bottom: 2.5vh !important;
width: 98vw !important;
height: 95vh !important;
transform: none !important;
transform-origin: top center !important;
}
.controlnet-modal.settings-panel-reborn .controlnet-modal-content {
flex-direction: column !important;
gap: 15px;
}
.controlnet-modal.settings-panel-reborn .settings-group[data-index] {
flex-direction: column !important;
}
.controlnet-modal.settings-panel-reborn .unit-body {
flex-direction: column !important;
}
.controlnet-modal.settings-panel-reborn .cn-image-area,
.controlnet-modal.settings-panel-reborn .cn-settings-area {
flex: none !important;
width: 100% !important;
}
.controlnet-modal.settings-panel-reborn .settings-group-title {
flex-direction: column !important;
align-items: flex-start !important;
}
}
.controlnet-modal.settings-panel-reborn {
/* 覆盖掉从主面板继承来的固定定位和居中变换 */
position: relative; /* 或者 static,让它恢复正常的文档流 */
top: auto;
left: auto;
transform: none; /* 移除 translate(-50%, -50%) */
/* 覆盖掉固定的宽高,让弹窗自己的宽高规则生效 */
width: auto;
height: auto;
/* 确保它仍然是一个flex容器,这对于其内部布局至关重要 */
display: flex;
}
/* --- 新增结束 --- */
.controlnet-modal-header {
padding: 12px 20px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.controlnet-modal-header h3 {
margin: 0;
font-size: 1.1em;
}
.controlnet-modal-content {
padding: 20px;
overflow-y: auto;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 20px;
}
.controlnet-modal-footer {
padding: 12px 20px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.controlnet-modal-footer .unit-count {
font-size: 0.9em;
color: var(--text-secondary);
}
/* --- Toast 通知样式 --- */
.toast-notification {
position: absolute;
bottom: 20px;
left: 50%;
transform: translate(-50%, 20px); /* 初始位置在下方偏离 */
padding: 10px 20px;
border-radius: 6px;
color: #fff;
font-size: 0.9em;
font-weight: 500;
z-index: 10005;
opacity: 0;
transition: opacity 0.4s ease, transform 0.4s ease;
box-shadow: 0 4px 15px rgba(0,0,0,0.25);
pointer-events: none; /* 防止遮挡下方的点击 */
}
.toast-notification.visible {
opacity: 1;
transform: translate(-50%, 0); /* 移动到最终位置 */
}
.toast-notification.success {
background-color: var(--accent-green);
}
.toast-notification.error {
background-color: var(--accent-secondary);
}
`;
// -- HTML 骨架 --
let header = `
<style>${styles}</style>
<div class="panel-header">
<h2>文生图设置</h2>
<div class="controls">
<label>
<span id="script-status-label"></span>
<label class="switch"><input type="checkbox" id="scriptToggle" ${settings.scriptEnabled ? 'checked' : ''}><span class="slider"></span></label>
</label>
<label>模式:
<select id="currentMode" style="width: auto; padding: 5px; margin-bottom:0;">
<option value=sd ${settings.currentMode === 'sd' ? 'selected' : ''}>Stable Diffusion</option>
<option value="nai" ${settings.currentMode === 'nai' ? 'selected' : ''}>NovelAI</option>
<option value="comfyui" ${settings.currentMode === 'comfyui' ? 'selected' : ''}>ComfyUI</option>
</select>
</label>
</div>
</div>`;
let body = `
<div class="panel-body">
<nav class="panel-sidebar">
<a href="#" class="nav-link active" data-target="pane-general">通用设置</a>
<a href="#" class="nav-link" data-target="pane-prompts">提示词</a>
${settings.currentMode === 'sd' ? `<a href="#" class="nav-link" data-target="pane-sd">Stable Diffusion</a>` : ''}
${settings.currentMode === 'nai' ? `<a href="#" class="nav-link" data-target="pane-nai">NovelAI</a>` : ''}
${settings.currentMode === 'comfyui' ? `<a href="#" class="nav-link" data-target="pane-comfyui">ComfyUI</a>` : ''}
<a href="#" class="nav-link" data-target="pane-misc">高级设置</a>
<a href="#" id="open-tags-supermarket-btn" class="nav-link">标签超市</a>
</nav>
<main class="panel-content">
${createGeneralPane()}
${createPromptsPane()}
${settings.currentMode === 'sd' ? createSdPane() : ''}
${settings.currentMode === 'nai' ? createNaiPane() : ''}
${settings.currentMode === 'comfyui' ? createComfyuiPane() : ''}
${createMiscPane()}
</main>
</div>`;
let footer = `
<div class="panel-footer">
<div class="footer-links">
<a id="visit-website-link" style="font-size: 1.5em; font-weight: bold; color: hotpink;">${decodeUnicodeBase64(encodedAuthor)}</a> |
<a href="${decodeUnicodeBase64(encodedQqLink)}" target="_blank" style="font-size: 1.5em; font-weight: bold; color: hotpink;">${decodeUnicodeBase64(encodedQqText)}</a> |
<a href="https://spell.novelai.dev/" target="_blank" style="font-size: 1.5em; font-weight: bold; color: hotpink;">AI图片信息提取</a> |
</div>
<div class="footer-buttons">
<button id="reset-current-mode-settings" class="panel-button">重置当前模式设置</button>
<button id="Clear-Cache" class="panel-button danger">清除图片缓存</button>
<button id="save-settings" class="panel-button primary">保存并关闭</button>
<button id="close-settings" class="panel-button">取消</button>
</div>
</div>`;
panel.innerHTML = header + body + footer;
document.body.appendChild(panel);
// -- 绑定事件 --
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const targetId = e.currentTarget.dataset.target;
// 【修复核心】如果点击的链接没有 data-target 属性,
// 说明它不是一个标准的窗格切换链接(比如“标签超市”按钮),
// 那么就直接返回,不执行后续的窗格切换逻辑,避免报错。
if (!targetId) {
return;
}
// 下面是更健壮的窗格切换逻辑
const currentActiveLink = panel.querySelector('.nav-link.active');
if (currentActiveLink) {
currentActiveLink.classList.remove('active');
}
e.currentTarget.classList.add('active');
const currentActivePane = panel.querySelector('.settings-pane.active');
if (currentActivePane) {
currentActivePane.classList.remove('active');
}
const newPane = panel.querySelector(`#${targetId}`);
if (newPane) {
newPane.classList.add('active');
}
});
});
document.getElementById('currentMode').addEventListener('change', function() {
GM_setValue('currentMode', this.value);
alert('模式已切换,请保存后重新打开设置面板以应用更改。');
});
document.getElementById('save-settings').addEventListener('click', saveSettings);
document.getElementById('close-settings').addEventListener('click', closeSettings);
document.getElementById('Clear-Cache').addEventListener('click', clearCache);
document.getElementById('reset-current-mode-settings').addEventListener('click', resetCurrentModeSettings);
document.getElementById('scriptToggle').addEventListener('change', function() {
settings.scriptEnabled = this.checked;
GM_setValue('scriptEnabled', this.checked);
const statusLabel = document.getElementById('script-status-label'); // 使用ID定位
if (statusLabel) { // 增加一个安全检查
statusLabel.textContent = this.checked ? '脚本已启用' : '脚本已禁用';
statusLabel.style.color = this.checked ? 'var(--accent-green)' : 'var(--accent-secondary)';
}
});
document.getElementById('size').addEventListener('change', size_change);
document.getElementById('yusheid').addEventListener('change', tishici_change);
document.getElementById('tishici_save_style').addEventListener('click', tishici_save);
document.getElementById('tishici_delete_style').addEventListener('click', tishici_delete);
document.getElementById('open-tags-supermarket-btn').addEventListener('click', (e) => {
e.preventDefault();
// 切换活动链接的视觉效果
const sidebar = document.querySelector('.panel-sidebar');
sidebar.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
e.currentTarget.classList.add('active');
openTagsSupermarketModal();
});
// -- 新增: 绑定Danbooru标签搜索事件 --
document.getElementById('tagSearchBtn').addEventListener('click', searchDanbooruTags);
document.getElementById('copyTagButton').addEventListener('click', copySelectedTag);
document.getElementById('fillTagButton').addEventListener('click', fillSelectedTag);
document.getElementById('tagSearchInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault(); // 防止回车触发表单提交等默认行为
document.getElementById('tagSearchBtn').click();
}
});
if (settings.currentMode === 'sd') {
document.getElementById('enableHr').addEventListener('change', function() { settings.enableHr = this.checked ? 'true' : 'false'; GM_setValue('enableHr', settings.enableHr); });
document.getElementById('refreshSdOptions').addEventListener('click', fetchSdOptions);
document.getElementById('loraWeight').addEventListener('input', function() { document.getElementById('loraWeightValue').textContent = parseFloat(this.value).toFixed(2); });
document.getElementById('insertLoraBtn').addEventListener('click', function(e) {
e.preventDefault();
const loraSelect = document.getElementById('sdLora');
const selectedLora = loraSelect.value;
if (!selectedLora || selectedLora === "") { alert('请先选择一个LoRA!'); return; }
const weight = document.getElementById('loraWeight').value;
const positivePromptTextarea = document.getElementById('positivePrompt');
const loraTag = `<lora:${selectedLora}:${parseFloat(weight).toFixed(2)}>`;
if (positivePromptTextarea.value.trim() !== '' && !positivePromptTextarea.value.endsWith(',')) { positivePromptTextarea.value += ', '; }
positivePromptTextarea.value += loraTag;
});
} else if (settings.currentMode === 'nai') {
document.getElementById('naiVibeTransferEnabled').addEventListener('change', function() { settings.naiVibeTransferEnabled = this.checked ? 'true' : 'false'; GM_setValue('naiVibeTransferEnabled', settings.naiVibeTransferEnabled); });
document.getElementById('naiVibeUploadBtn').addEventListener('click', () => document.getElementById('naiVibeUploadInput').click());
document.getElementById('naiVibeUploadInput').addEventListener('change', handleVibeImageUpload);
} else if (settings.currentMode === 'comfyui') {
document.getElementById('refreshComfyuiOptions').addEventListener('click', fetchComfyuiOptions);
document.getElementById('comfyuiCurrentWorkflow').addEventListener('change', workflow_change);
document.getElementById('workflow_update_from_params').addEventListener('click', updateWorkflowFromSettings);
document.getElementById('workflow_save').addEventListener('click', workflow_save);
document.getElementById('workflow_delete').addEventListener('click', workflow_delete);
document.querySelectorAll('.lora-strength-control input[type="range"]').forEach(slider => {
slider.addEventListener('input', function() {
this.nextElementSibling.textContent = parseFloat(this.value).toFixed(2);
});
});
}
return panel;
}
// -- Pane 生成函数 --
function createGeneralPane() {
// 对于ComfyUI,cfg使用sdCfgScale的设置,通用化处理
const cfgValue = settings.currentMode === 'nai' ? settings.naiCfg : settings.sdCfgScale;
const cfgId = settings.currentMode === 'nai' ? 'naiCfg' : 'sdCfgScale';
const cfgLabel = settings.currentMode === 'nai' ? '引导 (Scale)' : 'CFG Scale';
const scaleLabel = settings.currentMode === 'nai' ? 'CFG' : '引导 (Scale)'; // NAI的scale和cfg标签与SD/Comfy相反
return `
<div id="pane-general" class="settings-pane active">
<div class="settings-group">
<h3 class="settings-group-title">尺寸与分辨率</h3>
<div class="flex-row">
<div><label>预设尺寸: <select id="size">
${settings.currentMode === 'nai' ? `
<option value="竖图">竖图 (832x1216)</option>
<option value="横图">横图 (1216x832)</option>
<option value="方图">方图 (1024x1024)</option>
<option value="Custom">自定义(需要消耗点数)</option>
` : `
<option value="832x1216">832x1216 (推荐)</option>
<option value="1216x832">1216x832</option>
<option value="512x768">512x768</option>
<option value="768x512">768x512</option>
<option value="512x512">512x512</option>
<option value="1024x1024">1024x1024</option>
<option value="Custom">自定义</option>
`}
</select></label></div>
<div><label>宽度 (Width): <input type="number" id="width" value="${settings.width}"></label></div>
<div><label>高度 (Height): <input type="number" id="height" value="${settings.height}"></label></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">核心参数</h3>
<div class="flex-row">
${settings.currentMode === 'nai' ? `
<div><label>步数 (Steps): <input type="number" id="steps" value="${settings.steps}"></label></div>
<div><label>${scaleLabel}: <input type="number" id="naiScale" step="0.5" value="${settings.naiScale}"></label></div>
<div><label>${cfgLabel}: <input type="number" id="${cfgId}" step="0.5" value="${cfgValue}"></label></div>
` : `
<div><label>步数 (Steps): <input type="number" id="steps" value="${settings.steps}"></label></div>
<div><label>${cfgLabel}: <input type="number" id="${cfgId}" step="0.5" value="${cfgValue}"></label></div>
${settings.currentMode === 'sd' ? `<div><label>种子 (Seed): <input type="number" id="seed" value="${settings.seed}"></label></div>` : `<div><label>种子 (Seed): <input type="text" value="-1 (自动)" disabled></label></div>`}
`}
</div>
</div>
</div>`;
}
function createPromptsPane() {
const positivePromptValue = (settings.currentMode === 'sd' || settings.currentMode === 'comfyui') ? settings.fixedPrompt : settings.naiPositivePrompt;
return `
<div id="pane-prompts" class="settings-pane">
<div class="settings-group">
<h3 class="settings-group-title">标签搜索</h3>
<label>通过关键词查找标准化的标签(需要挂VPN):</label>
<div class="button-group" style="margin-bottom: 10px;">
<input type="text" id="tagSearchInput" placeholder="例如:原神..." style="margin-bottom: 0;">
<button id="tagSearchBtn" class="panel-button" title="搜索标签"><i class="fa-solid fa-search"></i></button>
</div>
<div class="flex-row" style="align-items: stretch; gap: 10px;">
<div style="flex-grow: 3;">
<label>搜索结果 (点击选择):</label>
<div id="tagSearchResults" class="tag-results-container">尚未搜索</div>
</div>
<div style="flex-grow: 1; display:flex; flex-direction: column; justify-content: flex-end; gap: 8px; padding-bottom:15px;">
<button id="copyTagButton" class="panel-button" style="width:100%;">复制标签</button>
<button id="fillTagButton" class="panel-button primary" style="width:100%;">填入提示词</button>
</div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">提示词预设管理</h3>
<div class="button-group">
<label style="margin-bottom:0;">选择预设: <select id="yusheid" style="margin-bottom:0;">
${Object.keys(settings.yushe).map(key => `<option value="${key}" ${settings.yusheid === key ? 'selected' : ''}>${key}</option>`).join('')}
</select></label>
<button class="panel-button" id="tishici_save_style" title="保存当前风格"><i class="fa-solid fa-save"></i> 保存</button>
<button class="panel-button danger" id="tishici_delete_style" title="删除选中风格"><i class="fa-solid fa-trash-can"></i> 删除</button>
</div>
<label>正面提示词 (Positive Prompt):</label>
<textarea id="positivePrompt" placeholder="1girl, solo, beautiful detailed eyes...">${positivePromptValue}</textarea>
<label>负面提示词 (Negative Prompt):</label>
<textarea id="negativePrompt" placeholder="low quality, bad anatomy...">${settings.negativePrompt}</textarea>
</div>
<div class="settings-group">
<h3 class="settings-group-title">快捷预设</h3>
<div class="flex-row">
<div><label>正面快捷预设: <select id="AQT"><option value="masterpiece,best quality,amazing quality">光辉预设</option><option value="best quality, amazing quality, very aesthetic, absurdres">原版预设</option><option value="">无</option></select></label></div>
<div><label>负面快捷预设: <select id="UCP"><option value="bad quality,worst quality,worst detail,sketch,censor,bad anatomy,jpeg artifacts,signature,watermark,old,oldest,conjoined,bad hands">光辉模型预设</option><option value="">无</option></select></label></div>
</div>
</div>
</div>`;
}
function createSdPane() {
return `
<div id="pane-sd" class="settings-pane">
<div class="settings-group">
<h3 class="settings-group-title">API & 模型</h3>
<label>SD WebUI API地址 (URL):</label>
<div style="display:flex; gap:10px;">
<input type="text" id="sdUrl" value="${settings.sdUrl}" style="margin-bottom:0;">
<button id="refreshSdOptions" class="panel-button primary" style="flex-shrink:0;">刷新数据</button>
</div>
<div class="flex-row">
<div><label>SD 模型 (Checkpoint): <select id="sdModel"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>采样方式 (Sampler): <select id="samplerName"><option value="">-- 点击刷新加载 --</option></select></label></div>
</div>
<div class="flex-row">
<div><label>调度器 (Scheduler): <select id="sdScheduler"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>LoRA 模型: <select id="sdLora" style="width: 100%;"><option value="">-- 点击刷新加载 --</option></select></label></div>
</div>
<label>LoRA 权重:</label>
<div style="display:flex; align-items:center; gap:10px;">
<input type="range" id="loraWeight" min=0 max=3 step="0.05" value="1.0" style="width:100%; padding:0; margin:0; accent-color: var(--accent-primary);">
<span id="loraWeightValue" style="min-width: 35px; font-weight:bold;">1.00</span>
<button id="insertLoraBtn" class="panel-button" style="padding: 5px 10px;">填入</button>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">高分辨率修复 (Hires. fix)</h3>
<label style="display:flex; align-items:center; gap: 10px;">
<label class="switch"><input type="checkbox" id="enableHr" ${settings.enableHr === 'true' ? 'checked' : ''}><span class="slider"></span></label>
<span>启用 Hires. fix</span>
</label>
<div class="flex-row">
<div><label>放大算法 (Upscaler): <select id="hrUpscaler"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>放大倍率 (Scale): <input type="number" id="hrScale" step="0.1" value="${settings.hrScale}"></label></div>
</div>
<div class="flex-row">
<div><label>重绘幅度 (Denoising): <input type="number" id="hrDenoisingStrength" step="0.05" value="${settings.hrDenoisingStrength}"></label></div>
<div><label>Hires Steps: <input type="number" id="hrSecondPassSteps" value="${settings.hrSecondPassSteps}"></label></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">ADetailer</h3>
<label style="display:flex; align-items:center; gap: 5px;">启用ADetailer面部修复:
<select id="adetailerEnabled" style="width:auto;"><option value="true" ${settings.adetailerEnabled === 'true' ? 'selected' : ''}>启用</option><option value="false" ${settings.adetailerEnabled === 'false' ? 'selected' : ''}>禁用</option></select>
</label>
<label>ADetailer 模型: <select id="adModel"><option value="face_yolov8n.pt">face_yolov8n.pt</option><option value="face_yolov8s.pt">face_yolov8s.pt</option><option value="face_yolov8m.pt">face_yolov8m.pt</option><option value="hand_yolov8n.pt">hand_yolov8n.pt</option></select></label>
<div class="flex-row">
<div><label>去噪强度: <input type="number" id="adDenoisingStrength" step="0.1" min=0 max=1 value="${settings.adDenoisingStrength}"></label></div>
<div><label>蒙版模糊: <input type="number" id="adMaskBlur" min=0 max="100" value="${settings.adMaskBlur}"></label></div>
<div><label>修复填充: <input type="number" id="adInpaintPadding" min=0 max="100" value="${settings.adInpaintPadding}"></label></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">ControlNet</h3>
<div style="display: flex; justify-content: space-between; align-items: center;">
<label style="display:flex; align-items:center; gap: 10px; margin-bottom: 0;">
<label class="switch"><input type="checkbox" id="controlNetEnabled" ${settings.controlNetEnabled === 'true' ? 'checked' : ''}><span class="slider"></span></label>
<span>启用 ControlNet</span>
</label>
<button id="open-controlnet-modal-btn" class="panel-button primary"><i class="fa-solid fa-cogs"></i> 配置 ControlNet 单元</button>
</div>
</div>
</div>`;
}
function createNaiPane() {
return `
<div id="pane-nai" class="settings-pane">
<div class="settings-group">
<h3 class="settings-group-title">API 设置</h3>
<label>渠道: <select id="naiChannel">
<option value="proxy" ${settings.naiChannel === 'proxy' ? 'selected' : ''}>第三方代理</option>
<option value="official" ${settings.naiChannel === 'official' ? 'selected' : ''}>官方 (Official)</option>
</select></label>
<label>API 地址 (URL):</label><input type="text" id="naiApiUrl" value="${settings.naiApiUrl}" data-default-official="https://image.novelai.net/ai/generate-image" data-default-proxy="https://std.loliyc.com/generate">
<label>Token:</label><input type="text" id="naiToken" value="${settings.naiToken}">
</div>
<div class="settings-group">
<h3 class="settings-group-title">模型与采样</h3>
<div class="flex-row">
<div><label>模型 (Model): <select id="naiModel">
<option value="nai-diffusion-3" ${settings.naiModel === 'nai-diffusion-3' ? 'selected' : ''}>nai-diffusion-3</option>
<option value="nai-diffusion-furry-3" ${settings.naiModel === 'nai-diffusion-furry-3' ? 'selected' : ''}>nai-diffusion-furry-3</option>
<option value="nai-diffusion-4-full" ${settings.naiModel === 'nai-diffusion-4-full' ? 'selected' : ''}>nai-diffusion-4-full</option>
<option value="nai-diffusion-4-curated-preview" ${settings.naiModel === 'nai-diffusion-4-curated-preview' ? 'selected' : ''}>nai-diffusion-4-curated-preview</option>
<option value="nai-diffusion-4-5-full" ${settings.naiModel === 'nai-diffusion-4-5-full' ? 'selected' : ''}>nai-diffusion-4-5-full</option>
<option value="nai-diffusion-4-5-curated" ${settings.naiModel === 'nai-diffusion-4-5-curated' ? 'selected' : ''}>nai-diffusion-4-5-curated</option>
<option value="nai-diffusio" ${settings.naiModel === 'nai-diffusio' ? 'selected' : ''}>nai-diffusio</option>
</select></label></div>
<div><label>采样器 (Sampler): <select id="naiSampler">
<option value="k_euler_ancestral" ${settings.naiSampler === 'k_euler_ancestral' ? 'selected' : ''}>Euler Ancestral</option>
<option value="k_euler" ${settings.naiSampler === 'k_euler' ? 'selected' : ''}>Euler</option>
<option value="k_dpmpp_2s_ancestral" ${settings.naiSampler === 'k_dpmpp_2s_ancestral' ? 'selected' : ''}>DMP++2S Ancestral</option>
<option value="k_dpmpp_2m_sde" ${settings.naiSampler === 'k_dpmpp_2m_sde' ? 'selected' : ''}>DMP++2M SDE</option>
<option value="k_dpmpp_2m" ${settings.naiSampler === 'k_dpmpp_2m' ? 'selected' : ''}>DMP++2M</option>
<option value="k_dpmpp_sde" ${settings.naiSampler === 'k_dpmpp_sde' ? 'selected' : ''}>DMP++ SDE</option>
</select></label></div>
</div>
<label>噪点调度 (Noise Schedule): <select id="naiNoiseSchedule">
<option value="native" ${settings.naiNoiseSchedule === 'native' ? 'selected' : ''}>native</option>
<option value="exponential" ${settings.naiNoiseSchedule === 'exponential' ? 'selected' : ''}>exponential</option>
<option value="polyexponential" ${settings.naiNoiseSchedule === 'polyexponential' ? 'selected' : ''}>polyexponential</option>
<option value="karras" ${settings.naiNoiseSchedule === 'karras' ? 'selected' : ''}>karras</option>
</select></label>
</div>
<div class="settings-group">
<h3 class="settings-group-title">高级参数 <small style="color:var(--text-secondary)"></small></h3>
<div class="flex-row">
<div><label>SM(nai3生效): <select id="nai3sm">
<option value="true" ${settings.nai3sm === 'true' ? 'selected' : ''}>True</option>
<option value="false" ${settings.nai3sm === 'false' ? 'selected' : ''}>False</option>
</select></label></div>
<div><label>SMEA + DYN(nai3生效): <select id="nai3dyn">
<option value="true" ${settings.nai3dyn === 'true' ? 'selected' : ''}>True</option>
<option value="false" ${settings.nai3dyn === 'false' ? 'selected' : ''}>False</option>
</select></label></div>
</div>
<div class="flex-row" style="margin-top:15px;">
<div><label>多样性(Variety): <select id="nai3Variety">
<option value="true" ${settings.nai3Variety === 'true' ? 'selected' : ''}>True</option>
<option value="false" ${settings.nai3Variety === 'false' ? 'selected' : ''}>False</option>
</select></label></div>
<div><label>减少伪影Decrisper(nai3生效): <select id="nai3Deceisp">
<option value="true" ${settings.nai3Deceisp === 'true' ? 'selected' : ''}>True</option>
<option value="false" ${settings.nai3Deceisp === 'false' ? 'selected' : ''}>False</option>
</select></label></div>
</div>
<div class="flex-row" style="margin-top:15px;">
<div><label>启用角色位置(nai4+生效): <select id="AI_use_coords">
<option value="true" ${settings.AI_use_coords === 'true' ? 'selected' : ''}>True</option>
<option value="false" ${settings.AI_use_coords === 'false' ? 'selected' : ''}>False</option>
</select></label></div>
<div></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">氛围转移 (Vibe Transfer)</h3>
<label style="display:flex; align-items:center; gap: 10px;">
<label class="switch"><input type="checkbox" id="naiVibeTransferEnabled" ${settings.naiVibeTransferEnabled === 'true' ? 'checked' : ''}><span class="slider"></span></label>
<span>启用氛围转移 (Vibe Transfer)</span>
</label>
<button id="naiVibeUploadBtn" class="panel-button" style="margin-top: 10px; width:auto;">上传参考图 (最多10张)</button>
<input type="file" id="naiVibeUploadInput" multiple accept="image/*,.naiv4vibe,.json" style="display:none;">
<p id="naiVibeStatus" class="info" style="font-size: 13px; margin-top: 10px;">未上传图片。提示:V4+模型请上传官网生成的.naiv4vibe,.json文件。</p>
<div id="naiVibeImageListContainer"></div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">角色位置 (多角色构图) 使用说明</h3>
<details>
<summary style="cursor: pointer; color: var(--accent-primary);">点击展开/折叠详细说明</summary>
<div style="margin-top: 15px; font-size: 0.9em; line-height: 1.6;">
<p><strong>注意:</strong> 此功能仅适用于NAI V4及更高版本的模型,并需要在上方【高级参数】中开启 <strong>"启用角色位置"</strong> 选项。</p>
<p>在酒馆聊天框中,使用以下特殊格式来精确控制多个角色的位置和特征:</p>
<pre style="background-color: var(--bg-input); padding: 10px; border-radius: 5px; white-space: pre-wrap; word-wrap: break-word;"><code>Scene Composition: [场景通用标签];
Character 1 Prompt: [角色1的标签] |centers:坐标;
Character 1 UC: [只对角色1生效的负面标签];
Character 2 Prompt: [角色2的标签] |centers:坐标;
Character 2 UC: [只对角色2生效的负面标签];</code></pre>
<h4>关键点解释:</h4>
<ol style="padding-left: 20px;">
<li>
<strong><code>Scene Composition:</code></strong>
<ul style="padding-left: 20px;">
<li><strong>作用:</strong> 定义整个画面的背景、整体风格、光照和构图。这些标签会影响到所有角色和环境。</li>
<li><strong>示例:</strong> <code>masterpiece, best quality, indoors, library, bookshelf background, dramatic lighting</code></li>
</ul>
</li>
<li>
<strong><code>Character X Prompt:</code></strong>
<ul style="padding-left: 20px;">
<li><strong>作用:</strong> 只描述这一个角色的所有特征,如性别、服装、发型、动作、表情等。</li>
<li>你可以定义最多4个角色 (<code>Character 1</code> 到 <code>Character 4</code>)。</li>
</ul>
</li>
<li>
<strong><code>|centers:XY</code></strong>
<ul style="padding-left: 20px;">
<li><strong>作用:</strong> 这是<strong>定位的关键</strong>。它紧跟在角色描述后面,用 <code>|</code> 分隔。</li>
<li><code>X</code> 是字母 A-E (从左到右)。</li>
<li><code>Y</code> 是数字 1-5 (从上到下)。</li>
<li>这会将图像想象成一个 5x5 的网格,你指定一个单元格作为该角色的中心。</li>
</ul>
<strong>坐标网格示意图:</strong>
<pre style="background-color: var(--bg-input); padding: 10px; border-radius: 5px; font-family: monospace;">+---+---+---+---+---+
| A1| B1| C1| D1| E1| (顶部)
+---+---+---+---+---+
| A2| B2| C2| D2| E2|
+---+---+---+---+---+
| A3| B3| C3| D3| E3| (中部)
+---+---+---+---+---+
| A4| B4| C4| D4| E4|
+---+---+---+---+---+
| A5| B5| C5| D5| E5| (底部)
+---+---+---+---+---+
(左侧) (右侧)</pre>
<ul style="padding-left: 20px;">
<li><code>C3</code> 是画面正中心。</li>
<li><code>B3</code> 在画面左半边中间。</li>
<li><code>D3</code> 在画面右半边中间。</li>
</ul>
</li>
<li>
<strong><code>Character X UC:</code></strong>
<ul style="padding-left: 20px;">
<li><strong>作用:</strong> <strong>极为强大</strong>。这是只针对<strong>这一个角色</strong>的负面提示词 (Undesired Content)。你可以用它来修复单个角色的问题,而不会影响到其他角色。</li>
<li><strong>示例:</strong> 如果角色1出现了多余的手指,你可以在 <code>Character 1 UC:</code> 中加入 <code>bad hands</code>,而不需要污染全局负面提示词。</li>
</ul>
</li>
<li>
<strong>分号 <code>;</code></strong>
<ul style="padding-left: 20px;"><li>每个定义块(<code>Scene</code>, <code>Character Prompt</code>, <code>UC</code>)都必须以分号结尾,这是代码解析的分隔符。</li></ul>
</li>
</ol>
<hr style="border-color: var(--border-color); margin: 20px 0;">
<h4>一个完整的实战例子</h4>
<p>假设我们想画一张图:<strong>一个金发蓝眼的女骑士在左边,一个黑发红眼的男法师在右边,背景是奇幻森林。</strong></p>
<p>你可以在酒馆聊天框中发送以下内容 (包含在 <code>image###...###</code> 标签内):</p>
<pre style="background-color: var(--bg-input); padding: 10px; border-radius: 5px; white-space: pre-wrap; word-wrap: break-word;"><code>Scene Composition: masterpiece, best quality, fantasy, enchanted forest, night, glowing mushrooms, god rays, cinematic angle;
Character 1 Prompt: 1girl, blonde hair, blue eyes, beautiful face, full silver armor, knight, holding a greatsword, serious expression |centers:B3;
Character 1 UC: red eyes, black hair, smiling;
Character 2 Prompt: 1boy, black hair, red eyes, handsome face, dark mage robes with gold trim, holding a glowing magic orb, smug smirk |centers:D3;
Character 2 UC: blonde hair, blue eyes, armor;</code></pre>
</div>
</details>
</div>
</div>`;
}
function createComfyuiPane() {
const currentWorkflowJSON = settings.comfyuiWorkflows[settings.comfyuiCurrentWorkflow] || "{}";
let loraControls = '';
for (let i = 1; i <= 4; i++) {
const loraId = i === 1 ? 'comfyuiLora' : `comfyuiLora${i}`;
const strengthId = i === 1 ? 'comfyuiLoraStrength' : `comfyuiLoraStrength${i}`;
const value = settings[strengthId] || '1.0';
loraControls += `
<div class="lora-item">
<label>LoRA 模型 ${i}:
<select id="${loraId}"><option value="None">-- 点击刷新加载 --</option></select>
</label>
<div class="lora-strength-control">
<input type="range" id="${strengthId}" min=0 max=2 step="0.05" value="${value}">
<span class="lora-strength-value">${parseFloat(value).toFixed(2)}</span>
</div>
</div>
`;
}
return `
<div id="pane-comfyui" class="settings-pane">
<div class="settings-group">
<h3 class="settings-group-title">API & 动态参数</h3>
<label>ComfyUI API地址 (URL):</label>
<div class="button-group">
<input type="text" id="comfyuiUrl" value="${settings.comfyuiUrl}" style="margin-bottom:0;">
<button id="refreshComfyuiOptions" class="panel-button primary" style="flex-shrink:0;">刷新数据</button>
</div>
<div class="flex-row">
<div><label>主模型 (Checkpoint): <select id="comfyuiModel"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>主采样方式 (Sampler): <select id="comfyuiSampler"><option value="">-- 点击刷新加载 --</option></select></label></div>
</div>
<div class="flex-row">
<div><label>主调度器 (Scheduler): <select id="comfyuiScheduler"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div></div>
</div>
<div class="lora-grid">${loraControls}</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">脸部修复 (FaceDetailer) <small style="color:var(--text-secondary)">(仅当工作流包含FaceDetailer节点时生效,face(脸部修复)hand(手部修复))</small></h3>
<div class="flex-row">
<div><label>修复检测类型: <select id="faceDetailerModel"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>修脸步数 (Steps): <input type="number" id="faceDetailerSteps" value="${settings.faceDetailerSteps}"></label></div>
<div><label>最小范围 (Guide Size): <input type="number" id="faceDetailerGuideSize" value="${settings.faceDetailerGuideSize}"></label></div>
<div><label>最大范围 (Max Size): <input type="number" id="faceDetailerMaxSize" value="${settings.faceDetailerMaxSize}"></label></div>
</div>
<div class="flex-row" style="margin-top:15px;">
<div><label>修脸 CFG: <input type="number" id="faceDetailerCfg" step="0.5" value="${settings.faceDetailerCfg}"></label></div>
<div><label>修脸采样器 (Sampler): <select id="faceDetailerSampler"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>修脸调度器 (Scheduler): <select id="faceDetailerScheduler"><option value="">-- 点击刷新加载 --</option></select></label></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">高清修复 (Ultimate SD Upscale) <small style="color:var(--text-secondary)">(仅当工作流包含UltimateSDUpscale节点时生效)</small></h3>
<div class="flex-row">
<div><label>修复算法 (Upscale Model): <select id="comfyuiUpscaleModel"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>放大倍率 (Upscale By): <input type="number" id="comfyuiUltimateUpscaleBy" step="0.1" value="${settings.comfyuiUltimateUpscaleBy}"></label></div>
<div><label>去噪 (Denoise): <input type="number" id="comfyuiUltimateDenoise" step="0.05" value="${settings.comfyuiUltimateDenoise}"></label></div>
</div>
<div class="flex-row" style="margin-top:15px;">
<div><label>步数 (Steps): <input type="number" id="comfyuiUltimateSteps" value="${settings.comfyuiUltimateSteps}"></label></div>
<div><label>提示词相关性 (CFG): <input type="number" id="comfyuiUltimateCfg" step="0.5" value="${settings.comfyuiUltimateCfg}"></label></div>
<div><label>模型类型 (Mode Type):
<select id="comfyuiUltimateModeType">
<option value="Linear" ${settings.comfyuiUltimateModeType === 'Linear' ? 'selected' : ''}>Linear</option>
<option value="Chess" ${settings.comfyuiUltimateModeType === 'Chess' ? 'selected' : ''}>Chess</option>
</select>
</label></div>
</div>
<div class="flex-row" style="margin-top:15px;">
<div><label>采样器 (Sampler): <select id="comfyuiUltimateSampler"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div><label>调度器 (Scheduler): <select id="comfyuiUltimateScheduler"><option value="">-- 点击刷新加载 --</option></select></label></div>
<div></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">工作流 (Workflow) 管理</h3>
<label style="display:flex; align-items:center; gap: 10px; margin-bottom: 15px; background-color: rgba(247, 118, 142, 0.1); padding: 8px; border-radius: 5px; border: 1px solid var(--accent-secondary);">
<label class="switch"><input type="checkbox" id="comfyuiUseCustomWorkflowOnly" ${settings.comfyuiUseCustomWorkflowOnly === 'true' ? 'checked' : ''}><span class="slider"></span></label>
<span><strong style="color:var(--accent-secondary);">启用纯净工作流模式:</strong> 仅使用下方编辑器中的JSON,并只动态注入提示词。</span>
</label>
<div class="button-group">
<label style="flex-grow: 1;">选择工作流:
<select id="comfyuiCurrentWorkflow">
${Object.keys(settings.comfyuiWorkflows).map(name => `<option value="${name}" ${settings.comfyuiCurrentWorkflow === name ? 'selected' : ''}>${name}</option>`).join('')}
</select>
</label>
<button class="panel-button" id="workflow_update_from_params" title="将上方选择的模型、采样器、分辨率等动态参数写入下方的JSON编辑器中,方便您检查。" style="background-color: var(--accent-green); color: var(--bg-main);"><i class="fa-solid fa-download"></i> 检查参数</button>
<button class="panel-button" id="workflow_save" title="将当前编辑器中的内容保存为一个新的或覆盖现有的工作流"><i class="fa-solid fa-save"></i> 保存</button>
<button class="panel-button danger" id="workflow_delete" title="删除当前选中的工作流"><i class="fa-solid fa-trash-can"></i> 删除</button>
</div>
<label>工作流 JSON (可在此编辑):</label>
<textarea id="comfyuiWorkflowEditor">${currentWorkflowJSON}</textarea>
<small style="color: var(--text-secondary);">提示:现在动态参数会在生成时<strong style="color: var(--accent-green);">自动应用</strong>,无需依赖此编辑器。点击 <strong style="color: var(--accent-green);"><a><a><a><a><a><a>“检查参数”</a></a></a></a></a></a></strong> 按钮可方便地查看当前参数在工作流中的配置情况。</small>
</div>
</div>`;
}
function createMiscPane() {
return `
<div id="pane-misc" class="settings-pane">
<div class="settings-group">
<h3 class="settings-group-title">内容解析</h3>
<div class="flex-row">
<div><label>开始标记 (Start Tag): <input type="text" id="startTag" value="${settings.startTag}"></label></div>
<div><label>结束标记 (End Tag): <input type="text" id="endTag" value="${settings.endTag}"></label></div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">自动化与显示</h3>
<div class="flex-row">
<div><label>自动点击生成:
<select id="zidongdianji">
<option value="true" ${settings.zidongdianji === 'true' ? 'selected' : ''}>启用</option>
<option value="false" ${settings.zidongdianji === 'false' ? 'selected' : ''}>禁用</option>
</select></label>
</div>
<div><label>最大自动点击数: <input type="number" id="maxAutoClicks" min=1 value="${settings.maxAutoClicks}"></label></div>
</div>
<div class="flex-row">
<div><label>隐藏按钮为双击图片:
<select id="dbclike">
<option value="true" ${settings.dbclike === 'true' ? 'selected' : ''}>启用</option>
<option value="false" ${settings.dbclike === 'false' ? 'selected' : ''}>禁用</option>
</select></label>
</div>
<div><label>兼容前端:
<select id="displayMode">
<option value=默认 ${settings.displayMode === '默认' ? 'selected' : ''}>默认(快速扫描)</option>
<option value="兼容前端" ${settings.displayMode === '兼容前端' ? 'selected' : ''}>深度扫描(匹配大部分前端卡)</option>
</select></label>
</div>
</div>
</div>
<div class="settings-group">
<h3 class="settings-group-title">缓存设置</h3>
<label>缓存有效期:
<select id="cache">
<option value=0 ${settings.cache == 0 ? 'selected' : ''}>不缓存</option>
<option value=1 ${settings.cache == 1 ? 'selected' : ''}>缓存一天</option>
<option value=7 ${settings.cache == 7 ? 'selected' : ''}>缓存一星期</option>
<option value=30 ${settings.cache == 30 ? 'selected' : ''}>缓存一个月</option>
<option value="365" ${settings.cache == "365" ? 'selected' : ''}>缓存一年</option>
</select></label>
</div>
<div class="settings-group">
<h3 class="settings-group-title">图片缓存查看</h3>
<div class="button-group" style="justify-content: space-between; align-items: center; margin-bottom: 10px;">
<p id="cache-info-text" style="margin: 0;">点击刷新加载缓存...</p>
<div style="display: flex; gap: 8px;">
<button id="download-cache-page" class="panel-button">
<i class="fa-solid fa-file-arrow-down"></i> 下载本页
</button>
<button id="download-cache-all" class="panel-button">
<i class="fa-solid fa-archive"></i> 下载全部
</button>
<button id="refresh-cache-view" class="panel-button">
<i class="fa-solid fa-sync"></i> 刷新
</button>
</div>
</div>
<div id="cache-viewer-grid" class="cache-grid">
<!-- 缩略图将在这里动态生成 -->
</div>
<div id="cache-viewer-pagination" class="pagination">
<!-- 分页控件将在这里动态生成 -->
</div>
</div>
</div>`;
}
async function downloadImagesAsZip(imageItems, zipFileName) {
if (!imageItems || imageItems.length === 0) {
showToast('没有可下载的图片。', 'error');
return;
}
const zip = new JSZip();
showToast(`正在打包 ${imageItems.length} 张图片...`, 'success', 10000); // 显示一个较长时间的提示
for (const item of imageItems) {
// 确保 imageData 存在且是 base64 格式
if (item.imageData && item.imageData.includes(',')) {
const base64Data = item.imageData.split(',')[1];
// 使用图片ID作为文件名,保证唯一性
zip.file(`image-${item.id}.png`, base64Data, { base64: true });
}
}
try {
const content = await zip.generateAsync({ type: "blob" });
showToast('打包完成,即将开始下载!', 'success');
// 创建一个隐藏的链接来触发浏览器下载
const link = document.createElement('a');
link.href = URL.createObjectURL(content);
link.download = zipFileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link); // 清理DOM
URL.revokeObjectURL(link.href); // 释放内存
} catch (error) {
console.error("生成ZIP文件失败:", error);
showToast('打包图片失败,请查看控制台。', 'error');
}
}
let sortedCache = [];
let currentCachePage = 1;
const ITEMS_PER_PAGE = 12; // 每页显示12张图
async function initCacheViewer() {
const infoText = document.getElementById('cache-info-text');
if (!infoText) return;
infoText.textContent = '正在加载缓存数据...';
try {
// 从IndexedDB直接获取所有图片记录
const allImages = await StoreGetAll();
if (!allImages || allImages.length === 0) {
infoText.textContent = '缓存为空。';
document.getElementById('cache-viewer-grid').innerHTML = '';
document.getElementById('cache-viewer-pagination').innerHTML = '';
sortedCache = [];
return;
}
// 按时间戳降序排序(最新的在前)
sortedCache = allImages.sort((a, b) => b.timestamp - a.timestamp);
currentCachePage = 1;
renderCachePage(currentCachePage);
} catch(error) {
console.error("加载缓存视图失败:", error);
infoText.textContent = '加载缓存失败。';
}
}
async function renderCachePage(page) {
currentCachePage = page;
const grid = document.getElementById('cache-viewer-grid');
const infoText = document.getElementById('cache-info-text');
if (!grid || !infoText) return;
grid.innerHTML = '<i>正在渲染图片...</i>';
const totalItems = sortedCache.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE);
const startIndex = (page - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
infoText.textContent = `共 ${totalItems} 张图片,正在显示 ${startIndex + 1}-${endIndex} 张`;
const pageItems = sortedCache.slice(startIndex, endIndex);
grid.innerHTML = ''; // 清空,准备填充
if (pageItems.length === 0 && totalItems > 0) {
// 如果当前页没有项目了(例如,删除了最后一页的所有项目),则跳到前一页
renderCachePage(Math.max(1, page - 1));
return;
}
// 现在item直接就是完整的图片对象,无需再次查询
for (const item of pageItems) {
// 【已修复】 item.imageData 是正确的图片数据来源
if (item && item.imageData) {
const cacheItemDiv = document.createElement('div');
cacheItemDiv.className = 'cache-item';
cacheItemDiv.dataset.id = item.id;
// 【核心修复】创建并设置 img 标签,然后将其添加到 cacheItemDiv
const img = document.createElement('img');
img.src = item.imageData;
img.alt = "Cached Image Thumbnail";
cacheItemDiv.appendChild(img);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-cache-btn';
deleteBtn.title = '删除此记录';
deleteBtn.innerHTML = '×';
cacheItemDiv.appendChild(deleteBtn);
grid.appendChild(cacheItemDiv);
// 添加事件监听
img.addEventListener('dblclick', () => showImageLightbox(item.imageData, item.id));
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 防止触发双击事件
deleteSingleCacheItem(item.id, cacheItemDiv);
});
}
}
renderPaginationControls(page, totalPages);
}
function renderPaginationControls(currentPage, totalPages) {
const paginationContainer = document.getElementById('cache-viewer-pagination');
if (!paginationContainer) return;
paginationContainer.innerHTML = '';
if (totalPages <= 1) return;
const prevButton = document.createElement('button');
prevButton.textContent = '上一页';
prevButton.className = 'panel-button';
prevButton.disabled = currentPage === 1;
prevButton.addEventListener('click', () => renderCachePage(currentPage - 1));
paginationContainer.appendChild(prevButton);
const pageInfo = document.createElement('span');
pageInfo.className = 'page-info';
pageInfo.textContent = `第 ${currentPage} / ${totalPages} 页`;
paginationContainer.appendChild(pageInfo);
const nextButton = document.createElement('button');
nextButton.textContent = '下一页';
nextButton.className = 'panel-button';
nextButton.disabled = currentPage === totalPages;
nextButton.addEventListener('click', () => renderCachePage(currentPage + 1));
paginationContainer.appendChild(nextButton);
}
async function deleteSingleCacheItem(id, elementToRemove) {
const imageId = Number(id); // ID现在是数字
if (await stylishConfirm("确定要永久删除这张图片缓存吗?")) {
try {
// 1. 从IndexedDB删除图片记录
await Storedelete(imageId);
// 2. 从内存中的排序数组删除
sortedCache = sortedCache.filter(item => item.id !== imageId);
// 3. 从位置映射中移除对此ID的引用
let mapUpdated = false;
for (const locationHash in locationToImageIdMap) {
if (locationToImageIdMap[locationHash] === imageId) {
delete locationToImageIdMap[locationHash];
mapUpdated = true;
}
}
// 4. 如果映射有更新,则写回数据库
if (mapUpdated) {
await Storereadwrite({ id: 'locationMap', data: locationToImageIdMap });
}
showToast(`图片记录 (ID: ${imageId}) 已删除。`);
// 5. 重新渲染当前页
renderCachePage(currentCachePage);
} catch (error) {
console.error("删除单个缓存项目失败:", error);
alert("删除失败,详情请查看控制台。");
}
}
}
function showImageLightbox(base64Data, id) {
// 防止重复创建
const existingLightbox = document.querySelector('.lightbox-overlay');
if (existingLightbox) {
existingLightbox.remove();
}
const overlay = document.createElement('div');
overlay.className = 'lightbox-overlay';
const downloadFilename = `image-${id}.png`;
overlay.innerHTML = `
<div class="lightbox-content">
<img src="${base64Data}" alt="preview" class="lightbox-img" />
<a href="${base64Data}" class="lightbox-download" download="${downloadFilename}" title="下载原图">
<i class="fa-solid fa-download"></i> 下载
</a>
<button class="lightbox-close" title="关闭 (Esc)">
<i class="fa-solid fa-times"></i> 关闭
</button>
</div>
`;
document.body.appendChild(overlay);
const close = () => {
if (overlay.parentNode) {
overlay.parentNode.removeChild(overlay);
}
document.removeEventListener('keydown', escListener);
};
overlay.addEventListener('click', (e) => {
// 如果点击的是背景遮罩层,而不是图片或按钮,则关闭
if (e.target === overlay) {
close();
}
});
overlay.querySelector('.lightbox-close').addEventListener('click', close);
// 添加键盘Esc键关闭功能
const escListener = (e) => {
if (e.key === 'Escape') {
close();
}
};
document.addEventListener('keydown', escListener);
}
function openControlNetModal() {
// 创建一个临时状态,以便用户可以取消更改
let tempUnits = JSON.parse(JSON.stringify(settings.controlNetUnits || []));
// 创建模态框的HTML结构
const modalOverlay = document.createElement('div');
modalOverlay.className = 'controlnet-modal-overlay';
// 使用主面板的类名以实现样式统一
modalOverlay.innerHTML = `
<div class="controlnet-modal settings-panel-reborn">
<div class="panel-header">
<h2>配置 ControlNet</h2>
<button id="add-cn-unit-modal-btn" class="panel-button primary"><i class="fa-solid fa-plus"></i> 新增单元</button>
</div>
<div id="controlnet-modal-content" class="controlnet-modal-content">
<!-- 单元将在这里动态生成 -->
</div>
<div class="panel-footer">
<span id="cn-unit-count-display" class="unit-count" style="color: var(--text-secondary); font-size: 0.9em;"></span>
<div class="footer-buttons">
<button id="cancel-cn-modal-btn" class="panel-button">取消</button>
<button id="save-cn-modal-btn" class="panel-button primary">保存并关闭</button>
</div>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const modal = modalOverlay.querySelector('.controlnet-modal');
const contentContainer = modal.querySelector('#controlnet-modal-content');
// --- 核心逻辑函数 ---
// 渲染函数:根据 tempUnits 数组更新UI
function renderUnits() {
contentContainer.innerHTML = ''; // 清空
tempUnits.forEach((unit, index) => {
contentContainer.innerHTML += createControlNetUnitHTML(unit, index);
});
populateControlNetSelects(); // 填充模型和预处理器
// 恢复已保存的值
tempUnits.forEach((unit, index) => {
// 使用新的选择器 .settings-group
const unitEl = contentContainer.querySelector(`.settings-group[data-index="${index}"]`);
if (unitEl) {
if (unit.model) unitEl.querySelector('.cn-model').value = unit.model;
if (unit.module) unitEl.querySelector('.cn-module').value = unit.module;
if (unit.resize_mode) unitEl.querySelector('.cn-resize_mode').value = unit.resize_mode;
if (unit.control_mode) unitEl.querySelector('.cn-control_mode').value = unit.control_mode;
unitEl.querySelector('.cn-pixel_perfect').checked = (unit.pixel_perfect === true || unit.pixel_perfect === 'true');
}
});
// 更新计数
modal.querySelector('#cn-unit-count-display').textContent = `当前单元数: ${tempUnits.length}/4`;
modal.querySelector('#add-cn-unit-modal-btn').style.display = tempUnits.length >= 4 ? 'none' : 'inline-flex';
}
function saveAndClose() {
// 从UI读取当前配置到 tempUnits
const updatedUnits = [];
// 使用新的选择器 .settings-group
contentContainer.querySelectorAll('.settings-group').forEach(unitEl => {
const imageEl = unitEl.querySelector('.cn-preview-img');
updatedUnits.push({
enabled: unitEl.querySelector('.cn-enabled').checked,
image: imageEl ? imageEl.src : null,
model: unitEl.querySelector('.cn-model').value,
module: unitEl.querySelector('.cn-module').value,
resize_mode: unitEl.querySelector('.cn-resize_mode').value,
control_mode: unitEl.querySelector('.cn-control_mode').value,
pixel_perfect: unitEl.querySelector('.cn-pixel_perfect').checked,
weight: 1.0, lowvram: false, processor_res: 512, threshold_a: 64, threshold_b: 64, guidance_start: 0.0, guidance_end: 1.0,
});
});
tempUnits = updatedUnits;
// 将临时状态正式应用到全局设置
settings.controlNetUnits = tempUnits;
closeModal();
showToast('ControlNet 配置已保存!');
}
// 取消并关闭
function closeModal() {
modal.classList.remove('visible');
setTimeout(() => {
if (modalOverlay.parentNode) {
document.body.removeChild(modalOverlay);
}
}, 200); // 等待动画完成
}
// --- 事件绑定 ---
modal.querySelector('#add-cn-unit-modal-btn').addEventListener('click', () => {
if (tempUnits.length < 4) {
tempUnits.push({ enabled: true, image: null, module: 'none', model: 'None', resize_mode: 1, control_mode: 0, pixel_perfect: false });
renderUnits();
}
});
contentContainer.addEventListener('click', e => {
// 使用新的选择器 .settings-group
const unitEl = e.target.closest('.settings-group');
if (!unitEl) return;
const index = parseInt(unitEl.dataset.index, 10);
if (e.target.closest('.remove-cn-unit')) {
tempUnits.splice(index, 1);
renderUnits();
} else if (e.target.closest('.cn-image-upload-wrapper')) {
unitEl.querySelector('.cn-image-input').click();
}
});
contentContainer.addEventListener('change', e => {
const input = e.target;
if (input.classList.contains('cn-image-input') && input.files[0]) {
const file = input.files[0];
const reader = new FileReader();
// 使用新的选择器 .settings-group
const unitEl = input.closest('.settings-group');
const index = parseInt(unitEl.dataset.index, 10);
reader.onload = (loadEvent) => {
const base64 = loadEvent.target.result;
if(tempUnits[index]) tempUnits[index].image = base64;
renderUnits(); // 重新渲染以显示预览
};
reader.readAsDataURL(file);
}
});
modal.querySelector('#save-cn-modal-btn').addEventListener('click', saveAndClose);
modal.querySelector('#cancel-cn-modal-btn').addEventListener('click', closeModal);
// 初始化
renderUnits();
setTimeout(() => modal.classList.add('visible'), 20); // 延迟添加以触发过渡动画
}
// -- Danbooru 标签搜索功能 ---
async function searchDanbooruTags() {
const query = document.getElementById('tagSearchInput').value.trim();
const resultsContainer = document.getElementById('tagSearchResults');
if (!query) {
resultsContainer.textContent = '请输入搜索关键词';
return;
}
resultsContainer.innerHTML = '<i>正在搜索...</i>';
const delay = 1000 + Math.random() * 2000;
await new Promise(resolve => setTimeout(resolve, delay));
const searchUrl = `https://danbooru.donmai.us/autocomplete?search%5Bquery%5D=${encodeURIComponent(query)}&search%5Btype%5D=tag_query&version=1&limit=10`;
try {
const response = await gmXmlHttpRequestPromise({
method: "GET",
url: searchUrl,
headers: {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Referer": "https://danbooru.donmai.us/",
"X-Requested-With": "XMLHttpRequest"
},
anonymous: false
});
const responseText = response.responseText;
if (isCloudflareChallenge(responseText)) {
handleCloudflareBlock(resultsContainer, query);
return;
}
const parser = new DOMParser();
const doc = parser.parseFromString(responseText, "text/html");
if (doc.title && doc.title.includes("Just a moment...")) {
handleCloudflareBlock(resultsContainer, query);
return;
}
const listItems = doc.querySelectorAll("li[data-autocomplete-value]");
const tagsArray = [];
listItems.forEach(li => {
// .label 是显示出来的文本,如 "原神→genshin impact"
const label = li.querySelector('a')?.textContent.trim() || '';
// .value 是实际的tag,如 "genshin_impact"
const value = li.dataset.autocompleteValue;
const post_count = li.querySelector('.post-count')?.textContent.trim() || '0';
// 构建成与原函数兼容的对象结构
if(value) {
tagsArray.push({ label, value, post_count });
}
});
// 将构建好的数组传递给渲染函数,无需修改渲染函数
renderResults(tagsArray, query, resultsContainer);
} catch (error) {
console.error('请求失败:', error);
resultsContainer.innerHTML = `
<div class="error">请求异常: ${error.status || '未知错误'}</div>
<button class="retry-btn">重试</button>
`;
resultsContainer.querySelector('.retry-btn').addEventListener('click', searchDanbooruTags);
}
}
// 检测Cloudflare拦截
function isCloudflareChallenge(text) {
return /<title>Just a moment...<\/title>|cloudflare/i.test(text);
}
// 处理Cloudflare拦截
function handleCloudflareBlock(container, query) {
container.innerHTML = `
<div class="cf-warning">
<h4>⚠️ Cloudflare拦截检测</h4>
<p>需要完成人机验证才能访问Danbooru:</p>
<ol>
<li><a href="https://danbooru.donmai.us/" target="_blank">点击此处打开新标签页</a></li>
<li>完成"Just a moment..."验证</li>
<li>返回此页面点击重试</li>
</ol>
<div class="actions">
<button class="retry-btn">我已验证,重试搜索</button>
<button class="direct-link" data-query="${encodeURIComponent(query)}">直接打开搜索结果</button>
</div>
</div>
`;
// 添加重试按钮事件
container.querySelector('.retry-btn').addEventListener('click', searchDanbooruTags);
// 添加直接打开链接事件
container.querySelector('.direct-link').addEventListener('click', function() {
window.open(`https://danbooru.donmai.us/posts?tags=${this.dataset.query}`, '_blank');
});
}
// 渲染结果
function renderResults(tagsArray, query, container) {
container.innerHTML = '';
if (tagsArray.length === 0) {
container.textContent = '无搜索结果';
return;
}
tagsArray.forEach(tagObject => {
const item = document.createElement('div');
item.className = 'tag-result-item';
// 使用'value'作为dataset.tag,这是实际的英文tag
item.dataset.tag = tagObject.value;
const tagName = document.createElement('span');
tagName.textContent = tagObject.label;
item.appendChild(tagName);
const postCountSpan = document.createElement('span');
postCountSpan.className = 'tag-post-count';
postCountSpan.textContent = formatCount(tagObject.post_count);
item.appendChild(postCountSpan);
item.addEventListener('click', () => toggleSelection(container, item));
container.appendChild(item);
});
}
// 辅助函数
function toggleSelection(container, item) {
container.querySelectorAll('.tag-result-item').forEach(el => {
el.classList.remove('selected');
});
item.classList.add('selected');
}
function formatCount(count) {
if (!count) return '0';
return count > 1000 ? `${(count/1000).toFixed(1)}k` : count;
}
function copySelectedTag() {
const selectedItem = document.querySelector('#tagSearchResults .selected');
if (!selectedItem) {
showToast('请先选择一个标签', 'error'); // 使用Toast提示错误
return;
}
// 从 data-tag 获取原始tag, 并替换下划线为空格
const tagToCopy = selectedItem.dataset.tag.replace(/_/g, ' ');
navigator.clipboard.writeText(tagToCopy).then(() => {
showToast(`标签 "${tagToCopy}" 已复制!`); // 使用Toast提示成功
}).catch(err => {
console.error('复制失败:', err);
showToast('复制失败, 请查看控制台', 'error'); // 使用Toast提示失败
});
}
// ... (后略)
function fillSelectedTag() {
const selectedItem = document.querySelector('#tagSearchResults .selected');
const positivePromptTextarea = document.getElementById('positivePrompt');
if (!selectedItem) {
alert('请先从搜索结果中选择一个标签。');
return;
}
// 从 data-tag 获取原始tag, 并替换下划线为空格
const tagToFill = selectedItem.dataset.tag.replace(/_/g, ' ');
if (positivePromptTextarea.value.trim() !== '' && !positivePromptTextarea.value.endsWith(',')) {
positivePromptTextarea.value += ', ';
}
positivePromptTextarea.value += tagToFill + ',';
}
function updateWorkflowFromSettings() {
const editor = document.getElementById("comfyuiWorkflowEditor");
if (!editor) {
alert("错误:找不到工作流编辑器。");
return;
}
let workflow;
try {
workflow = JSON.parse(editor.value);
} catch (e) {
alert("无法更新,因为编辑器中的JSON格式无效。请先修正。");
return;
}
console.log("[ComfyUI] 开始从设置面板更新工作流JSON...");
const log = [];
// 辅助函数: 优先按标题搜索,失败则按类型搜索
const findNode = (wf, titles, classType) => {
for (const title of titles) {
for (const id in wf) {
if (wf[id]?._meta?.title?.includes(title)) {
log.push(`✅ 按标题 "${title}" 找到节点 #${id} (${wf[id].class_type})`);
return [id];
}
}
}
const ids = [];
for (const id in wf) {
if (wf[id]?.class_type === classType) {
ids.push(id);
}
}
if(ids.length > 0) log.push(`⚠️ 按标题未找到,改用类型 "${classType}" 找到节点 #${ids.join(', ')}`);
else log.push(`❌ 未找到任何匹配 "${titles.join('/')}" 或类型 "${classType}" 的节点`);
return ids;
};
const findNodeByTitle = (title) => {
for (const id in workflow) {
if(workflow[id]?._meta?.title?.includes(title)) return id;
}
return null;
}
// 获取所有动态参数的值 (从UI界面直接获取)
const params = {
width: document.getElementById("width").value,
height: document.getElementById("height").value,
steps: document.getElementById("steps").value,
cfg: document.getElementById("sdCfgScale").value,
model: document.getElementById("comfyuiModel").value,
sampler: document.getElementById("comfyuiSampler").value,
scheduler: document.getElementById("comfyuiScheduler").value,
loras: [
{ name: document.getElementById("comfyuiLora").value, strength: document.getElementById("comfyuiLoraStrength").value },
{ name: document.getElementById("comfyuiLora2").value, strength: document.getElementById("comfyuiLoraStrength2").value },
{ name: document.getElementById("comfyuiLora3").value, strength: document.getElementById("comfyuiLoraStrength3").value },
{ name: document.getElementById("comfyuiLora4").value, strength: document.getElementById("comfyuiLoraStrength4").value }
],
faceDetailer: {
steps: document.getElementById("faceDetailerSteps").value,
guide_size: document.getElementById("faceDetailerGuideSize").value,
max_size: document.getElementById("faceDetailerMaxSize").value,
cfg: document.getElementById("faceDetailerCfg").value,
sampler_name: document.getElementById("faceDetailerSampler").value,
model_name: document.getElementById("faceDetailerModel").value,
scheduler: document.getElementById("faceDetailerScheduler").value
}
};
// 1. 注入分辨率
const resNodeIds = findNode(workflow, ["分辨率", "Latent"], "EmptyLatentImage");
if (resNodeIds.length > 0) {
resNodeIds.forEach(id => {
workflow[id].inputs.width = Number(params.width);
workflow[id].inputs.height = Number(params.height);
});
}
// 2. 注入采样器 (KSampler) 参数
const samplerNodeIds = findNode(workflow, ["采样器", "Sampler"], "KSampler");
if (samplerNodeIds.length > 0) {
samplerNodeIds.forEach(id => {
workflow[id].inputs.steps = Number(params.steps);
workflow[id].inputs.cfg = Number(params.cfg);
workflow[id].inputs.sampler_name = params.sampler;
workflow[id].inputs.scheduler = params.scheduler;
});
}
// 3. 注入主模型
const ckptNodeIds = findNode(workflow, ["主模型", "Checkpoint"], "CheckpointLoaderSimple");
if (ckptNodeIds.length > 0) {
ckptNodeIds.forEach(id => workflow[id].inputs.ckpt_name = params.model);
}
// 4. 注入 LoRA 参数
const loraStackNodeIds = findNode(workflow, ["Lora Loader Stack"], "Lora Loader Stack (rgthree)");
if (loraStackNodeIds.length > 0) {
const loraStackNodeId = loraStackNodeIds[0]; // 通常只有一个,我们操作第一个找到的
// 日志已经在 findNode 函数中生成,这里不再重复
workflow[loraStackNodeId].inputs.lora_01 = params.loras[0].name;
workflow[loraStackNodeId].inputs.strength_01 = parseFloat(params.loras[0].strength);
workflow[loraStackNodeId].inputs.lora_02 = params.loras[1].name;
workflow[loraStackNodeId].inputs.strength_02 = parseFloat(params.loras[1].strength);
workflow[loraStackNodeId].inputs.lora_03 = params.loras[2].name;
workflow[loraStackNodeId].inputs.strength_03 = parseFloat(params.loras[2].strength);
workflow[loraStackNodeId].inputs.lora_04 = params.loras[3].name;
workflow[loraStackNodeId].inputs.strength_04 = parseFloat(params.loras[3].strength);
} else {
log.push("⚠️ 未找到 'Lora Loader Stack' 标题的节点,LoRA参数未注入。");
}
// 5. 注入 FaceDetailer 参数
const faceDetailerNodeIds = findNode(workflow, ["FaceDetailer"], "FaceDetailer");
if (faceDetailerNodeIds.length > 0) {
faceDetailerNodeIds.forEach(id => {
workflow[id].inputs.steps = Number(params.faceDetailer.steps);
workflow[id].inputs.guide_size = Number(params.faceDetailer.guide_size);
workflow[id].inputs.max_size = Number(params.faceDetailer.max_size);
workflow[id].inputs.cfg = Number(params.faceDetailer.cfg);
workflow[id].inputs.sampler_name = params.faceDetailer.sampler_name;
workflow[id].inputs.scheduler = params.faceDetailer.scheduler;
});
}
// 6. 注入脸部修复检测模型 (UltralyticsDetectorProvider)
const detectorNodeIds = findNode(workflow, ["UltralyticsDetectorProvider"], "UltralyticsDetectorProvider");
if (detectorNodeIds.length > 0) {
detectorNodeIds.forEach(id => {
workflow[id].inputs.model_name = params.faceDetailer.model_name;
});
log.push(`✅ 按类型 "UltralyticsDetectorProvider" 找到节点 #${detectorNodeIds.join(', ')} 并更新模型`);
} else {
log.push(`⚠️ 未找到任何 "UltralyticsDetectorProvider" 类型的节点,修脸模型未注入。`);
}
editor.value = JSON.stringify(workflow, null, 2);
console.log("[ComfyUI] 更新日志:\n" + log.join('\n'));
alert("工作流已根据当前动态参数更新!\n这仅用于检查,实际生成时会自动应用已保存的最新设置。"
);
}
function createVibeThumbnail(imageData) {
const container = document.getElementById('naiVibeImageListContainer');
if (!container) return;
const timestamp = Date.now() + Math.random();
const item = document.createElement('div');
item.className = 'vibe-item';
item.dataset.id = timestamp;
let thumbnailUrl, infoExtract, refStrength;
// 根据传入数据类型进行处理
if (imageData.type === 'vibeFile' && imageData.vibeData) {
// 这是 .naiv4vibe 文件
const vibe = imageData.vibeData;
thumbnailUrl = vibe.thumbnail; // 使用文件内置的缩略图
infoExtract = vibe.importInfo?.information_extracted || '0.7';
refStrength = vibe.importInfo?.strength || '0.5';
item.dataset.type = 'vibeFile';
// 将完整的 vibe JSON 数据存储在 DOM 元素上
item.dataset.vibeData = JSON.stringify(vibe);
item.title = `Vibe 文件: ${vibe.name}`;
} else if (imageData.type === 'image') {
// 这是普通图片 (用于v3或代理)
thumbnailUrl = imageData.base64;
infoExtract = imageData.infoExtract || '1.0';
refStrength = imageData.refStrength || '0.5';
item.dataset.type = 'image';
// 将图片的 base64 存储起来
item.dataset.base64 = imageData.base64;
item.title = "普通参考图 (用于 V3 / 代理)";
} else {
console.error("创建Vibe缩略图失败:未知的数据类型", imageData);
return;
}
item.innerHTML = `
<button class="vibe-delete-btn" title="删除图片">×</button>
<img src="${imageData.type === 'vibeFile' ? thumbnailUrl : imageData.base64}" alt="Vibe Transfer Image">
<div class="controls">
<div class="slider-group">
<div class="slider-label">
<span>信息提取度</span>
<span class="slider-value">${parseFloat(infoExtract).toFixed(2)}</span>
</div>
<input type="range" class="info-extract-slider" min="0.01" max=1 step="0.01" value="${infoExtract}">
</div>
<div class="slider-group">
<div class="slider-label">
<span>参考强度</span>
<span class="slider-value">${parseFloat(refStrength).toFixed(2)}</span>
</div>
<input type="range" class="ref-strength-slider" min="0.01" max=1 step="0.01" value="${refStrength}">
</div>
</div>
`;
item.querySelector('.vibe-delete-btn').addEventListener('click', (e) => {
e.target.closest('.vibe-item').remove();
updateVibeStatus();
});
const sliders = item.querySelectorAll('input[type="range"]');
sliders.forEach(slider => {
slider.addEventListener('input', (e) => {
e.target.previousElementSibling.querySelector('.slider-value').textContent = parseFloat(e.target.value).toFixed(2);
});
});
container.appendChild(item);
updateVibeStatus();
}
function updateVibeStatus() {
const container = document.getElementById('naiVibeImageListContainer');
const statusEl = document.getElementById('naiVibeStatus');
if (!container || !statusEl) return;
const count = container.children.length;
statusEl.textContent = `已上传 ${count}/10 张图片。`;
statusEl.className = 'info';
}
function handleVibeImageUpload(event) {
const files = event.target.files;
const statusEl = document.getElementById('naiVibeStatus');
const container = document.getElementById('naiVibeImageListContainer');
if (!files.length || !statusEl || !container) return;
const currentCount = container.children.length;
const limit = 10;
if (currentCount + files.length > limit) {
statusEl.textContent = `超出数量限制!最多上传 ${limit} 张图片,您还可以上传 ${limit - currentCount} 张。`;
statusEl.className = 'error';
return;
}
Array.from(files).forEach(file => {
const reader = new FileReader();
if (file.name.toLowerCase().endsWith('.naiv4vibe') || file.name.toLowerCase().endsWith('.json')) {
// 处理 .naiv4vibe 文件
reader.onload = (e) => {
try {
const vibeData = JSON.parse(e.target.result);
if (vibeData.identifier === "novelai-vibe-transfer") {
createVibeThumbnail({ type: 'vibeFile', vibeData: vibeData });
} else {
alert(`文件 "${file.name}" 不是一个有效的 .naiv4vibe 文件。`);
}
} catch (err) {
console.error("解析 .naiv4vibe 文件失败:", err);
alert(`解析文件 "${file.name}" 失败,请确保它是一个有效的JSON文件。`);
}
};
reader.readAsText(file);
} else if (file.type.startsWith('image/')) {
// 处理普通图片文件
reader.onload = (e) => {
createVibeThumbnail({ type: 'image', base64: e.target.result });
};
reader.readAsDataURL(file);
} else {
alert(`不支持的文件类型: ${file.name}`);
}
});
event.target.value = '';
}
function gmXmlHttpRequestPromise(details) {
return new Promise((resolve, reject) => {
details.timeout = details.timeout || 300000; // 300000毫秒 = 5分钟
details.onload = resolve;
details.onerror = reject;
details.ontimeout = reject; // 明确处理超时事件
GM_xmlhttpRequest(details);
});
}
async function fetchSdOptions(showAlertOnSuccess = true) {
const sdUrl = removeTrailingSlash(document.getElementById('sdUrl').value);
if (!sdUrl) {
if (showAlertOnSuccess) alert('请先输入有效的 SD URL。');
return;
}
const modelsSelect = document.getElementById('sdModel');
const samplersSelect = document.getElementById('samplerName');
const upscalersSelect = document.getElementById('hrUpscaler');
const lorasSelect = document.getElementById('sdLora');
const sdSchedulerSelect = document.getElementById('sdScheduler');
const cnContainer = document.getElementById('controlnet-units-container');
const setLoading = (select) => { if (select) select.innerHTML = '<option>加载中...</option>'; };
[modelsSelect, samplersSelect, upscalersSelect, lorasSelect, sdSchedulerSelect].forEach(setLoading);
if (cnContainer) cnContainer.innerHTML = '<p style="color: var(--text-secondary);">正在加载 ControlNet 数据...</p>';
const results = await Promise.allSettled([
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/sdapi/v1/sd-models` }),
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/sdapi/v1/samplers` }),
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/sdapi/v1/schedulers` }),
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/sdapi/v1/upscalers` }),
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/sdapi/v1/loras` }),
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/controlnet/model_list` }),
gmXmlHttpRequestPromise({ method: "GET", url: `${sdUrl}/controlnet/module_list` })
]);
const [
modelsResult,
samplersResult,
schedulersResult,
upscalersResult,
lorasResult,
cnModelsResult,
cnModulesResult
] = results;
const populateSelect = (select, items, valueField, textField) => {
if (!select) return;
select.innerHTML = '';
items.forEach(item => {
const option = new Option(item[textField], item[valueField]);
select.add(option);
});
};
const setError = (select, savedValueKey) => {
if (select) {
select.innerHTML = `<option value="">刷新失败</option>`;
const savedValue = settings[savedValueKey];
if(savedValue){
select.add(new Option(savedValue, savedValue, true, true));
}
}
};
if (modelsResult.status === 'fulfilled') {
populateSelect(modelsSelect, JSON.parse(modelsResult.value.responseText), 'title', 'title');
modelsSelect.value = settings.sdModel;
} else {
console.error('获取SD模型失败:', modelsResult.reason);
setError(modelsSelect, 'sdModel');
}
if (samplersResult.status === 'fulfilled') {
populateSelect(samplersSelect, JSON.parse(samplersResult.value.responseText), 'name', 'name');
samplersSelect.value = settings.samplerName;
} else {
console.error('获取采样器失败:', samplersResult.reason);
setError(samplersSelect, 'samplerName');
}
if (schedulersResult.status === 'fulfilled') {
populateSelect(sdSchedulerSelect, JSON.parse(schedulersResult.value.responseText), 'name', 'label');
sdSchedulerSelect.value = settings.sdScheduler;
} else {
console.error('获取调度器失败:', schedulersResult.reason);
setError(sdSchedulerSelect, 'sdScheduler');
}
if (upscalersResult.status === 'fulfilled') {
populateSelect(upscalersSelect, JSON.parse(upscalersResult.value.responseText), 'name', 'name');
upscalersSelect.value = settings.hrUpscaler;
} else {
console.error('获取放大算法失败:', upscalersResult.reason);
setError(upscalersSelect, 'hrUpscaler');
}
if (lorasResult.status === 'fulfilled') {
const populateLoras = (select, items) => {
if (!select) return;
select.innerHTML = '<option value="">-- 选择一个LoRA --</option>';
items.forEach(item => select.add(new Option(item.alias, item.name)));
};
populateLoras(lorasSelect, JSON.parse(lorasResult.value.responseText));
} else {
console.error('获取LoRA列表失败:', lorasResult.reason);
if(lorasSelect) lorasSelect.innerHTML = '<option value="">刷新失败</option>';
}
// --- 处理ControlNet ---
if (cnModelsResult.status === 'fulfilled' && cnModulesResult.status === 'fulfilled') {
const cnModels = JSON.parse(cnModelsResult.value.responseText).model_list;
const cnModules = JSON.parse(cnModulesResult.value.responseText).module_list;
// 只把获取到的数据存到全局变量里,不做任何UI操作
window.tempCnData = { models: cnModels, modules: cnModules };
console.log("ControlNet 数据已刷新并存储。");
} else {
console.error('加载 ControlNet 数据失败。可能是插件未安装或API不可用。');
// 清理暂存数据,同样不做UI操作
window.tempCnData = { models: [], modules: [] };
}
const allSucceeded = results.every(r => r.status === 'fulfilled');
if (showAlertOnSuccess) {
if(allSucceeded){
alert('SD 数据列表已成功刷新!');
} else {
alert('部分SD数据刷新失败,请打开浏览器控制台(F12)查看详情。');
}
}
}
async function fetchComfyuiOptions(showAlertOnSuccess = true) {
const comfyUrl = removeTrailingSlash(document.getElementById('comfyuiUrl').value);
if (!comfyUrl) {
if(showAlertOnSuccess) alert('请先输入有效的 ComfyUI URL。');
return;
}
const modelSelect = document.getElementById('comfyuiModel');
const samplerSelect = document.getElementById('comfyuiSampler');
const schedulerSelect = document.getElementById('comfyuiScheduler');
const faceDetailerSamplerSelect = document.getElementById('faceDetailerSampler');
const faceDetailerModelSelect = document.getElementById('faceDetailerModel');
const faceDetailerSchedulerSelect = document.getElementById('faceDetailerScheduler');
const upscaleModelSelect = document.getElementById('comfyuiUpscaleModel');
const ultimateSamplerSelect = document.getElementById('comfyuiUltimateSampler');
const ultimateSchedulerSelect = document.getElementById('comfyuiUltimateScheduler');
const loraSelects = [
document.getElementById('comfyuiLora'), document.getElementById('comfyuiLora2'),
document.getElementById('comfyuiLora3'), document.getElementById('comfyuiLora4')
];
const setLoading = (select) => { if(select) select.innerHTML = '<option>加载中...</option>'; };
setLoading(modelSelect); setLoading(samplerSelect); setLoading(schedulerSelect);
setLoading(faceDetailerSamplerSelect); setLoading(faceDetailerSchedulerSelect);
setLoading(faceDetailerModelSelect);
setLoading(upscaleModelSelect); setLoading(ultimateSamplerSelect); setLoading(ultimateSchedulerSelect);
loraSelects.forEach(setLoading);
try {
const response = await gmXmlHttpRequestPromise({ method: "GET", url: `${comfyUrl}/object_info` });
const objectInfo = JSON.parse(response.responseText);
const populateSelect = (select, data, current) => {
if (!select || !data) return;
select.innerHTML = '';
data.forEach(name => select.add(new Option(name, name)));
select.value = current;
};
const ksamplerInfo = objectInfo['KSampler'];
const ckptInfo = objectInfo['CheckpointLoaderSimple'];
const loraStackInfo = objectInfo['Lora Loader Stack (rgthree)'];
const loraLoaderInfo = objectInfo['LoraLoader'];
const upscaleModelInfo = objectInfo['UpscaleModelLoader'];
const detectorProviderInfo = objectInfo['UltralyticsDetectorProvider'];
let loraList = [];
if(loraStackInfo?.input?.required?.lora_01[0]) {
loraList = loraStackInfo.input.required.lora_01[0].filter(lora => lora !== 'None');
} else if (loraLoaderInfo?.input?.required?.lora_name[0]) {
loraList = loraLoaderInfo.input.required.lora_name[0];
}
const samplerList = ksamplerInfo.input.required.sampler_name[0];
const schedulerList = ksamplerInfo.input.required.scheduler[0];
const upscaleModelList = upscaleModelInfo.input.required.model_name[0];
const detectorModelList = detectorProviderInfo.input.required.model_name[0];
populateSelect(samplerSelect, samplerList, settings.comfyuiSampler);
populateSelect(schedulerSelect, schedulerList, settings.comfyuiScheduler);
populateSelect(faceDetailerSamplerSelect, samplerList, settings.faceDetailerSampler);
populateSelect(faceDetailerSchedulerSelect, schedulerList, settings.faceDetailerScheduler);
populateSelect(upscaleModelSelect, upscaleModelList, settings.comfyuiUpscaleModel);
populateSelect(ultimateSamplerSelect, samplerList, settings.comfyuiUltimateSampler);
populateSelect(ultimateSchedulerSelect, schedulerList, settings.comfyuiUltimateScheduler);
populateSelect(faceDetailerModelSelect, detectorModelList, settings.faceDetailerModel);
populateSelect(modelSelect, ckptInfo.input.required.ckpt_name[0], settings.comfyuiModel);
loraSelects.forEach((select, index) => {
if (!select) return;
const settingKey = index === 0 ? 'comfyuiLora' : `comfyuiLora${index + 1}`;
select.innerHTML = '';
select.add(new Option('None', 'None'));
loraList.forEach(name => select.add(new Option(name, name)));
select.value = settings[settingKey] || 'None';
});
if (showAlertOnSuccess) {
alert('ComfyUI 数据列表已成功刷新!');
}
} catch (error) {
console.error('获取ComfyUI选项失败:', error);
if (showAlertOnSuccess) {
alert('获取ComfyUI选项列表失败。请检查URL是否正确,以及API是否可访问。');
}
const setError = (select) => {
if (select) {
select.innerHTML = '<option value="">刷新失败</option>';
const savedValue = settings[select.id];
if(savedValue){
select.add(new Option(savedValue, savedValue, true, true));
}
}
};
setError(modelSelect); setError(samplerSelect); setError(schedulerSelect);
setError(faceDetailerSamplerSelect); setError(faceDetailerSchedulerSelect);
setError(upscaleModelSelect); setError(ultimateSamplerSelect); setError(ultimateSchedulerSelect);
setError(faceDetailerModelSelect);
loraSelects.forEach(setError);
loraSelects.forEach(select => { if(select) select.innerHTML = '<option value="None">刷新失败</option>'});
}
}
// =========================================================================
// == 在这里,即 createSettingsPanel 函数之前,插入所有新的辅助函数 ==
// =========================================================================
/**
* 填充所有 ControlNet 单元中的模型和预处理器下拉框。
*/
function populateControlNetSelects() {
if (!window.tempCnData) return; // 如果没有获取到数据则不执行
const { models, modules } = window.tempCnData;
// VVVV 这是修正后的选择器 VVVV
// 它会查找在 .controlnet-modal 弹窗内,所有带有 data-index 属性的 .settings-group 元素
const unitElements = document.querySelectorAll('.controlnet-modal .settings-group[data-index]');
unitElements.forEach(unitEl => {
const modelSelect = unitEl.querySelector('.cn-model');
const moduleSelect = unitEl.querySelector('.cn-module');
// 在填充前保存当前选中的值
const currentModel = modelSelect.value;
const currentModule = moduleSelect.value;
// 填充模型
modelSelect.innerHTML = '<option value="None">None</option>';
if(Array.isArray(models)) {
models.forEach(name => modelSelect.add(new Option(name, name)));
}
// 尝试恢复之前的值
modelSelect.value = currentModel;
// 填充预处理器
moduleSelect.innerHTML = '<option value="none">none</option>';
if(Array.isArray(modules)) {
modules.forEach(name => moduleSelect.add(new Option(name, name)));
}
// 尝试恢复之前的值
moduleSelect.value = currentModule;
});
}
/**
* 根据传入的数据创建一个 ControlNet 单元的 HTML 结构。
* @param {object} unit - 单个单元的配置数据。
* @param {number} index - 单元的索引。
* @returns {string} - HTML 字符串。
*/
function createControlNetUnitHTML(unit, index) {
const isEnabled = unit.enabled === true || unit.enabled === 'true';
const hasImage = unit.image && unit.image.startsWith('data:image');
// 将整个单元渲染为一个 settings-group 以保持风格统一
return `
<div class="settings-group" data-index="${index}">
<div class="settings-group-title" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<span><i class="fa-solid fa-puzzle-piece" style="margin-right: 8px; color: var(--accent-primary);"></i>控制单元 ${index + 1}</span>
<div style="display: flex; align-items: center; gap: 15px;">
<label class="switch" title="启用/禁用此单元">
<input type="checkbox" class="cn-enabled" ${isEnabled ? 'checked' : ''}>
<span class="slider"></span>
</label>
<button class="panel-button danger remove-cn-unit" title="移除此单元" style="padding: 4px 8px; font-size: 0.8em;"><i class="fa-solid fa-trash-can"></i></button>
</div>
</div>
<div class="unit-body" style="display: flex; gap: 20px; flex-wrap: wrap;">
<div class="cn-image-area" style="flex: 0 0 160px;">
<label>参考图像</label>
<div class="cn-image-upload-wrapper">
${hasImage ? `<img src="${unit.image}" class="cn-preview-img">` : `<div class="cn-upload-placeholder"><i class="fa-solid fa-image"></i><span>点击上传</span></div>`}
<input type="file" class="cn-image-input" accept="image/*" style="display:none;">
</div>
</div>
<div class="cn-settings-area" style="flex-grow: 1; min-width: 300px;">
<div class="flex-row">
<div><label>模型 (Model): <select class="cn-model"><option value="None">-- 刷新后加载 --</option></select></label></div>
<div><label>预处理器 (Preprocessor): <select class="cn-module"><option value="none">-- 刷新后加载 --</option></select></label></div>
</div>
<div class="flex-row" style="margin-top: 15px;">
<div>
<label>缩放模式 (Resize Mode):
<select class="cn-resize_mode">
<option value="Just Resize">仅调整大小</option>
<option value="Scale to Fit (Inner Fit)" selected>剪裁后缩放</option>
<option value="Envelope (Outer Fit)">缩放后填充空白</option>
</select>
</label>
</div>
<div>
<label>控制模式 (Control Mode):
<select class="cn-control_mode">
<option value="Balanced">平衡 (Balanced)</option>
<option value="My prompt is more important">提示词更重要</option>
<option value="ControlNet is more important">ControlNet更重要</option>
</select>
</label>
</div>
<div>
<label style="display:flex; align-items:center; gap: 10px; margin-top: 20px;">
<label class="switch"><input type="checkbox" class="cn-pixel_perfect"><span class="slider"></span></label>
<span>完美像素</span>
</label>
</div>
</div>
</div>
</div>
</div>`;
}
function updateControlNetUI() {
const container = document.getElementById('controlnet-units-container');
if (!container) return;
// 解析存储的配置
let units = [];
try {
// 从 settings 对象直接获取,此时它应该是数组了
const storedUnits = settings.controlNetUnits;
// 如果 storedUnits 是字符串,则解析;如果是数组,直接使用
if (typeof storedUnits === 'string') {
units = JSON.parse(storedUnits || '[]');
} else if (Array.isArray(storedUnits)) {
units = storedUnits;
}
if (!Array.isArray(units)) { // 最终安全检查
units = [];
}
} catch(e) {
console.error("解析ControlNet单元配置失败", e);
units = [];
}
container.innerHTML = ''; // 清空现有UI
units.forEach((unit, index) => {
container.innerHTML += createControlNetUnitHTML(unit, index);
});
// 重新填充下拉框并恢复已存值
populateControlNetSelects();
units.forEach((unit, index) => {
const unitEl = container.querySelector(`.controlnet-unit[data-index="${index}"]`);
if (unitEl) {
if (unit.model) unitEl.querySelector('.cn-model').value = unit.model;
if (unit.module) unitEl.querySelector('.cn-module').value = unit.module;
if (unit.resize_mode) unitEl.querySelector('.cn-resize_mode').value = unit.resize_mode;
if (unit.control_mode) unitEl.querySelector('.cn-control_mode').value = unit.control_mode;
const ppCheckbox = unitEl.querySelector('.cn-pixel_perfect');
ppCheckbox.checked = (unit.pixel_perfect === true || unit.pixel_perfect === 'true');
}
});
// 检查是否达到上限
const addButton = document.getElementById('add-controlnet-unit');
if (addButton) {
addButton.style.display = units.length >= 4 ? 'none' : 'block';
}
}
function workflow_save(){
stylInput("请输入工作流名称").then((name) => {
if (!name) return;
const editor = document.getElementById("comfyuiWorkflowEditor");
const select = document.getElementById("comfyuiCurrentWorkflow");
try {
// 尝试解析以确保是有效的JSON
const parsed = JSON.parse(editor.value);
// 格式化后保存
const formattedJson = JSON.stringify(parsed, null, 2);
editor.value = formattedJson;
settings.comfyuiWorkflows[name] = formattedJson;
settings.comfyuiCurrentWorkflow = name;
// 更新下拉列表
if (!Array.from(select.options).some(opt => opt.value === name)) {
select.add(new Option(name, name));
}
select.value = name;
GM_setValue("comfyuiWorkflows", JSON.stringify(settings.comfyuiWorkflows));
GM_setValue("comfyuiCurrentWorkflow", name);
alert(`工作流 "${name}" 已保存!`);
} catch (e) {
alert("保存失败!编辑器内容不是有效的JSON格式。");
console.error("JSON parse error:", e);
}
});
}
function workflow_delete(){
stylishConfirm("您确定要删除这个工作流吗?").then((confirmed) => {
if (confirmed) {
const select = document.getElementById("comfyuiCurrentWorkflow");
const nameToDelete = select.value;
if (Object.keys(settings.comfyuiWorkflows).length <= 1) {
alert("无法删除最后一个工作流!");
return;
}
delete settings.comfyuiWorkflows[nameToDelete];
select.remove(select.selectedIndex);
const newCurrent = select.options[0].value;
select.value = newCurrent;
settings.comfyuiCurrentWorkflow = newCurrent;
workflow_change(); // 更新编辑器内容
GM_setValue("comfyuiWorkflows", JSON.stringify(settings.comfyuiWorkflows));
GM_setValue("comfyuiCurrentWorkflow", newCurrent);
alert(`工作流 "${nameToDelete}" 已删除。`);
}
});
}
function workflow_change(){
const select = document.getElementById("comfyuiCurrentWorkflow");
const editor = document.getElementById("comfyuiWorkflowEditor");
const workflowName = select.value;
settings.comfyuiCurrentWorkflow = workflowName;
editor.value = settings.comfyuiWorkflows[workflowName] || "{}";
}
function tishici_save(){
stylInput("请输入配置名称").then((result) => {
if (result) {
const negativePrompt = document.getElementById("negativePrompt").value;
const positivePrompt = document.getElementById("positivePrompt").value;
const selectElement = document.getElementById("yusheid");
if(!settings.yushe.hasOwnProperty(result)){
let newOption = new Option(result, result);
selectElement.add(newOption);
}
selectElement.value=result;
settings.yusheid=result;
settings.yushe[result]={"positivePrompt":positivePrompt,"negativePrompt":negativePrompt};
GM_setValue("yushe",JSON.stringify(settings.yushe));
GM_setValue("yusheid",settings.yusheid);
}
});
}
function tishici_delete(){
stylishConfirm("是否确定删除").then((result) => {
if (result) {
const selectElement = document.getElementById("yusheid");
if (selectElement.value === "默认") return alert("默认配置不能删除!");
Reflect.deleteProperty(settings.yushe, selectElement.value);
selectElement.remove(selectElement.selectedIndex);
selectElement.value="默认";
settings.yusheid="默认";
tishici_change();
GM_setValue("yusheid",settings.yusheid);
GM_setValue("yushe",JSON.stringify(settings.yushe));
}
});
}
function tishici_change(){
const negativePrompt = document.getElementById("negativePrompt");
const positivePrompt = document.getElementById("positivePrompt");
const selectElement = document.getElementById("yusheid");
settings.yusheid=selectElement.value;
const currentYushe = settings.yushe[settings.yusheid] || {"positivePrompt": '', "negativePrompt": ''};
negativePrompt.value=currentYushe.negativePrompt;
positivePrompt.value=currentYushe.positivePrompt;
}
function size_change(){
const widthInput = document.getElementById("width");
const heightInput = document.getElementById("height");
const selectElement = document.getElementById("size");
const selectedValue = selectElement.value;
let newWidth = widthInput.value;
let newHeight = heightInput.value;
if (selectedValue === 'Custom') {
} else if (selectedValue.includes('x')) {
const [w, h] = selectedValue.split('x');
widthInput.value = w;
heightInput.value = h;
} else {
switch (selectedValue) {
case '竖图':
widthInput.value = '832';
heightInput.value = '1216';
break;
case '横图':
widthInput.value = '1216';
heightInput.value = '832';
break;
case '方图':
widthInput.value = '1024';
heightInput.value = '1024';
break;
}
}
// 实时更新内存中的 settings 对象
settings.size = selectedValue;
settings.width = widthInput.value;
settings.height = heightInput.value;
console.log('实时更新分辨率设置为:', {size: settings.size, width: settings.width, height: settings.height});
}
function showToast(message, type = 'success', duration = 2500) {
const panel = document.getElementById('settings-panel');
if (!panel) return; // 如果面板不存在,则不执行任何操作
const toast = document.createElement('div');
toast.className = `toast-notification ${type}`; // 应用基础和特定类型的样式
toast.textContent = message;
panel.appendChild(toast);
// 短暂延迟后添加入场动画,确保过渡效果生效
setTimeout(() => {
toast.classList.add('visible');
}, 20);
// 在指定时间后,添加离场动画
setTimeout(() => {
toast.classList.remove('visible');
}, duration);
// 在动画结束后,从 DOM 中移除元素,避免 clutter
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, duration + 500); // 持续时间 + CSS过渡时间
}
function stylishConfirm(message) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 10001;';
const confirmBox = document.createElement('div');
confirmBox.style.cssText = 'position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); background-color: #1e1e2e; color: #c0caf5; padding: 25px; border-radius: 8px; box-shadow: 0 5px 20px rgba(0,0,0,0.4); z-index: 10002; text-align: center; border: 1px solid #414868;';
confirmBox.innerHTML = `<p style="margin: 0 0 20px; font-size: 1.1em; white-space: pre-wrap;">${message}</p>
<div style="display: flex; gap: 10px; justify-content: center;">
<button id="cancelBtn" class="panel-button">取消</button>
<button id="confirmBtn" class="panel-button primary">确定</button>
</div>`;
document.body.appendChild(overlay);
document.body.appendChild(confirmBox);
const close = (value) => { document.body.removeChild(overlay); document.body.removeChild(confirmBox); resolve(value); };
confirmBox.querySelector('#cancelBtn').addEventListener('click', () => close(false));
confirmBox.querySelector('#confirmBtn').addEventListener('click', () => close(true));
});
}
function stylInput(message) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.7); z-index: 10001;';
const confirmBox = document.createElement('div');
confirmBox.style.cssText = 'position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); background-color: #1e1e2e; color: #c0caf5; padding: 25px; border-radius: 8px; box-shadow: 0 5px 20px rgba(0,0,0,0.4); z-index: 10002; border: 1px solid #414868; min-width:300px;';
confirmBox.innerHTML = `<p style="margin: 0 0 15px;">${message}</p>`;
const input = document.createElement('input');
input.type = 'text';
input.style.cssText = 'width: 100%; background-color: #2a2e3e; color: #c0caf5; border: 1px solid #414868; border-radius: 5px; padding: 8px 10px; box-sizing: border-box;';
input.onkeydown = (e) => { if (e.key === 'Enter') confirmBtn.click(); };
confirmBox.appendChild(input);
const btnContainer = document.createElement('div');
btnContainer.style.cssText = 'display:flex; justify-content: flex-end; gap: 10px; margin-top: 20px;';
const cancelBtn = document.createElement('button'); cancelBtn.textContent = '取消'; cancelBtn.className = 'panel-button';
const confirmBtn = document.createElement('button'); confirmBtn.textContent = '确定'; confirmBtn.className = 'panel-button primary';
btnContainer.append(cancelBtn, confirmBtn);
confirmBox.appendChild(btnContainer);
document.body.append(overlay, confirmBox);
const close = (value) => { document.body.removeChild(overlay); document.body.removeChild(confirmBox); resolve(value); };
cancelBtn.addEventListener('click', () => close(false));
confirmBtn.addEventListener('click', () => close(input.value));
input.focus();
});
}
async function clearCache() {
if (await stylishConfirm("您确定要清空所有图片缓存吗?此操作不可逆。")) {
try {
// 1. 清空主图片存储区
await StoreClear();
// 2. 重置并保存空的映射表
locationToImageIdMap = {};
await Storereadwrite({ id: 'locationMap', data: {} });
// 3. 重置并保存全局ID计数器
globalImageCounter = 0;
await GM_setValue('globalImageCounter', 0);
// 4. 清理UI
initCacheViewer(); // 这会更新UI显示为空
alert("已清除所有图片缓存。");
} catch(error) {
console.error("清空缓存失败:", error);
alert("清空缓存失败,请查看控制台。");
}
}
}
function removeTrailingSlash(str) {
if (typeof str !== 'string') return '';
return str.endsWith('/') ? str.slice(0, -1) : str;
}
async function resetCurrentModeSettings() {
const currentMode = settings.currentMode;
const modeName = {sd: "Stable Diffusion", nai: "NovelAI", comfyui: "ComfyUI"}[currentMode];
if (!(await stylishConfirm(`您确定要重置 ${modeName} 模式的专属设置吗?\n(例如API地址、模型等,不包括通用尺寸、提示词预设等)\n此操作无法撤销。`))) {
return;
}
const keysToResetByMode = {
sd: [
'sdUrl', 'samplerName', 'sdScheduler', 'sdModel', 'enableHr', 'hrScale',
'controlNetEnabled',
'controlNetUnits',
'hrDenoisingStrength', 'hrUpscaler', 'hrSecondPassSteps', 'adetailerEnabled',
'adModel', 'adDenoisingStrength', 'adMaskBlur', 'adInpaintPadding', 'sdCfgScale', 'seed', 'restoreFaces'
],
nai: [
'naiApiUrl', 'naiToken', 'naiModel', 'naiSampler', 'naiScale', 'naiCfg',
'naiNoiseSchedule', 'naiVibeTransferEnabled', 'naiVibeTransferImages'
],
comfyui: [
'comfyuiUrl', 'comfyuiWorkflows', 'comfyuiCurrentWorkflow', 'comfyuiModel',
'comfyuiSampler', 'comfyuiScheduler', 'comfyuiLora', 'comfyuiLoraStrength',
'comfyuiLora2', 'comfyuiLoraStrength2', 'comfyuiLora3', 'comfyuiLoraStrength3',
'comfyuiLora4', 'comfyuiLoraStrength4', 'faceDetailerGuideSize', 'faceDetailerMaxSize','comfyuiUseCustomWorkflowOnly',
'faceDetailerSteps', 'sdCfgScale', 'faceDetailerCfg', 'faceDetailerSampler', 'faceDetailerScheduler','faceDetailerModel',
'comfyuiUpscaleModel', 'comfyuiUltimateUpscaleBy', 'comfyuiUltimateSteps',
'comfyuiUltimateCfg', 'comfyuiUltimateSampler', 'comfyuiUltimateScheduler',
'comfyuiUltimateDenoise', 'comfyuiUltimateModeType'
]
};
const keysToReset = keysToResetByMode[currentMode];
if (!keysToReset) {
alert("错误:未知的当前模式。");
return;
}
for (const key of keysToReset) {
if (defaultSettings.hasOwnProperty(key)) {
const defaultValue = defaultSettings[key];
settings[key] = JSON.parse(JSON.stringify(defaultValue));
if (typeof defaultValue === 'object' && defaultValue !== null) {
GM_setValue(key, JSON.stringify(defaultValue));
} else {
GM_setValue(key, defaultValue);
}
console.log(`[Reset] Setting '${key}' restored to default.`);
}
}
alert(`${modeName} 模式的设置已重置为默认值。\n请关闭并重新打开设置面板以查看更改。`);
hideSettingsPanel();
}
function saveSettings() {
if (settings.currentMode === 'nai') {
const vibeContainer = document.getElementById('naiVibeImageListContainer');
const vibeImagesData = [];
if (vibeContainer) {
const items = vibeContainer.querySelectorAll('.vibe-item');
items.forEach(item => {
const type = item.dataset.type;
const infoExtract = item.querySelector('.info-extract-slider').value;
const refStrength = item.querySelector('.ref-strength-slider').value;
if (type === 'vibeFile' && item.dataset.vibeData) {
const vibeData = JSON.parse(item.dataset.vibeData);
// 更新导入信息以反映滑块的当前值
vibeData.importInfo.information_extracted = infoExtract;
vibeData.importInfo.strength = refStrength;
vibeImagesData.push({
type: 'vibeFile',
vibeData: vibeData,
// 也保存滑块值,方便读取
infoExtract: infoExtract,
refStrength: refStrength
});
} else if (type === 'image' && item.dataset.base64) {
vibeImagesData.push({
type: 'image',
base64: item.dataset.base64,
infoExtract: infoExtract,
refStrength: refStrength
});
}
});
}
settings.naiVibeTransferImages = vibeImagesData;
GM_setValue('naiVibeTransferImages', JSON.stringify(vibeImagesData));
}
// --- 保存统一的提示词 ---
const positivePromptValue = document.getElementById('positivePrompt').value;
if (settings.currentMode === 'sd' || settings.currentMode === 'comfyui') {
settings.fixedPrompt = positivePromptValue;
GM_setValue('fixedPrompt', positivePromptValue);
} else { // nai
settings.naiPositivePrompt = positivePromptValue;
GM_setValue('naiPositivePrompt', positivePromptValue);
}
settings.negativePrompt = document.getElementById('negativePrompt').value;
settings.steps = document.getElementById('steps').value;
GM_setValue('negativePrompt', settings.negativePrompt);
GM_setValue('steps', settings.steps);
// **重要**:保存ComfyUI工作流编辑器中的内容到settings对象
if (settings.currentMode === 'comfyui') {
const editor = document.getElementById('comfyuiWorkflowEditor');
const workflowName = settings.comfyuiCurrentWorkflow;
if(editor && workflowName) {
try {
const parsed = JSON.parse(editor.value);
settings.comfyuiWorkflows[workflowName] = JSON.stringify(parsed, null, 2);
} catch(e) {
console.error("无法保存工作流,不是有效的JSON:", e);
alert("工作流编辑器中的内容不是有效的JSON,该工作流的更改将不会被保存。");
}
}
}
const settingsToSkip = new Set([
'naiVibeTransferImages', 'fixedPrompt', 'naiPositivePrompt', 'negativePrompt', 'steps'
]);
for (const key of Object.keys(defaultSettings)) {
if (settingsToSkip.has(key)) continue;
// 对于对象类型(如yushe, comfyuiWorkflows),直接从内存中的settings对象取值并保存
if (typeof defaultSettings[key] === 'object' && defaultSettings[key] !== null) {
GM_setValue(key, JSON.stringify(settings[key]));
continue;
}
const element = document.getElementById(key);
if (element) {
let valueToSave;
if (element.type === 'checkbox') {
valueToSave = element.checked ? 'true' : 'false';
} else {
valueToSave = element.value;
}
settings[key] = valueToSave;
if(key=="sdUrl" || key == "naiApiUrl" || key == "comfyuiUrl") settings[key] = removeTrailingSlash(settings[key]);
if((key=="startTag"||key=="endTag") && element.value !== GM_getValue(key, '')) sendGenerateTagsResponse();
GM_setValue(key, settings[key]);
}
}
console.log('设置已保存');
hideSettingsPanel();
}
// 【新增功能】在您的脚本中添加这个新函数
/**
* 使用已保存的单个值来填充下拉选择框。
* 这避免了在面板加载时需要进行网络请求。
* @param {string} selectId - <select> 元素的ID。
* @param {string} savedValue - 从 settings 中读取的已保存的值。
* @param {string} [placeholder='-- 点击刷新加载 --'] - 如果没有保存值时显示的文本。
* @param {string} [placeholderValue=''] - 如果没有保存值时选项的value。
*/
function populateSelectWithSavedValue(selectId, savedValue, placeholder = '-- 点击刷新加载 --', placeholderValue = '') {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = ''; // 清空所有现有选项
if (savedValue && savedValue !== '' && savedValue !== 'None') {
// 如果有一个有效值被保存了,就创建并选中这个选项
const option = new Option(savedValue, savedValue, true, true); // text, value, defaultSelected, selected
select.add(option);
} else {
// 否则,显示占位符文本
const option = new Option(placeholder, placeholderValue);
select.add(option);
}
}
function hideSettingsPanel() {
let panel = document.getElementById('settings-panel');
if(panel) panel.classList.remove('visible');
}
function closeSettings() { hideSettingsPanel(); }
function showSettingsPanel() {
let panel = document.getElementById('settings-panel');
if (panel) panel.remove();
panel = createSettingsPanel();
const settingsToShow = new Set(Object.keys(settings));
settingsToShow.delete('naiVibeTransferImages');
settingsToShow.delete('yushe');
settingsToShow.delete('comfyuiWorkflows');
settingsToShow.delete('fixedPrompt');
settingsToShow.delete('naiPositivePrompt');
for(const key of settingsToShow) {
const el = panel.querySelector(`#${key}`);
if (el) {
if (el.type === 'checkbox') el.checked = (settings[key] === 'true' || settings[key] === true);
else el.value = settings[key];
}
}
// 智能处理分辨率下拉框
const sizeDropdown = panel.querySelector('#size');
const widthInput = panel.querySelector('#width');
const heightInput = panel.querySelector('#height');
if (sizeDropdown && widthInput && heightInput) {
// 步骤1: 总是先根据 settings 恢复准确的宽高数值
widthInput.value = settings.width;
heightInput.value = settings.height;
// 步骤2: 智能判断并设置下拉框的显示
let determinedSize = "Custom"; // 默认为自定义
const currentSizeValue = `${settings.width}x${settings.height}`;
if (settings.currentMode === 'nai') {
if (currentSizeValue === '832x1216') determinedSize = '竖图';
else if (currentSizeValue === '1216x832') determinedSize = '横图';
else if (currentSizeValue === '1024x1024') determinedSize = '方图';
}
// 如果不是NAI模式的特殊值,或者当前不是NAI模式,则尝试匹配通用值
if (determinedSize === 'Custom') {
const generalOption = Array.from(sizeDropdown.options).find(opt => opt.value === currentSizeValue);
if (generalOption) {
determinedSize = generalOption.value;
}
}
// 如果settings中保存的值(比如竖图)本身就存在于选项中,则优先使用它
if (Array.from(sizeDropdown.options).some(opt => opt.value === settings.size)) {
sizeDropdown.value = settings.size;
} else {
sizeDropdown.value = determinedSize;
}
// 步骤3: (可选但推荐) 为 width 和 height 输入框添加监听,如果手动修改,则自动将下拉框设为 "Custom"
const syncToCustom = () => {
sizeDropdown.value = "Custom";
settings.size = "Custom";
settings.width = widthInput.value;
settings.height = heightInput.value;
};
widthInput.removeEventListener('input', syncToCustom); // 防止重复绑定
heightInput.removeEventListener('input', syncToCustom); // 防止重复绑定
widthInput.addEventListener('input', syncToCustom);
heightInput.addEventListener('input', syncToCustom);
}
const statusLabel = panel.querySelector('#script-status-label');
if(statusLabel) {
const isEnabled = settings.scriptEnabled === true || settings.scriptEnabled === 'true';
statusLabel.textContent = isEnabled ? '脚本已启用' : '脚本已禁用';
statusLabel.style.color = isEnabled ? 'var(--accent-green)' : 'var(--accent-secondary)';
}
panel.classList.add('visible');
// -- 初始化缓存查看器 --
const refreshBtn = panel.querySelector('#refresh-cache-view');
if (refreshBtn) {
refreshBtn.addEventListener('click', initCacheViewer);
}
const downloadPageBtn = panel.querySelector('#download-cache-page');
if (downloadPageBtn) {
downloadPageBtn.addEventListener('click', async () => {
const totalItems = sortedCache.length;
if (totalItems === 0) {
showToast('缓存为空,无法下载。', 'error');
return;
}
const startIndex = (currentCachePage - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
const pageItems = sortedCache.slice(startIndex, endIndex);
await downloadImagesAsZip(pageItems, `sillytavern_cache_page_${currentCachePage}.zip`);
});
}
const downloadAllBtn = panel.querySelector('#download-cache-all');
if (downloadAllBtn) {
downloadAllBtn.addEventListener('click', async () => {
if (sortedCache.length === 0) {
showToast('缓存为空,无法下载。', 'error');
return;
}
// 直接使用完整的 sortedCache 数组下载所有图片
await downloadImagesAsZip(sortedCache, `sillytavern_cache_all.zip`);
});
}
// 打开面板时自动加载第一页
initCacheViewer();
if(settings.currentMode === 'sd') {
// 【修改点】不再自动获取数据,而是用已保存的值填充下拉框
populateSelectWithSavedValue('sdModel', settings.sdModel);
populateSelectWithSavedValue('samplerName', settings.samplerName);
populateSelectWithSavedValue('sdScheduler', settings.sdScheduler);
populateSelectWithSavedValue('hrUpscaler', settings.hrUpscaler);
// LoRA 选择框初始为空,提示用户刷新
populateSelectWithSavedValue('sdLora', '', '-- 点击刷新加载 --');
const openCnBtn = panel.querySelector('#open-controlnet-modal-btn');
if (openCnBtn) {
openCnBtn.addEventListener('click', openControlNetModal);
}
} else if (settings.currentMode === 'comfyui') {
populateSelectWithSavedValue('comfyuiModel', settings.comfyuiModel);
populateSelectWithSavedValue('comfyuiSampler', settings.comfyuiSampler);
populateSelectWithSavedValue('comfyuiScheduler', settings.comfyuiScheduler);
populateSelectWithSavedValue('faceDetailerSampler', settings.faceDetailerSampler);
populateSelectWithSavedValue('faceDetailerScheduler', settings.faceDetailerScheduler);
populateSelectWithSavedValue('faceDetailerModel', settings.faceDetailerModel);
populateSelectWithSavedValue('comfyuiUpscaleModel', settings.comfyuiUpscaleModel);
populateSelectWithSavedValue('comfyuiUltimateSampler', settings.comfyuiUltimateSampler);
populateSelectWithSavedValue('comfyuiUltimateScheduler', settings.comfyuiUltimateScheduler);
populateSelectWithSavedValue('comfyuiLora', settings.comfyuiLora, 'None', 'None');
populateSelectWithSavedValue('comfyuiLora2', settings.comfyuiLora2, 'None', 'None');
populateSelectWithSavedValue('comfyuiLora3', settings.comfyuiLora3, 'None', 'None');
populateSelectWithSavedValue('comfyuiLora4', settings.comfyuiLora4, 'None', 'None');
} else {
const naiChannelSelect = document.getElementById('naiChannel');
const naiApiUrlInput = document.getElementById('naiApiUrl');
if (naiChannelSelect && naiApiUrlInput) {
// 保存用户手动修改的 URL
let userModifiedUrls = GM_getValue('naiUserModifiedUrls', {});
// 切换渠道时自动填充默认值
const updateUrlByChannel = () => {
const channel = naiChannelSelect.value;
const officialDefault = naiApiUrlInput.dataset.defaultOfficial;
const proxyDefault = naiApiUrlInput.dataset.defaultProxy;
const savedUrl = userModifiedUrls[channel];
if (savedUrl) {
naiApiUrlInput.value = savedUrl;
} else {
naiApiUrlInput.value = channel === 'official' ? officialDefault : proxyDefault;
}
};
// 监听渠道切换
naiChannelSelect.addEventListener('change', updateUrlByChannel);
// 监听用户手动修改保存
naiApiUrlInput.addEventListener('change', () => {
const channel = naiChannelSelect.value;
userModifiedUrls[channel] = naiApiUrlInput.value;
GM_setValue('naiUserModifiedUrls', userModifiedUrls);
});
// 初始化时触发一次
updateUrlByChannel();
}
if (settings.naiVibeTransferImages && Array.isArray(settings.naiVibeTransferImages)) {
// 根据保存的类型来重新创建缩略图
settings.naiVibeTransferImages.forEach(imgData => {
// 旧数据兼容:如果没有 type,则认为是 image
if (!imgData.type || imgData.type === 'image') {
createVibeThumbnail({
type: 'image',
base64: imgData.base64,
infoExtract: imgData.infoExtract,
refStrength: imgData.refStrength
});
} else if (imgData.type === 'vibeFile') {
createVibeThumbnail(imgData); // vibeFile 数据结构是完整的,直接传递
}
});
}
}
}
function hideSettingsPanel() {
let panel = document.getElementById('settings-panel');
if(panel) panel.classList.remove('visible');
}
function unzipFile(arrayBuffer) {
return new Promise((resolve, reject) => {
JSZip.loadAsync(arrayBuffer)
.then(function(zip) {
// 遍历 ZIP 文件中的所有文件
const imageFile = Object.values(zip.files).find(f => !f.dir && (f.name.endsWith('.png') || f.name.endsWith('.jpg')));
if (imageFile) {
imageFile.async('base64').then(resolve).catch(reject);
} else {
reject(new Error("ZIP文件中未找到图片。"));
}
}).catch(reject);
});
}
function isElementHidden(id) { const el = document.getElementById(id); return !el || window.getComputedStyle(el).display === 'none'; }
function checkSendBuClass() { return isElementHidden('send_but') || !isElementHidden('mes_stop'); }
function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function parsePromptStringWithCoordinates(promptString) {
const result = {
'Scene Composition': '', 'Character 1 Prompt': '', 'Character 1 UC': '',
'Character 2 Prompt': '', 'Character 2 UC': '', 'Character 3 Prompt': '', 'Character 3 UC': '',
'Character 4 Prompt': '', 'Character 4 UC': '', 'Character 1 centers': '',
'Character 2 centers': '', 'Character 3 centers': '', 'Character 4 centers': '',
'Character 1 coordinates': {}, 'Character 2 coordinates': {}, 'Character 3 coordinates': {}, 'Character 4 coordinates': {}
};
const sceneMatch = promptString.match(/Scene Composition:([^;]+);/);
if (sceneMatch) result['Scene Composition'] = sceneMatch[1].trim();
for (let i = 1; i <= 4; i++) {
const promptMatch = promptString.match(new RegExp(`Character ${i} Prompt:([^;|]+)(?:\\|centers:([^;]+))?;`));
if (promptMatch) {
result[`Character ${i} Prompt`] = promptMatch[1].trim();
if (promptMatch[2]) {
result[`Character ${i} centers`] = promptMatch[2].trim();
result[`Character ${i} coordinates`] = centersToCoordinates(promptMatch[2].trim());
} else {
result[`Character ${i} coordinates`] = {};
}
}
const ucMatch = promptString.match(new RegExp(`Character ${i} UC:([^;]+);`));
if (ucMatch) result[`Character ${i} UC`] = ucMatch[1].trim();
}
return result;
}
function centersToCoordinates(centers) {
if (!centers) return {};
const match = centers.match(/([a-e])([1-5])/i);
if (!match) return {};
const column = match[1].toLowerCase();
const row = parseInt(match[2]);
const columnMap = { 'a': 0.1, 'b': 0.3, 'c': 0.5, 'd': 0.7, 'e': 0.9 };
const rowMap = { 1: 0.1, 2: 0.3, 3: 0.5, 4: 0.7, 5: 0.9 };
return { x: columnMap[column] || 0.5, y: rowMap[row] || 0.5 };
}
async function sd(button1,Xwidth=null,Xheight=null){
if(!button1.id.includes("image")) return;
button1.textContent="加载中";
const locationHash = button1.dataset.locationHash; // 获取位置哈希
const url = removeTrailingSlash(settings.sdUrl);
// =================================================================
// =================================================================
// try {
// await gmXmlHttpRequestPromise({
// method: "POST", url: `${url}/sdapi/v1/options`,
// headers: { "Content-Type": "application/json" }, data: JSON.stringify({ "sd_model_checkpoint": settings.sdModel })
// });
// } catch (e) {
// button1.textContent="生成图片";
// alert(`切换SD模型失败! 请检查模型名称 "${settings.sdModel}"。`);
// console.error("模型切换失败:", e); return;
// }
// =================================================================
// =================================================================
let prompt = await zhengmian(settings.fixedPrompt,button1.dataset.link,settings.AQT);
let negative_prompt = await fumian(settings.negativePrompt,settings.UCP);
const payload = {
prompt, negative_prompt,
steps: Number(settings.steps),
sampler_name: settings.samplerName,
scheduler: settings.sdScheduler, // 应用选择的调度器
width: Xwidth ? Number(Xwidth) : Number(settings.width),
height: Xheight ? Number(Xheight) : Number(settings.height),
restore_faces: settings.restoreFaces === 'true',
cfg_scale: Number(settings.sdCfgScale),
seed: settings.seed === 0 || settings.seed === "" ? -1 : Number(settings.seed)
};
// Hires. fix 参数处理
if (settings.enableHr === 'true') {
Object.assign(payload, {
enable_hr: true,
hr_scale: parseFloat(settings.hrScale),
denoising_strength: parseFloat(settings.hrDenoisingStrength),
hr_upscaler: settings.hrUpscaler,
hr_second_pass_steps: parseInt(settings.hrSecondPassSteps, 10)
});
}
// 初始化 alwayson_scripts,用于合并 ADetailer 和 ControlNet
payload.alwayson_scripts = {};
// ADetailer 参数处理
if (settings.adetailerEnabled === 'true') {
payload.alwayson_scripts["ADetailer"] = {
"args": [
true,
false,
{
ad_model: settings.adModel,
ad_denoising_strength: parseFloat(settings.adDenoisingStrength),
ad_mask_blur: parseInt(settings.adMaskBlur, 10),
ad_inpaint_only_masked_padding: parseInt(settings.adInpaintPadding, 10)
}
]
};
}
// ControlNet 参数处理
if (settings.controlNetEnabled === 'true') {
try {
const cn_units = settings.controlNetUnits || [];
const active_units = cn_units.filter(unit => unit.enabled && unit.image);
if (active_units.length > 0) {
payload.alwayson_scripts["controlnet"] = {
"args": active_units.map(unit => ({
"enabled": unit.enabled,
"image": unit.image,
"module": unit.module,
"model": unit.model,
"weight": unit.weight || 1.0,
"resize_mode": unit.resize_mode,
"lowvram": unit.lowvram || false,
"processor_res": parseInt(unit.processor_res || 512, 10),
"threshold_a": parseInt(unit.threshold_a || 64, 10),
"threshold_b": parseInt(unit.threshold_b || 64, 10),
"guidance_start": parseFloat(unit.guidance_start || 0.0),
"guidance_end": parseFloat(unit.guidance_end || 1.0),
"control_mode": unit.control_mode,
"pixel_perfect": unit.pixel_perfect || false
}))
};
}
} catch(e) {
console.error("解析或应用ControlNet配置时出错:", e);
}
}
// 如果没有任何 alwayson_scripts 启用,则从 payload 中删除该键,以保持请求整洁
if (Object.keys(payload.alwayson_scripts).length === 0) {
delete payload.alwayson_scripts;
}
console.log("SD 生成参数:", payload);
if(!xiancheng) await sleep(1000);
xiancheng=false;
try {
const newImageId = await getNextImageId(); // 获取新的唯一ID
const response = await gmXmlHttpRequestPromise({
method: "POST", url: `${url}/sdapi/v1/txt2img`, data: JSON.stringify(payload),
headers: { "Content-Type": "application/json" },
timeout: 300000 // ← 放在这里
});
const r = JSON.parse(response.responseText);
const dataURL = "data:image/png;base64," + r.images[0];
await setItemImg(newImageId, dataURL, locationHash); // 使用新ID和位置哈希保存图片;
if(Xwidth && Xheight) return dataURL;
let imgSpan = button1.ownerDocument.getElementById(button1.name);
const img = document.createElement('img');
img.src = dataURL;
img.alt = "Generated Image"; img.dataset.name = imgSpan.dataset.name;
// 【核心修改】当处于兼容模式时,应用自适应样式
if (settings.displayMode === '兼容前端') {
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.minWidth = '50px';
img.style.minHeight = '50px';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.style.borderRadius = "5px";
}
if(settings.dbclike=="true"){
imgSpan.style.textAlign = 'center';
button1.style.cssText = 'width: 0; height: 0; overflow: hidden; padding: 0; border: none;';
}
imgSpan.replaceChildren(img);
} catch (error) {
alert("图片生成请求失败. 错误: " + (error.responseText || error.statusText || '未知网络错误'));
} finally {
xiancheng=true;
if (button1.isConnected) {
button1.textContent="生成图片";
}
}
}
function parsePromptStringWithCoordinates(promptString) {
const result = {
'Scene Composition': '',
'Character 1 Prompt': '',
'Character 1 UC': '',
'Character 2 Prompt': '',
'Character 2 UC': '',
'Character 3 Prompt': '',
'Character 3 UC': '',
'Character 4 Prompt': '',
'Character 4 UC': '',
'Character 1 centers': '',
'Character 2 centers': '',
'Character 3 centers': '',
'Character 4 centers': '',
'Character 1 coordinates': {},
'Character 2 coordinates': {},
'Character 3 coordinates': {},
'Character 4 coordinates': {}
};
const sceneMatch = promptString.match(/Scene Composition:([^;]+);/);
if (sceneMatch) { result['Scene Composition'] = sceneMatch[1].trim(); }
for (let i = 1; i <= 4; i++) {
const promptMatch = promptString.match(new RegExp(`Character ${i} Prompt:([^;|]+)(?:\\|centers:([^;]+))?;`));
if (promptMatch) {
result[`Character ${i} Prompt`] = promptMatch[1].trim();
if (promptMatch[2]) {
result[`Character ${i} centers`] = promptMatch[2].trim();
result[`Character ${i} coordinates`] = centersToCoordinates(promptMatch[2].trim());
} else {
result[`Character ${i} coordinates`] = {}
}
}
const ucMatch = promptString.match(new RegExp(`Character ${i} UC:([^;]+);`));
if (ucMatch) { result[`Character ${i} UC`] = ucMatch[1].trim(); }
}
return result;
}
function centersToCoordinates(centers) {
if (!centers) return {};
const match = centers.match(/([a-e])([1-5])/i);
if (!match) return {};
const column = match[1].toLowerCase();
const row = parseInt(match[2]);
const columnMap = {'a': 0.1,'b': 0.3,'c': 0.5,'d': 0.7,'e': 0.9 };
const rowMap = { 1: 0.1, 2: 0.3, 3: 0.5, 4: 0.7, 5: 0.9 };
return { x: columnMap[column] || 0.5, y: rowMap[row] || 0.5 };
}
async function naiGenerate(button1) {
if(!button1.id.includes("image")) return;
button1.textContent="加载中";
const locationHash = button1.dataset.locationHash; // 获取位置哈希
if (!xiancheng) await sleep(1000);
xiancheng = false;
// --- 公共参数准备 ---
const dynamic_prompt_raw = button1.dataset.link;
const negativePrompt = await fumian(settings.negativePrompt, settings.UCP);
const naiSteps = Number(settings.steps);
try {
let dataURL;
const newImageId = await getNextImageId(); // 获取新的唯一ID
if (settings.naiChannel === 'official') {
console.log("NAI Official Request");
// --- 官方渠道公共参数 ---
let base_prompt = "";
let payload;
// 检查是否为 V4 或更高版本模型
const isV4PlusModel = settings.naiModel.startsWith('nai-diffusion-4');
const useCoords = settings.AI_use_coords === 'true';
const useVibeTransfer = settings.naiVibeTransferEnabled === 'true' && settings.naiVibeTransferImages && settings.naiVibeTransferImages.length > 0;
if (isV4PlusModel) {
// --- V4及以上模型逻辑 ---
console.log("NAI v4+ Model Detected. Building new payload structure.");
let preset_data = {
"params_version": 3,
"width": Number(settings.width),
"height": Number(settings.height),
"scale": Number(settings.naiScale),
"sampler": settings.naiSampler,
"steps": naiSteps,
"n_samples": 1,
"ucPreset": 0, // V4中通常不使用ucPreset,设为0或移除
"qualityToggle": true,
"dynamic_thresholding": settings.nai3Deceisp === 'true',
"cfg_rescale": Number(settings.naiCfg),
"noise_schedule": settings.naiNoiseSchedule,
"seed": Date.now() + Math.floor(Math.random() * 1000),
"negative_prompt": negativePrompt,
};
// 处理Vibe Transfer
if (useVibeTransfer) {
button1.textContent = "加载中(Vibe)";
const modelToVibeKeyMap = {
'nai-diffusion-4-full': 'v4full',
'nai-diffusion-4-curated-preview': 'v4curated',
'nai-diffusion-4-5-full': 'v4-5full',
'nai-diffusion-4-5-curated': 'v4-5curated',
};
const currentVibeKey = modelToVibeKeyMap[settings.naiModel];
if (!currentVibeKey) {
throw new Error(`当前选择的模型 "${settings.naiModel}" 不支持 V4 Vibe Transfer。`);
}
const vibes = [];
const strengths = [];
const errors = [];
for (const item of settings.naiVibeTransferImages) {
if (item.type === 'vibeFile' && item.vibeData) {
const encodingBlock = item.vibeData.encodings[currentVibeKey];
if (encodingBlock) {
// Vibe文件结构中,encodingBlock下只有一个键值对
const encodingData = Object.values(encodingBlock)[0];
if(encodingData && encodingData.encoding){
vibes.push(encodingData.encoding);
strengths.push(parseFloat(item.refStrength)); // 使用滑块设置的强度
} else {
errors.push(`文件 "${item.vibeData.name}" 缺少有效的 'encoding' 数据。`);
}
} else {
errors.push(`文件 "${item.vibeData.name}" 没有为当前模型 "${settings.naiModel}" 生成的 Vibe 数据。`);
}
} else {
errors.push(`一个普通图片被用于 V4+ Vibe Transfer,这是不兼容的。请上传 .naiv4vibe 文件。`);
}
}
if(errors.length > 0){
const errorMsg = `Vibe Transfer 错误:\n\n- ${errors.join('\n- ')}\n\n请去官网为当前模型生成 Vibe 文件并重新上传。`;
alert(errorMsg);
throw new Error("Vibe Transfer 配置错误。");
}
// V4+ Vibe Transfer 使用不同的参数名
preset_data.reference_image_multiple = vibes;
preset_data.reference_strength_multiple = strengths;
}
if (settings.nai3Variety !== 'false') {
if (settings.naiModel.includes('nai-diffusion-4-5')) preset_data.skip_cfg_above_sigma = 59.047226;
else if (settings.naiModel.includes('nai-diffusion-4-full')) preset_data.skip_cfg_above_sigma = 19;
}
const useMultiCharacter = dynamic_prompt_raw.includes("Scene Composition:");
if (useMultiCharacter) {
const prompt_data = parsePromptStringWithCoordinates(dynamic_prompt_raw);
base_prompt = await zhengmian(settings.naiPositivePrompt, prompt_data["Scene Composition"], "");
const char_captions_prompt = [];
const char_captions_uc = [];
for (let i = 1; i <= 4; i++) {
if (prompt_data[`Character ${i} Prompt`]) {
char_captions_prompt.push({
char_caption: prompt_data[`Character ${i} Prompt`],
centers: [prompt_data[`Character ${i} coordinates`]]
});
char_captions_uc.push({
char_caption: prompt_data[`Character ${i} UC`] || '',
centers: [prompt_data[`Character ${i} coordinates`]]
});
}
}
preset_data.v4_prompt = { caption: { base_caption: base_prompt, char_captions: char_captions_prompt }, use_coords: useCoords, use_order: true };
preset_data.v4_negative_prompt = { caption: { base_caption: negativePrompt, char_captions: char_captions_uc } };
} else {
base_prompt = await zhengmian(settings.naiPositivePrompt, dynamic_prompt_raw, "");
preset_data.v4_prompt = { caption: { base_caption: base_prompt, char_captions: [] }, use_coords: useCoords, use_order: true };
preset_data.v4_negative_prompt = { caption: { base_caption: negativePrompt, char_captions: [] } };
}
payload = {
"input": base_prompt, // input可以是base_prompt或空字符串
"model": settings.naiModel,
"action": "generate",
"parameters": preset_data
};
} else {
// --- V3及以下官方模型逻辑 ---
console.log("NAI v3 Model Detected. Building legacy payload structure.");
base_prompt = await zhengmian(settings.naiPositivePrompt, dynamic_prompt_raw.replaceAll("\n", ", "), settings.AQT);
let preset_data = {
"params_version": 3,
"width": Number(settings.width),
"height": Number(settings.height),
"scale": Number(settings.naiScale),
"sampler": settings.naiSampler,
"steps": naiSteps,
"n_samples": 1,
"ucPreset": 3,
"qualityToggle": true,
sm: settings.nai3sm === 'true',
"sm_dyn": settings.nai3dyn === 'true' && settings.nai3sm === 'true',
"dynamic_thresholding": settings.nai3Deceisp === 'true',
"cfg_rescale": Number(settings.naiCfg),
"noise_schedule": settings.naiNoiseSchedule,
"seed": Date.now() + Math.floor(Math.random() * 1000),
"negative_prompt": negativePrompt,
};
if (useVibeTransfer) {
button1.textContent = "加载中(Vibe)";
// V3/代理 使用普通图片base64
const imageBase64s = settings.naiVibeTransferImages
.filter(img => img.type === 'image' && img.base64)
.map(img => img.base64.split(',')[1]);
if (imageBase64s.length !== settings.naiVibeTransferImages.length){
alert("警告:您为 V3 模型上传了 .naiv4vibe 文件,这些文件将被忽略。V3 模式仅支持普通图片。");
}
if (imageBase64s.length > 0) {
preset_data.reference_image_multiple = imageBase64s;
preset_data.reference_information_extracted_multiple = settings.naiVibeTransferImages.map(img => parseFloat(img.infoExtract));
preset_data.reference_strength_multiple = settings.naiVibeTransferImages.map(img => parseFloat(img.refStrength));
}
}
payload = {
"input": base_prompt,
"model": settings.naiModel,
"action": "generate",
"parameters": preset_data
};
}
console.log("NAI Official (POST) Payload:", JSON.parse(JSON.stringify(payload)));
const response = await gmXmlHttpRequestPromise({
method: "POST", url: settings.naiApiUrl,
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${settings.naiToken}` },
data: JSON.stringify(payload), responseType: 'arraybuffer'
});
if (response.status < 200 || response.status >= 300) {
const errorText = response.responseText || "服务器未返回错误信息。";
let readableError = errorText;
try {
const errorJson = JSON.parse(errorText);
readableError = `(${errorJson.statusCode}) ${errorJson.message}`;
} catch (e) {
}
throw new Error(`请求失败 (HTTP ${response.status}): ${readableError}`)
}
const base64Image = await unzipFile(response.response);
dataURL = "data:image/png;base64," + base64Image;
} else {
// ============ 第三方代理 API 逻辑==========
console.log("NAI Proxy Request");
// 新增:检测是否为多角色模式
const useMultiCharacter = dynamic_prompt_raw.includes("Scene Composition:");
let response;
if (useMultiCharacter) {
// --- 第三方代理的多角色逻辑 ---
console.log("Proxy: Multi-Role mode detected.");
button1.textContent = "加载中(角色定位)";
const prompt_data = parsePromptStringWithCoordinates(dynamic_prompt_raw);
// 构建 multiRoleList
const multiRoleList = [];
for (let i = 1; i <= 4; i++) {
if (prompt_data[`Character ${i} Prompt`]) {
multiRoleList.push({
prompt: prompt_data[`Character ${i} Prompt`],
negativePrompt: prompt_data[`Character ${i} UC`] || '',
position: prompt_data[`Character ${i} centers`]
});
}
}
// 构建 POST 请求体
const multiRolePayload = {
token: settings.naiToken,
model: settings.naiModel,
sampler: settings.naiSampler,
noise_schedule: settings.naiNoiseSchedule,
size: settings.size === "Custom" ? `${settings.width}x${settings.height}` : settings.size,
steps: naiSteps.toString(),
scale: settings.naiScale,
cfg: settings.naiCfg,
nocache: 1,
stream: 0, // 多角色模式通常不支持流式
tag: await zhengmian(settings.naiPositivePrompt, prompt_data["Scene Composition"], ""),
negative: negativePrompt, // 全局负面提示词
addition: {
imageToImageBase64: null,
vibeTransferList: [],
multiRoleList: multiRoleList
}
};
console.log("NAI Proxy Multi-Role (POST) Payload:", multiRolePayload);
response = await gmXmlHttpRequestPromise({
method: "POST",
url: settings.naiApiUrl,
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${settings.naiToken}` },
data: JSON.stringify(multiRolePayload),
responseType: 'blob'
});
} else {
const positivePrompt = await zhengmian(settings.naiPositivePrompt, dynamic_prompt_raw, settings.AQT);
const useVibeTransfer = settings.naiVibeTransferEnabled === 'true' && settings.naiVibeTransferImages && settings.naiVibeTransferImages.length > 0;
if (useVibeTransfer) {
console.log("Proxy: Vibe-Transfer mode detected.");
button1.textContent = "加载中(Vibe)";
// 代理仅支持普通图片
const vibeListForProxy = settings.naiVibeTransferImages
.filter(img => img.type === 'image' && img.base64)
.map(img => ({
base64: img.base64.split(',')[1],
infoExtract: parseFloat(img.infoExtract),
refStrength: parseFloat(img.refStrength)
}));
if (vibeListForProxy.length !== settings.naiVibeTransferImages.length) {
alert("警告:您为代理模式上传了 .naiv4vibe 文件,这些文件将被忽略。代理模式仅支持普通图片。");
}
const vibePayload = {
token: settings.naiToken,
model: settings.naiModel,
sampler: settings.naiSampler,
noise_schedule: settings.naiNoiseSchedule,
size: settings.size === "Custom" ? `${settings.width}x${settings.height}` : settings.size,
steps: naiSteps.toString(),
scale: settings.naiScale,
cfg: settings.naiCfg,
nocache: '1',
tag: positivePrompt,
negative: negativePrompt,
addition: {
imageToImageBase64: null,
vibeTransferList: vibeListForProxy
}
};
console.log("NAI Vibe Transfer (POST) Payload:", vibePayload);
response = await gmXmlHttpRequestPromise({
method: "POST",
url: settings.naiApiUrl,
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${settings.naiToken}` },
data: JSON.stringify(vibePayload),
responseType: 'blob'
});
} else {
console.log("Proxy: Standard GET request.");
const imageUrl = new URL(settings.naiApiUrl);
imageUrl.searchParams.set('token', settings.naiToken);
imageUrl.searchParams.set('model', settings.naiModel);
imageUrl.searchParams.set('sampler', settings.naiSampler);
imageUrl.searchParams.set('noise_schedule', settings.naiNoiseSchedule);
if (settings.size === "Custom") {
imageUrl.searchParams.set('size', `${settings.width}x${settings.height}`);
} else {
imageUrl.searchParams.set('size', settings.size);
}
imageUrl.searchParams.set('steps', naiSteps.toString());
imageUrl.searchParams.set('scale', settings.naiScale);
imageUrl.searchParams.set('cfg', settings.naiCfg);
imageUrl.searchParams.set('nocache', '1');
imageUrl.searchParams.set('artist', '@');
imageUrl.searchParams.set('tag', positivePrompt);
if (negativePrompt && negativePrompt.trim() !== '') {
imageUrl.searchParams.set('negative', negativePrompt);
}
console.log("NAI Proxy Request URL (GET):", imageUrl.href);
response = await gmXmlHttpRequestPromise({
method: "GET",
url: imageUrl.href,
responseType: 'blob'
});
}
}
// ============= 第三方代理 API 逻辑 ===========
dataURL = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(response.response);
});
}
await setItemImg(newImageId, dataURL, locationHash);
// ====================== 新增的修改点 ======================
// 如果是模态框调用,直接返回数据,不进行DOM操作
if (button1.isModalCall) {
return dataURL;
}
// ====================== 修改结束 ==========================
let imgSpan = button1.ownerDocument.getElementById(button1.name);
if (imgSpan) {
const img = document.createElement('img');
img.src = dataURL;
img.alt = "Generated Image";
img.dataset.name = imgSpan.dataset.name;
// 【核心修改】当处于兼容模式时,应用自适应样式
if (settings.displayMode === '兼容前端') {
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.minWidth = '50px';
img.style.minHeight = '50px';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.style.borderRadius = "5px";
}
if (settings.dbclike == "true") {
imgSpan.style.textAlign = 'center';
button1.style.cssText = 'width: 0; height: 0; overflow: hidden; padding: 0; border: none;';
}
imgSpan.replaceChildren(img);
}
} catch (error) {
console.error("NovelAI图片生成请求失败:", error);
// 如果是模态框调用,不要弹alert,而是返回null让调用者处理
if (button1.isModalCall) {
// 抛出异常,让调用方的catch块来处理UI反馈
throw error;
}
alert("NovelAI图片生成请求失败: " + (error.message || "未知错误,请检查控制台"));
} finally {
xiancheng = true;
// 只有在非模态框调用时,才恢复按钮文本
if (button1.isConnected && !button1.isModalCall) {
button1.textContent="生成图片";
}
}
}
// ==================== 修改/新增区域: ComfyUI 动态参数应用函数 ====================
async function applyDynamicParamsToWorkflow(workflow) {
console.log("[ComfyUI] Generating with JIT dynamic parameters from saved settings...");
const findNodeByTitle = (wf, title) => { for (const id in wf) { if (wf[id]?._meta?.title?.includes(title)) return id; } return null; };
const findNodesByClassType = (wf, classType) => { const ids = []; for (const id in wf) { if (wf[id]?.class_type === classType) ids.push(id); } return ids; };
const findNode = (wf, titles, classType) => {
for (const title of titles) { for (const id in wf) { if (wf[id]?._meta?.title?.includes(title)) return [id]; } }
const ids = [];
for (const id in wf) { if (wf[id]?.class_type === classType) ids.push(id); }
return ids;
};
// 创建工作流的深拷贝,以避免修改存储中的原始对象
let dynamicWorkflow = JSON.parse(JSON.stringify(workflow));
// 1. 注入分辨率
const resNodeIds = findNode(dynamicWorkflow, ["分辨率", "Latent"], "EmptyLatentImage");
if (resNodeIds.length > 0) {
resNodeIds.forEach(id => {
dynamicWorkflow[id].inputs.width = Number(settings.width);
dynamicWorkflow[id].inputs.height = Number(settings.height);
});
}
// 2. 注入主采样器参数
const samplerNodeIds = findNode(dynamicWorkflow, ["采样器", "Sampler"], "KSampler");
if (samplerNodeIds.length > 0) {
samplerNodeIds.forEach(id => {
dynamicWorkflow[id].inputs.steps = Number(settings.steps);
dynamicWorkflow[id].inputs.cfg = Number(settings.sdCfgScale); // ComfyUI 的 CFG 复用 SD 的设置
dynamicWorkflow[id].inputs.sampler_name = settings.comfyuiSampler;
dynamicWorkflow[id].inputs.scheduler = settings.comfyuiScheduler;
});
}
// 3. 注入主模型
const ckptNodeIds = findNode(dynamicWorkflow, ["主模型", "Checkpoint"], "CheckpointLoaderSimple");
if (ckptNodeIds.length > 0) {
ckptNodeIds.forEach(id => dynamicWorkflow[id].inputs.ckpt_name = settings.comfyuiModel);
}
// 4. 注入 LoRA 参数
const loraStackNodeId = findNodeByTitle(dynamicWorkflow, "Lora Loader Stack");
if (loraStackNodeId) {
dynamicWorkflow[loraStackNodeId].inputs.lora_01 = settings.comfyuiLora;
dynamicWorkflow[loraStackNodeId].inputs.strength_01 = parseFloat(settings.comfyuiLoraStrength);
dynamicWorkflow[loraStackNodeId].inputs.lora_02 = settings.comfyuiLora2;
dynamicWorkflow[loraStackNodeId].inputs.strength_02 = parseFloat(settings.comfyuiLoraStrength2);
dynamicWorkflow[loraStackNodeId].inputs.lora_03 = settings.comfyuiLora3;
dynamicWorkflow[loraStackNodeId].inputs.strength_03 = parseFloat(settings.comfyuiLoraStrength3);
dynamicWorkflow[loraStackNodeId].inputs.lora_04 = settings.comfyuiLora4;
dynamicWorkflow[loraStackNodeId].inputs.strength_04 = parseFloat(settings.comfyuiLoraStrength4);
}
// 5. 注入 FaceDetailer 参数
const faceDetailerNodeIds = findNode(dynamicWorkflow, ["FaceDetailer"], "FaceDetailer");
if(faceDetailerNodeIds.length > 0) {
faceDetailerNodeIds.forEach(id => {
if(!dynamicWorkflow[id].inputs) dynamicWorkflow[id].inputs = {};
dynamicWorkflow[id].inputs.steps = Number(settings.faceDetailerSteps);
dynamicWorkflow[id].inputs.guide_size = Number(settings.faceDetailerGuideSize);
dynamicWorkflow[id].inputs.max_size = Number(settings.faceDetailerMaxSize);
dynamicWorkflow[id].inputs.cfg = Number(settings.faceDetailerCfg);
dynamicWorkflow[id].inputs.sampler_name = settings.faceDetailerSampler;
dynamicWorkflow[id].inputs.scheduler = settings.faceDetailerScheduler;
});
console.log(`[ComfyUI] Applied FaceDetailer params to node(s) #${faceDetailerNodeIds.join(', ')}`);
}
// 6. 注入 Ultimate SD Upscale 参数
const ultimateUpscaleNodeIds = findNodesByClassType(dynamicWorkflow, "UltimateSDUpscale");
if (ultimateUpscaleNodeIds.length > 0) {
const upscaleModelNodeIds = findNodesByClassType(dynamicWorkflow, "UpscaleModelLoader");
if (upscaleModelNodeIds.length > 0) {
upscaleModelNodeIds.forEach(id => {
if(!dynamicWorkflow[id].inputs) dynamicWorkflow[id].inputs = {};
dynamicWorkflow[id].inputs.model_name = settings.comfyuiUpscaleModel;
});
console.log(`[ComfyUI] Applied Upscale Model to node(s) #${upscaleModelNodeIds.join(', ')}`);
}
ultimateUpscaleNodeIds.forEach(id => {
if(!dynamicWorkflow[id].inputs) dynamicWorkflow[id].inputs = {};
dynamicWorkflow[id].inputs.upscale_by = parseFloat(settings.comfyuiUltimateUpscaleBy);
dynamicWorkflow[id].inputs.steps = Number(settings.comfyuiUltimateSteps);
dynamicWorkflow[id].inputs.cfg = Number(settings.comfyuiUltimateCfg);
dynamicWorkflow[id].inputs.sampler_name = settings.comfyuiUltimateSampler;
dynamicWorkflow[id].inputs.scheduler = settings.comfyuiUltimateScheduler;
dynamicWorkflow[id].inputs.denoise = parseFloat(settings.comfyuiUltimateDenoise);
dynamicWorkflow[id].inputs.mode_type = settings.comfyuiUltimateModeType;
});
console.log(`[ComfyUI] Applied Ultimate SD Upscale params to node(s) #${ultimateUpscaleNodeIds.join(', ')}`);
}
// 7. 注入脸部修复检测模型
const detectorNodeIds = findNodesByClassType(dynamicWorkflow, "UltralyticsDetectorProvider");
if(detectorNodeIds.length > 0) {
detectorNodeIds.forEach(id => {
if(!dynamicWorkflow[id].inputs) dynamicWorkflow[id].inputs = {};
dynamicWorkflow[id].inputs.model_name = settings.faceDetailerModel;
});
console.log(`[ComfyUI] Applied Face Detailer Detector Model to node(s) #${detectorNodeIds.join(', ')}`);
}
return dynamicWorkflow;
}
async function comfyuiGenerate(button1) {
if(!button1.id.includes("image")) return;
// 在模态框调用时,我们不希望有任何DOM操作,所以textContent的修改也需要考虑
// 但由于伪按钮并不在DOM中,这个操作是安全的,可以保留作为状态指示
button1.textContent="加载中";
const locationHash = button1.dataset.locationHash; // 获取位置哈希
if(!xiancheng) await sleep(1000);
xiancheng=false;
// 辅助函数(仅用于本函数内的特定查找)
const findNodeByTitle = (wf, title) => { for (const id in wf) { if (wf[id]?._meta?.title?.includes(title)) return id; } return null; };
const findNodesByClassType = (wf, classType) => { const ids = []; for (const id in wf) { if (wf[id]?.class_type === classType) ids.push(id); } return ids; };
try {
const newImageId = await getNextImageId(); // 获取新的唯一ID
const url = removeTrailingSlash(settings.comfyuiUrl);
let baseWorkflow;
// 1. 从设置中加载用户选定的基础工作流
try {
baseWorkflow = JSON.parse(settings.comfyuiWorkflows[settings.comfyuiCurrentWorkflow]);
} catch (e) {
// 如果是模态框调用,不弹窗,直接抛出错误
const errorMsg = `当前选定的工作流 "${settings.comfyuiCurrentWorkflow}" 不是有效的JSON格式,无法生成。请在设置中修复或重新创建。`;
if (button1.isModalCall) throw new Error(errorMsg);
alert(errorMsg);
button1.textContent="生成图片";
xiancheng = true;
return;
}
// 2. 根据“纯净工作流模式”开关状态,决定如何处理工作流
let workflow;
if (settings.comfyuiUseCustomWorkflowOnly === 'true') {
console.log("[ComfyUI] 纯净工作流模式已启用。仅注入提示词和种子。");
workflow = JSON.parse(JSON.stringify(baseWorkflow));
} else {
console.log("[ComfyUI] 标准模式。正在应用所有动态参数。");
workflow = await applyDynamicParamsToWorkflow(baseWorkflow);
}
// 3. 注入本次生成的特定提示词
let prompt = await zhengmian(settings.fixedPrompt, button1.dataset.link, settings.AQT);
let negative_prompt = await fumian(settings.negativePrompt,settings.UCP);
const posPromptNodeId = findNodeByTitle(workflow, "正面提示词");
if(posPromptNodeId) {
workflow[posPromptNodeId].inputs.text = prompt;
} else {
console.warn("[ComfyUI] 未在工作流中找到'正面提示词'节点,动态提示词未注入。");
}
const negPromptNodeId = findNodeByTitle(workflow, "负面提示词");
if(negPromptNodeId) {
workflow[negPromptNodeId].inputs.text = negative_prompt;
} else {
console.warn("[ComfyUI] 未在工作流中找到'负面提示词'节点,动态负面提示词未注入。");
}
// 4. 注入随机种子
const samplerNodeIds = findNodesByClassType(workflow, "KSampler");
if (samplerNodeIds.length > 0) {
samplerNodeIds.forEach(id => {
if (workflow[id].inputs) {
workflow[id].inputs.seed = Date.now() + Math.floor(Math.random() * 1000000);
}
});
}
const faceDetailerNodeIds = findNodesByClassType(workflow, "FaceDetailer");
if (faceDetailerNodeIds.length > 0) {
faceDetailerNodeIds.forEach(id => {
if (workflow[id].inputs) {
workflow[id].inputs.seed = Date.now() + Math.floor(Math.random() * 1000000);
}
});
}
const ultimateUpscaleNodeIds = findNodesByClassType(workflow, "UltimateSDUpscale");
if(ultimateUpscaleNodeIds.length > 0){
ultimateUpscaleNodeIds.forEach(id => {
if (workflow[id].inputs) {
workflow[id].inputs.seed = Date.now() + Math.floor(Math.random() * 1000000);
}
});
}
// 5. 发送请求
const clientId = Math.random().toString(36).substring(2);
const payload = { prompt: workflow, client_id: clientId };
console.log("[ComfyUI] 最终发送的生成参数:", JSON.parse(JSON.stringify(payload)));
const resp = await gmXmlHttpRequestPromise({ method: "POST", url: `${url}/prompt`, data: JSON.stringify(payload), headers: {"Content-Type": "application/json"} });
const queueData = JSON.parse(resp.responseText);
if (queueData.error) {
console.error("[ComfyUI] Prompt Error:", queueData);
let errorDetails = queueData.error.message || '';
if(queueData.node_errors) {
for(const node in queueData.node_errors){
if(queueData.node_errors[node].errors[0].message){
errorDetails += `\n节点 ${node} (${queueData.node_errors[node].class_type}): ${queueData.node_errors[node].errors[0].message}`;
}
}
}
throw new Error(`ComfyUI 提交失败: ${queueData.error.type} - ${errorDetails}`);
}
if (queueData.prompt_id) {
const promptId = queueData.prompt_id;
button1.textContent=`排队中 #${queueData.number}`;
// ### 轮询结果 ###
let history = null;
for (let i = 0; i < 360; i++) { // 3分钟超时
await sleep(1000);
const historyResp = await gmXmlHttpRequestPromise({ method: "GET", url: `${url}/history/${promptId}` });
const historyData = JSON.parse(historyResp.responseText);
if (Object.keys(historyData).length > 0 && historyData[promptId]) {
history = historyData[promptId];
break;
}
}
if (!history) throw new Error("图片生成超时");
if (history.status?.status_str !== 'success' && history.status?.completed !== true) throw new Error(`生成失败: ${history.status?.status_str || '未知状态'}`);
// ### 查找并获取图片 ###
let outputImageInfo = null;
const potentialOutputNodes = findNodesByClassType(workflow, "SaveImage").concat(findNodesByClassType(workflow, "PreviewImage"));
for (const nodeId of potentialOutputNodes) {
if (history.outputs[nodeId]?.images?.[0]) {
outputImageInfo = history.outputs[nodeId].images[0];
break;
}
}
if (!outputImageInfo) {
throw new Error("在工作流结果中未找到有效的输出节点(如'SaveImage', 'PreviewImage'等),或该节点未生成图片。");
}
const imageUrl = `${url}/view?filename=${encodeURIComponent(outputImageInfo.filename)}&subfolder=${encodeURIComponent(outputImageInfo.subfolder)}&type=${outputImageInfo.type}`;
const imageResponse = await gmXmlHttpRequestPromise({ method: "GET", url: imageUrl, responseType: 'blob' });
const dataURL = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(imageResponse.response);
});
await setItemImg(newImageId, dataURL, locationHash);
// ====================== 新增的修改点 ======================
// 如果是模态框调用,直接返回数据,不进行DOM操作
if (button1.isModalCall) {
return dataURL;
}
// ====================== 修改结束 ==========================
let imgSpan = button1.ownerDocument.getElementById(button1.name);
const img = document.createElement('img');
img.src = dataURL; img.alt = "Generated Image"; img.dataset.name = imgSpan.dataset.name;
// 【核心修改】当处于兼容模式时,应用自适应样式
if (settings.displayMode === '兼容前端') {
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.minWidth = '50px';
img.style.minHeight = '50px';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.style.borderRadius = "5px";
}
if(settings.dbclike=="true"){
imgSpan.style.textAlign = 'center';
button1.style.cssText = 'width: 0; height: 0; overflow: hidden; padding: 0; border: none;';
}
imgSpan.replaceChildren(img);
} else {
throw new Error("ComfyUI返回的数据中没有prompt_id");
}
} catch (error) {
console.error("ComfyUI图片生成请求失败:", error);
// 如果是模态框调用,不弹窗,而是抛出错误让调用者处理UI
if (button1.isModalCall) {
throw error;
}
alert("ComfyUI图片生成请求失败: " + error.message);
} finally {
xiancheng = true;
// 只有在非模态框调用时,才恢复按钮文本
if (button1.isConnected && !button1.isModalCall) {
button1.textContent="生成图片";
}
}
}
/**
* 新的辅助函数,用于串行处理自动点击队列
* @param {Array<HTMLElement>} buttons - 需要自动点击的按钮元素数组
*/
async function processAutoClickQueue(buttons) {
// 如果队列已在运行,则直接返回,防止重复执行
if (isAutoClicking) {
console.log("文生图插件:自动点击队列已在运行,本次新任务将等待下一轮。");
return;
}
// 锁定队列,开始处理
isAutoClicking = true;
console.log(`文生图插件:开始处理自动点击队列,共 ${buttons.length} 个任务。`);
try {
// 使用 for...of 循环来确保任务按顺序执行
for (const button of buttons) {
// 再次检查按钮是否仍然存在且需要处理
if (button.isConnected && button.textContent === '生成图片') {
console.log(`自动处理任务: ${button.id}, 提示词: "${button.dataset.link}", CacheID: ${button.dataset.cacheId}`);
// 确定要调用的生成器函数
let generator;
switch (settings.currentMode) {
case 'sd': generator = sd; break;
case 'nai': generator = naiGenerate; break;
case 'comfyui': generator = comfyuiGenerate; break;
default:
console.error(`未知的生成模式: ${settings.currentMode},跳过此任务。`);
continue; // 跳到下一个按钮
}
try {
// 直接调用生成函数并等待其完成
await generator(button);
// 成功后可以短暂等待一下,避免对服务器造成连续冲击
await sleep(1000);
} catch (error) {
console.error(`自动生成图片失败 (按钮ID: ${button.id}):`, error);
// 即使失败,也继续处理队列中的下一个任务
if (document.body.contains(button)) {
button.textContent = "生成失败"; // 标记为失败状态
}
}
} else {
console.log(`跳过已处理或无效的按钮: ${button.id}`);
}
}
} finally {
// 所有任务处理完毕(无论成功或失败),解锁队列
console.log("文生图插件:自动点击队列处理完毕。");
isAutoClicking = false;
}
}
/**
* 新增:递归处理节点,深度扫描并替换文本中的标签。
* 这是 "重兼容模式" 的核心。
* @param {Node} node - 当前要处理的DOM节点。
* @param {RegExp} regex - 用于匹配标签的正则表达式。
* @param {string} stableMessageAnchor - 用于生成位置哈希的稳定锚点。
* @param {Array} buttonsToProcess - 一个数组,用于收集新创建的按钮信息以便后续处理。
* @param {number} matchCounter - 用于确保同一消息内多个标签的哈希唯一性。
* @returns {number} - 返回在此节点下找到并处理的匹配项数量。
*/
function processNodeRecursively(node, regex, stableMessageAnchor, buttonsToProcess) {
// 只处理元素节点和文本节点
if (node.nodeType === Node.ELEMENT_NODE) {
// 不扫描我们自己生成的按钮、span、图片,或脚本/样式标签
if (['BUTTON', 'SPAN', 'IMG', 'SCRIPT', 'STYLE'].includes(node.tagName) || node.classList.contains('button_image')) {
return;
}
// 遍历子节点(创建副本以安全地修改DOM)
const children = Array.from(node.childNodes);
for (const child of children) {
processNodeRecursively(child, regex, stableMessageAnchor, buttonsToProcess);
}
} else if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue;
// 重置正则表达式的 lastIndex
regex.lastIndex = 0;
if (!regex.test(textContent)) {
return; // 如果文本节点中没有匹配项,则跳过
}
const parent = node.parentNode;
const fragment = document.createDocumentFragment();
let lastIndex = 0;
// 再次重置以用于 replace
regex.lastIndex = 0;
let match;
while ((match = regex.exec(textContent)) !== null) {
// 1. 添加匹配项之前的文本
if (match.index > lastIndex) {
fragment.appendChild(document.createTextNode(textContent.substring(lastIndex, match.index)));
}
// 2. 创建并添加我们的按钮和span
const promptContent = match[1];
const link = promptContent.replaceAll("《", "<").replaceAll("》", ">").replaceAll("\n", "");
const buttonId = "button_image" + Math.random().toString(36).substr(2, 9);
const spanId = "span_" + buttonId;
// 使用稳定锚点和内容+计数器生成唯一哈希
const locationHash = CryptoJS.MD5(stableMessageAnchor + link + buttonsToProcess.length).toString();
const button = document.createElement('button');
button.id = buttonId;
button.name = spanId;
button.dataset.link = link;
button.dataset.locationHash = locationHash;
button.className = 'button_image';
button.textContent = '生成图片';
const span = document.createElement('span');
span.id = spanId;
span.dataset.name = buttonId;
fragment.appendChild(button);
fragment.appendChild(span);
// 收集新创建的按钮信息,以便稍后检查缓存和自动点击
buttonsToProcess.push({ button, span, locationHash });
// 更新下一个搜索的起始位置
lastIndex = match.index + match[0].length;
}
// 3. 添加最后一个匹配项之后的任何剩余文本
if (lastIndex < textContent.length) {
fragment.appendChild(document.createTextNode(textContent.substring(lastIndex)));
}
// 4. 用我们构建的片段替换原始文本节点
if (fragment.hasChildNodes()) {
parent.replaceChild(fragment, node);
}
}
}
/**
* 新增:辅助函数,用于等待iframe加载完成。
* @param {HTMLIFrameElement} iframe - 目标iframe元素。
* @returns {Promise<void>} - 当iframe加载完成时解析的Promise。
*/
function waitForIframeLoad(iframe) {
return new Promise(resolve => {
if (iframe.contentDocument && iframe.contentDocument.readyState === 'complete') {
resolve();
} else {
iframe.onload = () => resolve();
}
});
}
async function replaceSpansWithImagesst() {
if (!settings.scriptEnabled || checkSendBuClass()) return;
const messageContainers = document.getElementsByClassName("mes_text");
if (messageContainers.length === 0) return;
const buttonsToAutoClick = [];
const regex = new RegExp(`${escapeRegExp(settings.startTag)}([\\s\\S]*?)${escapeRegExp(settings.endTag)}`, 'g');
// =========================================================================
// ==================== 核心修改:根据模式选择不同逻辑 ====================
// =========================================================================
if (settings.displayMode === '兼容前端') {
// --- 兼容前端 (深度扫描) 模式 ---
// 这个模式功能强大,但可能破坏某些前端卡片的结构。
for (const p of messageContainers) {
if (p.dataset.processedByGenerator) {
continue;
}
const messageContainer = p.closest('.mes');
let stableMessageAnchor;
if (messageContainer) {
const messageId = messageContainer.getAttribute('mesid');
const timestamp = messageContainer.getAttribute('timestamp');
const charName = messageContainer.getAttribute('ch_name');
stableMessageAnchor = `${timestamp}-${charName}-${messageId}`;
} else {
stableMessageAnchor = CryptoJS.MD5(p.innerHTML.substring(0, 100)).toString();
}
let targetNode = p;
let eventTarget = p;
const iframe = p.querySelector('iframe');
if (iframe && iframe.contentWindow) {
try {
await waitForIframeLoad(iframe);
targetNode = iframe.contentWindow.document.body;
eventTarget = iframe.contentWindow.document.body;
} catch (e) {
console.error("访问iframe内容时出错 (可能是跨域限制):", e);
continue;
}
}
if (!targetNode || !targetNode.textContent || !targetNode.textContent.includes(settings.startTag)) {
continue;
}
const newButtons = [];
processNodeRecursively(targetNode, regex, stableMessageAnchor, newButtons);
if (newButtons.length > 0) {
if (!eventTarget.dataset.handlerAttached) {
eventTarget.dataset.handlerAttached = "true";
const clickHandler = (e) => {
if (e.target.tagName === 'BUTTON' && e.target.classList.contains("button_image")) {
const generator = { sd: sd, nai: naiGenerate, comfyui: comfyuiGenerate }[settings.currentMode];
if (generator) generator(e.target);
}
};
eventTarget.addEventListener('click', clickHandler);
if (settings.dbclike === "true") {
const dblClickHandler = (e) => {
if (e.target.tagName === 'IMG' && e.target.alt === "Generated Image" && e.target.dataset.name) {
addSmoothShakeEffect(e.target);
const ownerDocument = e.target.ownerDocument;
const button = ownerDocument.getElementById(e.target.dataset.name);
if (button) {
const generator = { sd: sd, nai: naiGenerate, comfyui: comfyuiGenerate }[settings.currentMode];
if (generator) generator(button);
}
}
};
eventTarget.addEventListener('dblclick', dblClickHandler);
}
}
for (const item of newButtons) {
const { button, span, locationHash } = item;
const imageId = locationToImageIdMap[locationHash];
if (imageId) {
const cachedImgData = await getItemImg(imageId);
if (cachedImgData) {
const img = document.createElement('img');
img.src = cachedImgData;
img.alt = "Generated Image";
img.dataset.name = span.dataset.name;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.minWidth = '50px';
img.style.minHeight = '50px';
img.style.objectFit = 'contain';
img.style.display = 'block';
img.style.borderRadius = "5px";
span.appendChild(img);
if (settings.dbclike === "true") {
button.style.display = 'none';
}
}
} else if (settings.zidongdianji === "true") {
buttonsToAutoClick.push(button);
}
}
}
p.dataset.processedByGenerator = "true";
}
} else {
// --- 默认 (快速扫描) 模式 ---
for (const p of messageContainers) {
if (!p.textContent.includes(settings.startTag) || p.dataset.processedByGenerator) {
continue;
}
const messageContainer = p.closest('.mes');
let stableMessageAnchor;
if (messageContainer) {
const messageId = messageContainer.getAttribute('mesid');
const timestamp = messageContainer.getAttribute('timestamp');
const charName = messageContainer.getAttribute('ch_name');
stableMessageAnchor = `${timestamp}-${charName}-${messageId}`;
} else {
stableMessageAnchor = CryptoJS.MD5(p.innerHTML).toString();
}
const newButtonsInP = new Set();
let hasChanges = false;
let matchCountInP = 0;
const newHtml = p.innerHTML.replace(regex, (match, content) => {
hasChanges = true;
const link = content.replaceAll("《", "<").replaceAll("》", ">").replaceAll("\n", "");
const buttonId = "button_image" + Math.random().toString(36).substr(2, 9);
const spanId = "span_" + buttonId;
const locationHash = CryptoJS.MD5(stableMessageAnchor + link + matchCountInP).toString();
matchCountInP++;
newButtonsInP.add({ buttonId, spanId, locationHash });
return `<button id="${buttonId}" name="${spanId}" data-link="${link}" data-location-hash="${locationHash}" class="button_image">生成图片</button><span id="${spanId}" data-name="${buttonId}"></span>`;
});
if (hasChanges) {
p.innerHTML = newHtml;
if (!p.dataset.handlerAttached) {
p.dataset.handlerAttached = "true";
p.addEventListener('click', (e) => {
if (e.target.tagName === 'BUTTON' && e.target.id.startsWith("button_image")) {
const generator = { sd: sd, nai: naiGenerate, comfyui: comfyuiGenerate }[settings.currentMode];
if(generator) generator(e.target);
}
});
if (settings.dbclike === "true") {
p.addEventListener('dblclick', (e) => {
if (e.target.alt && e.target.alt === "Generated Image") {
addSmoothShakeEffect(e.target);
const button = document.getElementById(e.target.dataset.name);
if (!button) return;
const generator = { sd: sd, nai: naiGenerate, comfyui: comfyuiGenerate }[settings.currentMode];
if(generator) generator(button);
}
});
}
}
for (const item of newButtonsInP) {
const button = document.getElementById(item.buttonId);
if (!button) continue;
const imageId = locationToImageIdMap[item.locationHash];
if (imageId) {
const cachedImgData = await getItemImg(imageId);
if (cachedImgData) {
const imgSpan = document.getElementById(item.spanId);
if (imgSpan) {
const img = document.createElement('img');
img.src = cachedImgData;
img.alt = "Generated Image";
img.dataset.name = imgSpan.dataset.name;
img.style.borderRadius = "5px";
imgSpan.appendChild(img);
if (settings.dbclike === "true") {
button.style.cssText = 'width: 0; height: 0; overflow: hidden; padding: 0; border: none;';
}
}
}
} else if (settings.zidongdianji === "true") {
buttonsToAutoClick.push(button);
}
}
newButtonsInP.clear();
}
p.dataset.processedByGenerator = "true";
}
}
// ==================== 后续处理(对两种模式都通用) ====================
for (const p of messageContainers) {
delete p.dataset.processedByGenerator;
}
if (buttonsToAutoClick.length > 0) {
let finalButtons = buttonsToAutoClick;
const maxClicks = parseInt(settings.maxAutoClicks, 10);
if (!isNaN(maxClicks) && maxClicks > 0 && finalButtons.length > maxClicks) {
console.log(`自动点击任务过多 (${finalButtons.length}个),已根据设置限制为最新的 ${maxClicks} 个。`);
finalButtons = finalButtons.slice(-maxClicks);
}
processAutoClickQueue(finalButtons);
}
}
async function initializeCache() {
console.log("文生图插件:开始初始化缓存系统...");
try {
// 1. 初始化全局ID计数器
globalImageCounter = await GM_getValue('globalImageCounter', 0);
// 2. 加载位置到ID的映射
const mapData = await Storereadonly('locationMap');
if (mapData && mapData.data) {
locationToImageIdMap = mapData.data;
}
// 3. 清理过期的图片
const allImages = await StoreGetAll();
const now = new Date().getTime();
const expiredIds = [];
let mapNeedsUpdate = false;
allImages.forEach(image => {
if (delDate(image.timestamp, now)) {
expiredIds.push(image.id);
}
});
if (expiredIds.length > 0) {
console.log(`清理 ${expiredIds.length} 个过期缓存...`);
// 并行删除过期的图片
await Promise.all(expiredIds.map(id => Storedelete(id)));
// 从映射表中移除对过期ID的引用
for (const locationHash in locationToImageIdMap) {
if (expiredIds.includes(locationToImageIdMap[locationHash])) {
delete locationToImageIdMap[locationHash];
mapNeedsUpdate = true;
}
}
if (mapNeedsUpdate) {
await Storereadwrite({ id: 'locationMap', data: locationToImageIdMap });
}
}
console.log(`文生图插件:缓存初始化完成。当前总图片数: ${allImages.length - expiredIds.length}。`);
} catch (error) {
console.error("文生图插件:缓存初始化失败!", error);
// 出错时重置,防止后续连锁错误
globalImageCounter = 0;
locationToImageIdMap = {};
}
}
async function setItemImg(imageId, imgData, locationHash) {
// 1. 创建图片数据对象
const imageRecord = {
id: imageId,
imageData: imgData,
timestamp: new Date().getTime()
};
// 2. 更新位置映射
locationToImageIdMap[locationHash] = imageId;
// 3. 并行写入数据库
await Promise.all([
Storereadwrite(imageRecord), // 写入图片数据
Storereadwrite({ id: 'locationMap', data: locationToImageIdMap }) // 更新映射表
]);
};
async function getItemImg(imageId) {
const imageData = await Storereadonly(imageId);
return imageData ? imageData.imageData : false; // 返回图片Base64数据
};
function delDate(timestamp, now = new Date().getTime()){
if (Number(settings.cache) === 0) return false;
// 使用传入的时间戳进行比较
return (now - Number(timestamp)) > (Number(settings.cache) * 86400000);
}
async function zhengmian(text,prom,AQT) { return [text, prom, AQT].filter(Boolean).join(', '); }
async function fumian(text,UCP){ return [UCP, text].filter(Boolean).join(', '); }
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
function addSmoothShakeEffect(imgEl) {
if (getComputedStyle(imgEl).position === 'static') imgEl.style.position = 'relative';
const start = Date.now(), duration = 300, amp = 3;
function shake() { const el = Date.now() - start; if (el < duration) { imgEl.style.left = `${amp * Math.sin(el/duration*Math.PI*10)}px`; requestAnimationFrame(shake); } else { imgEl.style.left = '0px'; } }
requestAnimationFrame(shake);
}
async function processGenerateImageRequest(reqId, prompt, width, height) {
if (settings.currentMode !== 'sd') {
console.warn("processGenerateImageRequest is only compatible with SD mode.");
return;
}
const btn = document.createElement('button');
btn.id = "compat_btn_" + reqId;
btn.dataset.link = prompt.replaceAll(/[《》\n]/g, (m) => ({'《':'<','》':'>','\n':','}[m]));
let imgData = await sd(btn, width, height);
await sendGenerateImageResponse(reqId, imgData);
}
async function sendGenerateImageResponse(reqId, response) {
if(!response) return;
try { localStorage.setItem("generate_image_response_"+reqId, JSON.stringify({imageData: response})); } catch (e) { console.error('油猴发送响应失败:', e); }
}
function sendGenerateTagsResponse() {
const resp={"value":settings.startTag,"value2":settings.endTag}, id=Date.now().toString(36);
try { localStorage.setItem("generate_image_response_tags"+id, JSON.stringify({imageData:resp})); setTimeout(()=>localStorage.removeItem("generate_image_response_tags"+id), 1000); } catch(e){}
}
function deleteStorageByKeywords() {
for (let i = localStorage.length - 1; i >= 0; i--) { const key = localStorage.key(i); if (key.includes('generate_image_response') || key.includes('messageDetails')) localStorage.removeItem(key); }
}
unsafeWindow.addEventListener('storage', (e) => {
if (e.key.startsWith("generate_image_request_")) { try { const req = JSON.parse(e.newValue); localStorage.removeItem(e.key); processGenerateImageRequest(req.id, req.prompt, req.width, req.height); } catch(err){} }
});
deleteStorageByKeywords();
sendGenerateTagsResponse();
})();