Sync upstream: 四次元壁鉴权模式、修复世界推演
This commit is contained in:
@@ -2,9 +2,7 @@ export function getTrustedOrigin() {
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
export function getIframeTargetOrigin(iframe) {
|
||||
const sandbox = iframe?.getAttribute?.('sandbox') || '';
|
||||
if (sandbox && !sandbox.includes('allow-same-origin')) return 'null';
|
||||
export function getIframeTargetOrigin() {
|
||||
return getTrustedOrigin();
|
||||
}
|
||||
|
||||
|
||||
10
index.js
10
index.js
@@ -31,7 +31,6 @@ import { initEnaPlanner } from "./modules/ena-planner/ena-planner.js";
|
||||
|
||||
extension_settings[EXT_ID] = extension_settings[EXT_ID] || {
|
||||
enabled: true,
|
||||
sandboxMode: false,
|
||||
recorded: { enabled: true },
|
||||
templateEditor: { enabled: true, characterBindings: {} },
|
||||
tasks: { enabled: true, globalTasks: [], processedMessages: [], character_allowed_tasks: [] },
|
||||
@@ -271,7 +270,7 @@ async function waitForElement(selector, root = document, timeout = 10000) {
|
||||
|
||||
function toggleSettingsControls(enabled) {
|
||||
const controls = [
|
||||
'xiaobaix_sandbox', 'xiaobaix_recorded_enabled', 'xiaobaix_preview_enabled',
|
||||
'xiaobaix_recorded_enabled', 'xiaobaix_preview_enabled',
|
||||
'scheduled_tasks_enabled', 'xiaobaix_template_enabled',
|
||||
'xiaobaix_immersive_enabled', 'xiaobaix_fourth_wall_enabled',
|
||||
'xiaobaix_audio_enabled', 'xiaobaix_variables_panel_enabled',
|
||||
@@ -380,12 +379,6 @@ async function setupSettings() {
|
||||
|
||||
if (!settings.enabled) toggleSettingsControls(false);
|
||||
|
||||
$("#xiaobaix_sandbox").prop("checked", settings.sandboxMode).on("change", async function () {
|
||||
if (!isXiaobaixEnabled) return;
|
||||
settings.sandboxMode = $(this).prop("checked");
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
const moduleConfigs = [
|
||||
{ id: 'xiaobaix_recorded_enabled', key: 'recorded' },
|
||||
{ id: 'xiaobaix_immersive_enabled', key: 'immersive', init: initImmersiveMode },
|
||||
@@ -532,7 +525,6 @@ async function setupSettings() {
|
||||
}
|
||||
ON.forEach(k => setChecked(MAP[k], true));
|
||||
OFF.forEach(k => setChecked(MAP[k], false));
|
||||
setChecked('xiaobaix_sandbox', false);
|
||||
setChecked('xiaobaix_use_blob', false);
|
||||
setChecked('Wrapperiframe', true);
|
||||
try { saveSettingsDebounced(); } catch (e) {}
|
||||
|
||||
@@ -480,16 +480,7 @@ html, body {
|
||||
<div class="fw-settings-row">
|
||||
<div class="fw-field">
|
||||
<input type="checkbox" id="voice-enabled">
|
||||
<label for="voice-enabled">允许语音</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>
|
||||
<label for="voice-enabled">允许语音(使用 TTS 模块音色)</label>
|
||||
</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);
|
||||
}
|
||||
|
||||
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 = `
|
||||
<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() {
|
||||
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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = $(`
|
||||
<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>
|
||||
@@ -792,13 +840,13 @@ function createOverlay() {
|
||||
</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 });
|
||||
@@ -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 = $(`
|
||||
<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>
|
||||
</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)' }); });
|
||||
|
||||
|
||||
$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();
|
||||
@@ -911,7 +956,7 @@ function createFloatingButton() {
|
||||
if (e.button !== undefined && e.button !== 0) return;
|
||||
const btn = e.currentTarget;
|
||||
pointerId = e.pointerId;
|
||||
try { btn.setPointerCapture(pointerId); } catch {}
|
||||
try { btn.setPointerCapture(pointerId); } catch { }
|
||||
const rect = btn.getBoundingClientRect();
|
||||
startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top;
|
||||
dragging = false;
|
||||
@@ -937,7 +982,7 @@ function createFloatingButton() {
|
||||
const onPointerUp = (e) => {
|
||||
if (pointerId === null || e.pointerId !== pointerId) return;
|
||||
const btn = e.currentTarget;
|
||||
try { btn.releasePointerCapture(pointerId); } catch {}
|
||||
try { btn.releasePointerCapture(pointerId); } catch { }
|
||||
pointerId = null;
|
||||
btn.style.transition = '';
|
||||
const rect = btn.getBoundingClientRect();
|
||||
@@ -976,20 +1021,20 @@ function removeFloatingButton() {
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 初始化和清理
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ════════════════════════════════════════════
|
||||
// Init & Cleanup
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
function initFourthWall() {
|
||||
try { xbLog.info('fourthWall', 'initFourthWall'); } catch {}
|
||||
try { xbLog.info('fourthWall', 'initFourthWall'); } catch { }
|
||||
const settings = getSettings();
|
||||
if (!settings.fourthWall?.enabled) return;
|
||||
|
||||
|
||||
createFloatingButton();
|
||||
initCommentary();
|
||||
clearExpiredCache();
|
||||
clearExpiredCache();
|
||||
initMessageEnhancer();
|
||||
|
||||
|
||||
events.on(event_types.CHAT_CHANGED, () => {
|
||||
cancelGeneration();
|
||||
currentLoadedChatId = null;
|
||||
@@ -999,24 +1044,26 @@ function initFourthWall() {
|
||||
}
|
||||
|
||||
function fourthWallCleanup() {
|
||||
try { xbLog.info('fourthWall', 'fourthWallCleanup'); } catch {}
|
||||
try { xbLog.info('fourthWall', 'fourthWallCleanup'); } catch { }
|
||||
events.cleanup();
|
||||
cleanupCommentary();
|
||||
removeFloatingButton();
|
||||
hideOverlay();
|
||||
cancelGeneration();
|
||||
cleanupMessageEnhancer();
|
||||
stopCurrentVoice();
|
||||
currentVoiceRequestId = null;
|
||||
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);
|
||||
}
|
||||
@@ -1026,10 +1073,10 @@ 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 {}
|
||||
try { fourthWallCleanup(); } catch { }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 消息楼层增强器
|
||||
// Message Floor Enhancer
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
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 { generateImage, clearQueue } from "./fw-image.js";
|
||||
import {
|
||||
synthesizeSpeech,
|
||||
loadVoices,
|
||||
VALID_EMOTIONS,
|
||||
DEFAULT_VOICE,
|
||||
DEFAULT_SPEED
|
||||
} from "./fw-voice.js";
|
||||
import { synthesizeAndPlay, stopCurrent as stopCurrentVoice } from "./fw-voice-runtime.js";
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 状态
|
||||
// ════════════════════════════════════════════
|
||||
// State
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
const events = createModuleEvents('messageEnhancer');
|
||||
const CSS_INJECTED_KEY = 'xb-me-css-injected';
|
||||
|
||||
let currentAudio = null;
|
||||
let imageObserver = null;
|
||||
let novelDrawObserver = null;
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 初始化与清理
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// ════════════════════════════════════════════
|
||||
// Init & Cleanup
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
export async function initMessageEnhancer() {
|
||||
const settings = extension_settings[EXT_ID];
|
||||
if (!settings?.fourthWall?.enabled) return;
|
||||
|
||||
xbLog.info('messageEnhancer', '初始化消息增强器');
|
||||
|
||||
|
||||
xbLog.info('messageEnhancer', 'init message enhancer');
|
||||
|
||||
injectStyles();
|
||||
await loadVoices();
|
||||
initImageObserver();
|
||||
initNovelDrawObserver();
|
||||
|
||||
|
||||
events.on(event_types.CHAT_CHANGED, () => {
|
||||
clearQueue();
|
||||
setTimeout(processAllMessages, 150);
|
||||
});
|
||||
|
||||
|
||||
events.on(event_types.MESSAGE_RECEIVED, handleMessageChange);
|
||||
events.on(event_types.USER_MESSAGE_RENDERED, handleMessageChange);
|
||||
events.on(event_types.MESSAGE_EDITED, handleMessageChange);
|
||||
events.on(event_types.MESSAGE_UPDATED, handleMessageChange);
|
||||
events.on(event_types.MESSAGE_SWIPED, handleMessageChange);
|
||||
|
||||
|
||||
events.on(event_types.GENERATION_STOPPED, () => setTimeout(processAllMessages, 150));
|
||||
events.on(event_types.GENERATION_ENDED, () => setTimeout(processAllMessages, 150));
|
||||
|
||||
|
||||
processAllMessages();
|
||||
}
|
||||
|
||||
export function cleanupMessageEnhancer() {
|
||||
xbLog.info('messageEnhancer', '清理消息增强器');
|
||||
|
||||
xbLog.info('messageEnhancer', 'cleanup message enhancer');
|
||||
|
||||
events.cleanup();
|
||||
clearQueue();
|
||||
|
||||
|
||||
stopCurrentVoice();
|
||||
|
||||
if (imageObserver) {
|
||||
imageObserver.disconnect();
|
||||
imageObserver = null;
|
||||
}
|
||||
|
||||
|
||||
if (novelDrawObserver) {
|
||||
novelDrawObserver.disconnect();
|
||||
novelDrawObserver = null;
|
||||
}
|
||||
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// NovelDraw 兼容
|
||||
// ════════════════════════════════════════════
|
||||
// NovelDraw Compat
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function initNovelDrawObserver() {
|
||||
if (novelDrawObserver) return;
|
||||
|
||||
|
||||
const chat = document.getElementById('chat');
|
||||
if (!chat) {
|
||||
setTimeout(initNovelDrawObserver, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
let debounceTimer = null;
|
||||
const pendingTexts = new Set();
|
||||
|
||||
|
||||
novelDrawObserver = new MutationObserver((mutations) => {
|
||||
const settings = extension_settings[EXT_ID];
|
||||
if (!settings?.fourthWall?.enabled) return;
|
||||
|
||||
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
|
||||
const hasNdImg = node.classList?.contains('xb-nd-img') || node.querySelector?.('.xb-nd-img');
|
||||
if (!hasNdImg) continue;
|
||||
|
||||
|
||||
const mesText = node.closest('.mes_text');
|
||||
if (mesText && hasUnrenderedVoice(mesText)) {
|
||||
pendingTexts.add(mesText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (pendingTexts.size > 0 && !debounceTimer) {
|
||||
debounceTimer = setTimeout(() => {
|
||||
pendingTexts.forEach(mesText => {
|
||||
@@ -125,7 +114,7 @@ function initNovelDrawObserver() {
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
novelDrawObserver.observe(chat, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
@@ -135,15 +124,15 @@ function hasUnrenderedVoice(mesText) {
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 事件处理
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Event Handlers
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
function handleMessageChange(data) {
|
||||
setTimeout(() => {
|
||||
const messageId = typeof data === 'object'
|
||||
? (data.messageId ?? data.id ?? data.index ?? data.mesId)
|
||||
const messageId = typeof data === 'object'
|
||||
? (data.messageId ?? data.id ?? data.index ?? data.mesId)
|
||||
: data;
|
||||
|
||||
|
||||
if (Number.isFinite(messageId)) {
|
||||
const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`);
|
||||
if (mesText) enhanceMessageContent(mesText);
|
||||
@@ -160,12 +149,12 @@ function processAllMessages() {
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 图片观察器
|
||||
// Image Observer
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function initImageObserver() {
|
||||
if (imageObserver) return;
|
||||
|
||||
|
||||
imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) return;
|
||||
@@ -180,12 +169,12 @@ function initImageObserver() {
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 样式注入
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// Style Injection
|
||||
// ════════════════════════════════════════════
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById(CSS_INJECTED_KEY)) return;
|
||||
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = CSS_INJECTED_KEY;
|
||||
style.textContent = `
|
||||
@@ -251,46 +240,46 @@ function injectStyles() {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 内容增强
|
||||
// ════════════════════════════════════════════
|
||||
// Content Enhancement
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function enhanceMessageContent(container) {
|
||||
if (!container) return;
|
||||
|
||||
|
||||
// Rewrites already-rendered message HTML; no new HTML source is introduced here.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
const html = container.innerHTML;
|
||||
let enhanced = html;
|
||||
let hasChanges = false;
|
||||
|
||||
|
||||
enhanced = enhanced.replace(/\[(?:img|图片)\s*:\s*([^\]]+)\]/gi, (match, inner) => {
|
||||
const tags = parseImageToken(inner);
|
||||
if (!tags) return match;
|
||||
hasChanges = true;
|
||||
return `<div class="xb-img-slot" data-tags="${encodeURIComponent(tags)}"></div>`;
|
||||
});
|
||||
|
||||
|
||||
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:([^:]*):([^\]]+)\]/gi, (match, emotionRaw, voiceText) => {
|
||||
const txt = voiceText.trim();
|
||||
if (!txt) return match;
|
||||
hasChanges = true;
|
||||
return createVoiceBubbleHTML(txt, (emotionRaw || '').trim().toLowerCase());
|
||||
});
|
||||
|
||||
|
||||
enhanced = enhanced.replace(/\[(?:voice|语音)\s*:\s*([^\]:]+)\]/gi, (match, voiceText) => {
|
||||
const txt = voiceText.trim();
|
||||
if (!txt) return match;
|
||||
hasChanges = true;
|
||||
return createVoiceBubbleHTML(txt, '');
|
||||
});
|
||||
|
||||
|
||||
if (hasChanges) {
|
||||
// Replaces existing message HTML with enhanced tokens only.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
container.innerHTML = enhanced;
|
||||
}
|
||||
|
||||
|
||||
hydrateImageSlots(container);
|
||||
hydrateVoiceSlots(container);
|
||||
}
|
||||
@@ -313,67 +302,60 @@ function escapeHtml(text) {
|
||||
return String(text || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 图片处理
|
||||
// ════════════════════════════════════════════
|
||||
// Image Handling
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function hydrateImageSlots(container) {
|
||||
container.querySelectorAll('.xb-img-slot').forEach(slot => {
|
||||
if (slot.dataset.observed === '1') return;
|
||||
slot.dataset.observed = '1';
|
||||
|
||||
|
||||
if (!slot.dataset.loaded && !slot.dataset.loading && !slot.querySelector('img')) {
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
|
||||
}
|
||||
|
||||
|
||||
imageObserver?.observe(slot);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadImage(slot, tags) {
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-spinner"></i> 检查缓存...</div>`;
|
||||
|
||||
|
||||
try {
|
||||
const base64 = await generateImage(tags, (status, position, delay) => {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position}</div>`;
|
||||
break;
|
||||
case 'generating':
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-palette"></i> 生成中${position > 0 ? ` (${position} 排队)` : ''}...</div>`;
|
||||
break;
|
||||
case 'waiting':
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-loading"><i class="fa-solid fa-clock"></i> 排队中 #${position} (${delay}s)</div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
if (base64) renderImage(slot, base64, false);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
slot.dataset.loaded = '1';
|
||||
slot.dataset.loading = '';
|
||||
|
||||
|
||||
if (err.message === '队列已清空') {
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-placeholder"><i class="fa-regular fa-image"></i><span>滚动加载</span></div>`;
|
||||
slot.dataset.loading = '';
|
||||
slot.dataset.observed = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Template-only UI markup with escaped error text.
|
||||
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = `<div class="xb-img-error"><i class="fa-solid fa-exclamation-triangle"></i><div>${escapeHtml(err?.message || '失败')}</div><button class="xb-img-retry" data-tags="${encodeURIComponent(tags)}">重试</button></div>`;
|
||||
bindRetryButton(slot);
|
||||
@@ -383,21 +365,19 @@ async function loadImage(slot, tags) {
|
||||
function renderImage(slot, base64, fromCache) {
|
||||
slot.dataset.loaded = '1';
|
||||
slot.dataset.loading = '';
|
||||
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = `data:image/png;base64,${base64}`;
|
||||
img.className = 'xb-generated-img';
|
||||
img.onclick = () => window.open(img.src, '_blank');
|
||||
|
||||
// Template-only UI markup.
|
||||
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
slot.innerHTML = '';
|
||||
slot.appendChild(img);
|
||||
|
||||
|
||||
if (fromCache) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'xb-img-badge';
|
||||
// Template-only UI markup.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
badge.innerHTML = '<i class="fa-solid fa-bolt"></i>';
|
||||
slot.appendChild(badge);
|
||||
@@ -417,65 +397,60 @@ function bindRetryButton(slot) {
|
||||
};
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 语音处理
|
||||
// ════════════════════════════════════════════
|
||||
// Voice Handling
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function hydrateVoiceSlots(container) {
|
||||
container.querySelectorAll('.xb-voice-bubble').forEach(bubble => {
|
||||
if (bubble.dataset.bound === '1') return;
|
||||
bubble.dataset.bound = '1';
|
||||
|
||||
|
||||
const text = decodeURIComponent(bubble.dataset.text || '');
|
||||
const emotion = bubble.dataset.emotion || '';
|
||||
if (!text) return;
|
||||
|
||||
|
||||
bubble.onclick = async (e) => {
|
||||
e.stopPropagation();
|
||||
if (bubble.classList.contains('loading')) return;
|
||||
|
||||
if (bubble.classList.contains('playing') && currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio = null;
|
||||
|
||||
if (bubble.classList.contains('playing')) {
|
||||
stopCurrentVoice();
|
||||
bubble.classList.remove('playing');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentAudio) {
|
||||
currentAudio.pause();
|
||||
currentAudio = null;
|
||||
}
|
||||
document.querySelectorAll('.xb-voice-bubble.playing').forEach(el => el.classList.remove('playing'));
|
||||
|
||||
await playVoice(text, emotion, bubble);
|
||||
|
||||
// Clear other bubble states
|
||||
document.querySelectorAll('.xb-voice-bubble.playing, .xb-voice-bubble.loading').forEach(el => {
|
||||
el.classList.remove('playing', 'loading');
|
||||
});
|
||||
|
||||
bubble.classList.add('loading');
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
132
modules/fourth-wall/fw-voice-runtime.js
Normal file
132
modules/fourth-wall/fw-voice-runtime.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 语音模块 - TTS 合成服务
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
// 语音模块 - 常量与提示词指南
|
||||
// ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export const TTS_WORKER_URL = 'https://hstts.velure.codes';
|
||||
export const DEFAULT_VOICE = 'female_1';
|
||||
export const DEFAULT_SPEED = 1.0;
|
||||
|
||||
@@ -11,96 +10,9 @@ export const EMOTION_ICONS = {
|
||||
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 = `## 模拟语音
|
||||
如需发送语音消息,使用以下格式:
|
||||
@@ -129,4 +41,4 @@ export const VOICE_GUIDELINE = `## 模拟语音
|
||||
[voice:happy:太好了!终于见到你了~]
|
||||
[voice::——啊!——不要!]
|
||||
|
||||
注意:voice部分需要在<msg>内`;
|
||||
注意:voice部分需要在<msg>内`;
|
||||
@@ -358,10 +358,6 @@ export function renderHtmlInIframe(htmlContent, container, preElement) {
|
||||
iframe.setAttribute('scrolling', 'no');
|
||||
iframe.loading = 'eager';
|
||||
|
||||
if (settings.sandboxMode) {
|
||||
iframe.setAttribute('sandbox', 'allow-scripts');
|
||||
}
|
||||
|
||||
const wrapper = getOrCreateWrapper(preElement);
|
||||
wrapper.querySelectorAll('.xiaobaix-iframe').forEach(old => {
|
||||
try { old.src = 'about:blank'; } catch (e) {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -236,7 +236,7 @@ All checks passed. Beginning incremental extraction...
|
||||
{
|
||||
"mindful_prelude":`,
|
||||
|
||||
userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容
|
||||
userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容,3000字以内
|
||||
</Chat_History>`,
|
||||
|
||||
assistantPrefill: JSON_PREFILL
|
||||
|
||||
@@ -289,6 +289,33 @@
|
||||
<!-- Trigger Settings -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section-title">总结设置</div>
|
||||
|
||||
<!-- Filter Rules -->
|
||||
<div class="settings-collapse" id="filter-rules-collapse"
|
||||
style="margin-top:0; margin-bottom: 16px;">
|
||||
<div class="settings-collapse-header" id="filter-rules-toggle">
|
||||
<span>文本过滤规则 · <strong id="filter-rules-count">0</strong> 条</span>
|
||||
<svg class="collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="settings-collapse-content hidden" id="filter-rules-content"
|
||||
style="border-left: 1px solid var(--bdr); border-right: 1px solid var(--bdr); border-bottom: 1px solid var(--bdr); border-radius: 0 0 6px 6px; margin-top: -2px;">
|
||||
<div class="filter-rules-header">
|
||||
<p class="settings-hint" style="margin:0">过滤干扰内容(如思考标签)</p>
|
||||
<button class="btn btn-sm btn-add" id="btn-add-filter-rule">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div id="filter-rules-list" class="filter-rules-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-row">
|
||||
<div class="settings-field">
|
||||
<label>注入角色</label>
|
||||
@@ -334,32 +361,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Rules -->
|
||||
<div class="settings-collapse" id="filter-rules-collapse"
|
||||
style="margin-top:0; margin-bottom: 16px;">
|
||||
<div class="settings-collapse-header" id="filter-rules-toggle">
|
||||
<span>文本过滤规则 · <strong id="filter-rules-count">0</strong> 条</span>
|
||||
<svg class="collapse-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2">
|
||||
<polyline points="6 9 12 15 18 9"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="settings-collapse-content hidden" id="filter-rules-content"
|
||||
style="border-left: 1px solid var(--bdr); border-right: 1px solid var(--bdr); border-bottom: 1px solid var(--bdr); border-radius: 0 0 6px 6px; margin-top: -2px;">
|
||||
<div class="filter-rules-header">
|
||||
<p class="settings-hint" style="margin:0">过滤干扰内容(如思考标签)</p>
|
||||
<button class="btn btn-sm btn-add" id="btn-add-filter-rule">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg> 添加
|
||||
</button>
|
||||
</div>
|
||||
<div id="filter-rules-list" class="filter-rules-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Force Insert with wrapper options -->
|
||||
<div class="settings-checkbox-group">
|
||||
<label class="settings-checkbox">
|
||||
|
||||
@@ -136,18 +136,13 @@ class StreamingGeneration {
|
||||
const opts = { ...baseOptions, ...this.resolveCurrentApiAndModel(baseOptions) };
|
||||
|
||||
const modelLower = String(opts.model || '').toLowerCase();
|
||||
const isClaudeThinkingModel =
|
||||
modelLower.includes('claude') &&
|
||||
modelLower.includes('thinking') &&
|
||||
!modelLower.includes('nothinking');
|
||||
const isClaudeModel = modelLower.includes('claude');
|
||||
|
||||
if (isClaudeThinkingModel && Array.isArray(messages) && messages.length > 0) {
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
console.log('[xbgen] Claude Thinking 模型:移除 assistant prefill');
|
||||
messages.pop();
|
||||
if (isClaudeModel && Array.isArray(messages) && messages.length > 0) {
|
||||
if (this._convertTrailingAssistantToSystem(messages)) {
|
||||
console.log('[xbgen] Claude model: converted trailing assistant prefill to system message');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const source = {
|
||||
openai: chat_completion_sources.OPENAI,
|
||||
@@ -292,10 +287,14 @@ class StreamingGeneration {
|
||||
const logSendRequestError = (err, streamMode) => {
|
||||
if (err?.name !== 'AbortError') {
|
||||
const safeApiUrl = String(cmdApiUrl || reverseProxy || oai_settings?.custom_url || '').trim();
|
||||
const status = this._extractHttpStatus(err);
|
||||
const isRateLimit = status === 429;
|
||||
try {
|
||||
xbLog.error('streamingGeneration', 'sendRequest failed', {
|
||||
message: err?.message || String(err),
|
||||
name: err?.name,
|
||||
status,
|
||||
isRateLimit,
|
||||
stream: !!streamMode,
|
||||
api: String(opts.api || ''),
|
||||
model,
|
||||
@@ -304,6 +303,12 @@ class StreamingGeneration {
|
||||
});
|
||||
} catch {}
|
||||
console.error('[xbgen:callAPI] sendRequest failed:', err);
|
||||
if (status) {
|
||||
console.error(`[xbgen:callAPI] HTTP status=${status} stream=${!!streamMode} api=${String(opts.api || '')} model=${model}`);
|
||||
}
|
||||
if (isRateLimit) {
|
||||
console.error('[xbgen:callAPI] Rate limited (429). Check provider RPM/TPM and concurrent requests.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -470,6 +475,14 @@ class StreamingGeneration {
|
||||
return String(session.text || '');
|
||||
}
|
||||
|
||||
const httpStatus = this._extractHttpStatus(err);
|
||||
if (!err?.error && httpStatus) {
|
||||
err.error = {
|
||||
code: String(httpStatus),
|
||||
message: String(err?.message || `HTTP ${httpStatus}`),
|
||||
};
|
||||
}
|
||||
|
||||
console.error('[StreamingGeneration] Generation error:', err);
|
||||
console.error('[StreamingGeneration] error.error =', err?.error);
|
||||
try { xbLog.error('streamingGeneration', `processGeneration error sid=${session.id}`, err); } catch {}
|
||||
@@ -805,6 +818,30 @@ class StreamingGeneration {
|
||||
return '';
|
||||
}
|
||||
|
||||
_convertTrailingAssistantToSystem(messages) {
|
||||
if (!Array.isArray(messages) || messages.length < 1) return false;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role !== 'assistant') return false;
|
||||
|
||||
const assistantText = this._extractTextFromMessage(lastMsg).trim();
|
||||
const systemText = assistantText ? `Assistant:\n${assistantText}` : 'Assistant:';
|
||||
lastMsg.role = 'system';
|
||||
lastMsg.content = systemText;
|
||||
return true;
|
||||
}
|
||||
|
||||
_extractHttpStatus(err) {
|
||||
const direct = Number(err?.status || err?.statusCode || err?.response?.status);
|
||||
if (Number.isFinite(direct) && direct >= 100 && direct <= 599) return direct;
|
||||
const msg = String(err?.message || '');
|
||||
const m = msg.match(/\bstatus\s+(\d{3})\b/i);
|
||||
if (m) {
|
||||
const s = Number(m[1]);
|
||||
if (Number.isFinite(s)) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
_getLastMessagesSnapshot() {
|
||||
const ctx = getContext();
|
||||
const list = Array.isArray(ctx?.chat) ? ctx.chat : [];
|
||||
|
||||
@@ -671,8 +671,6 @@ class IframeManager {
|
||||
static writeContentToIframe(iframe, content) {
|
||||
try {
|
||||
const html = buildWrappedHtml(content);
|
||||
const sbox = !!(extension_settings && extension_settings[EXT_ID] && extension_settings[EXT_ID].sandboxMode);
|
||||
if (sbox) iframe.setAttribute('sandbox', 'allow-scripts allow-modals');
|
||||
iframe.srcdoc = html;
|
||||
const probe = () => {
|
||||
const targetOrigin = getIframeTargetOrigin(iframe);
|
||||
|
||||
@@ -1301,6 +1301,7 @@ export async function initTts() {
|
||||
openSettings,
|
||||
closeSettings,
|
||||
player,
|
||||
synthesize: synthesizeForExternal,
|
||||
speak: async (text, options = {}) => {
|
||||
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() {
|
||||
moduleInitialized = false;
|
||||
|
||||
|
||||
541
settings.html
541
settings.html
@@ -1,243 +1,233 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=ZCOOL+KuaiLe&family=ZCOOL+XiaoWei&display=swap" rel="stylesheet">
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b>小白X</b>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<div class="littlewhitebox settings-grid">
|
||||
<div class="settings-menu-vertical">
|
||||
<div class="menu-tab active" data-target="js-memory" style="border-bottom:1px solid #303030;"><span class="vertical-text">渲染交互</span></div>
|
||||
<div class="menu-tab" data-target="task" style="border-bottom:1px solid #303030;"><span class="vertical-text">循环任务</span></div>
|
||||
<div class="menu-tab" data-target="template" style="border-bottom:1px solid #303030;"><span class="vertical-text">数据互动</span></div>
|
||||
<div class="menu-tab" data-target="wallhaven" style="border-bottom:1px solid #303030;"><span class="vertical-text">辅助工具</span></div>
|
||||
<div class="menu-tab" data-target="ena-planner" style="border-bottom:1px solid #303030;"><span class="vertical-text">剧情规划</span></div>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="js-memory settings-section" style="display:block;">
|
||||
<div class="section-divider" style="margin-bottom:0;margin-top:0;">总开关</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<input type="checkbox" id="xiaobaix_enabled" />
|
||||
<label for="xiaobaix_enabled" class="has-tooltip" data-tooltip="渲染被```包裹的html、!DOCTYPE、script的代码块内容为交互式界面
|
||||
|
||||
提供STscript(command)异步函数执行酒馆命令:
|
||||
|
||||
await STscript('/echo 你好世界!')">启用小白X</label>
|
||||
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<input type="checkbox" id="xiaobaix_render_enabled" />
|
||||
<label for="xiaobaix_render_enabled" class="has-tooltip" data-tooltip="控制代码块转换为iframe渲染的功能
|
||||
关闭后将清理所有已渲染的iframe">渲染开关</label>
|
||||
<label for="xiaobaix_max_rendered" style="margin-left:8px;">渲染楼层</label>
|
||||
<input id="xiaobaix_max_rendered"
|
||||
type="number"
|
||||
class="text_pole dark-number-input"
|
||||
min="1" max="9999" step="1"
|
||||
style="width:5rem;margin-left:4px;" />
|
||||
</div>
|
||||
<div class="section-divider">渲染模式
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_sandbox" />
|
||||
<label for="xiaobaix_sandbox">沙盒模式</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_use_blob" />
|
||||
<label for="xiaobaix_use_blob" class="has-tooltip" data-tooltip="大型html适用">启用Blob渲染</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="Wrapperiframe" />
|
||||
<label for="Wrapperiframe" class="has-tooltip" data-tooltip="按需在 iframe 中注入 Wrapperiframe.js">启用封装函数</label>
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<input type="checkbox" id="xiaobaix_audio_enabled" />
|
||||
<label for="xiaobaix_audio_enabled" style="margin-top:0;">启用音频</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="section-divider">流式,非基础的渲染
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_template_enabled" />
|
||||
<label for="xiaobaix_template_enabled" class="has-tooltip" data-tooltip="流式多楼层动态渲染">启用沉浸式模板</label>
|
||||
</div>
|
||||
<div id="current_template_settings">
|
||||
<div class="template-replacer-header">
|
||||
<div class="template-replacer-title">当前角色模板设置</div>
|
||||
<div class="template-replacer-controls">
|
||||
<button id="open_template_editor" class="menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
<small>编辑模板</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-replacer-status" id="template_character_status">
|
||||
请选择一个角色
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-divider">功能说明
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<div><a href="https://docs.littlewhitebox.qzz.io/" class="download-link" target="_blank">功能文档</a></div>
|
||||
<button id="xiaobaix_reset_btn" class="menu_button menu_button_icon" type="button" title="把小白X的功能按钮重置回默认功能设定">
|
||||
<small>默认开关</small>
|
||||
</button>
|
||||
<button id="xiaobaix_xposition_btn" class="menu_button menu_button_icon" type="button" title="切换X按钮的位置,仅两种">
|
||||
<small>X按钮:右</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wallhaven settings-section" style="display:none;">
|
||||
<div class="section-divider">消息日志与拦截
|
||||
<hr class="sysHR">
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_recorded_enabled" />
|
||||
<label for="xiaobaix_recorded_enabled" class="has-tooltip" data-tooltip="每条消息添加图标,点击可看到发送给时AI的记录">Log记录</label>
|
||||
<input type="checkbox" id="xiaobaix_preview_enabled" />
|
||||
<label for="xiaobaix_preview_enabled" class="has-tooltip" data-tooltip="在聊天框显示图标,点击可拦截将发送给AI的消息并显示">Log拦截</label>
|
||||
</div>
|
||||
<div class="section-divider">视觉增强
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_immersive_enabled" />
|
||||
<label for="xiaobaix_immersive_enabled" class="has-tooltip" data-tooltip="重构界面布局与细节优化">沉浸布局显示(边框窄化)</label>
|
||||
</div>
|
||||
<div class="section-divider">Novel 画图
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_novel_draw_enabled" />
|
||||
<label for="xiaobaix_novel_draw_enabled" class="has-tooltip" data-tooltip="使用 NovelAI 为 AI 回复自动或手动生成配图,需在酒馆设置中配置 NovelAI Key">启用 Novel 画图</label>
|
||||
<button id="xiaobaix_novel_draw_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开 Novel 画图详细设置">
|
||||
<i class="fa-solid fa-palette"></i>
|
||||
<small>画图设置</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-divider">豆包 语音
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_tts_enabled" />
|
||||
<label for="xiaobaix_tts_enabled" class="has-tooltip"
|
||||
data-tooltip="AI回复渲染后自动朗读。需要先在 config.yaml 开启 enableCorsProxy: true 并重启。所有请求通过 ST 内置代理,不经过第三方。">
|
||||
启用 TTS 语音
|
||||
</label>
|
||||
<button id="xiaobaix_tts_open_settings" class="menu_button menu_button_icon"
|
||||
type="button" style="margin-left:auto;"
|
||||
title="打开 TTS 设置(音色/复刻/跳过规则)">
|
||||
<i class="fa-solid fa-microphone-lines"></i>
|
||||
<small>语音设置</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ena-planner settings-section" style="display:none;">
|
||||
<div id="ena_planner_panel">
|
||||
<!-- Ena Planner UI will be injected here by ena-planner.js -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="task settings-section" style="display:none;">
|
||||
<div class="section-divider">循环任务
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="scheduled_tasks_enabled" />
|
||||
<label for="scheduled_tasks_enabled" class="has-tooltip" data-tooltip="自动执行设定好的斜杠命令
|
||||
输入/xbqte {{任务名称}}可以手动激活任务
|
||||
导出/入角色卡时, 角色任务会随角色卡一起导出/入">启用循环任务</label>
|
||||
<div id="toggle_task_bar" class="menu_button menu_button_icon" style="margin: 0px; margin-left: auto;" title="显示/隐藏按钮栏">
|
||||
<small>按钮栏</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container task-tab-bar">
|
||||
<div class="task-tab active" data-target="global_tasks_block">全局任务<span class="task-count" id="global_task_count"></span></div>
|
||||
<div class="task-tab" data-target="character_tasks_block">角色任务<span class="task-count" id="character_task_count"></span></div>
|
||||
<div class="task-tab" data-target="preset_tasks_block">预设任务<span class="task-count" id="preset_task_count"></span></div>
|
||||
</div>
|
||||
<div class="flex-container" style="justify-content: flex-end; flex-wrap: nowrap; gap: 0px; margin-top: 10px;">
|
||||
<div id="add_global_task" class="menu_button menu_button_icon" title="添加全局任务">
|
||||
<small>+全局</small>
|
||||
</div>
|
||||
<div id="add_character_task" class="menu_button menu_button_icon" title="添加角色任务">
|
||||
<small>+角色</small>
|
||||
</div>
|
||||
<div id="add_preset_task" class="menu_button menu_button_icon" title="添加预设任务">
|
||||
<small>+预设</small>
|
||||
</div>
|
||||
<div id="cloud_tasks_button" class="menu_button menu_button_icon" title="从云端获取任务脚本">
|
||||
<i class="fa-solid fa-cloud-arrow-down"></i>
|
||||
<small>任务下载</small>
|
||||
</div>
|
||||
<div id="import_global_tasks" class="menu_button menu_button_icon" title="导入任务">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<small>导入</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="sysHR">
|
||||
<div class="task-panel-group">
|
||||
<div id="global_tasks_block" class="padding5 task-panel" data-panel="global_tasks_block">
|
||||
<small>这些任务在所有角色中的聊天都会执行</small>
|
||||
<div id="global_tasks_list" class="flex-container task-container flexFlowColumn"></div>
|
||||
</div>
|
||||
<div id="character_tasks_block" class="padding5 task-panel" data-panel="character_tasks_block" style="display:none;">
|
||||
<small>这些任务只在当前角色的聊天中执行</small>
|
||||
<div id="character_tasks_list" class="flex-container task-container flexFlowColumn"></div>
|
||||
</div>
|
||||
<div id="preset_tasks_block" class="padding5 task-panel" data-panel="preset_tasks_block" style="display:none;">
|
||||
<small>这些任务会在使用<small id="preset_tasks_hint" class="preset-task-hint">未选择</small>预设时执行</small>
|
||||
<div id="preset_tasks_list" class="flex-container task-container flexFlowColumn"></div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="import_tasks_file" accept=".json" style="display:none;" />
|
||||
</div>
|
||||
<div class="template settings-section" style="display:none;">
|
||||
<div class="section-divider">四次元壁</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_fourth_wall_enabled" />
|
||||
<label for="xiaobaix_fourth_wall_enabled" class="has-tooltip" data-tooltip="突破第四面墙,与角色进行元对话交流。悬浮按钮位于页面右侧中间。">四次元壁</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="section-divider">剧情管理</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_story_summary_enabled" />
|
||||
<label for="xiaobaix_story_summary_enabled" class="has-tooltip" data-tooltip="在消息楼层添加总结按钮,点击可打开剧情总结面板,AI分析生成关键词云、时间线、人物关系、角色弧光">剧情总结</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_story_outline_enabled" />
|
||||
<label for="xiaobaix_story_outline_enabled" class="has-tooltip" data-tooltip="在X按钮区域添加地图图标,点击可打开可视化剧情地图编辑器">小白板</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="section-divider">变量控制</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container" style="gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="checkbox" id="xiaobaix_variables_core_enabled" />
|
||||
<label for="xiaobaix_variables_core_enabled">变量管理</label>
|
||||
|
||||
<select id="xiaobaix_variables_mode" class="text_pole" style="width:auto;margin-left:8px;padding:2px 6px;">
|
||||
<option value="1.0">1.0 (plot-log)</option>
|
||||
<option value="2.0">2.0 (state)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_variables_panel_enabled" />
|
||||
<label for="xiaobaix_variables_panel_enabled">变量面板</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-drawer">
|
||||
<div class="inline-drawer-toggle inline-drawer-header">
|
||||
<b>小白X</b>
|
||||
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
|
||||
</div>
|
||||
<div class="inline-drawer-content">
|
||||
<div class="littlewhitebox settings-grid">
|
||||
<div class="settings-menu-vertical">
|
||||
<div class="menu-tab active" data-target="js-memory" style="border-bottom:1px solid #303030;"><span class="vertical-text">渲染交互</span></div>
|
||||
<div class="menu-tab" data-target="task" style="border-bottom:1px solid #303030;"><span class="vertical-text">循环任务</span></div>
|
||||
<div class="menu-tab" data-target="template" style="border-bottom:1px solid #303030;"><span class="vertical-text">数据互动</span></div>
|
||||
<div class="menu-tab" data-target="wallhaven" style="border-bottom:1px solid #303030;"><span class="vertical-text">辅助工具</span></div>
|
||||
</div>
|
||||
<div class="settings-content">
|
||||
<div class="js-memory settings-section" style="display:block;">
|
||||
<div class="section-divider" style="margin-bottom:0;margin-top:0;">总开关</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<input type="checkbox" id="xiaobaix_enabled" />
|
||||
<label for="xiaobaix_enabled" class="has-tooltip" data-tooltip="渲染被```包裹的html、!DOCTYPE、script的代码块内容为交互式界面
|
||||
|
||||
提供STscript(command)异步函数执行酒馆命令:
|
||||
|
||||
await STscript('/echo 你好世界!')">启用小白X</label>
|
||||
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<input type="checkbox" id="xiaobaix_render_enabled" />
|
||||
<label for="xiaobaix_render_enabled" class="has-tooltip" data-tooltip="控制代码块转换为iframe渲染的功能
|
||||
关闭后将清理所有已渲染的iframe">渲染开关</label>
|
||||
<label for="xiaobaix_max_rendered" style="margin-left:8px;">渲染楼层</label>
|
||||
<input id="xiaobaix_max_rendered"
|
||||
type="number"
|
||||
class="text_pole dark-number-input"
|
||||
min="1" max="9999" step="1"
|
||||
style="width:5rem;margin-left:4px;" />
|
||||
</div>
|
||||
<div class="section-divider">渲染模式
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_use_blob" />
|
||||
<label for="xiaobaix_use_blob" class="has-tooltip" data-tooltip="大型html适用">启用Blob渲染</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="Wrapperiframe" />
|
||||
<label for="Wrapperiframe" class="has-tooltip" data-tooltip="按需在 iframe 中注入 Wrapperiframe.js">启用封装函数</label>
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<input type="checkbox" id="xiaobaix_audio_enabled" />
|
||||
<label for="xiaobaix_audio_enabled" style="margin-top:0;">启用音频</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="section-divider">流式,非基础的渲染
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_template_enabled" />
|
||||
<label for="xiaobaix_template_enabled" class="has-tooltip" data-tooltip="流式多楼层动态渲染">启用沉浸式模板</label>
|
||||
</div>
|
||||
<div id="current_template_settings">
|
||||
<div class="template-replacer-header">
|
||||
<div class="template-replacer-title">当前角色模板设置</div>
|
||||
<div class="template-replacer-controls">
|
||||
<button id="open_template_editor" class="menu_button menu_button_icon">
|
||||
<i class="fa-solid fa-pen-to-square"></i>
|
||||
<small>编辑模板</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="template-replacer-status" id="template_character_status">
|
||||
请选择一个角色
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-divider">功能说明
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container alignItemsCenter" style="gap:8px;flex-wrap:wrap;">
|
||||
<div><a href="https://docs.littlewhitebox.qzz.io/" class="download-link" target="_blank">功能文档</a></div>
|
||||
<button id="xiaobaix_reset_btn" class="menu_button menu_button_icon" type="button" title="把小白X的功能按钮重置回默认功能设定">
|
||||
<small>默认开关</small>
|
||||
</button>
|
||||
<button id="xiaobaix_xposition_btn" class="menu_button menu_button_icon" type="button" title="切换X按钮的位置,仅两种">
|
||||
<small>X按钮:右</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wallhaven settings-section" style="display:none;">
|
||||
<div class="section-divider">消息日志与拦截
|
||||
<hr class="sysHR">
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_recorded_enabled" />
|
||||
<label for="xiaobaix_recorded_enabled" class="has-tooltip" data-tooltip="每条消息添加图标,点击可看到发送给时AI的记录">Log记录</label>
|
||||
<input type="checkbox" id="xiaobaix_preview_enabled" />
|
||||
<label for="xiaobaix_preview_enabled" class="has-tooltip" data-tooltip="在聊天框显示图标,点击可拦截将发送给AI的消息并显示">Log拦截</label>
|
||||
</div>
|
||||
<div class="section-divider">视觉增强
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_immersive_enabled" />
|
||||
<label for="xiaobaix_immersive_enabled" class="has-tooltip" data-tooltip="重构界面布局与细节优化">沉浸布局显示(边框窄化)</label>
|
||||
</div>
|
||||
<div class="section-divider">Novel 画图
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_novel_draw_enabled" />
|
||||
<label for="xiaobaix_novel_draw_enabled" class="has-tooltip" data-tooltip="使用 NovelAI 为 AI 回复自动或手动生成配图,需在酒馆设置中配置 NovelAI Key">启用 Novel 画图</label>
|
||||
<button id="xiaobaix_novel_draw_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开 Novel 画图详细设置">
|
||||
<i class="fa-solid fa-palette"></i>
|
||||
<small>画图设置</small>
|
||||
</button>
|
||||
</div>
|
||||
<div class="section-divider">豆包 语音
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_tts_enabled" />
|
||||
<label for="xiaobaix_tts_enabled" class="has-tooltip"
|
||||
data-tooltip="AI回复渲染后自动朗读。需要先在 config.yaml 开启 enableCorsProxy: true 并重启。所有请求通过 ST 内置代理,不经过第三方。">
|
||||
启用 TTS 语音
|
||||
</label>
|
||||
<button id="xiaobaix_tts_open_settings" class="menu_button menu_button_icon"
|
||||
type="button" style="margin-left:auto;"
|
||||
title="打开 TTS 设置(音色/复刻/跳过规则)">
|
||||
<i class="fa-solid fa-microphone-lines"></i>
|
||||
<small>语音设置</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="task settings-section" style="display:none;">
|
||||
<div class="section-divider">循环任务
|
||||
<hr class="sysHR" />
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="scheduled_tasks_enabled" />
|
||||
<label for="scheduled_tasks_enabled" class="has-tooltip" data-tooltip="自动执行设定好的斜杠命令
|
||||
输入/xbqte {{任务名称}}可以手动激活任务
|
||||
导出/入角色卡时, 角色任务会随角色卡一起导出/入">启用循环任务</label>
|
||||
<div id="toggle_task_bar" class="menu_button menu_button_icon" style="margin: 0px; margin-left: auto;" title="显示/隐藏按钮栏">
|
||||
<small>按钮栏</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container task-tab-bar">
|
||||
<div class="task-tab active" data-target="global_tasks_block">全局任务<span class="task-count" id="global_task_count"></span></div>
|
||||
<div class="task-tab" data-target="character_tasks_block">角色任务<span class="task-count" id="character_task_count"></span></div>
|
||||
<div class="task-tab" data-target="preset_tasks_block">预设任务<span class="task-count" id="preset_task_count"></span></div>
|
||||
</div>
|
||||
<div class="flex-container" style="justify-content: flex-end; flex-wrap: nowrap; gap: 0px; margin-top: 10px;">
|
||||
<div id="add_global_task" class="menu_button menu_button_icon" title="添加全局任务">
|
||||
<small>+全局</small>
|
||||
</div>
|
||||
<div id="add_character_task" class="menu_button menu_button_icon" title="添加角色任务">
|
||||
<small>+角色</small>
|
||||
</div>
|
||||
<div id="add_preset_task" class="menu_button menu_button_icon" title="添加预设任务">
|
||||
<small>+预设</small>
|
||||
</div>
|
||||
<div id="cloud_tasks_button" class="menu_button menu_button_icon" title="从云端获取任务脚本">
|
||||
<i class="fa-solid fa-cloud-arrow-down"></i>
|
||||
<small>任务下载</small>
|
||||
</div>
|
||||
<div id="import_global_tasks" class="menu_button menu_button_icon" title="导入任务">
|
||||
<i class="fa-solid fa-download"></i>
|
||||
<small>导入</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="sysHR">
|
||||
<div class="task-panel-group">
|
||||
<div id="global_tasks_block" class="padding5 task-panel" data-panel="global_tasks_block">
|
||||
<small>这些任务在所有角色中的聊天都会执行</small>
|
||||
<div id="global_tasks_list" class="flex-container task-container flexFlowColumn"></div>
|
||||
</div>
|
||||
<div id="character_tasks_block" class="padding5 task-panel" data-panel="character_tasks_block" style="display:none;">
|
||||
<small>这些任务只在当前角色的聊天中执行</small>
|
||||
<div id="character_tasks_list" class="flex-container task-container flexFlowColumn"></div>
|
||||
</div>
|
||||
<div id="preset_tasks_block" class="padding5 task-panel" data-panel="preset_tasks_block" style="display:none;">
|
||||
<small>这些任务会在使用<small id="preset_tasks_hint" class="preset-task-hint">未选择</small>预设时执行</small>
|
||||
<div id="preset_tasks_list" class="flex-container task-container flexFlowColumn"></div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="import_tasks_file" accept=".json" style="display:none;" />
|
||||
</div>
|
||||
<div class="template settings-section" style="display:none;">
|
||||
<div class="section-divider">四次元壁</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_fourth_wall_enabled" />
|
||||
<label for="xiaobaix_fourth_wall_enabled" class="has-tooltip" data-tooltip="突破第四面墙,与角色进行元对话交流。悬浮按钮位于页面右侧中间。">四次元壁</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="section-divider">剧情管理</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_story_summary_enabled" />
|
||||
<label for="xiaobaix_story_summary_enabled" class="has-tooltip" data-tooltip="在消息楼层添加总结按钮,点击可打开剧情总结面板,AI分析生成关键词云、时间线、人物关系、角色弧光">剧情总结</label>
|
||||
</div>
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_story_outline_enabled" />
|
||||
<label for="xiaobaix_story_outline_enabled" class="has-tooltip" data-tooltip="在X按钮区域添加地图图标,点击可打开可视化剧情地图编辑器">小白板</label>
|
||||
</div>
|
||||
<br>
|
||||
<div class="section-divider">变量控制</div>
|
||||
<hr class="sysHR" />
|
||||
<div class="flex-container" style="gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="checkbox" id="xiaobaix_variables_core_enabled" />
|
||||
<label for="xiaobaix_variables_core_enabled">变量管理</label>
|
||||
|
||||
<select id="xiaobaix_variables_mode" class="text_pole" style="width:auto;margin-left:8px;padding:2px 6px;">
|
||||
<option value="1.0">1.0 (plot-log)</option>
|
||||
<option value="2.0">2.0 (state)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex-container">
|
||||
<input type="checkbox" id="xiaobaix_variables_panel_enabled" />
|
||||
<label for="xiaobaix_variables_panel_enabled">变量面板</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.littlewhitebox,
|
||||
@@ -516,28 +506,27 @@
|
||||
});
|
||||
}
|
||||
const EXT_ID = 'LittleWhiteBox';
|
||||
const KEY_TO_CHECKBOX = {
|
||||
recorded: 'xiaobaix_recorded_enabled',
|
||||
immersive: 'xiaobaix_immersive_enabled',
|
||||
preview: 'xiaobaix_preview_enabled',
|
||||
scriptAssistant: 'xiaobaix_script_assistant',
|
||||
tasks: 'scheduled_tasks_enabled',
|
||||
templateEditor: 'xiaobaix_template_enabled',
|
||||
fourthWall: 'xiaobaix_fourth_wall_enabled',
|
||||
variablesPanel: 'xiaobaix_variables_panel_enabled',
|
||||
variablesCore: 'xiaobaix_variables_core_enabled',
|
||||
audio: 'xiaobaix_audio_enabled',
|
||||
storySummary: 'xiaobaix_story_summary_enabled',
|
||||
tts: 'xiaobaix_tts_enabled',
|
||||
storyOutline: 'xiaobaix_story_outline_enabled',
|
||||
sandboxMode: 'xiaobaix_sandbox',
|
||||
useBlob: 'xiaobaix_use_blob',
|
||||
wrapperIframe: 'Wrapperiframe',
|
||||
renderEnabled: 'xiaobaix_render_enabled',
|
||||
};
|
||||
const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
|
||||
const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts'];
|
||||
const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts'];
|
||||
const KEY_TO_CHECKBOX = {
|
||||
recorded: 'xiaobaix_recorded_enabled',
|
||||
immersive: 'xiaobaix_immersive_enabled',
|
||||
preview: 'xiaobaix_preview_enabled',
|
||||
scriptAssistant: 'xiaobaix_script_assistant',
|
||||
tasks: 'scheduled_tasks_enabled',
|
||||
templateEditor: 'xiaobaix_template_enabled',
|
||||
fourthWall: 'xiaobaix_fourth_wall_enabled',
|
||||
variablesPanel: 'xiaobaix_variables_panel_enabled',
|
||||
variablesCore: 'xiaobaix_variables_core_enabled',
|
||||
audio: 'xiaobaix_audio_enabled',
|
||||
storySummary: 'xiaobaix_story_summary_enabled',
|
||||
tts: 'xiaobaix_tts_enabled',
|
||||
storyOutline: 'xiaobaix_story_outline_enabled',
|
||||
useBlob: 'xiaobaix_use_blob',
|
||||
wrapperIframe: 'Wrapperiframe',
|
||||
renderEnabled: 'xiaobaix_render_enabled',
|
||||
};
|
||||
const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded'];
|
||||
const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts'];
|
||||
const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts'];
|
||||
function setModuleEnabled(key, enabled) {
|
||||
try {
|
||||
if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};
|
||||
@@ -547,25 +536,20 @@
|
||||
if (el) { el.checked = !!enabled; try { $(el).trigger('change'); } catch (e) { } }
|
||||
}
|
||||
function captureStates() {
|
||||
const out = { modules: {}, sandboxMode: false, useBlob: false, wrapperIframe: false, renderEnabled: true };
|
||||
try { MODULE_KEYS.forEach(k => { out.modules[k] = !!(extension_settings[EXT_ID][k] && extension_settings[EXT_ID][k].enabled); }); } catch (e) { }
|
||||
try { out.sandboxMode = !!extension_settings[EXT_ID].sandboxMode; } catch (e) { }
|
||||
try { out.useBlob = !!extension_settings[EXT_ID].useBlob; } catch (e) { }
|
||||
try { out.wrapperIframe = !!extension_settings[EXT_ID].wrapperIframe; } catch (e) { }
|
||||
try { out.renderEnabled = extension_settings[EXT_ID].renderEnabled !== false; } catch (e) { }
|
||||
const out = { modules: {}, useBlob: false, wrapperIframe: false, renderEnabled: true };
|
||||
try { MODULE_KEYS.forEach(k => { out.modules[k] = !!(extension_settings[EXT_ID][k] && extension_settings[EXT_ID][k].enabled); }); } catch (e) { }
|
||||
try { out.useBlob = !!extension_settings[EXT_ID].useBlob; } catch (e) { }
|
||||
try { out.wrapperIframe = !!extension_settings[EXT_ID].wrapperIframe; } catch (e) { }
|
||||
try { out.renderEnabled = extension_settings[EXT_ID].renderEnabled !== false; } catch (e) { }
|
||||
return out;
|
||||
}
|
||||
function applyStates(st) {
|
||||
if (!st) return;
|
||||
try { Object.keys(st.modules || {}).forEach(k => setModuleEnabled(k, !!st.modules[k])); } catch (e) { }
|
||||
try {
|
||||
extension_settings[EXT_ID].sandboxMode = !!st.sandboxMode;
|
||||
const el = $id('xiaobaix_sandbox'); if (el) { el.checked = !!st.sandboxMode; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
|
||||
} catch (e) { }
|
||||
try {
|
||||
extension_settings[EXT_ID].useBlob = !!st.useBlob;
|
||||
const el = $id('xiaobaix_use_blob'); if (el) { el.checked = !!st.useBlob; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
|
||||
} catch (e) { }
|
||||
try {
|
||||
extension_settings[EXT_ID].useBlob = !!st.useBlob;
|
||||
const el = $id('xiaobaix_use_blob'); if (el) { el.checked = !!st.useBlob; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
|
||||
} catch (e) { }
|
||||
try {
|
||||
extension_settings[EXT_ID].wrapperIframe = !!st.wrapperIframe;
|
||||
const el = $id('Wrapperiframe'); if (el) { el.checked = !!st.wrapperIframe; if (window.isXiaobaixEnabled) try { $(el).trigger('change'); } catch (e) { } }
|
||||
@@ -579,13 +563,9 @@
|
||||
function applyResetDefaults() {
|
||||
DEFAULTS_ON.forEach(k => setModuleEnabled(k, true));
|
||||
DEFAULTS_OFF.forEach(k => setModuleEnabled(k, false));
|
||||
try {
|
||||
extension_settings[EXT_ID].sandboxMode = false; const sb = $id(KEY_TO_CHECKBOX.sandboxMode);
|
||||
if (sb) { sb.checked = false; try { $(sb).trigger('change'); } catch (e) { } }
|
||||
} catch (e) { }
|
||||
try {
|
||||
extension_settings[EXT_ID].useBlob = false; const bl = $id(KEY_TO_CHECKBOX.useBlob);
|
||||
if (bl) { bl.checked = false; try { $(bl).trigger('change'); } catch (e) { } }
|
||||
try {
|
||||
extension_settings[EXT_ID].useBlob = false; const bl = $id(KEY_TO_CHECKBOX.useBlob);
|
||||
if (bl) { bl.checked = false; try { $(bl).trigger('change'); } catch (e) { } }
|
||||
} catch (e) { }
|
||||
try {
|
||||
extension_settings[EXT_ID].wrapperIframe = true; const wp = $id(KEY_TO_CHECKBOX.wrapperIframe);
|
||||
@@ -788,4 +768,3 @@
|
||||
<div class="cloud-task-intro" style="color:#888;font-size:.9em;text-align:left;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user