diff --git a/modules/fourth-wall/fourth-wall.html b/modules/fourth-wall/fourth-wall.html
index 910cef9..b0d7151 100644
--- a/modules/fourth-wall/fourth-wall.html
+++ b/modules/fourth-wall/fourth-wall.html
@@ -480,16 +480,7 @@ html, body {
@@ -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);
}
-function getEmotionIcon(emotion) {
- return EMOTION_ICONS[emotion] || '';
+function getEmotionIcon() {
+ return '';
}
/* ══════════════════════════════════════════════════════════════════════════════
@@ -636,30 +616,19 @@ let state = {
sessions: [],
activeSessionId: null,
imgSettings: { enablePrompt: false },
- voiceSettings: { enabled: false, voice: 'female_1', speed: 1.0 },
+ voiceSettings: { enabled: false },
commentarySettings: { enabled: false, probability: 30 },
promptTemplates: {}
};
-let currentAudio = null;
+let activeVoiceRequestId = null;
/* ══════════════════════════════════════════════════════════════════════════════
加载声音列表
══════════════════════════════════════════════════════════════════════════════ */
-async function loadVoices() {
- try {
- 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 = [];
- }
+function generateVoiceRequestId() {
+ return 'fwv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
}
/* ══════════════════════════════════════════════════════════════════════════════
@@ -786,54 +755,48 @@ function bindRetryButton(slot) {
语音处理
══════════════════════════════════════════════════════════════════════════════ */
-async function playVoice(text, emotion, bubbleEl) {
- if (currentAudio) {
- currentAudio.pause();
- currentAudio = null;
- document.querySelectorAll('.fw-voice-bubble.playing').forEach(el => el.classList.remove('playing'));
- }
+function requestPlayVoice(text, emotion, bubbleEl) {
+ // Clear previous bubble state before issuing a new request.
+ document.querySelectorAll('.fw-voice-bubble.playing, .fw-voice-bubble.loading').forEach(el => {
+ el.classList.remove('playing', 'loading');
+ });
+ const voiceRequestId = generateVoiceRequestId();
+ activeVoiceRequestId = voiceRequestId;
+ bubbleEl.dataset.voiceRequestId = voiceRequestId;
bubbleEl.classList.add('loading');
bubbleEl.classList.remove('error');
- try {
+ postToParent({ type: 'PLAY_VOICE', text, emotion, voiceRequestId });
+}
- const requestBody = {
- voiceKey: state.voiceSettings.voice || defaultVoiceKey,
- text: text,
- speed: state.voiceSettings.speed || 1.0,
- uid: 'fw_' + Date.now(),
- reqid: generateUUID()
- };
+function handleVoiceState(data) {
+ const { voiceRequestId, state: voiceState, duration } = data;
+ const bubble = document.querySelector(`.fw-voice-bubble[data-voice-request-id="${voiceRequestId}"]`);
+ if (!bubble) return;
- 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(`HTTP ${res.status}`);
-
- const data = await res.json();
- if (data.code !== 3000) throw new Error(data.message || 'TTS失败');
-
- bubbleEl.classList.remove('loading');
- bubbleEl.classList.add('playing');
-
- currentAudio = new Audio(`data:audio/mp3;base64,${data.data}`);
- 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('[FW Voice] TTS错误:', err);
- bubbleEl.classList.remove('loading', 'playing');
- bubbleEl.classList.add('error');
- setTimeout(() => bubbleEl.classList.remove('error'), 3000);
+ switch (voiceState) {
+ case 'loading':
+ bubble.classList.add('loading');
+ bubble.classList.remove('playing', 'error');
+ break;
+ case 'playing':
+ bubble.classList.remove('loading', 'error');
+ bubble.classList.add('playing');
+ if (duration != null) {
+ const durationEl = bubble.querySelector('.fw-voice-duration');
+ if (durationEl) durationEl.textContent = Math.ceil(duration) + '"';
+ }
+ 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;
}
}
@@ -846,12 +809,10 @@ function hydrateVoiceSlots(container) {
bubble.onclick = e => {
e.stopPropagation();
if (bubble.classList.contains('loading')) return;
- if (bubble.classList.contains('playing') && currentAudio) {
- currentAudio.pause();
- currentAudio = null;
- bubble.classList.remove('playing');
+ if (bubble.classList.contains('playing')) {
+ postToParent({ type: 'STOP_VOICE', voiceRequestId: bubble.dataset.voiceRequestId });
} else {
- playVoice(text, emotion, bubble);
+ requestPlayVoice(text, emotion, bubble);
}
};
}
@@ -1022,24 +983,6 @@ function renderSessionSelect() {
).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 = `
-
-
- `;
- select.value = state.voiceSettings.voice || defaultVoiceKey;
-}
function updateMenuUI() {
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) {
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('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-prob').value = state.commentarySettings.probability;
document.getElementById('commentary-prob-val').textContent = state.commentarySettings.probability + '%';
@@ -1211,6 +1141,10 @@ window.addEventListener('message', event => {
updateFullscreenButton(data.isFullscreen);
break;
+ case 'VOICE_STATE':
+ handleVoiceState(data);
+ break;
+
case 'IMAGE_RESULT':
handleImageResult(data);
break;
@@ -1229,9 +1163,6 @@ window.addEventListener('message', event => {
══════════════════════════════════════════════════════════════════════════════ */
document.addEventListener('DOMContentLoaded', async () => {
- // 先加载声音列表
- await loadVoices();
-
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-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() {
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 });
};
diff --git a/modules/fourth-wall/fourth-wall.js b/modules/fourth-wall/fourth-wall.js
index 5d286bd..1e218e2 100644
--- a/modules/fourth-wall/fourth-wall.js
+++ b/modules/fourth-wall/fourth-wall.js
@@ -1,5 +1,5 @@
-// ════════════════════════════════════════════════════════════════════════════
-// 次元壁模块 - 主控制器
+// ════════════════════════════════════════════
+// Fourth Wall Module - Main Controller
// ════════════════════════════════════════════════════════════════════════════
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.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 { handleCheckCache, handleGenerate, clearExpiredCache } from "./fw-image.js";
-import { DEFAULT_VOICE, DEFAULT_SPEED } from "./fw-voice.js";
-import {
- buildPrompt,
- buildCommentaryPrompt,
+import { synthesizeAndPlay, stopCurrent as stopCurrentVoice } from "./fw-voice-runtime.js";
+import {
+ buildPrompt,
+ buildCommentaryPrompt,
DEFAULT_TOPUSER,
DEFAULT_CONFIRM,
DEFAULT_BOTTOM,
- DEFAULT_META_PROTOCOL
+ DEFAULT_META_PROTOCOL
} from "./fw-prompt.js";
import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js";
import { postToIframe, isTrustedMessage, getTrustedOrigin } from "../../core/iframe-messaging.js";
-// ════════════════════════════════════════════════════════════════════════════
-// 常量
+
+// ════════════════════════════════════════════
+// Constants
// ════════════════════════════════════════════════════════════════════════════
const events = createModuleEvents('fourthWall');
@@ -30,7 +31,7 @@ const COMMENTARY_COOLDOWN = 180000;
const IFRAME_PING_TIMEOUT = 800;
// ════════════════════════════════════════════════════════════════════════════
-// 状态
+// State
// ════════════════════════════════════════════════════════════════════════════
let overlayCreated = false;
@@ -44,37 +45,36 @@ let currentLoadedChatId = null;
let lastCommentaryTime = 0;
let commentaryBubbleEl = null;
let commentaryBubbleTimer = null;
+let currentVoiceRequestId = null;
-// ═══════════════════════════════ 新增 ═══════════════════════════════
let visibilityHandler = null;
let pendingPingId = null;
-// ════════════════════════════════════════════════════════════════════
// ════════════════════════════════════════════════════════════════════════════
-// 设置管理(保持不变)
+// Settings
// ════════════════════════════════════════════════════════════════════════════
function getSettings() {
extension_settings[EXT_ID] ||= {};
const s = extension_settings[EXT_ID];
-
+
s.fourthWall ||= { enabled: true };
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.fourthWallPromptTemplates ||= {};
-
+
const t = s.fourthWallPromptTemplates;
if (t.topuser === undefined) t.topuser = DEFAULT_TOPUSER;
if (t.confirm === undefined) t.confirm = DEFAULT_CONFIRM;
if (t.bottom === undefined) t.bottom = DEFAULT_BOTTOM;
if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL;
-
+
return s;
}
// ════════════════════════════════════════════════════════════════════════════
-// 工具函数(保持不变)
+// Utilities
// ════════════════════════════════════════════════════════════════════════════
function b64UrlEncode(str) {
@@ -162,7 +162,7 @@ function getAvatarUrls() {
}
// ════════════════════════════════════════════════════════════════════════════
-// 存储管理(保持不变)
+// Storage
// ════════════════════════════════════════════════════════════════════════════
function getFWStore(chatId = getCurrentChatIdSafe()) {
@@ -171,17 +171,17 @@ function getFWStore(chatId = getCurrentChatIdSafe()) {
chat_metadata[chatId].extensions ||= {};
chat_metadata[chatId].extensions[EXT_ID] ||= {};
chat_metadata[chatId].extensions[EXT_ID].fw ||= {};
-
+
const fw = chat_metadata[chatId].extensions[EXT_ID].fw;
fw.settings ||= { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true };
-
+
if (!fw.sessions) {
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';
if (Object.prototype.hasOwnProperty.call(fw, 'history')) delete fw.history;
}
-
+
if (!fw.activeSessionId || !fw.sessions.find(s => s.id === fw.activeSessionId)) {
fw.activeSessionId = fw.sessions[0]?.id || null;
}
@@ -199,7 +199,7 @@ function saveFWStore() {
}
// ════════════════════════════════════════════════════════════════════════════
-// iframe 通讯
+// iframe Communication
// ════════════════════════════════════════════════════════════════════════════
function postToFrame(payload) {
@@ -224,7 +224,7 @@ function sendInitData() {
const settings = getSettings();
const session = getActiveSession();
const avatars = getAvatarUrls();
-
+
postToFrame({
type: 'INIT_DATA',
settings: store?.settings || {},
@@ -240,86 +240,128 @@ function sendInitData() {
}
// ════════════════════════════════════════════════════════════════════════════
-// iframe 健康检测与恢复(新增)
+// iframe Health Check & Recovery
// ════════════════════════════════════════════════════════════════════════════
function handleVisibilityChange() {
if (document.visibilityState !== 'visible') return;
-
const overlay = document.getElementById('xiaobaix-fourth-wall-overlay');
if (!overlay || overlay.style.display === 'none') return;
-
checkIframeHealth();
}
function checkIframeHealth() {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
if (!iframe) return;
-
- // 生成唯一 ping ID
+
const pingId = 'ping_' + Date.now();
pendingPingId = pingId;
-
- // 尝试发送 PING
+
try {
const win = iframe.contentWindow;
if (!win) {
- recoverIframe('contentWindow 不存在');
+ recoverIframe('contentWindow missing');
return;
}
win.postMessage({ source: 'LittleWhiteBox', type: 'PING', pingId }, getTrustedOrigin());
} catch (e) {
- recoverIframe('无法访问 iframe: ' + e.message);
+ recoverIframe('Cannot access iframe: ' + e.message);
return;
}
-
- // 设置超时检测
+
setTimeout(() => {
if (pendingPingId === pingId) {
- // 没有收到 PONG 响应
- recoverIframe('PING 超时无响应');
+ recoverIframe('PING timeout');
}
}, IFRAME_PING_TIMEOUT);
}
function handlePongResponse(pingId) {
if (pendingPingId === pingId) {
- pendingPingId = null; // 清除,表示收到响应
+ pendingPingId = null;
}
}
function recoverIframe(reason) {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
if (!iframe) return;
-
- try { xbLog.warn('fourthWall', `iframe 恢复中: ${reason}`); } catch {}
-
- // 重置状态
+
+ try { xbLog.warn('fourthWall', `iframe recovery: ${reason}`); } catch { }
+
frameReady = false;
pendingFrameMessages = [];
pendingPingId = null;
-
- // 如果正在流式生成,取消
+
if (isStreaming) {
cancelGeneration();
}
-
- // 重新加载 iframe
+
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) {
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
if (!isTrustedMessage(event, iframe, 'LittleWhiteBox-FourthWall')) return;
const data = event.data;
-
+
const store = getFWStore();
const settings = getSettings();
-
+
switch (data.type) {
case 'FRAME_READY':
frameReady = true;
@@ -327,11 +369,9 @@ function handleFrameMessage(event) {
sendInitData();
break;
- // ═══════════════════════════ 新增 ═══════════════════════════
case 'PONG':
handlePongResponse(data.pingId);
break;
- // ════════════════════════════════════════════════════════════
case 'TOGGLE_FULLSCREEN':
toggleFullscreen();
@@ -340,29 +380,29 @@ function handleFrameMessage(event) {
case 'SEND_MESSAGE':
handleSendMessage(data);
break;
-
+
case 'REGENERATE':
handleRegenerate(data);
break;
-
+
case 'CANCEL_GENERATION':
cancelGeneration();
break;
-
+
case 'SAVE_SETTINGS':
if (store) {
Object.assign(store.settings, data.settings);
saveFWStore();
}
break;
-
+
case 'SAVE_IMG_SETTINGS':
Object.assign(settings.fourthWallImage, data.imgSettings);
saveSettingsDebounced();
break;
case 'SAVE_VOICE_SETTINGS':
- Object.assign(settings.fourthWallVoice, data.voiceSettings);
+ settings.fourthWallVoice.enabled = !!data.voiceSettings?.enabled;
saveSettingsDebounced();
break;
@@ -370,7 +410,7 @@ function handleFrameMessage(event) {
Object.assign(settings.fourthWallCommentary, data.commentarySettings);
saveSettingsDebounced();
break;
-
+
case 'SAVE_PROMPT_TEMPLATES':
settings.fourthWallPromptTemplates = data.templates;
saveSettingsDebounced();
@@ -382,7 +422,7 @@ function handleFrameMessage(event) {
saveSettingsDebounced();
sendInitData();
break;
-
+
case 'SAVE_HISTORY': {
const session = getActiveSession();
if (session) {
@@ -391,7 +431,7 @@ function handleFrameMessage(event) {
}
break;
}
-
+
case 'RESET_HISTORY': {
const session = getActiveSession();
if (session) {
@@ -400,7 +440,7 @@ function handleFrameMessage(event) {
}
break;
}
-
+
case 'SWITCH_SESSION':
if (store) {
store.activeSessionId = data.sessionId;
@@ -408,7 +448,7 @@ function handleFrameMessage(event) {
sendInitData();
}
break;
-
+
case 'ADD_SESSION':
if (store) {
const newId = 'sess_' + Date.now();
@@ -418,14 +458,14 @@ function handleFrameMessage(event) {
sendInitData();
}
break;
-
+
case 'RENAME_SESSION':
if (store) {
const sess = store.sessions.find(s => s.id === data.sessionId);
if (sess) { sess.name = data.name; saveFWStore(); sendInitData(); }
}
break;
-
+
case 'DELETE_SESSION':
if (store && store.sessions.length > 1) {
store.sessions = store.sessions.filter(s => s.id !== data.sessionId);
@@ -446,11 +486,19 @@ function handleFrameMessage(event) {
case 'GENERATE_IMAGE':
handleGenerate(data, postToFrame);
break;
+
+ case 'PLAY_VOICE':
+ handlePlayVoice(data);
+ break;
+
+ case 'STOP_VOICE':
+ handleStopVoice(data);
+ break;
}
}
// ════════════════════════════════════════════════════════════════════════════
-// 生成处理(保持不变)
+// Generation
// ════════════════════════════════════════════════════════════════════════════
async function startGeneration(data) {
@@ -462,9 +510,9 @@ async function startGeneration(data) {
voiceSettings: data.voiceSettings,
promptTemplates: getSettings().fourthWallPromptTemplates
});
-
+
const gen = window.xiaobaixStreamingGeneration;
- if (!gen?.xbgenrawCommand) throw new Error('xbgenraw 模块不可用');
+ if (!gen?.xbgenrawCommand) throw new Error('xbgenraw module unavailable');
const topMessages = [
{ role: 'user', content: msg1 },
@@ -479,7 +527,7 @@ async function startGeneration(data) {
nonstream: data.settings.stream ? 'false' : 'true',
as: 'user',
}, '');
-
+
if (data.settings.stream) {
startStreamingPoll();
} else {
@@ -490,13 +538,13 @@ async function startGeneration(data) {
async function handleSendMessage(data) {
if (isStreaming) return;
isStreaming = true;
-
+
const session = getActiveSession();
if (session) {
session.history = data.history;
saveFWStore();
}
-
+
try {
await startGeneration(data);
} catch {
@@ -509,13 +557,13 @@ async function handleSendMessage(data) {
async function handleRegenerate(data) {
if (isStreaming) return;
isStreaming = true;
-
+
const session = getActiveSession();
if (session) {
session.history = data.history;
saveFWStore();
}
-
+
try {
await startGeneration(data);
} catch {
@@ -535,7 +583,7 @@ function startStreamingPoll() {
const thinking = extractThinkingPartial(raw);
const msg = extractMsg(raw) || extractMsgPartial(raw);
postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined });
-
+
const st = gen.getStatus?.(STREAM_SESSION_ID);
if (st && st.isStreaming === false) finalizeGeneration();
}, 80);
@@ -560,18 +608,18 @@ function stopStreamingPoll() {
function finalizeGeneration() {
stopStreamingPoll();
const gen = window.xiaobaixStreamingGeneration;
- const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(无响应)';
- const finalText = extractMsg(rawText) || '(无响应)';
+ const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(no response)';
+ const finalText = extractMsg(rawText) || '(no response)';
const thinkingText = extractThinking(rawText);
-
+
isStreaming = false;
-
+
const session = getActiveSession();
if (session) {
session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() });
saveFWStore();
}
-
+
postToFrame({ type: 'STREAM_COMPLETE', finalText, thinking: thinkingText });
}
@@ -579,12 +627,12 @@ function cancelGeneration() {
const gen = window.xiaobaixStreamingGeneration;
stopStreamingPoll();
isStreaming = false;
- try { gen?.cancel?.(STREAM_SESSION_ID); } catch {}
+ try { gen?.cancel?.(STREAM_SESSION_ID); } catch { }
postToFrame({ type: 'GENERATION_CANCELLED' });
}
// ════════════════════════════════════════════════════════════════════════════
-// 实时吐槽(保持不变,省略...)
+// Commentary
// ════════════════════════════════════════════════════════════════════════════
function shouldTriggerCommentary() {
@@ -669,7 +717,7 @@ async function handleAIMessageForCommentary(data) {
if (!commentary) return;
const session = getActiveSession();
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();
}
showCommentaryBubble(commentary);
@@ -678,22 +726,22 @@ async function handleAIMessageForCommentary(data) {
async function handleEditForCommentary(data) {
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) return;
if (!shouldTriggerCommentary()) return;
-
+
const ctx = getContext?.() || {};
const messageId = typeof data === 'object' ? (data.messageId ?? data.id ?? data.index) : data;
const msgObj = Number.isFinite(messageId) ? ctx?.chat?.[messageId] : null;
const messageText = getMessageTextFromEventArg(data);
if (!String(messageText).trim()) return;
-
+
await new Promise(r => setTimeout(r, 500 + Math.random() * 500));
-
+
const editType = msgObj?.is_user ? 'edit_own' : 'edit_ai';
const commentary = await generateCommentary(messageText, editType);
if (!commentary) return;
-
+
const session = getActiveSession();
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' });
saveFWStore();
}
@@ -705,7 +753,7 @@ function getFloatBtnPosition() {
if (!btn) return null;
const rect = btn.getBoundingClientRect();
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' };
}
@@ -771,19 +819,19 @@ function cleanupCommentary() {
lastCommentaryTime = 0;
}
-// ════════════════════════════════════════════════════════════════════════════
-// Overlay 管理(添加可见性监听)
+// ════════════════════════════════════════════
+// Overlay
// ════════════════════════════════════════════════════════════════════════════
function createOverlay() {
if (overlayCreated) return;
overlayCreated = true;
-
+
const isMobile = window.innerWidth <= 768;
const frameInset = 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 $overlay = $(`
@@ -792,13 +840,13 @@ function createOverlay() {
`);
-
+
$overlay.on('click', '.fw-backdrop', hideOverlay);
document.body.appendChild($overlay[0]);
// Guarded by isTrustedMessage (origin + source).
// eslint-disable-next-line no-restricted-syntax
window.addEventListener('message', handleFrameMessage);
-
+
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement) {
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false });
@@ -821,26 +869,23 @@ function showOverlay() {
sendInitData();
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: !!document.fullscreenElement });
-
- // ═══════════════════════════ 新增:添加可见性监听 ═══════════════════════════
+
if (!visibilityHandler) {
visibilityHandler = handleVisibilityChange;
document.addEventListener('visibilitychange', visibilityHandler);
}
- // ════════════════════════════════════════════════════════════════════════════
}
function hideOverlay() {
$('#xiaobaix-fourth-wall-overlay').hide();
- if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
-
- // ═══════════════════════════ 新增:移除可见性监听 ═══════════════════════════
+ if (document.fullscreenElement) document.exitFullscreen().catch(() => { });
+ stopVoiceAndNotify();
+
if (visibilityHandler) {
document.removeEventListener('visibilitychange', visibilityHandler);
visibilityHandler = null;
}
pendingPingId = null;
- // ════════════════════════════════════════════════════════════════════════════
}
function toggleFullscreen() {
@@ -850,16 +895,16 @@ function toggleFullscreen() {
if (document.fullscreenElement) {
document.exitFullscreen().then(() => {
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false });
- }).catch(() => {});
+ }).catch(() => { });
} else if (overlay.requestFullscreen) {
overlay.requestFullscreen().then(() => {
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: true });
- }).catch(() => {});
+ }).catch(() => { });
}
}
// ════════════════════════════════════════════════════════════════════════════
-// 悬浮按钮(保持不变,省略...)
+// Floating Button
// ════════════════════════════════════════════════════════════════════════════
function createFloatingButton() {
@@ -871,7 +916,7 @@ function createFloatingButton() {
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 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 applyDocked = (side, topRatio) => {
const btn = document.getElementById('xiaobaix-fw-float-btn');
@@ -885,20 +930,20 @@ function createFloatingButton() {
};
const $btn = $(`
-