Unify fourth-wall voice playback with TTS synth runtime

This commit is contained in:
2026-02-18 23:08:44 +08:00
parent 65e26e4c72
commit 37ae0a9769
6 changed files with 557 additions and 472 deletions

View File

@@ -480,16 +480,7 @@ html, body {
<div class="fw-settings-row"> <div class="fw-settings-row">
<div class="fw-field"> <div class="fw-field">
<input type="checkbox" id="voice-enabled"> <input type="checkbox" id="voice-enabled">
<label for="voice-enabled">允许语音</label> <label for="voice-enabled">允许语音(使用 TTS 模块音色)</label>
</div>
<div class="fw-field fw-voice-select-wrap" style="display: none;">
<label>声音</label>
<select id="voice-select"></select>
</div>
<div class="fw-field fw-voice-speed-wrap" style="display: none;">
<label>语速</label>
<input type="range" id="voice-speed" min="0.5" max="2.0" step="0.1" value="1.0" style="width:70px;">
<span class="speed-val" id="voice-speed-val">1.0x</span>
</div> </div>
</div> </div>
@@ -561,17 +552,6 @@ html, body {
配置 配置
══════════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════════ */
const TTS_WORKER_URL = 'https://hstts.velure.codes';
const VALID_EMOTIONS = ['happy', 'sad', 'angry', 'surprise', 'scare', 'hate'];
const EMOTION_ICONS = {
happy: '😄', sad: '😢', angry: '😠', surprise: '😮', scare: '😨', hate: '🤢'
};
// 动态加载的声音列表
let voiceList = [];
let defaultVoiceKey = 'female_1';
/* ══════════════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════════════
工具函数 工具函数
══════════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════════ */
@@ -618,8 +598,8 @@ function postToParent(payload) {
window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, PARENT_ORIGIN); window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, PARENT_ORIGIN);
} }
function getEmotionIcon(emotion) { function getEmotionIcon() {
return EMOTION_ICONS[emotion] || ''; return '';
} }
/* ══════════════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════════════
@@ -636,30 +616,19 @@ let state = {
sessions: [], sessions: [],
activeSessionId: null, activeSessionId: null,
imgSettings: { enablePrompt: false }, imgSettings: { enablePrompt: false },
voiceSettings: { enabled: false, voice: 'female_1', speed: 1.0 }, voiceSettings: { enabled: false },
commentarySettings: { enabled: false, probability: 30 }, commentarySettings: { enabled: false, probability: 30 },
promptTemplates: {} promptTemplates: {}
}; };
let currentAudio = null; let activeVoiceRequestId = null;
/* ══════════════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════════════
加载声音列表 加载声音列表
══════════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════════ */
async function loadVoices() { function generateVoiceRequestId() {
try { return 'fwv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
const res = await fetch(`${TTS_WORKER_URL}/voices`);
if (!res.ok) throw new Error('Failed to load voices');
const data = await res.json();
voiceList = data.voices || [];
defaultVoiceKey = data.defaultVoice || 'female_1';
renderVoiceSelect();
} catch (err) {
console.error('[FW Voice] 加载声音列表失败:', err);
// 降级:使用空列表
voiceList = [];
}
} }
/* ══════════════════════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════════════════════
@@ -786,54 +755,48 @@ function bindRetryButton(slot) {
语音处理 语音处理
══════════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════════ */
async function playVoice(text, emotion, bubbleEl) { function requestPlayVoice(text, emotion, bubbleEl) {
if (currentAudio) { // Clear previous bubble state before issuing a new request.
currentAudio.pause(); document.querySelectorAll('.fw-voice-bubble.playing, .fw-voice-bubble.loading').forEach(el => {
currentAudio = null; el.classList.remove('playing', 'loading');
document.querySelectorAll('.fw-voice-bubble.playing').forEach(el => el.classList.remove('playing')); });
}
const voiceRequestId = generateVoiceRequestId();
activeVoiceRequestId = voiceRequestId;
bubbleEl.dataset.voiceRequestId = voiceRequestId;
bubbleEl.classList.add('loading'); bubbleEl.classList.add('loading');
bubbleEl.classList.remove('error'); bubbleEl.classList.remove('error');
try { postToParent({ type: 'PLAY_VOICE', text, emotion, voiceRequestId });
}
const requestBody = { function handleVoiceState(data) {
voiceKey: state.voiceSettings.voice || defaultVoiceKey, const { voiceRequestId, state: voiceState, duration } = data;
text: text, const bubble = document.querySelector(`.fw-voice-bubble[data-voice-request-id="${voiceRequestId}"]`);
speed: state.voiceSettings.speed || 1.0, if (!bubble) return;
uid: 'fw_' + Date.now(),
reqid: generateUUID()
};
if (emotion && VALID_EMOTIONS.includes(emotion)) { switch (voiceState) {
requestBody.emotion = emotion; case 'loading':
requestBody.emotionScale = 5; bubble.classList.add('loading');
} bubble.classList.remove('playing', 'error');
break;
const res = await fetch(TTS_WORKER_URL, { case 'playing':
method: 'POST', bubble.classList.remove('loading', 'error');
headers: { 'Content-Type': 'application/json' }, bubble.classList.add('playing');
body: JSON.stringify(requestBody) if (duration != null) {
}); const durationEl = bubble.querySelector('.fw-voice-duration');
if (durationEl) durationEl.textContent = Math.ceil(duration) + '"';
if (!res.ok) throw new Error(`HTTP ${res.status}`); }
break;
const data = await res.json(); case 'ended':
if (data.code !== 3000) throw new Error(data.message || 'TTS失败'); case 'stopped':
bubble.classList.remove('loading', 'playing');
bubbleEl.classList.remove('loading'); break;
bubbleEl.classList.add('playing'); case 'error':
bubble.classList.remove('loading', 'playing');
currentAudio = new Audio(`data:audio/mp3;base64,${data.data}`); bubble.classList.add('error');
currentAudio.onended = () => { bubbleEl.classList.remove('playing'); currentAudio = null; }; setTimeout(() => bubble.classList.remove('error'), 3000);
currentAudio.onerror = () => { bubbleEl.classList.remove('playing'); bubbleEl.classList.add('error'); currentAudio = null; }; break;
await currentAudio.play();
} catch (err) {
console.error('[FW Voice] TTS错误:', err);
bubbleEl.classList.remove('loading', 'playing');
bubbleEl.classList.add('error');
setTimeout(() => bubbleEl.classList.remove('error'), 3000);
} }
} }
@@ -846,12 +809,10 @@ function hydrateVoiceSlots(container) {
bubble.onclick = e => { bubble.onclick = e => {
e.stopPropagation(); e.stopPropagation();
if (bubble.classList.contains('loading')) return; if (bubble.classList.contains('loading')) return;
if (bubble.classList.contains('playing') && currentAudio) { if (bubble.classList.contains('playing')) {
currentAudio.pause(); postToParent({ type: 'STOP_VOICE', voiceRequestId: bubble.dataset.voiceRequestId });
currentAudio = null;
bubble.classList.remove('playing');
} else { } else {
playVoice(text, emotion, bubble); requestPlayVoice(text, emotion, bubble);
} }
}; };
} }
@@ -1022,24 +983,6 @@ function renderSessionSelect() {
).join(''); ).join('');
} }
// 使用动态加载的声音列表渲染下拉框
function renderVoiceSelect() {
const select = document.getElementById('voice-select');
if (!select || !voiceList.length) return;
const females = voiceList.filter(v => v.gender === 'female');
const males = voiceList.filter(v => v.gender === 'male');
select.innerHTML = `
<optgroup label="👩 女声">
${females.map(v => `<option value="${v.key}">${v.name} · ${v.tag}</option>`).join('')}
</optgroup>
<optgroup label="👨 男声">
${males.map(v => `<option value="${v.key}">${v.name} · ${v.tag}</option>`).join('')}
</optgroup>
`;
select.value = state.voiceSettings.voice || defaultVoiceKey;
}
function updateMenuUI() { function updateMenuUI() {
const actions = document.getElementById('header-actions'); const actions = document.getElementById('header-actions');
@@ -1057,11 +1000,6 @@ function updateMenuUI() {
} }
} }
function updateVoiceUI(enabled) {
document.querySelector('.fw-voice-select-wrap').style.display = enabled ? '' : 'none';
document.querySelector('.fw-voice-speed-wrap').style.display = enabled ? '' : 'none';
}
function updateCommentaryUI(enabled) { function updateCommentaryUI(enabled) {
document.querySelector('.fw-commentary-prob-wrap').style.display = enabled ? '' : 'none'; document.querySelector('.fw-commentary-prob-wrap').style.display = enabled ? '' : 'none';
} }
@@ -1161,14 +1099,6 @@ window.addEventListener('message', event => {
document.getElementById('img-prompt-enabled').checked = state.imgSettings.enablePrompt; document.getElementById('img-prompt-enabled').checked = state.imgSettings.enablePrompt;
document.getElementById('voice-enabled').checked = state.voiceSettings.enabled; document.getElementById('voice-enabled').checked = state.voiceSettings.enabled;
// 等声音列表加载完再设置值
if (voiceList.length) {
document.getElementById('voice-select').value = state.voiceSettings.voice || defaultVoiceKey;
}
document.getElementById('voice-speed').value = state.voiceSettings.speed || 1.0;
document.getElementById('voice-speed-val').textContent = (state.voiceSettings.speed || 1.0).toFixed(1) + 'x';
updateVoiceUI(state.voiceSettings.enabled);
document.getElementById('commentary-enabled').checked = state.commentarySettings.enabled; document.getElementById('commentary-enabled').checked = state.commentarySettings.enabled;
document.getElementById('commentary-prob').value = state.commentarySettings.probability; document.getElementById('commentary-prob').value = state.commentarySettings.probability;
document.getElementById('commentary-prob-val').textContent = state.commentarySettings.probability + '%'; document.getElementById('commentary-prob-val').textContent = state.commentarySettings.probability + '%';
@@ -1211,6 +1141,10 @@ window.addEventListener('message', event => {
updateFullscreenButton(data.isFullscreen); updateFullscreenButton(data.isFullscreen);
break; break;
case 'VOICE_STATE':
handleVoiceState(data);
break;
case 'IMAGE_RESULT': case 'IMAGE_RESULT':
handleImageResult(data); handleImageResult(data);
break; break;
@@ -1229,9 +1163,6 @@ window.addEventListener('message', event => {
══════════════════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════════════════ */
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// 先加载声音列表
await loadVoices();
document.getElementById('btn-menu-toggle').onclick = () => { state.menuExpanded = !state.menuExpanded; updateMenuUI(); }; document.getElementById('btn-menu-toggle').onclick = () => { state.menuExpanded = !state.menuExpanded; updateMenuUI(); };
document.getElementById('btn-settings').onclick = () => { state.settingsOpen = !state.settingsOpen; document.getElementById('settings-panel').classList.toggle('open', state.settingsOpen); }; document.getElementById('btn-settings').onclick = () => { state.settingsOpen = !state.settingsOpen; document.getElementById('settings-panel').classList.toggle('open', state.settingsOpen); };
document.getElementById('btn-settings-close').onclick = () => { state.settingsOpen = false; document.getElementById('settings-panel').classList.remove('open'); }; document.getElementById('btn-settings-close').onclick = () => { state.settingsOpen = false; document.getElementById('settings-panel').classList.remove('open'); };
@@ -1258,19 +1189,6 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('voice-enabled').onchange = function() { document.getElementById('voice-enabled').onchange = function() {
state.voiceSettings.enabled = this.checked; state.voiceSettings.enabled = this.checked;
updateVoiceUI(this.checked);
postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings });
};
document.getElementById('voice-select').onchange = function() {
state.voiceSettings.voice = this.value;
postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings });
};
document.getElementById('voice-speed').oninput = function() {
const val = parseFloat(this.value);
document.getElementById('voice-speed-val').textContent = val.toFixed(1) + 'x';
state.voiceSettings.speed = val;
postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings });
}; };

View File

@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 次元壁模块 - 主控制器 // Fourth Wall Module - Main Controller
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js"; import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js";
import { saveSettingsDebounced, chat_metadata, default_user_avatar, default_avatar } from "../../../../../../script.js"; import { saveSettingsDebounced, chat_metadata, default_user_avatar, default_avatar } from "../../../../../../script.js";
@@ -8,19 +8,20 @@ import { createModuleEvents, event_types } from "../../core/event-manager.js";
import { xbLog } from "../../core/debug-core.js"; import { xbLog } from "../../core/debug-core.js";
import { handleCheckCache, handleGenerate, clearExpiredCache } from "./fw-image.js"; import { handleCheckCache, handleGenerate, clearExpiredCache } from "./fw-image.js";
import { DEFAULT_VOICE, DEFAULT_SPEED } from "./fw-voice.js"; import { synthesizeAndPlay, stopCurrent as stopCurrentVoice } from "./fw-voice-runtime.js";
import { import {
buildPrompt, buildPrompt,
buildCommentaryPrompt, buildCommentaryPrompt,
DEFAULT_TOPUSER, DEFAULT_TOPUSER,
DEFAULT_CONFIRM, DEFAULT_CONFIRM,
DEFAULT_BOTTOM, DEFAULT_BOTTOM,
DEFAULT_META_PROTOCOL DEFAULT_META_PROTOCOL
} from "./fw-prompt.js"; } from "./fw-prompt.js";
import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js"; import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js";
import { postToIframe, isTrustedMessage, getTrustedOrigin } from "../../core/iframe-messaging.js"; import { postToIframe, isTrustedMessage, getTrustedOrigin } from "../../core/iframe-messaging.js";
// ════════════════════════════════════════════════════════════════════════════
// 常量 // ════════════════════════════════════════════
// Constants
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
const events = createModuleEvents('fourthWall'); const events = createModuleEvents('fourthWall');
@@ -30,7 +31,7 @@ const COMMENTARY_COOLDOWN = 180000;
const IFRAME_PING_TIMEOUT = 800; const IFRAME_PING_TIMEOUT = 800;
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 状态 // State
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
let overlayCreated = false; let overlayCreated = false;
@@ -44,37 +45,36 @@ let currentLoadedChatId = null;
let lastCommentaryTime = 0; let lastCommentaryTime = 0;
let commentaryBubbleEl = null; let commentaryBubbleEl = null;
let commentaryBubbleTimer = null; let commentaryBubbleTimer = null;
let currentVoiceRequestId = null;
// ═══════════════════════════════ 新增 ═══════════════════════════════
let visibilityHandler = null; let visibilityHandler = null;
let pendingPingId = null; let pendingPingId = null;
// ════════════════════════════════════════════════════════════════════
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 设置管理(保持不变) // Settings
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function getSettings() { function getSettings() {
extension_settings[EXT_ID] ||= {}; extension_settings[EXT_ID] ||= {};
const s = extension_settings[EXT_ID]; const s = extension_settings[EXT_ID];
s.fourthWall ||= { enabled: true }; s.fourthWall ||= { enabled: true };
s.fourthWallImage ||= { enablePrompt: false }; s.fourthWallImage ||= { enablePrompt: false };
s.fourthWallVoice ||= { enabled: false, voice: DEFAULT_VOICE, speed: DEFAULT_SPEED }; s.fourthWallVoice ||= { enabled: false };
s.fourthWallCommentary ||= { enabled: false, probability: 30 }; s.fourthWallCommentary ||= { enabled: false, probability: 30 };
s.fourthWallPromptTemplates ||= {}; s.fourthWallPromptTemplates ||= {};
const t = s.fourthWallPromptTemplates; const t = s.fourthWallPromptTemplates;
if (t.topuser === undefined) t.topuser = DEFAULT_TOPUSER; if (t.topuser === undefined) t.topuser = DEFAULT_TOPUSER;
if (t.confirm === undefined) t.confirm = DEFAULT_CONFIRM; if (t.confirm === undefined) t.confirm = DEFAULT_CONFIRM;
if (t.bottom === undefined) t.bottom = DEFAULT_BOTTOM; if (t.bottom === undefined) t.bottom = DEFAULT_BOTTOM;
if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL; if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL;
return s; return s;
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 工具函数(保持不变) // Utilities
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function b64UrlEncode(str) { function b64UrlEncode(str) {
@@ -162,7 +162,7 @@ function getAvatarUrls() {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 存储管理(保持不变) // Storage
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function getFWStore(chatId = getCurrentChatIdSafe()) { function getFWStore(chatId = getCurrentChatIdSafe()) {
@@ -171,17 +171,17 @@ function getFWStore(chatId = getCurrentChatIdSafe()) {
chat_metadata[chatId].extensions ||= {}; chat_metadata[chatId].extensions ||= {};
chat_metadata[chatId].extensions[EXT_ID] ||= {}; chat_metadata[chatId].extensions[EXT_ID] ||= {};
chat_metadata[chatId].extensions[EXT_ID].fw ||= {}; chat_metadata[chatId].extensions[EXT_ID].fw ||= {};
const fw = chat_metadata[chatId].extensions[EXT_ID].fw; const fw = chat_metadata[chatId].extensions[EXT_ID].fw;
fw.settings ||= { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true }; fw.settings ||= { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true };
if (!fw.sessions) { if (!fw.sessions) {
const oldHistory = Array.isArray(fw.history) ? fw.history.slice() : []; const oldHistory = Array.isArray(fw.history) ? fw.history.slice() : [];
fw.sessions = [{ id: 'default', name: '默认记录', createdAt: Date.now(), history: oldHistory }]; fw.sessions = [{ id: 'default', name: 'Default', createdAt: Date.now(), history: oldHistory }];
fw.activeSessionId = 'default'; fw.activeSessionId = 'default';
if (Object.prototype.hasOwnProperty.call(fw, 'history')) delete fw.history; if (Object.prototype.hasOwnProperty.call(fw, 'history')) delete fw.history;
} }
if (!fw.activeSessionId || !fw.sessions.find(s => s.id === fw.activeSessionId)) { if (!fw.activeSessionId || !fw.sessions.find(s => s.id === fw.activeSessionId)) {
fw.activeSessionId = fw.sessions[0]?.id || null; fw.activeSessionId = fw.sessions[0]?.id || null;
} }
@@ -199,7 +199,7 @@ function saveFWStore() {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// iframe 通讯 // iframe Communication
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function postToFrame(payload) { function postToFrame(payload) {
@@ -224,7 +224,7 @@ function sendInitData() {
const settings = getSettings(); const settings = getSettings();
const session = getActiveSession(); const session = getActiveSession();
const avatars = getAvatarUrls(); const avatars = getAvatarUrls();
postToFrame({ postToFrame({
type: 'INIT_DATA', type: 'INIT_DATA',
settings: store?.settings || {}, settings: store?.settings || {},
@@ -240,86 +240,128 @@ function sendInitData() {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// iframe 健康检测与恢复(新增) // iframe Health Check & Recovery
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function handleVisibilityChange() { function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return; if (document.visibilityState !== 'visible') return;
const overlay = document.getElementById('xiaobaix-fourth-wall-overlay'); const overlay = document.getElementById('xiaobaix-fourth-wall-overlay');
if (!overlay || overlay.style.display === 'none') return; if (!overlay || overlay.style.display === 'none') return;
checkIframeHealth(); checkIframeHealth();
} }
function checkIframeHealth() { function checkIframeHealth() {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
if (!iframe) return; if (!iframe) return;
// 生成唯一 ping ID
const pingId = 'ping_' + Date.now(); const pingId = 'ping_' + Date.now();
pendingPingId = pingId; pendingPingId = pingId;
// 尝试发送 PING
try { try {
const win = iframe.contentWindow; const win = iframe.contentWindow;
if (!win) { if (!win) {
recoverIframe('contentWindow 不存在'); recoverIframe('contentWindow missing');
return; return;
} }
win.postMessage({ source: 'LittleWhiteBox', type: 'PING', pingId }, getTrustedOrigin()); win.postMessage({ source: 'LittleWhiteBox', type: 'PING', pingId }, getTrustedOrigin());
} catch (e) { } catch (e) {
recoverIframe('无法访问 iframe: ' + e.message); recoverIframe('Cannot access iframe: ' + e.message);
return; return;
} }
// 设置超时检测
setTimeout(() => { setTimeout(() => {
if (pendingPingId === pingId) { if (pendingPingId === pingId) {
// 没有收到 PONG 响应 recoverIframe('PING timeout');
recoverIframe('PING 超时无响应');
} }
}, IFRAME_PING_TIMEOUT); }, IFRAME_PING_TIMEOUT);
} }
function handlePongResponse(pingId) { function handlePongResponse(pingId) {
if (pendingPingId === pingId) { if (pendingPingId === pingId) {
pendingPingId = null; // 清除,表示收到响应 pendingPingId = null;
} }
} }
function recoverIframe(reason) { function recoverIframe(reason) {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
if (!iframe) return; if (!iframe) return;
try { xbLog.warn('fourthWall', `iframe 恢复中: ${reason}`); } catch {} try { xbLog.warn('fourthWall', `iframe recovery: ${reason}`); } catch { }
// 重置状态
frameReady = false; frameReady = false;
pendingFrameMessages = []; pendingFrameMessages = [];
pendingPingId = null; pendingPingId = null;
// 如果正在流式生成,取消
if (isStreaming) { if (isStreaming) {
cancelGeneration(); cancelGeneration();
} }
// 重新加载 iframe
iframe.src = iframePath; iframe.src = iframePath;
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 消息处理(添加 PONG 处理) // Voice Handling
// ════════════════════════════════════════════════════════════════════════════
function handlePlayVoice(data) {
const { text, emotion, voiceRequestId } = data;
if (!text?.trim()) {
postToFrame({ type: 'VOICE_STATE', voiceRequestId, state: 'error', message: 'Voice text is empty' });
return;
}
// Notify old request as stopped
if (currentVoiceRequestId && currentVoiceRequestId !== voiceRequestId) {
postToFrame({ type: 'VOICE_STATE', voiceRequestId: currentVoiceRequestId, state: 'stopped' });
}
currentVoiceRequestId = voiceRequestId;
synthesizeAndPlay(text, emotion, {
requestId: voiceRequestId,
onState(state, info) {
if (currentVoiceRequestId !== voiceRequestId) return;
postToFrame({
type: 'VOICE_STATE',
voiceRequestId,
state,
duration: info?.duration,
message: info?.message,
});
},
});
}
function handleStopVoice(data) {
const targetId = data?.voiceRequestId || currentVoiceRequestId;
stopCurrentVoice();
if (targetId) {
postToFrame({ type: 'VOICE_STATE', voiceRequestId: targetId, state: 'stopped' });
}
currentVoiceRequestId = null;
}
function stopVoiceAndNotify() {
if (currentVoiceRequestId) {
postToFrame({ type: 'VOICE_STATE', voiceRequestId: currentVoiceRequestId, state: 'stopped' });
}
stopCurrentVoice();
currentVoiceRequestId = null;
}
// ════════════════════════════════════════════════════════════════════════════
// Frame Message Handler
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function handleFrameMessage(event) { function handleFrameMessage(event) {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
if (!isTrustedMessage(event, iframe, 'LittleWhiteBox-FourthWall')) return; if (!isTrustedMessage(event, iframe, 'LittleWhiteBox-FourthWall')) return;
const data = event.data; const data = event.data;
const store = getFWStore(); const store = getFWStore();
const settings = getSettings(); const settings = getSettings();
switch (data.type) { switch (data.type) {
case 'FRAME_READY': case 'FRAME_READY':
frameReady = true; frameReady = true;
@@ -327,11 +369,9 @@ function handleFrameMessage(event) {
sendInitData(); sendInitData();
break; break;
// ═══════════════════════════ 新增 ═══════════════════════════
case 'PONG': case 'PONG':
handlePongResponse(data.pingId); handlePongResponse(data.pingId);
break; break;
// ════════════════════════════════════════════════════════════
case 'TOGGLE_FULLSCREEN': case 'TOGGLE_FULLSCREEN':
toggleFullscreen(); toggleFullscreen();
@@ -340,29 +380,29 @@ function handleFrameMessage(event) {
case 'SEND_MESSAGE': case 'SEND_MESSAGE':
handleSendMessage(data); handleSendMessage(data);
break; break;
case 'REGENERATE': case 'REGENERATE':
handleRegenerate(data); handleRegenerate(data);
break; break;
case 'CANCEL_GENERATION': case 'CANCEL_GENERATION':
cancelGeneration(); cancelGeneration();
break; break;
case 'SAVE_SETTINGS': case 'SAVE_SETTINGS':
if (store) { if (store) {
Object.assign(store.settings, data.settings); Object.assign(store.settings, data.settings);
saveFWStore(); saveFWStore();
} }
break; break;
case 'SAVE_IMG_SETTINGS': case 'SAVE_IMG_SETTINGS':
Object.assign(settings.fourthWallImage, data.imgSettings); Object.assign(settings.fourthWallImage, data.imgSettings);
saveSettingsDebounced(); saveSettingsDebounced();
break; break;
case 'SAVE_VOICE_SETTINGS': case 'SAVE_VOICE_SETTINGS':
Object.assign(settings.fourthWallVoice, data.voiceSettings); settings.fourthWallVoice.enabled = !!data.voiceSettings?.enabled;
saveSettingsDebounced(); saveSettingsDebounced();
break; break;
@@ -370,7 +410,7 @@ function handleFrameMessage(event) {
Object.assign(settings.fourthWallCommentary, data.commentarySettings); Object.assign(settings.fourthWallCommentary, data.commentarySettings);
saveSettingsDebounced(); saveSettingsDebounced();
break; break;
case 'SAVE_PROMPT_TEMPLATES': case 'SAVE_PROMPT_TEMPLATES':
settings.fourthWallPromptTemplates = data.templates; settings.fourthWallPromptTemplates = data.templates;
saveSettingsDebounced(); saveSettingsDebounced();
@@ -382,7 +422,7 @@ function handleFrameMessage(event) {
saveSettingsDebounced(); saveSettingsDebounced();
sendInitData(); sendInitData();
break; break;
case 'SAVE_HISTORY': { case 'SAVE_HISTORY': {
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
@@ -391,7 +431,7 @@ function handleFrameMessage(event) {
} }
break; break;
} }
case 'RESET_HISTORY': { case 'RESET_HISTORY': {
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
@@ -400,7 +440,7 @@ function handleFrameMessage(event) {
} }
break; break;
} }
case 'SWITCH_SESSION': case 'SWITCH_SESSION':
if (store) { if (store) {
store.activeSessionId = data.sessionId; store.activeSessionId = data.sessionId;
@@ -408,7 +448,7 @@ function handleFrameMessage(event) {
sendInitData(); sendInitData();
} }
break; break;
case 'ADD_SESSION': case 'ADD_SESSION':
if (store) { if (store) {
const newId = 'sess_' + Date.now(); const newId = 'sess_' + Date.now();
@@ -418,14 +458,14 @@ function handleFrameMessage(event) {
sendInitData(); sendInitData();
} }
break; break;
case 'RENAME_SESSION': case 'RENAME_SESSION':
if (store) { if (store) {
const sess = store.sessions.find(s => s.id === data.sessionId); const sess = store.sessions.find(s => s.id === data.sessionId);
if (sess) { sess.name = data.name; saveFWStore(); sendInitData(); } if (sess) { sess.name = data.name; saveFWStore(); sendInitData(); }
} }
break; break;
case 'DELETE_SESSION': case 'DELETE_SESSION':
if (store && store.sessions.length > 1) { if (store && store.sessions.length > 1) {
store.sessions = store.sessions.filter(s => s.id !== data.sessionId); store.sessions = store.sessions.filter(s => s.id !== data.sessionId);
@@ -446,11 +486,19 @@ function handleFrameMessage(event) {
case 'GENERATE_IMAGE': case 'GENERATE_IMAGE':
handleGenerate(data, postToFrame); handleGenerate(data, postToFrame);
break; break;
case 'PLAY_VOICE':
handlePlayVoice(data);
break;
case 'STOP_VOICE':
handleStopVoice(data);
break;
} }
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 生成处理(保持不变) // Generation
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
async function startGeneration(data) { async function startGeneration(data) {
@@ -462,9 +510,9 @@ async function startGeneration(data) {
voiceSettings: data.voiceSettings, voiceSettings: data.voiceSettings,
promptTemplates: getSettings().fourthWallPromptTemplates promptTemplates: getSettings().fourthWallPromptTemplates
}); });
const gen = window.xiaobaixStreamingGeneration; const gen = window.xiaobaixStreamingGeneration;
if (!gen?.xbgenrawCommand) throw new Error('xbgenraw 模块不可用'); if (!gen?.xbgenrawCommand) throw new Error('xbgenraw module unavailable');
const topMessages = [ const topMessages = [
{ role: 'user', content: msg1 }, { role: 'user', content: msg1 },
@@ -479,7 +527,7 @@ async function startGeneration(data) {
nonstream: data.settings.stream ? 'false' : 'true', nonstream: data.settings.stream ? 'false' : 'true',
as: 'user', as: 'user',
}, ''); }, '');
if (data.settings.stream) { if (data.settings.stream) {
startStreamingPoll(); startStreamingPoll();
} else { } else {
@@ -490,13 +538,13 @@ async function startGeneration(data) {
async function handleSendMessage(data) { async function handleSendMessage(data) {
if (isStreaming) return; if (isStreaming) return;
isStreaming = true; isStreaming = true;
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
session.history = data.history; session.history = data.history;
saveFWStore(); saveFWStore();
} }
try { try {
await startGeneration(data); await startGeneration(data);
} catch { } catch {
@@ -509,13 +557,13 @@ async function handleSendMessage(data) {
async function handleRegenerate(data) { async function handleRegenerate(data) {
if (isStreaming) return; if (isStreaming) return;
isStreaming = true; isStreaming = true;
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
session.history = data.history; session.history = data.history;
saveFWStore(); saveFWStore();
} }
try { try {
await startGeneration(data); await startGeneration(data);
} catch { } catch {
@@ -535,7 +583,7 @@ function startStreamingPoll() {
const thinking = extractThinkingPartial(raw); const thinking = extractThinkingPartial(raw);
const msg = extractMsg(raw) || extractMsgPartial(raw); const msg = extractMsg(raw) || extractMsgPartial(raw);
postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined }); postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined });
const st = gen.getStatus?.(STREAM_SESSION_ID); const st = gen.getStatus?.(STREAM_SESSION_ID);
if (st && st.isStreaming === false) finalizeGeneration(); if (st && st.isStreaming === false) finalizeGeneration();
}, 80); }, 80);
@@ -560,18 +608,18 @@ function stopStreamingPoll() {
function finalizeGeneration() { function finalizeGeneration() {
stopStreamingPoll(); stopStreamingPoll();
const gen = window.xiaobaixStreamingGeneration; const gen = window.xiaobaixStreamingGeneration;
const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(无响应)'; const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(no response)';
const finalText = extractMsg(rawText) || '(无响应)'; const finalText = extractMsg(rawText) || '(no response)';
const thinkingText = extractThinking(rawText); const thinkingText = extractThinking(rawText);
isStreaming = false; isStreaming = false;
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() }); session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() });
saveFWStore(); saveFWStore();
} }
postToFrame({ type: 'STREAM_COMPLETE', finalText, thinking: thinkingText }); postToFrame({ type: 'STREAM_COMPLETE', finalText, thinking: thinkingText });
} }
@@ -579,12 +627,12 @@ function cancelGeneration() {
const gen = window.xiaobaixStreamingGeneration; const gen = window.xiaobaixStreamingGeneration;
stopStreamingPoll(); stopStreamingPoll();
isStreaming = false; isStreaming = false;
try { gen?.cancel?.(STREAM_SESSION_ID); } catch {} try { gen?.cancel?.(STREAM_SESSION_ID); } catch { }
postToFrame({ type: 'GENERATION_CANCELLED' }); postToFrame({ type: 'GENERATION_CANCELLED' });
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 实时吐槽(保持不变,省略... // Commentary
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function shouldTriggerCommentary() { function shouldTriggerCommentary() {
@@ -669,7 +717,7 @@ async function handleAIMessageForCommentary(data) {
if (!commentary) return; if (!commentary) return;
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
session.history.push({ role: 'ai', content: `(瞄了眼刚才的台词)${commentary}`, ts: Date.now(), type: 'commentary' }); session.history.push({ role: 'ai', content: `(glanced at the last line) ${commentary}`, ts: Date.now(), type: 'commentary' });
saveFWStore(); saveFWStore();
} }
showCommentaryBubble(commentary); showCommentaryBubble(commentary);
@@ -678,22 +726,22 @@ async function handleAIMessageForCommentary(data) {
async function handleEditForCommentary(data) { async function handleEditForCommentary(data) {
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) return; if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) return;
if (!shouldTriggerCommentary()) return; if (!shouldTriggerCommentary()) return;
const ctx = getContext?.() || {}; const ctx = getContext?.() || {};
const messageId = typeof data === 'object' ? (data.messageId ?? data.id ?? data.index) : data; const messageId = typeof data === 'object' ? (data.messageId ?? data.id ?? data.index) : data;
const msgObj = Number.isFinite(messageId) ? ctx?.chat?.[messageId] : null; const msgObj = Number.isFinite(messageId) ? ctx?.chat?.[messageId] : null;
const messageText = getMessageTextFromEventArg(data); const messageText = getMessageTextFromEventArg(data);
if (!String(messageText).trim()) return; if (!String(messageText).trim()) return;
await new Promise(r => setTimeout(r, 500 + Math.random() * 500)); await new Promise(r => setTimeout(r, 500 + Math.random() * 500));
const editType = msgObj?.is_user ? 'edit_own' : 'edit_ai'; const editType = msgObj?.is_user ? 'edit_own' : 'edit_ai';
const commentary = await generateCommentary(messageText, editType); const commentary = await generateCommentary(messageText, editType);
if (!commentary) return; if (!commentary) return;
const session = getActiveSession(); const session = getActiveSession();
if (session) { if (session) {
const prefix = editType === 'edit_ai' ? '(发现你改了我的台词)' : '(发现你偷偷改台词)'; const prefix = editType === 'edit_ai' ? '(noticed you edited my line) ' : '(caught you sneaking edits) ';
session.history.push({ role: 'ai', content: `${prefix}${commentary}`, ts: Date.now(), type: 'commentary' }); session.history.push({ role: 'ai', content: `${prefix}${commentary}`, ts: Date.now(), type: 'commentary' });
saveFWStore(); saveFWStore();
} }
@@ -705,7 +753,7 @@ function getFloatBtnPosition() {
if (!btn) return null; if (!btn) return null;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
let stored = {}; let stored = {};
try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch {} try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch { }
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, side: stored.side || 'right' }; return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, side: stored.side || 'right' };
} }
@@ -771,19 +819,19 @@ function cleanupCommentary() {
lastCommentaryTime = 0; lastCommentaryTime = 0;
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// Overlay 管理(添加可见性监听) // Overlay
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function createOverlay() { function createOverlay() {
if (overlayCreated) return; if (overlayCreated) return;
overlayCreated = true; overlayCreated = true;
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
const frameInset = isMobile ? '0px' : '12px'; const frameInset = isMobile ? '0px' : '12px';
const iframeRadius = isMobile ? '0px' : '12px'; const iframeRadius = isMobile ? '0px' : '12px';
const framePadding = isMobile ? 'padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) !important;' : ''; const framePadding = isMobile ? 'padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) !important;' : '';
const $overlay = $(` const $overlay = $(`
<div id="xiaobaix-fourth-wall-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 id="xiaobaix-fourth-wall-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="fw-backdrop" style="position:absolute!important;inset:0!important;background:rgba(0,0,0,.55)!important;backdrop-filter:blur(4px)!important;"></div> <div class="fw-backdrop" style="position:absolute!important;inset:0!important;background:rgba(0,0,0,.55)!important;backdrop-filter:blur(4px)!important;"></div>
@@ -792,13 +840,13 @@ function createOverlay() {
</div> </div>
</div> </div>
`); `);
$overlay.on('click', '.fw-backdrop', hideOverlay); $overlay.on('click', '.fw-backdrop', hideOverlay);
document.body.appendChild($overlay[0]); document.body.appendChild($overlay[0]);
// Guarded by isTrustedMessage (origin + source). // Guarded by isTrustedMessage (origin + source).
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
window.addEventListener('message', handleFrameMessage); window.addEventListener('message', handleFrameMessage);
document.addEventListener('fullscreenchange', () => { document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false }); postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false });
@@ -821,26 +869,23 @@ function showOverlay() {
sendInitData(); sendInitData();
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: !!document.fullscreenElement }); postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: !!document.fullscreenElement });
// ═══════════════════════════ 新增:添加可见性监听 ═══════════════════════════
if (!visibilityHandler) { if (!visibilityHandler) {
visibilityHandler = handleVisibilityChange; visibilityHandler = handleVisibilityChange;
document.addEventListener('visibilitychange', visibilityHandler); document.addEventListener('visibilitychange', visibilityHandler);
} }
// ════════════════════════════════════════════════════════════════════════════
} }
function hideOverlay() { function hideOverlay() {
$('#xiaobaix-fourth-wall-overlay').hide(); $('#xiaobaix-fourth-wall-overlay').hide();
if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); if (document.fullscreenElement) document.exitFullscreen().catch(() => { });
stopVoiceAndNotify();
// ═══════════════════════════ 新增:移除可见性监听 ═══════════════════════════
if (visibilityHandler) { if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler); document.removeEventListener('visibilitychange', visibilityHandler);
visibilityHandler = null; visibilityHandler = null;
} }
pendingPingId = null; pendingPingId = null;
// ════════════════════════════════════════════════════════════════════════════
} }
function toggleFullscreen() { function toggleFullscreen() {
@@ -850,16 +895,16 @@ function toggleFullscreen() {
if (document.fullscreenElement) { if (document.fullscreenElement) {
document.exitFullscreen().then(() => { document.exitFullscreen().then(() => {
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false }); postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false });
}).catch(() => {}); }).catch(() => { });
} else if (overlay.requestFullscreen) { } else if (overlay.requestFullscreen) {
overlay.requestFullscreen().then(() => { overlay.requestFullscreen().then(() => {
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: true }); postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: true });
}).catch(() => {}); }).catch(() => { });
} }
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 悬浮按钮(保持不变,省略... // Floating Button
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function createFloatingButton() { function createFloatingButton() {
@@ -871,7 +916,7 @@ function createFloatingButton() {
const clamp = (v, min, max) => Math.min(Math.max(v, min), max); const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
const readPos = () => { try { return JSON.parse(localStorage.getItem(POS_KEY) || 'null'); } catch { return null; } }; const readPos = () => { try { return JSON.parse(localStorage.getItem(POS_KEY) || 'null'); } catch { return null; } };
const writePos = (pos) => { try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch {} }; const writePos = (pos) => { try { localStorage.setItem(POS_KEY, JSON.stringify(pos)); } catch { } };
const calcDockLeft = (side, w) => (side === 'left' ? -Math.round(w / 2) : (window.innerWidth - Math.round(w / 2))); const calcDockLeft = (side, w) => (side === 'left' ? -Math.round(w / 2) : (window.innerWidth - Math.round(w / 2)));
const applyDocked = (side, topRatio) => { const applyDocked = (side, topRatio) => {
const btn = document.getElementById('xiaobaix-fw-float-btn'); const btn = document.getElementById('xiaobaix-fw-float-btn');
@@ -885,20 +930,20 @@ function createFloatingButton() {
}; };
const $btn = $(` const $btn = $(`
<button id="xiaobaix-fw-float-btn" title="皮下交流" style="position:fixed!important;left:0px!important;top:0px!important;z-index:9999!important;width:${size}px!important;height:${size}px!important;border-radius:50%!important;border:none!important;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)!important;color:#fff!important;font-size:${Math.round(size * 0.45)}px!important;cursor:pointer!important;box-shadow:0 4px 15px rgba(102,126,234,0.4)!important;display:flex!important;align-items:center!important;justify-content:center!important;transition:left 0.2s,top 0.2s,transform 0.2s,box-shadow 0.2s!important;touch-action:none!important;user-select:none!important;"> <button id="xiaobaix-fw-float-btn" title="Fourth Wall" style="position:fixed!important;left:0px!important;top:0px!important;z-index:9999!important;width:${size}px!important;height:${size}px!important;border-radius:50%!important;border:none!important;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)!important;color:#fff!important;font-size:${Math.round(size * 0.45)}px!important;cursor:pointer!important;box-shadow:0 4px 15px rgba(102,126,234,0.4)!important;display:flex!important;align-items:center!important;justify-content:center!important;transition:left 0.2s,top 0.2s,transform 0.2s,box-shadow 0.2s!important;touch-action:none!important;user-select:none!important;">
<i class="fa-solid fa-comments"></i> <i class="fa-solid fa-comments"></i>
</button> </button>
`); `);
$btn.on('click', () => { $btn.on('click', () => {
if (Date.now() < suppressFloatBtnClickUntil) return; if (Date.now() < suppressFloatBtnClickUntil) return;
if (!getSettings().fourthWall?.enabled) return; if (!getSettings().fourthWall?.enabled) return;
showOverlay(); showOverlay();
}); });
$btn.on('mouseenter', function() { $(this).css({ 'transform': 'scale(1.08)', 'box-shadow': '0 6px 20px rgba(102, 126, 234, 0.5)' }); }); $btn.on('mouseenter', function () { $(this).css({ 'transform': 'scale(1.08)', 'box-shadow': '0 6px 20px rgba(102, 126, 234, 0.5)' }); });
$btn.on('mouseleave', function() { $(this).css({ 'transform': 'none', 'box-shadow': '0 4px 15px rgba(102, 126, 234, 0.4)' }); }); $btn.on('mouseleave', function () { $(this).css({ 'transform': 'none', 'box-shadow': '0 4px 15px rgba(102, 126, 234, 0.4)' }); });
document.body.appendChild($btn[0]); document.body.appendChild($btn[0]);
const initial = readPos(); const initial = readPos();
@@ -911,7 +956,7 @@ function createFloatingButton() {
if (e.button !== undefined && e.button !== 0) return; if (e.button !== undefined && e.button !== 0) return;
const btn = e.currentTarget; const btn = e.currentTarget;
pointerId = e.pointerId; pointerId = e.pointerId;
try { btn.setPointerCapture(pointerId); } catch {} try { btn.setPointerCapture(pointerId); } catch { }
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top;
dragging = false; dragging = false;
@@ -937,7 +982,7 @@ function createFloatingButton() {
const onPointerUp = (e) => { const onPointerUp = (e) => {
if (pointerId === null || e.pointerId !== pointerId) return; if (pointerId === null || e.pointerId !== pointerId) return;
const btn = e.currentTarget; const btn = e.currentTarget;
try { btn.releasePointerCapture(pointerId); } catch {} try { btn.releasePointerCapture(pointerId); } catch { }
pointerId = null; pointerId = null;
btn.style.transition = ''; btn.style.transition = '';
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();
@@ -976,20 +1021,20 @@ function removeFloatingButton() {
} }
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 初始化和清理 // Init & Cleanup
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
function initFourthWall() { function initFourthWall() {
try { xbLog.info('fourthWall', 'initFourthWall'); } catch {} try { xbLog.info('fourthWall', 'initFourthWall'); } catch { }
const settings = getSettings(); const settings = getSettings();
if (!settings.fourthWall?.enabled) return; if (!settings.fourthWall?.enabled) return;
createFloatingButton(); createFloatingButton();
initCommentary(); initCommentary();
clearExpiredCache(); clearExpiredCache();
initMessageEnhancer(); initMessageEnhancer();
events.on(event_types.CHAT_CHANGED, () => { events.on(event_types.CHAT_CHANGED, () => {
cancelGeneration(); cancelGeneration();
currentLoadedChatId = null; currentLoadedChatId = null;
@@ -999,24 +1044,26 @@ function initFourthWall() {
} }
function fourthWallCleanup() { function fourthWallCleanup() {
try { xbLog.info('fourthWall', 'fourthWallCleanup'); } catch {} try { xbLog.info('fourthWall', 'fourthWallCleanup'); } catch { }
events.cleanup(); events.cleanup();
cleanupCommentary(); cleanupCommentary();
removeFloatingButton(); removeFloatingButton();
hideOverlay(); hideOverlay();
cancelGeneration(); cancelGeneration();
cleanupMessageEnhancer(); cleanupMessageEnhancer();
stopCurrentVoice();
currentVoiceRequestId = null;
frameReady = false; frameReady = false;
pendingFrameMessages = []; pendingFrameMessages = [];
overlayCreated = false; overlayCreated = false;
currentLoadedChatId = null; currentLoadedChatId = null;
pendingPingId = null; pendingPingId = null;
if (visibilityHandler) { if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler); document.removeEventListener('visibilitychange', visibilityHandler);
visibilityHandler = null; visibilityHandler = null;
} }
$('#xiaobaix-fourth-wall-overlay').remove(); $('#xiaobaix-fourth-wall-overlay').remove();
window.removeEventListener('message', handleFrameMessage); window.removeEventListener('message', handleFrameMessage);
} }
@@ -1026,10 +1073,10 @@ export { initFourthWall, fourthWallCleanup, showOverlay as showFourthWallPopup }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.fourthWallCleanup = fourthWallCleanup; window.fourthWallCleanup = fourthWallCleanup;
window.showFourthWallPopup = showOverlay; window.showFourthWallPopup = showOverlay;
document.addEventListener('xiaobaixEnabledChanged', e => { document.addEventListener('xiaobaixEnabledChanged', e => {
if (e?.detail?.enabled === false) { if (e?.detail?.enabled === false) {
try { fourthWallCleanup(); } catch {} try { fourthWallCleanup(); } catch { }
} }
}); });
} }

View File

@@ -1,5 +1,5 @@
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 消息楼层增强器 // Message Floor Enhancer
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
import { extension_settings } from "../../../../../extensions.js"; import { extension_settings } from "../../../../../extensions.js";
@@ -8,113 +8,102 @@ import { createModuleEvents, event_types } from "../../core/event-manager.js";
import { xbLog } from "../../core/debug-core.js"; import { xbLog } from "../../core/debug-core.js";
import { generateImage, clearQueue } from "./fw-image.js"; import { generateImage, clearQueue } from "./fw-image.js";
import { import { synthesizeAndPlay, stopCurrent as stopCurrentVoice } from "./fw-voice-runtime.js";
synthesizeSpeech,
loadVoices,
VALID_EMOTIONS,
DEFAULT_VOICE,
DEFAULT_SPEED
} from "./fw-voice.js";
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 状态 // State
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
const events = createModuleEvents('messageEnhancer'); const events = createModuleEvents('messageEnhancer');
const CSS_INJECTED_KEY = 'xb-me-css-injected'; const CSS_INJECTED_KEY = 'xb-me-css-injected';
let currentAudio = null;
let imageObserver = null; let imageObserver = null;
let novelDrawObserver = null; let novelDrawObserver = null;
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 初始化与清理 // Init & Cleanup
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
export async function initMessageEnhancer() { export async function initMessageEnhancer() {
const settings = extension_settings[EXT_ID]; const settings = extension_settings[EXT_ID];
if (!settings?.fourthWall?.enabled) return; if (!settings?.fourthWall?.enabled) return;
xbLog.info('messageEnhancer', '初始化消息增强器'); xbLog.info('messageEnhancer', 'init message enhancer');
injectStyles(); injectStyles();
await loadVoices();
initImageObserver(); initImageObserver();
initNovelDrawObserver(); initNovelDrawObserver();
events.on(event_types.CHAT_CHANGED, () => { events.on(event_types.CHAT_CHANGED, () => {
clearQueue(); clearQueue();
setTimeout(processAllMessages, 150); setTimeout(processAllMessages, 150);
}); });
events.on(event_types.MESSAGE_RECEIVED, handleMessageChange); events.on(event_types.MESSAGE_RECEIVED, handleMessageChange);
events.on(event_types.USER_MESSAGE_RENDERED, handleMessageChange); events.on(event_types.USER_MESSAGE_RENDERED, handleMessageChange);
events.on(event_types.MESSAGE_EDITED, handleMessageChange); events.on(event_types.MESSAGE_EDITED, handleMessageChange);
events.on(event_types.MESSAGE_UPDATED, handleMessageChange); events.on(event_types.MESSAGE_UPDATED, handleMessageChange);
events.on(event_types.MESSAGE_SWIPED, handleMessageChange); events.on(event_types.MESSAGE_SWIPED, handleMessageChange);
events.on(event_types.GENERATION_STOPPED, () => setTimeout(processAllMessages, 150)); events.on(event_types.GENERATION_STOPPED, () => setTimeout(processAllMessages, 150));
events.on(event_types.GENERATION_ENDED, () => setTimeout(processAllMessages, 150)); events.on(event_types.GENERATION_ENDED, () => setTimeout(processAllMessages, 150));
processAllMessages(); processAllMessages();
} }
export function cleanupMessageEnhancer() { export function cleanupMessageEnhancer() {
xbLog.info('messageEnhancer', '清理消息增强器'); xbLog.info('messageEnhancer', 'cleanup message enhancer');
events.cleanup(); events.cleanup();
clearQueue(); clearQueue();
stopCurrentVoice();
if (imageObserver) { if (imageObserver) {
imageObserver.disconnect(); imageObserver.disconnect();
imageObserver = null; imageObserver = null;
} }
if (novelDrawObserver) { if (novelDrawObserver) {
novelDrawObserver.disconnect(); novelDrawObserver.disconnect();
novelDrawObserver = null; novelDrawObserver = null;
} }
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
}
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// NovelDraw 兼容 // NovelDraw Compat
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function initNovelDrawObserver() { function initNovelDrawObserver() {
if (novelDrawObserver) return; if (novelDrawObserver) return;
const chat = document.getElementById('chat'); const chat = document.getElementById('chat');
if (!chat) { if (!chat) {
setTimeout(initNovelDrawObserver, 500); setTimeout(initNovelDrawObserver, 500);
return; return;
} }
let debounceTimer = null; let debounceTimer = null;
const pendingTexts = new Set(); const pendingTexts = new Set();
novelDrawObserver = new MutationObserver((mutations) => { novelDrawObserver = new MutationObserver((mutations) => {
const settings = extension_settings[EXT_ID]; const settings = extension_settings[EXT_ID];
if (!settings?.fourthWall?.enabled) return; if (!settings?.fourthWall?.enabled) return;
for (const mutation of mutations) { for (const mutation of mutations) {
for (const node of mutation.addedNodes) { for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue; if (node.nodeType !== Node.ELEMENT_NODE) continue;
const hasNdImg = node.classList?.contains('xb-nd-img') || node.querySelector?.('.xb-nd-img'); const hasNdImg = node.classList?.contains('xb-nd-img') || node.querySelector?.('.xb-nd-img');
if (!hasNdImg) continue; if (!hasNdImg) continue;
const mesText = node.closest('.mes_text'); const mesText = node.closest('.mes_text');
if (mesText && hasUnrenderedVoice(mesText)) { if (mesText && hasUnrenderedVoice(mesText)) {
pendingTexts.add(mesText); pendingTexts.add(mesText);
} }
} }
} }
if (pendingTexts.size > 0 && !debounceTimer) { if (pendingTexts.size > 0 && !debounceTimer) {
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
pendingTexts.forEach(mesText => { pendingTexts.forEach(mesText => {
@@ -125,7 +114,7 @@ function initNovelDrawObserver() {
}, 50); }, 50);
} }
}); });
novelDrawObserver.observe(chat, { childList: true, subtree: true }); novelDrawObserver.observe(chat, { childList: true, subtree: true });
} }
@@ -135,15 +124,15 @@ function hasUnrenderedVoice(mesText) {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 事件处理 // Event Handlers
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
function handleMessageChange(data) { function handleMessageChange(data) {
setTimeout(() => { setTimeout(() => {
const messageId = typeof data === 'object' const messageId = typeof data === 'object'
? (data.messageId ?? data.id ?? data.index ?? data.mesId) ? (data.messageId ?? data.id ?? data.index ?? data.mesId)
: data; : data;
if (Number.isFinite(messageId)) { if (Number.isFinite(messageId)) {
const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`); const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`);
if (mesText) enhanceMessageContent(mesText); if (mesText) enhanceMessageContent(mesText);
@@ -160,12 +149,12 @@ function processAllMessages() {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 图片观察器 // Image Observer
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function initImageObserver() { function initImageObserver() {
if (imageObserver) return; if (imageObserver) return;
imageObserver = new IntersectionObserver((entries) => { imageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => { entries.forEach(entry => {
if (!entry.isIntersecting) return; if (!entry.isIntersecting) return;
@@ -180,12 +169,12 @@ function initImageObserver() {
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 样式注入 // Style Injection
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
function injectStyles() { function injectStyles() {
if (document.getElementById(CSS_INJECTED_KEY)) return; if (document.getElementById(CSS_INJECTED_KEY)) return;
const style = document.createElement('style'); const style = document.createElement('style');
style.id = CSS_INJECTED_KEY; style.id = CSS_INJECTED_KEY;
style.textContent = ` style.textContent = `
@@ -251,46 +240,46 @@ function injectStyles() {
document.head.appendChild(style); document.head.appendChild(style);
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 内容增强 // Content Enhancement
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function enhanceMessageContent(container) { function enhanceMessageContent(container) {
if (!container) return; if (!container) return;
// Rewrites already-rendered message HTML; no new HTML source is introduced here. // Rewrites already-rendered message HTML; no new HTML source is introduced here.
// eslint-disable-next-line no-unsanitized/property // eslint-disable-next-line no-unsanitized/property
const html = container.innerHTML; const html = container.innerHTML;
let enhanced = html; let enhanced = html;
let hasChanges = false; let hasChanges = false;
enhanced = enhanced.replace(/\[(?:img|图片)\s*:\s*([^\]]+)\]/gi, (match, inner) => { enhanced = enhanced.replace(/\[(?:img|图片)\s*:\s*([^\]]+)\]/gi, (match, inner) => {
const tags = parseImageToken(inner); const tags = parseImageToken(inner);
if (!tags) return match; if (!tags) return match;
hasChanges = true; hasChanges = true;
return `<div class="xb-img-slot" data-tags="${encodeURIComponent(tags)}"></div>`; return `<div class="xb-img-slot" data-tags="${encodeURIComponent(tags)}"></div>`;
}); });
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:([^:]*):([^\]]+)\]/gi, (match, emotionRaw, voiceText) => { enhanced = enhanced.replace(/\[(?:voice|语音)\s*:([^:]*):([^\]]+)\]/gi, (match, emotionRaw, voiceText) => {
const txt = voiceText.trim(); const txt = voiceText.trim();
if (!txt) return match; if (!txt) return match;
hasChanges = true; hasChanges = true;
return createVoiceBubbleHTML(txt, (emotionRaw || '').trim().toLowerCase()); return createVoiceBubbleHTML(txt, (emotionRaw || '').trim().toLowerCase());
}); });
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:\s*([^\]:]+)\]/gi, (match, voiceText) => { enhanced = enhanced.replace(/\[(?:voice|语音)\s*:\s*([^\]:]+)\]/gi, (match, voiceText) => {
const txt = voiceText.trim(); const txt = voiceText.trim();
if (!txt) return match; if (!txt) return match;
hasChanges = true; hasChanges = true;
return createVoiceBubbleHTML(txt, ''); return createVoiceBubbleHTML(txt, '');
}); });
if (hasChanges) { if (hasChanges) {
// Replaces existing message HTML with enhanced tokens only. // Replaces existing message HTML with enhanced tokens only.
// eslint-disable-next-line no-unsanitized/property // eslint-disable-next-line no-unsanitized/property
container.innerHTML = enhanced; container.innerHTML = enhanced;
} }
hydrateImageSlots(container); hydrateImageSlots(container);
hydrateVoiceSlots(container); hydrateVoiceSlots(container);
} }
@@ -313,67 +302,60 @@ function escapeHtml(text) {
return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 图片处理 // Image Handling
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function hydrateImageSlots(container) { function hydrateImageSlots(container) {
container.querySelectorAll('.xb-img-slot').forEach(slot => { container.querySelectorAll('.xb-img-slot').forEach(slot => {
if (slot.dataset.observed === '1') return; if (slot.dataset.observed === '1') return;
slot.dataset.observed = '1'; slot.dataset.observed = '1';
if (!slot.dataset.loaded && !slot.dataset.loading && !slot.querySelector('img')) { if (!slot.dataset.loaded && !slot.dataset.loading && !slot.querySelector('img')) {
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // 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.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
} }
imageObserver?.observe(slot); imageObserver?.observe(slot);
}); });
} }
async function loadImage(slot, tags) { async function loadImage(slot, tags) {
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // eslint-disable-next-line no-unsanitized/property
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-spinner"></i> 检查缓存...</div>`; slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-spinner"></i> 检查缓存...</div>`;
try { try {
const base64 = await generateImage(tags, (status, position, delay) => { const base64 = await generateImage(tags, (status, position, delay) => {
switch (status) { switch (status) {
case 'queued': case 'queued':
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // eslint-disable-next-line no-unsanitized/property
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position}</div>`; slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position}</div>`;
break; break;
case 'generating': case 'generating':
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // 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>`; slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-palette"></i> 生成中${position > 0 ? ` (${position} 排队)` : ''}...</div>`;
break; break;
case 'waiting': case 'waiting':
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // 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>`; slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position} (${delay}s)</div>`;
break; break;
} }
}); });
if (base64) renderImage(slot, base64, false); if (base64) renderImage(slot, base64, false);
} catch (err) { } catch (err) {
slot.dataset.loaded = '1'; slot.dataset.loaded = '1';
slot.dataset.loading = ''; slot.dataset.loading = '';
if (err.message === '队列已清空') { if (err.message === '队列已清空') {
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // 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.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
slot.dataset.loading = ''; slot.dataset.loading = '';
slot.dataset.observed = ''; slot.dataset.observed = '';
return; return;
} }
// Template-only UI markup with escaped error text.
// eslint-disable-next-line no-unsanitized/property // 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>`; 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); bindRetryButton(slot);
@@ -383,21 +365,19 @@ async function loadImage(slot, tags) {
function renderImage(slot, base64, fromCache) { function renderImage(slot, base64, fromCache) {
slot.dataset.loaded = '1'; slot.dataset.loaded = '1';
slot.dataset.loading = ''; slot.dataset.loading = '';
const img = document.createElement('img'); const img = document.createElement('img');
img.src = `data:image/png;base64,${base64}`; img.src = `data:image/png;base64,${base64}`;
img.className = 'xb-generated-img'; img.className = 'xb-generated-img';
img.onclick = () => window.open(img.src, '_blank'); img.onclick = () => window.open(img.src, '_blank');
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // eslint-disable-next-line no-unsanitized/property
slot.innerHTML = ''; slot.innerHTML = '';
slot.appendChild(img); slot.appendChild(img);
if (fromCache) { if (fromCache) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'xb-img-badge'; badge.className = 'xb-img-badge';
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property // eslint-disable-next-line no-unsanitized/property
badge.innerHTML = '<i class="fa-solid fa-bolt"></i>'; badge.innerHTML = '<i class="fa-solid fa-bolt"></i>';
slot.appendChild(badge); slot.appendChild(badge);
@@ -417,65 +397,60 @@ function bindRetryButton(slot) {
}; };
} }
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
// 语音处理 // Voice Handling
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
function hydrateVoiceSlots(container) { function hydrateVoiceSlots(container) {
container.querySelectorAll('.xb-voice-bubble').forEach(bubble => { container.querySelectorAll('.xb-voice-bubble').forEach(bubble => {
if (bubble.dataset.bound === '1') return; if (bubble.dataset.bound === '1') return;
bubble.dataset.bound = '1'; bubble.dataset.bound = '1';
const text = decodeURIComponent(bubble.dataset.text || ''); const text = decodeURIComponent(bubble.dataset.text || '');
const emotion = bubble.dataset.emotion || ''; const emotion = bubble.dataset.emotion || '';
if (!text) return; if (!text) return;
bubble.onclick = async (e) => { bubble.onclick = async (e) => {
e.stopPropagation(); e.stopPropagation();
if (bubble.classList.contains('loading')) return; if (bubble.classList.contains('loading')) return;
if (bubble.classList.contains('playing') && currentAudio) { if (bubble.classList.contains('playing')) {
currentAudio.pause(); stopCurrentVoice();
currentAudio = null;
bubble.classList.remove('playing'); bubble.classList.remove('playing');
return; return;
} }
if (currentAudio) { // Clear other bubble states
currentAudio.pause(); document.querySelectorAll('.xb-voice-bubble.playing, .xb-voice-bubble.loading').forEach(el => {
currentAudio = null; el.classList.remove('playing', 'loading');
} });
document.querySelectorAll('.xb-voice-bubble.playing').forEach(el => el.classList.remove('playing'));
bubble.classList.add('loading');
await playVoice(text, emotion, bubble); bubble.classList.remove('error');
synthesizeAndPlay(text, emotion, {
onState(state, info) {
switch (state) {
case 'loading':
bubble.classList.add('loading');
bubble.classList.remove('playing', 'error');
break;
case 'playing':
bubble.classList.remove('loading', 'error');
bubble.classList.add('playing');
break;
case 'ended':
case 'stopped':
bubble.classList.remove('loading', 'playing');
break;
case 'error':
bubble.classList.remove('loading', 'playing');
bubble.classList.add('error');
setTimeout(() => bubble.classList.remove('error'), 3000);
break;
}
},
});
}; };
}); });
} }
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);
}
}

View File

@@ -0,0 +1,132 @@
// ════════════════════════════════════════════
// 语音运行时 - 统一合成与互斥播放
// ════════════════════════════════════════════
import { normalizeEmotion } from '../tts/tts-text.js';
let currentAudio = null;
let currentObjectUrl = null;
let currentAbort = null;
let currentRequestId = null;
/**
* 合成并播放语音(后播覆盖前播)
*
* @param {string} text - 要合成的文本
* @param {string} [emotion] - 原始情绪字符串(自动 normalize
* @param {Object} [callbacks] - 状态回调
* @param {string} [callbacks.requestId] - 请求标识,用于防时序错乱
* @param {(state: string, info?: object) => void} [callbacks.onState] - 状态变化回调
* state: 'loading' | 'playing' | 'ended' | 'error' | 'stopped'
* info: { duration?, message? }
* @returns {{ stop: () => void }} 控制句柄
*/
export function synthesizeAndPlay(text, emotion, callbacks) {
const requestId = callbacks?.requestId || `vr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const onState = callbacks?.onState;
// 停掉前一个(回传 stopped
stopCurrent();
const abortController = new AbortController();
currentAbort = abortController;
currentRequestId = requestId;
const notify = (state, info) => {
if (currentRequestId !== requestId && state !== 'stopped') return;
onState?.(state, info);
};
notify('loading');
const run = async () => {
const synthesize = window.xiaobaixTts?.synthesize;
if (typeof synthesize !== 'function') {
throw new Error('请先启用 TTS 模块');
}
const blob = await synthesize(text, {
emotion: normalizeEmotion(emotion || ''),
signal: abortController.signal,
});
if (abortController.signal.aborted) return;
if (currentRequestId !== requestId) return;
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
// 清理旧的(理论上 stopCurrent 已清理,防御性)
cleanup();
currentAudio = audio;
currentObjectUrl = url;
audio.onloadedmetadata = () => {
if (currentRequestId !== requestId) return;
notify('playing', { duration: audio.duration || 0 });
};
audio.onended = () => {
if (currentRequestId !== requestId) return;
notify('ended');
cleanup();
};
audio.onerror = () => {
if (currentRequestId !== requestId) return;
notify('error', { message: '播放失败' });
cleanup();
};
await audio.play();
};
run().catch(err => {
if (abortController.signal.aborted) return;
if (currentRequestId !== requestId) return;
notify('error', { message: err?.message || '合成失败' });
cleanup();
});
return {
stop() {
if (currentRequestId === requestId) {
stopCurrent();
}
},
};
}
/**
* 停止当前语音(合成中止 + 播放停止 + 资源回收)
*/
export function stopCurrent() {
if (currentAbort) {
try { currentAbort.abort(); } catch { }
currentAbort = null;
}
cleanup();
currentRequestId = null;
}
// ════════════════════════════════════════════════════════════════════════════
// 内部
// ════════════════════════════════════════════════════════════════════════════
function cleanup() {
if (currentAudio) {
currentAudio.onloadedmetadata = null;
currentAudio.onended = null;
currentAudio.onerror = null;
try { currentAudio.pause(); } catch { }
currentAudio = null;
}
if (currentObjectUrl) {
URL.revokeObjectURL(currentObjectUrl);
currentObjectUrl = null;
}
}

View File

@@ -1,8 +1,7 @@
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 语音模块 - TTS 合成服务 // 语音模块 - 常量与提示词指南
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
export const TTS_WORKER_URL = 'https://hstts.velure.codes';
export const DEFAULT_VOICE = 'female_1'; export const DEFAULT_VOICE = 'female_1';
export const DEFAULT_SPEED = 1.0; export const DEFAULT_SPEED = 1.0;
@@ -11,96 +10,9 @@ export const EMOTION_ICONS = {
happy: '😄', sad: '😢', angry: '😠', surprise: '😮', scare: '😨', hate: '🤢' happy: '😄', sad: '😢', angry: '😠', surprise: '😮', scare: '😨', hate: '🤢'
}; };
let voiceListCache = null;
let defaultVoiceKey = DEFAULT_VOICE;
// ════════════════════════════════════════════════════════════════════════════
// 声音列表管理
// ════════════════════════════════════════════════════════════════════════════
/**
* 加载可用声音列表
*/
export async function loadVoices() {
if (voiceListCache) return { voices: voiceListCache, defaultVoice: defaultVoiceKey };
try {
const res = await fetch(`${TTS_WORKER_URL}/voices`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
voiceListCache = data.voices || [];
defaultVoiceKey = data.defaultVoice || DEFAULT_VOICE;
return { voices: voiceListCache, defaultVoice: defaultVoiceKey };
} catch (err) {
console.error('[FW Voice] 加载声音列表失败:', err);
return { voices: [], defaultVoice: DEFAULT_VOICE };
}
}
/**
* 获取已缓存的声音列表
*/
export function getVoiceList() {
return voiceListCache || [];
}
/**
* 获取默认声音
*/
export function getDefaultVoice() {
return defaultVoiceKey;
}
// ════════════════════════════════════════════════════════════════════════════
// TTS 合成
// ════════════════════════════════════════════════════════════════════════════
/**
* 合成语音
* @param {string} text - 要合成的文本
* @param {Object} options - 选项
* @param {string} [options.voiceKey] - 声音标识
* @param {number} [options.speed] - 语速 0.5-2.0
* @param {string} [options.emotion] - 情绪
* @returns {Promise<string>} base64 编码的音频数据
*/
export async function synthesizeSpeech(text, options = {}) {
const {
voiceKey = defaultVoiceKey,
speed = DEFAULT_SPEED,
emotion = null
} = options;
const requestBody = {
voiceKey,
text: String(text || ''),
speed: Number(speed) || DEFAULT_SPEED,
uid: 'xb_' + Date.now(),
reqid: crypto.randomUUID?.() || `${Date.now()}_${Math.random().toString(36).slice(2)}`
};
if (emotion && VALID_EMOTIONS.includes(emotion)) {
requestBody.emotion = emotion;
requestBody.emotionScale = 5;
}
const res = await fetch(TTS_WORKER_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!res.ok) throw new Error(`TTS HTTP ${res.status}`);
const data = await res.json();
if (data.code !== 3000) throw new Error(data.message || 'TTS 合成失败');
return data.data; // base64 音频
}
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════
// 提示词指南 // 提示词指南
// ════════════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════
export const VOICE_GUIDELINE = `## 模拟语音 export const VOICE_GUIDELINE = `## 模拟语音
如需发送语音消息,使用以下格式: 如需发送语音消息,使用以下格式:
@@ -129,4 +41,4 @@ export const VOICE_GUIDELINE = `## 模拟语音
[voice:happy:太好了!终于见到你了~] [voice:happy:太好了!终于见到你了~]
[voice::——啊!——不要!] [voice::——啊!——不要!]
注意voice部分需要在<msg>内`; 注意voice部分需要在<msg>内`;

View File

@@ -1301,6 +1301,7 @@ export async function initTts() {
openSettings, openSettings,
closeSettings, closeSettings,
player, player,
synthesize: synthesizeForExternal,
speak: async (text, options = {}) => { speak: async (text, options = {}) => {
if (!isModuleEnabled()) return; if (!isModuleEnabled()) return;
@@ -1347,6 +1348,106 @@ export async function initTts() {
}; };
} }
// ============ External synthesis API (no enqueue) ============
async function synthesizeForExternal(text, options = {}) {
if (!isModuleEnabled()) {
throw new Error('TTS 模块未启用');
}
const trimmed = String(text || '').trim();
if (!trimmed) {
throw new Error('合成文本为空');
}
const { emotion, speaker, signal } = options;
const mySpeakers = config.volc?.mySpeakers || [];
const defaultSpeaker = config.volc?.defaultSpeaker || FREE_DEFAULT_VOICE;
const resolved = speaker
? resolveSpeakerWithSource(speaker, mySpeakers, defaultSpeaker)
: resolveSpeakerWithSource('', mySpeakers, defaultSpeaker);
const normalizedEmotion = emotion ? normalizeEmotion(emotion) : '';
if (resolved.source === 'free') {
return await synthesizeFreeBlob(trimmed, resolved.value, normalizedEmotion, signal);
}
if (!isAuthConfigured()) {
throw new Error('鉴权音色需要配置 API');
}
return await synthesizeAuthBlob(trimmed, resolved, normalizedEmotion, signal);
}
async function synthesizeFreeBlob(text, voiceKey, emotion, signal) {
const freeSpeed = normalizeSpeed(config?.volc?.speechRate);
const cacheParams = {
providerMode: 'free',
text,
speaker: voiceKey,
freeSpeed,
emotion: emotion || '',
};
const cacheHit = await tryLoadLocalCache(cacheParams);
if (cacheHit?.entry?.blob) return cacheHit.entry.blob;
const { synthesizeFreeV1 } = await import('./tts-api.js');
const { audioBase64 } = await synthesizeFreeV1({ text, voiceKey, speed: freeSpeed, emotion: emotion || null }, { signal });
const byteString = atob(audioBase64);
const bytes = new Uint8Array(byteString.length);
for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j);
const blob = new Blob([bytes], { type: 'audio/mpeg' });
const cacheKey = buildCacheKey(cacheParams);
storeLocalCache(cacheKey, blob, { text: text.slice(0, 200), textLength: text.length, speaker: voiceKey, resourceId: 'free' }).catch(() => {});
return blob;
}
async function synthesizeAuthBlob(text, resolved, emotion, signal) {
const resourceId = resolved.resourceId || inferResourceIdBySpeaker(resolved.value);
const params = {
providerMode: 'auth',
appId: config.volc.appId,
accessKey: config.volc.accessKey,
resourceId,
speaker: resolved.value,
text,
format: 'mp3',
sampleRate: 24000,
speechRate: speedToV3SpeechRate(config.volc.speechRate),
loudnessRate: 0,
emotionScale: config.volc.emotionScale,
explicitLanguage: config.volc.explicitLanguage,
disableMarkdownFilter: config.volc.disableMarkdownFilter,
disableEmojiFilter: config.volc.disableEmojiFilter,
enableLanguageDetector: config.volc.enableLanguageDetector,
maxLengthToFilterParenthesis: config.volc.maxLengthToFilterParenthesis,
postProcessPitch: config.volc.postProcessPitch,
signal,
};
if (emotion) { params.emotion = emotion; }
if (resourceId === 'seed-tts-1.0' && config.volc.useTts11 !== false) { params.model = 'seed-tts-1.1'; }
if (config.volc.serverCacheEnabled) { params.cacheConfig = { text_type: 1, use_cache: true }; }
const cacheHit = await tryLoadLocalCache(params);
if (cacheHit?.entry?.blob) return cacheHit.entry.blob;
const headers = buildV3Headers(resourceId, config);
const result = await synthesizeV3(params, headers);
const cacheKey = buildCacheKey(params);
storeLocalCache(cacheKey, result.audioBlob, { text: text.slice(0, 200), textLength: text.length, speaker: resolved.value, resourceId, usage: result.usage || null }).catch(() => {});
return result.audioBlob;
}
export function cleanupTts() { export function cleanupTts() {
moduleInitialized = false; moduleInitialized = false;