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'}
-
-
-
-
-
-
-
-
-
-
-
-
开启后:你点发送/回车,会先走"规划模型",把规划结果写回输入框再发送。
-
-
-
-
-
防止"原始+规划文本"再次被拦截规划。
-
-
-
-
-
-
-
-
-
-
角色绑定的世界书总是会读取。这里选择是否额外包含全局世界书。
-
-
-
-
-
-
-
-
-
-
-
-
条目名称/备注包含这些字符串的条目会被排除。
-
-
-
-
-
-
-
-
-
-
-
- 自动行为说明:
- · 聊天片段:自动读取所有未隐藏的 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] = {};