Refactor Ena Planner to iframe settings and harden save ack flow

This commit is contained in:
2026-02-25 10:26:01 +08:00
parent 526054dac8
commit 287abff599
8 changed files with 1221 additions and 1213 deletions

View File

@@ -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 });

View File

@@ -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);

View File

@@ -1,242 +1,188 @@
/* Ena Planner v0.5 — collapsible, clean */
/* ===== Settings panel inside inline-drawer ===== */
#ena_planner_panel {
padding: 8px 0;
}
#ena_planner_panel .ep-row {
: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;
flex-wrap: wrap;
margin: 8px 0;
}
#ena_planner_panel label {
font-size: 12px;
opacity: .9;
display: block;
margin-bottom: 4px;
}
#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;
}
#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 {
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
opacity: .9;
margin-left: 8px;
}
.ep-badge-inline .dot {
color: var(--muted);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #888;
display: inline-block;
}
background: #ff9800;
}
.dot.ok { background: #4caf50; }
.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 {
.tabs {
display: flex;
gap: 8px;
gap: 6px;
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;
}
}
.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;
}
.ep-log-modal .ep-log-head .title {
font-weight: 700;
font-size: 15px;
}
.panel { display: none; }
.panel.active { display: block; }
.ep-log-modal .ep-log-body {
overflow: auto;
flex: 1 1 auto;
border: 1px solid rgba(255, 255, 255, .08);
.card {
background: var(--card);
border: 1px solid var(--line);
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 {
padding: 12px;
margin-bottom: 10px;
}
.row {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
opacity: .85;
font-size: 12px;
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); }
.ep-log-item .ep-log-error {
color: #ffb3b3;
.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;
}
.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;
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 {
}
.log-pre {
margin-top: 6px;
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;
background: #0f0f0f;
border: 1px solid #2f2f2f;
border-radius: 6px;
padding: 8px;
max-height: 260px;
overflow: auto;
}
}

View File

@@ -0,0 +1,653 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ena Planner</title>
<link rel="stylesheet" href="./ena-planner.css">
</head>
<body>
<div class="wrap">
<div class="top">
<strong>Ena Planner</strong>
<span id="stateBadge" class="badge"><span class="dot"></span><span>Disabled</span></span>
</div>
<div class="tabs">
<div class="tab active" data-tab="general">General</div>
<div class="tab" data-tab="api">API</div>
<div class="tab" data-tab="prompt">Prompt</div>
<div class="tab" data-tab="debug">Debug</div>
<div class="tab" data-tab="logs">Logs</div>
</div>
<div class="panel active" data-panel="general">
<div class="card">
<div class="row">
<div class="col">
<label>Enabled</label>
<select id="ep_enabled"><option value="true">Enabled</option><option value="false">Disabled</option></select>
</div>
<div class="col">
<label>Skip if input already contains &lt;plot&gt;</label>
<select id="ep_skip_plot"><option value="true">Yes</option><option value="false">No</option></select>
</div>
</div>
<div class="row">
<div class="col">
<label>Include global worldbooks</label>
<select id="ep_include_global_wb"><option value="false">No</option><option value="true">Yes</option></select>
</div>
<div class="col">
<label>Exclude worldbook entries with position=4</label>
<select id="ep_wb_pos4"><option value="true">Yes</option><option value="false">No</option></select>
</div>
</div>
<div class="row">
<div class="col">
<label>Worldbook name excludes (comma separated)</label>
<input id="ep_wb_exclude_names" type="text" placeholder="mvu_update">
</div>
<div class="col">
<label>Recent plot count</label>
<input id="ep_plot_n" type="number" min="0" step="1">
</div>
</div>
<div class="row">
<div class="col">
<label>Chat exclude tags (comma separated)</label>
<input id="ep_exclude_tags" type="text" placeholder="ActionOptions, UpdateVariable, StatusPlaceHolderImpl">
</div>
</div>
<div class="row">
<div class="col">
<label>Persist logs</label>
<select id="ep_logs_persist"><option value="true">Yes</option><option value="false">No</option></select>
</div>
<div class="col">
<label>Log max count</label>
<input id="ep_logs_max" type="number" min="1" max="200" step="1">
</div>
</div>
</div>
</div>
<div class="panel" data-panel="api">
<div class="card">
<div class="row">
<div class="col">
<label>Channel</label>
<select id="ep_api_channel">
<option value="openai">OpenAI compatible</option>
<option value="gemini">Gemini compatible</option>
<option value="claude">Claude compatible</option>
</select>
</div>
<div class="col">
<label>Prefix mode</label>
<select id="ep_prefix_mode"><option value="auto">Auto</option><option value="custom">Custom</option></select>
</div>
</div>
<div class="row">
<div class="col"><label>API base URL</label><input id="ep_api_base" type="text" placeholder="https://..."></div>
<div class="col"><label>Custom prefix</label><input id="ep_prefix_custom" type="text" placeholder="/v1"></div>
</div>
<div class="row">
<div class="col"><label>API key</label><input id="ep_api_key" type="password" placeholder="sk-..."></div>
<div class="col"><label>Model</label><input id="ep_model" type="text" placeholder="model-name"></div>
</div>
<div class="row">
<div class="col"><label>Stream</label><select id="ep_stream"><option value="true">Enabled</option><option value="false">Disabled</option></select></div>
<div class="col"><label>Temperature</label><input id="ep_temp" type="number" step="0.1"></div>
</div>
<div class="row">
<div class="col"><label>Top P</label><input id="ep_top_p" type="number" step="0.05"></div>
<div class="col"><label>Top K</label><input id="ep_top_k" type="number" step="1"></div>
</div>
<div class="row">
<div class="col"><label>Presence penalty</label><input id="ep_pp" type="text"></div>
<div class="col"><label>Frequency penalty</label><input id="ep_fp" type="text"></div>
</div>
<div class="row">
<div class="col"><label>Max tokens</label><input id="ep_mt" type="text"></div>
</div>
<div class="actions">
<button id="ep_fetch_models" class="btn">Fetch models</button>
<button id="ep_test_conn" class="btn">Test connection</button>
</div>
<div class="hint">Models preview: <span id="ep_models_preview">Not loaded</span></div>
</div>
</div>
<div class="panel" data-panel="prompt">
<div class="card">
<div class="row">
<div class="col">
<label>Template</label>
<select id="ep_tpl_select"><option value="">-- Select template --</option></select>
</div>
</div>
<div class="actions">
<button id="ep_tpl_save" class="btn">Save</button>
<button id="ep_tpl_saveas" class="btn">Save as</button>
<button id="ep_tpl_delete" class="btn">Delete</button>
</div>
<div id="ep_prompt_list"></div>
<div class="actions">
<button id="ep_add_prompt" class="btn">Add block</button>
<button id="ep_reset_prompt" class="btn">Reset default</button>
</div>
</div>
</div>
<div class="panel" data-panel="debug">
<div class="card">
<div class="actions">
<button id="ep_debug_worldbook" class="btn">Debug worldbook</button>
<button id="ep_debug_char" class="btn">Debug character</button>
<button id="ep_test_planner" class="btn">Run planner test</button>
</div>
<pre id="ep_debug_output" class="log-pre" style="display:none;"></pre>
</div>
</div>
<div class="panel" data-panel="logs">
<div class="card">
<div class="actions">
<button id="ep_open_logs" class="btn">Refresh logs</button>
<button id="ep_log_export" class="btn">Export JSON</button>
<button id="ep_log_clear" class="btn">Clear</button>
</div>
<div id="ep_log_body" class="log-list"></div>
</div>
</div>
<div class="actions">
<button id="ep_save" class="btn primary">Save</button>
<button id="ep_close" class="btn">Close</button>
</div>
<div id="ep_status" class="status"></div>
</div>
<script>
const PARENT_ORIGIN = (() => {
try { return new URL(document.referrer).origin; } catch { return window.location.origin; }
})();
const post = (type, payload) => parent.postMessage({ type, payload }, PARENT_ORIGIN);
const $ = (id) => document.getElementById(id);
const $$ = (sel) => document.querySelectorAll(sel);
let cfg = null;
let logs = [];
let pendingSave = null;
function setStatus(text, isError = false) {
const el = $('ep_status');
el.textContent = text || '';
el.className = `status${isError ? ' error' : ''}`;
}
function setSaveButtonBusy(busy) {
const btn = $('ep_save');
if (!btn) return;
if (busy) {
btn.disabled = true;
btn.dataset.originalText = btn.textContent;
btn.textContent = 'Saving...';
} else {
btn.disabled = false;
btn.textContent = btn.dataset.originalText || 'Save';
delete btn.dataset.originalText;
}
}
function startPendingSave(requestId, pendingText = 'Saving...') {
pendingSave = {
requestId,
timer: setTimeout(() => {
if (!pendingSave || pendingSave.requestId !== requestId) return;
pendingSave = null;
setSaveButtonBusy(false);
setStatus('Save timeout after 3s', true);
}, 3000)
};
setSaveButtonBusy(true);
setStatus(pendingText);
}
function setBadge(enabled) {
const badge = $('stateBadge');
badge.innerHTML = `<span class="dot${enabled ? ' ok' : ''}"></span><span>${enabled ? 'Enabled' : 'Disabled'}</span>`;
}
function activateTab(tabId) {
$$('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tabId));
$$('.panel').forEach((p) => p.classList.toggle('active', p.dataset.panel === tabId));
if (tabId === 'logs') post('xb-ena:logs-request');
}
function toBool(v, fallback = false) {
if (v === true || v === false) return v;
if (v === 'true') return true;
if (v === 'false') return false;
return fallback;
}
function toNum(v, fallback = 0) {
const n = Number(v);
return Number.isFinite(n) ? n : fallback;
}
function arrToCsv(arr) {
return Array.isArray(arr) ? arr.join(', ') : '';
}
function csvToArr(text) {
return String(text || '').split(',').map((x) => x.trim()).filter(Boolean);
}
function createPromptBlockElement(block, idx, total) {
const wrap = document.createElement('div');
wrap.className = 'prompt-block';
const head = document.createElement('div');
head.className = 'prompt-head';
const left = document.createElement('div');
left.className = 'prompt-head-left';
const name = document.createElement('input');
name.type = 'text';
name.placeholder = 'Block name';
name.value = block.name || '';
name.style.minWidth = '180px';
name.addEventListener('change', () => {
block.name = name.value || '';
});
const role = document.createElement('select');
['system', 'user', 'assistant'].forEach((r) => {
const opt = document.createElement('option');
opt.value = r;
opt.textContent = r;
opt.selected = (block.role || 'system') === r;
role.appendChild(opt);
});
role.addEventListener('change', () => {
block.role = role.value;
});
left.append(name, role);
const right = document.createElement('div');
right.style.display = 'flex';
right.style.gap = '6px';
const up = document.createElement('button');
up.className = 'btn';
up.textContent = 'Up';
up.disabled = idx === 0;
up.addEventListener('click', () => {
if (idx === 0) return;
const list = cfg.promptBlocks;
[list[idx - 1], list[idx]] = [list[idx], list[idx - 1]];
renderPromptList();
});
const down = document.createElement('button');
down.className = 'btn';
down.textContent = 'Down';
down.disabled = idx === total - 1;
down.addEventListener('click', () => {
if (idx >= total - 1) return;
const list = cfg.promptBlocks;
[list[idx], list[idx + 1]] = [list[idx + 1], list[idx]];
renderPromptList();
});
const del = document.createElement('button');
del.className = 'btn';
del.textContent = 'Delete';
del.addEventListener('click', () => {
cfg.promptBlocks.splice(idx, 1);
renderPromptList();
});
right.append(up, down, del);
const content = document.createElement('textarea');
content.value = block.content || '';
content.placeholder = 'Prompt block content';
content.addEventListener('change', () => {
block.content = content.value || '';
});
head.append(left, right);
wrap.append(head, content);
return wrap;
}
function renderTemplateSelect(selected = '') {
const sel = $('ep_tpl_select');
sel.textContent = '';
const first = document.createElement('option');
first.value = '';
first.textContent = '-- Select template --';
sel.appendChild(first);
const names = Object.keys(cfg?.promptTemplates || {});
names.forEach((name) => {
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
opt.selected = name === selected;
sel.appendChild(opt);
});
}
function renderPromptList() {
const list = $('ep_prompt_list');
list.textContent = '';
const blocks = cfg?.promptBlocks || [];
if (!blocks.length) {
const empty = document.createElement('div');
empty.className = 'hint';
empty.textContent = 'No prompt blocks';
list.appendChild(empty);
return;
}
blocks.forEach((block, idx) => {
list.appendChild(createPromptBlockElement(block, idx, blocks.length));
});
}
function renderLogs() {
const body = $('ep_log_body');
body.textContent = '';
if (!Array.isArray(logs) || logs.length === 0) {
const empty = document.createElement('div');
empty.className = 'hint';
empty.textContent = 'No logs';
body.appendChild(empty);
return;
}
logs.forEach((item) => {
const row = document.createElement('div');
row.className = 'log-item';
const meta = document.createElement('div');
meta.className = 'log-meta';
const left = document.createElement('span');
left.textContent = `${item.time || '-'} | ${item.ok ? 'OK' : 'FAIL'}`;
const right = document.createElement('span');
right.textContent = item.model || '-';
meta.append(left, right);
row.appendChild(meta);
if (item.error) {
const err = document.createElement('div');
err.className = 'log-error';
err.textContent = String(item.error);
row.appendChild(err);
}
const req = document.createElement('details');
const reqSm = document.createElement('summary');
reqSm.textContent = 'Request messages';
const reqPre = document.createElement('pre');
reqPre.className = 'log-pre';
reqPre.textContent = JSON.stringify(item.requestMessages || [], null, 2);
req.append(reqSm, reqPre);
row.appendChild(req);
const raw = document.createElement('details');
const rawSm = document.createElement('summary');
rawSm.textContent = 'Raw reply';
const rawPre = document.createElement('pre');
rawPre.className = 'log-pre';
rawPre.textContent = String(item.rawReply || '');
raw.append(rawSm, rawPre);
row.appendChild(raw);
const filtered = document.createElement('details');
filtered.open = true;
const filteredSm = document.createElement('summary');
filteredSm.textContent = 'Filtered reply';
const filteredPre = document.createElement('pre');
filteredPre.className = 'log-pre';
filteredPre.textContent = String(item.filteredReply || '');
filtered.append(filteredSm, filteredPre);
row.appendChild(filtered);
body.appendChild(row);
});
}
function applyConfig(nextCfg) {
cfg = nextCfg || {};
logs = Array.isArray(cfg.logs) ? cfg.logs : [];
$('ep_enabled').value = String(toBool(cfg.enabled, true));
$('ep_skip_plot').value = String(toBool(cfg.skipIfPlotPresent, true));
$('ep_include_global_wb').value = String(toBool(cfg.includeGlobalWorldbooks, false));
$('ep_wb_pos4').value = String(toBool(cfg.excludeWorldbookPosition4, true));
$('ep_wb_exclude_names').value = arrToCsv(cfg.worldbookExcludeNames);
$('ep_plot_n').value = String(toNum(cfg.plotCount, 2));
$('ep_exclude_tags').value = arrToCsv(cfg.chatExcludeTags);
$('ep_logs_persist').value = String(toBool(cfg.logsPersist, true));
$('ep_logs_max').value = String(toNum(cfg.logsMax, 20));
const api = cfg.api || {};
$('ep_api_channel').value = api.channel || 'openai';
$('ep_prefix_mode').value = api.prefixMode || 'auto';
$('ep_api_base').value = api.baseUrl || '';
$('ep_prefix_custom').value = api.customPrefix || '';
$('ep_api_key').value = api.apiKey || '';
$('ep_model').value = api.model || '';
$('ep_stream').value = String(toBool(api.stream, false));
$('ep_temp').value = String(toNum(api.temperature, 1));
$('ep_top_p').value = String(toNum(api.top_p, 1));
$('ep_top_k').value = String(toNum(api.top_k, 0));
$('ep_pp').value = api.presence_penalty ?? '';
$('ep_fp').value = api.frequency_penalty ?? '';
$('ep_mt').value = api.max_tokens ?? '';
setBadge(toBool(cfg.enabled, true));
renderTemplateSelect();
renderPromptList();
renderLogs();
}
function collectPatch() {
const next = structuredClone(cfg || {});
next.enabled = toBool($('ep_enabled').value, true);
next.skipIfPlotPresent = toBool($('ep_skip_plot').value, true);
next.includeGlobalWorldbooks = toBool($('ep_include_global_wb').value, false);
next.excludeWorldbookPosition4 = toBool($('ep_wb_pos4').value, true);
next.worldbookExcludeNames = csvToArr($('ep_wb_exclude_names').value);
next.plotCount = Math.max(0, Math.floor(toNum($('ep_plot_n').value, 2)));
next.chatExcludeTags = csvToArr($('ep_exclude_tags').value);
next.logsPersist = toBool($('ep_logs_persist').value, true);
next.logsMax = Math.max(1, Math.min(200, Math.floor(toNum($('ep_logs_max').value, 20))));
next.api = next.api || {};
next.api.channel = $('ep_api_channel').value;
next.api.prefixMode = $('ep_prefix_mode').value;
next.api.baseUrl = $('ep_api_base').value.trim();
next.api.customPrefix = $('ep_prefix_custom').value.trim();
next.api.apiKey = $('ep_api_key').value;
next.api.model = $('ep_model').value.trim();
next.api.stream = toBool($('ep_stream').value, false);
next.api.temperature = toNum($('ep_temp').value, 1);
next.api.top_p = toNum($('ep_top_p').value, 1);
next.api.top_k = Math.floor(toNum($('ep_top_k').value, 0));
next.api.presence_penalty = $('ep_pp').value.trim();
next.api.frequency_penalty = $('ep_fp').value.trim();
next.api.max_tokens = $('ep_mt').value.trim();
next.promptBlocks = Array.isArray(cfg?.promptBlocks) ? cfg.promptBlocks : [];
next.promptTemplates = cfg?.promptTemplates || {};
return next;
}
function bindUiEvents() {
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => activateTab(tab.dataset.tab));
});
$('ep_enabled').addEventListener('change', () => setBadge(toBool($('ep_enabled').value, true)));
$('ep_add_prompt').addEventListener('click', () => {
cfg.promptBlocks = cfg.promptBlocks || [];
cfg.promptBlocks.push({
id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
role: 'system',
name: 'New block',
content: ''
});
renderPromptList();
});
$('ep_reset_prompt').addEventListener('click', () => {
if (!confirm('Reset prompt blocks to default?')) return;
if (pendingSave) return;
const requestId = `ena_reset_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
startPendingSave(requestId, 'Resetting prompt blocks...');
post('xb-ena:reset-prompt-default', { requestId });
});
$('ep_tpl_select').addEventListener('change', () => {
const name = $('ep_tpl_select').value;
if (!name) return;
const blocks = cfg?.promptTemplates?.[name];
if (!Array.isArray(blocks)) return;
cfg.promptBlocks = structuredClone(blocks);
renderPromptList();
setStatus(`Template loaded: ${name}`);
});
$('ep_tpl_save').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) return setStatus('Select a template first', true);
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
renderTemplateSelect(name);
setStatus(`Template saved: ${name}`);
});
$('ep_tpl_saveas').addEventListener('click', () => {
const name = prompt('Template name');
if (!name) return;
cfg.promptTemplates = cfg.promptTemplates || {};
cfg.promptTemplates[name] = structuredClone(cfg.promptBlocks || []);
renderTemplateSelect(name);
setStatus(`Template saved as: ${name}`);
});
$('ep_tpl_delete').addEventListener('click', () => {
const name = $('ep_tpl_select').value;
if (!name) return;
if (!confirm(`Delete template \"${name}\"?`)) return;
delete cfg.promptTemplates[name];
renderTemplateSelect('');
setStatus(`Template deleted: ${name}`);
});
$('ep_open_logs').addEventListener('click', () => post('xb-ena:logs-request'));
$('ep_log_clear').addEventListener('click', () => post('xb-ena:logs-clear'));
$('ep_log_export').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(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);
});
$('ep_fetch_models').addEventListener('click', () => post('xb-ena:fetch-models'));
$('ep_test_conn').addEventListener('click', () => post('xb-ena:fetch-models'));
$('ep_debug_worldbook').addEventListener('click', () => post('xb-ena:debug-worldbook'));
$('ep_debug_char').addEventListener('click', () => post('xb-ena:debug-char'));
$('ep_test_planner').addEventListener('click', () => post('xb-ena:run-test', { text: '(test input) Please plan the next story step.' }));
$('ep_save').addEventListener('click', () => {
if (pendingSave) return;
const requestId = `ena_save_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const patch = collectPatch();
startPendingSave(requestId, 'Saving...');
post('xb-ena:save-config', { requestId, patch });
});
$('ep_close').addEventListener('click', () => post('xb-ena:close'));
}
window.addEventListener('message', (ev) => {
if (ev.origin !== PARENT_ORIGIN) return;
const { type, payload } = ev.data || {};
if (type === 'xb-ena:config' || type === 'xb-ena:config-saved') {
applyConfig(payload || {});
if (type === 'xb-ena:config-saved') {
const requestId = payload?.requestId || '';
if (pendingSave && pendingSave.requestId === requestId) {
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveButtonBusy(false);
setStatus('Saved');
}
}
} else if (type === 'xb-ena:config-save-error') {
const requestId = payload?.requestId || '';
if (pendingSave && pendingSave.requestId === requestId) {
clearTimeout(pendingSave.timer);
pendingSave = null;
setSaveButtonBusy(false);
}
setStatus(payload?.message || 'Save failed', true);
} else if (type === 'xb-ena:test-done') {
setStatus('Planner test completed');
} else if (type === 'xb-ena:test-error') {
setStatus(payload?.message || 'Planner test failed', true);
} else if (type === 'xb-ena:logs') {
logs = Array.isArray(payload?.logs) ? payload.logs : [];
renderLogs();
} else if (type === 'xb-ena:models') {
const models = Array.isArray(payload?.models) ? payload.models : [];
const preview = models.slice(0, 8).join(', ');
$('ep_models_preview').textContent = models.length ? `${models.length} models: ${preview}` : 'No models';
setStatus(`Fetched ${models.length} models`);
} else if (type === 'xb-ena:models-error') {
setStatus(payload?.message || 'Model fetch failed', true);
} else if (type === 'xb-ena:debug-output') {
const out = $('ep_debug_output');
out.style.display = '';
out.textContent = String(payload?.output || '');
}
});
bindUiEvents();
post('xb-ena:ready');
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1275,7 +1275,7 @@ select.input { cursor: pointer; }
<div class="tip-box" style="margin-bottom: 16px;">
<i class="fa-solid fa-info-circle"></i>
<div>
<strong>试用音色</strong> — 无需配置,使用插件服务器(11个音色<br>
<strong>试用音色</strong> — 无需配置,使用插件服务器(21个音色<br>
<strong>鉴权音色</strong> — 需配置火山引擎 API200+ 音色 + 复刻)
</div>
</div>
@@ -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'));

View File

@@ -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;
}

View File

@@ -206,6 +206,14 @@
<input type="checkbox" id="xiaobaix_story_outline_enabled" />
<label for="xiaobaix_story_outline_enabled" class="has-tooltip" data-tooltip="在X按钮区域添加地图图标点击可打开可视化剧情地图编辑器">小白板</label>
</div>
<div class="flex-container">
<input type="checkbox" id="xiaobaix_ena_planner_enabled" />
<label for="xiaobaix_ena_planner_enabled" class="has-tooltip" data-tooltip="发送前剧情规划,自动注入 plot/note">剧情规划</label>
<button id="xiaobaix_ena_planner_open_settings" class="menu_button menu_button_icon" type="button" style="margin-left:auto;" title="打开剧情规划设置">
<i class="fa-solid fa-compass-drafting"></i>
<small>规划设置</small>
</button>
</div>
<br>
<div class="section-divider">变量控制</div>
<hr class="sysHR" />
@@ -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] = {};