482 lines
21 KiB
JavaScript
482 lines
21 KiB
JavaScript
// ════════════════════════════════════════════════════════════════════════════
|
|
// 消息楼层增强器
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
import { extension_settings } from "../../../../../extensions.js";
|
|
import { EXT_ID } from "../../core/constants.js";
|
|
import { createModuleEvents, event_types } from "../../core/event-manager.js";
|
|
import { xbLog } from "../../core/debug-core.js";
|
|
|
|
import { generateImage, clearQueue } from "./fw-image.js";
|
|
import {
|
|
synthesizeSpeech,
|
|
loadVoices,
|
|
VALID_EMOTIONS,
|
|
DEFAULT_VOICE,
|
|
DEFAULT_SPEED
|
|
} from "./fw-voice.js";
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 状态
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
const events = createModuleEvents('messageEnhancer');
|
|
const CSS_INJECTED_KEY = 'xb-me-css-injected';
|
|
|
|
let currentAudio = null;
|
|
let imageObserver = null;
|
|
let novelDrawObserver = null;
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 初始化与清理
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
export async function initMessageEnhancer() {
|
|
const settings = extension_settings[EXT_ID];
|
|
if (!settings?.fourthWall?.enabled) return;
|
|
|
|
xbLog.info('messageEnhancer', '初始化消息增强器');
|
|
|
|
injectStyles();
|
|
await loadVoices();
|
|
initImageObserver();
|
|
initNovelDrawObserver();
|
|
|
|
events.on(event_types.CHAT_CHANGED, () => {
|
|
clearQueue();
|
|
setTimeout(processAllMessages, 150);
|
|
});
|
|
|
|
events.on(event_types.MESSAGE_RECEIVED, handleMessageChange);
|
|
events.on(event_types.USER_MESSAGE_RENDERED, handleMessageChange);
|
|
events.on(event_types.MESSAGE_EDITED, handleMessageChange);
|
|
events.on(event_types.MESSAGE_UPDATED, handleMessageChange);
|
|
events.on(event_types.MESSAGE_SWIPED, handleMessageChange);
|
|
|
|
events.on(event_types.GENERATION_STOPPED, () => setTimeout(processAllMessages, 150));
|
|
events.on(event_types.GENERATION_ENDED, () => setTimeout(processAllMessages, 150));
|
|
|
|
processAllMessages();
|
|
}
|
|
|
|
export function cleanupMessageEnhancer() {
|
|
xbLog.info('messageEnhancer', '清理消息增强器');
|
|
|
|
events.cleanup();
|
|
clearQueue();
|
|
|
|
if (imageObserver) {
|
|
imageObserver.disconnect();
|
|
imageObserver = null;
|
|
}
|
|
|
|
if (novelDrawObserver) {
|
|
novelDrawObserver.disconnect();
|
|
novelDrawObserver = null;
|
|
}
|
|
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio = null;
|
|
}
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// NovelDraw 兼容
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function initNovelDrawObserver() {
|
|
if (novelDrawObserver) return;
|
|
|
|
const chat = document.getElementById('chat');
|
|
if (!chat) {
|
|
setTimeout(initNovelDrawObserver, 500);
|
|
return;
|
|
}
|
|
|
|
let debounceTimer = null;
|
|
const pendingTexts = new Set();
|
|
|
|
novelDrawObserver = new MutationObserver((mutations) => {
|
|
const settings = extension_settings[EXT_ID];
|
|
if (!settings?.fourthWall?.enabled) return;
|
|
|
|
for (const mutation of mutations) {
|
|
for (const node of mutation.addedNodes) {
|
|
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
|
|
|
const hasNdImg = node.classList?.contains('xb-nd-img') || node.querySelector?.('.xb-nd-img');
|
|
if (!hasNdImg) continue;
|
|
|
|
const mesText = node.closest('.mes_text');
|
|
if (mesText && hasUnrenderedVoice(mesText)) {
|
|
pendingTexts.add(mesText);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (pendingTexts.size > 0 && !debounceTimer) {
|
|
debounceTimer = setTimeout(() => {
|
|
pendingTexts.forEach(mesText => {
|
|
if (document.contains(mesText)) enhanceMessageContent(mesText);
|
|
});
|
|
pendingTexts.clear();
|
|
debounceTimer = null;
|
|
}, 50);
|
|
}
|
|
});
|
|
|
|
novelDrawObserver.observe(chat, { childList: true, subtree: true });
|
|
}
|
|
|
|
function hasUnrenderedVoice(mesText) {
|
|
if (!mesText) return false;
|
|
return /\[(?:voice|语音)\s*:[^\]]+\]/i.test(mesText.innerHTML);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 事件处理
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function handleMessageChange(data) {
|
|
setTimeout(() => {
|
|
const messageId = typeof data === 'object'
|
|
? (data.messageId ?? data.id ?? data.index ?? data.mesId)
|
|
: data;
|
|
|
|
if (Number.isFinite(messageId)) {
|
|
const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`);
|
|
if (mesText) enhanceMessageContent(mesText);
|
|
} else {
|
|
processAllMessages();
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
function processAllMessages() {
|
|
const settings = extension_settings[EXT_ID];
|
|
if (!settings?.fourthWall?.enabled) return;
|
|
document.querySelectorAll('#chat .mes .mes_text').forEach(enhanceMessageContent);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 图片观察器
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function initImageObserver() {
|
|
if (imageObserver) return;
|
|
|
|
imageObserver = new IntersectionObserver((entries) => {
|
|
entries.forEach(entry => {
|
|
if (!entry.isIntersecting) return;
|
|
const slot = entry.target;
|
|
if (slot.dataset.loaded === '1' || slot.dataset.loading === '1') return;
|
|
const tags = decodeURIComponent(slot.dataset.tags || '');
|
|
if (!tags) return;
|
|
slot.dataset.loading = '1';
|
|
loadImage(slot, tags);
|
|
});
|
|
}, { rootMargin: '200px 0px', threshold: 0.01 });
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 样式注入
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function injectStyles() {
|
|
if (document.getElementById(CSS_INJECTED_KEY)) return;
|
|
|
|
const style = document.createElement('style');
|
|
style.id = CSS_INJECTED_KEY;
|
|
style.textContent = `
|
|
.xb-voice-bubble {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 10px;
|
|
background: #95ec69;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
min-width: 60px;
|
|
max-width: 180px;
|
|
margin: 3px 0;
|
|
transition: filter 0.15s;
|
|
}
|
|
.xb-voice-bubble:hover { filter: brightness(0.95); }
|
|
.xb-voice-bubble:active { filter: brightness(0.9); }
|
|
.xb-voice-waves {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 2px;
|
|
width: 16px;
|
|
height: 14px;
|
|
flex-shrink: 0;
|
|
}
|
|
.xb-voice-bar {
|
|
width: 2px;
|
|
background: #fff;
|
|
border-radius: 1px;
|
|
opacity: 0.9;
|
|
}
|
|
.xb-voice-bar:nth-child(1) { height: 5px; }
|
|
.xb-voice-bar:nth-child(2) { height: 8px; }
|
|
.xb-voice-bar:nth-child(3) { height: 11px; }
|
|
.xb-voice-bubble.playing .xb-voice-bar { animation: xb-wechat-wave 1.2s infinite ease-in-out; }
|
|
.xb-voice-bubble.playing .xb-voice-bar:nth-child(1) { animation-delay: 0s; }
|
|
.xb-voice-bubble.playing .xb-voice-bar:nth-child(2) { animation-delay: 0.2s; }
|
|
.xb-voice-bubble.playing .xb-voice-bar:nth-child(3) { animation-delay: 0.4s; }
|
|
@keyframes xb-wechat-wave { 0%, 100% { opacity: 0.3; } 50% { opacity: 1; } }
|
|
.xb-voice-duration { font-size: 12px; color: #000; opacity: 0.7; margin-left: auto; }
|
|
.xb-voice-bubble.loading { opacity: 0.7; }
|
|
.xb-voice-bubble.loading .xb-voice-waves { animation: xb-voice-pulse 1s infinite; }
|
|
@keyframes xb-voice-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
|
.xb-voice-bubble.error { background: #ffb3b3 !important; }
|
|
.mes[is_user="true"] .xb-voice-bubble { background: #fff; }
|
|
.mes[is_user="true"] .xb-voice-bar { background: #b2b2b2; }
|
|
.xb-img-slot { margin: 8px 0; min-height: 60px; position: relative; display: inline-block; }
|
|
.xb-img-slot img.xb-generated-img { max-width: min(400px, 80%); max-height: 60vh; border-radius: 4px; display: block; cursor: pointer; transition: opacity 0.2s; }
|
|
.xb-img-slot img.xb-generated-img:hover { opacity: 0.9; }
|
|
.xb-img-placeholder { display: inline-flex; align-items: center; gap: 6px; padding: 12px 16px; background: rgba(0,0,0,0.04); border: 1px dashed rgba(0,0,0,0.15); border-radius: 4px; color: #999; font-size: 12px; }
|
|
.xb-img-placeholder i { font-size: 16px; opacity: 0.5; }
|
|
.xb-img-loading { display: inline-flex; align-items: center; gap: 8px; padding: 12px 16px; background: rgba(76,154,255,0.08); border: 1px solid rgba(76,154,255,0.2); border-radius: 4px; color: #666; font-size: 12px; }
|
|
.xb-img-loading i { animation: fa-spin 1s infinite linear; }
|
|
.xb-img-loading i.fa-clock { animation: none; }
|
|
.xb-img-error { display: inline-flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 16px; background: rgba(255,100,100,0.08); border: 1px dashed rgba(255,100,100,0.3); border-radius: 4px; color: #e57373; font-size: 12px; }
|
|
.xb-img-retry { padding: 4px 10px; background: rgba(255,100,100,0.1); border: 1px solid rgba(255,100,100,0.3); border-radius: 3px; color: #e57373; font-size: 11px; cursor: pointer; }
|
|
.xb-img-retry:hover { background: rgba(255,100,100,0.2); }
|
|
.xb-img-badge { position: absolute; top: 4px; right: 4px; background: rgba(0,0,0,0.5); color: #ffd700; font-size: 10px; padding: 2px 5px; border-radius: 3px; }
|
|
`;
|
|
document.head.appendChild(style);
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 内容增强
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function enhanceMessageContent(container) {
|
|
if (!container) return;
|
|
|
|
// Rewrites already-rendered message HTML; no new HTML source is introduced here.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
const html = container.innerHTML;
|
|
let enhanced = html;
|
|
let hasChanges = false;
|
|
|
|
enhanced = enhanced.replace(/\[(?:img|图片)\s*:\s*([^\]]+)\]/gi, (match, inner) => {
|
|
const tags = parseImageToken(inner);
|
|
if (!tags) return match;
|
|
hasChanges = true;
|
|
return `<div class="xb-img-slot" data-tags="${encodeURIComponent(tags)}"></div>`;
|
|
});
|
|
|
|
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:([^:]*):([^\]]+)\]/gi, (match, emotionRaw, voiceText) => {
|
|
const txt = voiceText.trim();
|
|
if (!txt) return match;
|
|
hasChanges = true;
|
|
return createVoiceBubbleHTML(txt, (emotionRaw || '').trim().toLowerCase());
|
|
});
|
|
|
|
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:\s*([^\]:]+)\]/gi, (match, voiceText) => {
|
|
const txt = voiceText.trim();
|
|
if (!txt) return match;
|
|
hasChanges = true;
|
|
return createVoiceBubbleHTML(txt, '');
|
|
});
|
|
|
|
if (hasChanges) {
|
|
// Replaces existing message HTML with enhanced tokens only.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
container.innerHTML = enhanced;
|
|
}
|
|
|
|
hydrateImageSlots(container);
|
|
hydrateVoiceSlots(container);
|
|
}
|
|
|
|
function parseImageToken(rawCSV) {
|
|
let txt = String(rawCSV || '').trim();
|
|
txt = txt.replace(/^(nsfw|sketchy)\s*:\s*/i, 'nsfw, ');
|
|
return txt.split(',').map(s => s.trim()).filter(Boolean).join(', ');
|
|
}
|
|
|
|
function createVoiceBubbleHTML(text, emotion) {
|
|
const duration = Math.max(2, Math.ceil(text.length / 4));
|
|
return `<div class="xb-voice-bubble" data-text="${encodeURIComponent(text)}" data-emotion="${emotion || ''}">
|
|
<div class="xb-voice-waves"><div class="xb-voice-bar"></div><div class="xb-voice-bar"></div><div class="xb-voice-bar"></div></div>
|
|
<span class="xb-voice-duration">${duration}"</span>
|
|
</div>`;
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 图片处理
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function hydrateImageSlots(container) {
|
|
container.querySelectorAll('.xb-img-slot').forEach(slot => {
|
|
if (slot.dataset.observed === '1') return;
|
|
slot.dataset.observed = '1';
|
|
|
|
if (!slot.dataset.loaded && !slot.dataset.loading && !slot.querySelector('img')) {
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
|
|
}
|
|
|
|
imageObserver?.observe(slot);
|
|
});
|
|
}
|
|
|
|
async function loadImage(slot, tags) {
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-spinner"></i> 检查缓存...</div>`;
|
|
|
|
try {
|
|
const base64 = await generateImage(tags, (status, position, delay) => {
|
|
switch (status) {
|
|
case 'queued':
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position}</div>`;
|
|
break;
|
|
case 'generating':
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-palette"></i> 生成中${position > 0 ? ` (${position} 排队)` : ''}...</div>`;
|
|
break;
|
|
case 'waiting':
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position} (${delay}s)</div>`;
|
|
break;
|
|
}
|
|
});
|
|
|
|
if (base64) renderImage(slot, base64, false);
|
|
|
|
} catch (err) {
|
|
slot.dataset.loaded = '1';
|
|
slot.dataset.loading = '';
|
|
|
|
if (err.message === '队列已清空') {
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
|
|
slot.dataset.loading = '';
|
|
slot.dataset.observed = '';
|
|
return;
|
|
}
|
|
|
|
// Template-only UI markup with escaped error text.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = `<div class="xb-img-error"><i class="fa-solid fa-exclamation-triangle"></i><div>${escapeHtml(err?.message || '失败')}</div><button class="xb-img-retry" data-tags="${encodeURIComponent(tags)}">重试</button></div>`;
|
|
bindRetryButton(slot);
|
|
}
|
|
}
|
|
|
|
function renderImage(slot, base64, fromCache) {
|
|
slot.dataset.loaded = '1';
|
|
slot.dataset.loading = '';
|
|
|
|
const img = document.createElement('img');
|
|
img.src = `data:image/png;base64,${base64}`;
|
|
img.className = 'xb-generated-img';
|
|
img.onclick = () => window.open(img.src, '_blank');
|
|
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
slot.innerHTML = '';
|
|
slot.appendChild(img);
|
|
|
|
if (fromCache) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'xb-img-badge';
|
|
// Template-only UI markup.
|
|
// eslint-disable-next-line no-unsanitized/property
|
|
badge.innerHTML = '<i class="fa-solid fa-bolt"></i>';
|
|
slot.appendChild(badge);
|
|
}
|
|
}
|
|
|
|
function bindRetryButton(slot) {
|
|
const btn = slot.querySelector('.xb-img-retry');
|
|
if (!btn) return;
|
|
btn.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
const tags = decodeURIComponent(btn.dataset.tags || '');
|
|
if (!tags) return;
|
|
slot.dataset.loaded = '';
|
|
slot.dataset.loading = '1';
|
|
await loadImage(slot, tags);
|
|
};
|
|
}
|
|
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
// 语音处理
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|
|
|
function hydrateVoiceSlots(container) {
|
|
container.querySelectorAll('.xb-voice-bubble').forEach(bubble => {
|
|
if (bubble.dataset.bound === '1') return;
|
|
bubble.dataset.bound = '1';
|
|
|
|
const text = decodeURIComponent(bubble.dataset.text || '');
|
|
const emotion = bubble.dataset.emotion || '';
|
|
if (!text) return;
|
|
|
|
bubble.onclick = async (e) => {
|
|
e.stopPropagation();
|
|
if (bubble.classList.contains('loading')) return;
|
|
|
|
if (bubble.classList.contains('playing') && currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio = null;
|
|
bubble.classList.remove('playing');
|
|
return;
|
|
}
|
|
|
|
if (currentAudio) {
|
|
currentAudio.pause();
|
|
currentAudio = null;
|
|
}
|
|
document.querySelectorAll('.xb-voice-bubble.playing').forEach(el => el.classList.remove('playing'));
|
|
|
|
await playVoice(text, emotion, bubble);
|
|
};
|
|
});
|
|
}
|
|
|
|
async function playVoice(text, emotion, bubbleEl) {
|
|
bubbleEl.classList.add('loading');
|
|
bubbleEl.classList.remove('error');
|
|
|
|
try {
|
|
const settings = extension_settings[EXT_ID]?.fourthWallVoice || {};
|
|
const audioBase64 = await synthesizeSpeech(text, {
|
|
voiceKey: settings.voice || DEFAULT_VOICE,
|
|
speed: settings.speed || DEFAULT_SPEED,
|
|
emotion: VALID_EMOTIONS.includes(emotion) ? emotion : null
|
|
});
|
|
|
|
bubbleEl.classList.remove('loading');
|
|
bubbleEl.classList.add('playing');
|
|
|
|
currentAudio = new Audio(`data:audio/mp3;base64,${audioBase64}`);
|
|
currentAudio.onended = () => { bubbleEl.classList.remove('playing'); currentAudio = null; };
|
|
currentAudio.onerror = () => { bubbleEl.classList.remove('playing'); bubbleEl.classList.add('error'); currentAudio = null; };
|
|
await currentAudio.play();
|
|
|
|
} catch (err) {
|
|
console.error('[MessageEnhancer] TTS 错误:', err);
|
|
bubbleEl.classList.remove('loading', 'playing');
|
|
bubbleEl.classList.add('error');
|
|
setTimeout(() => bubbleEl.classList.remove('error'), 3000);
|
|
}
|
|
}
|