2686 lines
116 KiB
JavaScript
2686 lines
116 KiB
JavaScript
// novel-draw.js
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 导入
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
import { getContext } from "../../../../../extensions.js";
|
||
import { saveBase64AsFile } from "../../../../../utils.js";
|
||
import { extensionFolderPath } from "../../core/constants.js";
|
||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||
import { NovelDrawStorage } from "../../core/server-storage.js";
|
||
import {
|
||
openDB, storePreview, getPreview, getPreviewsBySlot,
|
||
getDisplayPreviewForSlot, storeFailedPlaceholder, deleteFailedRecordsForSlot,
|
||
setSlotSelection, clearSlotSelection,
|
||
updatePreviewSavedUrl, deletePreview, getCacheStats, clearExpiredCache, clearAllCache,
|
||
getGallerySummary, getCharacterPreviews, openGallery, closeGallery, destroyGalleryCache
|
||
} from './gallery-cache.js';
|
||
import {
|
||
PROVIDER_MAP,
|
||
LLMServiceError,
|
||
loadTagGuide,
|
||
generateScenePlan,
|
||
parseImagePlan,
|
||
} from './llm-service.js';
|
||
import {
|
||
openCloudPresetsModal,
|
||
downloadPresetAsFile,
|
||
parsePresetData,
|
||
destroyCloudPresets
|
||
} from './cloud-presets.js';
|
||
import { postToIframe, isTrustedMessage } from "../../core/iframe-messaging.js";
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 常量
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
const MODULE_KEY = 'novelDraw';
|
||
const SERVER_FILE_KEY = 'settings';
|
||
const HTML_PATH = `${extensionFolderPath}/modules/novel-draw/novel-draw.html`;
|
||
const NOVELAI_IMAGE_API = 'https://image.novelai.net/ai/generate-image';
|
||
const CONFIG_VERSION = 4;
|
||
const MAX_SEED = 0xFFFFFFFF;
|
||
const API_TEST_TIMEOUT = 15000;
|
||
const PLACEHOLDER_REGEX = /\[image:([a-z0-9\-_]+)\]/gi;
|
||
const INITIAL_RENDER_MESSAGE_LIMIT = 1;
|
||
|
||
const events = createModuleEvents(MODULE_KEY);
|
||
|
||
const ImageState = { PREVIEW: 'preview', SAVING: 'saving', SAVED: 'saved', REFRESHING: 'refreshing', FAILED: 'failed' };
|
||
|
||
const ErrorType = {
|
||
NETWORK: { code: 'network', label: '网络', desc: '连接超时或网络不稳定' },
|
||
AUTH: { code: 'auth', label: '认证', desc: 'API Key 无效或过期' },
|
||
QUOTA: { code: 'quota', label: '额度', desc: 'Anlas 点数不足' },
|
||
PARSE: { code: 'parse', label: '解析', desc: '返回格式无法解析' },
|
||
LLM: { code: 'llm', label: 'LLM', desc: '场景分析失败' },
|
||
TIMEOUT: { code: 'timeout', label: '超时', desc: '请求超时' },
|
||
UNKNOWN: { code: 'unknown', label: '错误', desc: '未知错误' },
|
||
CACHE_LOST: { code: 'cache_lost', label: '缓存丢失', desc: '图片缓存已过期' },
|
||
};
|
||
|
||
const DEFAULT_PARAMS_PRESET = {
|
||
id: '', name: '默认 (V4.5 Full)',
|
||
positivePrefix: 'best quality, amazing quality, very aesthetic, absurdres,',
|
||
negativePrefix: 'lowres, bad anatomy, bad hands, missing fingers, extra digits, fewer digits, cropped, worst quality, low quality, normal quality, jpeg artifacts, signature, watermark, username, blurry',
|
||
params: {
|
||
model: 'nai-diffusion-4-5-full', sampler: 'k_euler_ancestral', scheduler: 'karras',
|
||
steps: 28, scale: 6, width: 1216, height: 832, seed: -1,
|
||
qualityToggle: true, autoSmea: false, ucPreset: 0, cfg_rescale: 0,
|
||
variety_boost: false, sm: false, sm_dyn: false, decrisper: false,
|
||
},
|
||
};
|
||
|
||
const DEFAULT_SETTINGS = {
|
||
configVersion: CONFIG_VERSION,
|
||
updatedAt: 0,
|
||
mode: 'manual',
|
||
apiKey: '',
|
||
cacheDays: 3,
|
||
selectedParamsPresetId: null,
|
||
paramsPresets: [],
|
||
requestDelay: { min: 15000, max: 30000 },
|
||
timeout: 60000,
|
||
llmApi: { provider: 'st', url: '', key: '', model: '', modelCache: [] },
|
||
useStream: false,
|
||
useWorldInfo: false,
|
||
characterTags: [],
|
||
overrideSize: 'default',
|
||
showFloorButton: true,
|
||
showFloatingButton: false,
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 状态
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
let autoBusy = false;
|
||
let overlayCreated = false;
|
||
let frameReady = false;
|
||
let jsZipLoaded = false;
|
||
let moduleInitialized = false;
|
||
let touchState = null;
|
||
let settingsCache = null;
|
||
let settingsLoaded = false;
|
||
let generationAbortController = null;
|
||
let messageObserver = null;
|
||
let ensureNovelDrawPanelRef = null;
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 样式
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function ensureStyles() {
|
||
if (document.getElementById('nd-styles')) return;
|
||
const style = document.createElement('style');
|
||
style.id = 'nd-styles';
|
||
style.textContent = `
|
||
.xb-nd-img{margin:0.8em 0;text-align:center;position:relative;display:block;width:100%;border-radius:14px;padding:4px}
|
||
.xb-nd-img[data-state="preview"]{border:1px dashed rgba(255,152,0,0.35)}
|
||
.xb-nd-img[data-state="failed"]{border:1px dashed rgba(248,113,113,0.5);background:rgba(248,113,113,0.05);padding:20px}
|
||
.xb-nd-img.busy img{opacity:0.5}
|
||
.xb-nd-img-wrap{position:relative;overflow:hidden;border-radius:10px;touch-action:pan-y pinch-zoom}
|
||
.xb-nd-img img{width:auto;height:auto;max-width:100%;border-radius:10px;cursor:pointer;box-shadow:0 3px 15px rgba(0,0,0,0.25);display:block;user-select:none;-webkit-user-drag:none;transition:transform 0.25s ease,opacity 0.2s ease;will-change:transform,opacity}
|
||
.xb-nd-img img.sliding-left{animation:ndSlideOutLeft 0.25s ease forwards}
|
||
.xb-nd-img img.sliding-right{animation:ndSlideOutRight 0.25s ease forwards}
|
||
.xb-nd-img img.sliding-in-left{animation:ndSlideInLeft 0.25s ease forwards}
|
||
.xb-nd-img img.sliding-in-right{animation:ndSlideInRight 0.25s ease forwards}
|
||
@keyframes ndSlideOutLeft{from{transform:translateX(0);opacity:1}to{transform:translateX(-30%);opacity:0}}
|
||
@keyframes ndSlideOutRight{from{transform:translateX(0);opacity:1}to{transform:translateX(30%);opacity:0}}
|
||
@keyframes ndSlideInLeft{from{transform:translateX(30%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||
@keyframes ndSlideInRight{from{transform:translateX(-30%);opacity:0}to{transform:translateX(0);opacity:1}}
|
||
.xb-nd-nav-pill{position:absolute;bottom:10px;left:10px;display:inline-flex;align-items:center;gap:2px;background:rgba(0,0,0,0.75);border-radius:20px;padding:4px 6px;font-size:12px;color:rgba(255,255,255,0.9);font-weight:500;user-select:none;z-index:5;opacity:0.85;transition:opacity 0.2s}
|
||
.xb-nd-nav-pill:hover{opacity:1}
|
||
.xb-nd-nav-arrow{width:24px;height:24px;border:none;background:transparent;color:rgba(255,255,255,0.8);cursor:pointer;display:flex;align-items:center;justify-content:center;border-radius:50%;font-size:14px;transition:background 0.15s,color 0.15s;padding:0}
|
||
.xb-nd-nav-arrow:hover{background:rgba(255,255,255,0.15);color:#fff}
|
||
.xb-nd-nav-arrow:disabled{opacity:0.3;cursor:not-allowed}
|
||
.xb-nd-nav-text{min-width:36px;text-align:center;font-variant-numeric:tabular-nums;padding:0 2px}
|
||
@media(hover:none),(pointer:coarse){.xb-nd-nav-pill{opacity:0.9;padding:5px 8px}}
|
||
.xb-nd-menu-wrap{position:absolute;top:8px;right:8px;z-index:10}
|
||
.xb-nd-menu-wrap.busy{pointer-events:none;opacity:0.3}
|
||
.xb-nd-menu-trigger{width:32px;height:32px;border-radius:50%;border:none;background:rgba(0,0,0,0.75);color:rgba(255,255,255,0.85);cursor:pointer;font-size:16px;display:flex;align-items:center;justify-content:center;transition:all 0.15s;opacity:0.85}
|
||
.xb-nd-menu-trigger:hover{background:rgba(0,0,0,0.85);opacity:1}
|
||
.xb-nd-menu-wrap.open .xb-nd-menu-trigger{background:rgba(0,0,0,0.9);opacity:1}
|
||
.xb-nd-dropdown{position:absolute;top:calc(100% + 4px);right:0;background:rgba(20,20,24,0.98);border:1px solid rgba(255,255,255,0.12);border-radius:16px;padding:4px;display:none;flex-direction:column;gap:2px;opacity:0;visibility:hidden;transform:translateY(-4px) scale(0.96);transform-origin:top right;transition:all 0.15s ease;box-shadow:0 8px 24px rgba(0,0,0,0.4);pointer-events:none}
|
||
.xb-nd-menu-wrap.open .xb-nd-dropdown{display:flex;opacity:1;visibility:visible;transform:translateY(0) scale(1);pointer-events:auto}
|
||
.xb-nd-dropdown button{width:32px;height:32px;border:none;background:transparent;color:rgba(255,255,255,0.85);cursor:pointer;font-size:14px;border-radius:50%;display:flex;align-items:center;justify-content:center;transition:background 0.15s;padding:0;margin:0}
|
||
.xb-nd-dropdown button:hover{background:rgba(255,255,255,0.15)}
|
||
.xb-nd-dropdown button[data-action="delete-image"]{color:rgba(248,113,113,0.9)}
|
||
.xb-nd-dropdown button[data-action="delete-image"]:hover{background:rgba(248,113,113,0.2)}
|
||
.xb-nd-indicator{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:rgba(0,0,0,0.85);padding:8px 16px;border-radius:8px;color:#fff;font-size:12px;z-index:10}
|
||
.xb-nd-edit{animation:nd-slide-up 0.2s ease-out}
|
||
.xb-nd-edit-input{width:100%;min-height:60px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;font-size:12px;padding:8px;resize:vertical;font-family:monospace}
|
||
.xb-nd-failed-icon{color:rgba(248,113,113,0.9);font-size:24px;margin-bottom:8px}
|
||
.xb-nd-failed-title{color:rgba(255,255,255,0.7);font-size:13px;margin-bottom:4px}
|
||
.xb-nd-failed-desc{color:rgba(255,255,255,0.4);font-size:11px;margin-bottom:12px}
|
||
.xb-nd-failed-btns{display:flex;gap:8px;justify-content:center;flex-wrap:wrap}
|
||
.xb-nd-failed-btns button{padding:8px 16px;border-radius:8px;font-size:12px;cursor:pointer;transition:all 0.15s}
|
||
.xb-nd-retry-btn{border:1px solid rgba(212,165,116,0.5);background:rgba(212,165,116,0.2);color:#fff}
|
||
.xb-nd-retry-btn:hover{background:rgba(212,165,116,0.35)}
|
||
.xb-nd-edit-btn{border:1px solid rgba(255,255,255,0.2);background:rgba(255,255,255,0.1);color:#fff}
|
||
.xb-nd-edit-btn:hover{background:rgba(255,255,255,0.2)}
|
||
.xb-nd-remove-btn{border:1px solid rgba(248,113,113,0.3);background:transparent;color:rgba(248,113,113,0.8)}
|
||
.xb-nd-remove-btn:hover{background:rgba(248,113,113,0.1)}
|
||
@keyframes nd-slide-up{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}
|
||
@keyframes fadeInOut{0%{opacity:0;transform:translateX(-50%) translateY(-10px)}15%{opacity:1;transform:translateX(-50%) translateY(0)}85%{opacity:1;transform:translateX(-50%) translateY(0)}100%{opacity:0;transform:translateX(-50%) translateY(-10px)}}
|
||
#xiaobaix-novel-draw-overlay .nd-backdrop{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7)}
|
||
#xiaobaix-novel-draw-overlay .nd-frame-wrap{position:absolute;z-index:1}
|
||
#xiaobaix-novel-draw-iframe{width:100%;height:100%;border:none;background:#0d1117}
|
||
@media(min-width:769px){#xiaobaix-novel-draw-overlay .nd-frame-wrap{top:12px;left:12px;right:12px;bottom:12px}#xiaobaix-novel-draw-iframe{border-radius:12px}}
|
||
@media(max-width:768px){#xiaobaix-novel-draw-overlay .nd-frame-wrap{top:0;left:0;right:0;bottom:0}#xiaobaix-novel-draw-iframe{border-radius:0}}
|
||
.xb-nd-edit-content{max-height:250px;overflow-y:auto;margin-bottom:8px}
|
||
.xb-nd-edit-content::-webkit-scrollbar{width:4px}
|
||
.xb-nd-edit-content::-webkit-scrollbar-thumb{background:rgba(255,255,255,0.2);border-radius:2px}
|
||
.xb-nd-edit-group{margin-bottom:8px}
|
||
.xb-nd-edit-group:last-child{margin-bottom:0}
|
||
.xb-nd-edit-label{font-size:10px;color:rgba(255,255,255,0.5);margin-bottom:4px;display:flex;align-items:center;gap:4px}
|
||
.xb-nd-edit-label .char-icon{font-size:8px;opacity:0.6}
|
||
.xb-nd-edit-input{width:100%;min-height:50px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.15);border-radius:6px;color:#fff;font-size:11px;padding:8px;resize:vertical;font-family:monospace;line-height:1.4}
|
||
.xb-nd-edit-input:focus{border-color:rgba(212,165,116,0.5);outline:none}
|
||
.xb-nd-edit-input.scene{border-color:rgba(212,165,116,0.3)}
|
||
.xb-nd-edit-input.char{border-color:rgba(147,197,253,0.3)}
|
||
.xb-nd-live-btn{position:absolute;bottom:10px;right:10px;z-index:5;padding:4px 8px;background:rgba(0,0,0,0.75);border:none;border-radius:12px;color:rgba(255,255,255,0.7);font-size:10px;font-weight:700;letter-spacing:0.5px;cursor:pointer;opacity:0.7;transition:all 0.2s;user-select:none}
|
||
.xb-nd-live-btn:hover{opacity:1;background:rgba(0,0,0,0.85)}
|
||
.xb-nd-live-btn.active{background:rgba(62,207,142,0.9);color:#fff;opacity:1;box-shadow:0 0 10px rgba(62,207,142,0.5)}
|
||
.xb-nd-live-btn.loading{pointer-events:none;opacity:0.5}
|
||
.xb-nd-img.mode-live .xb-nd-img-wrap>img{opacity:0!important;pointer-events:none}
|
||
.xb-nd-live-canvas{border-radius:10px;overflow:hidden}
|
||
.xb-nd-live-canvas canvas{display:block;border-radius:10px}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 工具函数
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function createPlaceholder(slotId) { return `[image:${slotId}]`; }
|
||
|
||
function extractSlotIds(mes) {
|
||
const ids = new Set();
|
||
if (!mes) return ids;
|
||
let match;
|
||
const regex = new RegExp(PLACEHOLDER_REGEX.source, 'gi');
|
||
while ((match = regex.exec(mes)) !== null) ids.add(match[1]);
|
||
return ids;
|
||
}
|
||
|
||
function isModuleEnabled() { return moduleInitialized; }
|
||
|
||
function generateSlotId() { return `slot-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; }
|
||
|
||
function generateImgId() { return `img-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; }
|
||
|
||
function joinTags(...parts) {
|
||
return parts
|
||
.filter(Boolean)
|
||
.map(p => String(p).trim().replace(/[,、]/g, ',').replace(/^,+|,+$/g, ''))
|
||
.filter(p => p.length > 0)
|
||
.join(', ');
|
||
}
|
||
|
||
function escapeHtml(str) { return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||
|
||
function escapeRegexChars(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
||
|
||
function getChatCharacterName() {
|
||
const ctx = getContext();
|
||
if (ctx.groupId) return String(ctx.groups?.[ctx.groupId]?.id ?? 'group');
|
||
return String(ctx.characters?.[ctx.characterId]?.name || 'character');
|
||
}
|
||
|
||
function findLastAIMessageId() {
|
||
const ctx = getContext();
|
||
const chat = ctx.chat || [];
|
||
let id = chat.length - 1;
|
||
while (id >= 0 && chat[id]?.is_user) id--;
|
||
return id;
|
||
}
|
||
|
||
function randomDelay(min, max) {
|
||
const safeMin = (min > 0) ? min : DEFAULT_SETTINGS.requestDelay.min;
|
||
const safeMax = (max > 0) ? max : DEFAULT_SETTINGS.requestDelay.max;
|
||
return safeMin + Math.random() * (safeMax - safeMin);
|
||
}
|
||
|
||
function showToast(message, type = 'success', duration = 2500) {
|
||
const colors = { success: 'rgba(62,207,142,0.95)', error: 'rgba(248,113,113,0.95)', info: 'rgba(212,165,116,0.95)' };
|
||
const toast = document.createElement('div');
|
||
toast.textContent = message;
|
||
toast.style.cssText = `position:fixed;top:20px;left:50%;transform:translateX(-50%);background:${colors[type] || colors.info};color:#fff;padding:10px 20px;border-radius:8px;font-size:13px;z-index:99999;animation:fadeInOut ${duration / 1000}s ease-in-out;max-width:80vw;text-align:center;word-break:break-all`;
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), duration);
|
||
}
|
||
|
||
function isMessageBeingEdited(messageId) {
|
||
const mesElement = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (!mesElement) return false;
|
||
return mesElement.querySelector('textarea.edit_textarea') !== null || mesElement.classList.contains('editing');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 中止控制
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function abortGeneration() {
|
||
if (generationAbortController) {
|
||
generationAbortController.abort();
|
||
generationAbortController = null;
|
||
autoBusy = false;
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isGenerating() {
|
||
return autoBusy || generationAbortController !== null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 错误处理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
class NovelDrawError extends Error {
|
||
constructor(message, errorType = ErrorType.UNKNOWN) {
|
||
super(message);
|
||
this.name = 'NovelDrawError';
|
||
this.errorType = errorType;
|
||
}
|
||
}
|
||
|
||
function classifyError(e) {
|
||
if (e instanceof LLMServiceError) return ErrorType.LLM;
|
||
if (e instanceof NovelDrawError && e.errorType) return e.errorType;
|
||
const msg = (e?.message || '').toLowerCase();
|
||
if (msg.includes('network') || msg.includes('fetch') || msg.includes('failed to fetch')) return ErrorType.NETWORK;
|
||
if (msg.includes('401') || msg.includes('key') || msg.includes('auth')) return ErrorType.AUTH;
|
||
if (msg.includes('402') || msg.includes('anlas') || msg.includes('quota')) return ErrorType.QUOTA;
|
||
if (msg.includes('timeout') || msg.includes('abort')) return ErrorType.TIMEOUT;
|
||
if (msg.includes('parse') || msg.includes('json')) return ErrorType.PARSE;
|
||
if (msg.includes('llm') || msg.includes('xbgenraw')) return ErrorType.LLM;
|
||
return { ...ErrorType.UNKNOWN, desc: e?.message || '未知错误' };
|
||
}
|
||
|
||
function parseApiError(status, text) {
|
||
switch (status) {
|
||
case 401: return new NovelDrawError('API Key 无效', ErrorType.AUTH);
|
||
case 402: return new NovelDrawError('Anlas 不足', ErrorType.QUOTA);
|
||
case 429: return new NovelDrawError('请求频繁', ErrorType.QUOTA);
|
||
case 500:
|
||
case 502:
|
||
case 503: return new NovelDrawError('服务不可用', ErrorType.NETWORK);
|
||
default: return new NovelDrawError(`失败: ${text || status}`, ErrorType.UNKNOWN);
|
||
}
|
||
}
|
||
|
||
function handleFetchError(e) {
|
||
if (e.name === 'AbortError') return new NovelDrawError('超时', ErrorType.TIMEOUT);
|
||
if (e.message?.includes('Failed to fetch')) return new NovelDrawError('网络错误', ErrorType.NETWORK);
|
||
if (e instanceof NovelDrawError) return e;
|
||
return new NovelDrawError(e.message || '未知错误', ErrorType.UNKNOWN);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 设置管理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function normalizeSettings(saved) {
|
||
const merged = { ...DEFAULT_SETTINGS, ...(saved || {}) };
|
||
merged.llmApi = { ...DEFAULT_SETTINGS.llmApi, ...(saved?.llmApi || {}) };
|
||
|
||
if (!merged.paramsPresets?.length) {
|
||
const id = generateSlotId();
|
||
merged.paramsPresets = [{ ...JSON.parse(JSON.stringify(DEFAULT_PARAMS_PRESET)), id }];
|
||
merged.selectedParamsPresetId = id;
|
||
}
|
||
if (!merged.selectedParamsPresetId) merged.selectedParamsPresetId = merged.paramsPresets[0]?.id;
|
||
if (!Number.isFinite(Number(merged.updatedAt))) merged.updatedAt = 0;
|
||
|
||
merged.characterTags = (merged.characterTags || []).map(char => ({
|
||
id: char.id || generateSlotId(),
|
||
name: char.name || '',
|
||
aliases: char.aliases || [],
|
||
type: char.type || 'girl',
|
||
appearance: char.appearance || char.tags || '',
|
||
negativeTags: char.negativeTags || '',
|
||
posX: char.posX ?? 0.5,
|
||
posY: char.posY ?? 0.5,
|
||
}));
|
||
|
||
delete merged.llmPresets;
|
||
delete merged.selectedLlmPresetId;
|
||
|
||
return merged;
|
||
}
|
||
|
||
async function loadSettings() {
|
||
if (settingsLoaded && settingsCache) return settingsCache;
|
||
|
||
try {
|
||
const saved = await NovelDrawStorage.get(SERVER_FILE_KEY, null);
|
||
settingsCache = normalizeSettings(saved || {});
|
||
|
||
if (!saved || saved.configVersion !== CONFIG_VERSION) {
|
||
settingsCache.configVersion = CONFIG_VERSION;
|
||
settingsCache.updatedAt = Date.now();
|
||
NovelDrawStorage.set(SERVER_FILE_KEY, settingsCache);
|
||
}
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 加载设置失败:', e);
|
||
settingsCache = normalizeSettings({});
|
||
}
|
||
|
||
settingsLoaded = true;
|
||
return settingsCache;
|
||
}
|
||
|
||
function getSettings() {
|
||
if (!settingsCache) {
|
||
console.warn('[NovelDraw] 设置未加载,使用默认值');
|
||
settingsCache = normalizeSettings({});
|
||
}
|
||
return settingsCache;
|
||
}
|
||
|
||
function saveSettings(s) {
|
||
const next = normalizeSettings(s);
|
||
next.updatedAt = Date.now();
|
||
next.configVersion = CONFIG_VERSION;
|
||
settingsCache = next;
|
||
return next;
|
||
}
|
||
|
||
async function saveSettingsAndToast(s, okText = '已保存') {
|
||
const next = saveSettings(s);
|
||
|
||
try {
|
||
const data = await NovelDrawStorage.load();
|
||
data[SERVER_FILE_KEY] = next;
|
||
NovelDrawStorage._dirtyVersion = (NovelDrawStorage._dirtyVersion || 0) + 1;
|
||
|
||
await NovelDrawStorage.saveNow({ silent: false });
|
||
postStatus('success', okText);
|
||
return true;
|
||
} catch (e) {
|
||
postStatus('error', `保存失败:${e?.message || '网络异常'}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function getActiveParamsPreset() {
|
||
const s = getSettings();
|
||
return s.paramsPresets.find(p => p.id === s.selectedParamsPresetId) || s.paramsPresets[0];
|
||
}
|
||
|
||
async function notifySettingsUpdated() {
|
||
try {
|
||
const { refreshPresetSelect, updateAutoModeUI } = await import('./floating-panel.js');
|
||
refreshPresetSelect?.();
|
||
updateAutoModeUI?.();
|
||
} catch {}
|
||
|
||
if (overlayCreated && frameReady) {
|
||
try { await sendInitData(); } catch {}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// JSZip
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function ensureJSZip() {
|
||
if (window.JSZip) return window.JSZip;
|
||
if (jsZipLoaded) {
|
||
await new Promise(r => {
|
||
const c = setInterval(() => {
|
||
if (window.JSZip) { clearInterval(c); r(); }
|
||
}, 50);
|
||
});
|
||
return window.JSZip;
|
||
}
|
||
jsZipLoaded = true;
|
||
return new Promise((resolve, reject) => {
|
||
const s = document.createElement('script');
|
||
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';
|
||
s.onload = () => resolve(window.JSZip);
|
||
s.onerror = () => reject(new NovelDrawError('JSZip 加载失败', ErrorType.NETWORK));
|
||
document.head.appendChild(s);
|
||
});
|
||
}
|
||
|
||
async function extractImageFromZip(zipData) {
|
||
const JSZip = await ensureJSZip();
|
||
const zip = await JSZip.loadAsync(zipData);
|
||
const file = Object.values(zip.files).find(f => f.name.endsWith('.png') || f.name.endsWith('.webp'));
|
||
if (!file) throw new NovelDrawError('ZIP 无图片', ErrorType.PARSE);
|
||
return await file.async('base64');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 角色检测与标签组装
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function detectPresentCharacters(messageText, characterTags) {
|
||
if (!messageText || !characterTags?.length) return [];
|
||
const text = messageText.toLowerCase();
|
||
const present = [];
|
||
|
||
for (const char of characterTags) {
|
||
if (!char.name) continue;
|
||
const names = [char.name, ...(char.aliases || [])].filter(Boolean);
|
||
const isPresent = names.some(name => {
|
||
const lowerName = name.toLowerCase();
|
||
return text.includes(lowerName) || new RegExp(`\\b${escapeRegexChars(lowerName)}\\b`, 'i').test(text);
|
||
});
|
||
|
||
if (isPresent) {
|
||
present.push({
|
||
name: char.name,
|
||
aliases: char.aliases || [],
|
||
type: char.type || 'girl',
|
||
appearance: char.appearance || '',
|
||
negativeTags: char.negativeTags || '',
|
||
posX: char.posX ?? 0.5,
|
||
posY: char.posY ?? 0.5,
|
||
});
|
||
}
|
||
}
|
||
return present;
|
||
}
|
||
|
||
function assembleCharacterPrompts(sceneChars, knownCharacters) {
|
||
return sceneChars.map(char => {
|
||
const known = knownCharacters.find(k =>
|
||
k.name === char.name || k.aliases?.includes(char.name)
|
||
);
|
||
|
||
if (known) {
|
||
|
||
return {
|
||
prompt: joinTags(known.type, known.appearance, char.costume, char.action, char.interact),
|
||
uc: known.negativeTags || '',
|
||
center: { x: known.posX ?? 0.5, y: known.posY ?? 0.5 }
|
||
};
|
||
} else {
|
||
|
||
return {
|
||
prompt: joinTags(char.type, char.appear, char.costume, char.action, char.interact),
|
||
uc: '',
|
||
center: { x: 0.5, y: 0.5 }
|
||
};
|
||
}
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// NovelAI API
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function testApiConnection(apiKey) {
|
||
if (!apiKey) throw new NovelDrawError('请填写 API Key', ErrorType.AUTH);
|
||
const controller = new AbortController();
|
||
const tid = setTimeout(() => controller.abort(), API_TEST_TIMEOUT);
|
||
try {
|
||
const res = await fetch(NOVELAI_IMAGE_API, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ input: 'test', model: 'nai-diffusion-3', action: 'generate', parameters: { width: 64, height: 64, steps: 1 } }),
|
||
signal: controller.signal,
|
||
});
|
||
clearTimeout(tid);
|
||
if (res.status === 401) throw new NovelDrawError('API Key 无效', ErrorType.AUTH);
|
||
if (res.status === 400 || res.status === 402 || res.ok) return { success: true };
|
||
throw new NovelDrawError(`返回: ${res.status}`, ErrorType.NETWORK);
|
||
} catch (e) {
|
||
clearTimeout(tid);
|
||
throw handleFetchError(e);
|
||
}
|
||
}
|
||
|
||
function buildNovelAIRequestBody({ scene, characterPrompts, negativePrompt, params }) {
|
||
const dp = DEFAULT_PARAMS_PRESET.params;
|
||
const width = params?.width ?? dp.width;
|
||
const height = params?.height ?? dp.height;
|
||
const seed = (params?.seed >= 0) ? params.seed : Math.floor(Math.random() * (MAX_SEED + 1));
|
||
const modelName = params?.model ?? dp.model;
|
||
const isV3 = modelName.includes('nai-diffusion-3') || modelName.includes('furry-3');
|
||
const isV45 = modelName.includes('nai-diffusion-4-5');
|
||
|
||
if (isV3) {
|
||
const allCharPrompts = characterPrompts.map(cp => cp.prompt).filter(Boolean).join(', ');
|
||
const fullPrompt = scene ? `${scene}, ${allCharPrompts}` : allCharPrompts;
|
||
const allNegative = [negativePrompt, ...characterPrompts.map(cp => cp.uc)].filter(Boolean).join(', ');
|
||
|
||
return {
|
||
action: 'generate',
|
||
input: String(fullPrompt || ''),
|
||
model: modelName,
|
||
parameters: {
|
||
width, height,
|
||
scale: params?.scale ?? dp.scale,
|
||
seed,
|
||
sampler: params?.sampler ?? dp.sampler,
|
||
noise_schedule: params?.scheduler ?? dp.scheduler,
|
||
steps: params?.steps ?? dp.steps,
|
||
n_samples: 1,
|
||
negative_prompt: String(allNegative || ''),
|
||
ucPreset: params?.ucPreset ?? dp.ucPreset,
|
||
sm: params?.sm ?? dp.sm,
|
||
sm_dyn: params?.sm_dyn ?? dp.sm_dyn,
|
||
dynamic_thresholding: params?.decrisper ?? dp.decrisper,
|
||
},
|
||
};
|
||
}
|
||
|
||
let skipCfgAboveSigma = null;
|
||
if (isV45 && params?.variety_boost) {
|
||
skipCfgAboveSigma = Math.pow((width * height) / 1011712, 0.5) * 58;
|
||
}
|
||
|
||
const charCaptions = characterPrompts.map(cp => ({
|
||
char_caption: cp.prompt || '',
|
||
centers: [cp.center || { x: 0.5, y: 0.5 }]
|
||
}));
|
||
|
||
const negativeCharCaptions = characterPrompts.map(cp => ({
|
||
char_caption: cp.uc || '',
|
||
centers: [cp.center || { x: 0.5, y: 0.5 }]
|
||
}));
|
||
|
||
return {
|
||
action: 'generate',
|
||
input: String(scene || ''),
|
||
model: modelName,
|
||
parameters: {
|
||
params_version: 3,
|
||
width, height,
|
||
scale: params?.scale ?? dp.scale,
|
||
seed,
|
||
sampler: params?.sampler ?? dp.sampler,
|
||
noise_schedule: params?.scheduler ?? dp.scheduler,
|
||
steps: params?.steps ?? dp.steps,
|
||
n_samples: 1,
|
||
ucPreset: params?.ucPreset ?? dp.ucPreset,
|
||
qualityToggle: params?.qualityToggle ?? dp.qualityToggle,
|
||
autoSmea: params?.autoSmea ?? dp.autoSmea,
|
||
cfg_rescale: params?.cfg_rescale ?? dp.cfg_rescale,
|
||
dynamic_thresholding: false,
|
||
controlnet_strength: 1,
|
||
legacy: false,
|
||
add_original_image: true,
|
||
legacy_v3_extend: false,
|
||
use_coords: false,
|
||
legacy_uc: false,
|
||
normalize_reference_strength_multiple: true,
|
||
inpaintImg2ImgStrength: 1,
|
||
deliberate_euler_ancestral_bug: false,
|
||
prefer_brownian: true,
|
||
image_format: 'png',
|
||
skip_cfg_above_sigma: skipCfgAboveSigma,
|
||
characterPrompts: characterPrompts.map(cp => ({
|
||
prompt: cp.prompt || '',
|
||
uc: cp.uc || '',
|
||
center: cp.center || { x: 0.5, y: 0.5 },
|
||
enabled: true
|
||
})),
|
||
v4_prompt: {
|
||
caption: {
|
||
base_caption: String(scene || ''),
|
||
char_captions: charCaptions
|
||
},
|
||
use_coords: false,
|
||
use_order: true
|
||
},
|
||
v4_negative_prompt: {
|
||
caption: {
|
||
base_caption: String(negativePrompt || ''),
|
||
char_captions: negativeCharCaptions
|
||
},
|
||
legacy_uc: false
|
||
},
|
||
negative_prompt: String(negativePrompt || ''),
|
||
},
|
||
};
|
||
}
|
||
|
||
async function generateNovelImage({ scene, characterPrompts, negativePrompt, params, signal }) {
|
||
const settings = getSettings();
|
||
if (!settings.apiKey) throw new NovelDrawError('请先配置 API Key', ErrorType.AUTH);
|
||
|
||
const finalParams = { ...params };
|
||
|
||
if (settings.overrideSize && settings.overrideSize !== 'default') {
|
||
const { SIZE_OPTIONS } = await import('./floating-panel.js');
|
||
const sizeOpt = SIZE_OPTIONS.find(o => o.value === settings.overrideSize);
|
||
if (sizeOpt && sizeOpt.width && sizeOpt.height) {
|
||
finalParams.width = sizeOpt.width;
|
||
finalParams.height = sizeOpt.height;
|
||
}
|
||
}
|
||
|
||
const controller = new AbortController();
|
||
const timeout = (settings.timeout > 0) ? settings.timeout : DEFAULT_SETTINGS.timeout;
|
||
const tid = setTimeout(() => controller.abort(), timeout);
|
||
|
||
if (signal) {
|
||
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||
}
|
||
|
||
const t0 = Date.now();
|
||
|
||
try {
|
||
if (signal?.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||
|
||
const res = await fetch(NOVELAI_IMAGE_API, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${settings.apiKey}` },
|
||
signal: controller.signal,
|
||
body: JSON.stringify(buildNovelAIRequestBody({
|
||
scene,
|
||
characterPrompts,
|
||
negativePrompt,
|
||
params: finalParams
|
||
})),
|
||
});
|
||
if (!res.ok) throw parseApiError(res.status, await res.text().catch(() => ''));
|
||
const buffer = await res.arrayBuffer();
|
||
const base64 = await extractImageFromZip(buffer);
|
||
console.log(`[NovelDraw] 完成 ${Date.now() - t0}ms`);
|
||
return base64;
|
||
} catch (e) {
|
||
if (signal?.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||
throw handleFetchError(e);
|
||
} finally {
|
||
clearTimeout(tid);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 锚点定位
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function findAnchorPosition(mes, anchor) {
|
||
if (!anchor || !mes) return -1;
|
||
const a = anchor.trim();
|
||
let idx = mes.indexOf(a);
|
||
if (idx !== -1) return idx + a.length;
|
||
if (a.length > 8) {
|
||
const short = a.slice(-10);
|
||
idx = mes.indexOf(short);
|
||
if (idx !== -1) return idx + short.length;
|
||
}
|
||
const norm = s => s.replace(/[\s,。!?、""'':;…\-\n\r]/g, '');
|
||
const normMes = norm(mes);
|
||
const normA = norm(a);
|
||
if (normA.length >= 4) {
|
||
const key = normA.slice(-6);
|
||
const normIdx = normMes.indexOf(key);
|
||
if (normIdx !== -1) {
|
||
let origIdx = 0, nIdx = 0;
|
||
while (origIdx < mes.length && nIdx < normIdx + key.length) {
|
||
if (norm(mes[origIdx]) === normMes[nIdx]) nIdx++;
|
||
origIdx++;
|
||
}
|
||
return origIdx;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function findNearestSentenceEnd(mes, startPos) {
|
||
if (startPos < 0 || !mes) return startPos;
|
||
if (startPos >= mes.length) return mes.length;
|
||
|
||
const maxLookAhead = 80;
|
||
const endLimit = Math.min(mes.length, startPos + maxLookAhead);
|
||
const basicEnders = new Set(['\u3002', '\uFF01', '\uFF1F', '!', '?', '\u2026']);
|
||
const closingMarks = new Set(['\u201D', '\u201C', '\u2019', '\u2018', '\u300D', '\u300F', '\u3011', '\uFF09', ')', '"', "'", '*', '~', '\uFF5E', ']']);
|
||
|
||
const eatClosingMarks = (pos) => {
|
||
while (pos < mes.length && closingMarks.has(mes[pos])) pos++;
|
||
return pos;
|
||
};
|
||
|
||
if (startPos > 0 && basicEnders.has(mes[startPos - 1])) {
|
||
return eatClosingMarks(startPos);
|
||
}
|
||
|
||
for (let i = 0; i < maxLookAhead && startPos + i < endLimit; i++) {
|
||
const pos = startPos + i;
|
||
const char = mes[pos];
|
||
if (char === '\n') return pos + 1;
|
||
if (basicEnders.has(char)) return eatClosingMarks(pos + 1);
|
||
if (char === '.' && mes.slice(pos, pos + 3) === '...') return eatClosingMarks(pos + 3);
|
||
}
|
||
|
||
return startPos;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 图片渲染
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function buildImageHtml({ slotId, imgId, url, tags, positive, messageId, state = ImageState.PREVIEW, historyCount = 1, currentIndex = 0 }) {
|
||
const escapedTags = escapeHtml(tags);
|
||
const escapedPositive = escapeHtml(positive);
|
||
const isPreview = state === ImageState.PREVIEW;
|
||
const isBusy = state === ImageState.SAVING || state === ImageState.REFRESHING;
|
||
|
||
let indicator = '';
|
||
if (state === ImageState.SAVING) indicator = '<div class="xb-nd-indicator">💾 保存中...</div>';
|
||
else if (state === ImageState.REFRESHING) indicator = '<div class="xb-nd-indicator">🔄 生成中...</div>';
|
||
|
||
const border = isPreview ? 'border:1px dashed rgba(255,152,0,0.35);' : '';
|
||
const lazyAttr = url.startsWith('data:') ? '' : 'loading="lazy"';
|
||
const displayVersion = historyCount - currentIndex;
|
||
|
||
const navPill = `<div class="xb-nd-nav-pill" data-total="${historyCount}" data-current="${currentIndex}">
|
||
<button class="xb-nd-nav-arrow" data-action="nav-prev" title="上一版本" ${currentIndex >= historyCount - 1 ? 'disabled' : ''}>‹</button>
|
||
<span class="xb-nd-nav-text">${displayVersion} / ${historyCount}</span>
|
||
<button class="xb-nd-nav-arrow" data-action="nav-next" title="${currentIndex === 0 ? '重新生成' : '下一版本'}">›</button>
|
||
</div>`;
|
||
const liveBtn = `<button class="xb-nd-live-btn" data-action="toggle-live" title="Live Photo">LIVE</button>`;
|
||
|
||
const menuBusy = isBusy ? ' busy' : '';
|
||
const menuHtml = `<div class="xb-nd-menu-wrap${menuBusy}">
|
||
<button class="xb-nd-menu-trigger" data-action="toggle-menu" title="操作">⋮</button>
|
||
<div class="xb-nd-dropdown">
|
||
${isPreview ? '<button data-action="save-image" title="保存到服务器">⬇</button>' : ''}
|
||
<button data-action="refresh-image" title="重新生成">⟳</button>
|
||
<button data-action="edit-tags" title="编辑TAG">✐️</button>
|
||
<button data-action="delete-image" title="删除">✕</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
return `<div class="xb-nd-img ${isBusy ? 'busy' : ''}" data-slot-id="${slotId}" data-img-id="${imgId}" data-tags="${escapedTags}" data-positive="${escapedPositive}" data-mesid="${messageId}" data-state="${state}" data-current-index="${currentIndex}" data-history-count="${historyCount}" style="margin:0.8em auto;position:relative;display:block;width:fit-content;max-width:100%;${border}border-radius:14px;padding:4px;">
|
||
${indicator}
|
||
<div class="xb-nd-img-wrap" data-total="${historyCount}">
|
||
<img src="${url}" style="max-width:100%;width:auto;height:auto;border-radius:10px;cursor:pointer;box-shadow:0 3px 15px rgba(0,0,0,0.25);${isBusy ? 'opacity:0.5;' : ''}" data-action="open-gallery" ${lazyAttr}>
|
||
${navPill}
|
||
${liveBtn}
|
||
</div>
|
||
${menuHtml}
|
||
<div class="xb-nd-edit" style="display:none;position:absolute;bottom:8px;left:8px;right:8px;background:rgba(0,0,0,0.9);border-radius:10px;padding:10px;text-align:left;z-index:15;">
|
||
<div style="font-size:11px;color:rgba(255,255,255,0.6);margin-bottom:6px;">编辑 TAG(场景描述)</div>
|
||
<textarea class="xb-nd-edit-input">${escapedTags}</textarea>
|
||
<div style="display:flex;gap:6px;margin-top:8px;">
|
||
<button data-action="save-tags" style="flex:1;padding:6px 12px;background:rgba(212,165,116,0.3);border:1px solid rgba(212,165,116,0.5);border-radius:6px;color:#fff;font-size:12px;cursor:pointer;">保存 TAG</button>
|
||
<button data-action="cancel-edit" style="padding:6px 12px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;font-size:12px;cursor:pointer;">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function buildFailedPlaceholderHtml({ slotId, messageId, tags, positive, errorType, errorMessage }) {
|
||
const escapedTags = escapeHtml(tags);
|
||
const escapedPositive = escapeHtml(positive);
|
||
return `<div class="xb-nd-img" data-slot-id="${slotId}" data-tags="${escapedTags}" data-positive="${escapedPositive}" data-mesid="${messageId}" data-state="failed" style="margin:0.8em 0;text-align:center;position:relative;display:block;width:100%;border:1px dashed rgba(248,113,113,0.5);border-radius:14px;padding:20px;background:rgba(248,113,113,0.05);">
|
||
<div class="xb-nd-failed-icon">⚠️</div>
|
||
<div class="xb-nd-failed-title">${escapeHtml(errorType || '生成失败')}</div>
|
||
<div class="xb-nd-failed-desc">${escapeHtml(errorMessage || '点击重试')}</div>
|
||
<div class="xb-nd-failed-btns">
|
||
<button class="xb-nd-retry-btn" data-action="retry-image">🔄 重新生成</button>
|
||
<button class="xb-nd-edit-btn" data-action="edit-tags">✏️ 编辑TAG</button>
|
||
<button class="xb-nd-remove-btn" data-action="remove-placeholder">🗑️ 移除</button>
|
||
</div>
|
||
<div class="xb-nd-edit" style="display:none;margin-top:12px;text-align:left;">
|
||
<div style="font-size:11px;color:rgba(255,255,255,0.6);margin-bottom:6px;">编辑 TAG(场景描述)</div>
|
||
<textarea class="xb-nd-edit-input">${escapedTags}</textarea>
|
||
<div style="display:flex;gap:6px;margin-top:8px;">
|
||
<button data-action="save-tags-retry" style="flex:1;padding:6px 12px;background:rgba(212,165,116,0.3);border:1px solid rgba(212,165,116,0.5);border-radius:6px;color:#fff;font-size:12px;cursor:pointer;">保存并重试</button>
|
||
<button data-action="cancel-edit" style="padding:6px 12px;background:rgba(255,255,255,0.1);border:1px solid rgba(255,255,255,0.2);border-radius:6px;color:#fff;font-size:12px;cursor:pointer;">取消</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function setImageState(container, state) {
|
||
container.dataset.state = state;
|
||
const imgEl = container.querySelector('img');
|
||
const menuWrap = container.querySelector('.xb-nd-menu-wrap');
|
||
const isBusy = state === ImageState.SAVING || state === ImageState.REFRESHING;
|
||
|
||
if (imgEl) imgEl.style.opacity = isBusy ? '0.5' : '';
|
||
if (menuWrap) {
|
||
menuWrap.style.pointerEvents = isBusy ? 'none' : '';
|
||
menuWrap.style.opacity = isBusy ? '0.3' : '';
|
||
}
|
||
container.style.border = state === ImageState.PREVIEW ? '1px dashed rgba(255,152,0,0.35)' : 'none';
|
||
|
||
const dropdown = container.querySelector('.xb-nd-dropdown');
|
||
if (dropdown) {
|
||
const saveItem = dropdown.querySelector('[data-action="save-image"]');
|
||
if (state === ImageState.PREVIEW && !saveItem) {
|
||
dropdown.insertAdjacentHTML('afterbegin', `<button data-action="save-image" title="保存到服务器">💾</button>`);
|
||
} else if (state !== ImageState.PREVIEW && saveItem) {
|
||
saveItem.remove();
|
||
}
|
||
}
|
||
|
||
container.querySelector('.xb-nd-indicator')?.remove();
|
||
if (state === ImageState.SAVING) container.insertAdjacentHTML('afterbegin', '<div class="xb-nd-indicator">💾 保存中...</div>');
|
||
else if (state === ImageState.REFRESHING) container.insertAdjacentHTML('afterbegin', '<div class="xb-nd-indicator">🔄 生成中...</div>');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 图片导航
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function navigateToImage(container, targetIndex) {
|
||
try {
|
||
const { destroyLiveEffect } = await import('./image-live-effect.js');
|
||
destroyLiveEffect(container);
|
||
container.querySelector('.xb-nd-live-btn')?.classList.remove('active');
|
||
} catch {}
|
||
|
||
const slotId = container.dataset.slotId;
|
||
const historyCount = parseInt(container.dataset.historyCount) || 1;
|
||
const currentIndex = parseInt(container.dataset.currentIndex) || 0;
|
||
|
||
if (targetIndex < 0 || targetIndex >= historyCount || targetIndex === currentIndex) return;
|
||
|
||
const previews = await getPreviewsBySlot(slotId);
|
||
const successPreviews = previews.filter(p => p.status !== 'failed' && p.base64);
|
||
if (targetIndex >= successPreviews.length) return;
|
||
|
||
const targetPreview = successPreviews[targetIndex];
|
||
if (!targetPreview) return;
|
||
|
||
const imgEl = container.querySelector('.xb-nd-img-wrap > img');
|
||
if (!imgEl) return;
|
||
|
||
const direction = targetIndex > currentIndex ? 'left' : 'right';
|
||
imgEl.classList.add(`sliding-${direction}`);
|
||
|
||
await new Promise(r => setTimeout(r, 200));
|
||
|
||
const newUrl = targetPreview.savedUrl || `data:image/png;base64,${targetPreview.base64}`;
|
||
imgEl.src = newUrl;
|
||
container.dataset.imgId = targetPreview.imgId;
|
||
container.dataset.tags = escapeHtml(targetPreview.tags || '');
|
||
container.dataset.positive = escapeHtml(targetPreview.positive || '');
|
||
container.dataset.currentIndex = targetIndex;
|
||
|
||
setImageState(container, targetPreview.savedUrl ? ImageState.SAVED : ImageState.PREVIEW);
|
||
updateNavControls(container, targetIndex, historyCount);
|
||
await setSlotSelection(slotId, targetPreview.imgId);
|
||
|
||
imgEl.classList.remove(`sliding-${direction}`);
|
||
imgEl.classList.add(`sliding-in-${direction === 'left' ? 'left' : 'right'}`);
|
||
|
||
await new Promise(r => setTimeout(r, 250));
|
||
imgEl.classList.remove('sliding-in-left', 'sliding-in-right');
|
||
}
|
||
|
||
function updateNavControls(container, currentIndex, total) {
|
||
const pill = container.querySelector('.xb-nd-nav-pill');
|
||
if (pill) {
|
||
pill.dataset.current = currentIndex;
|
||
pill.dataset.total = total;
|
||
const text = pill.querySelector('.xb-nd-nav-text');
|
||
if (text) text.textContent = `${total - currentIndex} / ${total}`;
|
||
const prevBtn = pill.querySelector('[data-action="nav-prev"]');
|
||
const nextBtn = pill.querySelector('[data-action="nav-next"]');
|
||
if (prevBtn) prevBtn.disabled = currentIndex >= total - 1;
|
||
if (nextBtn) {
|
||
nextBtn.disabled = false;
|
||
nextBtn.title = currentIndex === 0 ? '重新生成' : '下一版本';
|
||
}
|
||
}
|
||
const wrap = container.querySelector('.xb-nd-img-wrap');
|
||
if (wrap) wrap.dataset.total = total;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 触摸滑动
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function handleTouchStart(e) {
|
||
const wrap = e.target.closest('.xb-nd-img-wrap');
|
||
if (!wrap) return;
|
||
const total = parseInt(wrap.dataset.total) || 1;
|
||
if (total <= 1) return;
|
||
const touch = e.touches[0];
|
||
touchState = {
|
||
startX: touch.clientX,
|
||
startY: touch.clientY,
|
||
startTime: Date.now(),
|
||
wrap,
|
||
container: wrap.closest('.xb-nd-img'),
|
||
moved: false
|
||
};
|
||
}
|
||
|
||
function handleTouchMove(e) {
|
||
if (!touchState) return;
|
||
const touch = e.touches[0];
|
||
const dx = touch.clientX - touchState.startX;
|
||
const dy = touch.clientY - touchState.startY;
|
||
if (!touchState.moved && Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||
touchState.moved = true;
|
||
e.preventDefault();
|
||
}
|
||
if (touchState.moved) e.preventDefault();
|
||
}
|
||
|
||
function handleTouchEnd(e) {
|
||
if (!touchState || !touchState.moved) { touchState = null; return; }
|
||
const touch = e.changedTouches[0];
|
||
const dx = touch.clientX - touchState.startX;
|
||
const dt = Date.now() - touchState.startTime;
|
||
const { container } = touchState;
|
||
const currentIndex = parseInt(container.dataset.currentIndex) || 0;
|
||
const historyCount = parseInt(container.dataset.historyCount) || 1;
|
||
const isSwipe = Math.abs(dx) > 50 || (Math.abs(dx) > 30 && dt < 300);
|
||
if (isSwipe) {
|
||
if (dx < 0 && currentIndex < historyCount - 1) navigateToImage(container, currentIndex + 1);
|
||
else if (dx > 0 && currentIndex > 0) navigateToImage(container, currentIndex - 1);
|
||
}
|
||
touchState = null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 事件委托与图片操作
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function handleLiveToggle(container) {
|
||
const btn = container.querySelector('.xb-nd-live-btn');
|
||
if (!btn || btn.classList.contains('loading')) return;
|
||
|
||
btn.classList.add('loading');
|
||
|
||
try {
|
||
const { toggleLiveEffect } = await import('./image-live-effect.js');
|
||
const isActive = await toggleLiveEffect(container);
|
||
btn.classList.remove('loading');
|
||
btn.classList.toggle('active', isActive);
|
||
} catch (e) {
|
||
console.error('[NovelDraw] Live effect failed:', e);
|
||
btn.classList.remove('loading');
|
||
}
|
||
}
|
||
|
||
function setupEventDelegation() {
|
||
if (window._xbNovelEventsBound) return;
|
||
window._xbNovelEventsBound = true;
|
||
|
||
document.addEventListener('click', async (e) => {
|
||
const container = e.target.closest('.xb-nd-img');
|
||
if (!container) {
|
||
if (document.querySelector('.xb-nd-menu-wrap.open')) {
|
||
const clickedMenuWrap = e.target.closest('.xb-nd-menu-wrap');
|
||
if (!clickedMenuWrap) {
|
||
document.querySelectorAll('.xb-nd-menu-wrap.open').forEach(w => w.classList.remove('open'));
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
const actionEl = e.target.closest('[data-action]');
|
||
const action = actionEl?.dataset?.action;
|
||
if (!action) return;
|
||
|
||
e.preventDefault();
|
||
e.stopImmediatePropagation();
|
||
|
||
switch (action) {
|
||
case 'toggle-menu': {
|
||
const wrap = container.querySelector('.xb-nd-menu-wrap');
|
||
if (!wrap) break;
|
||
document.querySelectorAll('.xb-nd-menu-wrap.open').forEach(w => {
|
||
if (w !== wrap) w.classList.remove('open');
|
||
});
|
||
wrap.classList.toggle('open');
|
||
break;
|
||
}
|
||
case 'open-gallery':
|
||
await handleImageClick(container);
|
||
break;
|
||
case 'refresh-image':
|
||
container.querySelector('.xb-nd-menu-wrap')?.classList.remove('open');
|
||
await refreshSingleImage(container);
|
||
break;
|
||
case 'save-image':
|
||
container.querySelector('.xb-nd-menu-wrap')?.classList.remove('open');
|
||
await saveSingleImage(container);
|
||
break;
|
||
case 'edit-tags':
|
||
container.querySelector('.xb-nd-menu-wrap')?.classList.remove('open');
|
||
toggleEditPanel(container, true);
|
||
break;
|
||
case 'save-tags':
|
||
await saveEditedTags(container);
|
||
break;
|
||
case 'cancel-edit':
|
||
toggleEditPanel(container, false);
|
||
break;
|
||
case 'retry-image':
|
||
await retryFailedImage(container);
|
||
break;
|
||
case 'save-tags-retry':
|
||
await saveTagsAndRetry(container);
|
||
break;
|
||
case 'remove-placeholder':
|
||
await removePlaceholder(container);
|
||
break;
|
||
case 'delete-image':
|
||
container.querySelector('.xb-nd-menu-wrap')?.classList.remove('open');
|
||
await deleteCurrentImage(container);
|
||
break;
|
||
case 'nav-prev': {
|
||
const i = parseInt(container.dataset.currentIndex) || 0;
|
||
const t = parseInt(container.dataset.historyCount) || 1;
|
||
if (i < t - 1) await navigateToImage(container, i + 1);
|
||
break;
|
||
}
|
||
case 'nav-next': {
|
||
const i = parseInt(container.dataset.currentIndex) || 0;
|
||
if (i > 0) await navigateToImage(container, i - 1);
|
||
else await refreshSingleImage(container);
|
||
break;
|
||
}
|
||
case 'toggle-live': {
|
||
handleLiveToggle(container);
|
||
break;
|
||
}
|
||
}
|
||
}, { capture: true });
|
||
|
||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||
}
|
||
|
||
async function handleImageClick(container) {
|
||
const slotId = container.dataset.slotId;
|
||
const messageId = parseInt(container.dataset.mesid);
|
||
await openGallery(slotId, messageId, {
|
||
onUse: (sid, msgId, selected, historyCount) => {
|
||
const cont = document.querySelector(`.xb-nd-img[data-slot-id="${sid}"]`);
|
||
if (cont) {
|
||
cont.querySelector('img').src = selected.savedUrl || `data:image/png;base64,${selected.base64}`;
|
||
cont.dataset.imgId = selected.imgId;
|
||
cont.dataset.tags = escapeHtml(selected.tags || '');
|
||
cont.dataset.positive = escapeHtml(selected.positive || '');
|
||
setImageState(cont, selected.savedUrl ? ImageState.SAVED : ImageState.PREVIEW);
|
||
updateNavControls(cont, 0, historyCount);
|
||
cont.dataset.currentIndex = '0';
|
||
cont.dataset.historyCount = String(historyCount);
|
||
}
|
||
},
|
||
onSave: (imgId, url) => {
|
||
const cont = document.querySelector(`.xb-nd-img[data-img-id="${imgId}"]`);
|
||
if (cont) {
|
||
cont.querySelector('img').src = url;
|
||
setImageState(cont, ImageState.SAVED);
|
||
}
|
||
},
|
||
onDelete: async (sid, deletedImgId, remainingPreviews) => {
|
||
const cont = document.querySelector(`.xb-nd-img[data-slot-id="${sid}"]`);
|
||
if (cont && cont.dataset.imgId === deletedImgId && remainingPreviews.length > 0) {
|
||
const latest = remainingPreviews[0];
|
||
cont.querySelector('img').src = latest.savedUrl || `data:image/png;base64,${latest.base64}`;
|
||
cont.dataset.imgId = latest.imgId;
|
||
setImageState(cont, latest.savedUrl ? ImageState.SAVED : ImageState.PREVIEW);
|
||
}
|
||
if (cont) {
|
||
cont.dataset.historyCount = String(remainingPreviews.length);
|
||
updateNavControls(cont, 0, remainingPreviews.length);
|
||
}
|
||
},
|
||
onBecameEmpty: (sid, msgId, lastImageInfo) => {
|
||
const cont = document.querySelector(`.xb-nd-img[data-slot-id="${sid}"]`);
|
||
if (!cont) return;
|
||
const failedHtml = buildFailedPlaceholderHtml({
|
||
slotId: sid,
|
||
messageId: msgId,
|
||
tags: lastImageInfo.tags || '',
|
||
positive: lastImageInfo.positive || '',
|
||
errorType: '图片已删除',
|
||
errorMessage: '点击重试可重新生成'
|
||
});
|
||
// Template-only UI markup built locally.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
cont.outerHTML = failedHtml;
|
||
},
|
||
});
|
||
}
|
||
|
||
async function toggleEditPanel(container, show) {
|
||
const editPanel = container.querySelector('.xb-nd-edit');
|
||
const btnsPanel = container.querySelector('.xb-nd-btns') || container.querySelector('.xb-nd-failed-btns');
|
||
|
||
if (!editPanel) return;
|
||
|
||
const origLabel = Array.from(editPanel.children).find(el =>
|
||
el.tagName === 'DIV' && el.textContent.includes('编辑 TAG')
|
||
);
|
||
const origTextarea = Array.from(editPanel.children).find(el =>
|
||
el.tagName === 'TEXTAREA' && !el.dataset.type
|
||
);
|
||
|
||
if (show) {
|
||
const imgId = container.dataset.imgId;
|
||
const currentTags = container.dataset.tags || '';
|
||
|
||
let preview = null;
|
||
if (imgId) {
|
||
try { preview = await getPreview(imgId); } catch {}
|
||
}
|
||
|
||
if (origLabel) origLabel.style.display = 'none';
|
||
if (origTextarea) origTextarea.style.display = 'none';
|
||
|
||
let scrollWrap = editPanel.querySelector('.xb-nd-edit-scroll');
|
||
if (!scrollWrap) {
|
||
scrollWrap = document.createElement('div');
|
||
scrollWrap.className = 'xb-nd-edit-scroll';
|
||
editPanel.insertBefore(scrollWrap, editPanel.firstChild);
|
||
}
|
||
|
||
let html = `
|
||
<div class="xb-nd-edit-group">
|
||
<div class="xb-nd-edit-group-label">🎬 场景</div>
|
||
<textarea class="xb-nd-edit-input" data-type="scene">${escapeHtml(currentTags)}</textarea>
|
||
</div>`;
|
||
|
||
if (preview?.characterPrompts?.length > 0) {
|
||
preview.characterPrompts.forEach((char, i) => {
|
||
const name = char.name || `角色 ${i + 1}`;
|
||
html += `
|
||
<div class="xb-nd-edit-group">
|
||
<div class="xb-nd-edit-group-label">👤 ${escapeHtml(name)}</div>
|
||
<textarea class="xb-nd-edit-input" data-type="char" data-index="${i}">${escapeHtml(char.prompt || '')}</textarea>
|
||
</div>`;
|
||
});
|
||
}
|
||
|
||
// Escaped data used in template.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
scrollWrap.innerHTML = html;
|
||
editPanel.style.display = 'block';
|
||
|
||
if (btnsPanel) {
|
||
btnsPanel.style.opacity = '0.3';
|
||
btnsPanel.style.pointerEvents = 'none';
|
||
}
|
||
|
||
scrollWrap.querySelector('[data-type="scene"]')?.focus();
|
||
|
||
} else {
|
||
const scrollWrap = editPanel.querySelector('.xb-nd-edit-scroll');
|
||
if (scrollWrap) scrollWrap.remove();
|
||
|
||
if (origLabel) origLabel.style.display = '';
|
||
if (origTextarea) {
|
||
origTextarea.style.display = '';
|
||
origTextarea.value = container.dataset.tags || '';
|
||
}
|
||
|
||
editPanel.style.display = 'none';
|
||
if (btnsPanel) {
|
||
btnsPanel.style.opacity = '';
|
||
btnsPanel.style.pointerEvents = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
async function saveEditedTags(container) {
|
||
const imgId = container.dataset.imgId;
|
||
const slotId = container.dataset.slotId;
|
||
const messageId = parseInt(container.dataset.mesid);
|
||
const editPanel = container.querySelector('.xb-nd-edit');
|
||
|
||
if (!editPanel) return;
|
||
|
||
const sceneInput = editPanel.querySelector('textarea[data-type="scene"]');
|
||
if (!sceneInput) return;
|
||
|
||
const newSceneTags = sceneInput.value.trim();
|
||
if (!newSceneTags) {
|
||
alert('场景 TAG 不能为空');
|
||
return;
|
||
}
|
||
|
||
let originalPreview = null;
|
||
try {
|
||
originalPreview = await getPreview(imgId);
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 获取原始预览失败:', e);
|
||
}
|
||
|
||
const charInputs = editPanel.querySelectorAll('textarea[data-type="char"]');
|
||
let newCharPrompts = null;
|
||
|
||
if (charInputs.length > 0 && originalPreview?.characterPrompts?.length > 0) {
|
||
newCharPrompts = [];
|
||
charInputs.forEach(input => {
|
||
const index = parseInt(input.dataset.index);
|
||
const newPrompt = input.value.trim();
|
||
|
||
if (originalPreview.characterPrompts[index]) {
|
||
newCharPrompts.push({
|
||
...originalPreview.characterPrompts[index],
|
||
prompt: newPrompt
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
container.dataset.tags = newSceneTags;
|
||
|
||
if (originalPreview) {
|
||
const preset = getActiveParamsPreset();
|
||
const newPositive = joinTags(preset?.positivePrefix, newSceneTags);
|
||
|
||
await storePreview({
|
||
imgId,
|
||
slotId: originalPreview.slotId || slotId,
|
||
messageId,
|
||
base64: originalPreview.base64,
|
||
tags: newSceneTags,
|
||
positive: newPositive,
|
||
savedUrl: originalPreview.savedUrl,
|
||
characterPrompts: newCharPrompts || originalPreview.characterPrompts,
|
||
negativePrompt: originalPreview.negativePrompt,
|
||
});
|
||
|
||
container.dataset.positive = escapeHtml(newPositive);
|
||
}
|
||
|
||
toggleEditPanel(container, false);
|
||
|
||
const charCount = newCharPrompts?.length || 0;
|
||
const msg = charCount > 0
|
||
? `TAG 已保存 (场景 + ${charCount} 个角色)`
|
||
: 'TAG 已保存';
|
||
showToast(msg);
|
||
}
|
||
|
||
async function refreshSingleImage(container) {
|
||
const tags = container.dataset.tags;
|
||
const currentState = container.dataset.state;
|
||
const slotId = container.dataset.slotId;
|
||
const messageId = parseInt(container.dataset.mesid);
|
||
const currentImgId = container.dataset.imgId;
|
||
|
||
if (!tags || currentState === ImageState.SAVING || currentState === ImageState.REFRESHING || !slotId) return;
|
||
|
||
try {
|
||
const { destroyLiveEffect } = await import('./image-live-effect.js');
|
||
destroyLiveEffect(container);
|
||
container.querySelector('.xb-nd-live-btn')?.classList.remove('active');
|
||
} catch {}
|
||
|
||
toggleEditPanel(container, false);
|
||
setImageState(container, ImageState.REFRESHING);
|
||
|
||
try {
|
||
const preset = getActiveParamsPreset();
|
||
const settings = getSettings();
|
||
|
||
let characterPrompts = null;
|
||
let negativePrompt = preset.negativePrefix || '';
|
||
|
||
if (currentImgId) {
|
||
const existingPreview = await getPreview(currentImgId);
|
||
if (existingPreview?.characterPrompts?.length) {
|
||
characterPrompts = existingPreview.characterPrompts;
|
||
}
|
||
if (existingPreview?.negativePrompt) {
|
||
negativePrompt = existingPreview.negativePrompt;
|
||
}
|
||
}
|
||
|
||
if (!characterPrompts) {
|
||
const ctx = getContext();
|
||
const message = ctx.chat?.[messageId];
|
||
const presentCharacters = detectPresentCharacters(String(message?.mes || ''), settings.characterTags || []);
|
||
characterPrompts = presentCharacters.map(c => ({
|
||
prompt: joinTags(c.type, c.appearance),
|
||
uc: c.negativeTags || '',
|
||
center: { x: c.posX ?? 0.5, y: c.posY ?? 0.5 }
|
||
}));
|
||
}
|
||
|
||
const scene = joinTags(preset.positivePrefix, tags);
|
||
|
||
const base64 = await generateNovelImage({
|
||
scene,
|
||
characterPrompts,
|
||
negativePrompt,
|
||
params: preset.params || {}
|
||
});
|
||
|
||
const newImgId = generateImgId();
|
||
await storePreview({
|
||
imgId: newImgId,
|
||
slotId,
|
||
messageId,
|
||
base64,
|
||
tags,
|
||
positive: scene,
|
||
characterPrompts,
|
||
negativePrompt,
|
||
});
|
||
await setSlotSelection(slotId, newImgId);
|
||
|
||
container.querySelector('img').src = `data:image/png;base64,${base64}`;
|
||
container.dataset.imgId = newImgId;
|
||
container.dataset.positive = escapeHtml(scene);
|
||
container.dataset.currentIndex = '0';
|
||
setImageState(container, ImageState.PREVIEW);
|
||
|
||
const previews = await getPreviewsBySlot(slotId);
|
||
const successPreviews = previews.filter(p => p.status !== 'failed' && p.base64);
|
||
container.dataset.historyCount = String(successPreviews.length);
|
||
updateNavControls(container, 0, successPreviews.length);
|
||
|
||
showToast(`图片已刷新(共 ${successPreviews.length} 个版本)`);
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 刷新失败:', e);
|
||
alert('刷新失败: ' + e.message);
|
||
setImageState(container, ImageState.PREVIEW);
|
||
}
|
||
}
|
||
|
||
async function saveSingleImage(container) {
|
||
const imgId = container.dataset.imgId;
|
||
const slotId = container.dataset.slotId;
|
||
const currentState = container.dataset.state;
|
||
if (currentState !== ImageState.PREVIEW) return;
|
||
const preview = await getPreview(imgId);
|
||
if (!preview?.base64) { alert('图片数据丢失,请刷新'); return; }
|
||
setImageState(container, ImageState.SAVING);
|
||
try {
|
||
const charName = preview.characterName || getChatCharacterName();
|
||
const url = await saveBase64AsFile(preview.base64, charName, `novel_${imgId}`, 'png');
|
||
await updatePreviewSavedUrl(imgId, url);
|
||
await setSlotSelection(slotId, imgId);
|
||
container.querySelector('img').src = url;
|
||
setImageState(container, ImageState.SAVED);
|
||
showToast(`已保存到: ${url}`, 'success', 5000);
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 保存失败:', e);
|
||
alert('保存失败: ' + e.message);
|
||
setImageState(container, ImageState.PREVIEW);
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentImage(container) {
|
||
const imgId = container.dataset.imgId;
|
||
const slotId = container.dataset.slotId;
|
||
const messageId = parseInt(container.dataset.mesid);
|
||
const tags = container.dataset.tags || '';
|
||
const positive = container.dataset.positive || '';
|
||
|
||
if (!confirm('确定删除这张图片吗?')) return;
|
||
|
||
try {
|
||
await deletePreview(imgId);
|
||
const previews = await getPreviewsBySlot(slotId);
|
||
const successPreviews = previews.filter(p => p.status !== 'failed' && p.base64);
|
||
|
||
if (successPreviews.length > 0) {
|
||
const latest = successPreviews[0];
|
||
await setSlotSelection(slotId, latest.imgId);
|
||
container.querySelector('img').src = latest.savedUrl || `data:image/png;base64,${latest.base64}`;
|
||
container.dataset.imgId = latest.imgId;
|
||
container.dataset.tags = escapeHtml(latest.tags || '');
|
||
container.dataset.positive = escapeHtml(latest.positive || '');
|
||
container.dataset.currentIndex = '0';
|
||
container.dataset.historyCount = String(successPreviews.length);
|
||
setImageState(container, latest.savedUrl ? ImageState.SAVED : ImageState.PREVIEW);
|
||
updateNavControls(container, 0, successPreviews.length);
|
||
showToast(`已删除(剩余 ${successPreviews.length} 张)`);
|
||
} else {
|
||
await clearSlotSelection(slotId);
|
||
const failedHtml = buildFailedPlaceholderHtml({
|
||
slotId,
|
||
messageId,
|
||
tags,
|
||
positive,
|
||
errorType: '图片已删除',
|
||
errorMessage: '点击重试可重新生成'
|
||
});
|
||
// Template-only UI markup built locally.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
container.outerHTML = failedHtml;
|
||
showToast('图片已删除,占位符已保留');
|
||
}
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 删除失败:', e);
|
||
showToast('删除失败: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function retryFailedImage(container) {
|
||
const slotId = container.dataset.slotId;
|
||
const messageId = parseInt(container.dataset.mesid);
|
||
const tags = container.dataset.tags;
|
||
if (!slotId) return;
|
||
|
||
// Template-only UI markup.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
container.innerHTML = `<div style="padding:30px;text-align:center;color:rgba(255,255,255,0.6);"><div style="font-size:24px;margin-bottom:8px;">🎨</div><div>生成中...</div></div>`;
|
||
|
||
try {
|
||
const preset = getActiveParamsPreset();
|
||
const settings = getSettings();
|
||
const scene = tags ? joinTags(preset.positivePrefix, tags) : preset.positivePrefix;
|
||
const negativePrompt = preset.negativePrefix || '';
|
||
|
||
let characterPrompts = null;
|
||
const failedPreviews = await getPreviewsBySlot(slotId);
|
||
const latestFailed = failedPreviews.find(p => p.status === 'failed');
|
||
if (latestFailed?.characterPrompts?.length) {
|
||
characterPrompts = latestFailed.characterPrompts;
|
||
}
|
||
|
||
if (!characterPrompts) {
|
||
const ctx = getContext();
|
||
const message = ctx.chat?.[messageId];
|
||
const presentCharacters = detectPresentCharacters(String(message?.mes || ''), settings.characterTags || []);
|
||
characterPrompts = presentCharacters.map(c => ({
|
||
prompt: joinTags(c.type, c.appearance),
|
||
uc: c.negativeTags || '',
|
||
center: { x: c.posX ?? 0.5, y: c.posY ?? 0.5 }
|
||
}));
|
||
}
|
||
|
||
const base64 = await generateNovelImage({
|
||
scene,
|
||
characterPrompts,
|
||
negativePrompt,
|
||
params: preset.params || {}
|
||
});
|
||
|
||
const newImgId = generateImgId();
|
||
await storePreview({
|
||
imgId: newImgId,
|
||
slotId,
|
||
messageId,
|
||
base64,
|
||
tags: tags || '',
|
||
positive: scene,
|
||
characterPrompts,
|
||
negativePrompt,
|
||
});
|
||
await deleteFailedRecordsForSlot(slotId);
|
||
await setSlotSelection(slotId, newImgId);
|
||
|
||
const imgHtml = buildImageHtml({
|
||
slotId,
|
||
imgId: newImgId,
|
||
url: `data:image/png;base64,${base64}`,
|
||
tags: tags || '',
|
||
positive: scene,
|
||
messageId,
|
||
state: ImageState.PREVIEW,
|
||
historyCount: 1,
|
||
currentIndex: 0
|
||
});
|
||
// Template-only UI markup built locally.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
container.outerHTML = imgHtml;
|
||
showToast('图片生成成功!');
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 重试失败:', e);
|
||
const errorType = classifyError(e);
|
||
await storeFailedPlaceholder({
|
||
slotId,
|
||
messageId,
|
||
tags: tags || '',
|
||
positive: container.dataset.positive || '',
|
||
errorType: errorType.code,
|
||
errorMessage: errorType.desc
|
||
});
|
||
// Template-only UI markup built locally.
|
||
// eslint-disable-next-line no-unsanitized/property
|
||
container.outerHTML = buildFailedPlaceholderHtml({
|
||
slotId,
|
||
messageId,
|
||
tags: tags || '',
|
||
positive: container.dataset.positive || '',
|
||
errorType: errorType.label,
|
||
errorMessage: errorType.desc
|
||
});
|
||
showToast(`重试失败: ${errorType.desc}`, 'error');
|
||
}
|
||
}
|
||
|
||
async function saveTagsAndRetry(container) {
|
||
const textarea = container.querySelector('.xb-nd-edit-input');
|
||
if (!textarea) return;
|
||
const newTags = textarea.value.trim();
|
||
if (!newTags) { alert('TAG 不能为空'); return; }
|
||
container.dataset.tags = newTags;
|
||
const preset = getActiveParamsPreset();
|
||
container.dataset.positive = escapeHtml(joinTags(preset?.positivePrefix, newTags));
|
||
toggleEditPanel(container, false);
|
||
await retryFailedImage(container);
|
||
}
|
||
|
||
async function removePlaceholder(container) {
|
||
const slotId = container.dataset.slotId;
|
||
const messageId = parseInt(container.dataset.mesid);
|
||
if (!confirm('确定移除此占位符?')) return;
|
||
await deleteFailedRecordsForSlot(slotId);
|
||
await clearSlotSelection(slotId);
|
||
const ctx = getContext();
|
||
const message = ctx.chat?.[messageId];
|
||
if (message) message.mes = message.mes.replace(createPlaceholder(slotId), '');
|
||
container.remove();
|
||
showToast('占位符已移除');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 消息级懒加载
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function initMessageObserver() {
|
||
if (messageObserver) return;
|
||
messageObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(entry => {
|
||
if (!entry.isIntersecting) return;
|
||
const mesEl = entry.target;
|
||
messageObserver.unobserve(mesEl);
|
||
const messageId = parseInt(mesEl.getAttribute('mesid'), 10);
|
||
if (!Number.isNaN(messageId)) {
|
||
renderPreviewsForMessage(messageId);
|
||
}
|
||
});
|
||
}, { rootMargin: '600px 0px', threshold: 0.01 });
|
||
}
|
||
|
||
function observeMessageForLazyRender(messageId) {
|
||
const mesEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (!mesEl || mesEl.dataset.ndLazyObserved === '1') return;
|
||
initMessageObserver();
|
||
mesEl.dataset.ndLazyObserved = '1';
|
||
messageObserver.observe(mesEl);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 预览渲染
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function renderPreviewsForMessage(messageId) {
|
||
const ctx = getContext();
|
||
const message = ctx.chat?.[messageId];
|
||
if (!message?.mes) return;
|
||
|
||
const slotIds = extractSlotIds(message.mes);
|
||
if (slotIds.size === 0) return;
|
||
|
||
const $mesText = $(`#chat .mes[mesid="${messageId}"] .mes_text`);
|
||
if (!$mesText.length) return;
|
||
|
||
let html = $mesText.html();
|
||
let replaced = false;
|
||
|
||
for (const slotId of slotIds) {
|
||
if (html.includes(`data-slot-id="${slotId}"`)) continue;
|
||
|
||
const placeholder = createPlaceholder(slotId);
|
||
const escapedPlaceholder = placeholder.replace(/[[\]]/g, '\\$&');
|
||
if (!new RegExp(escapedPlaceholder).test(html)) continue;
|
||
|
||
let replacementHtml;
|
||
|
||
try {
|
||
const displayData = await getDisplayPreviewForSlot(slotId);
|
||
|
||
if (displayData.isFailed) {
|
||
replacementHtml = buildFailedPlaceholderHtml({
|
||
slotId,
|
||
messageId,
|
||
tags: displayData.failedInfo?.tags || '',
|
||
positive: displayData.failedInfo?.positive || '',
|
||
errorType: displayData.failedInfo?.errorType || ErrorType.CACHE_LOST.label,
|
||
errorMessage: displayData.failedInfo?.errorMessage || ErrorType.CACHE_LOST.desc
|
||
});
|
||
} else if (displayData.hasData && displayData.preview) {
|
||
const url = displayData.preview.savedUrl || `data:image/png;base64,${displayData.preview.base64}`;
|
||
replacementHtml = buildImageHtml({
|
||
slotId,
|
||
imgId: displayData.preview.imgId,
|
||
url,
|
||
tags: displayData.preview.tags || '',
|
||
positive: displayData.preview.positive || '',
|
||
messageId,
|
||
state: displayData.preview.savedUrl ? ImageState.SAVED : ImageState.PREVIEW,
|
||
historyCount: displayData.historyCount,
|
||
currentIndex: 0
|
||
});
|
||
} else {
|
||
replacementHtml = buildFailedPlaceholderHtml({
|
||
slotId,
|
||
messageId,
|
||
tags: '',
|
||
positive: '',
|
||
errorType: ErrorType.CACHE_LOST.label,
|
||
errorMessage: ErrorType.CACHE_LOST.desc
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error(`[NovelDraw] 渲染 ${slotId} 失败:`, e);
|
||
replacementHtml = buildFailedPlaceholderHtml({
|
||
slotId,
|
||
messageId,
|
||
tags: '',
|
||
positive: '',
|
||
errorType: ErrorType.UNKNOWN.label,
|
||
errorMessage: e?.message || '未知错误'
|
||
});
|
||
}
|
||
|
||
html = html.replace(new RegExp(escapedPlaceholder, 'g'), replacementHtml);
|
||
replaced = true;
|
||
}
|
||
|
||
if (replaced && !isMessageBeingEdited(messageId)) {
|
||
$mesText.html(html);
|
||
}
|
||
}
|
||
|
||
async function renderAllPreviews() {
|
||
const ctx = getContext();
|
||
const chat = ctx.chat || [];
|
||
let rendered = 0;
|
||
|
||
for (let i = chat.length - 1; i >= 0; i--) {
|
||
if (extractSlotIds(chat[i]?.mes).size === 0) continue;
|
||
if (rendered < INITIAL_RENDER_MESSAGE_LIMIT) {
|
||
await renderPreviewsForMessage(i);
|
||
rendered++;
|
||
} else {
|
||
observeMessageForLazyRender(i);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function handleMessageRendered(data) {
|
||
const messageId = typeof data === 'number' ? data : data?.messageId ?? data?.mesId;
|
||
if (messageId !== undefined) await renderPreviewsForMessage(messageId);
|
||
}
|
||
|
||
async function handleChatChanged() {
|
||
await new Promise(r => setTimeout(r, 50));
|
||
await renderAllPreviews();
|
||
}
|
||
|
||
async function handleMessageModified(data) {
|
||
const raw = typeof data === 'object' ? (data?.messageId ?? data?.mesId) : data;
|
||
const messageId = parseInt(raw, 10);
|
||
if (isNaN(messageId)) return;
|
||
await new Promise(r => setTimeout(r, 100));
|
||
await renderPreviewsForMessage(messageId);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 多图生成
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function generateAndInsertImages({ messageId, onStateChange, skipLock = false }) {
|
||
await loadSettings();
|
||
const ctx = getContext();
|
||
const message = ctx.chat?.[messageId];
|
||
if (!message) throw new NovelDrawError('消息不存在', ErrorType.PARSE);
|
||
|
||
if (!skipLock && isGenerating()) {
|
||
throw new NovelDrawError('已有任务进行中', ErrorType.UNKNOWN);
|
||
}
|
||
|
||
generationAbortController = new AbortController();
|
||
const signal = generationAbortController.signal;
|
||
|
||
try {
|
||
const settings = getSettings();
|
||
const preset = getActiveParamsPreset();
|
||
|
||
const messageText = String(message.mes || '').replace(PLACEHOLDER_REGEX, '').trim();
|
||
if (!messageText) throw new NovelDrawError('消息内容为空', ErrorType.PARSE);
|
||
|
||
const presentCharacters = detectPresentCharacters(messageText, settings.characterTags || []);
|
||
|
||
onStateChange?.('llm', {});
|
||
|
||
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||
|
||
let planRaw;
|
||
try {
|
||
planRaw = await generateScenePlan({
|
||
messageText,
|
||
presentCharacters,
|
||
llmApi: settings.llmApi,
|
||
useStream: settings.useStream,
|
||
useWorldInfo: settings.useWorldInfo,
|
||
timeout: settings.timeout || 120000
|
||
});
|
||
} catch (e) {
|
||
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||
if (e instanceof LLMServiceError) {
|
||
throw new NovelDrawError(`场景分析失败: ${e.message}`, ErrorType.LLM);
|
||
}
|
||
throw e;
|
||
}
|
||
|
||
if (signal.aborted) throw new NovelDrawError('已取消', ErrorType.UNKNOWN);
|
||
|
||
const tasks = parseImagePlan(planRaw);
|
||
if (!tasks.length) throw new NovelDrawError('未解析到图片任务', ErrorType.PARSE);
|
||
|
||
const initialChatId = ctx.chatId;
|
||
message.mes = message.mes.replace(PLACEHOLDER_REGEX, '');
|
||
|
||
onStateChange?.('gen', { current: 0, total: tasks.length });
|
||
|
||
const results = [];
|
||
const { messageFormatting } = await import('../../../../../../script.js');
|
||
let successCount = 0;
|
||
|
||
for (let i = 0; i < tasks.length; i++) {
|
||
if (signal.aborted) {
|
||
console.log('[NovelDraw] 用户中止,停止生成');
|
||
break;
|
||
}
|
||
|
||
const currentCtx = getContext();
|
||
if (currentCtx.chatId !== initialChatId) {
|
||
console.warn('[NovelDraw] 聊天已切换,中止生成');
|
||
break;
|
||
}
|
||
if (!currentCtx.chat?.[messageId]) {
|
||
console.warn('[NovelDraw] 消息已删除,中止生成');
|
||
break;
|
||
}
|
||
|
||
const task = tasks[i];
|
||
const slotId = generateSlotId();
|
||
|
||
onStateChange?.('progress', { current: i + 1, total: tasks.length });
|
||
|
||
let position = findAnchorPosition(message.mes, task.anchor);
|
||
|
||
const scene = joinTags(preset.positivePrefix, task.scene);
|
||
const characterPrompts = assembleCharacterPrompts(task.chars, settings.characterTags || []);
|
||
const tagsForStore = task.scene;
|
||
|
||
try {
|
||
const base64 = await generateNovelImage({
|
||
scene,
|
||
characterPrompts,
|
||
negativePrompt: preset.negativePrefix || '',
|
||
params: preset.params || {},
|
||
signal
|
||
});
|
||
const imgId = generateImgId();
|
||
await storePreview({
|
||
imgId,
|
||
slotId,
|
||
messageId,
|
||
base64,
|
||
tags: tagsForStore,
|
||
positive: scene,
|
||
characterPrompts,
|
||
negativePrompt: preset.negativePrefix
|
||
});
|
||
await setSlotSelection(slotId, imgId);
|
||
results.push({ slotId, imgId, tags: tagsForStore, success: true });
|
||
successCount++;
|
||
} catch (e) {
|
||
if (signal.aborted) {
|
||
console.log('[NovelDraw] 图片生成被中止');
|
||
break;
|
||
}
|
||
console.error(`[NovelDraw] 图${i + 1} 失败:`, e.message);
|
||
const errorType = classifyError(e);
|
||
await storeFailedPlaceholder({
|
||
slotId,
|
||
messageId,
|
||
tags: tagsForStore,
|
||
positive: scene,
|
||
errorType: errorType.code,
|
||
errorMessage: errorType.desc,
|
||
characterPrompts,
|
||
negativePrompt: preset.negativePrefix,
|
||
});
|
||
results.push({ slotId, tags: tagsForStore, success: false, error: errorType });
|
||
}
|
||
|
||
if (signal.aborted) break;
|
||
|
||
const msgCheck = getContext().chat?.[messageId];
|
||
if (!msgCheck) {
|
||
console.warn('[NovelDraw] 消息已删除,跳过占位符插入');
|
||
break;
|
||
}
|
||
|
||
const placeholder = createPlaceholder(slotId);
|
||
|
||
if (position >= 0) {
|
||
position = findNearestSentenceEnd(message.mes, position);
|
||
const before = message.mes.slice(0, position);
|
||
const after = message.mes.slice(position);
|
||
let insertText = placeholder;
|
||
if (before.length > 0 && !before.endsWith('\n')) insertText = '\n' + insertText;
|
||
if (after.length > 0 && !after.startsWith('\n')) insertText = insertText + '\n';
|
||
message.mes = before + insertText + after;
|
||
} else {
|
||
const needNewline = message.mes.length > 0 && !message.mes.endsWith('\n');
|
||
message.mes += (needNewline ? '\n' : '') + placeholder;
|
||
}
|
||
|
||
if (signal.aborted) break;
|
||
|
||
if (i < tasks.length - 1) {
|
||
const delay = randomDelay(settings.requestDelay?.min, settings.requestDelay?.max);
|
||
onStateChange?.('cooldown', { duration: delay, nextIndex: i + 2, total: tasks.length });
|
||
|
||
await new Promise(r => {
|
||
const tid = setTimeout(r, delay);
|
||
signal.addEventListener('abort', () => { clearTimeout(tid); r(); }, { once: true });
|
||
});
|
||
}
|
||
}
|
||
|
||
if (signal.aborted) {
|
||
onStateChange?.('success', { success: successCount, total: tasks.length, aborted: true });
|
||
return { success: successCount, total: tasks.length, results, aborted: true };
|
||
}
|
||
|
||
const finalCtx = getContext();
|
||
const shouldUpdateDom = finalCtx.chatId === initialChatId &&
|
||
finalCtx.chat?.[messageId] &&
|
||
!isMessageBeingEdited(messageId);
|
||
|
||
if (shouldUpdateDom) {
|
||
const formatted = messageFormatting(
|
||
message.mes,
|
||
message.name,
|
||
message.is_system,
|
||
message.is_user,
|
||
messageId
|
||
);
|
||
$('[mesid="' + messageId + '"] .mes_text').html(formatted);
|
||
|
||
await renderPreviewsForMessage(messageId);
|
||
|
||
try {
|
||
const { processMessageById } = await import('../iframe-renderer.js');
|
||
processMessageById(messageId, true);
|
||
} catch {}
|
||
}
|
||
|
||
const resultColor = successCount === tasks.length ? '#3ecf8e' : '#f0b429';
|
||
console.log(`%c[NovelDraw] 完成: ${successCount}/${tasks.length} 张`, `color: ${resultColor}; font-weight: bold`);
|
||
|
||
onStateChange?.('success', { success: successCount, total: tasks.length });
|
||
|
||
if (shouldUpdateDom) {
|
||
getContext().saveChat?.().then(() => {
|
||
console.log('[NovelDraw] 聊天已保存');
|
||
}).catch(e => {
|
||
console.warn('[NovelDraw] 保存聊天失败:', e);
|
||
});
|
||
}
|
||
|
||
return { success: successCount, total: tasks.length, results };
|
||
|
||
} finally {
|
||
generationAbortController = null;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 自动模式
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
async function autoGenerateForLastAI() {
|
||
const s = getSettings();
|
||
if (!isModuleEnabled() || s.mode !== 'auto') return;
|
||
|
||
if (isGenerating()) {
|
||
console.log('[NovelDraw] 自动模式:已有任务进行中,跳过');
|
||
return;
|
||
}
|
||
|
||
const ctx = getContext();
|
||
const chat = ctx.chat || [];
|
||
const lastIdx = chat.length - 1;
|
||
if (lastIdx < 0) return;
|
||
|
||
const lastMessage = chat[lastIdx];
|
||
if (!lastMessage || lastMessage.is_user) return;
|
||
|
||
const content = String(lastMessage.mes || '').replace(PLACEHOLDER_REGEX, '').trim();
|
||
if (content.length < 50) return;
|
||
|
||
lastMessage.extra ||= {};
|
||
if (lastMessage.extra.xb_novel_auto_done) return;
|
||
|
||
autoBusy = true;
|
||
|
||
try {
|
||
const { setStateForMessage, setFloatingState, FloatState, ensureNovelDrawPanel } = await import('./floating-panel.js');
|
||
const floatingOn = s.showFloatingButton === true;
|
||
const floorOn = s.showFloorButton !== false;
|
||
const useFloatingOnly = floatingOn && floorOn;
|
||
|
||
const updateState = (state, data = {}) => {
|
||
if (useFloatingOnly || (floatingOn && !floorOn)) {
|
||
setFloatingState?.(state, data);
|
||
} else if (floorOn) {
|
||
setStateForMessage(lastIdx, state, data);
|
||
}
|
||
};
|
||
|
||
if (floorOn && !useFloatingOnly) {
|
||
const messageEl = document.querySelector(`.mes[mesid="${lastIdx}"]`);
|
||
if (messageEl) {
|
||
ensureNovelDrawPanel(messageEl, lastIdx, { force: true });
|
||
}
|
||
}
|
||
|
||
await generateAndInsertImages({
|
||
messageId: lastIdx,
|
||
skipLock: true,
|
||
onStateChange: (state, data) => {
|
||
switch (state) {
|
||
case 'llm':
|
||
updateState(FloatState.LLM);
|
||
break;
|
||
case 'gen':
|
||
case 'progress':
|
||
updateState(FloatState.GEN, data);
|
||
break;
|
||
case 'cooldown':
|
||
updateState(FloatState.COOLDOWN, data);
|
||
break;
|
||
case 'success':
|
||
updateState(
|
||
(data.aborted && data.success === 0) ? FloatState.IDLE
|
||
: (data.success < data.total) ? FloatState.PARTIAL
|
||
: FloatState.SUCCESS,
|
||
data
|
||
);
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
lastMessage.extra.xb_novel_auto_done = true;
|
||
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 自动配图失败:', e);
|
||
try {
|
||
const { setStateForMessage, setFloatingState, FloatState } = await import('./floating-panel.js');
|
||
const floatingOn = s.showFloatingButton === true;
|
||
const floorOn = s.showFloorButton !== false;
|
||
const useFloatingOnly = floatingOn && floorOn;
|
||
|
||
if (useFloatingOnly || (floatingOn && !floorOn)) {
|
||
setFloatingState?.(FloatState.ERROR, { error: classifyError(e) });
|
||
} else if (floorOn) {
|
||
setStateForMessage(lastIdx, FloatState.ERROR, { error: classifyError(e) });
|
||
}
|
||
} catch {}
|
||
} finally {
|
||
autoBusy = false;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 生成拦截器
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function setupGenerateInterceptor() {
|
||
if (!window.xiaobaixGenerateInterceptor) {
|
||
window.xiaobaixGenerateInterceptor = function (chat) {
|
||
for (const msg of chat) {
|
||
if (msg.mes) {
|
||
msg.mes = msg.mes.replace(PLACEHOLDER_REGEX, '');
|
||
msg.mes = msg.mes.replace(/<div[^>]*class="xb-nd-img"[^>]*>[\s\S]*?<\/div>/gi, '');
|
||
}
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// Overlay 设置面板
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
function createOverlay() {
|
||
if (overlayCreated) return;
|
||
overlayCreated = true;
|
||
ensureStyles();
|
||
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'xiaobaix-novel-draw-overlay';
|
||
|
||
overlay.style.cssText = `position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:${window.innerHeight}px!important;z-index:99999!important;display:none;overflow:hidden!important;`;
|
||
|
||
const updateHeight = () => {
|
||
if (overlay.style.display !== 'none') {
|
||
overlay.style.height = `${window.innerHeight}px`;
|
||
}
|
||
};
|
||
window.addEventListener('resize', updateHeight);
|
||
if (window.visualViewport) {
|
||
window.visualViewport.addEventListener('resize', updateHeight);
|
||
}
|
||
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'nd-backdrop';
|
||
backdrop.addEventListener('click', hideOverlay);
|
||
|
||
const frameWrap = document.createElement('div');
|
||
frameWrap.className = 'nd-frame-wrap';
|
||
|
||
const iframe = document.createElement('iframe');
|
||
iframe.id = 'xiaobaix-novel-draw-iframe';
|
||
iframe.src = HTML_PATH;
|
||
|
||
frameWrap.appendChild(iframe);
|
||
overlay.appendChild(backdrop);
|
||
overlay.appendChild(frameWrap);
|
||
document.body.appendChild(overlay);
|
||
// Guarded by isTrustedMessage (origin + source).
|
||
// eslint-disable-next-line no-restricted-syntax
|
||
window.addEventListener('message', handleFrameMessage);
|
||
}
|
||
|
||
function showOverlay() {
|
||
if (!overlayCreated) createOverlay();
|
||
const overlay = document.getElementById('xiaobaix-novel-draw-overlay');
|
||
if (overlay) {
|
||
overlay.style.height = `${window.innerHeight}px`;
|
||
overlay.style.display = 'block';
|
||
}
|
||
if (frameReady) sendInitData();
|
||
}
|
||
|
||
function hideOverlay() {
|
||
const overlay = document.getElementById('xiaobaix-novel-draw-overlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
}
|
||
|
||
async function sendInitData() {
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (!iframe?.contentWindow) return;
|
||
const stats = await getCacheStats();
|
||
const settings = getSettings();
|
||
const gallerySummary = await getGallerySummary();
|
||
postToIframe(iframe, {
|
||
type: 'INIT_DATA',
|
||
settings: {
|
||
enabled: moduleInitialized,
|
||
mode: settings.mode,
|
||
apiKey: settings.apiKey,
|
||
timeout: settings.timeout,
|
||
requestDelay: settings.requestDelay,
|
||
cacheDays: settings.cacheDays,
|
||
selectedParamsPresetId: settings.selectedParamsPresetId,
|
||
paramsPresets: settings.paramsPresets,
|
||
llmApi: settings.llmApi,
|
||
useStream: settings.useStream,
|
||
useWorldInfo: settings.useWorldInfo,
|
||
characterTags: settings.characterTags,
|
||
overrideSize: settings.overrideSize,
|
||
showFloorButton: settings.showFloorButton !== false,
|
||
showFloatingButton: settings.showFloatingButton === true,
|
||
},
|
||
cacheStats: stats,
|
||
gallerySummary,
|
||
}, 'LittleWhiteBox-NovelDraw');
|
||
}
|
||
|
||
function postStatus(state, text) {
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (iframe) postToIframe(iframe, { type: 'STATUS', state, text }, 'LittleWhiteBox-NovelDraw');
|
||
}
|
||
|
||
async function handleFrameMessage(event) {
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (!isTrustedMessage(event, iframe, 'NovelDraw-Frame')) return;
|
||
const data = event.data;
|
||
|
||
switch (data.type) {
|
||
case 'FRAME_READY':
|
||
frameReady = true;
|
||
sendInitData();
|
||
break;
|
||
|
||
case 'CLOSE':
|
||
hideOverlay();
|
||
break;
|
||
|
||
case 'SAVE_MODE': {
|
||
const s = getSettings();
|
||
s.mode = data.mode || s.mode;
|
||
await saveSettingsAndToast(s, '已保存');
|
||
import('./floating-panel.js').then(m => m.updateAutoModeUI?.());
|
||
break;
|
||
}
|
||
|
||
case 'SAVE_BUTTON_MODE': {
|
||
const s = getSettings();
|
||
if (typeof data.showFloorButton === 'boolean') s.showFloorButton = data.showFloorButton;
|
||
if (typeof data.showFloatingButton === 'boolean') s.showFloatingButton = data.showFloatingButton;
|
||
const ok = await saveSettingsAndToast(s, '已保存');
|
||
if (ok) {
|
||
try {
|
||
const fp = await import('./floating-panel.js');
|
||
fp.updateButtonVisibility?.(s.showFloorButton !== false, s.showFloatingButton === true);
|
||
} catch {}
|
||
if (s.showFloorButton !== false && typeof ensureNovelDrawPanelRef === 'function') {
|
||
const context = getContext();
|
||
const chat = context.chat || [];
|
||
chat.forEach((message, messageId) => {
|
||
if (!message || message.is_user) return;
|
||
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (!messageEl) return;
|
||
ensureNovelDrawPanelRef?.(messageEl, messageId);
|
||
});
|
||
}
|
||
sendInitData();
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'SAVE_API_KEY': {
|
||
const s = getSettings();
|
||
s.apiKey = typeof data.apiKey === 'string' ? data.apiKey : s.apiKey;
|
||
await saveSettingsAndToast(s, '已保存');
|
||
break;
|
||
}
|
||
|
||
case 'SAVE_TIMEOUT': {
|
||
const s = getSettings();
|
||
if (typeof data.timeout === 'number' && data.timeout > 0) s.timeout = data.timeout;
|
||
if (data.requestDelay?.min > 0 && data.requestDelay?.max > 0) s.requestDelay = data.requestDelay;
|
||
await saveSettingsAndToast(s, '已保存');
|
||
break;
|
||
}
|
||
|
||
case 'SAVE_CACHE_DAYS': {
|
||
const s = getSettings();
|
||
if (typeof data.cacheDays === 'number' && data.cacheDays >= 1 && data.cacheDays <= 30) {
|
||
s.cacheDays = data.cacheDays;
|
||
}
|
||
await saveSettingsAndToast(s, '已保存');
|
||
break;
|
||
}
|
||
|
||
case 'TEST_API': {
|
||
try {
|
||
postStatus('loading', '测试中...');
|
||
await testApiConnection(data.apiKey);
|
||
postStatus('success', '连接成功');
|
||
} catch (e) {
|
||
postStatus('error', e?.message);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'SAVE_PARAMS_PRESET': {
|
||
const s = getSettings();
|
||
if (data.selectedParamsPresetId) s.selectedParamsPresetId = data.selectedParamsPresetId;
|
||
if (Array.isArray(data.paramsPresets) && data.paramsPresets.length > 0) {
|
||
s.paramsPresets = data.paramsPresets;
|
||
}
|
||
const ok = await saveSettingsAndToast(s, '已保存');
|
||
if (ok) {
|
||
sendInitData();
|
||
try {
|
||
const { refreshPresetSelect } = await import('./floating-panel.js');
|
||
refreshPresetSelect?.();
|
||
} catch {}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'ADD_PARAMS_PRESET': {
|
||
const s = getSettings();
|
||
const id = generateSlotId();
|
||
const base = getActiveParamsPreset() || DEFAULT_PARAMS_PRESET;
|
||
const copy = JSON.parse(JSON.stringify(base));
|
||
copy.id = id;
|
||
copy.name = (typeof data.name === 'string' && data.name.trim()) ? data.name.trim() : `配置-${s.paramsPresets.length + 1}`;
|
||
s.paramsPresets.push(copy);
|
||
s.selectedParamsPresetId = id;
|
||
const ok = await saveSettingsAndToast(s, '已创建');
|
||
if (ok) {
|
||
sendInitData();
|
||
try {
|
||
const { refreshPresetSelect } = await import('./floating-panel.js');
|
||
refreshPresetSelect?.();
|
||
} catch {}
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'DEL_PARAMS_PRESET': {
|
||
const s = getSettings();
|
||
if (s.paramsPresets.length <= 1) {
|
||
postStatus('error', '至少保留一个预设');
|
||
break;
|
||
}
|
||
const idx = s.paramsPresets.findIndex(p => p.id === s.selectedParamsPresetId);
|
||
if (idx >= 0) s.paramsPresets.splice(idx, 1);
|
||
s.selectedParamsPresetId = s.paramsPresets[0]?.id || null;
|
||
const ok = await saveSettingsAndToast(s, '已删除');
|
||
if (ok) {
|
||
sendInitData();
|
||
try {
|
||
const { refreshPresetSelect } = await import('./floating-panel.js');
|
||
refreshPresetSelect?.();
|
||
} catch {}
|
||
}
|
||
break;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 新增:云端预设
|
||
// ═══════════════════════════════════════════════════════════════
|
||
case 'OPEN_CLOUD_PRESETS': {
|
||
openCloudPresetsModal(async (presetData) => {
|
||
const s = getSettings();
|
||
const newPreset = parsePresetData(presetData, generateSlotId);
|
||
s.paramsPresets.push(newPreset);
|
||
s.selectedParamsPresetId = newPreset.id;
|
||
await saveSettingsAndToast(s, `已导入: ${newPreset.name}`);
|
||
await notifySettingsUpdated();
|
||
sendInitData();
|
||
});
|
||
break;
|
||
}
|
||
case 'EXPORT_CURRENT_PRESET': {
|
||
const s = getSettings();
|
||
const presetId = data.presetId || s.selectedParamsPresetId;
|
||
const preset = s.paramsPresets.find(p => p.id === presetId);
|
||
if (!preset) {
|
||
postStatus('error', '没有可导出的预设');
|
||
break;
|
||
}
|
||
downloadPresetAsFile(preset);
|
||
postStatus('success', '已导出');
|
||
break;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
|
||
case 'SAVE_LLM_API': {
|
||
const s = getSettings();
|
||
if (data.llmApi && typeof data.llmApi === 'object') {
|
||
s.llmApi = { ...s.llmApi, ...data.llmApi };
|
||
}
|
||
if (typeof data.useStream === 'boolean') s.useStream = data.useStream;
|
||
if (typeof data.useWorldInfo === 'boolean') s.useWorldInfo = data.useWorldInfo;
|
||
const ok = await saveSettingsAndToast(s, '已保存');
|
||
if (ok) sendInitData();
|
||
break;
|
||
}
|
||
|
||
case 'FETCH_LLM_MODELS': {
|
||
try {
|
||
postStatus('loading', '连接中...');
|
||
const apiCfg = data.llmApi || {};
|
||
let baseUrl = String(apiCfg.url || '').trim().replace(/\/+$/, '');
|
||
const apiKey = String(apiCfg.key || '').trim();
|
||
if (!apiKey) {
|
||
postStatus('error', '请先填写 API KEY');
|
||
break;
|
||
}
|
||
|
||
const tryFetch = async url => {
|
||
const res = await fetch(url, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' } });
|
||
return res.ok ? (await res.json())?.data?.map(m => m?.id).filter(Boolean) || null : null;
|
||
};
|
||
|
||
if (baseUrl.endsWith('/v1')) baseUrl = baseUrl.slice(0, -3);
|
||
let models = await tryFetch(`${baseUrl}/v1/models`);
|
||
if (!models) models = await tryFetch(`${baseUrl}/models`);
|
||
if (!models?.length) throw new Error('未获取到模型列表');
|
||
|
||
const s = getSettings();
|
||
s.llmApi.provider = apiCfg.provider;
|
||
s.llmApi.url = apiCfg.url;
|
||
s.llmApi.key = apiCfg.key;
|
||
s.llmApi.modelCache = [...new Set(models)];
|
||
if (!s.llmApi.model && models.length) s.llmApi.model = models[0];
|
||
|
||
const ok = await saveSettingsAndToast(s, `获取 ${models.length} 个模型`);
|
||
if (ok) sendInitData();
|
||
} catch (e) {
|
||
postStatus('error', '连接失败:' + (e.message || '请检查配置'));
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'SAVE_CHARACTER_TAGS': {
|
||
const s = getSettings();
|
||
if (Array.isArray(data.characterTags)) s.characterTags = data.characterTags;
|
||
await saveSettingsAndToast(s, '角色标签已保存');
|
||
break;
|
||
}
|
||
|
||
case 'CLEAR_EXPIRED_CACHE': {
|
||
const s = getSettings();
|
||
const n = await clearExpiredCache(s.cacheDays || 3);
|
||
sendInitData();
|
||
postStatus('success', `已清理 ${n} 张`);
|
||
break;
|
||
}
|
||
|
||
case 'CLEAR_ALL_CACHE':
|
||
await clearAllCache();
|
||
sendInitData();
|
||
postStatus('success', '已清空');
|
||
break;
|
||
|
||
case 'REFRESH_CACHE_STATS':
|
||
sendInitData();
|
||
break;
|
||
|
||
case 'USE_GALLERY_IMAGE':
|
||
sendInitData();
|
||
postStatus('success', '已选择');
|
||
break;
|
||
|
||
case 'SAVE_GALLERY_IMAGE': {
|
||
try {
|
||
const preview = await getPreview(data.imgId);
|
||
if (!preview?.base64) {
|
||
postStatus('error', '图片数据不存在');
|
||
break;
|
||
}
|
||
const charName = preview.characterName || getChatCharacterName();
|
||
const url = await saveBase64AsFile(preview.base64, charName, `novel_${data.imgId}`, 'png');
|
||
await updatePreviewSavedUrl(data.imgId, url);
|
||
{
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (iframe) postToIframe(iframe, { type: 'GALLERY_IMAGE_SAVED', imgId: data.imgId, savedUrl: url }, 'LittleWhiteBox-NovelDraw');
|
||
}
|
||
sendInitData();
|
||
showToast(`已保存: ${url}`, 'success', 5000);
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 保存失败:', e);
|
||
postStatus('error', '保存失败: ' + e.message);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'LOAD_CHARACTER_PREVIEWS': {
|
||
try {
|
||
const charName = data.charName;
|
||
if (!charName) break;
|
||
const slots = await getCharacterPreviews(charName);
|
||
{
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (iframe) postToIframe(iframe, { type: 'CHARACTER_PREVIEWS_LOADED', charName, slots }, 'LittleWhiteBox-NovelDraw');
|
||
}
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 加载预览失败:', e);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'DELETE_GALLERY_IMAGE': {
|
||
try {
|
||
await deletePreview(data.imgId);
|
||
{
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (iframe) postToIframe(iframe, { type: 'GALLERY_IMAGE_DELETED', imgId: data.imgId }, 'LittleWhiteBox-NovelDraw');
|
||
}
|
||
sendInitData();
|
||
showToast('已删除');
|
||
} catch (e) {
|
||
console.error('[NovelDraw] 删除失败:', e);
|
||
postStatus('error', '删除失败: ' + e.message);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'GENERATE_IMAGES': {
|
||
try {
|
||
const messageId = typeof data.messageId === 'number' ? data.messageId : findLastAIMessageId();
|
||
if (messageId < 0) {
|
||
postStatus('error', '无AI消息');
|
||
break;
|
||
}
|
||
const result = await generateAndInsertImages({
|
||
messageId,
|
||
onStateChange: (state, d) => {
|
||
if (state === 'progress') postStatus('loading', `${d.current}/${d.total}`);
|
||
}
|
||
});
|
||
postStatus('success', `完成! ${result.success} 张`);
|
||
} catch (e) {
|
||
postStatus('error', e?.message);
|
||
}
|
||
break;
|
||
}
|
||
|
||
case 'TEST_SINGLE': {
|
||
try {
|
||
postStatus('loading', '生成中...');
|
||
const t0 = Date.now();
|
||
const preset = getActiveParamsPreset();
|
||
const tags = (typeof data.tags === 'string' && data.tags.trim()) ? data.tags.trim() : '1girl, smile';
|
||
const scene = joinTags(preset?.positivePrefix, tags);
|
||
const base64 = await generateNovelImage({ scene, characterPrompts: [], negativePrompt: preset?.negativePrefix || '', params: preset?.params || {} });
|
||
{
|
||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||
if (iframe) postToIframe(iframe, { type: 'TEST_RESULT', url: `data:image/png;base64,${base64}` }, 'LittleWhiteBox-NovelDraw');
|
||
}
|
||
postStatus('success', `完成 ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
||
} catch (e) {
|
||
postStatus('error', e?.message);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 初始化与清理
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
export async function openNovelDrawSettings() {
|
||
await loadSettings();
|
||
showOverlay();
|
||
}
|
||
|
||
// eslint-disable-next-line no-unused-vars
|
||
function renderExistingPanels() {
|
||
if (typeof ensureNovelDrawPanelRef !== 'function') return;
|
||
const context = getContext();
|
||
const chat = context.chat || [];
|
||
|
||
chat.forEach((message, messageId) => {
|
||
if (!message || message.is_user) return; // 跳过用户消息
|
||
|
||
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (!messageEl) return;
|
||
|
||
ensureNovelDrawPanelRef(messageEl, messageId);
|
||
});
|
||
}
|
||
|
||
export async function initNovelDraw() {
|
||
if (window?.isXiaobaixEnabled === false) return;
|
||
|
||
await loadSettings();
|
||
moduleInitialized = true;
|
||
ensureStyles();
|
||
|
||
await loadTagGuide();
|
||
|
||
setupEventDelegation();
|
||
setupGenerateInterceptor();
|
||
openDB().then(() => {
|
||
const s = getSettings();
|
||
clearExpiredCache(s.cacheDays || 3);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 动态导入 floating-panel(避免循环依赖)
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
const { ensureNovelDrawPanel: ensureNovelDrawPanelFn, initFloatingPanel } = await import('./floating-panel.js');
|
||
ensureNovelDrawPanelRef = ensureNovelDrawPanelFn;
|
||
initFloatingPanel?.();
|
||
|
||
// 为现有消息创建画图面板
|
||
const renderExistingPanels = () => {
|
||
const context = getContext();
|
||
const chat = context.chat || [];
|
||
|
||
chat.forEach((message, messageId) => {
|
||
if (!message || message.is_user) return;
|
||
|
||
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (!messageEl) return;
|
||
|
||
ensureNovelDrawPanelRef?.(messageEl, messageId);
|
||
});
|
||
};
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 事件监听
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
// AI 消息渲染时创建画图按钮
|
||
events.on(event_types.CHARACTER_MESSAGE_RENDERED, (data) => {
|
||
const messageId = typeof data === 'number' ? data : data?.messageId ?? data?.mesId;
|
||
if (messageId === undefined) return;
|
||
|
||
const messageEl = document.querySelector(`.mes[mesid="${messageId}"]`);
|
||
if (!messageEl) return;
|
||
|
||
const context = getContext();
|
||
const message = context.chat?.[messageId];
|
||
if (message?.is_user) return;
|
||
|
||
ensureNovelDrawPanelRef?.(messageEl, messageId);
|
||
});
|
||
|
||
events.on(event_types.CHARACTER_MESSAGE_RENDERED, handleMessageRendered);
|
||
events.on(event_types.USER_MESSAGE_RENDERED, handleMessageRendered);
|
||
events.on(event_types.CHAT_CHANGED, handleChatChanged);
|
||
events.on(event_types.MESSAGE_EDITED, handleMessageModified);
|
||
events.on(event_types.MESSAGE_UPDATED, handleMessageModified);
|
||
events.on(event_types.MESSAGE_SWIPED, handleMessageModified);
|
||
events.on(event_types.GENERATION_ENDED, async () => {
|
||
try {
|
||
await autoGenerateForLastAI();
|
||
} catch (e) {
|
||
console.error('[NovelDraw]', e);
|
||
}
|
||
});
|
||
|
||
// 聊天切换时重新创建面板
|
||
events.on(event_types.CHAT_CHANGED, () => {
|
||
setTimeout(renderExistingPanels, 200);
|
||
});
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 初始渲染
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
renderExistingPanels();
|
||
|
||
// ════════════════════════════════════════════════════════════════════
|
||
// 全局 API
|
||
// ════════════════════════════════════════════════════════════════════
|
||
|
||
window.xiaobaixNovelDraw = {
|
||
getSettings,
|
||
saveSettings,
|
||
generateNovelImage,
|
||
generateAndInsertImages,
|
||
refreshSingleImage,
|
||
saveSingleImage,
|
||
testApiConnection,
|
||
openSettings: openNovelDrawSettings,
|
||
createPlaceholder,
|
||
extractSlotIds,
|
||
PLACEHOLDER_REGEX,
|
||
renderAllPreviews,
|
||
renderPreviewsForMessage,
|
||
getCacheStats,
|
||
clearExpiredCache,
|
||
clearAllCache,
|
||
detectPresentCharacters,
|
||
assembleCharacterPrompts,
|
||
getPreviewsBySlot,
|
||
getDisplayPreviewForSlot,
|
||
openGallery,
|
||
closeGallery,
|
||
isEnabled: () => moduleInitialized,
|
||
loadSettings,
|
||
};
|
||
|
||
window.registerModuleCleanup?.(MODULE_KEY, cleanupNovelDraw);
|
||
console.log('[NovelDraw] 模块已初始化');
|
||
}
|
||
|
||
export async function cleanupNovelDraw() {
|
||
moduleInitialized = false;
|
||
settingsCache = null;
|
||
settingsLoaded = false;
|
||
events.cleanup();
|
||
hideOverlay();
|
||
destroyGalleryCache();
|
||
destroyCloudPresets();
|
||
overlayCreated = false;
|
||
frameReady = false;
|
||
|
||
if (messageObserver) {
|
||
messageObserver.disconnect();
|
||
messageObserver = null;
|
||
}
|
||
|
||
window.removeEventListener('message', handleFrameMessage);
|
||
document.getElementById('xiaobaix-novel-draw-overlay')?.remove();
|
||
|
||
// 动态导入并清理
|
||
try {
|
||
const { destroyFloatingPanel } = await import('./floating-panel.js');
|
||
destroyFloatingPanel();
|
||
} catch {}
|
||
|
||
try {
|
||
const { destroyAllLiveEffects } = await import('./image-live-effect.js');
|
||
destroyAllLiveEffects();
|
||
} catch {}
|
||
|
||
delete window.xiaobaixNovelDraw;
|
||
delete window._xbNovelEventsBound;
|
||
delete window.xiaobaixGenerateInterceptor;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
// 导出
|
||
// ═══════════════════════════════════════════════════════════════════════════
|
||
|
||
export {
|
||
getSettings,
|
||
saveSettings,
|
||
loadSettings,
|
||
getActiveParamsPreset,
|
||
isModuleEnabled,
|
||
findLastAIMessageId,
|
||
generateAndInsertImages,
|
||
generateNovelImage,
|
||
classifyError,
|
||
ErrorType,
|
||
PROVIDER_MAP,
|
||
abortGeneration,
|
||
isGenerating,
|
||
};
|