1.18更新

This commit is contained in:
RT15548
2026-01-18 20:04:43 +08:00
committed by GitHub
parent be142640c0
commit 03ba508a31
62 changed files with 18838 additions and 7264 deletions

View File

@@ -29,6 +29,7 @@ import {
parsePresetData,
destroyCloudPresets
} from './cloud-presets.js';
import { postToIframe, isTrustedMessage } from "../../core/iframe-messaging.js";
// ═══════════════════════════════════════════════════════════════════════════
// 常量
@@ -42,7 +43,7 @@ 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 = 10;
const INITIAL_RENDER_MESSAGE_LIMIT = 1;
const events = createModuleEvents(MODULE_KEY);
@@ -86,6 +87,8 @@ const DEFAULT_SETTINGS = {
useWorldInfo: false,
characterTags: [],
overrideSize: 'default',
showFloorButton: true,
showFloatingButton: false,
};
// ═══════════════════════════════════════════════════════════════════════════
@@ -102,6 +105,7 @@ let settingsCache = null;
let settingsLoaded = false;
let generationAbortController = null;
let messageObserver = null;
let ensureNovelDrawPanelRef = null;
// ═══════════════════════════════════════════════════════════════════════════
// 样式
@@ -176,6 +180,13 @@ function ensureStyles() {
.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);
}
@@ -263,7 +274,7 @@ function abortGeneration() {
}
function isGenerating() {
return generationAbortController !== null;
return autoBusy || generationAbortController !== null;
}
// ═══════════════════════════════════════════════════════════════════════════
@@ -769,6 +780,7 @@ function buildImageHtml({ slotId, imgId, url, tags, positive, messageId, state =
<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}">
@@ -786,6 +798,7 @@ ${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;">
@@ -855,6 +868,12 @@ function setImageState(container, state) {
// ═══════════════════════════════════════════════════════════════════════════
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;
@@ -965,6 +984,23 @@ function handleTouchEnd(e) {
// 事件委托与图片操作
// ═══════════════════════════════════════════════════════════════════════════
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;
@@ -1044,6 +1080,10 @@ function setupEventDelegation() {
else await refreshSingleImage(container);
break;
}
case 'toggle-live': {
handleLiveToggle(container);
break;
}
}
}, { capture: true });
@@ -1100,6 +1140,8 @@ async function handleImageClick(container) {
errorType: '图片已删除',
errorMessage: '点击重试可重新生成'
});
// Template-only UI markup built locally.
// eslint-disable-next-line no-unsanitized/property
cont.outerHTML = failedHtml;
},
});
@@ -1154,6 +1196,8 @@ async function toggleEditPanel(container, show) {
});
}
// Escaped data used in template.
// eslint-disable-next-line no-unsanitized/property
scrollWrap.innerHTML = html;
editPanel.style.display = 'block';
@@ -1263,6 +1307,12 @@ async function refreshSingleImage(container) {
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);
@@ -1394,6 +1444,8 @@ async function deleteCurrentImage(container) {
errorType: '图片已删除',
errorMessage: '点击重试可重新生成'
});
// Template-only UI markup built locally.
// eslint-disable-next-line no-unsanitized/property
container.outerHTML = failedHtml;
showToast('图片已删除,占位符已保留');
}
@@ -1409,6 +1461,8 @@ async function retryFailedImage(container) {
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 {
@@ -1467,6 +1521,8 @@ async function retryFailedImage(container) {
historyCount: 1,
currentIndex: 0
});
// Template-only UI markup built locally.
// eslint-disable-next-line no-unsanitized/property
container.outerHTML = imgHtml;
showToast('图片生成成功!');
} catch (e) {
@@ -1480,6 +1536,8 @@ async function retryFailedImage(container) {
errorType: errorType.code,
errorMessage: errorType.desc
});
// Template-only UI markup built locally.
// eslint-disable-next-line no-unsanitized/property
container.outerHTML = buildFailedPlaceholderHtml({
slotId,
messageId,
@@ -1665,12 +1723,16 @@ async function handleMessageModified(data) {
// 多图生成
// ═══════════════════════════════════════════════════════════════════════════
async function generateAndInsertImages({ messageId, onStateChange }) {
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;
@@ -1878,37 +1940,93 @@ async function generateAndInsertImages({ messageId, onStateChange }) {
async function autoGenerateForLastAI() {
const s = getSettings();
if (!isModuleEnabled() || s.mode !== 'auto' || autoBusy) return;
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 { setState, FloatState } = await import('./floating-panel.js');
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': setState(FloatState.LLM); break;
case 'gen': setState(FloatState.GEN, data); break;
case 'progress': setState(FloatState.GEN, data); break;
case 'cooldown': setState(FloatState.COOLDOWN, data); break;
case 'success': setState(data.success === data.total ? FloatState.SUCCESS : FloatState.PARTIAL, data); break;
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);
const { setState, FloatState } = await import('./floating-panel.js');
setState(FloatState.ERROR, { error: classifyError(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;
}
@@ -1970,6 +2088,8 @@ function createOverlay() {
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);
}
@@ -1994,8 +2114,7 @@ async function sendInitData() {
const stats = await getCacheStats();
const settings = getSettings();
const gallerySummary = await getGallerySummary();
iframe.contentWindow.postMessage({
source: 'LittleWhiteBox-NovelDraw',
postToIframe(iframe, {
type: 'INIT_DATA',
settings: {
enabled: moduleInitialized,
@@ -2011,19 +2130,23 @@ async function sendInitData() {
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) {
document.getElementById('xiaobaix-novel-draw-iframe')?.contentWindow?.postMessage({ source: 'LittleWhiteBox-NovelDraw', type: 'STATUS', 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;
if (!data || data.source !== 'NovelDraw-Frame') return;
switch (data.type) {
case 'FRAME_READY':
@@ -2043,6 +2166,31 @@ async function handleFrameMessage(event) {
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;
@@ -2253,12 +2401,10 @@ async function handleFrameMessage(event) {
const charName = preview.characterName || getChatCharacterName();
const url = await saveBase64AsFile(preview.base64, charName, `novel_${data.imgId}`, 'png');
await updatePreviewSavedUrl(data.imgId, url);
document.getElementById('xiaobaix-novel-draw-iframe')?.contentWindow?.postMessage({
source: 'LittleWhiteBox-NovelDraw',
type: 'GALLERY_IMAGE_SAVED',
imgId: data.imgId,
savedUrl: 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) {
@@ -2273,12 +2419,10 @@ async function handleFrameMessage(event) {
const charName = data.charName;
if (!charName) break;
const slots = await getCharacterPreviews(charName);
document.getElementById('xiaobaix-novel-draw-iframe')?.contentWindow?.postMessage({
source: 'LittleWhiteBox-NovelDraw',
type: 'CHARACTER_PREVIEWS_LOADED',
charName,
slots
}, '*');
{
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);
}
@@ -2288,11 +2432,10 @@ async function handleFrameMessage(event) {
case 'DELETE_GALLERY_IMAGE': {
try {
await deletePreview(data.imgId);
document.getElementById('xiaobaix-novel-draw-iframe')?.contentWindow?.postMessage({
source: 'LittleWhiteBox-NovelDraw',
type: 'GALLERY_IMAGE_DELETED',
imgId: 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) {
@@ -2330,11 +2473,10 @@ async function handleFrameMessage(event) {
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 || {} });
document.getElementById('xiaobaix-novel-draw-iframe')?.contentWindow?.postMessage({
source: 'LittleWhiteBox-NovelDraw',
type: 'TEST_RESULT',
url: `data:image/png;base64,${base64}`
}, '*');
{
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);
@@ -2353,6 +2495,22 @@ export async function openNovelDrawSettings() {
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;
@@ -2364,10 +2522,52 @@ export async function initNovelDraw() {
setupEventDelegation();
setupGenerateInterceptor();
openDB().then(() => { const s = getSettings(); clearExpiredCache(s.cacheDays || 3); });
openDB().then(() => {
const s = getSettings();
clearExpiredCache(s.cacheDays || 3);
});
const { createFloatingPanel } = await import('./floating-panel.js');
createFloatingPanel();
// ════════════════════════════════════════════════════════════════════
// 动态导入 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);
@@ -2375,7 +2575,28 @@ export async function initNovelDraw() {
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.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,
@@ -2427,8 +2648,16 @@ export async function cleanupNovelDraw() {
window.removeEventListener('message', handleFrameMessage);
document.getElementById('xiaobaix-novel-draw-overlay')?.remove();
const { destroyFloatingPanel } = await import('./floating-panel.js');
destroyFloatingPanel();
// 动态导入并清理
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;