您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
使用sd、nai或comfyui 配合ai在酒馆生成插图,支持动态加载模型/采样器/调度器/Hires修复算法/Lora,并为NAI添加Vibe Transfer功能,为ComfyUI添加工作流管理和动态节点连接。新增标签搜索功能。1.8增加sd一致化。
// ==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(); })();