Ena Planner
${s.enabled ? 'Enabled' : 'Disabled'}
开启后:你点发送/回车,会先走"规划模型",把规划结果写回输入框再发送。
防止"原始+规划文本"再次被拦截规划。
角色绑定的世界书总是会读取。这里选择是否额外包含全局世界书。
自动行为说明:
· 聊天片段:自动读取所有未隐藏的 AI 回复(不含用户输入)
· 自动剔除 <think> 以前的内容(含未包裹的思考段落)
· 角色卡字段(desc/personality/scenario):有就全部加入
· 向量召回(extensionPrompts):有就自动加入
· 世界书激活:常驻(蓝灯)+ 关键词触发(绿灯)
新增多条提示词块,选择 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 && /