Files
LittleWhiteBox/modules/novel-draw/novel-draw.js
RT15548 593fce3c8c
2025-12-19 02:19:10 +08:00

701 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}