diff --git a/core/iframe-messaging.js b/core/iframe-messaging.js index 8f3fdcb..4b58d10 100644 --- a/core/iframe-messaging.js +++ b/core/iframe-messaging.js @@ -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(); } diff --git a/index.js b/index.js index 421661a..0f9d5a3 100644 --- a/index.js +++ b/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) {} diff --git a/modules/fourth-wall/fourth-wall.html b/modules/fourth-wall/fourth-wall.html index 910cef9..b0d7151 100644 --- a/modules/fourth-wall/fourth-wall.html +++ b/modules/fourth-wall/fourth-wall.html @@ -480,16 +480,7 @@ html, body {
- -
- -
@@ -561,17 +552,6 @@ html, body { 配置 ══════════════════════════════════════════════════════════════════════════════ */ -const TTS_WORKER_URL = 'https://hstts.velure.codes'; - -const VALID_EMOTIONS = ['happy', 'sad', 'angry', 'surprise', 'scare', 'hate']; -const EMOTION_ICONS = { - happy: '😄', sad: '😢', angry: '😠', surprise: '😮', scare: '😨', hate: '🤢' -}; - -// 动态加载的声音列表 -let voiceList = []; -let defaultVoiceKey = 'female_1'; - /* ══════════════════════════════════════════════════════════════════════════════ 工具函数 ══════════════════════════════════════════════════════════════════════════════ */ @@ -618,8 +598,8 @@ function postToParent(payload) { window.parent.postMessage({ source: 'LittleWhiteBox-FourthWall', ...payload }, PARENT_ORIGIN); } -function getEmotionIcon(emotion) { - return EMOTION_ICONS[emotion] || ''; +function getEmotionIcon() { + return ''; } /* ══════════════════════════════════════════════════════════════════════════════ @@ -636,30 +616,19 @@ let state = { sessions: [], activeSessionId: null, imgSettings: { enablePrompt: false }, - voiceSettings: { enabled: false, voice: 'female_1', speed: 1.0 }, + voiceSettings: { enabled: false }, commentarySettings: { enabled: false, probability: 30 }, promptTemplates: {} }; -let currentAudio = null; +let activeVoiceRequestId = null; /* ══════════════════════════════════════════════════════════════════════════════ 加载声音列表 ══════════════════════════════════════════════════════════════════════════════ */ -async function loadVoices() { - try { - const res = await fetch(`${TTS_WORKER_URL}/voices`); - if (!res.ok) throw new Error('Failed to load voices'); - const data = await res.json(); - voiceList = data.voices || []; - defaultVoiceKey = data.defaultVoice || 'female_1'; - renderVoiceSelect(); - } catch (err) { - console.error('[FW Voice] 加载声音列表失败:', err); - // 降级:使用空列表 - voiceList = []; - } +function generateVoiceRequestId() { + return 'fwv_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8); } /* ══════════════════════════════════════════════════════════════════════════════ @@ -786,54 +755,48 @@ function bindRetryButton(slot) { 语音处理 ══════════════════════════════════════════════════════════════════════════════ */ -async function playVoice(text, emotion, bubbleEl) { - if (currentAudio) { - currentAudio.pause(); - currentAudio = null; - document.querySelectorAll('.fw-voice-bubble.playing').forEach(el => el.classList.remove('playing')); - } +function requestPlayVoice(text, emotion, bubbleEl) { + // Clear previous bubble state before issuing a new request. + document.querySelectorAll('.fw-voice-bubble.playing, .fw-voice-bubble.loading').forEach(el => { + el.classList.remove('playing', 'loading'); + }); + const voiceRequestId = generateVoiceRequestId(); + activeVoiceRequestId = voiceRequestId; + bubbleEl.dataset.voiceRequestId = voiceRequestId; bubbleEl.classList.add('loading'); bubbleEl.classList.remove('error'); - try { + postToParent({ type: 'PLAY_VOICE', text, emotion, voiceRequestId }); +} - const requestBody = { - voiceKey: state.voiceSettings.voice || defaultVoiceKey, - text: text, - speed: state.voiceSettings.speed || 1.0, - uid: 'fw_' + Date.now(), - reqid: generateUUID() - }; +function handleVoiceState(data) { + const { voiceRequestId, state: voiceState, duration } = data; + const bubble = document.querySelector(`.fw-voice-bubble[data-voice-request-id="${voiceRequestId}"]`); + if (!bubble) return; - if (emotion && VALID_EMOTIONS.includes(emotion)) { - requestBody.emotion = emotion; - requestBody.emotionScale = 5; - } - - const res = await fetch(TTS_WORKER_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }); - - if (!res.ok) throw new Error(`HTTP ${res.status}`); - - const data = await res.json(); - if (data.code !== 3000) throw new Error(data.message || 'TTS失败'); - - bubbleEl.classList.remove('loading'); - bubbleEl.classList.add('playing'); - - currentAudio = new Audio(`data:audio/mp3;base64,${data.data}`); - currentAudio.onended = () => { bubbleEl.classList.remove('playing'); currentAudio = null; }; - currentAudio.onerror = () => { bubbleEl.classList.remove('playing'); bubbleEl.classList.add('error'); currentAudio = null; }; - await currentAudio.play(); - } catch (err) { - console.error('[FW Voice] TTS错误:', err); - bubbleEl.classList.remove('loading', 'playing'); - bubbleEl.classList.add('error'); - setTimeout(() => bubbleEl.classList.remove('error'), 3000); + switch (voiceState) { + case 'loading': + bubble.classList.add('loading'); + bubble.classList.remove('playing', 'error'); + break; + case 'playing': + bubble.classList.remove('loading', 'error'); + bubble.classList.add('playing'); + if (duration != null) { + const durationEl = bubble.querySelector('.fw-voice-duration'); + if (durationEl) durationEl.textContent = Math.ceil(duration) + '"'; + } + break; + case 'ended': + case 'stopped': + bubble.classList.remove('loading', 'playing'); + break; + case 'error': + bubble.classList.remove('loading', 'playing'); + bubble.classList.add('error'); + setTimeout(() => bubble.classList.remove('error'), 3000); + break; } } @@ -846,12 +809,10 @@ function hydrateVoiceSlots(container) { bubble.onclick = e => { e.stopPropagation(); if (bubble.classList.contains('loading')) return; - if (bubble.classList.contains('playing') && currentAudio) { - currentAudio.pause(); - currentAudio = null; - bubble.classList.remove('playing'); + if (bubble.classList.contains('playing')) { + postToParent({ type: 'STOP_VOICE', voiceRequestId: bubble.dataset.voiceRequestId }); } else { - playVoice(text, emotion, bubble); + requestPlayVoice(text, emotion, bubble); } }; } @@ -1022,24 +983,6 @@ function renderSessionSelect() { ).join(''); } -// 使用动态加载的声音列表渲染下拉框 -function renderVoiceSelect() { - const select = document.getElementById('voice-select'); - if (!select || !voiceList.length) return; - - const females = voiceList.filter(v => v.gender === 'female'); - const males = voiceList.filter(v => v.gender === 'male'); - - select.innerHTML = ` - - ${females.map(v => ``).join('')} - - - ${males.map(v => ``).join('')} - - `; - select.value = state.voiceSettings.voice || defaultVoiceKey; -} function updateMenuUI() { const actions = document.getElementById('header-actions'); @@ -1057,11 +1000,6 @@ function updateMenuUI() { } } -function updateVoiceUI(enabled) { - document.querySelector('.fw-voice-select-wrap').style.display = enabled ? '' : 'none'; - document.querySelector('.fw-voice-speed-wrap').style.display = enabled ? '' : 'none'; -} - function updateCommentaryUI(enabled) { document.querySelector('.fw-commentary-prob-wrap').style.display = enabled ? '' : 'none'; } @@ -1161,14 +1099,6 @@ window.addEventListener('message', event => { document.getElementById('img-prompt-enabled').checked = state.imgSettings.enablePrompt; document.getElementById('voice-enabled').checked = state.voiceSettings.enabled; - // 等声音列表加载完再设置值 - if (voiceList.length) { - document.getElementById('voice-select').value = state.voiceSettings.voice || defaultVoiceKey; - } - document.getElementById('voice-speed').value = state.voiceSettings.speed || 1.0; - document.getElementById('voice-speed-val').textContent = (state.voiceSettings.speed || 1.0).toFixed(1) + 'x'; - updateVoiceUI(state.voiceSettings.enabled); - document.getElementById('commentary-enabled').checked = state.commentarySettings.enabled; document.getElementById('commentary-prob').value = state.commentarySettings.probability; document.getElementById('commentary-prob-val').textContent = state.commentarySettings.probability + '%'; @@ -1211,6 +1141,10 @@ window.addEventListener('message', event => { updateFullscreenButton(data.isFullscreen); break; + case 'VOICE_STATE': + handleVoiceState(data); + break; + case 'IMAGE_RESULT': handleImageResult(data); break; @@ -1229,9 +1163,6 @@ window.addEventListener('message', event => { ══════════════════════════════════════════════════════════════════════════════ */ document.addEventListener('DOMContentLoaded', async () => { - // 先加载声音列表 - await loadVoices(); - document.getElementById('btn-menu-toggle').onclick = () => { state.menuExpanded = !state.menuExpanded; updateMenuUI(); }; document.getElementById('btn-settings').onclick = () => { state.settingsOpen = !state.settingsOpen; document.getElementById('settings-panel').classList.toggle('open', state.settingsOpen); }; document.getElementById('btn-settings-close').onclick = () => { state.settingsOpen = false; document.getElementById('settings-panel').classList.remove('open'); }; @@ -1258,19 +1189,6 @@ document.addEventListener('DOMContentLoaded', async () => { document.getElementById('voice-enabled').onchange = function() { state.voiceSettings.enabled = this.checked; - updateVoiceUI(this.checked); - postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); - }; - - document.getElementById('voice-select').onchange = function() { - state.voiceSettings.voice = this.value; - postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); - }; - - document.getElementById('voice-speed').oninput = function() { - const val = parseFloat(this.value); - document.getElementById('voice-speed-val').textContent = val.toFixed(1) + 'x'; - state.voiceSettings.speed = val; postToParent({ type: 'SAVE_VOICE_SETTINGS', voiceSettings: state.voiceSettings }); }; diff --git a/modules/fourth-wall/fourth-wall.js b/modules/fourth-wall/fourth-wall.js index 5d286bd..1e218e2 100644 --- a/modules/fourth-wall/fourth-wall.js +++ b/modules/fourth-wall/fourth-wall.js @@ -1,5 +1,5 @@ -// ════════════════════════════════════════════════════════════════════════════ -// 次元壁模块 - 主控制器 +// ════════════════════════════════════════════ +// Fourth Wall Module - Main Controller // ════════════════════════════════════════════════════════════════════════════ import { extension_settings, getContext, saveMetadataDebounced } from "../../../../../extensions.js"; import { saveSettingsDebounced, chat_metadata, default_user_avatar, default_avatar } from "../../../../../../script.js"; @@ -8,19 +8,20 @@ import { createModuleEvents, event_types } from "../../core/event-manager.js"; import { xbLog } from "../../core/debug-core.js"; import { handleCheckCache, handleGenerate, clearExpiredCache } from "./fw-image.js"; -import { DEFAULT_VOICE, DEFAULT_SPEED } from "./fw-voice.js"; -import { - buildPrompt, - buildCommentaryPrompt, +import { synthesizeAndPlay, stopCurrent as stopCurrentVoice } from "./fw-voice-runtime.js"; +import { + buildPrompt, + buildCommentaryPrompt, DEFAULT_TOPUSER, DEFAULT_CONFIRM, DEFAULT_BOTTOM, - DEFAULT_META_PROTOCOL + DEFAULT_META_PROTOCOL } from "./fw-prompt.js"; import { initMessageEnhancer, cleanupMessageEnhancer } from "./fw-message-enhancer.js"; import { postToIframe, isTrustedMessage, getTrustedOrigin } from "../../core/iframe-messaging.js"; -// ════════════════════════════════════════════════════════════════════════════ -// 常量 + +// ════════════════════════════════════════════ +// Constants // ════════════════════════════════════════════════════════════════════════════ const events = createModuleEvents('fourthWall'); @@ -30,7 +31,7 @@ const COMMENTARY_COOLDOWN = 180000; const IFRAME_PING_TIMEOUT = 800; // ════════════════════════════════════════════════════════════════════════════ -// 状态 +// State // ════════════════════════════════════════════════════════════════════════════ let overlayCreated = false; @@ -44,37 +45,36 @@ let currentLoadedChatId = null; let lastCommentaryTime = 0; let commentaryBubbleEl = null; let commentaryBubbleTimer = null; +let currentVoiceRequestId = null; -// ═══════════════════════════════ 新增 ═══════════════════════════════ let visibilityHandler = null; let pendingPingId = null; -// ════════════════════════════════════════════════════════════════════ // ════════════════════════════════════════════════════════════════════════════ -// 设置管理(保持不变) +// Settings // ════════════════════════════════════════════════════════════════════════════ function getSettings() { extension_settings[EXT_ID] ||= {}; const s = extension_settings[EXT_ID]; - + s.fourthWall ||= { enabled: true }; s.fourthWallImage ||= { enablePrompt: false }; - s.fourthWallVoice ||= { enabled: false, voice: DEFAULT_VOICE, speed: DEFAULT_SPEED }; + s.fourthWallVoice ||= { enabled: false }; s.fourthWallCommentary ||= { enabled: false, probability: 30 }; s.fourthWallPromptTemplates ||= {}; - + const t = s.fourthWallPromptTemplates; if (t.topuser === undefined) t.topuser = DEFAULT_TOPUSER; if (t.confirm === undefined) t.confirm = DEFAULT_CONFIRM; if (t.bottom === undefined) t.bottom = DEFAULT_BOTTOM; if (t.metaProtocol === undefined) t.metaProtocol = DEFAULT_META_PROTOCOL; - + return s; } // ════════════════════════════════════════════════════════════════════════════ -// 工具函数(保持不变) +// Utilities // ════════════════════════════════════════════════════════════════════════════ function b64UrlEncode(str) { @@ -162,7 +162,7 @@ function getAvatarUrls() { } // ════════════════════════════════════════════════════════════════════════════ -// 存储管理(保持不变) +// Storage // ════════════════════════════════════════════════════════════════════════════ function getFWStore(chatId = getCurrentChatIdSafe()) { @@ -171,17 +171,17 @@ function getFWStore(chatId = getCurrentChatIdSafe()) { chat_metadata[chatId].extensions ||= {}; chat_metadata[chatId].extensions[EXT_ID] ||= {}; chat_metadata[chatId].extensions[EXT_ID].fw ||= {}; - + const fw = chat_metadata[chatId].extensions[EXT_ID].fw; fw.settings ||= { maxChatLayers: 9999, maxMetaTurns: 9999, stream: true }; - + if (!fw.sessions) { const oldHistory = Array.isArray(fw.history) ? fw.history.slice() : []; - fw.sessions = [{ id: 'default', name: '默认记录', createdAt: Date.now(), history: oldHistory }]; + fw.sessions = [{ id: 'default', name: 'Default', createdAt: Date.now(), history: oldHistory }]; fw.activeSessionId = 'default'; if (Object.prototype.hasOwnProperty.call(fw, 'history')) delete fw.history; } - + if (!fw.activeSessionId || !fw.sessions.find(s => s.id === fw.activeSessionId)) { fw.activeSessionId = fw.sessions[0]?.id || null; } @@ -199,7 +199,7 @@ function saveFWStore() { } // ════════════════════════════════════════════════════════════════════════════ -// iframe 通讯 +// iframe Communication // ════════════════════════════════════════════════════════════════════════════ function postToFrame(payload) { @@ -224,7 +224,7 @@ function sendInitData() { const settings = getSettings(); const session = getActiveSession(); const avatars = getAvatarUrls(); - + postToFrame({ type: 'INIT_DATA', settings: store?.settings || {}, @@ -240,86 +240,128 @@ function sendInitData() { } // ════════════════════════════════════════════════════════════════════════════ -// iframe 健康检测与恢复(新增) +// iframe Health Check & Recovery // ════════════════════════════════════════════════════════════════════════════ function handleVisibilityChange() { if (document.visibilityState !== 'visible') return; - const overlay = document.getElementById('xiaobaix-fourth-wall-overlay'); if (!overlay || overlay.style.display === 'none') return; - checkIframeHealth(); } function checkIframeHealth() { const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); if (!iframe) return; - - // 生成唯一 ping ID + const pingId = 'ping_' + Date.now(); pendingPingId = pingId; - - // 尝试发送 PING + try { const win = iframe.contentWindow; if (!win) { - recoverIframe('contentWindow 不存在'); + recoverIframe('contentWindow missing'); return; } win.postMessage({ source: 'LittleWhiteBox', type: 'PING', pingId }, getTrustedOrigin()); } catch (e) { - recoverIframe('无法访问 iframe: ' + e.message); + recoverIframe('Cannot access iframe: ' + e.message); return; } - - // 设置超时检测 + setTimeout(() => { if (pendingPingId === pingId) { - // 没有收到 PONG 响应 - recoverIframe('PING 超时无响应'); + recoverIframe('PING timeout'); } }, IFRAME_PING_TIMEOUT); } function handlePongResponse(pingId) { if (pendingPingId === pingId) { - pendingPingId = null; // 清除,表示收到响应 + pendingPingId = null; } } function recoverIframe(reason) { const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); if (!iframe) return; - - try { xbLog.warn('fourthWall', `iframe 恢复中: ${reason}`); } catch {} - - // 重置状态 + + try { xbLog.warn('fourthWall', `iframe recovery: ${reason}`); } catch { } + frameReady = false; pendingFrameMessages = []; pendingPingId = null; - - // 如果正在流式生成,取消 + if (isStreaming) { cancelGeneration(); } - - // 重新加载 iframe + iframe.src = iframePath; } // ════════════════════════════════════════════════════════════════════════════ -// 消息处理(添加 PONG 处理) +// Voice Handling +// ════════════════════════════════════════════════════════════════════════════ + +function handlePlayVoice(data) { + const { text, emotion, voiceRequestId } = data; + + if (!text?.trim()) { + postToFrame({ type: 'VOICE_STATE', voiceRequestId, state: 'error', message: 'Voice text is empty' }); + return; + } + + // Notify old request as stopped + if (currentVoiceRequestId && currentVoiceRequestId !== voiceRequestId) { + postToFrame({ type: 'VOICE_STATE', voiceRequestId: currentVoiceRequestId, state: 'stopped' }); + } + + currentVoiceRequestId = voiceRequestId; + + synthesizeAndPlay(text, emotion, { + requestId: voiceRequestId, + onState(state, info) { + if (currentVoiceRequestId !== voiceRequestId) return; + postToFrame({ + type: 'VOICE_STATE', + voiceRequestId, + state, + duration: info?.duration, + message: info?.message, + }); + }, + }); +} + +function handleStopVoice(data) { + const targetId = data?.voiceRequestId || currentVoiceRequestId; + stopCurrentVoice(); + if (targetId) { + postToFrame({ type: 'VOICE_STATE', voiceRequestId: targetId, state: 'stopped' }); + } + currentVoiceRequestId = null; +} + +function stopVoiceAndNotify() { + if (currentVoiceRequestId) { + postToFrame({ type: 'VOICE_STATE', voiceRequestId: currentVoiceRequestId, state: 'stopped' }); + } + stopCurrentVoice(); + currentVoiceRequestId = null; +} + +// ════════════════════════════════════════════════════════════════════════════ +// Frame Message Handler // ════════════════════════════════════════════════════════════════════════════ function handleFrameMessage(event) { const iframe = document.getElementById('xiaobaix-fourth-wall-iframe'); if (!isTrustedMessage(event, iframe, 'LittleWhiteBox-FourthWall')) return; const data = event.data; - + const store = getFWStore(); const settings = getSettings(); - + switch (data.type) { case 'FRAME_READY': frameReady = true; @@ -327,11 +369,9 @@ function handleFrameMessage(event) { sendInitData(); break; - // ═══════════════════════════ 新增 ═══════════════════════════ case 'PONG': handlePongResponse(data.pingId); break; - // ════════════════════════════════════════════════════════════ case 'TOGGLE_FULLSCREEN': toggleFullscreen(); @@ -340,29 +380,29 @@ function handleFrameMessage(event) { case 'SEND_MESSAGE': handleSendMessage(data); break; - + case 'REGENERATE': handleRegenerate(data); break; - + case 'CANCEL_GENERATION': cancelGeneration(); break; - + case 'SAVE_SETTINGS': if (store) { Object.assign(store.settings, data.settings); saveFWStore(); } break; - + case 'SAVE_IMG_SETTINGS': Object.assign(settings.fourthWallImage, data.imgSettings); saveSettingsDebounced(); break; case 'SAVE_VOICE_SETTINGS': - Object.assign(settings.fourthWallVoice, data.voiceSettings); + settings.fourthWallVoice.enabled = !!data.voiceSettings?.enabled; saveSettingsDebounced(); break; @@ -370,7 +410,7 @@ function handleFrameMessage(event) { Object.assign(settings.fourthWallCommentary, data.commentarySettings); saveSettingsDebounced(); break; - + case 'SAVE_PROMPT_TEMPLATES': settings.fourthWallPromptTemplates = data.templates; saveSettingsDebounced(); @@ -382,7 +422,7 @@ function handleFrameMessage(event) { saveSettingsDebounced(); sendInitData(); break; - + case 'SAVE_HISTORY': { const session = getActiveSession(); if (session) { @@ -391,7 +431,7 @@ function handleFrameMessage(event) { } break; } - + case 'RESET_HISTORY': { const session = getActiveSession(); if (session) { @@ -400,7 +440,7 @@ function handleFrameMessage(event) { } break; } - + case 'SWITCH_SESSION': if (store) { store.activeSessionId = data.sessionId; @@ -408,7 +448,7 @@ function handleFrameMessage(event) { sendInitData(); } break; - + case 'ADD_SESSION': if (store) { const newId = 'sess_' + Date.now(); @@ -418,14 +458,14 @@ function handleFrameMessage(event) { sendInitData(); } break; - + case 'RENAME_SESSION': if (store) { const sess = store.sessions.find(s => s.id === data.sessionId); if (sess) { sess.name = data.name; saveFWStore(); sendInitData(); } } break; - + case 'DELETE_SESSION': if (store && store.sessions.length > 1) { store.sessions = store.sessions.filter(s => s.id !== data.sessionId); @@ -446,11 +486,19 @@ function handleFrameMessage(event) { case 'GENERATE_IMAGE': handleGenerate(data, postToFrame); break; + + case 'PLAY_VOICE': + handlePlayVoice(data); + break; + + case 'STOP_VOICE': + handleStopVoice(data); + break; } } // ════════════════════════════════════════════════════════════════════════════ -// 生成处理(保持不变) +// Generation // ════════════════════════════════════════════════════════════════════════════ async function startGeneration(data) { @@ -462,9 +510,9 @@ async function startGeneration(data) { voiceSettings: data.voiceSettings, promptTemplates: getSettings().fourthWallPromptTemplates }); - + const gen = window.xiaobaixStreamingGeneration; - if (!gen?.xbgenrawCommand) throw new Error('xbgenraw 模块不可用'); + if (!gen?.xbgenrawCommand) throw new Error('xbgenraw module unavailable'); const topMessages = [ { role: 'user', content: msg1 }, @@ -479,7 +527,7 @@ async function startGeneration(data) { nonstream: data.settings.stream ? 'false' : 'true', as: 'user', }, ''); - + if (data.settings.stream) { startStreamingPoll(); } else { @@ -490,13 +538,13 @@ async function startGeneration(data) { async function handleSendMessage(data) { if (isStreaming) return; isStreaming = true; - + const session = getActiveSession(); if (session) { session.history = data.history; saveFWStore(); } - + try { await startGeneration(data); } catch { @@ -509,13 +557,13 @@ async function handleSendMessage(data) { async function handleRegenerate(data) { if (isStreaming) return; isStreaming = true; - + const session = getActiveSession(); if (session) { session.history = data.history; saveFWStore(); } - + try { await startGeneration(data); } catch { @@ -535,7 +583,7 @@ function startStreamingPoll() { const thinking = extractThinkingPartial(raw); const msg = extractMsg(raw) || extractMsgPartial(raw); postToFrame({ type: 'STREAM_UPDATE', text: msg || '...', thinking: thinking || undefined }); - + const st = gen.getStatus?.(STREAM_SESSION_ID); if (st && st.isStreaming === false) finalizeGeneration(); }, 80); @@ -560,18 +608,18 @@ function stopStreamingPoll() { function finalizeGeneration() { stopStreamingPoll(); const gen = window.xiaobaixStreamingGeneration; - const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(无响应)'; - const finalText = extractMsg(rawText) || '(无响应)'; + const rawText = gen?.getLastGeneration?.(STREAM_SESSION_ID) || '(no response)'; + const finalText = extractMsg(rawText) || '(no response)'; const thinkingText = extractThinking(rawText); - + isStreaming = false; - + const session = getActiveSession(); if (session) { session.history.push({ role: 'ai', content: finalText, thinking: thinkingText || undefined, ts: Date.now() }); saveFWStore(); } - + postToFrame({ type: 'STREAM_COMPLETE', finalText, thinking: thinkingText }); } @@ -579,12 +627,12 @@ function cancelGeneration() { const gen = window.xiaobaixStreamingGeneration; stopStreamingPoll(); isStreaming = false; - try { gen?.cancel?.(STREAM_SESSION_ID); } catch {} + try { gen?.cancel?.(STREAM_SESSION_ID); } catch { } postToFrame({ type: 'GENERATION_CANCELLED' }); } // ════════════════════════════════════════════════════════════════════════════ -// 实时吐槽(保持不变,省略...) +// Commentary // ════════════════════════════════════════════════════════════════════════════ function shouldTriggerCommentary() { @@ -669,7 +717,7 @@ async function handleAIMessageForCommentary(data) { if (!commentary) return; const session = getActiveSession(); if (session) { - session.history.push({ role: 'ai', content: `(瞄了眼刚才的台词)${commentary}`, ts: Date.now(), type: 'commentary' }); + session.history.push({ role: 'ai', content: `(glanced at the last line) ${commentary}`, ts: Date.now(), type: 'commentary' }); saveFWStore(); } showCommentaryBubble(commentary); @@ -678,22 +726,22 @@ async function handleAIMessageForCommentary(data) { async function handleEditForCommentary(data) { if ($('#xiaobaix-fourth-wall-overlay').is(':visible')) return; if (!shouldTriggerCommentary()) return; - + const ctx = getContext?.() || {}; const messageId = typeof data === 'object' ? (data.messageId ?? data.id ?? data.index) : data; const msgObj = Number.isFinite(messageId) ? ctx?.chat?.[messageId] : null; const messageText = getMessageTextFromEventArg(data); if (!String(messageText).trim()) return; - + await new Promise(r => setTimeout(r, 500 + Math.random() * 500)); - + const editType = msgObj?.is_user ? 'edit_own' : 'edit_ai'; const commentary = await generateCommentary(messageText, editType); if (!commentary) return; - + const session = getActiveSession(); if (session) { - const prefix = editType === 'edit_ai' ? '(发现你改了我的台词)' : '(发现你偷偷改台词)'; + const prefix = editType === 'edit_ai' ? '(noticed you edited my line) ' : '(caught you sneaking edits) '; session.history.push({ role: 'ai', content: `${prefix}${commentary}`, ts: Date.now(), type: 'commentary' }); saveFWStore(); } @@ -705,7 +753,7 @@ function getFloatBtnPosition() { if (!btn) return null; const rect = btn.getBoundingClientRect(); let stored = {}; - try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch {} + try { stored = JSON.parse(localStorage.getItem(`${EXT_ID}:fourthWallFloatBtnPos`) || '{}') || {}; } catch { } return { top: rect.top, left: rect.left, width: rect.width, height: rect.height, side: stored.side || 'right' }; } @@ -771,19 +819,19 @@ function cleanupCommentary() { lastCommentaryTime = 0; } -// ════════════════════════════════════════════════════════════════════════════ -// Overlay 管理(添加可见性监听) +// ════════════════════════════════════════════ +// Overlay // ════════════════════════════════════════════════════════════════════════════ function createOverlay() { if (overlayCreated) return; overlayCreated = true; - + const isMobile = window.innerWidth <= 768; const frameInset = isMobile ? '0px' : '12px'; const iframeRadius = isMobile ? '0px' : '12px'; const framePadding = isMobile ? 'padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left) !important;' : ''; - + const $overlay = $(` `); - + $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 = $(` - `); - + $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 { } } }); } diff --git a/modules/fourth-wall/fw-message-enhancer.js b/modules/fourth-wall/fw-message-enhancer.js index c3d04f9..359cfb5 100644 --- a/modules/fourth-wall/fw-message-enhancer.js +++ b/modules/fourth-wall/fw-message-enhancer.js @@ -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 `
`; }); - + 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, '>'); } -// ════════════════════════════════════════════════════════════════════════════ -// 图片处理 +// ════════════════════════════════════════════ +// 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 = `
滚动加载
`; } - + imageObserver?.observe(slot); }); } async function loadImage(slot, tags) { - // Template-only UI markup. // eslint-disable-next-line no-unsanitized/property slot.innerHTML = `
检查缓存...
`; - + 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 = `
排队中 #${position}
`; break; case 'generating': - // Template-only UI markup. // eslint-disable-next-line no-unsanitized/property slot.innerHTML = `
生成中${position > 0 ? ` (${position} 排队)` : ''}...
`; break; case 'waiting': - // Template-only UI markup. // eslint-disable-next-line no-unsanitized/property slot.innerHTML = `
排队中 #${position} (${delay}s)
`; 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 = `
滚动加载
`; slot.dataset.loading = ''; slot.dataset.observed = ''; return; } - - // Template-only UI markup with escaped error text. + // eslint-disable-next-line no-unsanitized/property slot.innerHTML = `
${escapeHtml(err?.message || '失败')}
`; 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 = ''; 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); - } -} diff --git a/modules/fourth-wall/fw-voice-runtime.js b/modules/fourth-wall/fw-voice-runtime.js new file mode 100644 index 0000000..6ad6be5 --- /dev/null +++ b/modules/fourth-wall/fw-voice-runtime.js @@ -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; + } +} diff --git a/modules/fourth-wall/fw-voice.js b/modules/fourth-wall/fw-voice.js index c1574c2..a8d3441 100644 --- a/modules/fourth-wall/fw-voice.js +++ b/modules/fourth-wall/fw-voice.js @@ -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} 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部分需要在内`; +注意:voice部分需要在内`; \ No newline at end of file diff --git a/modules/iframe-renderer.js b/modules/iframe-renderer.js index 63f49a5..1c650d1 100644 --- a/modules/iframe-renderer.js +++ b/modules/iframe-renderer.js @@ -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) {} diff --git a/modules/story-outline/story-outline.html b/modules/story-outline/story-outline.html index 51906fc..bfeb786 100644 --- a/modules/story-outline/story-outline.html +++ b/modules/story-outline/story-outline.html @@ -2870,7 +2870,7 @@ (() => { const t = getStoredTheme(); "dark" === t && setTheme("dark") })(); $("btn-theme")?.addEventListener("click", toggleTheme); - const inds = popup.querySelectorAll(".pop-h-ind span"), setPopH = t => { const e = snaps(); popH = Math.max(e[0], Math.min(.85 * innerHeight, t)), popup.style.height = popH + "px", popLv = e.map(((t, e) => [e, Math.abs(popH - t)])).sort(((t, e) => t[1] - e[1]))[0][0], inds.forEach(((t, e) => t.classList.toggle("act", e === popLv))) }, snapTo = t => { popLv = Math.max(0, Math.min(2, t)), setPopH(snaps()[popLv]) }, openPop = (t = 1) => { popup.classList.add("act"), snapTo(t) }; $("side-pop-handle").onclick = () => sidePop.classList.toggle("act"), $("pop-hd").onmousedown = t => { popDrag = !0, popSY = t.clientY, popSH = popH || snaps()[1], popup.classList.add("drag"), t.preventDefault() }, $("pop-hd").ontouchstart = t => { t.preventDefault(), popDrag = !0, popSY = t.touches[0].clientY, popSH = popH || snaps()[1], popup.classList.add("drag") }, document.onmousemove = t => { popDrag && setPopH(popSH + popSY - t.clientY) }, document.ontouchmove = t => { popDrag && t.touches.length && (t.preventDefault(), setPopH(popSH + popSY - t.touches[0].clientY)) }; const endDrag = () => { popDrag && (popDrag = !1, popup.classList.remove("drag"), snapTo(popLv)) }; document.onmouseup = endDrag, document.ontouchend = endDrag, document.ontouchcancel = endDrag; const bindLinks = t => t.querySelectorAll(".loc-lk").forEach((t => t.onclick = e => { e.stopPropagation(); const s = t.dataset.loc, o = nodes.find((t => t.name === s)); if (o) return panTo(o), void showInfo(o); const a = getCurInside(), n = a?.nodes?.find((t => t.name === s)); n && ($("info-t").textContent = n.name, $("info-c").textContent = n.info || "暂无信息...", $("tip").classList.add("show"), $("btn-goto").classList.remove("show"), isMob() && ($("mob-info-t").textContent = n.name, $("mob-info-c").textContent = n.info || "暂无信息...", popup.classList.contains("act") || openPop(1))) })), bindFold = t => t.querySelector(".fold-h").onclick = () => t.classList.toggle("exp"); $$(".modal-bd,.modal-x,.m-cancel").forEach((t => t.onclick = () => t.closest(".modal").classList.remove("act"))); const openChat = t => { chatTgt = t, $("chat-av").textContent = t.avatar, $("chat-av").style.background = t.color, $("chat-nm").textContent = t.name, $("chat-st").textContent = t.online ? "● 在线" : t.location, !t.worldbookUid || t.messages && t.messages.length ? renderMsgs() : post("LOAD_SMS_HISTORY", { worldbookUid: t.worldbookUid }), chat.classList.add("act"), $("chat-in").focus() }, closeChat = () => { chat.classList.remove("act"), chatTgt = null, smsGen = !1 }, renderMsgs = () => { if (!chatTgt) return; const t = chatTgt.messages || [], e = chatTgt.summarizedCount || 0; let s = ""; t.length ? t.forEach(((t, o) => { e > 0 && o === e && (s += '
—— 以上为已总结消息 ——
'), s += `
${escHtml(stripXml(t.text))}
` })) : s = '
暂无消息,开始聊天吧
', $("chat-msgs").innerHTML = s, $("chat-msgs").scrollTop = $("chat-msgs").scrollHeight, $("chat-compress").disabled = t.length - e < 2 || smsGen, $("chat-back").disabled = !t.length || smsGen }, sendMsg = () => { const t = $("chat-in").value.trim(); if (!t || !chatTgt || smsGen) return; chatTgt.messages = chatTgt.messages || [], chatTgt.messages.push({ type: "sent", text: t }), $("chat-in").value = "", renderMsgs(), smsGen = !0, $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !0, chatTgt.messages.push({ type: "received", text: "正在输入...", typing: !0 }), renderMsgs(); const e = Req.create("sms"); Req.set("sms", { tgt: chatTgt }), post("SEND_SMS", { requestId: e, contactName: chatTgt.name, worldbookUid: chatTgt.worldbookUid, userMessage: t, chatHistory: chatTgt.messages.filter((t => !t.typing)).slice(-20), summarizedCount: chatTgt.summarizedCount || 0 }) }, isCharCardContact = t => "__CHARACTER_CARD__" === t?.worldbookUid, contactsForSave = () => (D.contacts.contacts || []).filter((t => !isCharCardContact(t))), saveCt = () => post("SAVE_CONTACTS", { contacts: contactsForSave(), strangers: D.contacts.strangers }), saveChat = t => post("SAVE_SMS_HISTORY", { worldbookUid: t.worldbookUid, contactName: t.name, messages: t.messages.filter((t => !t.typing)), summarizedCount: t.summarizedCount || 0 }); $("chat-x").onclick = closeChat, $("chat-back").onclick = () => { if (!chatTgt || smsGen) return; const t = chatTgt.messages || []; if (t.length) { for (t.pop(); t[t.length - 1]?.typing;)t.pop(); chatTgt.summarizedCount = Math.min(chatTgt.summarizedCount || 0, t.length), renderMsgs(), saveCt(), chatTgt.worldbookUid && saveChat(chatTgt) } }, $("chat-clr").onclick = () => { chatTgt && confirm(`确定要清空与 ${chatTgt.name} 的所有聊天记录吗?`) && (chatTgt.messages = [], chatTgt.summarizedCount = 0, renderMsgs(), saveCt(), chatTgt.worldbookUid && saveChat(chatTgt)) }, $("chat-compress").onclick = () => { if (!chatTgt || smsGen) return; const t = chatTgt.messages || [], e = chatTgt.summarizedCount || 0, s = t.slice(e); if (s.length < 2) return void alert("至少需要2条未总结的消息才能压缩"); if (!confirm(`确定要压缩总结 ${s.length} 条消息吗?`)) return; smsGen = !0, $("chat-compress").disabled = $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !0; const o = Req.create("compress"); Req.set("compress", { tgt: chatTgt }), post("COMPRESS_SMS", { requestId: o, contactName: chatTgt.name, worldbookUid: chatTgt.worldbookUid, messages: t.filter((t => !t.typing)), summarizedCount: e }) }, $("chat-send").onclick = sendMsg; const chatIn = $("chat-in");["keydown", "keypress", "keyup"].forEach((t => chatIn.addEventListener(t, (t => t.stopPropagation())))), chatIn.addEventListener("keydown", (t => { "Enter" !== t.key || t.shiftKey || (t.preventDefault(), sendMsg()) })); const openInv = t => { invTgt = t, selLoc = null, $("inv-t").textContent = `邀请:${t.name}`, $("loc-list").innerHTML = D.maps.outdoor.nodes.map((t => `
${h(t.name)}
${h(t.info || "")}
`)).join(""), $$("#loc-list .loc-i").forEach((t => t.onclick = () => { $$("#loc-list .loc-i").forEach((t => t.classList.remove("sel"))), t.classList.add("sel"), selLoc = t.dataset.n })), openM("m-invite") }; $("inv-ok").onclick = () => { if (!selLoc || !invTgt) return; const t = $("inv-ok"); BtnState.load(t, "询问中..."); const e = Req.create("invite"), s = canonicalLoc(selLoc); Req.set("invite", { contact: invTgt, loc: s, btn: t }), post("SEND_INVITE", { requestId: e, contactName: invTgt.name, contactUid: invTgt.worldbookUid, targetLocation: s, smsHistory: (invTgt.messages || []).map((t => "sent" === t.type ? `{{user}}: ${t.text}` : `${invTgt.name}: ${t.text}`)).join("\n") }) }; let addCtState = { uid: "", name: "", keys: [] }; const resetAddCt = () => { addCtState = { uid: "", name: "", keys: [] }, $("add-uid").value = "", $("add-name").value = "", $("add-name").innerHTML = '', $("name-select-group").style.display = "none", $("uid-check-err").classList.remove("vis"), $("add-ct-ok").disabled = !0, BtnState.reset($("btn-check-uid"), ' 检查') }, showUidErr = t => { $("uid-check-err").textContent = t, $("uid-check-err").classList.add("vis") }; $("btn-check-uid").onclick = () => { const t = $("add-uid").value.trim(); t ? ($("uid-check-err").classList.remove("vis"), BtnState.load($("btn-check-uid"), "检查中"), $("name-select-group").style.display = "none", $("add-ct-ok").disabled = !0, addCtState.uid = t, post("CHECK_WORLDBOOK_UID", { uid: t, requestId: Req.create("uidck") })) : showUidErr("请输入UID") }, $("btn-add-ct").onclick = () => { resetAddCt(), openM("m-add-ct") }, $("add-ct-ok").onclick = () => { const t = addCtState.uid || $("add-uid").value.trim(), e = addCtState.name || $("add-name").value.trim(); t && e && (D.contacts.contacts.some((e => e.worldbookUid === t)) ? showUidErr("该联络人已存在") : (D.contacts.contacts.push({ name: e, avatar: e[0], color: "#" + Math.floor(16777215 * Math.random()).toString(16).padStart(6, "0"), location: "未知", worldbookUid: t, messages: [] }), saveCt(), closeM("m-add-ct"), render())) }; const genAddCt = (t, e, s) => { BtnState.load(s, "检查中"); const o = Req.create("stgwb"); Req.set("stgwb", { name: t, info: e, btn: s }), post("CHECK_STRANGER_WORLDBOOK", { requestId: o, strangerName: t }) }; function canonicalLoc(t) { return String(t || "").trim().replace(/^\u90ae\u8f6e/, "") } $("btn-refresh-strangers").onclick = () => { const t = $("btn-refresh-strangers"); BtnState.load(t, ""); const e = Req.create("extract"); Req.set("extract", { btn: t }), post("EXTRACT_STRANGERS", { requestId: e, existingContacts: D.contacts.contacts, existingStrangers: D.contacts.strangers }) }, $("world-gen-ok").onclick = () => { const t = $("world-gen-ok"), e = $("world-gen-status"); BtnState.load(t, "生成中"), e.style.display = "block", e.textContent = "正在生成世界数据,请稍候...", post("GENERATE_WORLD", { requestId: Req.create("wgen"), playerRequests: $("world-gen-req").value.trim() }) }, $("world-sim-ok").onclick = () => { if (!D.meta && !D.timeline && !D.maps?.outdoor) return void alert("请先生成世界数据,再进行推演"); const t = $("world-sim-ok"), e = $("world-sim-status"); BtnState.load(t, "推演中"), e.style.display = "block", e.style.color = "var(--ok)", e.textContent = "正在分析玩家行为并推演世界变化...", post("SIMULATE_WORLD", { requestId: id, currentData: JSON.stringify({ meta: D.meta, timeline: D.timeline, world: D.world, maps: D.maps }, null, 2) }) }, $("btn-deduce").onclick = () => openM("m-world-gen"), $("btn-simulate").onclick = () => openM("m-world-sim"), $("btn-side-menu-toggle").onclick = () => { const t = $("side-menu-panel"), e = $("btn-side-menu-toggle"); t.classList.toggle("show"), e.classList.toggle("act", t.classList.contains("show")) }, document.addEventListener("click", (t => { t.target.closest(".side-menu") || ($("side-menu-panel")?.classList.remove("show"), $("btn-side-menu-toggle")?.classList.remove("act")) })); const getWaitingContacts = t => { const e = canonicalLoc(t); return e ? (D.contacts.contacts || []).filter((t => t?.waitingAt && canonicalLoc(t.waitingAt) === e)) : [] }, finishTravel = (t, e) => { playerLocation = t, selectedMapValue = "current"; const s = getWaitingContacts(t); s.forEach((t => delete t.waitingAt)), saveAll(), render(), hideInfo(); const o = $("goto-task")?.value; let a = `{{user}}离开了${e || "上一地点"},来到${t}。${o ? "意图:" + o : ""}`; s.length && (a += ` ${s.map((t => t.name)).join("、")}已经在这里等你了。`), post("EXECUTE_SLASH_COMMAND", { command: `/sendas name="剧情任务" ${a}` }) }; $("goto-ok").onclick = () => { if (!curNode) return; const t = $("goto-ok"), e = D.maps?.outdoor?.nodes?.find((t => t.name === playerLocation)), s = { name: playerLocation, info: e?.info || "" }, o = curNode.name, a = D.maps?.indoor?.[o]; if (a?.description) return BtnState.reset(t, "确认前往"), closeM("m-goto"), finishTravel(o, s.name), void switchMapView("current"); let n = "main" === curNode.type ? "main" : "home" === curNode.type ? "home" : "sub"; const r = Req.create("scene"); Req.set("scene", { node: curNode, prev: s.name }), BtnState.load(t, "生成中"), post("SCENE_SWITCH", { requestId: r, prevLocationName: s.name, prevLocationInfo: s.info, targetLocationName: curNode.name, targetLocationType: n, targetLocationInfo: curNode.data?.info || "", playerAction: $("goto-task").value || "" }) }, $("btn-gen-local-map").onclick = () => { const t = $("btn-gen-local-map"); BtnState.load(t, "生成中"); const e = Req.create("localmap"); Req.set("localmap", { btn: t }), post("GENERATE_LOCAL_MAP", { requestId: e, outdoorDescription: D.maps?.outdoor?.description || "" }) }, $("btn-refresh-local-map").onclick = () => { if (!playerLocation) return void showResultModal("提示", "请先生成世界数据", !0); const t = $("btn-refresh-local-map"), e = D.maps?.indoor?.[playerLocation], s = e; if (!s) return void showResultModal("提示", "当前区域没有局部地图,请先生成", !0); BtnState.load(t, "刷新中"); const o = Req.create("localmaprf"); Req.set("localmaprf", { btn: t, loc: playerLocation }), post("REFRESH_LOCAL_MAP", { requestId: o, locationName: playerLocation, currentLocalMap: s, outdoorDescription: D.maps?.outdoor?.description || "" }) }, $("btn-gen-local-scene").onclick = () => { if (!playerLocation || "未知" === playerLocation) return void showResultModal("提示", "请先生成世界数据", !0); const t = $("btn-gen-local-scene"); BtnState.load(t, "生成中"); const e = D.maps?.outdoor?.nodes?.find((t => t.name === playerLocation)), s = D.maps?.indoor?.[playerLocation], o = s?.description || e?.info || e?.data?.info || "", a = Req.create("localscene"); Req.set("localscene", { btn: t, loc: playerLocation }), post("GENERATE_LOCAL_SCENE", { requestId: a, locationName: playerLocation, locationInfo: o }) }, $("btn-refresh-world-news").onclick = () => { const t = $("btn-refresh-world-news"); if (!t) return; t.disabled = !0, t._o = t._o ?? t.innerHTML, t.innerHTML = ''; const e = Req.create("newsrf"); Req.set("newsrf", { btn: t }), post("REFRESH_WORLD_NEWS", { requestId: e }) }; const saveAll = () => post("SAVE_ALL_DATA", { allData: { meta: D.meta, world: D.world, outdoor: D.maps.outdoor, indoor: D.maps.indoor, sceneSetup: D.sceneSetup, strangers: D.contacts.strangers, contacts: contactsForSave() }, playerLocation }), dataKeys = [["meta", "大纲", "核心真相、洋葱结构、时间线、用户指南", () => D.meta, t => D.meta = t], ["world", "世界资讯", "世界新闻等信息", () => D.world, t => D.world = t], ["outdoor", "大地图", "室外区域的地点和路线", () => D.maps.outdoor, t => D.maps.outdoor = t], ["indoor", "局部地图", "隐藏的室内/局部场景地图", () => D.maps.indoor, t => D.maps.indoor = t], ["sceneSetup", "区域剧情", "当前区域的 Side Story", () => D.sceneSetup, t => D.sceneSetup = t], ["characterContactSms", "角色卡短信", "角色卡联络人的短信记录", () => ({ messages: charSmsHistory?.messages || [], summarizedCount: charSmsHistory?.summarizedCount || 0, summaries: charSmsHistory?.summaries || {} }), t => { t && "object" == typeof t && (charSmsHistory = { messages: charSmsHistory?.messages || [], summarizedCount: charSmsHistory?.summarizedCount || 0, ...t || {} }) }], ["strangers", "陌路人", "已遇见但未建立联系的角色", () => D.contacts.strangers, t => D.contacts.strangers = t], ["contacts", "联络人", "已添加的联系人", () => contactsForSave(), t => { const e = (D.contacts.contacts || []).find(isCharCardContact); D.contacts.contacts = (e ? [e] : []).concat(Array.isArray(t) ? t : []) }]]; let gSet = { apiUrl: "", apiKey: "", model: "", mode: "assist" }, dataCk = {}, editCtx = null, commSet = { historyCount: 50, npcPosition: 0, npcOrder: 100, stream: !1 }, promptDefaults = { jsonTemplates: {}, promptSources: {} }, promptStores = { global: { jsonTemplates: {}, promptSources: {} }, character: { jsonTemplates: {}, promptSources: {} } }; const reqSet = () => post("GET_SETTINGS"), renderDataList = () => { $("data-list").innerHTML = dataKeys.map((([t, e, s]) => `
${e}
${s}
`)).join(""), $$("#data-list .data-item").forEach((t => t.onclick = e => { if (e.target.closest(".data-edit")) return; const s = t.dataset.k; dataCk[s] = !dataCk[s], t.classList.toggle("sel", dataCk[s]) })), $$("#data-list .data-edit").forEach((t => t.onclick = e => { e.stopPropagation(), openDataEdit(t.dataset.k) })) }, ADV_PROMPT_ITEMS = [["sms", "短信回复"], ["invite", "邀请回复"], ["npc", "NPC 生成"], ["stranger", "提取陌路人"], ["worldGenStep1", "大纲生成"], ["worldGenStep2", "世界生成"], ["worldSim", "世界推演(故事模式)"], ["worldSimAssist", "世界推演(辅助模式)"], ["worldNewsRefresh", "世界新闻刷新"], ["sceneSwitch", "场景切换"], ["localMapGen", "局部地图生成"], ["localMapRefresh", "局部地图刷新"], ["localSceneGen", "局部剧情生成"], ["summary", "总结压缩"]], advHasJsonTemplate = t => { const e = promptDefaults?.jsonTemplates || {}; return Object.prototype.hasOwnProperty.call(e, t) }, advGetScope = () => "global" === $("adv-scope")?.value ? "global" : "character", advStoreHasKey = (t, e) => { const s = ("global" === t ? promptStores.global : promptStores.character) || {}; return !(!Object.prototype.hasOwnProperty.call(s?.promptSources || {}, e) && !Object.prototype.hasOwnProperty.call(s?.jsonTemplates || {}, e)) }, advGetPromptObj = (t, e = "character") => { const s = promptDefaults?.promptSources || {}, o = promptStores?.global?.promptSources || {}, a = promptStores?.character?.promptSources || {}, n = ("global" === e ? o[t] || s[t] : a[t] || o[t] || s[t]) || {}; return { u1: "string" == typeof n.u1 ? n.u1 : "", a1: "string" == typeof n.a1 ? n.a1 : "", u2: "string" == typeof n.u2 ? n.u2 : "", a2: "string" == typeof n.a2 ? n.a2 : "" } }, advNormalizeDisplayText = t => { let e = String(t ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); return !e.includes("\n") && e.includes("\\n") && (e = e.replaceAll("\\n", "\n")), e.includes("\\t") && (e = e.replaceAll("\\t", "\t")), e.includes("\\`") && (e = e.replaceAll("\\`", "`")), e }, advNormalizeSaveText = t => String(t ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"), advGetJsonTemplate = (t, e = "character") => { const s = promptDefaults?.jsonTemplates || {}, o = promptStores?.global?.jsonTemplates || {}, a = promptStores?.character?.jsonTemplates || {}; if (!(advHasJsonTemplate(t) || Object.prototype.hasOwnProperty.call(o, t) || Object.prototype.hasOwnProperty.call(a, t))) return ""; const n = "global" === e ? Object.prototype.hasOwnProperty.call(o, t) ? o[t] : s[t] : Object.prototype.hasOwnProperty.call(a, t) ? a[t] : Object.prototype.hasOwnProperty.call(o, t) ? o[t] : s[t]; return "string" == typeof n ? n : "" }, advApplyToUI = (t, e = "character") => { const s = advGetPromptObj(t, e), o = (t, e) => { const s = $(t); s && (s.value = advNormalizeDisplayText(e)) }; o("adv-u1", s.u1), o("adv-a1", s.a1), o("adv-u2", s.u2), o("adv-a2", s.a2), $("adv-json-wrap").style.display = ""; const a = advGetJsonTemplate(t, e), n = $("adv-json"); n && (n.value = String(a ?? "")), advUpdateVarHelp(t) }, advUpdateVarHelp = t => { $$(".adv-vars-group").forEach((e => { const s = String(e.dataset.advFor || "").split(",").map((t => t.trim())).filter(Boolean); e.style.display = !s.length || s.includes(t) ? "" : "none" })) }, advBuildEdits = () => { const t = t => advNormalizeSaveText($(t)?.value ?? ""); return { prompt: { u1: t("adv-u1"), a1: t("adv-a1"), u2: t("adv-u2"), a2: t("adv-a2") }, jsonTemplate: advNormalizeSaveText($("adv-json")?.value ?? "") } }, advInit = () => { const t = $("adv-key"); if (!t || t._inited) return; t._inited = !0, t.innerHTML = ADV_PROMPT_ITEMS.map((([t, e]) => ``)).join(""), t.onchange = () => advApplyToUI(t.value, advGetScope()); const e = $("adv-scope"); e && !e._inited && (e._inited = !0, e.onchange = () => { const t = $("adv-key")?.value; t && advApplyToUI(t, advGetScope()) }) }, advOpen = () => { advInit(); const t = $("adv-key"), e = t?.value || ADV_PROMPT_ITEMS[0]?.[0]; if (e) { const t = $("adv-scope"); t && (t.value = advStoreHasKey("character", e) ? "character" : "global"), advApplyToUI(e, advGetScope()) } openM("m-adv-prompts") }, advSaveTo = t => { advInit(); const e = $("adv-key")?.value; if (!e) return; const { prompt: s, jsonTemplate: o } = advBuildEdits(); post("SAVE_PROMPTS", { scope: t, key: e, prompt: s, jsonTemplate: o }), closeM("m-adv-prompts") }, advReset = () => { advInit(); const t = $("adv-key")?.value; t && (post("SAVE_PROMPTS", { scope: advGetScope(), key: t, reset: !0 }), closeM("m-adv-prompts")) }, parseJsonLoose = t => { const e = String(t ?? "").trim(); if (!e) throw new Error("空内容"); try { return JSON.parse(e) } catch { } const s = e.match(/```[^\n]*\n([\s\S]*?)\n```/); if (s?.[1]) { const t = s[1].trim(); try { return JSON.parse(t) } catch { } } const o = (t, s) => { const o = e.indexOf(t), a = e.lastIndexOf(s); return -1 === o || -1 === a || a <= o ? null : e.slice(o, a + 1) }, a = o("{", "}") ?? o("[", "]"); return a ? JSON.parse(a) : JSON.parse(e) }, updateEditPreview = () => { const t = $("data-edit-preview"); t && (t.style.display = "none", t.textContent = "") }, setEditContent = (t, e) => { $("data-edit-title").textContent = t, $("data-edit-ta").value = e, $("data-edit-err").classList.remove("vis"), updateEditPreview(), openM("m-data-edit") }, openDataEdit = t => { const e = dataKeys.find((([e]) => e === t)); e && (editCtx = { type: "characterContactSms" === t ? "charSms" : "data", key: t }, setEditContent(`编辑 - ${e[1]}`, JSON.stringify(e[3](), null, 2))) }; $("data-edit-save").onclick = () => { if (editCtx) try { const t = parseJsonLoose($("data-edit-ta").value); if ("data" === editCtx.type) { const e = dataKeys.find((([t]) => t === editCtx.key)); if (!e) return; e[4](t), render(), saveAll() } else if ("charSms" === editCtx.type) { const e = t?.summaries ?? t; if (!e || "object" != typeof e || Array.isArray(e)) throw new Error("需要 summaries 对象"); charSmsHistory.summaries = e, post("SAVE_CHAR_SMS_HISTORY", { summaries: e }) } closeM("m-data-edit"), editCtx = null } catch (t) { $("data-edit-err").textContent = `JSON错误: ${t.message}`, $("data-edit-err").classList.add("vis") } }, $("data-edit-ta").addEventListener("input", updateEditPreview); const showTestRes = (t, e) => { const s = $("test-res"); s.textContent = e, s.className = "set-test-res " + (t ? "ok" : "err") }, showResultModal = (t, e, s = !1, o = null) => { $("res-title").textContent = t, $("res-title").style.color = s ? "var(--err)" : "", $("res-msg").textContent = e; const a = $("res-record-box"), n = $("res-record"); o ? (a.style.display = "block", n.textContent = "object" == typeof o ? JSON.stringify(o, null, 2) : String(o)) : (a.style.display = "none", n.textContent = ""); const r = $("res-action"); r.style.display = "none", r.textContent = "", r.onclick = null, openM("m-result") }; function render() { const t = D.world?.news || []; $("news-list").innerHTML = t.length ? t.map((t => `
${h(t.title)}
${h(t.time || "")}

${h(t.content)}

`)).join("") : '
暂无新闻
', $$("#news-list .fold").forEach(bindFold); const e = D.meta?.user_guide; e && ($("ug-state").textContent = e.current_state || "未知状态", $("ug-actions").innerHTML = (e.guides || []).map(((t, e) => `
${e + 1}. ${h(t)}
`)).join("") || '
暂无行动指南
'); const s = (t, e) => (t || []).length ? t.map((t => `
${h(t.avatar || "")}
${h(t.name || "")}
${t.online ? "● 在线" : h(t.location)}
${t.info ? `
${h(t.info)}
` : ""}
${e ? `` : ``}
`)).join("") : '
暂无
'; if ($("sec-stranger").innerHTML = s(D.contacts.strangers, !0), $("sec-contact").innerHTML = s(D.contacts.contacts, !1), $$(".comm-sec .fold").forEach(bindFold), $$(".add-btn").forEach((t => t.onclick = e => { e.stopPropagation(), genAddCt(t.dataset.name, t.dataset.info || "", t) })), $$(".ignore-btn").forEach((t => t.onclick = e => { e.stopPropagation(); const s = D.contacts.strangers.findIndex((e => e.name === t.dataset.name)); s > -1 && (D.contacts.strangers.splice(s, 1), saveCt(), render()) })), $$(".msg-btn").forEach((t => t.onclick = e => { e.stopPropagation(); const s = D.contacts.contacts.find((e => e.worldbookUid === t.dataset.uid)); s && openChat(s) })), $$(".inv-btn").forEach((t => t.onclick = e => { e.stopPropagation(); const s = D.contacts.contacts.find((e => e.worldbookUid === t.dataset.uid)); s && openInv(s) })), "current" === selectedMapValue) { const t = getCurInside(); $("side-desc").innerHTML = t?.description ? `
📍 ${h(playerLocation)}
` + parseLinks(t.description) : parseLinks(D.maps?.outdoor?.description || "") } else $("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""); bindLinks($("side-desc")), $("mob-desc").innerHTML = "", renderMapSelector(), renderMap() } function renderMap() { const t = D.maps?.outdoor; if (seed = 123456789, inner.querySelectorAll(".item").forEach((t => t.remove())), svg.innerHTML = "", nodes = [], lines = [], !t?.nodes?.length) return; t.nodes.forEach(((t, e) => { const s = dirMap[t.position] || [0, 0], o = Math.hypot(s[0], s[1]) || 1, a = 120 * (t.distant || 1); nodes.push({ id: "n" + e, name: t.name, type: t.type || "sub", pos: t.position, distant: t.distant || 1, x: s[0] / o * a, y: s[1] / o * a, data: t }) })); const e = {}; nodes.forEach((t => (e[t.pos] = e[t.pos] || []).push(t))); for (let t in e) { const s = e[t]; if (s.length <= 1) continue; const o = dirMap[t] || [0, 0], a = Math.hypot(o[0], o[1]) || 1, n = -o[1] / a, r = o[0] / a, i = (s.length - 1) / 2; s.sort(((t, e) => t.distant - e.distant)).forEach(((t, e) => { t.x += n * (e - i) * 50, t.y += r * (e - i) * 50 })) } for (let t = 0; t < 15; t++)for (let t = 0; t < nodes.length; t++)for (let e = t + 1; e < nodes.length; e++) { const s = nodes[t], o = nodes[e], a = s.x - o.x, n = s.y - o.y, r = Math.hypot(a, n); if (r < 80 && r > 0) { const t = (80 - r) / r * .5; s.x += a * t, s.y += n * t, o.x -= a * t, o.y -= n * t } } nodes.forEach((t => { const e = document.createElement("div"); e.className = `item node-${t.type}`, e.id = t.id, e.textContent = "home" === t.type ? "🏠 " + t.name : t.name, e.style.cssText = `left:${t.x + 2e3}px;top:${t.y + 2e3}px`, e.onclick = e => { e.stopPropagation(), curNode?.id === t.id ? hideInfo() : showInfo(t) }, inner.appendChild(e) })); for (let t in e) { const s = e[t].sort(((t, e) => t.distant - e.distant)); for (let t = 0; t < s.length - 1; t++)lines.push([s[t], s[t + 1]]) } const s = Object.values(e).map((t => t.sort(((t, e) => t.distant - e.distant))[0])).sort(((t, e) => Math.atan2(t.y, t.x) - Math.atan2(e.y, e.x))); s.forEach(((t, e) => lines.push([t, s[(e + 1) % s.length]]))); const o = (t, e) => lines.some((([s, o]) => s === t && o === e || s === e && o === t)); for (let t = 0, e = 0; e < Math.floor(nodes.length / 5) && t < 200; t++) { const t = nodes[Math.floor(rand() * nodes.length)], s = nodes[Math.floor(rand() * nodes.length)]; t === s || o(t, s) || (lines.push([t, s]), e++) } drawLines() } function drawLines() { svg.innerHTML = ""; const t = mapWrap.getBoundingClientRect(); lines.forEach((([e, s]) => { const o = $(e.id), a = $(s.id); if (!o || !a) return; const n = o.getBoundingClientRect(), r = a.getBoundingClientRect(), i = document.createElementNS("http://www.w3.org/2000/svg", "line"); i.setAttribute("x1", (n.left + n.width / 2 - t.left) / scale - offX / scale), i.setAttribute("y1", (n.top + n.height / 2 - t.top) / scale - offY / scale), i.setAttribute("x2", (r.left + r.width / 2 - t.left) / scale - offX / scale), i.setAttribute("y2", (r.top + r.height / 2 - t.top) / scale - offY / scale); const c = "main" === e.type && "main" === s.type || "home" === e.type || "home" === s.type; i.setAttribute("stroke", c ? "var(--c)" : "var(--c4)"), i.setAttribute("stroke-width", c ? "2" : "1"), c || i.setAttribute("stroke-dasharray", "4 3"), svg.appendChild(i) })) } $("btn-settings").onclick = () => { reqSet(), $("set-api-url").value = gSet.apiUrl || "", $("set-api-key").value = gSet.apiKey || "", $("set-model").value = gSet.model || "", $("set-model-list").style.display = "none", $("test-res").className = "set-test-res", $("set-stage").value = D.stage || 0, $("set-deviation").value = D.deviationScore || 0, $("set-sim-target").value = D.simulationTarget ?? 5, $("set-mode").value = gSet.mode || "story", $("set-history-count").value = commSet.historyCount || 50, $("set-use-stream").checked = !!commSet.stream, $("set-npc-position").value = commSet.npcPosition || 0, $("set-npc-order").value = commSet.npcOrder || 100, renderDataList(), syncSimDueUI(), openM("m-settings") }, $("btn-adv-prompts").onclick = () => advOpen(), $("adv-save-global").onclick = () => advSaveTo("global"), $("adv-save-char").onclick = () => advSaveTo("character"), $("adv-reset").onclick = () => advReset(), $("btn-fetch-models").onclick = () => { BtnState.load($("btn-fetch-models"), "加载"), post("FETCH_MODELS", { apiUrl: $("set-api-url").value.trim(), apiKey: $("set-api-key").value.trim() }) }, $("btn-test-conn").onclick = () => { $("test-res").className = "set-test-res", BtnState.load($("btn-test-conn"), "测试"), post("TEST_CONNECTION", { apiUrl: $("set-api-url").value.trim(), apiKey: $("set-api-key").value.trim(), model: $("set-model").value.trim() }) }, $("set-save").onclick = () => { gSet = { apiUrl: $("set-api-url").value.trim(), apiKey: $("set-api-key").value.trim(), model: $("set-model").value.trim(), mode: $("set-mode").value || "story" }, D.stage = Math.max(0, Math.min(10, parseInt($("set-stage").value, 10) || 0)), D.deviationScore = Math.max(0, Math.min(100, parseInt($("set-deviation").value, 10) || 0)), D.simulationTarget = parseInt($("set-sim-target").value, 10), Number.isNaN(D.simulationTarget) && (D.simulationTarget = 5), commSet = { historyCount: Math.max(0, Math.min(200, parseInt($("set-history-count").value, 10) || 50)), stream: !!$("set-use-stream").checked, npcPosition: parseInt($("set-npc-position").value, 10) || 0, npcOrder: Math.max(0, Math.min(1e3, parseInt($("set-npc-order").value, 10) || 100)) }; const t = {}; dataKeys.forEach((([e, , , s]) => { dataCk[e] && (t[e] = s()) })), syncSimDueUI(), post("SAVE_SETTINGS", { globalSettings: gSet, commSettings: commSet, stage: D.stage, deviationScore: D.deviationScore, simulationTarget: D.simulationTarget, playerLocation, dataChecked: dataCk, outlineData: t, allData: { meta: D.meta, timeline: D.timeline, world: D.world, outdoor: D.maps.outdoor, indoor: D.maps.indoor, strangers: D.contacts.strangers, contacts: contactsForSave() } }), closeM("m-settings") }, $("btn-close").onclick = () => post("CLOSE_PANEL"), window.addEventListener("message", (t => { if (t.origin !== PARENT_ORIGIN || t.source !== parent) return; if ("LittleWhiteBox" !== t.data?.source) return; const e = t.data, s = e.type; if ("LOAD_SETTINGS" === s) { if (e.globalSettings && (gSet = e.globalSettings), void 0 !== e.stage && (D.stage = e.stage), void 0 !== e.deviationScore && (D.deviationScore = e.deviationScore), void 0 !== e.simulationTarget && (D.simulationTarget = e.simulationTarget), e.playerLocation && (playerLocation = e.playerLocation), e.commSettings && (commSet = { historyCount: e.commSettings.historyCount ?? 50, npcPosition: e.commSettings.npcPosition ?? 0, npcOrder: e.commSettings.npcOrder ?? 100, stream: !!e.commSettings.stream }), e.dataChecked && (dataCk = e.dataChecked), e.promptConfig && (promptDefaults = e.promptConfig.defaults || promptDefaults, e.promptConfig.stores ? (promptStores.global = e.promptConfig.stores.global || { jsonTemplates: {}, promptSources: {} }, promptStores.character = e.promptConfig.stores.character || { jsonTemplates: {}, promptSources: {} }) : (promptStores.global = e.promptConfig.current || { jsonTemplates: {}, promptSources: {} }, promptStores.character = promptStores.character || { jsonTemplates: {}, promptSources: {} })), e.outlineData) { const t = e.outlineData; t.meta && (D.meta = t.meta), t.world && (D.world = t.world), t.outdoor && (D.maps.outdoor = t.outdoor), t.indoor && (D.maps.indoor = t.indoor), t.sceneSetup && (D.sceneSetup = t.sceneSetup), t.strangers && (D.contacts.strangers = t.strangers), t.contacts && (D.contacts.contacts = t.contacts) } { const t = e.characterContactSmsHistory || {}; charSmsHistory = { messages: Array.isArray(t.messages) ? t.messages : [], summarizedCount: t.summarizedCount || 0, summaries: t.summaries || {} } } let t = D.contacts.contacts.find((t => "__CHARACTER_CARD__" === t.worldbookUid)); t || (t = D.contacts.contacts.find((t => !t.worldbookUid && "炒饭智能" === t.name)), t ? (t.worldbookUid = "__CHARACTER_CARD__", t.info = "角色卡联络人", t.location = "在线", t.online = !0) : (D.contacts.contacts.unshift({ name: e.characterCardName || "{{characterName}}", avatar: "", color: "#555", location: "在线", info: "角色卡联络人", online: !0, worldbookUid: "__CHARACTER_CARD__", messages: [], summarizedCount: 0 }), t = D.contacts.contacts[0])), t && e.characterCardName && (t.name = e.characterCardName, t.avatar = (e.characterCardName || "")[0] || t.avatar || ""), render(), syncSimDueUI(), $("m-settings").classList.contains("act") && ($("set-api-url").value = gSet.apiUrl || "", $("set-api-key").value = gSet.apiKey || "", $("set-model").value = gSet.model || "", $("set-stage").value = D.stage, $("set-deviation").value = D.deviationScore, $("set-sim-target").value = D.simulationTarget ?? 5, $("set-mode").value = gSet.mode || "story", $("set-history-count").value = commSet.historyCount, $("set-use-stream").checked = !!commSet.stream, $("set-npc-position").value = commSet.npcPosition, $("set-npc-order").value = commSet.npcOrder, renderDataList()) } else if ("PROMPT_CONFIG_UPDATED" === s) { if (e.promptConfig && (promptDefaults = e.promptConfig.defaults || promptDefaults, e.promptConfig.stores ? (promptStores.global = e.promptConfig.stores.global || { jsonTemplates: {}, promptSources: {} }, promptStores.character = e.promptConfig.stores.character || { jsonTemplates: {}, promptSources: {} }) : (promptStores.global = e.promptConfig.current || { jsonTemplates: {}, promptSources: {} }, promptStores.character = promptStores.character || { jsonTemplates: {}, promptSources: {} }), $("m-adv-prompts").classList.contains("act"))) { const t = $("adv-key")?.value; t && advApplyToUI(t, advGetScope()) } } else if ("FETCH_MODELS_RESULT" === s) { BtnState.reset($("btn-fetch-models"), "获取"); const t = $("set-model-list"); if (e.error) return t.style.display = "none", void showTestRes(!1, "获取模型失败: " + e.error); if (!e.models?.length) return t.style.display = "none", void showTestRes(!1, "未找到可用模型"); t.innerHTML = '' + e.models.map((t => ``)).join(""), t.style.display = "block", t.onchange = () => { t.value && ($("set-model").value = t.value) }, showTestRes(!0, `找到 ${e.models.length} 个模型`) } else if ("TEST_CONN_RESULT" === s) BtnState.reset($("btn-test-conn"), "测试连接"), showTestRes(e.success, e.message); else if ("CHECK_WORLDBOOK_UID_RESULT" === s) { if (BtnState.reset($("btn-check-uid"), ' 检查'), !Req.match(e.requestId)) return; if (e.error) return void showUidErr(e.error); if (!e.primaryKeys?.length) return void showUidErr("该条目没有主要关键字"); addCtState.keys = e.primaryKeys; const t = $("add-name"); t.innerHTML = '' + e.primaryKeys.map((t => ``)).join(""), t.onchange = () => { addCtState.name = t.value, $("add-ct-ok").disabled = !t.value }, $("name-select-group").style.display = "block", 1 === e.primaryKeys.length && (addCtState.name = e.primaryKeys[0], t.value = addCtState.name, $("add-ct-ok").disabled = !1) } else if ("SMS_RESULT" === s) { const t = Req.get("sms"); if (!t || t.id !== e.requestId) return; if (Req.clear("sms"), smsGen = !1, $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !1, !chatTgt) return; chatTgt.messages = chatTgt.messages.filter((t => !t.typing)), e.error ? chatTgt.messages.push({ type: "received", text: `[错误] ${e.error}` }) : e.reply && chatTgt.messages.push({ type: "received", text: stripXml(e.reply) }), renderMsgs(), saveCt(), chatTgt.worldbookUid && saveChat(chatTgt) } else if ("SMS_STREAM" === s) { const t = Req.get("sms"); if (!t || t.id !== e.requestId || !chatTgt) return; const s = chatTgt.messages.find((t => t.typing)); s && e.text && (s.text = e.text, renderMsgs()) } else if ("LOAD_SMS_HISTORY_RESULT" === s) { if (!chatTgt || chatTgt.worldbookUid !== e.worldbookUid) return; e.messages?.length && (chatTgt.messages = e.messages, chatTgt.summarizedCount = e.summarizedCount || 0, saveCt()), renderMsgs() } else if ("COMPRESS_SMS_RESULT" === s) { const t = Req.get("compress"); if (!t || t.id !== e.requestId) return; if (Req.clear("compress"), smsGen = !1, $("chat-compress").disabled = $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !1, !chatTgt) return; if (e.error) return void alert(`压缩失败: ${e.error}`); void 0 !== e.newSummarizedCount && (chatTgt.summarizedCount = e.newSummarizedCount, renderMsgs(), saveCt()) } else if ("CHECK_STRANGER_WORLDBOOK_RESULT" === s) { const t = Req.get("stgwb"); if (!t || t.id !== e.requestId) return; const { name: s, info: o, btn: a } = t; if (Req.clear("stgwb"), e.found && e.worldbookUid) { BtnState.reset(a, ' 添加'); const t = D.contacts.strangers.findIndex((t => t.name === s)); if (t > -1) { const s = D.contacts.strangers.splice(t, 1)[0]; D.contacts.contacts.push({ name: s.name, avatar: s.avatar || s.name[0], color: s.color || "#" + Math.floor(16777215 * Math.random()).toString(16).padStart(6, "0"), location: s.location || "未知", info: s.info || "", worldbookUid: e.worldbookUid, messages: [] }), saveCt(), render() } } else { BtnState.load(a, "生成中"); const t = Req.create("npcgen"); Req.set("npcgen", { name: s, info: o, btn: a }), post("GENERATE_NPC", { requestId: t, strangerName: s, strangerInfo: o }) } } else if ("GENERATE_NPC_RESULT" === s) { const t = Req.get("npcgen"); if (!t || t.id !== e.requestId) return; const { name: s, btn: o } = t; if (Req.clear("npcgen"), BtnState.reset(o, ' 添加'), e.error) return void showResultModal("生成角色失败", "生成 NPC 失败", !0, e.error); if (e.success && e.worldbookUid) { const t = D.contacts.strangers.findIndex((t => t.name === s)); if (t > -1) { const o = D.contacts.strangers.splice(t, 1)[0], a = e.npcData || {}; D.contacts.contacts.push({ name: a.name || o.name, avatar: (a.name || o.name)[0], color: o.color || "#" + Math.floor(16777215 * Math.random()).toString(16).padStart(6, "0"), location: o.location || "未知", info: a.intro || o.info || "", worldbookUid: e.worldbookUid, messages: [] }), saveCt(), render(), showResultModal("生成成功", `NPC ${s} 已生成并添加到联络人`, !1, e.npcData) } } } else if ("EXTRACT_STRANGERS_RESULT" === s) { const t = Req.get("extract"); if (!t || t.id !== e.requestId) return; const { btn: s } = t; if (Req.clear("extract"), BtnState.reset(s, ''), e.error) return void showResultModal("提取失败", "提取陌路人失败", !0, e.error); if (e.success && Array.isArray(e.strangers)) { if (!e.strangers.length) return void showResultModal("提取结果", "没有发现新的陌路人"); const t = [...D.contacts.contacts.map((t => t.name)), ...D.contacts.strangers.map((t => t.name))], s = e.strangers.filter((e => !t.includes(e.name))); if (!s.length) return void showResultModal("提取结果", "提取到的角色都已存在"); D.contacts.strangers = D.contacts.strangers.concat(s), saveCt(), render(), showResultModal("提取成功", `成功提取 ${s.length} 个新陌路人`, !1, s) } } else if ("GENERATE_WORLD_STATUS" === s) { if (!Req.match(e.requestId)) return; const t = $("world-gen-status"); t.style.display = "block", t.style.color = "var(--ok)", t.textContent = e.message } else if ("GENERATE_WORLD_RESULT" === s) { if (!Req.match(e.requestId)) return; Req.clear("wgen"); const t = $("world-gen-ok"), s = $("world-gen-status"); if (BtnState.reset(t, ' 开始生成'), e.error) { if (s.style.display = "none", showResultModal("生成失败", "世界生成失败", !0, e.error), String(e.error || "").includes("Step 2")) { const e = $("res-action"); e.style.display = "inline-block", e.textContent = "重试 Step2", e.onclick = () => { closeM("m-result"); const e = Req.create("wgen"); BtnState.load(t, "重试中"), s.style.display = "block", s.style.color = "var(--ok)", s.textContent = "准备重试 Step 2/2...", post("RETRY_WORLD_GEN_STEP2", { requestId: e }) } } return } if (e.success && e.worldData) { s.style.color = "var(--ok)", s.textContent = "生成成功!正在应用数据..."; const t = e.worldData; if (t.meta && (D.meta = t.meta), t.world && (D.world = t.world), t.maps?.outdoor && (D.maps.outdoor = t.maps.outdoor), D.stage = 0, D.deviationScore = 0, t.playerLocation) playerLocation = t.playerLocation; else { const t = D.maps?.outdoor?.nodes?.find((t => "home" === t.type)); playerLocation = t?.name || D.maps?.outdoor?.nodes?.[0]?.name || "未知" } t.maps?.inside && playerLocation && (D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[playerLocation] = t.maps.inside), selectedMapValue = "current", saveAll(), render(), setTimeout((() => { closeM("m-world-gen"), s.style.display = "none", $("world-gen-req").value = "", showResultModal("生成成功", "世界数据生成完成!Stage 和 Deviation 已重置为 0", !1, e.worldData) }), 500) } } else if ("SIMULATE_WORLD_RESULT" === s) { if (!e.isAuto && !Req.match(e.requestId)) return; if (!e.isAuto) { Req.clear("wsim"); const t = $("world-sim-ok"), s = $("world-sim-status"); if (BtnState.reset(t, ' 开始推演'), e.error) return s.style.display = "none", void showResultModal("推演失败", "世界推演失败", !0, e.error) } if (e.success && e.simData) { if (!e.isAuto) { const t = $("world-sim-status"); t.style.color = "var(--ok)", t.textContent = "推演成功!正在应用数据..." } const t = e.simData; t.meta && (D.meta = t.meta), t.world && (D.world = t.world), t.maps?.outdoor && (D.maps.outdoor = t.maps.outdoor), D.stage = (D.stage || 0) + 1, saveAll(), render(), e.isAuto || setTimeout((() => { closeM("m-world-sim"), $("world-sim-status").style.display = "none", showResultModal("推演成功", `世界推演完成!Stage 已推进到 ${D.stage}`, !1, e.simData) }), 500) } } else if ("REFRESH_WORLD_NEWS_RESULT" === s) { const t = Req.get("newsrf"); if (!t || t.id !== e.requestId) return; const { btn: s } = t; if (Req.clear("newsrf"), s && (s.disabled = !1, s.innerHTML = s._o ?? ''), e.error) return void showResultModal("刷新失败", "世界新闻刷新失败", !0, e.error); e.success && Array.isArray(e.news) && (D.world = D.world || {}, D.world.news = e.news, saveAll(), render(), showResultModal("刷新成功", `已更新世界新闻(${e.news.length} 条)`, !1, e.news)) } else if ("SCENE_SWITCH_RESULT" === s) { const t = Req.get("scene"); if (!t || t.id !== e.requestId) return; const { node: s, prev: o } = t; if (Req.clear("scene"), BtnState.reset($("goto-ok"), "确认前往"), closeM("m-goto"), e.error) return void showResultModal("切换失败", "场景切换失败", !0, e.error); if (e.success && e.sceneData) { const t = e.sceneData; if ("number" == typeof t.newScore && (D.deviationScore = t.newScore), t.localMap && (D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[s.name] = t.localMap), t.strangers?.length) { const e = new Set((D.contacts.strangers || []).map((t => t.name))), s = t.strangers.filter((t => !e.has(t.name))); D.contacts.strangers = [...D.contacts.strangers || [], ...s] } finishTravel(s.name, o), 0 !== t.scoreDelta && showResultModal("切换成功", `场景切换完成!\n偏差值变化: ${t.scoreDelta > 0 ? "+" : ""}${t.scoreDelta} (当前: ${t.newScore})`, !1, e.sceneData) } } else if ("REFRESH_LOCAL_MAP_RESULT" === s) { const t = Req.get("localmaprf"); if (!t || t.id !== e.requestId) return; const { btn: s, loc: o } = t; if (Req.clear("localmaprf"), BtnState.reset(s, '刷新'), e.error) return void showResultModal("刷新失败", "刷新局部地图失败", !0, e.error); if (e.success && e.localMapData) { const t = e.localMapData, s = t.name || o || playerLocation || "当前位置"; D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[s] = t, playerLocation = s, selectedMapValue = "current", saveAll(), render(), t.description && ($("side-desc").innerHTML = `
📍 ${h(s)}
` + parseLinks(t.description), bindLinks($("side-desc"))), showResultModal("刷新成功", `局部地图已刷新!当前位置: ${s}`, !1, e.localMapData) } } else if ("GENERATE_LOCAL_SCENE_RESULT" === s) { const t = Req.get("localscene"); if (!t || t.id !== e.requestId) return; const { btn: s, loc: o } = t; if (Req.clear("localscene"), BtnState.reset(s, '局部剧情'), e.error) return void showResultModal("生成失败", "局部剧情生成失败", !0, e.error); if (e.success && e.sceneSetup) { D.sceneSetup = { ...D.sceneSetup || {}, ...e.sceneSetup || {} }, saveAll(), render(); const t = String(e.introduce || "").replace(/^\s*(?:\/?\s*(?:sendas|as)\s+name\s*=\s*(?:"[^"]*"|'[^']*'|\S+)\s+)/i, "").replace(/\s+/g, " ").trim(); t && post("EXECUTE_SLASH_COMMAND", { command: `/sendas name="剧情任务" ${t}` }), showResultModal("生成成功", `局部剧情已生成:${o || playerLocation}`, !1, e.sceneSetup) } } else if ("SEND_INVITE_RESULT" === s) { const t = Req.get("invite"); if (!t || t.id !== e.requestId) return; const { contact: s, loc: o, btn: a } = t; if (Req.clear("invite"), BtnState.reset(a, "发送邀请"), e.error) return void showResultModal("邀请失败", "邀请发送失败", !0, e.error); if (e.success && e.inviteData) { const t = e.inviteData, a = canonicalLoc(t.targetLocation || o || ""), n = D.contacts.contacts.find((t => t && s && t.worldbookUid && s.worldbookUid && t.worldbookUid === s.worldbookUid)) || D.contacts.contacts.find((t => t && s && t.name === s.name)) || s; if (n.messages = n.messages || [], n.messages.push({ type: "sent", text: `我邀请你前往「${a}」` }), n.messages.push({ type: "received", text: t.reply }), t.accepted) { const e = canonicalLoc(playerLocation); n.location = a, e && a && e === a ? (delete n.waitingAt, post("EXECUTE_SLASH_COMMAND", { command: `/sendas name="剧情任务" ${n.name}过来了。` })) : n.waitingAt = a, showResultModal("邀请成功", `${n.name} 接受了邀请!\n回复: ${t.reply}`, !1, t) } else showResultModal("邀请被拒", `${n.name} 拒绝了邀请。\n回复: ${t.reply}`, !1, t); saveAll(), n.worldbookUid && saveChat(n), closeM("m-invite"), render(), openChat(n) } } else if ("GENERATE_LOCAL_MAP_RESULT" === s) { const t = Req.get("localmap"); if (!t || t.id !== e.requestId) return; const { btn: s } = t; if (Req.clear("localmap"), BtnState.reset(s, '局部地图'), e.error) return void showResultModal("生成失败", "局部地图生成失败", !0, e.error); if (e.success && e.localMapData) { const t = e.localMapData, s = t.name || "当前位置"; D.sceneSetup = null, D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[s] = t, playerLocation = s, selectedMapValue = "current", saveAll(), render(), t.description && ($("side-desc").innerHTML = `
📍 ${h(s)}
` + parseLinks(t.description), bindLinks($("side-desc"))), showResultModal("生成成功", `局部地图生成完成!当前位置: ${s}`, !1, t) } } })); const updateTf = () => { inner.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`, $("zoom-ind").textContent = Math.round(100 * scale) + "%", requestAnimationFrame(drawLines) }, initPos = () => { offX = mapWrap.clientWidth / 2 - 2e3, offY = mapWrap.clientHeight / 2 - 2e3, scale = 1, updateTf() }; function panTo(t, e = 350) { if (!t || anim) return; if (!$(t.id)) return; const s = -(t.x + 2e3) * scale + mapWrap.clientWidth / 2, o = -(t.y + 2e3) * scale + .25 * mapWrap.clientHeight, a = offX, n = offY, r = performance.now(); anim = !0, function t(i) { const c = Math.min((i - r) / e, 1), l = 1 - Math.pow(1 - c, 3); offX = a + (s - a) * l, offY = n + (o - n) * l, updateTf(), c < 1 ? requestAnimationFrame(t) : anim = !1 }(r) } function showInfo(t) { if (!t?.data) return; curNode = t, inner.querySelectorAll(".item").forEach((t => t.classList.remove("hl"))), $(t.id)?.classList.add("hl"); const e = t.name === playerLocation; $("btn-goto").classList.toggle("show", !e), e || ($("goto-t").textContent = `前往 ${t.name}`); const s = D.maps?.indoor?.[t.name]; e && s?.description ? ($("side-desc").innerHTML = `
📍 ${h(t.name)}
` + parseLinks(s.description), bindLinks($("side-desc"))) : ($("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc"))), isMob() ? ($("mob-info-t").textContent = t.name, $("mob-info-c").textContent = e ? s?.description || t.data.info || "暂无信息..." : t.data.info || "暂无信息...", popup.classList.contains("act") || openPop(1)) : ($("info-t").textContent = t.name, $("info-c").textContent = t.data.info || "暂无信息...", $("tip").classList.add("show")) } const hideInfo = () => { curNode = null, inner.querySelectorAll(".item").forEach((t => t.classList.remove("hl"))), $("btn-goto").classList.remove("show"), $("tip").classList.remove("show") }; function renderMapSelector() { const t = $("map-lbl-select"); t.innerHTML = ''; const e = D.maps?.outdoor?.nodes?.findIndex((t => t.name === playerLocation)), s = D.maps?.indoor && D.maps.indoor[playerLocation]; if ((e >= 0 || s) && (t.innerHTML += ``), t.innerHTML += "", D.maps?.outdoor?.nodes?.length && D.maps.outdoor.nodes.forEach(((e, s) => { e.name !== playerLocation && (t.innerHTML += ``) })), D.maps?.indoor) { const e = Object.keys(D.maps.indoor).filter((t => t !== playerLocation && !D.maps?.outdoor?.nodes?.some((e => e.name === t)))); e.length && (t.innerHTML += "", e.forEach((e => t.innerHTML += ``))) } t.value = selectedMapValue, updateMapLabel() } function updateMapLabel() { const t = $("map-lbl-select").value; if ("overview" === t) $("map-lbl-t").textContent = "大地图"; else if ("current" === t) $("map-lbl-t").textContent = playerLocation + "(你)"; else if (t.startsWith("node:")) { const e = parseInt(t.split(":")[1]); $("map-lbl-t").textContent = D.maps?.outdoor?.nodes?.[e]?.name || "未知" } else t.startsWith("indoor:") && ($("map-lbl-t").textContent = t.replace("indoor:", "")) } function switchMapView(t) { if (selectedMapValue = t, hideInfo(), "overview" === t) $("btn-goto").classList.remove("show"), $("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc")), initPos(); else if ("current" === t) { $("btn-goto").classList.remove("show"); const t = getCurInside(); t?.description ? ($("side-desc").innerHTML = `
📍 ${h(playerLocation)}
` + parseLinks(t.description), bindLinks($("side-desc"))) : ($("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc"))); const e = nodes.find((t => t.name === playerLocation)); e && (panTo(e), showInfo(e)) } else if (t.startsWith("node:")) { const e = parseInt(t.split(":")[1]), s = D.maps?.outdoor?.nodes?.[e]; $("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc")); const o = nodes.find((t => t.name === s?.name)); o && (panTo(o), showInfo(o)) } else if (t.startsWith("indoor:")) { const e = t.replace("indoor:", ""), s = D.maps?.indoor?.[e]; e !== playerLocation ? ($("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc")), curNode = { name: e, type: "sub", data: { info: stripXml(s?.description || "") }, isIndoor: !0 }, $("btn-goto").classList.add("show"), $("goto-t").textContent = `前往 ${e}`) : ($("btn-goto").classList.remove("show"), s?.description && ($("side-desc").innerHTML = `
🏠 ${h(e)}
` + parseLinks(s.description), bindLinks($("side-desc")))) } updateMapLabel() } $("info-bk").onclick = hideInfo, $("mob-info-bk").onclick = () => popup.classList.remove("act"), $("map-lbl-select").onchange = t => switchMapView(t.target.value); let sx, sy, lastDist = 0, lastCX = 0, lastCY = 0; mapWrap.onmousedown = t => { anim || t.target.closest(".map-act,.map-lbl") || (t.target.classList.contains("item") || hideInfo(), drag = !0, sx = t.clientX, sy = t.clientY, mapWrap.style.cursor = "grabbing") }, mapWrap.onmousemove = t => { drag && (offX += t.clientX - sx, offY += t.clientY - sy, sx = t.clientX, sy = t.clientY, updateTf()) }, mapWrap.onmouseup = mapWrap.onmouseleave = () => { drag = !1, mapWrap.style.cursor = "grab" }, mapWrap.onwheel = t => { if (anim) return; t.preventDefault(); const e = Math.max(.3, Math.min(3, scale + (t.deltaY > 0 ? -.1 : .1))), s = mapWrap.getBoundingClientRect(), o = t.clientX - s.left, a = t.clientY - s.top, n = e / scale; offX = o - (o - offX) * n, offY = a - (a - offY) * n, scale = e, updateTf() }, mapWrap.ontouchstart = t => { if (anim || t.target.closest(".map-act,.map-lbl")) return; const e = t.target.closest(".item"); e ? e._ts = { x: t.touches[0].clientX, y: t.touches[0].clientY, t: Date.now() } : (hideInfo(), 1 === t.touches.length ? (drag = !0, sx = t.touches[0].clientX, sy = t.touches[0].clientY) : 2 === t.touches.length && (drag = !1, lastDist = Math.hypot(t.touches[0].clientX - t.touches[1].clientX, t.touches[0].clientY - t.touches[1].clientY), lastCX = (t.touches[0].clientX + t.touches[1].clientX) / 2, lastCY = (t.touches[0].clientY + t.touches[1].clientY) / 2)) }, mapWrap.ontouchmove = t => { const e = t.target.closest(".item"); if (e && e._ts) Math.hypot(t.touches[0].clientX - e._ts.x, t.touches[0].clientY - e._ts.y) > 10 && (delete e._ts, drag = !0, sx = t.touches[0].clientX, sy = t.touches[0].clientY); else if (1 === t.touches.length && drag) t.preventDefault(), offX += t.touches[0].clientX - sx, offY += t.touches[0].clientY - sy, sx = t.touches[0].clientX, sy = t.touches[0].clientY, updateTf(); else if (2 === t.touches.length) { t.preventDefault(); const e = Math.hypot(t.touches[0].clientX - t.touches[1].clientX, t.touches[0].clientY - t.touches[1].clientY), s = (t.touches[0].clientX + t.touches[1].clientX) / 2, o = (t.touches[0].clientY + t.touches[1].clientY) / 2, a = Math.max(.3, Math.min(3, scale * (e / lastDist))), n = mapWrap.getBoundingClientRect(), r = s - n.left, i = o - n.top, c = a / scale; offX = r - (r - offX) * c, offY = i - (i - offY) * c, offX += s - lastCX, offY += o - lastCY, scale = a, lastDist = e, lastCX = s, lastCY = o, updateTf() } }, mapWrap.ontouchend = t => { const e = t.target.closest(".item"); if (e && e._ts) { const s = Date.now() - e._ts.t; if (delete e._ts, s < 300) { const s = nodes.find((t => t.id === e.id)); s && (t.preventDefault(), curNode?.id === s.id ? hideInfo() : showInfo(s)) } } drag = !1 }, $$(".nav-i").forEach((t => t.onclick = () => { $$(".nav-i").forEach((t => t.classList.remove("act"))), $$(".page").forEach((t => t.classList.remove("act"))), t.classList.add("act"), $(`page-${t.dataset.p}`).classList.add("act"); const e = "map" === t.dataset.p; sidePop.classList.toggle("show", e), isMob() && (e ? openPop(1) : popup.classList.remove("act")), e && setTimeout((() => { initPos(), drawLines() }), 50) })), $$(".comm-tab").forEach((t => t.onclick = () => { $$(".comm-tab").forEach((t => t.classList.remove("act"))), $$(".comm-sec").forEach((t => t.classList.remove("act"))), t.classList.add("act"), $(`sec-${t.dataset.t}`).classList.add("act") })), $("btn-goto").onclick = t => { t.stopPropagation(), curNode && ($("goto-d").textContent = `目的地:${curNode.name}`, $("goto-task").value = "", openM("m-goto")) }, addEventListener("resize", (() => requestAnimationFrame(drawLines))), window.clickTab = (t, e) => { const s = t.closest(".settings-modal"); if (!s) return; s.querySelectorAll(".set-nav-item").forEach((t => t.classList.remove("act"))), t.classList.add("act"), s.querySelectorAll(".set-tab-page").forEach((t => t.classList.remove("act"))); const o = document.getElementById(e); o && (o.classList.add("act"), o.style.animation = "none", o.offsetHeight, o.style.animation = null) }, document.addEventListener("DOMContentLoaded", (() => { render(), initPos(), sidePop.classList.add("show"), sidePop.classList.add("act"), isMob() && openPop(1), post("FRAME_READY"), setTimeout((() => { "current" === selectedMapValue && switchMapView("current") }), 100) })); + const inds = popup.querySelectorAll(".pop-h-ind span"), setPopH = t => { const e = snaps(); popH = Math.max(e[0], Math.min(.85 * innerHeight, t)), popup.style.height = popH + "px", popLv = e.map(((t, e) => [e, Math.abs(popH - t)])).sort(((t, e) => t[1] - e[1]))[0][0], inds.forEach(((t, e) => t.classList.toggle("act", e === popLv))) }, snapTo = t => { popLv = Math.max(0, Math.min(2, t)), setPopH(snaps()[popLv]) }, openPop = (t = 1) => { popup.classList.add("act"), snapTo(t) }; $("side-pop-handle").onclick = () => sidePop.classList.toggle("act"), $("pop-hd").onmousedown = t => { popDrag = !0, popSY = t.clientY, popSH = popH || snaps()[1], popup.classList.add("drag"), t.preventDefault() }, $("pop-hd").ontouchstart = t => { t.preventDefault(), popDrag = !0, popSY = t.touches[0].clientY, popSH = popH || snaps()[1], popup.classList.add("drag") }, document.onmousemove = t => { popDrag && setPopH(popSH + popSY - t.clientY) }, document.ontouchmove = t => { popDrag && t.touches.length && (t.preventDefault(), setPopH(popSH + popSY - t.touches[0].clientY)) }; const endDrag = () => { popDrag && (popDrag = !1, popup.classList.remove("drag"), snapTo(popLv)) }; document.onmouseup = endDrag, document.ontouchend = endDrag, document.ontouchcancel = endDrag; const bindLinks = t => t.querySelectorAll(".loc-lk").forEach((t => t.onclick = e => { e.stopPropagation(); const s = t.dataset.loc, o = nodes.find((t => t.name === s)); if (o) return panTo(o), void showInfo(o); const a = getCurInside(), n = a?.nodes?.find((t => t.name === s)); n && ($("info-t").textContent = n.name, $("info-c").textContent = n.info || "暂无信息...", $("tip").classList.add("show"), $("btn-goto").classList.remove("show"), isMob() && ($("mob-info-t").textContent = n.name, $("mob-info-c").textContent = n.info || "暂无信息...", popup.classList.contains("act") || openPop(1))) })), bindFold = t => t.querySelector(".fold-h").onclick = () => t.classList.toggle("exp"); $$(".modal-bd,.modal-x,.m-cancel").forEach((t => t.onclick = () => t.closest(".modal").classList.remove("act"))); const openChat = t => { chatTgt = t, $("chat-av").textContent = t.avatar, $("chat-av").style.background = t.color, $("chat-nm").textContent = t.name, $("chat-st").textContent = t.online ? "● 在线" : t.location, !t.worldbookUid || t.messages && t.messages.length ? renderMsgs() : post("LOAD_SMS_HISTORY", { worldbookUid: t.worldbookUid }), chat.classList.add("act"), $("chat-in").focus() }, closeChat = () => { chat.classList.remove("act"), chatTgt = null, smsGen = !1 }, renderMsgs = () => { if (!chatTgt) return; const t = chatTgt.messages || [], e = chatTgt.summarizedCount || 0; let s = ""; t.length ? t.forEach(((t, o) => { e > 0 && o === e && (s += '
—— 以上为已总结消息 ——
'), s += `
${escHtml(stripXml(t.text))}
` })) : s = '
暂无消息,开始聊天吧
', $("chat-msgs").innerHTML = s, $("chat-msgs").scrollTop = $("chat-msgs").scrollHeight, $("chat-compress").disabled = t.length - e < 2 || smsGen, $("chat-back").disabled = !t.length || smsGen }, sendMsg = () => { const t = $("chat-in").value.trim(); if (!t || !chatTgt || smsGen) return; chatTgt.messages = chatTgt.messages || [], chatTgt.messages.push({ type: "sent", text: t }), $("chat-in").value = "", renderMsgs(), smsGen = !0, $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !0, chatTgt.messages.push({ type: "received", text: "正在输入...", typing: !0 }), renderMsgs(); const e = Req.create("sms"); Req.set("sms", { tgt: chatTgt }), post("SEND_SMS", { requestId: e, contactName: chatTgt.name, worldbookUid: chatTgt.worldbookUid, userMessage: t, chatHistory: chatTgt.messages.filter((t => !t.typing)).slice(-20), summarizedCount: chatTgt.summarizedCount || 0 }) }, isCharCardContact = t => "__CHARACTER_CARD__" === t?.worldbookUid, contactsForSave = () => (D.contacts.contacts || []).filter((t => !isCharCardContact(t))), saveCt = () => post("SAVE_CONTACTS", { contacts: contactsForSave(), strangers: D.contacts.strangers }), saveChat = t => post("SAVE_SMS_HISTORY", { worldbookUid: t.worldbookUid, contactName: t.name, messages: t.messages.filter((t => !t.typing)), summarizedCount: t.summarizedCount || 0 }); $("chat-x").onclick = closeChat, $("chat-back").onclick = () => { if (!chatTgt || smsGen) return; const t = chatTgt.messages || []; if (t.length) { for (t.pop(); t[t.length - 1]?.typing;)t.pop(); chatTgt.summarizedCount = Math.min(chatTgt.summarizedCount || 0, t.length), renderMsgs(), saveCt(), chatTgt.worldbookUid && saveChat(chatTgt) } }, $("chat-clr").onclick = () => { chatTgt && confirm(`确定要清空与 ${chatTgt.name} 的所有聊天记录吗?`) && (chatTgt.messages = [], chatTgt.summarizedCount = 0, renderMsgs(), saveCt(), chatTgt.worldbookUid && saveChat(chatTgt)) }, $("chat-compress").onclick = () => { if (!chatTgt || smsGen) return; const t = chatTgt.messages || [], e = chatTgt.summarizedCount || 0, s = t.slice(e); if (s.length < 2) return void alert("至少需要2条未总结的消息才能压缩"); if (!confirm(`确定要压缩总结 ${s.length} 条消息吗?`)) return; smsGen = !0, $("chat-compress").disabled = $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !0; const o = Req.create("compress"); Req.set("compress", { tgt: chatTgt }), post("COMPRESS_SMS", { requestId: o, contactName: chatTgt.name, worldbookUid: chatTgt.worldbookUid, messages: t.filter((t => !t.typing)), summarizedCount: e }) }, $("chat-send").onclick = sendMsg; const chatIn = $("chat-in");["keydown", "keypress", "keyup"].forEach((t => chatIn.addEventListener(t, (t => t.stopPropagation())))), chatIn.addEventListener("keydown", (t => { "Enter" !== t.key || t.shiftKey || (t.preventDefault(), sendMsg()) })); const openInv = t => { invTgt = t, selLoc = null, $("inv-t").textContent = `邀请:${t.name}`, $("loc-list").innerHTML = D.maps.outdoor.nodes.map((t => `
${h(t.name)}
${h(t.info || "")}
`)).join(""), $$("#loc-list .loc-i").forEach((t => t.onclick = () => { $$("#loc-list .loc-i").forEach((t => t.classList.remove("sel"))), t.classList.add("sel"), selLoc = t.dataset.n })), openM("m-invite") }; $("inv-ok").onclick = () => { if (!selLoc || !invTgt) return; const t = $("inv-ok"); BtnState.load(t, "询问中..."); const e = Req.create("invite"), s = canonicalLoc(selLoc); Req.set("invite", { contact: invTgt, loc: s, btn: t }), post("SEND_INVITE", { requestId: e, contactName: invTgt.name, contactUid: invTgt.worldbookUid, targetLocation: s, smsHistory: (invTgt.messages || []).map((t => "sent" === t.type ? `{{user}}: ${t.text}` : `${invTgt.name}: ${t.text}`)).join("\n") }) }; let addCtState = { uid: "", name: "", keys: [] }; const resetAddCt = () => { addCtState = { uid: "", name: "", keys: [] }, $("add-uid").value = "", $("add-name").value = "", $("add-name").innerHTML = '', $("name-select-group").style.display = "none", $("uid-check-err").classList.remove("vis"), $("add-ct-ok").disabled = !0, BtnState.reset($("btn-check-uid"), ' 检查') }, showUidErr = t => { $("uid-check-err").textContent = t, $("uid-check-err").classList.add("vis") }; $("btn-check-uid").onclick = () => { const t = $("add-uid").value.trim(); t ? ($("uid-check-err").classList.remove("vis"), BtnState.load($("btn-check-uid"), "检查中"), $("name-select-group").style.display = "none", $("add-ct-ok").disabled = !0, addCtState.uid = t, post("CHECK_WORLDBOOK_UID", { uid: t, requestId: Req.create("uidck") })) : showUidErr("请输入UID") }, $("btn-add-ct").onclick = () => { resetAddCt(), openM("m-add-ct") }, $("add-ct-ok").onclick = () => { const t = addCtState.uid || $("add-uid").value.trim(), e = addCtState.name || $("add-name").value.trim(); t && e && (D.contacts.contacts.some((e => e.worldbookUid === t)) ? showUidErr("该联络人已存在") : (D.contacts.contacts.push({ name: e, avatar: e[0], color: "#" + Math.floor(16777215 * Math.random()).toString(16).padStart(6, "0"), location: "未知", worldbookUid: t, messages: [] }), saveCt(), closeM("m-add-ct"), render())) }; const genAddCt = (t, e, s) => { BtnState.load(s, "检查中"); const o = Req.create("stgwb"); Req.set("stgwb", { name: t, info: e, btn: s }), post("CHECK_STRANGER_WORLDBOOK", { requestId: o, strangerName: t }) }; function canonicalLoc(t) { return String(t || "").trim().replace(/^\u90ae\u8f6e/, "") } $("btn-refresh-strangers").onclick = () => { const t = $("btn-refresh-strangers"); BtnState.load(t, ""); const e = Req.create("extract"); Req.set("extract", { btn: t }), post("EXTRACT_STRANGERS", { requestId: e, existingContacts: D.contacts.contacts, existingStrangers: D.contacts.strangers }) }, $("world-gen-ok").onclick = () => { const t = $("world-gen-ok"), e = $("world-gen-status"); BtnState.load(t, "生成中"), e.style.display = "block", e.textContent = "正在生成世界数据,请稍候...", post("GENERATE_WORLD", { requestId: Req.create("wgen"), playerRequests: $("world-gen-req").value.trim() }) }, $("world-sim-ok").onclick = () => { if (!D.meta && !D.timeline && !D.maps?.outdoor) return void alert("请先生成世界数据,再进行推演"); const t = $("world-sim-ok"), e = $("world-sim-status"); BtnState.load(t, "推演中"), e.style.display = "block", e.style.color = "var(--ok)", e.textContent = "正在分析玩家行为并推演世界变化...", post("SIMULATE_WORLD", { requestId: Req.create("wsim"), currentData: JSON.stringify({ meta: D.meta, timeline: D.timeline, world: D.world, maps: D.maps }, null, 2) }) }, $("btn-deduce").onclick = () => openM("m-world-gen"), $("btn-simulate").onclick = () => openM("m-world-sim"), $("btn-side-menu-toggle").onclick = () => { const t = $("side-menu-panel"), e = $("btn-side-menu-toggle"); t.classList.toggle("show"), e.classList.toggle("act", t.classList.contains("show")) }, document.addEventListener("click", (t => { t.target.closest(".side-menu") || ($("side-menu-panel")?.classList.remove("show"), $("btn-side-menu-toggle")?.classList.remove("act")) })); const getWaitingContacts = t => { const e = canonicalLoc(t); return e ? (D.contacts.contacts || []).filter((t => t?.waitingAt && canonicalLoc(t.waitingAt) === e)) : [] }, finishTravel = (t, e) => { playerLocation = t, selectedMapValue = "current"; const s = getWaitingContacts(t); s.forEach((t => delete t.waitingAt)), saveAll(), render(), hideInfo(); const o = $("goto-task")?.value; let a = `{{user}}离开了${e || "上一地点"},来到${t}。${o ? "意图:" + o : ""}`; s.length && (a += ` ${s.map((t => t.name)).join("、")}已经在这里等你了。`), post("EXECUTE_SLASH_COMMAND", { command: `/sendas name="剧情任务" ${a}` }) }; $("goto-ok").onclick = () => { if (!curNode) return; const t = $("goto-ok"), e = D.maps?.outdoor?.nodes?.find((t => t.name === playerLocation)), s = { name: playerLocation, info: e?.info || "" }, o = curNode.name, a = D.maps?.indoor?.[o]; if (a?.description) return BtnState.reset(t, "确认前往"), closeM("m-goto"), finishTravel(o, s.name), void switchMapView("current"); let n = "main" === curNode.type ? "main" : "home" === curNode.type ? "home" : "sub"; const r = Req.create("scene"); Req.set("scene", { node: curNode, prev: s.name }), BtnState.load(t, "生成中"), post("SCENE_SWITCH", { requestId: r, prevLocationName: s.name, prevLocationInfo: s.info, targetLocationName: curNode.name, targetLocationType: n, targetLocationInfo: curNode.data?.info || "", playerAction: $("goto-task").value || "" }) }, $("btn-gen-local-map").onclick = () => { const t = $("btn-gen-local-map"); BtnState.load(t, "生成中"); const e = Req.create("localmap"); Req.set("localmap", { btn: t }), post("GENERATE_LOCAL_MAP", { requestId: e, outdoorDescription: D.maps?.outdoor?.description || "" }) }, $("btn-refresh-local-map").onclick = () => { if (!playerLocation) return void showResultModal("提示", "请先生成世界数据", !0); const t = $("btn-refresh-local-map"), e = D.maps?.indoor?.[playerLocation], s = e; if (!s) return void showResultModal("提示", "当前区域没有局部地图,请先生成", !0); BtnState.load(t, "刷新中"); const o = Req.create("localmaprf"); Req.set("localmaprf", { btn: t, loc: playerLocation }), post("REFRESH_LOCAL_MAP", { requestId: o, locationName: playerLocation, currentLocalMap: s, outdoorDescription: D.maps?.outdoor?.description || "" }) }, $("btn-gen-local-scene").onclick = () => { if (!playerLocation || "未知" === playerLocation) return void showResultModal("提示", "请先生成世界数据", !0); const t = $("btn-gen-local-scene"); BtnState.load(t, "生成中"); const e = D.maps?.outdoor?.nodes?.find((t => t.name === playerLocation)), s = D.maps?.indoor?.[playerLocation], o = s?.description || e?.info || e?.data?.info || "", a = Req.create("localscene"); Req.set("localscene", { btn: t, loc: playerLocation }), post("GENERATE_LOCAL_SCENE", { requestId: a, locationName: playerLocation, locationInfo: o }) }, $("btn-refresh-world-news").onclick = () => { const t = $("btn-refresh-world-news"); if (!t) return; t.disabled = !0, t._o = t._o ?? t.innerHTML, t.innerHTML = ''; const e = Req.create("newsrf"); Req.set("newsrf", { btn: t }), post("REFRESH_WORLD_NEWS", { requestId: e }) }; const saveAll = () => post("SAVE_ALL_DATA", { allData: { meta: D.meta, world: D.world, outdoor: D.maps.outdoor, indoor: D.maps.indoor, sceneSetup: D.sceneSetup, strangers: D.contacts.strangers, contacts: contactsForSave() }, playerLocation }), dataKeys = [["meta", "大纲", "核心真相、洋葱结构、时间线、用户指南", () => D.meta, t => D.meta = t], ["world", "世界资讯", "世界新闻等信息", () => D.world, t => D.world = t], ["outdoor", "大地图", "室外区域的地点和路线", () => D.maps.outdoor, t => D.maps.outdoor = t], ["indoor", "局部地图", "隐藏的室内/局部场景地图", () => D.maps.indoor, t => D.maps.indoor = t], ["sceneSetup", "区域剧情", "当前区域的 Side Story", () => D.sceneSetup, t => D.sceneSetup = t], ["characterContactSms", "角色卡短信", "角色卡联络人的短信记录", () => ({ messages: charSmsHistory?.messages || [], summarizedCount: charSmsHistory?.summarizedCount || 0, summaries: charSmsHistory?.summaries || {} }), t => { t && "object" == typeof t && (charSmsHistory = { messages: charSmsHistory?.messages || [], summarizedCount: charSmsHistory?.summarizedCount || 0, ...t || {} }) }], ["strangers", "陌路人", "已遇见但未建立联系的角色", () => D.contacts.strangers, t => D.contacts.strangers = t], ["contacts", "联络人", "已添加的联系人", () => contactsForSave(), t => { const e = (D.contacts.contacts || []).find(isCharCardContact); D.contacts.contacts = (e ? [e] : []).concat(Array.isArray(t) ? t : []) }]]; let gSet = { apiUrl: "", apiKey: "", model: "", mode: "assist" }, dataCk = {}, editCtx = null, commSet = { historyCount: 50, npcPosition: 0, npcOrder: 100, stream: !1 }, promptDefaults = { jsonTemplates: {}, promptSources: {} }, promptStores = { global: { jsonTemplates: {}, promptSources: {} }, character: { jsonTemplates: {}, promptSources: {} } }; const reqSet = () => post("GET_SETTINGS"), renderDataList = () => { $("data-list").innerHTML = dataKeys.map((([t, e, s]) => `
${e}
${s}
`)).join(""), $$("#data-list .data-item").forEach((t => t.onclick = e => { if (e.target.closest(".data-edit")) return; const s = t.dataset.k; dataCk[s] = !dataCk[s], t.classList.toggle("sel", dataCk[s]) })), $$("#data-list .data-edit").forEach((t => t.onclick = e => { e.stopPropagation(), openDataEdit(t.dataset.k) })) }, ADV_PROMPT_ITEMS = [["sms", "短信回复"], ["invite", "邀请回复"], ["npc", "NPC 生成"], ["stranger", "提取陌路人"], ["worldGenStep1", "大纲生成"], ["worldGenStep2", "世界生成"], ["worldSim", "世界推演(故事模式)"], ["worldSimAssist", "世界推演(辅助模式)"], ["worldNewsRefresh", "世界新闻刷新"], ["sceneSwitch", "场景切换"], ["localMapGen", "局部地图生成"], ["localMapRefresh", "局部地图刷新"], ["localSceneGen", "局部剧情生成"], ["summary", "总结压缩"]], advHasJsonTemplate = t => { const e = promptDefaults?.jsonTemplates || {}; return Object.prototype.hasOwnProperty.call(e, t) }, advGetScope = () => "global" === $("adv-scope")?.value ? "global" : "character", advStoreHasKey = (t, e) => { const s = ("global" === t ? promptStores.global : promptStores.character) || {}; return !(!Object.prototype.hasOwnProperty.call(s?.promptSources || {}, e) && !Object.prototype.hasOwnProperty.call(s?.jsonTemplates || {}, e)) }, advGetPromptObj = (t, e = "character") => { const s = promptDefaults?.promptSources || {}, o = promptStores?.global?.promptSources || {}, a = promptStores?.character?.promptSources || {}, n = ("global" === e ? o[t] || s[t] : a[t] || o[t] || s[t]) || {}; return { u1: "string" == typeof n.u1 ? n.u1 : "", a1: "string" == typeof n.a1 ? n.a1 : "", u2: "string" == typeof n.u2 ? n.u2 : "", a2: "string" == typeof n.a2 ? n.a2 : "" } }, advNormalizeDisplayText = t => { let e = String(t ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"); return !e.includes("\n") && e.includes("\\n") && (e = e.replaceAll("\\n", "\n")), e.includes("\\t") && (e = e.replaceAll("\\t", "\t")), e.includes("\\`") && (e = e.replaceAll("\\`", "`")), e }, advNormalizeSaveText = t => String(t ?? "").replace(/\r\n/g, "\n").replace(/\r/g, "\n"), advGetJsonTemplate = (t, e = "character") => { const s = promptDefaults?.jsonTemplates || {}, o = promptStores?.global?.jsonTemplates || {}, a = promptStores?.character?.jsonTemplates || {}; if (!(advHasJsonTemplate(t) || Object.prototype.hasOwnProperty.call(o, t) || Object.prototype.hasOwnProperty.call(a, t))) return ""; const n = "global" === e ? Object.prototype.hasOwnProperty.call(o, t) ? o[t] : s[t] : Object.prototype.hasOwnProperty.call(a, t) ? a[t] : Object.prototype.hasOwnProperty.call(o, t) ? o[t] : s[t]; return "string" == typeof n ? n : "" }, advApplyToUI = (t, e = "character") => { const s = advGetPromptObj(t, e), o = (t, e) => { const s = $(t); s && (s.value = advNormalizeDisplayText(e)) }; o("adv-u1", s.u1), o("adv-a1", s.a1), o("adv-u2", s.u2), o("adv-a2", s.a2), $("adv-json-wrap").style.display = ""; const a = advGetJsonTemplate(t, e), n = $("adv-json"); n && (n.value = String(a ?? "")), advUpdateVarHelp(t) }, advUpdateVarHelp = t => { $$(".adv-vars-group").forEach((e => { const s = String(e.dataset.advFor || "").split(",").map((t => t.trim())).filter(Boolean); e.style.display = !s.length || s.includes(t) ? "" : "none" })) }, advBuildEdits = () => { const t = t => advNormalizeSaveText($(t)?.value ?? ""); return { prompt: { u1: t("adv-u1"), a1: t("adv-a1"), u2: t("adv-u2"), a2: t("adv-a2") }, jsonTemplate: advNormalizeSaveText($("adv-json")?.value ?? "") } }, advInit = () => { const t = $("adv-key"); if (!t || t._inited) return; t._inited = !0, t.innerHTML = ADV_PROMPT_ITEMS.map((([t, e]) => ``)).join(""), t.onchange = () => advApplyToUI(t.value, advGetScope()); const e = $("adv-scope"); e && !e._inited && (e._inited = !0, e.onchange = () => { const t = $("adv-key")?.value; t && advApplyToUI(t, advGetScope()) }) }, advOpen = () => { advInit(); const t = $("adv-key"), e = t?.value || ADV_PROMPT_ITEMS[0]?.[0]; if (e) { const t = $("adv-scope"); t && (t.value = advStoreHasKey("character", e) ? "character" : "global"), advApplyToUI(e, advGetScope()) } openM("m-adv-prompts") }, advSaveTo = t => { advInit(); const e = $("adv-key")?.value; if (!e) return; const { prompt: s, jsonTemplate: o } = advBuildEdits(); post("SAVE_PROMPTS", { scope: t, key: e, prompt: s, jsonTemplate: o }), closeM("m-adv-prompts") }, advReset = () => { advInit(); const t = $("adv-key")?.value; t && (post("SAVE_PROMPTS", { scope: advGetScope(), key: t, reset: !0 }), closeM("m-adv-prompts")) }, parseJsonLoose = t => { const e = String(t ?? "").trim(); if (!e) throw new Error("空内容"); try { return JSON.parse(e) } catch { } const s = e.match(/```[^\n]*\n([\s\S]*?)\n```/); if (s?.[1]) { const t = s[1].trim(); try { return JSON.parse(t) } catch { } } const o = (t, s) => { const o = e.indexOf(t), a = e.lastIndexOf(s); return -1 === o || -1 === a || a <= o ? null : e.slice(o, a + 1) }, a = o("{", "}") ?? o("[", "]"); return a ? JSON.parse(a) : JSON.parse(e) }, updateEditPreview = () => { const t = $("data-edit-preview"); t && (t.style.display = "none", t.textContent = "") }, setEditContent = (t, e) => { $("data-edit-title").textContent = t, $("data-edit-ta").value = e, $("data-edit-err").classList.remove("vis"), updateEditPreview(), openM("m-data-edit") }, openDataEdit = t => { const e = dataKeys.find((([e]) => e === t)); e && (editCtx = { type: "characterContactSms" === t ? "charSms" : "data", key: t }, setEditContent(`编辑 - ${e[1]}`, JSON.stringify(e[3](), null, 2))) }; $("data-edit-save").onclick = () => { if (editCtx) try { const t = parseJsonLoose($("data-edit-ta").value); if ("data" === editCtx.type) { const e = dataKeys.find((([t]) => t === editCtx.key)); if (!e) return; e[4](t), render(), saveAll() } else if ("charSms" === editCtx.type) { const e = t?.summaries ?? t; if (!e || "object" != typeof e || Array.isArray(e)) throw new Error("需要 summaries 对象"); charSmsHistory.summaries = e, post("SAVE_CHAR_SMS_HISTORY", { summaries: e }) } closeM("m-data-edit"), editCtx = null } catch (t) { $("data-edit-err").textContent = `JSON错误: ${t.message}`, $("data-edit-err").classList.add("vis") } }, $("data-edit-ta").addEventListener("input", updateEditPreview); const showTestRes = (t, e) => { const s = $("test-res"); s.textContent = e, s.className = "set-test-res " + (t ? "ok" : "err") }, showResultModal = (t, e, s = !1, o = null) => { $("res-title").textContent = t, $("res-title").style.color = s ? "var(--err)" : "", $("res-msg").textContent = e; const a = $("res-record-box"), n = $("res-record"); o ? (a.style.display = "block", n.textContent = "object" == typeof o ? JSON.stringify(o, null, 2) : String(o)) : (a.style.display = "none", n.textContent = ""); const r = $("res-action"); r.style.display = "none", r.textContent = "", r.onclick = null, openM("m-result") }; function render() { const t = D.world?.news || []; $("news-list").innerHTML = t.length ? t.map((t => `
${h(t.title)}
${h(t.time || "")}

${h(t.content)}

`)).join("") : '
暂无新闻
', $$("#news-list .fold").forEach(bindFold); const e = D.meta?.user_guide; e && ($("ug-state").textContent = e.current_state || "未知状态", $("ug-actions").innerHTML = (e.guides || []).map(((t, e) => `
${e + 1}. ${h(t)}
`)).join("") || '
暂无行动指南
'); const s = (t, e) => (t || []).length ? t.map((t => `
${h(t.avatar || "")}
${h(t.name || "")}
${t.online ? "● 在线" : h(t.location)}
${t.info ? `
${h(t.info)}
` : ""}
${e ? `` : ``}
`)).join("") : '
暂无
'; if ($("sec-stranger").innerHTML = s(D.contacts.strangers, !0), $("sec-contact").innerHTML = s(D.contacts.contacts, !1), $$(".comm-sec .fold").forEach(bindFold), $$(".add-btn").forEach((t => t.onclick = e => { e.stopPropagation(), genAddCt(t.dataset.name, t.dataset.info || "", t) })), $$(".ignore-btn").forEach((t => t.onclick = e => { e.stopPropagation(); const s = D.contacts.strangers.findIndex((e => e.name === t.dataset.name)); s > -1 && (D.contacts.strangers.splice(s, 1), saveCt(), render()) })), $$(".msg-btn").forEach((t => t.onclick = e => { e.stopPropagation(); const s = D.contacts.contacts.find((e => e.worldbookUid === t.dataset.uid)); s && openChat(s) })), $$(".inv-btn").forEach((t => t.onclick = e => { e.stopPropagation(); const s = D.contacts.contacts.find((e => e.worldbookUid === t.dataset.uid)); s && openInv(s) })), "current" === selectedMapValue) { const t = getCurInside(); $("side-desc").innerHTML = t?.description ? `
📍 ${h(playerLocation)}
` + parseLinks(t.description) : parseLinks(D.maps?.outdoor?.description || "") } else $("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""); bindLinks($("side-desc")), $("mob-desc").innerHTML = "", renderMapSelector(), renderMap() } function renderMap() { const t = D.maps?.outdoor; if (seed = 123456789, inner.querySelectorAll(".item").forEach((t => t.remove())), svg.innerHTML = "", nodes = [], lines = [], !t?.nodes?.length) return; t.nodes.forEach(((t, e) => { const s = dirMap[t.position] || [0, 0], o = Math.hypot(s[0], s[1]) || 1, a = 120 * (t.distant || 1); nodes.push({ id: "n" + e, name: t.name, type: t.type || "sub", pos: t.position, distant: t.distant || 1, x: s[0] / o * a, y: s[1] / o * a, data: t }) })); const e = {}; nodes.forEach((t => (e[t.pos] = e[t.pos] || []).push(t))); for (let t in e) { const s = e[t]; if (s.length <= 1) continue; const o = dirMap[t] || [0, 0], a = Math.hypot(o[0], o[1]) || 1, n = -o[1] / a, r = o[0] / a, i = (s.length - 1) / 2; s.sort(((t, e) => t.distant - e.distant)).forEach(((t, e) => { t.x += n * (e - i) * 50, t.y += r * (e - i) * 50 })) } for (let t = 0; t < 15; t++)for (let t = 0; t < nodes.length; t++)for (let e = t + 1; e < nodes.length; e++) { const s = nodes[t], o = nodes[e], a = s.x - o.x, n = s.y - o.y, r = Math.hypot(a, n); if (r < 80 && r > 0) { const t = (80 - r) / r * .5; s.x += a * t, s.y += n * t, o.x -= a * t, o.y -= n * t } } nodes.forEach((t => { const e = document.createElement("div"); e.className = `item node-${t.type}`, e.id = t.id, e.textContent = "home" === t.type ? "🏠 " + t.name : t.name, e.style.cssText = `left:${t.x + 2e3}px;top:${t.y + 2e3}px`, e.onclick = e => { e.stopPropagation(), curNode?.id === t.id ? hideInfo() : showInfo(t) }, inner.appendChild(e) })); for (let t in e) { const s = e[t].sort(((t, e) => t.distant - e.distant)); for (let t = 0; t < s.length - 1; t++)lines.push([s[t], s[t + 1]]) } const s = Object.values(e).map((t => t.sort(((t, e) => t.distant - e.distant))[0])).sort(((t, e) => Math.atan2(t.y, t.x) - Math.atan2(e.y, e.x))); s.forEach(((t, e) => lines.push([t, s[(e + 1) % s.length]]))); const o = (t, e) => lines.some((([s, o]) => s === t && o === e || s === e && o === t)); for (let t = 0, e = 0; e < Math.floor(nodes.length / 5) && t < 200; t++) { const t = nodes[Math.floor(rand() * nodes.length)], s = nodes[Math.floor(rand() * nodes.length)]; t === s || o(t, s) || (lines.push([t, s]), e++) } drawLines() } function drawLines() { svg.innerHTML = ""; const t = mapWrap.getBoundingClientRect(); lines.forEach((([e, s]) => { const o = $(e.id), a = $(s.id); if (!o || !a) return; const n = o.getBoundingClientRect(), r = a.getBoundingClientRect(), i = document.createElementNS("http://www.w3.org/2000/svg", "line"); i.setAttribute("x1", (n.left + n.width / 2 - t.left) / scale - offX / scale), i.setAttribute("y1", (n.top + n.height / 2 - t.top) / scale - offY / scale), i.setAttribute("x2", (r.left + r.width / 2 - t.left) / scale - offX / scale), i.setAttribute("y2", (r.top + r.height / 2 - t.top) / scale - offY / scale); const c = "main" === e.type && "main" === s.type || "home" === e.type || "home" === s.type; i.setAttribute("stroke", c ? "var(--c)" : "var(--c4)"), i.setAttribute("stroke-width", c ? "2" : "1"), c || i.setAttribute("stroke-dasharray", "4 3"), svg.appendChild(i) })) } $("btn-settings").onclick = () => { reqSet(), $("set-api-url").value = gSet.apiUrl || "", $("set-api-key").value = gSet.apiKey || "", $("set-model").value = gSet.model || "", $("set-model-list").style.display = "none", $("test-res").className = "set-test-res", $("set-stage").value = D.stage || 0, $("set-deviation").value = D.deviationScore || 0, $("set-sim-target").value = D.simulationTarget ?? 5, $("set-mode").value = gSet.mode || "story", $("set-history-count").value = commSet.historyCount || 50, $("set-use-stream").checked = !!commSet.stream, $("set-npc-position").value = commSet.npcPosition || 0, $("set-npc-order").value = commSet.npcOrder || 100, renderDataList(), syncSimDueUI(), openM("m-settings") }, $("btn-adv-prompts").onclick = () => advOpen(), $("adv-save-global").onclick = () => advSaveTo("global"), $("adv-save-char").onclick = () => advSaveTo("character"), $("adv-reset").onclick = () => advReset(), $("btn-fetch-models").onclick = () => { BtnState.load($("btn-fetch-models"), "加载"), post("FETCH_MODELS", { apiUrl: $("set-api-url").value.trim(), apiKey: $("set-api-key").value.trim() }) }, $("btn-test-conn").onclick = () => { $("test-res").className = "set-test-res", BtnState.load($("btn-test-conn"), "测试"), post("TEST_CONNECTION", { apiUrl: $("set-api-url").value.trim(), apiKey: $("set-api-key").value.trim(), model: $("set-model").value.trim() }) }, $("set-save").onclick = () => { gSet = { apiUrl: $("set-api-url").value.trim(), apiKey: $("set-api-key").value.trim(), model: $("set-model").value.trim(), mode: $("set-mode").value || "story" }, D.stage = Math.max(0, Math.min(10, parseInt($("set-stage").value, 10) || 0)), D.deviationScore = Math.max(0, Math.min(100, parseInt($("set-deviation").value, 10) || 0)), D.simulationTarget = parseInt($("set-sim-target").value, 10), Number.isNaN(D.simulationTarget) && (D.simulationTarget = 5), commSet = { historyCount: Math.max(0, Math.min(200, parseInt($("set-history-count").value, 10) || 50)), stream: !!$("set-use-stream").checked, npcPosition: parseInt($("set-npc-position").value, 10) || 0, npcOrder: Math.max(0, Math.min(1e3, parseInt($("set-npc-order").value, 10) || 100)) }; const t = {}; dataKeys.forEach((([e, , , s]) => { dataCk[e] && (t[e] = s()) })), syncSimDueUI(), post("SAVE_SETTINGS", { globalSettings: gSet, commSettings: commSet, stage: D.stage, deviationScore: D.deviationScore, simulationTarget: D.simulationTarget, playerLocation, dataChecked: dataCk, outlineData: t, allData: { meta: D.meta, timeline: D.timeline, world: D.world, outdoor: D.maps.outdoor, indoor: D.maps.indoor, strangers: D.contacts.strangers, contacts: contactsForSave() } }), closeM("m-settings") }, $("btn-close").onclick = () => post("CLOSE_PANEL"), window.addEventListener("message", (t => { if (t.origin !== PARENT_ORIGIN || t.source !== parent) return; if ("LittleWhiteBox" !== t.data?.source) return; const e = t.data, s = e.type; if ("LOAD_SETTINGS" === s) { if (e.globalSettings && (gSet = e.globalSettings), void 0 !== e.stage && (D.stage = e.stage), void 0 !== e.deviationScore && (D.deviationScore = e.deviationScore), void 0 !== e.simulationTarget && (D.simulationTarget = e.simulationTarget), e.playerLocation && (playerLocation = e.playerLocation), e.commSettings && (commSet = { historyCount: e.commSettings.historyCount ?? 50, npcPosition: e.commSettings.npcPosition ?? 0, npcOrder: e.commSettings.npcOrder ?? 100, stream: !!e.commSettings.stream }), e.dataChecked && (dataCk = e.dataChecked), e.promptConfig && (promptDefaults = e.promptConfig.defaults || promptDefaults, e.promptConfig.stores ? (promptStores.global = e.promptConfig.stores.global || { jsonTemplates: {}, promptSources: {} }, promptStores.character = e.promptConfig.stores.character || { jsonTemplates: {}, promptSources: {} }) : (promptStores.global = e.promptConfig.current || { jsonTemplates: {}, promptSources: {} }, promptStores.character = promptStores.character || { jsonTemplates: {}, promptSources: {} })), e.outlineData) { const t = e.outlineData; t.meta && (D.meta = t.meta), t.world && (D.world = t.world), t.outdoor && (D.maps.outdoor = t.outdoor), t.indoor && (D.maps.indoor = t.indoor), t.sceneSetup && (D.sceneSetup = t.sceneSetup), t.strangers && (D.contacts.strangers = t.strangers), t.contacts && (D.contacts.contacts = t.contacts) } { const t = e.characterContactSmsHistory || {}; charSmsHistory = { messages: Array.isArray(t.messages) ? t.messages : [], summarizedCount: t.summarizedCount || 0, summaries: t.summaries || {} } } let t = D.contacts.contacts.find((t => "__CHARACTER_CARD__" === t.worldbookUid)); t || (t = D.contacts.contacts.find((t => !t.worldbookUid && "炒饭智能" === t.name)), t ? (t.worldbookUid = "__CHARACTER_CARD__", t.info = "角色卡联络人", t.location = "在线", t.online = !0) : (D.contacts.contacts.unshift({ name: e.characterCardName || "{{characterName}}", avatar: "", color: "#555", location: "在线", info: "角色卡联络人", online: !0, worldbookUid: "__CHARACTER_CARD__", messages: [], summarizedCount: 0 }), t = D.contacts.contacts[0])), t && e.characterCardName && (t.name = e.characterCardName, t.avatar = (e.characterCardName || "")[0] || t.avatar || ""), render(), syncSimDueUI(), $("m-settings").classList.contains("act") && ($("set-api-url").value = gSet.apiUrl || "", $("set-api-key").value = gSet.apiKey || "", $("set-model").value = gSet.model || "", $("set-stage").value = D.stage, $("set-deviation").value = D.deviationScore, $("set-sim-target").value = D.simulationTarget ?? 5, $("set-mode").value = gSet.mode || "story", $("set-history-count").value = commSet.historyCount, $("set-use-stream").checked = !!commSet.stream, $("set-npc-position").value = commSet.npcPosition, $("set-npc-order").value = commSet.npcOrder, renderDataList()) } else if ("PROMPT_CONFIG_UPDATED" === s) { if (e.promptConfig && (promptDefaults = e.promptConfig.defaults || promptDefaults, e.promptConfig.stores ? (promptStores.global = e.promptConfig.stores.global || { jsonTemplates: {}, promptSources: {} }, promptStores.character = e.promptConfig.stores.character || { jsonTemplates: {}, promptSources: {} }) : (promptStores.global = e.promptConfig.current || { jsonTemplates: {}, promptSources: {} }, promptStores.character = promptStores.character || { jsonTemplates: {}, promptSources: {} }), $("m-adv-prompts").classList.contains("act"))) { const t = $("adv-key")?.value; t && advApplyToUI(t, advGetScope()) } } else if ("FETCH_MODELS_RESULT" === s) { BtnState.reset($("btn-fetch-models"), "获取"); const t = $("set-model-list"); if (e.error) return t.style.display = "none", void showTestRes(!1, "获取模型失败: " + e.error); if (!e.models?.length) return t.style.display = "none", void showTestRes(!1, "未找到可用模型"); t.innerHTML = '' + e.models.map((t => ``)).join(""), t.style.display = "block", t.onchange = () => { t.value && ($("set-model").value = t.value) }, showTestRes(!0, `找到 ${e.models.length} 个模型`) } else if ("TEST_CONN_RESULT" === s) BtnState.reset($("btn-test-conn"), "测试连接"), showTestRes(e.success, e.message); else if ("CHECK_WORLDBOOK_UID_RESULT" === s) { if (BtnState.reset($("btn-check-uid"), ' 检查'), !Req.match(e.requestId)) return; if (e.error) return void showUidErr(e.error); if (!e.primaryKeys?.length) return void showUidErr("该条目没有主要关键字"); addCtState.keys = e.primaryKeys; const t = $("add-name"); t.innerHTML = '' + e.primaryKeys.map((t => ``)).join(""), t.onchange = () => { addCtState.name = t.value, $("add-ct-ok").disabled = !t.value }, $("name-select-group").style.display = "block", 1 === e.primaryKeys.length && (addCtState.name = e.primaryKeys[0], t.value = addCtState.name, $("add-ct-ok").disabled = !1) } else if ("SMS_RESULT" === s) { const t = Req.get("sms"); if (!t || t.id !== e.requestId) return; if (Req.clear("sms"), smsGen = !1, $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !1, !chatTgt) return; chatTgt.messages = chatTgt.messages.filter((t => !t.typing)), e.error ? chatTgt.messages.push({ type: "received", text: `[错误] ${e.error}` }) : e.reply && chatTgt.messages.push({ type: "received", text: stripXml(e.reply) }), renderMsgs(), saveCt(), chatTgt.worldbookUid && saveChat(chatTgt) } else if ("SMS_STREAM" === s) { const t = Req.get("sms"); if (!t || t.id !== e.requestId || !chatTgt) return; const s = chatTgt.messages.find((t => t.typing)); s && e.text && (s.text = e.text, renderMsgs()) } else if ("LOAD_SMS_HISTORY_RESULT" === s) { if (!chatTgt || chatTgt.worldbookUid !== e.worldbookUid) return; e.messages?.length && (chatTgt.messages = e.messages, chatTgt.summarizedCount = e.summarizedCount || 0, saveCt()), renderMsgs() } else if ("COMPRESS_SMS_RESULT" === s) { const t = Req.get("compress"); if (!t || t.id !== e.requestId) return; if (Req.clear("compress"), smsGen = !1, $("chat-compress").disabled = $("chat-in").disabled = $("chat-send").disabled = $("chat-back").disabled = !1, !chatTgt) return; if (e.error) return void alert(`压缩失败: ${e.error}`); void 0 !== e.newSummarizedCount && (chatTgt.summarizedCount = e.newSummarizedCount, renderMsgs(), saveCt()) } else if ("CHECK_STRANGER_WORLDBOOK_RESULT" === s) { const t = Req.get("stgwb"); if (!t || t.id !== e.requestId) return; const { name: s, info: o, btn: a } = t; if (Req.clear("stgwb"), e.found && e.worldbookUid) { BtnState.reset(a, ' 添加'); const t = D.contacts.strangers.findIndex((t => t.name === s)); if (t > -1) { const s = D.contacts.strangers.splice(t, 1)[0]; D.contacts.contacts.push({ name: s.name, avatar: s.avatar || s.name[0], color: s.color || "#" + Math.floor(16777215 * Math.random()).toString(16).padStart(6, "0"), location: s.location || "未知", info: s.info || "", worldbookUid: e.worldbookUid, messages: [] }), saveCt(), render() } } else { BtnState.load(a, "生成中"); const t = Req.create("npcgen"); Req.set("npcgen", { name: s, info: o, btn: a }), post("GENERATE_NPC", { requestId: t, strangerName: s, strangerInfo: o }) } } else if ("GENERATE_NPC_RESULT" === s) { const t = Req.get("npcgen"); if (!t || t.id !== e.requestId) return; const { name: s, btn: o } = t; if (Req.clear("npcgen"), BtnState.reset(o, ' 添加'), e.error) return void showResultModal("生成角色失败", "生成 NPC 失败", !0, e.error); if (e.success && e.worldbookUid) { const t = D.contacts.strangers.findIndex((t => t.name === s)); if (t > -1) { const o = D.contacts.strangers.splice(t, 1)[0], a = e.npcData || {}; D.contacts.contacts.push({ name: a.name || o.name, avatar: (a.name || o.name)[0], color: o.color || "#" + Math.floor(16777215 * Math.random()).toString(16).padStart(6, "0"), location: o.location || "未知", info: a.intro || o.info || "", worldbookUid: e.worldbookUid, messages: [] }), saveCt(), render(), showResultModal("生成成功", `NPC ${s} 已生成并添加到联络人`, !1, e.npcData) } } } else if ("EXTRACT_STRANGERS_RESULT" === s) { const t = Req.get("extract"); if (!t || t.id !== e.requestId) return; const { btn: s } = t; if (Req.clear("extract"), BtnState.reset(s, ''), e.error) return void showResultModal("提取失败", "提取陌路人失败", !0, e.error); if (e.success && Array.isArray(e.strangers)) { if (!e.strangers.length) return void showResultModal("提取结果", "没有发现新的陌路人"); const t = [...D.contacts.contacts.map((t => t.name)), ...D.contacts.strangers.map((t => t.name))], s = e.strangers.filter((e => !t.includes(e.name))); if (!s.length) return void showResultModal("提取结果", "提取到的角色都已存在"); D.contacts.strangers = D.contacts.strangers.concat(s), saveCt(), render(), showResultModal("提取成功", `成功提取 ${s.length} 个新陌路人`, !1, s) } } else if ("GENERATE_WORLD_STATUS" === s) { if (!Req.match(e.requestId)) return; const t = $("world-gen-status"); t.style.display = "block", t.style.color = "var(--ok)", t.textContent = e.message } else if ("GENERATE_WORLD_RESULT" === s) { if (!Req.match(e.requestId)) return; Req.clear("wgen"); const t = $("world-gen-ok"), s = $("world-gen-status"); if (BtnState.reset(t, ' 开始生成'), e.error) { if (s.style.display = "none", showResultModal("生成失败", "世界生成失败", !0, e.error), String(e.error || "").includes("Step 2")) { const e = $("res-action"); e.style.display = "inline-block", e.textContent = "重试 Step2", e.onclick = () => { closeM("m-result"); const e = Req.create("wgen"); BtnState.load(t, "重试中"), s.style.display = "block", s.style.color = "var(--ok)", s.textContent = "准备重试 Step 2/2...", post("RETRY_WORLD_GEN_STEP2", { requestId: e }) } } return } if (e.success && e.worldData) { s.style.color = "var(--ok)", s.textContent = "生成成功!正在应用数据..."; const t = e.worldData; if (t.meta && (D.meta = t.meta), t.world && (D.world = t.world), t.maps?.outdoor && (D.maps.outdoor = t.maps.outdoor), D.stage = 0, D.deviationScore = 0, t.playerLocation) playerLocation = t.playerLocation; else { const t = D.maps?.outdoor?.nodes?.find((t => "home" === t.type)); playerLocation = t?.name || D.maps?.outdoor?.nodes?.[0]?.name || "未知" } t.maps?.inside && playerLocation && (D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[playerLocation] = t.maps.inside), selectedMapValue = "current", saveAll(), render(), setTimeout((() => { closeM("m-world-gen"), s.style.display = "none", $("world-gen-req").value = "", showResultModal("生成成功", "世界数据生成完成!Stage 和 Deviation 已重置为 0", !1, e.worldData) }), 500) } } else if ("SIMULATE_WORLD_RESULT" === s) { if (!e.isAuto && !Req.match(e.requestId)) return; if (!e.isAuto) { Req.clear("wsim"); const t = $("world-sim-ok"), s = $("world-sim-status"); if (BtnState.reset(t, ' 开始推演'), e.error) return s.style.display = "none", void showResultModal("推演失败", "世界推演失败", !0, e.error) } if (e.success && e.simData) { if (!e.isAuto) { const t = $("world-sim-status"); t.style.color = "var(--ok)", t.textContent = "推演成功!正在应用数据..." } const t = e.simData; t.meta && (D.meta = t.meta), t.world && (D.world = t.world), t.maps?.outdoor && (D.maps.outdoor = t.maps.outdoor), D.stage = (D.stage || 0) + 1, saveAll(), render(), e.isAuto || setTimeout((() => { closeM("m-world-sim"), $("world-sim-status").style.display = "none", showResultModal("推演成功", `世界推演完成!Stage 已推进到 ${D.stage}`, !1, e.simData) }), 500) } } else if ("REFRESH_WORLD_NEWS_RESULT" === s) { const t = Req.get("newsrf"); if (!t || t.id !== e.requestId) return; const { btn: s } = t; if (Req.clear("newsrf"), s && (s.disabled = !1, s.innerHTML = s._o ?? ''), e.error) return void showResultModal("刷新失败", "世界新闻刷新失败", !0, e.error); e.success && Array.isArray(e.news) && (D.world = D.world || {}, D.world.news = e.news, saveAll(), render(), showResultModal("刷新成功", `已更新世界新闻(${e.news.length} 条)`, !1, e.news)) } else if ("SCENE_SWITCH_RESULT" === s) { const t = Req.get("scene"); if (!t || t.id !== e.requestId) return; const { node: s, prev: o } = t; if (Req.clear("scene"), BtnState.reset($("goto-ok"), "确认前往"), closeM("m-goto"), e.error) return void showResultModal("切换失败", "场景切换失败", !0, e.error); if (e.success && e.sceneData) { const t = e.sceneData; if ("number" == typeof t.newScore && (D.deviationScore = t.newScore), t.localMap && (D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[s.name] = t.localMap), t.strangers?.length) { const e = new Set((D.contacts.strangers || []).map((t => t.name))), s = t.strangers.filter((t => !e.has(t.name))); D.contacts.strangers = [...D.contacts.strangers || [], ...s] } finishTravel(s.name, o), 0 !== t.scoreDelta && showResultModal("切换成功", `场景切换完成!\n偏差值变化: ${t.scoreDelta > 0 ? "+" : ""}${t.scoreDelta} (当前: ${t.newScore})`, !1, e.sceneData) } } else if ("REFRESH_LOCAL_MAP_RESULT" === s) { const t = Req.get("localmaprf"); if (!t || t.id !== e.requestId) return; const { btn: s, loc: o } = t; if (Req.clear("localmaprf"), BtnState.reset(s, '刷新'), e.error) return void showResultModal("刷新失败", "刷新局部地图失败", !0, e.error); if (e.success && e.localMapData) { const t = e.localMapData, s = t.name || o || playerLocation || "当前位置"; D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[s] = t, playerLocation = s, selectedMapValue = "current", saveAll(), render(), t.description && ($("side-desc").innerHTML = `
📍 ${h(s)}
` + parseLinks(t.description), bindLinks($("side-desc"))), showResultModal("刷新成功", `局部地图已刷新!当前位置: ${s}`, !1, e.localMapData) } } else if ("GENERATE_LOCAL_SCENE_RESULT" === s) { const t = Req.get("localscene"); if (!t || t.id !== e.requestId) return; const { btn: s, loc: o } = t; if (Req.clear("localscene"), BtnState.reset(s, '局部剧情'), e.error) return void showResultModal("生成失败", "局部剧情生成失败", !0, e.error); if (e.success && e.sceneSetup) { D.sceneSetup = { ...D.sceneSetup || {}, ...e.sceneSetup || {} }, saveAll(), render(); const t = String(e.introduce || "").replace(/^\s*(?:\/?\s*(?:sendas|as)\s+name\s*=\s*(?:"[^"]*"|'[^']*'|\S+)\s+)/i, "").replace(/\s+/g, " ").trim(); t && post("EXECUTE_SLASH_COMMAND", { command: `/sendas name="剧情任务" ${t}` }), showResultModal("生成成功", `局部剧情已生成:${o || playerLocation}`, !1, e.sceneSetup) } } else if ("SEND_INVITE_RESULT" === s) { const t = Req.get("invite"); if (!t || t.id !== e.requestId) return; const { contact: s, loc: o, btn: a } = t; if (Req.clear("invite"), BtnState.reset(a, "发送邀请"), e.error) return void showResultModal("邀请失败", "邀请发送失败", !0, e.error); if (e.success && e.inviteData) { const t = e.inviteData, a = canonicalLoc(t.targetLocation || o || ""), n = D.contacts.contacts.find((t => t && s && t.worldbookUid && s.worldbookUid && t.worldbookUid === s.worldbookUid)) || D.contacts.contacts.find((t => t && s && t.name === s.name)) || s; if (n.messages = n.messages || [], n.messages.push({ type: "sent", text: `我邀请你前往「${a}」` }), n.messages.push({ type: "received", text: t.reply }), t.accepted) { const e = canonicalLoc(playerLocation); n.location = a, e && a && e === a ? (delete n.waitingAt, post("EXECUTE_SLASH_COMMAND", { command: `/sendas name="剧情任务" ${n.name}过来了。` })) : n.waitingAt = a, showResultModal("邀请成功", `${n.name} 接受了邀请!\n回复: ${t.reply}`, !1, t) } else showResultModal("邀请被拒", `${n.name} 拒绝了邀请。\n回复: ${t.reply}`, !1, t); saveAll(), n.worldbookUid && saveChat(n), closeM("m-invite"), render(), openChat(n) } } else if ("GENERATE_LOCAL_MAP_RESULT" === s) { const t = Req.get("localmap"); if (!t || t.id !== e.requestId) return; const { btn: s } = t; if (Req.clear("localmap"), BtnState.reset(s, '局部地图'), e.error) return void showResultModal("生成失败", "局部地图生成失败", !0, e.error); if (e.success && e.localMapData) { const t = e.localMapData, s = t.name || "当前位置"; D.sceneSetup = null, D.maps.indoor = D.maps.indoor || {}, D.maps.indoor[s] = t, playerLocation = s, selectedMapValue = "current", saveAll(), render(), t.description && ($("side-desc").innerHTML = `
📍 ${h(s)}
` + parseLinks(t.description), bindLinks($("side-desc"))), showResultModal("生成成功", `局部地图生成完成!当前位置: ${s}`, !1, t) } } })); const updateTf = () => { inner.style.transform = `translate(${offX}px,${offY}px) scale(${scale})`, $("zoom-ind").textContent = Math.round(100 * scale) + "%", requestAnimationFrame(drawLines) }, initPos = () => { offX = mapWrap.clientWidth / 2 - 2e3, offY = mapWrap.clientHeight / 2 - 2e3, scale = 1, updateTf() }; function panTo(t, e = 350) { if (!t || anim) return; if (!$(t.id)) return; const s = -(t.x + 2e3) * scale + mapWrap.clientWidth / 2, o = -(t.y + 2e3) * scale + .25 * mapWrap.clientHeight, a = offX, n = offY, r = performance.now(); anim = !0, function t(i) { const c = Math.min((i - r) / e, 1), l = 1 - Math.pow(1 - c, 3); offX = a + (s - a) * l, offY = n + (o - n) * l, updateTf(), c < 1 ? requestAnimationFrame(t) : anim = !1 }(r) } function showInfo(t) { if (!t?.data) return; curNode = t, inner.querySelectorAll(".item").forEach((t => t.classList.remove("hl"))), $(t.id)?.classList.add("hl"); const e = t.name === playerLocation; $("btn-goto").classList.toggle("show", !e), e || ($("goto-t").textContent = `前往 ${t.name}`); const s = D.maps?.indoor?.[t.name]; e && s?.description ? ($("side-desc").innerHTML = `
📍 ${h(t.name)}
` + parseLinks(s.description), bindLinks($("side-desc"))) : ($("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc"))), isMob() ? ($("mob-info-t").textContent = t.name, $("mob-info-c").textContent = e ? s?.description || t.data.info || "暂无信息..." : t.data.info || "暂无信息...", popup.classList.contains("act") || openPop(1)) : ($("info-t").textContent = t.name, $("info-c").textContent = t.data.info || "暂无信息...", $("tip").classList.add("show")) } const hideInfo = () => { curNode = null, inner.querySelectorAll(".item").forEach((t => t.classList.remove("hl"))), $("btn-goto").classList.remove("show"), $("tip").classList.remove("show") }; function renderMapSelector() { const t = $("map-lbl-select"); t.innerHTML = ''; const e = D.maps?.outdoor?.nodes?.findIndex((t => t.name === playerLocation)), s = D.maps?.indoor && D.maps.indoor[playerLocation]; if ((e >= 0 || s) && (t.innerHTML += ``), t.innerHTML += "", D.maps?.outdoor?.nodes?.length && D.maps.outdoor.nodes.forEach(((e, s) => { e.name !== playerLocation && (t.innerHTML += ``) })), D.maps?.indoor) { const e = Object.keys(D.maps.indoor).filter((t => t !== playerLocation && !D.maps?.outdoor?.nodes?.some((e => e.name === t)))); e.length && (t.innerHTML += "", e.forEach((e => t.innerHTML += ``))) } t.value = selectedMapValue, updateMapLabel() } function updateMapLabel() { const t = $("map-lbl-select").value; if ("overview" === t) $("map-lbl-t").textContent = "大地图"; else if ("current" === t) $("map-lbl-t").textContent = playerLocation + "(你)"; else if (t.startsWith("node:")) { const e = parseInt(t.split(":")[1]); $("map-lbl-t").textContent = D.maps?.outdoor?.nodes?.[e]?.name || "未知" } else t.startsWith("indoor:") && ($("map-lbl-t").textContent = t.replace("indoor:", "")) } function switchMapView(t) { if (selectedMapValue = t, hideInfo(), "overview" === t) $("btn-goto").classList.remove("show"), $("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc")), initPos(); else if ("current" === t) { $("btn-goto").classList.remove("show"); const t = getCurInside(); t?.description ? ($("side-desc").innerHTML = `
📍 ${h(playerLocation)}
` + parseLinks(t.description), bindLinks($("side-desc"))) : ($("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc"))); const e = nodes.find((t => t.name === playerLocation)); e && (panTo(e), showInfo(e)) } else if (t.startsWith("node:")) { const e = parseInt(t.split(":")[1]), s = D.maps?.outdoor?.nodes?.[e]; $("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc")); const o = nodes.find((t => t.name === s?.name)); o && (panTo(o), showInfo(o)) } else if (t.startsWith("indoor:")) { const e = t.replace("indoor:", ""), s = D.maps?.indoor?.[e]; e !== playerLocation ? ($("side-desc").innerHTML = parseLinks(D.maps?.outdoor?.description || ""), bindLinks($("side-desc")), curNode = { name: e, type: "sub", data: { info: stripXml(s?.description || "") }, isIndoor: !0 }, $("btn-goto").classList.add("show"), $("goto-t").textContent = `前往 ${e}`) : ($("btn-goto").classList.remove("show"), s?.description && ($("side-desc").innerHTML = `
🏠 ${h(e)}
` + parseLinks(s.description), bindLinks($("side-desc")))) } updateMapLabel() } $("info-bk").onclick = hideInfo, $("mob-info-bk").onclick = () => popup.classList.remove("act"), $("map-lbl-select").onchange = t => switchMapView(t.target.value); let sx, sy, lastDist = 0, lastCX = 0, lastCY = 0; mapWrap.onmousedown = t => { anim || t.target.closest(".map-act,.map-lbl") || (t.target.classList.contains("item") || hideInfo(), drag = !0, sx = t.clientX, sy = t.clientY, mapWrap.style.cursor = "grabbing") }, mapWrap.onmousemove = t => { drag && (offX += t.clientX - sx, offY += t.clientY - sy, sx = t.clientX, sy = t.clientY, updateTf()) }, mapWrap.onmouseup = mapWrap.onmouseleave = () => { drag = !1, mapWrap.style.cursor = "grab" }, mapWrap.onwheel = t => { if (anim) return; t.preventDefault(); const e = Math.max(.3, Math.min(3, scale + (t.deltaY > 0 ? -.1 : .1))), s = mapWrap.getBoundingClientRect(), o = t.clientX - s.left, a = t.clientY - s.top, n = e / scale; offX = o - (o - offX) * n, offY = a - (a - offY) * n, scale = e, updateTf() }, mapWrap.ontouchstart = t => { if (anim || t.target.closest(".map-act,.map-lbl")) return; const e = t.target.closest(".item"); e ? e._ts = { x: t.touches[0].clientX, y: t.touches[0].clientY, t: Date.now() } : (hideInfo(), 1 === t.touches.length ? (drag = !0, sx = t.touches[0].clientX, sy = t.touches[0].clientY) : 2 === t.touches.length && (drag = !1, lastDist = Math.hypot(t.touches[0].clientX - t.touches[1].clientX, t.touches[0].clientY - t.touches[1].clientY), lastCX = (t.touches[0].clientX + t.touches[1].clientX) / 2, lastCY = (t.touches[0].clientY + t.touches[1].clientY) / 2)) }, mapWrap.ontouchmove = t => { const e = t.target.closest(".item"); if (e && e._ts) Math.hypot(t.touches[0].clientX - e._ts.x, t.touches[0].clientY - e._ts.y) > 10 && (delete e._ts, drag = !0, sx = t.touches[0].clientX, sy = t.touches[0].clientY); else if (1 === t.touches.length && drag) t.preventDefault(), offX += t.touches[0].clientX - sx, offY += t.touches[0].clientY - sy, sx = t.touches[0].clientX, sy = t.touches[0].clientY, updateTf(); else if (2 === t.touches.length) { t.preventDefault(); const e = Math.hypot(t.touches[0].clientX - t.touches[1].clientX, t.touches[0].clientY - t.touches[1].clientY), s = (t.touches[0].clientX + t.touches[1].clientX) / 2, o = (t.touches[0].clientY + t.touches[1].clientY) / 2, a = Math.max(.3, Math.min(3, scale * (e / lastDist))), n = mapWrap.getBoundingClientRect(), r = s - n.left, i = o - n.top, c = a / scale; offX = r - (r - offX) * c, offY = i - (i - offY) * c, offX += s - lastCX, offY += o - lastCY, scale = a, lastDist = e, lastCX = s, lastCY = o, updateTf() } }, mapWrap.ontouchend = t => { const e = t.target.closest(".item"); if (e && e._ts) { const s = Date.now() - e._ts.t; if (delete e._ts, s < 300) { const s = nodes.find((t => t.id === e.id)); s && (t.preventDefault(), curNode?.id === s.id ? hideInfo() : showInfo(s)) } } drag = !1 }, $$(".nav-i").forEach((t => t.onclick = () => { $$(".nav-i").forEach((t => t.classList.remove("act"))), $$(".page").forEach((t => t.classList.remove("act"))), t.classList.add("act"), $(`page-${t.dataset.p}`).classList.add("act"); const e = "map" === t.dataset.p; sidePop.classList.toggle("show", e), isMob() && (e ? openPop(1) : popup.classList.remove("act")), e && setTimeout((() => { initPos(), drawLines() }), 50) })), $$(".comm-tab").forEach((t => t.onclick = () => { $$(".comm-tab").forEach((t => t.classList.remove("act"))), $$(".comm-sec").forEach((t => t.classList.remove("act"))), t.classList.add("act"), $(`sec-${t.dataset.t}`).classList.add("act") })), $("btn-goto").onclick = t => { t.stopPropagation(), curNode && ($("goto-d").textContent = `目的地:${curNode.name}`, $("goto-task").value = "", openM("m-goto")) }, addEventListener("resize", (() => requestAnimationFrame(drawLines))), window.clickTab = (t, e) => { const s = t.closest(".settings-modal"); if (!s) return; s.querySelectorAll(".set-nav-item").forEach((t => t.classList.remove("act"))), t.classList.add("act"), s.querySelectorAll(".set-tab-page").forEach((t => t.classList.remove("act"))); const o = document.getElementById(e); o && (o.classList.add("act"), o.style.animation = "none", o.offsetHeight, o.style.animation = null) }, document.addEventListener("DOMContentLoaded", (() => { render(), initPos(), sidePop.classList.add("show"), sidePop.classList.add("act"), isMob() && openPop(1), post("FRAME_READY"), setTimeout((() => { "current" === selectedMapValue && switchMapView("current") }), 100) })); diff --git a/modules/story-summary/generate/llm.js b/modules/story-summary/generate/llm.js index 28c7e49..ca84ae2 100644 --- a/modules/story-summary/generate/llm.js +++ b/modules/story-summary/generate/llm.js @@ -236,7 +236,7 @@ All checks passed. Beginning incremental extraction... { "mindful_prelude":`, - userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容 + userConfirm: `怎么截断了!重新完整生成,只输出JSON,不要任何其他内容,3000字以内 `, assistantPrefill: JSON_PREFILL diff --git a/modules/story-summary/story-summary.html b/modules/story-summary/story-summary.html index 9c7d259..bef07e8 100644 --- a/modules/story-summary/story-summary.html +++ b/modules/story-summary/story-summary.html @@ -289,6 +289,33 @@
总结设置
+ + +
+
+ 文本过滤规则 · 0 + + + +
+ +
+
@@ -334,32 +361,6 @@
- -
-
- 文本过滤规则 · 0 - - - -
- -
-