1036 lines
43 KiB
JavaScript
1036 lines
43 KiB
JavaScript
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 次元壁模块 - 主控制器
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js";
|
|||
|
|
import { saveSettingsDebounced, chat_metadata, default_user_avatar, default_avatar } from "../../../../../../script.js";
|
|||
|
|
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
|
|||
|
|
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,
|
|||
|
|
DEFAULT_TOPUSER,
|
|||
|
|
DEFAULT_CONFIRM,
|
|||
|
|
DEFAULT_BOTTOM,
|
|||
|
|
DEFAULT_META_PROTOCOL
|
|||
|
|
} from "./fw-prompt.js";
|
|||
|
|
import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js";
|
|||
|
|
import { postToIframe, isTrustedMessage, getTrustedOrigin } from "../../core/iframe-messaging.js";
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 常量
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
const events = createModuleEvents('fourthWall');
|
|||
|
|
const iframePath = `${extensionFolderPath}/modules/fourth-wall/fourth-wall.html`;
|
|||
|
|
const STREAM_SESSION_ID = 'xb9';
|
|||
|
|
const COMMENTARY_COOLDOWN = 180000;
|
|||
|
|
const IFRAME_PING_TIMEOUT = 800;
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 状态
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
let overlayCreated = false;
|
|||
|
|
let frameReady = false;
|
|||
|
|
let pendingFrameMessages = [];
|
|||
|
|
let isStreaming = false;
|
|||
|
|
let streamTimerId = null;
|
|||
|
|
let floatBtnResizeHandler = null;
|
|||
|
|
let suppressFloatBtnClickUntil = 0;
|
|||
|
|
let currentLoadedChatId = null;
|
|||
|
|
let lastCommentaryTime = 0;
|
|||
|
|
let commentaryBubbleEl = null;
|
|||
|
|
let commentaryBubbleTimer = null;
|
|||
|
|
|
|||
|
|
// ═══════════════════════════════ 新增 ═══════════════════════════════
|
|||
|
|
let visibilityHandler = null;
|
|||
|
|
let pendingPingId = null;
|
|||
|
|
// ════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 设置管理(保持不变)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
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.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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 工具函数(保持不变)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function b64UrlEncode(str) {
|
|||
|
|
const utf8 = new TextEncoder().encode(String(str));
|
|||
|
|
let bin = '';
|
|||
|
|
utf8.forEach(b => bin += String.fromCharCode(b));
|
|||
|
|
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractMsg(text) {
|
|||
|
|
const src = String(text || '');
|
|||
|
|
const re = /<msg\b[^>]*>([\s\S]*?)<\/msg>/gi;
|
|||
|
|
const parts = [];
|
|||
|
|
let m;
|
|||
|
|
while ((m = re.exec(src)) !== null) {
|
|||
|
|
const inner = String(m[1] || '').trim();
|
|||
|
|
if (inner) parts.push(inner);
|
|||
|
|
}
|
|||
|
|
return parts.join('\n').trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractMsgPartial(text) {
|
|||
|
|
const src = String(text || '');
|
|||
|
|
const openIdx = src.toLowerCase().lastIndexOf('<msg');
|
|||
|
|
if (openIdx < 0) return '';
|
|||
|
|
const gt = src.indexOf('>', openIdx);
|
|||
|
|
if (gt < 0) return '';
|
|||
|
|
let out = src.slice(gt + 1);
|
|||
|
|
const closeIdx = out.toLowerCase().indexOf('</msg>');
|
|||
|
|
if (closeIdx >= 0) out = out.slice(0, closeIdx);
|
|||
|
|
return out.trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractThinking(text) {
|
|||
|
|
const src = String(text || '');
|
|||
|
|
const msgStart = src.toLowerCase().indexOf('<msg');
|
|||
|
|
if (msgStart <= 0) return '';
|
|||
|
|
return src.slice(0, msgStart).trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function extractThinkingPartial(text) {
|
|||
|
|
const src = String(text || '');
|
|||
|
|
const msgStart = src.toLowerCase().indexOf('<msg');
|
|||
|
|
if (msgStart < 0) return src.trim();
|
|||
|
|
if (msgStart === 0) return '';
|
|||
|
|
return src.slice(0, msgStart).trim();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getCurrentChatIdSafe() {
|
|||
|
|
try { return getContext().chatId || null; } catch { return null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getAvatarUrls() {
|
|||
|
|
const origin = typeof location !== 'undefined' && location.origin ? location.origin : '';
|
|||
|
|
const toAbsUrl = (relOrUrl) => {
|
|||
|
|
if (!relOrUrl) return '';
|
|||
|
|
const s = String(relOrUrl);
|
|||
|
|
if (/^(data:|blob:|https?:)/i.test(s)) return s;
|
|||
|
|
if (s.startsWith('User Avatars/')) return `${origin}/${s}`;
|
|||
|
|
const encoded = s.split('/').map(seg => encodeURIComponent(seg)).join('/');
|
|||
|
|
return `${origin}/${encoded.replace(/^\/+/, '')}`;
|
|||
|
|
};
|
|||
|
|
const pickSrc = (selectors) => {
|
|||
|
|
for (const sel of selectors) {
|
|||
|
|
const el = document.querySelector(sel);
|
|||
|
|
if (el) {
|
|||
|
|
const highRes = el.getAttribute('data-izoomify-url');
|
|||
|
|
if (highRes) return highRes;
|
|||
|
|
if (el.src) return el.src;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
};
|
|||
|
|
let user = pickSrc(['#user_avatar_block img', '#avatar_user img', '.user_avatar img', 'img#avatar_user', '.st-user-avatar img']) || (typeof default_user_avatar !== 'undefined' ? default_user_avatar : '');
|
|||
|
|
const m = String(user).match(/\/thumbnail\?type=persona&file=([^&]+)/i);
|
|||
|
|
if (m) user = `User Avatars/${decodeURIComponent(m[1])}`;
|
|||
|
|
const ctx = getContext?.() || {};
|
|||
|
|
const chId = ctx.characterId ?? ctx.this_chid;
|
|||
|
|
const ch = Array.isArray(ctx.characters) ? ctx.characters[chId] : null;
|
|||
|
|
let char = ch?.avatar || (typeof default_avatar !== 'undefined' ? default_avatar : '');
|
|||
|
|
if (char && !/^(data:|blob:|https?:)/i.test(char)) {
|
|||
|
|
char = String(char).includes('/') ? char.replace(/^\/+/, '') : `characters/${char}`;
|
|||
|
|
}
|
|||
|
|
return { user: toAbsUrl(user), char: toAbsUrl(char) };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 存储管理(保持不变)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function getFWStore(chatId = getCurrentChatIdSafe()) {
|
|||
|
|
if (!chatId) return null;
|
|||
|
|
chat_metadata[chatId] ||= {};
|
|||
|
|
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.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;
|
|||
|
|
}
|
|||
|
|
return fw;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getActiveSession(chatId = getCurrentChatIdSafe()) {
|
|||
|
|
const store = getFWStore(chatId);
|
|||
|
|
if (!store) return null;
|
|||
|
|
return store.sessions.find(s => s.id === store.activeSessionId) || store.sessions[0];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function saveFWStore() {
|
|||
|
|
saveMetadataDebounced?.();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// iframe 通讯
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function postToFrame(payload) {
|
|||
|
|
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
|
|||
|
|
if (!iframe?.contentWindow || !frameReady) {
|
|||
|
|
pendingFrameMessages.push(payload);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
postToIframe(iframe, payload, 'LittleWhiteBox');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function flushPendingMessages() {
|
|||
|
|
if (!frameReady) return;
|
|||
|
|
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
|
|||
|
|
if (!iframe?.contentWindow) return;
|
|||
|
|
pendingFrameMessages.forEach(p => postToIframe(iframe, p, 'LittleWhiteBox'));
|
|||
|
|
pendingFrameMessages = [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sendInitData() {
|
|||
|
|
const store = getFWStore();
|
|||
|
|
const settings = getSettings();
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
const avatars = getAvatarUrls();
|
|||
|
|
|
|||
|
|
postToFrame({
|
|||
|
|
type: 'INIT_DATA',
|
|||
|
|
settings: store?.settings || {},
|
|||
|
|
sessions: store?.sessions || [],
|
|||
|
|
activeSessionId: store?.activeSessionId,
|
|||
|
|
history: session?.history || [],
|
|||
|
|
imgSettings: settings.fourthWallImage || {},
|
|||
|
|
voiceSettings: settings.fourthWallVoice || {},
|
|||
|
|
commentarySettings: settings.fourthWallCommentary || {},
|
|||
|
|
promptTemplates: settings.fourthWallPromptTemplates || {},
|
|||
|
|
avatars
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// iframe 健康检测与恢复(新增)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
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 不存在');
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
win.postMessage({ source: 'LittleWhiteBox', type: 'PING', pingId }, getTrustedOrigin());
|
|||
|
|
} catch (e) {
|
|||
|
|
recoverIframe('无法访问 iframe: ' + e.message);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 设置超时检测
|
|||
|
|
setTimeout(() => {
|
|||
|
|
if (pendingPingId === pingId) {
|
|||
|
|
// 没有收到 PONG 响应
|
|||
|
|
recoverIframe('PING 超时无响应');
|
|||
|
|
}
|
|||
|
|
}, IFRAME_PING_TIMEOUT);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handlePongResponse(pingId) {
|
|||
|
|
if (pendingPingId === pingId) {
|
|||
|
|
pendingPingId = null; // 清除,表示收到响应
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function recoverIframe(reason) {
|
|||
|
|
const iframe = document.getElementById('xiaobaix-fourth-wall-iframe');
|
|||
|
|
if (!iframe) return;
|
|||
|
|
|
|||
|
|
try { xbLog.warn('fourthWall', `iframe 恢复中: ${reason}`); } catch {}
|
|||
|
|
|
|||
|
|
// 重置状态
|
|||
|
|
frameReady = false;
|
|||
|
|
pendingFrameMessages = [];
|
|||
|
|
pendingPingId = null;
|
|||
|
|
|
|||
|
|
// 如果正在流式生成,取消
|
|||
|
|
if (isStreaming) {
|
|||
|
|
cancelGeneration();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 重新加载 iframe
|
|||
|
|
iframe.src = iframePath;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 消息处理(添加 PONG 处理)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
flushPendingMessages();
|
|||
|
|
sendInitData();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
// ═══════════════════════════ 新增 ═══════════════════════════
|
|||
|
|
case 'PONG':
|
|||
|
|
handlePongResponse(data.pingId);
|
|||
|
|
break;
|
|||
|
|
// ════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
case 'TOGGLE_FULLSCREEN':
|
|||
|
|
toggleFullscreen();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'SAVE_COMMENTARY_SETTINGS':
|
|||
|
|
Object.assign(settings.fourthWallCommentary, data.commentarySettings);
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'SAVE_PROMPT_TEMPLATES':
|
|||
|
|
settings.fourthWallPromptTemplates = data.templates;
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'RESTORE_DEFAULT_PROMPT_TEMPLATES':
|
|||
|
|
extension_settings[EXT_ID].fourthWallPromptTemplates = {};
|
|||
|
|
getSettings();
|
|||
|
|
saveSettingsDebounced();
|
|||
|
|
sendInitData();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'SAVE_HISTORY': {
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
if (session) {
|
|||
|
|
session.history = data.history;
|
|||
|
|
saveFWStore();
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'RESET_HISTORY': {
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
if (session) {
|
|||
|
|
session.history = [];
|
|||
|
|
saveFWStore();
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
case 'SWITCH_SESSION':
|
|||
|
|
if (store) {
|
|||
|
|
store.activeSessionId = data.sessionId;
|
|||
|
|
saveFWStore();
|
|||
|
|
sendInitData();
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'ADD_SESSION':
|
|||
|
|
if (store) {
|
|||
|
|
const newId = 'sess_' + Date.now();
|
|||
|
|
store.sessions.push({ id: newId, name: data.name, createdAt: Date.now(), history: [] });
|
|||
|
|
store.activeSessionId = newId;
|
|||
|
|
saveFWStore();
|
|||
|
|
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);
|
|||
|
|
store.activeSessionId = store.sessions[0].id;
|
|||
|
|
saveFWStore();
|
|||
|
|
sendInitData();
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'CLOSE_OVERLAY':
|
|||
|
|
hideOverlay();
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'CHECK_IMAGE_CACHE':
|
|||
|
|
handleCheckCache(data, postToFrame);
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case 'GENERATE_IMAGE':
|
|||
|
|
handleGenerate(data, postToFrame);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 生成处理(保持不变)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
async function startGeneration(data) {
|
|||
|
|
const { msg1, msg2, msg3, msg4 } = await buildPrompt({
|
|||
|
|
userInput: data.userInput,
|
|||
|
|
history: data.history,
|
|||
|
|
settings: data.settings,
|
|||
|
|
imgSettings: data.imgSettings,
|
|||
|
|
voiceSettings: data.voiceSettings,
|
|||
|
|
promptTemplates: getSettings().fourthWallPromptTemplates
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const gen = window.xiaobaixStreamingGeneration;
|
|||
|
|
if (!gen?.xbgenrawCommand) throw new Error('xbgenraw 模块不可用');
|
|||
|
|
|
|||
|
|
const topMessages = [
|
|||
|
|
{ role: 'user', content: msg1 },
|
|||
|
|
{ role: 'assistant', content: msg2 },
|
|||
|
|
{ role: 'user', content: msg3 },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
await gen.xbgenrawCommand({
|
|||
|
|
id: STREAM_SESSION_ID,
|
|||
|
|
top64: b64UrlEncode(JSON.stringify(topMessages)),
|
|||
|
|
bottomassistant: msg4,
|
|||
|
|
nonstream: data.settings.stream ? 'false' : 'true',
|
|||
|
|
as: 'user',
|
|||
|
|
}, '');
|
|||
|
|
|
|||
|
|
if (data.settings.stream) {
|
|||
|
|
startStreamingPoll();
|
|||
|
|
} else {
|
|||
|
|
startNonstreamAwait();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleSendMessage(data) {
|
|||
|
|
if (isStreaming) return;
|
|||
|
|
isStreaming = true;
|
|||
|
|
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
if (session) {
|
|||
|
|
session.history = data.history;
|
|||
|
|
saveFWStore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await startGeneration(data);
|
|||
|
|
} catch {
|
|||
|
|
stopStreamingPoll();
|
|||
|
|
isStreaming = false;
|
|||
|
|
postToFrame({ type: 'GENERATION_CANCELLED' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleRegenerate(data) {
|
|||
|
|
if (isStreaming) return;
|
|||
|
|
isStreaming = true;
|
|||
|
|
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
if (session) {
|
|||
|
|
session.history = data.history;
|
|||
|
|
saveFWStore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
await startGeneration(data);
|
|||
|
|
} catch {
|
|||
|
|
stopStreamingPoll();
|
|||
|
|
isStreaming = false;
|
|||
|
|
postToFrame({ type: 'GENERATION_CANCELLED' });
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startStreamingPoll() {
|
|||
|
|
stopStreamingPoll();
|
|||
|
|
streamTimerId = setInterval(() => {
|
|||
|
|
const gen = window.xiaobaixStreamingGeneration;
|
|||
|
|
if (!gen?.getLastGeneration) return;
|
|||
|
|
|
|||
|
|
const raw = gen.getLastGeneration(STREAM_SESSION_ID) || '...';
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startNonstreamAwait() {
|
|||
|
|
stopStreamingPoll();
|
|||
|
|
streamTimerId = setInterval(() => {
|
|||
|
|
const gen = window.xiaobaixStreamingGeneration;
|
|||
|
|
const st = gen?.getStatus?.(STREAM_SESSION_ID);
|
|||
|
|
if (st && st.isStreaming === false) finalizeGeneration();
|
|||
|
|
}, 120);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopStreamingPoll() {
|
|||
|
|
if (streamTimerId) {
|
|||
|
|
clearInterval(streamTimerId);
|
|||
|
|
streamTimerId = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function finalizeGeneration() {
|
|||
|
|
stopStreamingPoll();
|
|||
|
|
const gen = window.xiaobaixStreamingGeneration;
|
|||
|
|
const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(无响应)';
|
|||
|
|
const finalText = extractMsg(rawText) || '(无响应)';
|
|||
|
|
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 });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cancelGeneration() {
|
|||
|
|
const gen = window.xiaobaixStreamingGeneration;
|
|||
|
|
stopStreamingPoll();
|
|||
|
|
isStreaming = false;
|
|||
|
|
try { gen?.cancel?.(STREAM_SESSION_ID); } catch {}
|
|||
|
|
postToFrame({ type: 'GENERATION_CANCELLED' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 实时吐槽(保持不变,省略...)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function shouldTriggerCommentary() {
|
|||
|
|
const settings = getSettings();
|
|||
|
|
if (!settings.fourthWallCommentary?.enabled) return false;
|
|||
|
|
if (Date.now() - lastCommentaryTime < COMMENTARY_COOLDOWN) return false;
|
|||
|
|
const prob = settings.fourthWallCommentary.probability || 30;
|
|||
|
|
if (Math.random() * 100 > prob) return false;
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getMessageTextFromEventArg(arg) {
|
|||
|
|
if (!arg) return '';
|
|||
|
|
if (typeof arg === 'string') return arg;
|
|||
|
|
if (typeof arg === 'object') {
|
|||
|
|
if (typeof arg.mes === 'string') return arg.mes;
|
|||
|
|
if (typeof arg.message === 'string') return arg.message;
|
|||
|
|
const messageId = arg.messageId ?? arg.id ?? arg.index;
|
|||
|
|
if (Number.isFinite(messageId)) {
|
|||
|
|
try { return getContext?.()?.chat?.[messageId]?.mes || ''; } catch { return ''; }
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
if (typeof arg === 'number') {
|
|||
|
|
try { return getContext?.()?.chat?.[arg]?.mes || ''; } catch { return ''; }
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function generateCommentary(targetText, type) {
|
|||
|
|
const store = getFWStore();
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
const settings = getSettings();
|
|||
|
|
if (!store || !session) return null;
|
|||
|
|
|
|||
|
|
const built = await buildCommentaryPrompt({
|
|||
|
|
targetText,
|
|||
|
|
type,
|
|||
|
|
history: session.history || [],
|
|||
|
|
settings: store.settings || {},
|
|||
|
|
imgSettings: settings.fourthWallImage || {},
|
|||
|
|
voiceSettings: settings.fourthWallVoice || {}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (!built) return null;
|
|||
|
|
const { msg1, msg2, msg3, msg4 } = built;
|
|||
|
|
|
|||
|
|
const gen = window.xiaobaixStreamingGeneration;
|
|||
|
|
if (!gen?.xbgenrawCommand) return null;
|
|||
|
|
|
|||
|
|
const topMessages = [
|
|||
|
|
{ role: 'user', content: msg1 },
|
|||
|
|
{ role: 'assistant', content: msg2 },
|
|||
|
|
{ role: 'user', content: msg3 },
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const result = await gen.xbgenrawCommand({
|
|||
|
|
id: 'xb8',
|
|||
|
|
top64: b64UrlEncode(JSON.stringify(topMessages)),
|
|||
|
|
bottomassistant: msg4,
|
|||
|
|
nonstream: 'true',
|
|||
|
|
as: 'user',
|
|||
|
|
}, '');
|
|||
|
|
return extractMsg(result) || null;
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function handleAIMessageForCommentary(data) {
|
|||
|
|
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) return;
|
|||
|
|
if (!shouldTriggerCommentary()) return;
|
|||
|
|
const ctx = getContext?.() || {};
|
|||
|
|
const messageId = typeof data === 'object' ? data.messageId : data;
|
|||
|
|
const msgObj = Number.isFinite(messageId) ? ctx?.chat?.[messageId] : null;
|
|||
|
|
if (msgObj?.is_user) return;
|
|||
|
|
const messageText = getMessageTextFromEventArg(data);
|
|||
|
|
if (!String(messageText).trim()) return;
|
|||
|
|
await new Promise(r => setTimeout(r, 1000 + Math.random() * 1000));
|
|||
|
|
const commentary = await generateCommentary(messageText, 'ai_message');
|
|||
|
|
if (!commentary) return;
|
|||
|
|
const session = getActiveSession();
|
|||
|
|
if (session) {
|
|||
|
|
session.history.push({ role: 'ai', content: `(瞄了眼刚才的台词)${commentary}`, ts: Date.now(), type: 'commentary' });
|
|||
|
|
saveFWStore();
|
|||
|
|
}
|
|||
|
|
showCommentaryBubble(commentary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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' ? '(发现你改了我的台词)' : '(发现你偷偷改台词)';
|
|||
|
|
session.history.push({ role: 'ai', content: `${prefix}${commentary}`, ts: Date.now(), type: 'commentary' });
|
|||
|
|
saveFWStore();
|
|||
|
|
}
|
|||
|
|
showCommentaryBubble(commentary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getFloatBtnPosition() {
|
|||
|
|
const btn = document.getElementById('xiaobaix-fw-float-btn');
|
|||
|
|
if (!btn) return null;
|
|||
|
|
const rect = btn.getBoundingClientRect();
|
|||
|
|
let stored = {};
|
|||
|
|
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' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showCommentaryBubble(text) {
|
|||
|
|
hideCommentaryBubble();
|
|||
|
|
const pos = getFloatBtnPosition();
|
|||
|
|
if (!pos) return;
|
|||
|
|
const bubble = document.createElement('div');
|
|||
|
|
bubble.className = 'fw-commentary-bubble';
|
|||
|
|
bubble.textContent = text;
|
|||
|
|
bubble.onclick = hideCommentaryBubble;
|
|||
|
|
Object.assign(bubble.style, {
|
|||
|
|
position: 'fixed', zIndex: '10000', maxWidth: '200px', padding: '8px 12px',
|
|||
|
|
background: 'rgba(255,255,255,0.95)', borderRadius: '12px', boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
|||
|
|
fontSize: '13px', color: '#333', cursor: 'pointer', opacity: '0', transform: 'scale(0.8)', transition: 'opacity 0.3s, transform 0.3s'
|
|||
|
|
});
|
|||
|
|
document.body.appendChild(bubble);
|
|||
|
|
commentaryBubbleEl = bubble;
|
|||
|
|
const margin = 8;
|
|||
|
|
const bubbleW = bubble.offsetWidth || 0;
|
|||
|
|
const bubbleH = bubble.offsetHeight || 0;
|
|||
|
|
const maxTop = Math.max(margin, window.innerHeight - bubbleH - margin);
|
|||
|
|
const top = Math.min(Math.max(pos.top, margin), maxTop);
|
|||
|
|
bubble.style.top = `${top}px`;
|
|||
|
|
if (pos.side === 'right') {
|
|||
|
|
const maxRight = Math.max(margin, window.innerWidth - bubbleW - margin);
|
|||
|
|
const right = Math.min(Math.max(window.innerWidth - pos.left + 8, margin), maxRight);
|
|||
|
|
bubble.style.right = `${right}px`;
|
|||
|
|
bubble.style.left = '';
|
|||
|
|
bubble.style.borderBottomRightRadius = '4px';
|
|||
|
|
} else {
|
|||
|
|
const maxLeft = Math.max(margin, window.innerWidth - bubbleW - margin);
|
|||
|
|
const left = Math.min(Math.max(pos.left + pos.width + 8, margin), maxLeft);
|
|||
|
|
bubble.style.left = `${left}px`;
|
|||
|
|
bubble.style.right = '';
|
|||
|
|
bubble.style.borderBottomLeftRadius = '4px';
|
|||
|
|
}
|
|||
|
|
requestAnimationFrame(() => { bubble.style.opacity = '1'; bubble.style.transform = 'scale(1)'; });
|
|||
|
|
const len = (text || '').length;
|
|||
|
|
const duration = Math.min(2000 + Math.ceil(len / 5) * 1000, 8000);
|
|||
|
|
commentaryBubbleTimer = setTimeout(hideCommentaryBubble, duration);
|
|||
|
|
lastCommentaryTime = Date.now();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function hideCommentaryBubble() {
|
|||
|
|
if (commentaryBubbleTimer) { clearTimeout(commentaryBubbleTimer); commentaryBubbleTimer = null; }
|
|||
|
|
if (commentaryBubbleEl) {
|
|||
|
|
commentaryBubbleEl.style.opacity = '0';
|
|||
|
|
commentaryBubbleEl.style.transform = 'scale(0.8)';
|
|||
|
|
setTimeout(() => { commentaryBubbleEl?.remove(); commentaryBubbleEl = null; }, 300);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function initCommentary() {
|
|||
|
|
events.on(event_types.MESSAGE_RECEIVED, handleAIMessageForCommentary);
|
|||
|
|
events.on(event_types.MESSAGE_EDITED, handleEditForCommentary);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cleanupCommentary() {
|
|||
|
|
events.off(event_types.MESSAGE_RECEIVED, handleAIMessageForCommentary);
|
|||
|
|
events.off(event_types.MESSAGE_EDITED, handleEditForCommentary);
|
|||
|
|
hideCommentaryBubble();
|
|||
|
|
lastCommentaryTime = 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 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 = $(`
|
|||
|
|
<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-frame-wrap" style="position:absolute!important;inset:${frameInset}!important;z-index:1!important;${framePadding}">
|
|||
|
|
<iframe id="xiaobaix-fourth-wall-iframe" class="xiaobaix-iframe" src="${iframePath}" style="width:100%!important;height:100%!important;border:none!important;border-radius:${iframeRadius}!important;box-shadow:0 0 30px rgba(0,0,0,.4)!important;background:#1a1a2e!important;"></iframe>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
$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 });
|
|||
|
|
} else {
|
|||
|
|
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: true });
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function showOverlay() {
|
|||
|
|
if (!overlayCreated) createOverlay();
|
|||
|
|
const overlay = document.getElementById('xiaobaix-fourth-wall-overlay');
|
|||
|
|
overlay.style.display = 'block';
|
|||
|
|
|
|||
|
|
const newChatId = getCurrentChatIdSafe();
|
|||
|
|
if (newChatId !== currentLoadedChatId) {
|
|||
|
|
currentLoadedChatId = newChatId;
|
|||
|
|
pendingFrameMessages = [];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 (visibilityHandler) {
|
|||
|
|
document.removeEventListener('visibilitychange', visibilityHandler);
|
|||
|
|
visibilityHandler = null;
|
|||
|
|
}
|
|||
|
|
pendingPingId = null;
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toggleFullscreen() {
|
|||
|
|
const overlay = document.getElementById('xiaobaix-fourth-wall-overlay');
|
|||
|
|
if (!overlay) return;
|
|||
|
|
|
|||
|
|
if (document.fullscreenElement) {
|
|||
|
|
document.exitFullscreen().then(() => {
|
|||
|
|
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: false });
|
|||
|
|
}).catch(() => {});
|
|||
|
|
} else if (overlay.requestFullscreen) {
|
|||
|
|
overlay.requestFullscreen().then(() => {
|
|||
|
|
postToFrame({ type: 'FULLSCREEN_STATE', isFullscreen: true });
|
|||
|
|
}).catch(() => {});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 悬浮按钮(保持不变,省略...)
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function createFloatingButton() {
|
|||
|
|
if (document.getElementById('xiaobaix-fw-float-btn')) return;
|
|||
|
|
|
|||
|
|
const POS_KEY = `${EXT_ID}:fourthWallFloatBtnPos`;
|
|||
|
|
const size = window.innerWidth <= 768 ? 32 : 40;
|
|||
|
|
const margin = 8;
|
|||
|
|
|
|||
|
|
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 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');
|
|||
|
|
if (!btn) return;
|
|||
|
|
const w = btn.offsetWidth || size;
|
|||
|
|
const h = btn.offsetHeight || size;
|
|||
|
|
const left = calcDockLeft(side, w);
|
|||
|
|
const top = clamp(Math.round((Number.isFinite(topRatio) ? topRatio : 0.5) * window.innerHeight), margin, Math.max(margin, window.innerHeight - h - margin));
|
|||
|
|
btn.style.left = `${left}px`;
|
|||
|
|
btn.style.top = `${top}px`;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
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;">
|
|||
|
|
<i class="fa-solid fa-comments"></i>
|
|||
|
|
</button>
|
|||
|
|
`);
|
|||
|
|
|
|||
|
|
$btn.on('click', () => {
|
|||
|
|
if (Date.now() < suppressFloatBtnClickUntil) return;
|
|||
|
|
if (!getSettings().fourthWall?.enabled) return;
|
|||
|
|
showOverlay();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
$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)' }); });
|
|||
|
|
|
|||
|
|
document.body.appendChild($btn[0]);
|
|||
|
|
|
|||
|
|
const initial = readPos();
|
|||
|
|
applyDocked(initial?.side || 'right', Number.isFinite(initial?.topRatio) ? initial.topRatio : 0.5);
|
|||
|
|
|
|||
|
|
let dragging = false;
|
|||
|
|
let startX = 0, startY = 0, startLeft = 0, startTop = 0, pointerId = null;
|
|||
|
|
|
|||
|
|
const onPointerDown = (e) => {
|
|||
|
|
if (e.button !== undefined && e.button !== 0) return;
|
|||
|
|
const btn = e.currentTarget;
|
|||
|
|
pointerId = e.pointerId;
|
|||
|
|
try { btn.setPointerCapture(pointerId); } catch {}
|
|||
|
|
const rect = btn.getBoundingClientRect();
|
|||
|
|
startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top;
|
|||
|
|
dragging = false;
|
|||
|
|
btn.style.transition = 'none';
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onPointerMove = (e) => {
|
|||
|
|
if (pointerId === null || e.pointerId !== pointerId) return;
|
|||
|
|
const btn = e.currentTarget;
|
|||
|
|
const dx = e.clientX - startX;
|
|||
|
|
const dy = e.clientY - startY;
|
|||
|
|
if (!dragging && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) dragging = true;
|
|||
|
|
if (!dragging) return;
|
|||
|
|
const w = btn.offsetWidth || size;
|
|||
|
|
const h = btn.offsetHeight || size;
|
|||
|
|
const left = clamp(Math.round(startLeft + dx), -Math.round(w / 2), window.innerWidth - Math.round(w / 2));
|
|||
|
|
const top = clamp(Math.round(startTop + dy), margin, Math.max(margin, window.innerHeight - h - margin));
|
|||
|
|
btn.style.left = `${left}px`;
|
|||
|
|
btn.style.top = `${top}px`;
|
|||
|
|
e.preventDefault();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const onPointerUp = (e) => {
|
|||
|
|
if (pointerId === null || e.pointerId !== pointerId) return;
|
|||
|
|
const btn = e.currentTarget;
|
|||
|
|
try { btn.releasePointerCapture(pointerId); } catch {}
|
|||
|
|
pointerId = null;
|
|||
|
|
btn.style.transition = '';
|
|||
|
|
const rect = btn.getBoundingClientRect();
|
|||
|
|
const w = btn.offsetWidth || size;
|
|||
|
|
const h = btn.offsetHeight || size;
|
|||
|
|
if (dragging) {
|
|||
|
|
const centerX = rect.left + w / 2;
|
|||
|
|
const side = centerX < window.innerWidth / 2 ? 'left' : 'right';
|
|||
|
|
const top = clamp(Math.round(rect.top), margin, Math.max(margin, window.innerHeight - h - margin));
|
|||
|
|
const topRatio = window.innerHeight ? (top / window.innerHeight) : 0.5;
|
|||
|
|
applyDocked(side, topRatio);
|
|||
|
|
writePos({ side, topRatio });
|
|||
|
|
suppressFloatBtnClickUntil = Date.now() + 350;
|
|||
|
|
e.preventDefault();
|
|||
|
|
}
|
|||
|
|
dragging = false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
$btn[0].addEventListener('pointerdown', onPointerDown, { passive: false });
|
|||
|
|
$btn[0].addEventListener('pointermove', onPointerMove, { passive: false });
|
|||
|
|
$btn[0].addEventListener('pointerup', onPointerUp, { passive: false });
|
|||
|
|
$btn[0].addEventListener('pointercancel', onPointerUp, { passive: false });
|
|||
|
|
|
|||
|
|
floatBtnResizeHandler = () => {
|
|||
|
|
const pos = readPos();
|
|||
|
|
applyDocked(pos?.side || 'right', Number.isFinite(pos?.topRatio) ? pos.topRatio : 0.5);
|
|||
|
|
};
|
|||
|
|
window.addEventListener('resize', floatBtnResizeHandler);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function removeFloatingButton() {
|
|||
|
|
$('#xiaobaix-fw-float-btn').remove();
|
|||
|
|
if (floatBtnResizeHandler) {
|
|||
|
|
window.removeEventListener('resize', floatBtnResizeHandler);
|
|||
|
|
floatBtnResizeHandler = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 初始化和清理
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function initFourthWall() {
|
|||
|
|
try { xbLog.info('fourthWall', 'initFourthWall'); } catch {}
|
|||
|
|
const settings = getSettings();
|
|||
|
|
if (!settings.fourthWall?.enabled) return;
|
|||
|
|
|
|||
|
|
createFloatingButton();
|
|||
|
|
initCommentary();
|
|||
|
|
clearExpiredCache();
|
|||
|
|
initMessageEnhancer();
|
|||
|
|
|
|||
|
|
events.on(event_types.CHAT_CHANGED, () => {
|
|||
|
|
cancelGeneration();
|
|||
|
|
currentLoadedChatId = null;
|
|||
|
|
pendingFrameMessages = [];
|
|||
|
|
if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) hideOverlay();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function fourthWallCleanup() {
|
|||
|
|
try { xbLog.info('fourthWall', 'fourthWallCleanup'); } catch {}
|
|||
|
|
events.cleanup();
|
|||
|
|
cleanupCommentary();
|
|||
|
|
removeFloatingButton();
|
|||
|
|
hideOverlay();
|
|||
|
|
cancelGeneration();
|
|||
|
|
cleanupMessageEnhancer();
|
|||
|
|
frameReady = false;
|
|||
|
|
pendingFrameMessages = [];
|
|||
|
|
overlayCreated = false;
|
|||
|
|
currentLoadedChatId = null;
|
|||
|
|
pendingPingId = null;
|
|||
|
|
|
|||
|
|
if (visibilityHandler) {
|
|||
|
|
document.removeEventListener('visibilitychange', visibilityHandler);
|
|||
|
|
visibilityHandler = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
$('#xiaobaix-fourth-wall-overlay').remove();
|
|||
|
|
window.removeEventListener('message', handleFrameMessage);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export { initFourthWall, fourthWallCleanup, showOverlay as showFourthWallPopup };
|
|||
|
|
|
|||
|
|
if (typeof window !== 'undefined') {
|
|||
|
|
window.fourthWallCleanup = fourthWallCleanup;
|
|||
|
|
window.showFourthWallPopup = showOverlay;
|
|||
|
|
|
|||
|
|
document.addEventListener('xiaobaixEnabledChanged', e => {
|
|||
|
|
if (e?.detail?.enabled === false) {
|
|||
|
|
try { fourthWallCleanup(); } catch {}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
}
|