1.18更新
This commit is contained in:
@@ -489,6 +489,8 @@ function createModal() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'cloud-presets-overlay';
|
||||
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
overlay.innerHTML = `
|
||||
<div class="cloud-presets-modal">
|
||||
<div class="cp-header">
|
||||
@@ -584,6 +586,8 @@ function renderPage() {
|
||||
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const pageItems = filteredPresets.slice(start, start + ITEMS_PER_PAGE);
|
||||
|
||||
// Escaped fields are used in the template.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
grid.innerHTML = pageItems.map(p => `
|
||||
<div class="cp-card">
|
||||
<div class="cp-card-head">
|
||||
@@ -609,24 +613,34 @@ function renderPage() {
|
||||
|
||||
btn.disabled = true;
|
||||
const origHtml = btn.innerHTML;
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> 导入中';
|
||||
|
||||
try {
|
||||
const data = await downloadPreset(url);
|
||||
if (onImportCallback) await onImportCallback(data);
|
||||
btn.classList.add('success');
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
btn.innerHTML = '<i class="fa-solid fa-check"></i> 成功';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('success');
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
btn.innerHTML = origHtml;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('[CloudPresets]', err);
|
||||
btn.classList.add('error');
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
btn.innerHTML = '<i class="fa-solid fa-xmark"></i> 失败';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('error');
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
btn.innerHTML = origHtml;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,10 +53,6 @@ function invalidateCache(slotId) {
|
||||
// 工具函数
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function getChatCharacterName() {
|
||||
const ctx = getContext();
|
||||
if (ctx.groupId) return String(ctx.groups?.[ctx.groupId]?.id ?? 'group');
|
||||
@@ -558,6 +554,8 @@ function createGalleryOverlay() {
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'nd-gallery-overlay';
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
overlay.innerHTML = `<button class="nd-gallery-close" id="nd-gallery-close">✕</button><div class="nd-gallery-main"><button class="nd-gallery-nav" id="nd-gallery-prev">‹</button><div class="nd-gallery-img-wrap"><img class="nd-gallery-img" id="nd-gallery-img" src="" alt=""><div class="nd-gallery-saved-badge" id="nd-gallery-saved-badge" style="display:none">已保存</div></div><button class="nd-gallery-nav" id="nd-gallery-next">›</button></div><div class="nd-gallery-thumbs" id="nd-gallery-thumbs"></div><div class="nd-gallery-actions" id="nd-gallery-actions"><button class="nd-gallery-btn primary" id="nd-gallery-use">使用此图</button><button class="nd-gallery-btn" id="nd-gallery-save">💾 保存到服务器</button><button class="nd-gallery-btn danger" id="nd-gallery-delete">🗑️ 删除</button></div><div class="nd-gallery-info" id="nd-gallery-info"></div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
@@ -612,6 +610,8 @@ function renderGallery() {
|
||||
const reversedPreviews = previews.slice().reverse();
|
||||
const thumbsContainer = document.getElementById('nd-gallery-thumbs');
|
||||
|
||||
// Generated from local preview data only.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
thumbsContainer.innerHTML = reversedPreviews.map((p, i) => {
|
||||
const src = p.savedUrl || `data:image/png;base64,${p.base64}`;
|
||||
const originalIndex = previews.length - 1 - i;
|
||||
|
||||
331
modules/novel-draw/image-live-effect.js
Normal file
331
modules/novel-draw/image-live-effect.js
Normal file
@@ -0,0 +1,331 @@
|
||||
// image-live-effect.js
|
||||
// Live Photo - 柔和分区 + 亮度感知
|
||||
|
||||
import { extensionFolderPath } from "../../core/constants.js";
|
||||
|
||||
let PIXI = null;
|
||||
let pixiLoading = null;
|
||||
const activeEffects = new Map();
|
||||
|
||||
async function ensurePixi() {
|
||||
if (PIXI) return PIXI;
|
||||
if (pixiLoading) return pixiLoading;
|
||||
|
||||
pixiLoading = new Promise((resolve, reject) => {
|
||||
if (window.PIXI) { PIXI = window.PIXI; resolve(PIXI); return; }
|
||||
const script = document.createElement('script');
|
||||
script.src = `${extensionFolderPath}/libs/pixi.min.js`;
|
||||
script.onload = () => { PIXI = window.PIXI; resolve(PIXI); };
|
||||
script.onerror = () => reject(new Error('PixiJS 加载失败'));
|
||||
// eslint-disable-next-line no-unsanitized/method
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
return pixiLoading;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// 着色器 - 柔和分区 + 亮度感知
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const VERTEX_SHADER = `
|
||||
attribute vec2 aVertexPosition;
|
||||
attribute vec2 aTextureCoord;
|
||||
uniform mat3 projectionMatrix;
|
||||
varying vec2 vTextureCoord;
|
||||
void main() {
|
||||
gl_Position = vec4((projectionMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0);
|
||||
vTextureCoord = aTextureCoord;
|
||||
}`;
|
||||
|
||||
const FRAGMENT_SHADER = `
|
||||
precision highp float;
|
||||
varying vec2 vTextureCoord;
|
||||
uniform sampler2D uSampler;
|
||||
uniform float uTime;
|
||||
uniform float uIntensity;
|
||||
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
float noise(vec2 p) {
|
||||
vec2 i = floor(p);
|
||||
vec2 f = fract(p);
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
return mix(
|
||||
mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
|
||||
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x),
|
||||
f.y
|
||||
);
|
||||
}
|
||||
|
||||
float zone(float v, float start, float end) {
|
||||
return smoothstep(start, start + 0.08, v) * (1.0 - smoothstep(end - 0.08, end, v));
|
||||
}
|
||||
|
||||
float skinDetect(vec4 color) {
|
||||
float brightness = dot(color.rgb, vec3(0.299, 0.587, 0.114));
|
||||
float warmth = color.r - color.b;
|
||||
return smoothstep(0.3, 0.6, brightness) * smoothstep(0.0, 0.15, warmth);
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = vTextureCoord;
|
||||
float v = uv.y;
|
||||
float u = uv.x;
|
||||
float centerX = abs(u - 0.5);
|
||||
|
||||
vec4 baseColor = texture2D(uSampler, uv);
|
||||
float skin = skinDetect(baseColor);
|
||||
|
||||
vec2 offset = vec2(0.0);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🛡️ 头部保护 (Y: 0 ~ 0.30)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float headLock = 1.0 - smoothstep(0.0, 0.30, v);
|
||||
float headDampen = mix(1.0, 0.05, headLock);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🫁 全局呼吸
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float breath = sin(uTime * 0.8) * 0.004;
|
||||
offset += (uv - 0.5) * breath * headDampen;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 👙 胸部区域 (Y: 0.35 ~ 0.55) - 呼吸起伏
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float chestZone = zone(v, 0.35, 0.55);
|
||||
float chestCenter = 1.0 - smoothstep(0.0, 0.35, centerX);
|
||||
float chestStrength = chestZone * chestCenter;
|
||||
|
||||
float breathRhythm = sin(uTime * 1.0) * 0.6 + sin(uTime * 2.0) * 0.4;
|
||||
|
||||
// 纵向起伏
|
||||
float chestY = breathRhythm * 0.010 * (1.0 + skin * 0.7);
|
||||
offset.y += chestY * chestStrength * uIntensity;
|
||||
|
||||
// 横向微扩
|
||||
float chestX = breathRhythm * 0.005 * (u - 0.5);
|
||||
offset.x += chestX * chestStrength * uIntensity * (1.0 + skin * 0.4);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🍑 腰臀区域 (Y: 0.55 ~ 0.75) - 轻微摇摆
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float hipZone = zone(v, 0.55, 0.75);
|
||||
float hipCenter = 1.0 - smoothstep(0.0, 0.4, centerX);
|
||||
float hipStrength = hipZone * hipCenter;
|
||||
|
||||
// 左右轻晃
|
||||
float hipSway = sin(uTime * 0.6) * 0.008;
|
||||
offset.x += hipSway * hipStrength * uIntensity * (1.0 + skin * 0.4);
|
||||
|
||||
// 微弱弹动
|
||||
float hipBounce = sin(uTime * 1.0 + 0.3) * 0.006;
|
||||
offset.y += hipBounce * hipStrength * uIntensity * (1.0 + skin * 0.6);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 👗 底部区域 (Y: 0.75+) - 轻微飘动
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float bottomZone = smoothstep(0.73, 0.80, v);
|
||||
float bottomStrength = bottomZone * (v - 0.75) * 2.5;
|
||||
|
||||
float bottomWave = sin(uTime * 1.2 + u * 5.0) * 0.012;
|
||||
offset.x += bottomWave * bottomStrength * uIntensity;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 🌊 环境流动 - 极轻微
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
float ambient = noise(uv * 2.5 + uTime * 0.15) * 0.003;
|
||||
offset.x += ambient * headDampen * uIntensity;
|
||||
offset.y += noise(uv * 3.0 - uTime * 0.12) * 0.002 * headDampen * uIntensity;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 应用偏移
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
vec2 finalUV = clamp(uv + offset, 0.001, 0.999);
|
||||
|
||||
gl_FragColor = texture2D(uSampler, finalUV);
|
||||
}`;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Live 效果类
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ImageLiveEffect {
|
||||
constructor(container, imageSrc) {
|
||||
this.container = container;
|
||||
this.imageSrc = imageSrc;
|
||||
this.app = null;
|
||||
this.sprite = null;
|
||||
this.filter = null;
|
||||
this.canvas = null;
|
||||
this.running = false;
|
||||
this.destroyed = false;
|
||||
this.startTime = Date.now();
|
||||
this.intensity = 1.0;
|
||||
this._boundAnimate = this.animate.bind(this);
|
||||
}
|
||||
|
||||
async init() {
|
||||
const wrap = this.container.querySelector('.xb-nd-img-wrap');
|
||||
const img = this.container.querySelector('img');
|
||||
if (!wrap || !img) return false;
|
||||
|
||||
const rect = img.getBoundingClientRect();
|
||||
this.width = Math.round(rect.width);
|
||||
this.height = Math.round(rect.height);
|
||||
if (this.width < 50 || this.height < 50) return false;
|
||||
|
||||
try {
|
||||
this.app = new PIXI.Application({
|
||||
width: this.width,
|
||||
height: this.height,
|
||||
backgroundAlpha: 0,
|
||||
resolution: 1,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
this.canvas = document.createElement('div');
|
||||
this.canvas.className = 'xb-nd-live-canvas';
|
||||
this.canvas.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;z-index:1;pointer-events:none;';
|
||||
this.app.view.style.cssText = 'width:100%;height:100%;display:block;';
|
||||
this.canvas.appendChild(this.app.view);
|
||||
wrap.appendChild(this.canvas);
|
||||
|
||||
const texture = await this.loadTexture(this.imageSrc);
|
||||
if (!texture || this.destroyed) { this.destroy(); return false; }
|
||||
|
||||
this.sprite = new PIXI.Sprite(texture);
|
||||
this.sprite.width = this.width;
|
||||
this.sprite.height = this.height;
|
||||
|
||||
this.filter = new PIXI.Filter(VERTEX_SHADER, FRAGMENT_SHADER, {
|
||||
uTime: 0,
|
||||
uIntensity: this.intensity,
|
||||
});
|
||||
this.sprite.filters = [this.filter];
|
||||
this.app.stage.addChild(this.sprite);
|
||||
|
||||
img.style.opacity = '0';
|
||||
this.container.classList.add('mode-live');
|
||||
this.start();
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('[Live] init error:', e);
|
||||
this.destroy();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
loadTexture(src) {
|
||||
return new Promise((resolve) => {
|
||||
if (this.destroyed) { resolve(null); return; }
|
||||
try {
|
||||
const texture = PIXI.Texture.from(src);
|
||||
if (texture.baseTexture.valid) resolve(texture);
|
||||
else {
|
||||
texture.baseTexture.once('loaded', () => resolve(texture));
|
||||
texture.baseTexture.once('error', () => resolve(null));
|
||||
}
|
||||
} catch { resolve(null); }
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.running || this.destroyed) return;
|
||||
this.running = true;
|
||||
this.app.ticker.add(this._boundAnimate);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false;
|
||||
this.app?.ticker?.remove(this._boundAnimate);
|
||||
}
|
||||
|
||||
animate() {
|
||||
if (this.destroyed || !this.filter) return;
|
||||
this.filter.uniforms.uTime = (Date.now() - this.startTime) / 1000;
|
||||
}
|
||||
|
||||
setIntensity(value) {
|
||||
this.intensity = Math.max(0, Math.min(2, value));
|
||||
if (this.filter) this.filter.uniforms.uIntensity = this.intensity;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyed) return;
|
||||
this.destroyed = true;
|
||||
this.stop();
|
||||
this.container?.classList.remove('mode-live');
|
||||
const img = this.container?.querySelector('img');
|
||||
if (img) img.style.opacity = '';
|
||||
this.canvas?.remove();
|
||||
this.app?.destroy(true, { children: true, texture: false });
|
||||
this.app = null;
|
||||
this.sprite = null;
|
||||
this.filter = null;
|
||||
this.canvas = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// API
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export async function toggleLiveEffect(container) {
|
||||
const existing = activeEffects.get(container);
|
||||
const btn = container.querySelector('.xb-nd-live-btn');
|
||||
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
activeEffects.delete(container);
|
||||
btn?.classList.remove('active');
|
||||
return false;
|
||||
}
|
||||
|
||||
btn?.classList.add('loading');
|
||||
|
||||
try {
|
||||
await ensurePixi();
|
||||
const img = container.querySelector('img');
|
||||
if (!img?.src) { btn?.classList.remove('loading'); return false; }
|
||||
|
||||
const effect = new ImageLiveEffect(container, img.src);
|
||||
const success = await effect.init();
|
||||
btn?.classList.remove('loading');
|
||||
|
||||
if (success) {
|
||||
activeEffects.set(container, effect);
|
||||
btn?.classList.add('active');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
console.error('[Live] failed:', e);
|
||||
btn?.classList.remove('loading');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyLiveEffect(container) {
|
||||
const effect = activeEffects.get(container);
|
||||
if (effect) {
|
||||
effect.destroy();
|
||||
activeEffects.delete(container);
|
||||
container.querySelector('.xb-nd-live-btn')?.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyAllLiveEffects() {
|
||||
activeEffects.forEach(e => e.destroy());
|
||||
activeEffects.clear();
|
||||
}
|
||||
|
||||
export function isLiveActive(container) {
|
||||
return activeEffects.has(container);
|
||||
}
|
||||
|
||||
export function getEffect(container) {
|
||||
return activeEffects.get(container);
|
||||
}
|
||||
@@ -65,6 +65,13 @@ body {
|
||||
display: flex; background: var(--bg-input);
|
||||
border: 1px solid var(--border); border-radius: 16px; padding: 2px;
|
||||
}
|
||||
.header-toggles { display: flex; gap: 6px; margin-right: 8px; }
|
||||
.header-toggle {
|
||||
display: flex; align-items: center; gap: 4px; padding: 4px 8px;
|
||||
background: var(--bg-input); border: 1px solid var(--border); border-radius: 12px;
|
||||
font-size: 11px; color: var(--text-secondary); cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.header-toggle input { accent-color: var(--accent); }
|
||||
.header-mode button {
|
||||
padding: 6px 14px; border: none; border-radius: 14px;
|
||||
background: transparent; color: var(--text-secondary);
|
||||
@@ -210,6 +217,7 @@ select.input { cursor: pointer; }
|
||||
border: 1px solid rgba(212, 165, 116, 0.2); border-radius: 8px;
|
||||
font-size: 12px; color: var(--text-secondary); line-height: 1.6;
|
||||
}
|
||||
.tip-text { display: flex; flex-direction: column; gap: 4px; }
|
||||
.tip-box i { color: var(--accent); flex-shrink: 0; margin-top: 2px; }
|
||||
.gallery-char-section { margin-bottom: 16px; }
|
||||
.gallery-char-header {
|
||||
@@ -363,6 +371,16 @@ select.input { cursor: pointer; }
|
||||
<div id="nd_badge" class="header-badge"><i class="fa-solid fa-circle"></i><span>未启用</span></div>
|
||||
<span class="header-credit" id="nd_credits"></span>
|
||||
<div class="header-spacer"></div>
|
||||
<div class="header-toggles">
|
||||
<label class="header-toggle">
|
||||
<input type="checkbox" id="nd_show_floor">
|
||||
<span>楼层</span>
|
||||
</label>
|
||||
<label class="header-toggle">
|
||||
<input type="checkbox" id="nd_show_floating">
|
||||
<span>悬浮</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="header-mode">
|
||||
<button data-mode="manual" class="active">手动</button>
|
||||
<button data-mode="auto">自动</button>
|
||||
@@ -410,7 +428,11 @@ select.input { cursor: pointer; }
|
||||
</div>
|
||||
<div class="tip-box">
|
||||
<i class="fa-solid fa-lightbulb"></i>
|
||||
<div>聊天界面点击悬浮球 🎨 即可为最后一条AI消息生成配图。开启自动模式后,AI回复时会自动配图。</div>
|
||||
<div class="tip-text">
|
||||
<div>消息楼层按钮的 🎨 为对应消息生成配图。</div>
|
||||
<div>悬浮按钮的 🎨 仅作用于最后一条AI消息。</div>
|
||||
<div>开启自动模式后,AI回复时会自动配图。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -662,7 +684,7 @@ select.input { cursor: pointer; }
|
||||
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer;">
|
||||
<input type="checkbox" id="nd_use_stream"> 启用流式生成(gemini不勾)
|
||||
<input type="checkbox" id="nd_use_stream"> 启用流式生成
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:8px;">
|
||||
@@ -829,7 +851,9 @@ let state = {
|
||||
paramsPresets: [],
|
||||
llmApi: { provider: 'st', url: '', key: '', model: '', modelCache: [] },
|
||||
useStream: true,
|
||||
characterTags: []
|
||||
characterTags: [],
|
||||
showFloorButton: true,
|
||||
showFloatingButton: false
|
||||
};
|
||||
|
||||
let gallerySummary = {};
|
||||
@@ -845,8 +869,11 @@ let modalData = { slotId: null, images: [], currentIndex: 0, charName: null };
|
||||
const $ = id => document.getElementById(id);
|
||||
const $$ = sel => document.querySelectorAll(sel);
|
||||
|
||||
const PARENT_ORIGIN = (() => {
|
||||
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
|
||||
})();
|
||||
function postToParent(payload) {
|
||||
window.parent.postMessage({ source: 'NovelDraw-Frame', ...payload }, '*');
|
||||
window.parent.postMessage({ source: 'NovelDraw-Frame', ...payload }, PARENT_ORIGIN);
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
@@ -1256,6 +1283,8 @@ function getCurrentLlmModel() {
|
||||
function applyStateToUI() {
|
||||
updateBadge(state.enabled);
|
||||
updateModeButtons(state.mode);
|
||||
$('nd_show_floor').checked = state.showFloorButton !== false;
|
||||
$('nd_show_floating').checked = state.showFloatingButton === true;
|
||||
|
||||
$('nd_api_key').value = state.apiKey || '';
|
||||
$('nd_timeout').value = Math.round((state.timeout > 0 ? state.timeout : DEFAULTS.timeout) / 1000);
|
||||
@@ -1384,7 +1413,9 @@ function collectParamsPreset() {
|
||||
// 消息处理
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Guarded by origin/source check.
|
||||
window.addEventListener('message', event => {
|
||||
if (event.origin !== PARENT_ORIGIN || event.source !== window.parent) return;
|
||||
const data = event.data;
|
||||
if (!data || data.source !== 'LittleWhiteBox-NovelDraw') return;
|
||||
|
||||
@@ -1483,6 +1514,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
updateModeButtons(state.mode);
|
||||
postToParent({ type: 'SAVE_MODE', mode: state.mode });
|
||||
}));
|
||||
|
||||
$('nd_show_floor').addEventListener('change', () => {
|
||||
postToParent({
|
||||
type: 'SAVE_BUTTON_MODE',
|
||||
showFloorButton: $('nd_show_floor').checked,
|
||||
showFloatingButton: $('nd_show_floating').checked
|
||||
});
|
||||
});
|
||||
|
||||
$('nd_show_floating').addEventListener('change', () => {
|
||||
postToParent({
|
||||
type: 'SAVE_BUTTON_MODE',
|
||||
showFloorButton: $('nd_show_floor').checked,
|
||||
showFloatingButton: $('nd_show_floating').checked
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 关闭按钮
|
||||
@@ -1717,4 +1764,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user