+
+ 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
+ * --------------------------
+ */
+async function runPlanningOnce(rawUserInput, silent = false) {
+ const s = ensureSettings();
+
+ const log = {
+ time: nowISO(), ok: false, model: s.api.model,
+ requestMessages: [], rawReply: '', filteredReply: '', error: ''
+ };
+
+ try {
+ const { messages } = await buildPlannerMessages(rawUserInput);
+ log.requestMessages = messages;
+
+ const rawReply = await callPlanner(messages);
+ log.rawReply = rawReply;
+
+ const filtered = filterPlannerForInput(rawReply);
+ log.filteredReply = filtered;
+ log.ok = true;
+
+ state.logs.unshift(log); clampLogs(); persistLogsMaybe();
+ return { rawReply, filtered };
+ } catch (e) {
+ log.error = String(e?.message ?? e);
+ state.logs.unshift(log); clampLogs(); persistLogsMaybe();
+ if (!silent) toastErr(log.error);
+ throw e;
+ }
+}
+
+/**
+ * -------------------------
+ * Intercept send
+ * --------------------------
+ */
+function getSendTextarea() { return document.getElementById('send_textarea'); }
+function getSendButton() { return document.getElementById('send_but') || document.getElementById('send_button'); }
+
+function shouldInterceptNow() {
+ const s = ensureSettings();
+ if (!s.enabled || state.isPlanning) return false;
+ const ta = getSendTextarea();
+ if (!ta) return false;
+ const txt = String(ta.value ?? '').trim();
+ if (!txt) return false;
+ if (state.bypassNextSend) return false;
+ if (s.skipIfPlotPresent && /