From 287abff5997b4181139823819f14e3347f18be0b Mon Sep 17 00:00:00 2001 From: bielie Date: Wed, 25 Feb 2026 10:26:01 +0800 Subject: [PATCH] Refactor Ena Planner to iframe settings and harden save ack flow --- core/server-storage.js | 1 + index.js | 32 +- modules/ena-planner/ena-planner.css | 420 ++++----- modules/ena-planner/ena-planner.html | 653 +++++++++++++ modules/ena-planner/ena-planner.js | 1267 +++++++------------------- modules/tts/tts-overlay.html | 40 +- modules/tts/tts.js | 8 +- settings.html | 13 +- 8 files changed, 1221 insertions(+), 1213 deletions(-) create mode 100644 modules/ena-planner/ena-planner.html diff --git a/core/server-storage.js b/core/server-storage.js index 4797524..9d13d8f 100644 --- a/core/server-storage.js +++ b/core/server-storage.js @@ -182,5 +182,6 @@ export const TasksStorage = new StorageFile('LittleWhiteBox_Tasks.json'); export const StoryOutlineStorage = new StorageFile('LittleWhiteBox_StoryOutline.json'); export const NovelDrawStorage = new StorageFile('LittleWhiteBox_NovelDraw.json', { debounceMs: 800 }); export const TtsStorage = new StorageFile('LittleWhiteBox_TTS.json', { debounceMs: 800 }); +export const EnaPlannerStorage = new StorageFile('LittleWhiteBox_EnaPlanner.json', { debounceMs: 800 }); export const CommonSettingStorage = new StorageFile('LittleWhiteBox_CommonSettings.json', { debounceMs: 1000 }); export const VectorStorage = new StorageFile('LittleWhiteBox_Vectors.json', { debounceMs: 3000 }); diff --git a/index.js b/index.js index ef422be..eb795ca 100644 --- a/index.js +++ b/index.js @@ -27,7 +27,7 @@ import { initNovelDraw, cleanupNovelDraw } from "./modules/novel-draw/novel-draw import "./modules/story-summary/story-summary.js"; import "./modules/story-outline/story-outline.js"; import { initTts, cleanupTts } from "./modules/tts/tts.js"; -import { initEnaPlanner } from "./modules/ena-planner/ena-planner.js"; +import { initEnaPlanner, cleanupEnaPlanner } from "./modules/ena-planner/ena-planner.js"; extension_settings[EXT_ID] = extension_settings[EXT_ID] || { enabled: true, @@ -45,6 +45,7 @@ extension_settings[EXT_ID] = extension_settings[EXT_ID] || { storyOutline: { enabled: false }, novelDraw: { enabled: false }, tts: { enabled: false }, + enaPlanner: { enabled: false }, useBlob: false, wrapperIframe: true, renderEnabled: true, @@ -277,7 +278,8 @@ function toggleSettingsControls(enabled) { 'xiaobaix_use_blob', 'xiaobaix_variables_core_enabled', 'xiaobaix_variables_mode', 'Wrapperiframe', 'xiaobaix_render_enabled', 'xiaobaix_max_rendered', 'xiaobaix_story_outline_enabled', 'xiaobaix_story_summary_enabled', 'xiaobaix_novel_draw_enabled', 'xiaobaix_novel_draw_open_settings', - 'xiaobaix_tts_enabled', 'xiaobaix_tts_open_settings' + 'xiaobaix_tts_enabled', 'xiaobaix_tts_open_settings', + 'xiaobaix_ena_planner_enabled', 'xiaobaix_ena_planner_open_settings' ]; controls.forEach(id => { $(`#${id}`).prop('disabled', !enabled).closest('.flex-container').toggleClass('disabled-control', !enabled); @@ -312,6 +314,7 @@ async function toggleAllFeatures(enabled) { { condition: extension_settings[EXT_ID].variablesCore?.enabled, init: initVariablesCore }, { condition: extension_settings[EXT_ID].novelDraw?.enabled, init: initNovelDraw }, { condition: extension_settings[EXT_ID].tts?.enabled, init: initTts }, + { condition: extension_settings[EXT_ID].enaPlanner?.enabled, init: initEnaPlanner }, { condition: true, init: initStreamingGeneration }, { condition: true, init: initButtonCollapse } ]; @@ -347,6 +350,7 @@ async function toggleAllFeatures(enabled) { try { cleanupVareventEditor(); } catch (e) { } try { cleanupNovelDraw(); } catch (e) { } try { cleanupTts(); } catch (e) { } + try { cleanupEnaPlanner(); } catch (e) { } try { clearBlobCaches(); } catch (e) { } toggleSettingsControls(false); try { window.cleanupWorldbookHostBridge && window.cleanupWorldbookHostBridge(); document.getElementById('xb-worldbook')?.remove(); } catch (e) { } @@ -391,7 +395,8 @@ async function setupSettings() { { id: 'xiaobaix_story_summary_enabled', key: 'storySummary' }, { id: 'xiaobaix_story_outline_enabled', key: 'storyOutline' }, { id: 'xiaobaix_novel_draw_enabled', key: 'novelDraw', init: initNovelDraw }, - { id: 'xiaobaix_tts_enabled', key: 'tts', init: initTts } + { id: 'xiaobaix_tts_enabled', key: 'tts', init: initTts }, + { id: 'xiaobaix_ena_planner_enabled', key: 'enaPlanner', init: initEnaPlanner } ]; moduleConfigs.forEach(({ id, key, init }) => { @@ -407,6 +412,9 @@ async function setupSettings() { if (!enabled && key === 'tts') { try { cleanupTts(); } catch (e) { } } + if (!enabled && key === 'enaPlanner') { + try { cleanupEnaPlanner(); } catch (e) { } + } settings[key] = extension_settings[EXT_ID][key] || {}; settings[key].enabled = enabled; extension_settings[EXT_ID][key] = settings[key]; @@ -450,6 +458,15 @@ async function setupSettings() { } }); + $("#xiaobaix_ena_planner_open_settings").on("click", function () { + if (!isXiaobaixEnabled) return; + if (settings.enaPlanner?.enabled && window.xiaobaixEnaPlanner?.openSettings) { + window.xiaobaixEnaPlanner.openSettings(); + } else { + toastr.warning('请先启用剧情规划模块'); + } + }); + $("#xiaobaix_use_blob").prop("checked", !!settings.useBlob).on("change", async function () { if (!isXiaobaixEnabled) return; settings.useBlob = $(this).prop("checked"); @@ -512,10 +529,11 @@ async function setupSettings() { variablesPanel: 'xiaobaix_variables_panel_enabled', variablesCore: 'xiaobaix_variables_core_enabled', novelDraw: 'xiaobaix_novel_draw_enabled', - tts: 'xiaobaix_tts_enabled' + tts: 'xiaobaix_tts_enabled', + enaPlanner: 'xiaobaix_ena_planner_enabled' }; const ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; - const OFF = ['preview', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts']; + const OFF = ['preview', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner']; function setChecked(id, val) { const el = document.getElementById(id); if (el) { @@ -650,11 +668,11 @@ jQuery(async () => { { condition: settings.variablesCore?.enabled, init: initVariablesCore }, { condition: settings.novelDraw?.enabled, init: initNovelDraw }, { condition: settings.tts?.enabled, init: initTts }, + { condition: settings.enaPlanner?.enabled, init: initEnaPlanner }, { condition: true, init: initStreamingGeneration }, { condition: true, init: initButtonCollapse } ]; moduleInits.forEach(({ condition, init }) => { if (condition) init(); }); - try { initEnaPlanner(); } catch (e) { console.error('[EnaPlanner] Init failed:', e); } if (settings.preview?.enabled || settings.recorded?.enabled) { setTimeout(initMessagePreview, 1500); @@ -675,4 +693,4 @@ jQuery(async () => { } catch (err) { } }); -export { executeSlashCommand }; \ No newline at end of file +export { executeSlashCommand }; diff --git a/modules/ena-planner/ena-planner.css b/modules/ena-planner/ena-planner.css index 701ec6a..9901c0f 100644 --- a/modules/ena-planner/ena-planner.css +++ b/modules/ena-planner/ena-planner.css @@ -1,242 +1,188 @@ -/* Ena Planner v0.5 — collapsible, clean */ +:root { + --bg: #121212; + --card: #1b1b1b; + --line: #343434; + --muted: #a8a8a8; + --text: #e9e9e9; + --ok: #85d48b; + --err: #f48f8f; + --btn: #2c2c2c; + --primary: #355fcf; + } + * { box-sizing: border-box; } + body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; + } + .wrap { + max-width: 1120px; + margin: 0 auto; + padding: 14px; + } + .top { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + } + .badge { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--muted); + } + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ff9800; + } + .dot.ok { background: #4caf50; } -/* ===== Settings panel inside inline-drawer ===== */ -#ena_planner_panel { - padding: 8px 0; -} + .tabs { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; + } + .tab { + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 12px; + cursor: pointer; + opacity: 0.75; + user-select: none; + } + .tab.active { + opacity: 1; + background: #3a3a3a; + } -#ena_planner_panel .ep-row { - display: flex; - gap: 10px; - flex-wrap: wrap; - margin: 8px 0; -} + .panel { display: none; } + .panel.active { display: block; } -#ena_planner_panel label { - font-size: 12px; - opacity: .9; - display: block; - margin-bottom: 4px; -} + .card { + background: var(--card); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px; + margin-bottom: 10px; + } + .row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 8px; + } + .col { + flex: 1; + min-width: 230px; + } + label { + display: block; + margin-bottom: 4px; + color: #d0d0d0; + font-size: 13px; + } + input, select, textarea { + width: 100%; + background: #111; + color: #efefef; + border: 1px solid #444; + border-radius: 6px; + padding: 7px 8px; + } + textarea { + min-height: 110px; + resize: vertical; + } + .hint { + font-size: 12px; + color: var(--muted); + margin-top: 3px; + } + .actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; + } + .btn { + border: 1px solid #4a4a4a; + border-radius: 6px; + background: var(--btn); + color: #fff; + padding: 6px 10px; + cursor: pointer; + } + .btn.primary { + border-color: var(--primary); + background: var(--primary); + } + .status { + min-height: 18px; + font-size: 12px; + margin-top: 8px; + white-space: pre-wrap; + color: var(--ok); + } + .status.error { color: var(--err); } -#ena_planner_panel input[type="text"], -#ena_planner_panel input[type="password"], -#ena_planner_panel input[type="number"], -#ena_planner_panel select, -#ena_planner_panel textarea { - width: 100%; - box-sizing: border-box; -} + .prompt-block { + border: 1px solid #404040; + border-radius: 8px; + padding: 8px; + margin-bottom: 8px; + } + .prompt-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + margin-bottom: 6px; + flex-wrap: wrap; + } + .prompt-head-left { + display: flex; + gap: 8px; + flex-wrap: wrap; + flex: 1; + min-width: 280px; + } -#ena_planner_panel .ep-col { - flex: 1 1 220px; - min-width: 220px; -} - -#ena_planner_panel .ep-col.wide { - flex: 1 1 100%; - min-width: 260px; -} - -/* Tabs */ -#ena_planner_panel .ep-tabs { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-bottom: 10px; -} - -#ena_planner_panel .ep-tab { - padding: 6px 10px; - border-radius: 999px; - cursor: pointer; - border: 1px solid var(--SmartThemeBorderColor, #333); - opacity: .85; - user-select: none; - font-size: 13px; -} - -#ena_planner_panel .ep-tab.active { - opacity: 1; - background: rgba(255, 255, 255, .06); -} - -#ena_planner_panel .ep-panel { - display: none; -} - -#ena_planner_panel .ep-panel.active { - display: block; -} - -#ena_planner_panel .ep-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 10px; -} - -#ena_planner_panel .ep-hint { - font-size: 11px; - opacity: .7; - margin-top: 4px; -} - -#ena_planner_panel .ep-hint-box { - font-size: 12px; - opacity: .85; - margin: 10px 0; - padding: 10px; - border-radius: 8px; - background: rgba(255, 255, 255, .04); - border: 1px solid rgba(255, 255, 255, .08); - line-height: 1.6; -} - -#ena_planner_panel .ep-divider { - margin: 10px 0; - border-top: 1px dashed rgba(255, 255, 255, .15); -} - -/* Inline badge (in drawer header) */ -.ep-badge-inline { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 12px; - opacity: .9; - margin-left: 8px; -} - -.ep-badge-inline .dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: #888; - display: inline-block; -} - -.ep-badge-inline.ok .dot { - background: #2ecc71; -} - -.ep-badge-inline.warn .dot { - background: #f39c12; -} - -/* Prompt block */ -.ep-prompt-block { - border: 1px solid rgba(255, 255, 255, .12); - border-radius: 10px; - padding: 10px; - margin: 10px 0; -} - -.ep-prompt-head { - display: flex; - gap: 8px; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; -} - -/* ===== Log modal ===== */ -.ep-log-modal { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, .65); - z-index: 99999; - display: none; -} - -.ep-log-modal.open { - display: block; -} - -.ep-log-modal .ep-log-card { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: min(980px, 96vw); - height: min(82vh, 900px); - background: rgba(20, 20, 20, .95); - border: 1px solid rgba(255, 255, 255, .15); - border-radius: 12px; - padding: 14px; - display: flex; - flex-direction: column; -} - -.ep-log-modal .ep-log-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - margin-bottom: 10px; -} - -.ep-log-modal .ep-log-head .title { - font-weight: 700; - font-size: 15px; -} - -.ep-log-modal .ep-log-body { - overflow: auto; - flex: 1 1 auto; - border: 1px solid rgba(255, 255, 255, .08); - border-radius: 10px; - padding: 10px; -} - -.ep-log-item { - border-bottom: 1px solid rgba(255, 255, 255, .08); - padding: 12px 0; -} - -.ep-log-item:last-child { - border-bottom: none; -} - -.ep-log-item .meta { - display: flex; - justify-content: space-between; - gap: 10px; - flex-wrap: wrap; - opacity: .85; - font-size: 12px; - margin-bottom: 8px; -} - -.ep-log-item .ep-log-error { - color: #ffb3b3; - font-size: 12px; - white-space: pre-wrap; - margin-bottom: 6px; -} - -.ep-log-item details { - margin: 6px 0; -} - -.ep-log-item details summary { - cursor: pointer; - font-size: 12px; - opacity: .85; - padding: 4px 0; -} - -/* Issue #3: proper log formatting with line breaks */ -.ep-log-pre { - white-space: pre-wrap; - word-break: break-word; - font-size: 12px; - line-height: 1.5; - padding: 10px; - border-radius: 8px; - background: rgba(255, 255, 255, .04); - border: 1px solid rgba(255, 255, 255, .06); - max-height: 400px; - overflow: auto; -} \ No newline at end of file + .log-list { + max-height: 62vh; + overflow: auto; + } + .log-item { + border-bottom: 1px solid #2f2f2f; + padding: 8px 0; + } + .log-meta { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--muted); + gap: 8px; + } + .log-error { + margin: 5px 0; + color: var(--err); + font-size: 12px; + white-space: pre-wrap; + } + .log-pre { + margin-top: 6px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + background: #0f0f0f; + border: 1px solid #2f2f2f; + border-radius: 6px; + padding: 8px; + max-height: 260px; + overflow: auto; + } diff --git a/modules/ena-planner/ena-planner.html b/modules/ena-planner/ena-planner.html new file mode 100644 index 0000000..2200503 --- /dev/null +++ b/modules/ena-planner/ena-planner.html @@ -0,0 +1,653 @@ + + + + + + Ena Planner + + + +
+
+ Ena Planner + Disabled +
+ +
+
General
+
API
+
Prompt
+
Debug
+
Logs
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+ +
+ + +
+
Models preview: Not loaded
+
+
+ +
+
+
+
+ + +
+
+
+ + + +
+ +
+ +
+ + +
+
+
+ +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+
+
+
+ +
+ + +
+
+
+ + + + + diff --git a/modules/ena-planner/ena-planner.js b/modules/ena-planner/ena-planner.js index db9a0c2..b207e05 100644 --- a/modules/ena-planner/ena-planner.js +++ b/modules/ena-planner/ena-planner.js @@ -1,8 +1,13 @@ import { extension_settings } from '../../../../../extensions.js'; import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js'; import { getStorySummaryForEna } from '../story-summary/story-summary.js'; +import { extensionFolderPath } from '../../core/constants.js'; +import { EnaPlannerStorage } from '../../core/server-storage.js'; +import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.js'; const EXT_NAME = 'ena-planner'; +const OVERLAY_ID = 'xiaobaix-ena-planner-overlay'; +const HTML_PATH = `${extensionFolderPath}/modules/ena-planner/ena-planner.html`; /** * ------------------------- @@ -81,15 +86,21 @@ const state = { logs: [] }; +let config = null; +let overlay = null; +let iframeMessageBound = false; +let sendListenersInstalled = false; +let sendClickHandler = null; +let sendKeydownHandler = null; + /** * ------------------------- * Helpers * -------------------------- */ function ensureSettings() { - extension_settings[EXT_NAME] = extension_settings[EXT_NAME] ?? getDefaultSettings(); const d = getDefaultSettings(); - const s = extension_settings[EXT_NAME]; + const s = config || structuredClone(d); function deepMerge(target, src) { for (const k of Object.keys(src)) { @@ -112,17 +123,38 @@ function ensureSettings() { delete s.historyMessageCount; delete s.worldbookActivationMode; + config = s; return s; } +async function loadConfig() { + const loaded = await EnaPlannerStorage.get('config', null); + config = (loaded && typeof loaded === 'object') ? loaded : getDefaultSettings(); + ensureSettings(); + state.logs = Array.isArray(await EnaPlannerStorage.get('logs', [])) ? await EnaPlannerStorage.get('logs', []) : []; + + if (extension_settings?.[EXT_NAME]) { + delete extension_settings[EXT_NAME]; + saveSettingsDebounced?.(); + } + return config; +} + +async function saveConfigNow() { + ensureSettings(); + await EnaPlannerStorage.set('config', config); + await EnaPlannerStorage.set('logs', state.logs); + try { + return await EnaPlannerStorage.saveNow({ silent: false }); + } catch { + return false; + } +} + function toastInfo(msg) { if (window.toastr?.info) return window.toastr.info(msg); console.log('[EnaPlanner]', msg); } -function toastWarn(msg) { - if (window.toastr?.warning) return window.toastr.warning(msg); - console.warn('[EnaPlanner]', msg); -} function toastErr(msg) { if (window.toastr?.error) return window.toastr.error(msg); console.error('[EnaPlanner]', msg); @@ -136,20 +168,13 @@ function clampLogs() { function persistLogsMaybe() { const s = ensureSettings(); if (!s.logsPersist) return; - try { - localStorage.setItem('ena_planner_logs', JSON.stringify(state.logs.slice(0, s.logsMax))); - } catch { } + state.logs = state.logs.slice(0, s.logsMax); + EnaPlannerStorage.set('logs', state.logs).catch(() => {}); } function loadPersistedLogsMaybe() { const s = ensureSettings(); - if (!s.logsPersist) return; - try { - const raw = localStorage.getItem('ena_planner_logs'); - if (raw) state.logs = JSON.parse(raw) || []; - } catch { - state.logs = []; - } + if (!s.logsPersist) state.logs = []; } function nowISO() { @@ -189,35 +214,6 @@ function setSendUIBusy(busy) { if (textarea) textarea.disabled = !!busy; } -function escapeHtml(s) { - return String(s ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -/** - * Universal tap handler — works on both desktop (click) and mobile (touch). - * Prevents ghost double-fires by tracking the last trigger time. - * On touch devices, fires on touchend for zero delay; on desktop, fires on click. - */ -function _addUniversalTap(el, fn) { - if (!el) return; - let lastTrigger = 0; - const guard = (e) => { - const now = Date.now(); - if (now - lastTrigger < 400) return; // debounce - lastTrigger = now; - e.preventDefault(); - e.stopPropagation(); - fn(e); - }; - el.addEventListener('click', guard); - el.addEventListener('touchend', guard, { passive: false }); -} - function safeStringify(val) { if (val == null) return ''; if (typeof val === 'string') return val; @@ -918,30 +914,6 @@ function filterPlannerForInput(rawFull) { * Planner API calls * -------------------------- */ -async function fetchModels() { - const s = ensureSettings(); - if (!s.api.baseUrl) throw new Error('请先填写 API URL'); - if (!s.api.apiKey) throw new Error('请先填写 API KEY'); - - const url = buildUrl('/models'); - const res = await fetch(url, { - method: 'GET', - headers: { - ...getRequestHeaders(), - Authorization: `Bearer ${s.api.apiKey}` - } - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`拉取模型失败: ${res.status} ${text}`.slice(0, 300)); - } - - const data = await res.json(); - const list = Array.isArray(data?.data) ? data.data : []; - return list.map(x => x?.id).filter(Boolean); -} - async function callPlanner(messages) { const s = ensureSettings(); if (!s.api.baseUrl) throw new Error('未配置 API URL'); @@ -1020,6 +992,80 @@ async function callPlanner(messages) { return full; } +async function fetchModelsForUi() { + const s = ensureSettings(); + if (!s.api.baseUrl) throw new Error('请先填写 API URL'); + if (!s.api.apiKey) throw new Error('请先填写 API KEY'); + const url = buildUrl('/models'); + const res = await fetch(url, { + method: 'GET', + headers: { + ...getRequestHeaders(), + Authorization: `Bearer ${s.api.apiKey}` + } + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`拉取模型失败: ${res.status} ${text}`.slice(0, 300)); + } + const data = await res.json(); + const list = Array.isArray(data?.data) ? data.data : []; + return list.map(x => x?.id).filter(Boolean); +} + +async function debugWorldbookForUi() { + let out = '正在诊断世界书读取...\n'; + const charWb = await getCharacterWorldbooks(); + out += `角色世界书名称: ${JSON.stringify(charWb)}\n`; + const globalWb = await getGlobalWorldbooks(); + out += `全局世界书名称: ${JSON.stringify(globalWb)}\n`; + const all = [...new Set([...charWb, ...globalWb])]; + for (const name of all) { + const data = await getWorldbookData(name); + const count = data?.entries?.length ?? 0; + const enabled = data?.entries?.filter(e => !e?.disable && !e?.disabled)?.length ?? 0; + out += ` "${name}": ${count} 条目, ${enabled} 已启用\n`; + } + if (!all.length) { + out += '⚠️ 未找到任何世界书。请检查角色卡是否绑定了世界书。\n'; + const charObj = getCurrentCharSafe(); + out += `charObj存在: ${!!charObj}\n`; + if (charObj) { + out += `charObj.world: ${charObj?.world}\n`; + out += `charObj.data.extensions.world: ${charObj?.data?.extensions?.world}\n`; + } + const ctx = getContextSafe(); + out += `ctx存在: ${!!ctx}\n`; + if (ctx) { + out += `ctx.characterId: ${ctx?.characterId}\n`; + out += `ctx.this_chid: ${ctx?.this_chid}\n`; + } + } + return out; +} + +function debugCharForUi() { + const charObj = getCurrentCharSafe(); + if (!charObj) { + const ctx = getContextSafe(); + return [ + '⚠️ 未检测到角色。', + `ctx: ${!!ctx}, ctx.characterId: ${ctx?.characterId}, ctx.this_chid: ${ctx?.this_chid}`, + `window.this_chid: ${window.this_chid}`, + `window.characters count: ${window.characters?.length ?? 'N/A'}` + ].join('\n'); + } + const block = formatCharCardBlock(charObj); + return [ + `角色名: ${charObj?.name}`, + `desc长度: ${(charObj?.description ?? '').length}`, + `personality长度: ${(charObj?.personality ?? '').length}`, + `scenario长度: ${(charObj?.scenario ?? '').length}`, + `world: ${charObj?.world ?? charObj?.data?.extensions?.world ?? '(无)'}`, + `---\n${block.slice(0, 500)}...` + ].join('\n'); +} + /** * ------------------------- * Build planner messages @@ -1119,875 +1165,6 @@ async function buildPlannerMessages(rawUserInput) { return { messages, meta: { charBlockRaw, worldbookRaw, recentChatRaw, vectorRaw, cachedSummaryLen: cachedSummary.length, plotsRaw } }; } -/** - * ------------------------- - * Logs UI — Issue #3: proper formatting - * -------------------------- - */ -function createLogModalHTML() { - return ` - -
-
-
-
Ena Planner Logs
-
- - - -
-
-
-
-
`; -} - -function renderLogs() { - const body = document.getElementById('ep_log_body'); - if (!body) return; - - body.textContent = ''; - - if (!state.logs.length) { - const empty = document.createElement('div'); - empty.style.opacity = '.75'; - empty.textContent = '暂无日志(发送一次消息后就会记录)。'; - body.appendChild(empty); - return; - } - - state.logs.forEach((log, idx) => { - const t = log.time ?? ''; - const title = log.ok ? 'OK' : 'ERROR'; - const model = log.model ?? ''; - const err = log.error ?? ''; - - // Format request messages for readable display. - const reqDisplay = (log.requestMessages ?? []).map((m, i) => { - return `--- Message #${i + 1} [${m.role}] ---\n${m.content ?? '(empty)'}`; - }).join('\n\n'); - - const item = document.createElement('div'); - item.className = 'ep-log-item'; - - const meta = document.createElement('div'); - meta.className = 'meta'; - const metaLeft = document.createElement('span'); - metaLeft.textContent = `#${idx + 1} · ${title} · ${t} · ${model}`; - const metaRight = document.createElement('span'); - metaRight.textContent = log.ok ? '✅' : '❌'; - meta.append(metaLeft, metaRight); - item.appendChild(meta); - - if (err) { - const errDiv = document.createElement('div'); - errDiv.className = 'ep-log-error'; - errDiv.textContent = err; - item.appendChild(errDiv); - } - - const buildDetails = (summaryText, contentText, open = false) => { - const details = document.createElement('details'); - if (open) details.open = true; - - const summary = document.createElement('summary'); - summary.textContent = summaryText; - const pre = document.createElement('pre'); - pre.className = 'ep-log-pre'; - pre.textContent = contentText; - - details.append(summary, pre); - return details; - }; - - item.appendChild(buildDetails('发出去的 messages(完整)', reqDisplay)); - item.appendChild(buildDetails('规划 AI 原始完整回复(含 )', String(log.rawReply ?? ''))); - item.appendChild(buildDetails('写回输入框的版本(已剔除 think,只保留 plot+note)', String(log.filteredReply ?? ''), true)); - - body.appendChild(item); - }); -} - -function openLogModal() { - const m = document.getElementById('ep_log_modal'); - if (!m) return; - m.classList.add('open'); - renderLogs(); -} -function closeLogModal() { - const m = document.getElementById('ep_log_modal'); - if (!m) return; - m.classList.remove('open'); -} - -/** - * ------------------------- - * Settings UI — Issue #1: use inline-drawer for collapsible - * -------------------------- - */ -function createSettingsHTML() { - const s = ensureSettings(); - const channel = s.api.channel; - - return ` - -
-
- Ena Planner - - - ${s.enabled ? 'Enabled' : 'Disabled'} - -
- -
-
总览
-
API
-
提示词
-
调试
-
- - -
-
-
- - -
开启后:你点发送/回车,会先走"规划模型",把规划结果写回输入框再发送。
-
-
- - -
防止"原始+规划文本"再次被拦截规划。
-
-
- -
- -
-
- - -
角色绑定的世界书总是会读取。这里选择是否额外包含全局世界书。
-
-
- - -
-
- -
-
- - -
条目名称/备注包含这些字符串的条目会被排除。
-
-
- -
-
- - -
-
- -
- 自动行为说明:
- · 聊天片段:自动读取所有未隐藏的 AI 回复(不含用户输入)
- · 自动剔除 <think> 以前的内容(含未包裹的思考段落)
- · 角色卡字段(desc/personality/scenario):有就全部加入
- · 向量召回(extensionPrompts):有就自动加入
- · 世界书激活:常驻(蓝灯)+ 关键词触发(绿灯) -
- -
- -
-
- - -
这些 XML 标签及其内容会从聊天历史中剔除。自闭合标签(如 <Tag/>)也会被移除。
-
-
- -
- - -
-
- - -
-
-
- - -
影响默认前缀:OpenAI/Claude → /v1,Gemini → /v1beta
-
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
- - -
- -
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
新增多条提示词块,选择 role(system/user/assistant)。系统块放最前面;assistant 块放最后。
- -
- - - - -
- -
-
- - -
-
- - -
-
- 工作原理:
- · 规划时会锁定发送按钮
- · Log 静默记录,只有出错才弹提示
- · 写回版本:剔除 <think>,只保留 <plot>+<note>
- · 前文自动剔除 <think> 以前内容和排除标签内容 -
-
- - -
- -
-
`; -} - -function renderPromptDesigner() { - const s = ensureSettings(); - const list = document.getElementById('ep_prompt_list'); - if (!list) return; - - const blocks = s.promptBlocks || []; - list.textContent = ''; - - if (!blocks.length) { - const empty = document.createElement('div'); - empty.style.opacity = '.75'; - empty.textContent = '暂无提示词块'; - list.appendChild(empty); - return; - } - - for (let idx = 0; idx < blocks.length; idx++) { - const b = blocks[idx]; - const role = b.role || 'system'; - - const block = document.createElement('div'); - block.className = 'ep-prompt-block'; - - const head = document.createElement('div'); - head.className = 'ep-prompt-head'; - - const leftGroup = document.createElement('div'); - leftGroup.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;'; - - const nameInput = document.createElement('input'); - nameInput.type = 'text'; - nameInput.className = 'text_pole ep_pb_name'; - nameInput.dataset.id = b.id; - nameInput.placeholder = '名称'; - nameInput.value = b.name ?? ''; - nameInput.style.minWidth = '180px'; - - const roleSelect = document.createElement('select'); - roleSelect.className = 'ep_pb_role'; - roleSelect.dataset.id = b.id; - for (const r of ['system', 'user', 'assistant']) { - const opt = document.createElement('option'); - opt.value = r; - opt.textContent = r; - opt.selected = r === role; - roleSelect.appendChild(opt); - } - leftGroup.append(nameInput, roleSelect); - - const rightGroup = document.createElement('div'); - rightGroup.style.cssText = 'display:flex;gap:6px;'; - for (const [cls, label, disabled] of [ - ['ep_pb_up', '↑', idx === 0], - ['ep_pb_down', '↓', idx === blocks.length - 1], - ['ep_pb_del', '删除', false], - ]) { - const btn = document.createElement('button'); - btn.className = `menu_button ${cls}`; - btn.dataset.id = b.id; - btn.textContent = label; - btn.disabled = disabled; - rightGroup.appendChild(btn); - } - - head.append(leftGroup, rightGroup); - - const textarea = document.createElement('textarea'); - textarea.className = 'text_pole ep_pb_content'; - textarea.dataset.id = b.id; - textarea.rows = 6; - textarea.placeholder = '内容...'; - textarea.value = b.content ?? ''; - - block.append(head, textarea); - list.appendChild(block); - } -} - -function bindSettingsUI() { - const settingsEl = document.getElementById('ena_planner_panel'); - if (!settingsEl) return; - - // Tabs - settingsEl.querySelectorAll('.ep-tab').forEach(tab => { - tab.addEventListener('click', () => { - settingsEl.querySelectorAll('.ep-tab').forEach(t => t.classList.remove('active')); - tab.classList.add('active'); - const id = tab.getAttribute('data-ep-tab'); - settingsEl.querySelectorAll('.ep-panel').forEach(p => p.classList.remove('active')); - const panel = settingsEl.querySelector(`.ep-panel[data-ep-panel="${id}"]`); - if (panel) panel.classList.add('active'); - if (id === 'prompt') renderPromptDesigner(); - }); - }); - - function save() { saveSettingsDebounced(); } - - // General - document.getElementById('ep_enabled')?.addEventListener('change', (e) => { - const s = ensureSettings(); - s.enabled = e.target.value === 'true'; - save(); - toastInfo(`Ena Planner: ${s.enabled ? '启用' : '关闭'}`); - // Update badge - const badge = document.querySelector('.ep-badge-inline'); - if (badge) { - badge.className = `ep-badge-inline ${s.enabled ? 'ok' : 'warn'}`; - badge.querySelector('span:last-child').textContent = s.enabled ? 'Enabled' : 'Disabled'; - } - }); - - document.getElementById('ep_skip_plot')?.addEventListener('change', (e) => { - ensureSettings().skipIfPlotPresent = e.target.value === 'true'; save(); - }); - - document.getElementById('ep_include_global_wb')?.addEventListener('change', (e) => { - ensureSettings().includeGlobalWorldbooks = e.target.value === 'true'; save(); - }); - - document.getElementById('ep_wb_pos4')?.addEventListener('change', (e) => { - ensureSettings().excludeWorldbookPosition4 = e.target.value === 'true'; save(); - }); - - document.getElementById('ep_wb_exclude_names')?.addEventListener('change', (e) => { - const raw = e.target.value ?? ''; - ensureSettings().worldbookExcludeNames = raw.split(',').map(t => t.trim()).filter(Boolean); - save(); - }); - - document.getElementById('ep_plot_n')?.addEventListener('change', (e) => { - ensureSettings().plotCount = Number(e.target.value) || 0; save(); - }); - - document.getElementById('ep_exclude_tags')?.addEventListener('change', (e) => { - const raw = e.target.value ?? ''; - ensureSettings().chatExcludeTags = raw.split(',').map(t => t.trim()).filter(Boolean); - save(); - }); - - // Logs — unified pointer handler for desktop + mobile - const logBtn = document.getElementById('ep_open_logs'); - if (logBtn) { - _addUniversalTap(logBtn, () => openLogModal()); - } - - document.getElementById('ep_test_planner')?.addEventListener('click', async () => { - try { - const fake = '(测试输入)我想让你帮我规划下一步剧情。'; - await runPlanningOnce(fake, true); - toastInfo('测试完成:去 Logs 查看。'); - } catch (e) { toastErr(String(e?.message ?? e)); } - }); - - // API - document.getElementById('ep_api_channel')?.addEventListener('change', (e) => { ensureSettings().api.channel = e.target.value; save(); }); - document.getElementById('ep_api_base')?.addEventListener('change', (e) => { ensureSettings().api.baseUrl = e.target.value.trim(); save(); }); - document.getElementById('ep_prefix_mode')?.addEventListener('change', (e) => { ensureSettings().api.prefixMode = e.target.value; save(); }); - document.getElementById('ep_prefix_custom')?.addEventListener('change', (e) => { ensureSettings().api.customPrefix = e.target.value.trim(); save(); }); - document.getElementById('ep_api_key')?.addEventListener('change', (e) => { ensureSettings().api.apiKey = e.target.value; save(); }); - document.getElementById('ep_model')?.addEventListener('change', (e) => { ensureSettings().api.model = e.target.value.trim(); save(); }); - document.getElementById('ep_stream')?.addEventListener('change', (e) => { ensureSettings().api.stream = e.target.value === 'true'; save(); }); - document.getElementById('ep_temp')?.addEventListener('change', (e) => { ensureSettings().api.temperature = Number(e.target.value); save(); }); - document.getElementById('ep_top_p')?.addEventListener('change', (e) => { ensureSettings().api.top_p = Number(e.target.value); save(); }); - document.getElementById('ep_top_k')?.addEventListener('change', (e) => { ensureSettings().api.top_k = Number(e.target.value) || 0; save(); }); - document.getElementById('ep_pp')?.addEventListener('change', (e) => { ensureSettings().api.presence_penalty = e.target.value.trim(); save(); }); - document.getElementById('ep_fp')?.addEventListener('change', (e) => { ensureSettings().api.frequency_penalty = e.target.value.trim(); save(); }); - document.getElementById('ep_mt')?.addEventListener('change', (e) => { ensureSettings().api.max_tokens = e.target.value.trim(); save(); }); - - document.getElementById('ep_test_conn')?.addEventListener('click', async () => { - try { - const models = await fetchModels(); - toastInfo(`连接成功:${models.length} 个模型`); - } catch (e) { toastErr(String(e?.message ?? e)); } - }); - - document.getElementById('ep_fetch_models')?.addEventListener('click', async () => { - try { - const models = await fetchModels(); - toastInfo(`拉取成功:${models.length} 个模型`); - state.logs.unshift({ - time: nowISO(), ok: true, model: 'GET /models', - requestMessages: [], rawReply: safeStringify(models), filteredReply: safeStringify(models) - }); - clampLogs(); persistLogsMaybe(); - openLogModal(); renderLogs(); - } catch (e) { toastErr(String(e?.message ?? e)); } - }); - - // Prompt designer - document.getElementById('ep_add_prompt')?.addEventListener('click', () => { - const s = ensureSettings(); - s.promptBlocks.push({ - id: crypto?.randomUUID?.() ?? String(Date.now()), - role: 'system', name: 'New Block', content: '' - }); - save(); renderPromptDesigner(); - }); - - document.getElementById('ep_reset_prompt')?.addEventListener('click', () => { - extension_settings[EXT_NAME].promptBlocks = getDefaultSettings().promptBlocks; - save(); renderPromptDesigner(); - }); - - // Template management - document.getElementById('ep_tpl_save')?.addEventListener('click', () => { - const sel = document.getElementById('ep_tpl_select'); - const name = sel?.value; - if (!name) { toastWarn('请先选择一个模板再储存'); return; } - const s = ensureSettings(); - if (!s.promptTemplates) s.promptTemplates = {}; - s.promptTemplates[name] = JSON.parse(JSON.stringify(s.promptBlocks || [])); - save(); - toastInfo(`模板「${name}」已覆盖保存`); - }); - document.getElementById('ep_tpl_select')?.addEventListener('change', (e) => { - const name = e.target.value; - if (!name) return; // 选的是占位符,不做事 - const s = ensureSettings(); - const tpl = s.promptTemplates?.[name]; - if (!tpl) { toastWarn('模板不存在'); return; } - s.promptBlocks = JSON.parse(JSON.stringify(tpl)).map(b => ({ - ...b, id: crypto?.randomUUID?.() ?? String(Date.now() + Math.random()) - })); - save(); renderPromptDesigner(); - toastInfo(`模板「${name}」已载入`); - }); - document.getElementById('ep_tpl_saveas')?.addEventListener('click', () => { - const name = prompt('请输入新模板名称:'); - if (!name || !name.trim()) return; - const s = ensureSettings(); - if (!s.promptTemplates) s.promptTemplates = {}; - s.promptTemplates[name.trim()] = JSON.parse(JSON.stringify(s.promptBlocks || [])); - save(); - refreshTemplateSelect(name.trim()); // 刷新并选中新模板 - toastInfo(`模板「${name.trim()}」已保存`); - }); - - document.getElementById('ep_tpl_delete')?.addEventListener('click', () => { - const sel = document.getElementById('ep_tpl_select'); - const name = sel?.value; - if (!name) { toastWarn('请先选择要删除的模板'); return; } - if (!confirm(`确定删除模板「${name}」?`)) return; - const s = ensureSettings(); - if (s.promptTemplates) delete s.promptTemplates[name]; - save(); - refreshTemplateSelect(); - toastInfo(`模板「${name}」已删除`); - }); - - function refreshTemplateSelect(selectName) { - const sel = document.getElementById('ep_tpl_select'); - if (!sel) return; - const s = ensureSettings(); - const names = Object.keys(s.promptTemplates || {}); - - sel.textContent = ''; - const placeholder = document.createElement('option'); - placeholder.value = ''; - placeholder.textContent = '-- 选择模板 --'; - sel.appendChild(placeholder); - - for (const n of names) { - const opt = document.createElement('option'); - opt.value = n; - opt.textContent = n; - opt.selected = n === selectName; - sel.appendChild(opt); - } - } - - document.getElementById('ep_prompt_list')?.addEventListener('input', (e) => { - const s = ensureSettings(); - const id = e.target?.getAttribute?.('data-id'); - if (!id) return; - const b = s.promptBlocks.find(x => x.id === id); - if (!b) return; - if (e.target.classList.contains('ep_pb_name')) b.name = e.target.value; - if (e.target.classList.contains('ep_pb_content')) b.content = e.target.value; - save(); - }); - - document.getElementById('ep_prompt_list')?.addEventListener('change', (e) => { - const s = ensureSettings(); - const id = e.target?.getAttribute?.('data-id'); - if (!id) return; - const b = s.promptBlocks.find(x => x.id === id); - if (!b) return; - if (e.target.classList.contains('ep_pb_role')) b.role = e.target.value; - save(); - }); - - document.getElementById('ep_prompt_list')?.addEventListener('click', (e) => { - const s = ensureSettings(); - const id = e.target?.getAttribute?.('data-id'); - if (!id) return; - const idx = s.promptBlocks.findIndex(x => x.id === id); - if (idx < 0) return; - - if (e.target.classList.contains('ep_pb_del')) { - s.promptBlocks.splice(idx, 1); save(); renderPromptDesigner(); - } - if (e.target.classList.contains('ep_pb_up') && idx > 0) { - [s.promptBlocks[idx - 1], s.promptBlocks[idx]] = [s.promptBlocks[idx], s.promptBlocks[idx - 1]]; - save(); renderPromptDesigner(); - } - if (e.target.classList.contains('ep_pb_down') && idx < s.promptBlocks.length - 1) { - [s.promptBlocks[idx + 1], s.promptBlocks[idx]] = [s.promptBlocks[idx], s.promptBlocks[idx + 1]]; - save(); renderPromptDesigner(); - } - }); - - // Debug buttons - document.getElementById('ep_debug_worldbook')?.addEventListener('click', async () => { - const out = document.getElementById('ep_debug_output'); - if (!out) return; - out.style.display = 'block'; - out.textContent = '正在诊断世界书读取...\n'; - try { - const charWb = await getCharacterWorldbooks(); - out.textContent += `角色世界书名称: ${JSON.stringify(charWb)}\n`; - const globalWb = await getGlobalWorldbooks(); - out.textContent += `全局世界书名称: ${JSON.stringify(globalWb)}\n`; - const all = [...new Set([...charWb, ...globalWb])]; - for (const name of all) { - const data = await getWorldbookData(name); - const count = data?.entries?.length ?? 0; - const enabled = data?.entries?.filter(e => !e?.disable && !e?.disabled)?.length ?? 0; - out.textContent += ` "${name}": ${count} 条目, ${enabled} 已启用\n`; - } - if (!all.length) { - out.textContent += '⚠️ 未找到任何世界书。请检查角色卡是否绑定了世界书。\n'; - // Extra diagnostics - const charObj = getCurrentCharSafe(); - out.textContent += `charObj存在: ${!!charObj}\n`; - if (charObj) { - out.textContent += `charObj.world: ${charObj?.world}\n`; - out.textContent += `charObj.data.extensions.world: ${charObj?.data?.extensions?.world}\n`; - } - const ctx = getContextSafe(); - out.textContent += `ctx存在: ${!!ctx}\n`; - if (ctx) { - out.textContent += `ctx.characterId: ${ctx?.characterId}\n`; - out.textContent += `ctx.this_chid: ${ctx?.this_chid}\n`; - } - } - } catch (e) { out.textContent += `错误: ${e?.message ?? e}\n`; } - }); - - document.getElementById('ep_debug_char')?.addEventListener('click', () => { - const out = document.getElementById('ep_debug_output'); - if (!out) return; - out.style.display = 'block'; - const charObj = getCurrentCharSafe(); - if (!charObj) { - out.textContent = '⚠️ 未检测到角色。\n'; - const ctx = getContextSafe(); - out.textContent += `ctx: ${!!ctx}, ctx.characterId: ${ctx?.characterId}, ctx.this_chid: ${ctx?.this_chid}\n`; - out.textContent += `window.this_chid: ${window.this_chid}\n`; - out.textContent += `window.characters count: ${window.characters?.length ?? 'N/A'}\n`; - return; - } - const block = formatCharCardBlock(charObj); - out.textContent = `角色名: ${charObj?.name}\n`; - out.textContent += `desc长度: ${(charObj?.description ?? '').length}\n`; - out.textContent += `personality长度: ${(charObj?.personality ?? '').length}\n`; - out.textContent += `scenario长度: ${(charObj?.scenario ?? '').length}\n`; - out.textContent += `world: ${charObj?.world ?? charObj?.data?.extensions?.world ?? '(无)'}\n`; - out.textContent += `---\n${block.slice(0, 500)}...\n`; - }); -} - -function injectUI() { - ensureSettings(); - loadPersistedLogsMaybe(); - - if (document.getElementById('ena_planner_settings')) return; - - // 动态注入 tab 按钮 - const menuBar = document.querySelector('.settings-menu-vertical'); - if (!menuBar) return; - if (!menuBar.querySelector('[data-target="ena-planner"]')) { - const tabDiv = document.createElement('div'); - tabDiv.className = 'menu-tab'; - tabDiv.setAttribute('data-target', 'ena-planner'); - tabDiv.setAttribute('style', 'border-bottom:1px solid #303030;'); - const tabSpan = document.createElement('span'); - tabSpan.className = 'vertical-text'; - tabSpan.textContent = '剧情规划'; - tabDiv.appendChild(tabSpan); - menuBar.appendChild(tabDiv); - } - - // 动态注入面板容器 - const contentArea = document.querySelector('.settings-content'); - if (!contentArea) return; - if (!document.getElementById('ena_planner_panel')) { - const panel = document.createElement('div'); - panel.id = 'ena_planner_panel'; - panel.className = 'ena-planner settings-section'; - panel.style.display = 'none'; - contentArea.appendChild(panel); - } - - const container = document.getElementById('ena_planner_panel'); - if (!container) return; - - // Security: createSettingsHTML() is template-controlled and dynamic fields are escaped. - // eslint-disable-next-line no-unsanitized/property - container.innerHTML = createSettingsHTML(); - - // Log modal - if (!document.getElementById('ep_log_modal')) { - const modalHost = document.createElement('div'); - // Security: createLogModalHTML() is static markup. - // eslint-disable-next-line no-unsanitized/property - modalHost.innerHTML = createLogModalHTML(); - while (modalHost.firstChild) { - document.body.appendChild(modalHost.firstChild); - } - - _addUniversalTap(document.getElementById('ep_log_close'), () => closeLogModal()); - const logModal = document.getElementById('ep_log_modal'); - if (logModal) { - _addUniversalTap(logModal, (e) => { if (e.target === logModal) closeLogModal(); }); - } - document.getElementById('ep_log_clear')?.addEventListener('click', () => { - state.logs = []; persistLogsMaybe(); renderLogs(); - }); - document.getElementById('ep_log_export')?.addEventListener('click', () => { - try { - const blob = new Blob([JSON.stringify(state.logs, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; a.download = `ena-planner-logs-${Date.now()}.json`; a.click(); - URL.revokeObjectURL(url); - } catch (e) { toastErr('导出失败:' + String(e?.message ?? e)); } - }); - } - - bindSettingsUI(); -} - /** * ------------------------- * Planning runner + logging @@ -2070,7 +1247,8 @@ async function doInterceptAndPlanThenSend() { } function installSendInterceptors() { - document.addEventListener('click', (e) => { + if (sendListenersInstalled) return; + sendClickHandler = (e) => { const btn = getSendButton(); if (!btn) return; if (e.target !== btn && !btn.contains(e.target)) return; @@ -2078,9 +1256,8 @@ function installSendInterceptors() { e.preventDefault(); e.stopImmediatePropagation(); doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err))); - }, true); - - document.addEventListener('keydown', (e) => { + }; + sendKeydownHandler = (e) => { const ta = getSendTextarea(); if (!ta || e.target !== ta) return; if (e.key === 'Enter' && !e.shiftKey) { @@ -2089,21 +1266,197 @@ function installSendInterceptors() { e.stopImmediatePropagation(); doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err))); } - }, true); -} - -export function initEnaPlanner() { - ensureSettings(); - loadPersistedLogsMaybe(); - - const tryInject = () => { - if (document.querySelector('.settings-menu-vertical')) { - injectUI(); - installSendInterceptors(); - } else { - setTimeout(tryInject, 500); - } }; - tryInject(); + document.addEventListener('click', sendClickHandler, true); + document.addEventListener('keydown', sendKeydownHandler, true); + sendListenersInstalled = true; } +function uninstallSendInterceptors() { + if (!sendListenersInstalled) return; + if (sendClickHandler) document.removeEventListener('click', sendClickHandler, true); + if (sendKeydownHandler) document.removeEventListener('keydown', sendKeydownHandler, true); + sendClickHandler = null; + sendKeydownHandler = null; + sendListenersInstalled = false; +} + +function getIframeConfigPayload() { + const s = ensureSettings(); + return { + ...s, + logs: state.logs, + }; +} + +function openSettings() { + if (document.getElementById(OVERLAY_ID)) return; + + overlay = document.createElement('div'); + overlay.id = OVERLAY_ID; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: ${window.innerHeight}px; + background: rgba(0,0,0,0.5); + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + `; + + const iframe = document.createElement('iframe'); + iframe.src = HTML_PATH; + iframe.style.cssText = ` + width: min(1200px, 96vw); + height: min(980px, 94vh); + max-height: calc(100% - 24px); + border: none; + border-radius: 12px; + background: #1a1a1a; + `; + + overlay.appendChild(iframe); + document.body.appendChild(overlay); + + if (!iframeMessageBound) { + // Guarded by isTrustedIframeEvent (origin + source). + // eslint-disable-next-line no-restricted-syntax + window.addEventListener('message', handleIframeMessage); + iframeMessageBound = true; + } +} + +function closeSettings() { + const overlayEl = document.getElementById(OVERLAY_ID); + if (overlayEl) overlayEl.remove(); + overlay = null; +} + +async function handleIframeMessage(ev) { + const iframe = overlay?.querySelector('iframe'); + if (!isTrustedIframeEvent(ev, iframe)) return; + if (!ev.data?.type?.startsWith('xb-ena:')) return; + + const { type, payload } = ev.data; + switch (type) { + case 'xb-ena:ready': + postToIframe(iframe, { type: 'xb-ena:config', payload: getIframeConfigPayload() }); + break; + case 'xb-ena:close': + closeSettings(); + break; + case 'xb-ena:save-config': { + const requestId = payload?.requestId || ''; + const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload; + Object.assign(ensureSettings(), patch || {}); + const ok = await saveConfigNow(); + if (ok) { + postToIframe(iframe, { + type: 'xb-ena:config-saved', + payload: { + ...getIframeConfigPayload(), + requestId + } + }); + } else { + postToIframe(iframe, { + type: 'xb-ena:config-save-error', + payload: { + message: '保存失败', + requestId + } + }); + } + break; + } + case 'xb-ena:reset-prompt-default': { + const requestId = payload?.requestId || ''; + const s = ensureSettings(); + s.promptBlocks = getDefaultSettings().promptBlocks; + const ok = await saveConfigNow(); + if (ok) { + postToIframe(iframe, { + type: 'xb-ena:config-saved', + payload: { + ...getIframeConfigPayload(), + requestId + } + }); + } else { + postToIframe(iframe, { + type: 'xb-ena:config-save-error', + payload: { + message: '重置失败', + requestId + } + }); + } + break; + } + case 'xb-ena:run-test': { + try { + const fake = payload?.text || '(测试输入)我想让你帮我规划下一步剧情。'; + await runPlanningOnce(fake, true); + postToIframe(iframe, { type: 'xb-ena:test-done' }); + postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } }); + } catch (err) { + postToIframe(iframe, { type: 'xb-ena:test-error', payload: { message: String(err?.message ?? err) } }); + } + break; + } + case 'xb-ena:logs-request': + postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } }); + break; + case 'xb-ena:logs-clear': + state.logs = []; + await saveConfigNow(); + postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } }); + break; + case 'xb-ena:fetch-models': { + try { + const models = await fetchModelsForUi(); + postToIframe(iframe, { type: 'xb-ena:models', payload: { models } }); + } catch (err) { + postToIframe(iframe, { type: 'xb-ena:models-error', payload: { message: String(err?.message ?? err) } }); + } + break; + } + case 'xb-ena:debug-worldbook': { + try { + const output = await debugWorldbookForUi(); + postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output } }); + } catch (err) { + postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output: String(err?.message ?? err) } }); + } + break; + } + case 'xb-ena:debug-char': { + const output = debugCharForUi(); + postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output } }); + break; + } + } +} + +export async function initEnaPlanner() { + await loadConfig(); + loadPersistedLogsMaybe(); + installSendInterceptors(); + window.xiaobaixEnaPlanner = { openSettings, closeSettings }; +} + +export function cleanupEnaPlanner() { + uninstallSendInterceptors(); + closeSettings(); + if (iframeMessageBound) { + window.removeEventListener('message', handleIframeMessage); + iframeMessageBound = false; + } + delete window.xiaobaixEnaPlanner; +} + + diff --git a/modules/tts/tts-overlay.html b/modules/tts/tts-overlay.html index 839b821..e79a5fa 100644 --- a/modules/tts/tts-overlay.html +++ b/modules/tts/tts-overlay.html @@ -1275,7 +1275,7 @@ select.input { cursor: pointer; }
- 试用音色 — 无需配置,使用插件服务器(11个音色)
+ 试用音色 — 无需配置,使用插件服务器(21个音色)
鉴权音色 — 需配置火山引擎 API(200+ 音色 + 复刻)
@@ -1719,6 +1719,7 @@ let selectedTrialVoiceValue = ''; let selectedAuthVoiceValue = ''; let editingVoiceValue = null; let activeSaveBtn = null; +let pendingSaveRequest = null; const TRIAL_VOICES = [ { key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' }, @@ -1791,6 +1792,25 @@ function handleSaveResult(success) { } } +function requestSaveConfig(form, btn = null) { + const requestId = `tts_save_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer); + if (btn) setSavingState(btn); + + pendingSaveRequest = { + requestId, + timer: setTimeout(() => { + if (!pendingSaveRequest || pendingSaveRequest.requestId !== requestId) return; + pendingSaveRequest = null; + handleSaveResult(false); + post('xb-tts:toast', { type: 'error', message: '保存超时(3秒)' }); + }, 3000), + }; + + post('xb-tts:save-config', { requestId, patch: form }); +} + function setTestStatus(elId, status, text) { const el = $(elId); if (!el) return; @@ -2060,7 +2080,7 @@ function bindMyVoiceEvents(listEl) { const input = btn.closest('.voice-item').querySelector('.voice-edit-input'); if (item && input?.value?.trim()) { item.name = input.value.trim(); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); } editingVoiceValue = null; renderMyVoiceList(); @@ -2090,7 +2110,7 @@ function bindMyVoiceEvents(listEl) { renderTrialVoiceList(); renderAuthVoiceList(); updateCurrentVoiceDisplay(); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); } }); }); @@ -2313,11 +2333,17 @@ window.addEventListener('message', ev => { fillForm(payload); break; case 'xb-tts:config-saved': + if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break; + if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer); + pendingSaveRequest = null; fillForm(payload); handleSaveResult(true); post('xb-tts:toast', { type: 'success', message: '配置已保存' }); break; case 'xb-tts:config-save-error': + if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break; + if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer); + pendingSaveRequest = null; handleSaveResult(false); post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' }); break; @@ -2432,7 +2458,7 @@ document.addEventListener('DOMContentLoaded', () => { $$('.voice-tab')[0].classList.add('active'); $('panel-myVoice').classList.add('active'); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); post('xb-tts:toast', { type: 'success', message: `已添加:${name}` }); }); @@ -2456,7 +2482,7 @@ document.addEventListener('DOMContentLoaded', () => { $$('.voice-tab')[0].classList.add('active'); $('panel-myVoice').classList.add('active'); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); post('xb-tts:toast', { type: 'success', message: `已添加:${name}` }); }); @@ -2475,12 +2501,12 @@ document.addEventListener('DOMContentLoaded', () => { renderMyVoiceList(); updateCurrentVoiceDisplay(); - post('xb-tts:save-config', collectForm()); + requestSaveConfig(collectForm()); post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` }); }); ['saveConfigBtn', 'saveVoiceBtn', 'saveAdvancedBtn', 'saveCacheBtn'].forEach(id => { - $(id)?.addEventListener('click', () => { setSavingState($(id)); post('xb-tts:save-config', collectForm()); }); + $(id)?.addEventListener('click', () => { requestSaveConfig(collectForm(), $(id)); }); }); $('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh')); diff --git a/modules/tts/tts.js b/modules/tts/tts.js index 8415337..4c37cbb 100644 --- a/modules/tts/tts.js +++ b/modules/tts/tts.js @@ -1079,15 +1079,17 @@ async function handleIframeMessage(ev) { closeSettings(); break; case 'xb-tts:save-config': { - const ok = await saveConfig(payload); + const requestId = payload?.requestId || ''; + const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload; + const ok = await saveConfig(patch); if (ok) { const cacheStats = await getCacheStatsSafe(); - postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } }); + postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats, requestId } }); updateAutoSpeakAll(); updateSpeedAll(); updateVoiceAll(); } else { - postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); + postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败', requestId } }); } break; } diff --git a/settings.html b/settings.html index 4d76b53..7d1804e 100644 --- a/settings.html +++ b/settings.html @@ -206,6 +206,14 @@ +
+ + + +

变量控制

@@ -519,14 +527,15 @@ audio: 'xiaobaix_audio_enabled', storySummary: 'xiaobaix_story_summary_enabled', tts: 'xiaobaix_tts_enabled', + enaPlanner: 'xiaobaix_ena_planner_enabled', storyOutline: 'xiaobaix_story_outline_enabled', useBlob: 'xiaobaix_use_blob', wrapperIframe: 'Wrapperiframe', renderEnabled: 'xiaobaix_render_enabled', }; const DEFAULTS_ON = ['templateEditor', 'tasks', 'variablesCore', 'audio', 'storySummary', 'recorded']; - const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts']; - const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts']; + const DEFAULTS_OFF = ['preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'fourthWall', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner']; + const MODULE_KEYS = ['templateEditor', 'tasks', 'fourthWall', 'variablesCore', 'recorded', 'preview', 'scriptAssistant', 'immersive', 'variablesPanel', 'audio', 'storySummary', 'storyOutline', 'novelDraw', 'tts', 'enaPlanner']; function setModuleEnabled(key, enabled) { try { if (!extension_settings[EXT_ID][key]) extension_settings[EXT_ID][key] = {};