啊
This commit is contained in:
1136
modules/novel-draw/novel-draw.html
Normal file
1136
modules/novel-draw/novel-draw.html
Normal file
File diff suppressed because it is too large
Load Diff
700
modules/novel-draw/novel-draw.js
Normal file
700
modules/novel-draw/novel-draw.js
Normal file
@@ -0,0 +1,700 @@
|
||||
import { extension_settings, getContext } from "../../../../../extensions.js";
|
||||
import { appendMediaToMessage, getRequestHeaders, saveSettingsDebounced } from "../../../../../../script.js";
|
||||
import { saveBase64AsFile } from "../../../../../utils.js";
|
||||
import { secret_state, writeSecret, SECRET_KEYS } from "../../../../../secrets.js";
|
||||
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
|
||||
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 常量与状态
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const MODULE_KEY = 'novelDraw';
|
||||
const HTML_PATH = `${extensionFolderPath}/modules/novel-draw/novel-draw.html`;
|
||||
const TAGS_SESSION_ID = 'xb_nd_tags';
|
||||
const NOVELAI_IMAGE_API = 'https://image.novelai.net/ai/generate-image';
|
||||
const REFERENCE_PIXEL_COUNT = 1011712;
|
||||
const SIGMA_MAGIC_NUMBER = 19;
|
||||
const SIGMA_MAGIC_NUMBER_V4_5 = 58;
|
||||
|
||||
const events = createModuleEvents(MODULE_KEY);
|
||||
|
||||
const DEFAULT_PRESET = {
|
||||
id: '',
|
||||
name: '默认',
|
||||
positivePrefix: 'masterpiece, best quality,',
|
||||
negativePrefix: 'lowres, bad anatomy, bad hands,',
|
||||
params: {
|
||||
model: 'nai-diffusion-4-full',
|
||||
sampler: 'k_dpmpp_2m',
|
||||
scheduler: 'karras',
|
||||
steps: 28,
|
||||
scale: 9,
|
||||
width: 832,
|
||||
height: 1216,
|
||||
seed: -1,
|
||||
sm: false,
|
||||
sm_dyn: false,
|
||||
decrisper: false,
|
||||
variety_boost: false,
|
||||
upscale_ratio: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
enabled: false,
|
||||
mode: 'manual',
|
||||
selectedPresetId: null,
|
||||
presets: [],
|
||||
api: {
|
||||
mode: 'tavern',
|
||||
apiKey: '',
|
||||
},
|
||||
};
|
||||
|
||||
let autoBusy = false;
|
||||
let overlayCreated = false;
|
||||
let frameReady = false;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 设置管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function getSettings() {
|
||||
const root = extension_settings[EXT_ID] ||= {};
|
||||
const s = root[MODULE_KEY] ||= { ...DEFAULT_SETTINGS };
|
||||
if (!Array.isArray(s.presets) || !s.presets.length) {
|
||||
const id = generateId();
|
||||
s.presets = [{ ...JSON.parse(JSON.stringify(DEFAULT_PRESET)), id }];
|
||||
s.selectedPresetId = id;
|
||||
}
|
||||
if (!s.selectedPresetId || !s.presets.find(p => p.id === s.selectedPresetId)) {
|
||||
s.selectedPresetId = s.presets[0]?.id ?? null;
|
||||
}
|
||||
if (!s.api) {
|
||||
s.api = { ...DEFAULT_SETTINGS.api };
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function getActivePreset() {
|
||||
const s = getSettings();
|
||||
return s.presets.find(p => p.id === s.selectedPresetId) || s.presets[0];
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `xb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function joinTags(prefix, scene) {
|
||||
const a = String(prefix || '').trim().replace(/[,、]/g, ',');
|
||||
const b = String(scene || '').trim().replace(/[,、]/g, ',');
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return `${a.replace(/,+\s*$/g, '')}, ${b.replace(/^,+\s*/g, '')}`;
|
||||
}
|
||||
|
||||
function b64UrlEncode(str) {
|
||||
const utf8 = new TextEncoder().encode(String(str));
|
||||
let bin = '';
|
||||
utf8.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function normalizeSceneTags(raw) {
|
||||
if (!raw) return '';
|
||||
return String(raw).trim()
|
||||
.replace(/^```[\s\S]*?\n/i, '').replace(/```$/i, '')
|
||||
.replace(/^\s*(tags?\s*[::]\s*)/i, '')
|
||||
.replace(/\r?\n+/g, ', ')
|
||||
.replace(/[,、]/g, ',')
|
||||
.replace(/\s*,\s*/g, ', ')
|
||||
.replace(/,+\s*$/g, '').replace(/^\s*,+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
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 calculateSkipCfgAboveSigma(width, height, modelName) {
|
||||
const magicConstant = modelName?.includes('nai-diffusion-4-5') ? SIGMA_MAGIC_NUMBER_V4_5 : SIGMA_MAGIC_NUMBER;
|
||||
const pixelCount = width * height;
|
||||
return Math.pow(pixelCount / REFERENCE_PIXEL_COUNT, 0.5) * magicConstant;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 场景 TAG 生成
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function buildSceneTagPrompt({ lastAssistantText, positivePrefix, negativePrefix }) {
|
||||
const msg1 = `你是"NovelAI 场景TAG生成器"。只输出一行逗号分隔的英文tag(场景/构图/光照/氛围/动作/镜头),不要解释,不要换行,不要加代码块。25-60个tag。`;
|
||||
const msg2 = `明白,我只输出一行逗号分隔的场景TAG。`;
|
||||
const msg3 = `<正向固定词>\n${positivePrefix}\n</正向固定词>\n<负向固定词>\n${negativePrefix}\n</负向固定词>\n<对话上下文>\n{$history20}\n</对话上下文>\n<最后AI回复>\n${lastAssistantText}\n</最后AI回复>\n请基于"最后AI回复"生成场景TAG:`;
|
||||
const msg4 = `场景TAG:`;
|
||||
return b64UrlEncode(`user={${msg1}};assistant={${msg2}};user={${msg3}};assistant={${msg4}}`);
|
||||
}
|
||||
|
||||
async function generateSceneTagsFromChat({ messageId }) {
|
||||
const preset = getActivePreset();
|
||||
if (!preset) throw new Error('未找到预设');
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
const lastAssistantText = String(chat[messageId]?.mes || '').trim();
|
||||
const top64 = buildSceneTagPrompt({
|
||||
lastAssistantText,
|
||||
positivePrefix: preset.positivePrefix,
|
||||
negativePrefix: preset.negativePrefix,
|
||||
});
|
||||
const mod = window?.xiaobaixStreamingGeneration;
|
||||
if (!mod?.xbgenrawCommand) throw new Error('xbgenraw 不可用');
|
||||
const raw = await mod.xbgenrawCommand({ as: 'user', nonstream: 'true', top64, id: TAGS_SESSION_ID }, '');
|
||||
const tags = normalizeSceneTags(raw);
|
||||
if (!tags) throw new Error('AI 未返回有效场景TAG');
|
||||
return tags;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// API Key 管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function ensureApiKeyInSecrets(apiKey) {
|
||||
if (!apiKey) throw new Error('API Key 不能为空');
|
||||
await writeSecret(SECRET_KEYS.NOVEL, apiKey);
|
||||
}
|
||||
|
||||
function hasApiKeyInSecrets() {
|
||||
return !!secret_state[SECRET_KEYS.NOVEL];
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 连接测试
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function testApiConnection(apiKey, mode) {
|
||||
if (!apiKey) throw new Error('请填写 API Key');
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
try {
|
||||
if (mode === 'direct') {
|
||||
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(timeoutId);
|
||||
|
||||
if (res.status === 401) throw new Error('API Key 无效');
|
||||
if (res.status === 400 || res.status === 402 || res.ok) {
|
||||
return { success: true, message: '连接成功' };
|
||||
}
|
||||
throw new Error(`NovelAI 返回: ${res.status}`);
|
||||
|
||||
} else {
|
||||
await ensureApiKeyInSecrets(apiKey);
|
||||
const res = await fetch('/api/novelai/status', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!res.ok) throw new Error(`酒馆后端返回错误: ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (data.error) throw new Error('API Key 无效或已过期');
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId);
|
||||
if (e.name === 'AbortError') {
|
||||
throw new Error(mode === 'direct' ? '连接超时,请检查网络或开启代理' : '连接超时,酒馆服务器可能无法访问 NovelAI');
|
||||
}
|
||||
if (e.message?.includes('Failed to fetch')) {
|
||||
throw new Error(mode === 'direct' ? '无法连接 NovelAI,请检查网络或开启代理' : '无法连接酒馆后端');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// ZIP 解析
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function extractImageFromZip(zipData) {
|
||||
const JSZip = window.JSZip;
|
||||
if (!JSZip) throw new Error('缺少 JSZip 库,请使用酒馆模式');
|
||||
|
||||
const zip = await JSZip.loadAsync(zipData);
|
||||
const imageFile = Object.values(zip.files).find(f => f.name.endsWith('.png') || f.name.endsWith('.webp'));
|
||||
if (!imageFile) throw new Error('无法从返回数据中提取图片');
|
||||
|
||||
return await imageFile.async('base64');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 图片生成(核心)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function generateNovelImageBase64({ prompt, negativePrompt, params, signal }) {
|
||||
const settings = getSettings();
|
||||
const apiMode = settings.api?.mode || 'tavern';
|
||||
const apiKey = settings.api?.apiKey || '';
|
||||
|
||||
const width = params?.width ?? 832;
|
||||
const height = params?.height ?? 1216;
|
||||
const seed = (params?.seed >= 0) ? params.seed : Math.floor(Math.random() * 9999999999);
|
||||
const promptText = String(prompt || '');
|
||||
const negativeText = String(negativePrompt || '');
|
||||
const modelName = params?.model ?? 'nai-diffusion-4-full';
|
||||
|
||||
if (apiMode === 'direct') {
|
||||
if (!apiKey) throw new Error('官网直连模式需要填写 API Key');
|
||||
|
||||
const skipCfgAboveSigma = params?.variety_boost ? calculateSkipCfgAboveSigma(width, height, modelName) : null;
|
||||
|
||||
const requestBody = {
|
||||
action: 'generate',
|
||||
input: promptText,
|
||||
model: modelName,
|
||||
parameters: {
|
||||
params_version: 3,
|
||||
prefer_brownian: true,
|
||||
width: width,
|
||||
height: height,
|
||||
scale: params?.scale ?? 9,
|
||||
seed: seed,
|
||||
sampler: params?.sampler ?? 'k_dpmpp_2m',
|
||||
noise_schedule: params?.scheduler ?? 'karras',
|
||||
steps: params?.steps ?? 28,
|
||||
n_samples: 1,
|
||||
negative_prompt: negativeText,
|
||||
ucPreset: 0,
|
||||
qualityToggle: false,
|
||||
add_original_image: false,
|
||||
controlnet_strength: 1,
|
||||
deliberate_euler_ancestral_bug: false,
|
||||
dynamic_thresholding: params?.decrisper ?? false,
|
||||
legacy: false,
|
||||
legacy_v3_extend: false,
|
||||
sm: params?.sm ?? false,
|
||||
sm_dyn: params?.sm_dyn ?? false,
|
||||
uncond_scale: 1,
|
||||
skip_cfg_above_sigma: skipCfgAboveSigma,
|
||||
use_coords: false,
|
||||
characterPrompts: [],
|
||||
reference_image_multiple: [],
|
||||
reference_information_extracted_multiple: [],
|
||||
reference_strength_multiple: [],
|
||||
v4_prompt: {
|
||||
caption: {
|
||||
base_caption: promptText,
|
||||
char_captions: [],
|
||||
},
|
||||
use_coords: false,
|
||||
use_order: true,
|
||||
},
|
||||
v4_negative_prompt: {
|
||||
caption: {
|
||||
base_caption: negativeText,
|
||||
char_captions: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(NOVELAI_IMAGE_API, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
},
|
||||
signal,
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) throw new Error('API Key 无效');
|
||||
if (res.status === 402) throw new Error('点数不足,请充值');
|
||||
const errText = await res.text().catch(() => res.statusText);
|
||||
throw new Error(`NovelAI 请求失败: ${errText}`);
|
||||
}
|
||||
|
||||
const zipData = await res.arrayBuffer();
|
||||
return await extractImageFromZip(zipData);
|
||||
|
||||
} else {
|
||||
if (apiKey) {
|
||||
await ensureApiKeyInSecrets(apiKey);
|
||||
} else if (!hasApiKeyInSecrets()) {
|
||||
throw new Error('请先填写 API Key');
|
||||
}
|
||||
|
||||
const body = {
|
||||
prompt: promptText,
|
||||
negative_prompt: negativeText,
|
||||
model: modelName,
|
||||
sampler: params?.sampler ?? 'k_dpmpp_2m',
|
||||
scheduler: params?.scheduler ?? 'karras',
|
||||
steps: params?.steps ?? 28,
|
||||
scale: params?.scale ?? 9,
|
||||
width: width,
|
||||
height: height,
|
||||
seed: seed,
|
||||
upscale_ratio: params?.upscale_ratio ?? 1,
|
||||
decrisper: params?.decrisper ?? false,
|
||||
variety_boost: params?.variety_boost ?? false,
|
||||
sm: params?.sm ?? false,
|
||||
sm_dyn: params?.sm_dyn ?? false,
|
||||
};
|
||||
|
||||
const res = await fetch('/api/novelai/generate-image', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
signal,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(await res.text() || res.statusText || 'Novel 画图失败');
|
||||
return String(await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 生成并附加到消息
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
async function generateAndAttachToMessage({ messageId, sceneTags }) {
|
||||
if (!Number.isInteger(messageId) || messageId < 0) throw new Error('messageId 无效');
|
||||
const preset = getActivePreset();
|
||||
if (!preset) throw new Error('未找到预设');
|
||||
const positive = joinTags(preset.positivePrefix, sceneTags);
|
||||
const negative = String(preset.negativePrefix || '');
|
||||
const base64 = await generateNovelImageBase64({ prompt: positive, negativePrompt: negative, params: preset.params || {} });
|
||||
const url = await saveBase64AsFile(base64, getChatCharacterName(), `novel_${Date.now()}`, 'png');
|
||||
const ctx = getContext();
|
||||
const message = ctx.chat?.[messageId];
|
||||
if (!message) throw new Error('找不到对应楼层消息');
|
||||
message.extra ||= {};
|
||||
message.extra.media ||= [];
|
||||
message.extra.media.push({ url, type: 'image', title: positive, negative, generation_type: 'xb_novel_draw', source: 'generated' });
|
||||
message.extra.media_index = message.extra.media.length - 1;
|
||||
message.extra.media_display ||= 'gallery';
|
||||
message.extra.inline_image = false;
|
||||
const el = document.querySelector(`#chat .mes[mesid="${messageId}"]`);
|
||||
if (el) appendMediaToMessage(message, el);
|
||||
await ctx.saveChat();
|
||||
return { url, prompt: positive, negative, messageId };
|
||||
}
|
||||
|
||||
async function autoGenerateAndAttachToLastAI() {
|
||||
const s = getSettings();
|
||||
if (!s.enabled || s.mode !== 'auto' || autoBusy) return null;
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
if (!chat.length) return null;
|
||||
let messageId = chat.length - 1;
|
||||
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
|
||||
if (messageId < 0) return null;
|
||||
const msg = chat[messageId];
|
||||
msg.extra ||= {};
|
||||
if (msg.extra.xb_novel_draw?.auto_done) return null;
|
||||
autoBusy = true;
|
||||
try {
|
||||
const sceneTags = await generateSceneTagsFromChat({ messageId });
|
||||
const result = await generateAndAttachToMessage({ messageId, sceneTags });
|
||||
msg.extra.xb_novel_draw = { auto_done: true, at: Date.now(), sceneTags };
|
||||
await ctx.saveChat();
|
||||
return result;
|
||||
} finally {
|
||||
autoBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Overlay 管理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function createOverlay() {
|
||||
if (overlayCreated) return;
|
||||
overlayCreated = true;
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const frameInset = isMobile ? '0px' : '12px';
|
||||
const iframeRadius = isMobile ? '0px' : '12px';
|
||||
|
||||
const $overlay = $(`
|
||||
<div id="xiaobaix-novel-draw-overlay" style="
|
||||
position: fixed !important; inset: 0 !important;
|
||||
width: 100vw !important; height: 100vh !important; height: 100dvh !important;
|
||||
z-index: 99999 !important; display: none; overflow: hidden !important;
|
||||
background: #000 !important;
|
||||
">
|
||||
<div class="nd-backdrop" style="
|
||||
position: absolute !important; inset: 0 !important;
|
||||
background: rgba(0,0,0,.55) !important;
|
||||
backdrop-filter: blur(4px) !important;
|
||||
"></div>
|
||||
<div class="nd-frame-wrap" style="
|
||||
position: absolute !important; inset: ${frameInset} !important; z-index: 1 !important;
|
||||
">
|
||||
<iframe id="xiaobaix-novel-draw-iframe"
|
||||
src="${HTML_PATH}"
|
||||
style="width:100% !important; height:100% !important; border:none !important;
|
||||
border-radius:${iframeRadius} !important; box-shadow:0 0 30px rgba(0,0,0,.4) !important;
|
||||
background:#1a1a2e !important;">
|
||||
</iframe>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$overlay.on('click', '.nd-backdrop', hideOverlay);
|
||||
document.body.appendChild($overlay[0]);
|
||||
window.addEventListener('message', handleFrameMessage);
|
||||
}
|
||||
|
||||
function showOverlay() {
|
||||
if (!overlayCreated) createOverlay();
|
||||
document.getElementById('xiaobaix-novel-draw-overlay').style.display = 'block';
|
||||
if (frameReady) sendInitData();
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
const overlay = document.getElementById('xiaobaix-novel-draw-overlay');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
function sendInitData() {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
if (!iframe?.contentWindow) return;
|
||||
const settings = getSettings();
|
||||
iframe.contentWindow.postMessage({
|
||||
source: 'LittleWhiteBox-NovelDraw',
|
||||
type: 'INIT_DATA',
|
||||
settings: {
|
||||
enabled: settings.enabled,
|
||||
mode: settings.mode,
|
||||
selectedPresetId: settings.selectedPresetId,
|
||||
presets: settings.presets,
|
||||
api: {
|
||||
mode: settings.api?.mode || 'tavern',
|
||||
apiKey: settings.api?.apiKey || '',
|
||||
},
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// iframe 通讯
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function handleFrameMessage(event) {
|
||||
const data = event.data;
|
||||
if (!data || data.source !== 'NovelDraw-Frame') return;
|
||||
|
||||
const settings = getSettings();
|
||||
|
||||
switch (data.type) {
|
||||
case 'FRAME_READY':
|
||||
frameReady = true;
|
||||
sendInitData();
|
||||
break;
|
||||
|
||||
case 'CLOSE':
|
||||
hideOverlay();
|
||||
break;
|
||||
|
||||
case 'SAVE_MODE':
|
||||
settings.mode = data.mode;
|
||||
saveSettingsDebounced();
|
||||
break;
|
||||
|
||||
case 'SAVE_API_CONFIG':
|
||||
settings.api = {
|
||||
mode: data.apiMode || 'tavern',
|
||||
apiKey: data.apiKey || '',
|
||||
};
|
||||
saveSettingsDebounced();
|
||||
postStatus('success', 'API 设置已保存');
|
||||
break;
|
||||
|
||||
case 'TEST_API_CONNECTION':
|
||||
handleTestConnection(data);
|
||||
break;
|
||||
|
||||
case 'SAVE_PRESET':
|
||||
settings.selectedPresetId = data.selectedPresetId;
|
||||
settings.presets = data.presets;
|
||||
saveSettingsDebounced();
|
||||
break;
|
||||
|
||||
case 'ADD_PRESET': {
|
||||
const id = generateId();
|
||||
const base = getActivePreset();
|
||||
const copy = base ? JSON.parse(JSON.stringify(base)) : { ...DEFAULT_PRESET };
|
||||
copy.id = id;
|
||||
copy.name = data.name || `新预设-${settings.presets.length + 1}`;
|
||||
settings.presets.push(copy);
|
||||
settings.selectedPresetId = id;
|
||||
saveSettingsDebounced();
|
||||
sendInitData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DUP_PRESET': {
|
||||
const base = getActivePreset();
|
||||
if (!base) break;
|
||||
const id = generateId();
|
||||
const copy = JSON.parse(JSON.stringify(base));
|
||||
copy.id = id;
|
||||
copy.name = `${base.name || '预设'}-副本`;
|
||||
settings.presets.push(copy);
|
||||
settings.selectedPresetId = id;
|
||||
saveSettingsDebounced();
|
||||
sendInitData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'DEL_PRESET': {
|
||||
if (settings.presets.length <= 1) break;
|
||||
const idx = settings.presets.findIndex(p => p.id === settings.selectedPresetId);
|
||||
if (idx >= 0) settings.presets.splice(idx, 1);
|
||||
settings.selectedPresetId = settings.presets[0]?.id ?? null;
|
||||
saveSettingsDebounced();
|
||||
sendInitData();
|
||||
break;
|
||||
}
|
||||
|
||||
case 'TEST_PREVIEW':
|
||||
handleTestPreview(data);
|
||||
break;
|
||||
|
||||
case 'ATTACH_LAST':
|
||||
handleAttachLast(data);
|
||||
break;
|
||||
|
||||
case 'AI_TAGS_ATTACH':
|
||||
handleAiTagsAttach();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection(data) {
|
||||
try {
|
||||
postStatus('loading', '测试连接中...');
|
||||
await testApiConnection(data.apiKey, data.apiMode);
|
||||
postStatus('success', '连接成功');
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '连接失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestPreview(data) {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
try {
|
||||
postStatus('loading', '生成中...');
|
||||
const preset = getActivePreset();
|
||||
const positive = joinTags(preset?.positivePrefix, data.sceneTags);
|
||||
const base64 = await generateNovelImageBase64({
|
||||
prompt: positive,
|
||||
negativePrompt: preset?.negativePrefix || '',
|
||||
params: preset?.params || {}
|
||||
});
|
||||
const url = await saveBase64AsFile(base64, getChatCharacterName(), `novel_preview_${Date.now()}`, 'png');
|
||||
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'PREVIEW_RESULT', url }, '*');
|
||||
postStatus('success', '完成');
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAttachLast(data) {
|
||||
try {
|
||||
postStatus('loading', '生成并追加中...');
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
let messageId = chat.length - 1;
|
||||
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
|
||||
if (messageId < 0) throw new Error('没有可追加的AI楼层');
|
||||
await generateAndAttachToMessage({ messageId, sceneTags: data.sceneTags || '' });
|
||||
postStatus('success', `已追加到楼层 ${messageId + 1}`);
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAiTagsAttach() {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
try {
|
||||
postStatus('loading', '生成场景TAG中...');
|
||||
const ctx = getContext();
|
||||
const chat = ctx.chat || [];
|
||||
let messageId = chat.length - 1;
|
||||
while (messageId >= 0 && chat[messageId]?.is_user) messageId--;
|
||||
if (messageId < 0) throw new Error('没有可追加的AI楼层');
|
||||
const tags = await generateSceneTagsFromChat({ messageId });
|
||||
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'AI_TAGS_RESULT', tags }, '*');
|
||||
postStatus('loading', '出图并追加中...');
|
||||
await generateAndAttachToMessage({ messageId, sceneTags: tags });
|
||||
postStatus('success', `已追加到楼层 ${messageId + 1}`);
|
||||
} catch (e) {
|
||||
postStatus('error', e?.message || '失败');
|
||||
}
|
||||
}
|
||||
|
||||
function postStatus(state, text) {
|
||||
const iframe = document.getElementById('xiaobaix-novel-draw-iframe');
|
||||
iframe?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'STATUS', state, text }, '*');
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 初始化与清理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function openNovelDrawSettings() {
|
||||
showOverlay();
|
||||
}
|
||||
|
||||
export function initNovelDraw() {
|
||||
if (window?.isXiaobaixEnabled === false) return;
|
||||
getSettings();
|
||||
events.on(event_types.GENERATION_ENDED, async () => {
|
||||
try { await autoGenerateAndAttachToLastAI(); } catch {}
|
||||
});
|
||||
window.xiaobaixNovelDraw = {
|
||||
getSettings,
|
||||
generateNovelImageBase64,
|
||||
generateAndAttachToMessage,
|
||||
generateSceneTagsFromChat,
|
||||
autoGenerateAndAttachToLastAI,
|
||||
openSettings: openNovelDrawSettings,
|
||||
};
|
||||
window.registerModuleCleanup?.(MODULE_KEY, cleanupNovelDraw);
|
||||
}
|
||||
|
||||
export function cleanupNovelDraw() {
|
||||
events.cleanup();
|
||||
hideOverlay();
|
||||
overlayCreated = false;
|
||||
frameReady = false;
|
||||
window.removeEventListener('message', handleFrameMessage);
|
||||
document.getElementById('xiaobaix-novel-draw-overlay')?.remove();
|
||||
delete window.xiaobaixNovelDraw;
|
||||
}
|
||||
Reference in New Issue
Block a user