Files
LittleWhiteBox/modules/ena-planner/ena-planner.js

2035 lines
72 KiB
JavaScript
Raw Normal View History

2026-02-18 22:53:41 +08:00
import { extension_settings } from '../../../../../extensions.js';
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js';
2026-02-18 22:32:13 +08:00
import { getStorySummaryForEna } from '../story-summary/story-summary.js';
const EXT_NAME = 'ena-planner';
/** -------------------------
* Default settings
* --------------------------*/
function getDefaultSettings() {
return {
enabled: true,
skipIfPlotPresent: true,
// Chat history: tags to strip from AI responses (besides <think>)
chatExcludeTags: ['行动选项', 'UpdateVariable', 'StatusPlaceHolderImpl'],
// Worldbook: always read character-linked lorebooks by default
// User can also opt-in to include global worldbooks
includeGlobalWorldbooks: false,
excludeWorldbookPosition4: true,
// Worldbook entry names containing these strings will be excluded
worldbookExcludeNames: ['mvu_update'],
// Plot extraction
plotCount: 2,
// Planner prompts (designer)
promptBlocks: [
{
id: crypto?.randomUUID?.() ?? String(Date.now()),
role: 'system',
name: 'Ena Planner System',
content: `(把你的"规划的提示词.txt"粘贴到这里)
要求输出 <plot>...</plot> <note>...</note> <think>...</think>`
},
{
id: crypto?.randomUUID?.() ?? String(Date.now() + 1),
role: 'assistant',
name: 'Assistant Seed (optional)',
content: ''
}
],
// Saved prompt templates: { name: promptBlocks[] }
promptTemplates: {},
// Planner API
api: {
channel: 'openai',
baseUrl: '',
prefixMode: 'auto',
customPrefix: '',
apiKey: '',
model: '',
stream: false,
temperature: 1,
top_p: 1,
top_k: 0,
presence_penalty: '',
frequency_penalty: '',
max_tokens: ''
},
// Logs
logsPersist: true,
logsMax: 20
};
}
/** -------------------------
* Local state
* --------------------------*/
const state = {
isPlanning: false,
bypassNextSend: false,
lastInjectedText: '',
logs: []
};
/** -------------------------
* Helpers
* --------------------------*/
function ensureSettings() {
extension_settings[EXT_NAME] = extension_settings[EXT_NAME] ?? getDefaultSettings();
const d = getDefaultSettings();
const s = extension_settings[EXT_NAME];
function deepMerge(target, src) {
for (const k of Object.keys(src)) {
if (src[k] && typeof src[k] === 'object' && !Array.isArray(src[k])) {
target[k] = target[k] ?? {};
deepMerge(target[k], src[k]);
} else if (target[k] === undefined) {
target[k] = src[k];
}
}
}
deepMerge(s, d);
// Migration: remove old keys that are no longer needed
delete s.includeCharacterLorebooks;
delete s.includeCharDesc;
delete s.includeCharPersonality;
delete s.includeCharScenario;
delete s.includeVectorRecall;
delete s.historyMessageCount;
delete s.worldbookActivationMode;
return s;
}
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);
}
function clampLogs() {
const s = ensureSettings();
if (state.logs.length > s.logsMax) state.logs = state.logs.slice(0, s.logsMax);
}
function persistLogsMaybe() {
const s = ensureSettings();
if (!s.logsPersist) return;
try {
localStorage.setItem('ena_planner_logs', JSON.stringify(state.logs.slice(0, s.logsMax)));
} 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 = [];
}
}
function nowISO() {
return new Date().toISOString();
}
function normalizeUrlBase(u) {
if (!u) return '';
return u.replace(/\/+$/g, '');
}
function getDefaultPrefixByChannel(channel) {
if (channel === 'gemini') return '/v1beta';
return '/v1';
}
function buildApiPrefix() {
const s = ensureSettings();
if (s.api.prefixMode === 'custom' && s.api.customPrefix?.trim()) return s.api.customPrefix.trim();
return getDefaultPrefixByChannel(s.api.channel);
}
function buildUrl(path) {
const s = ensureSettings();
const base = normalizeUrlBase(s.api.baseUrl);
const prefix = buildApiPrefix();
const p = prefix.startsWith('/') ? prefix : `/${prefix}`;
const finalPrefix = p.replace(/\/+$/g, '');
const finalPath = path.startsWith('/') ? path : `/${path}`;
return `${base}${finalPrefix}${finalPath}`;
}
function setSendUIBusy(busy) {
const sendBtn = document.getElementById('send_but') || document.getElementById('send_button');
const textarea = document.getElementById('send_textarea');
if (sendBtn) sendBtn.disabled = !!busy;
if (textarea) textarea.disabled = !!busy;
}
function escapeHtml(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
/**
* 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;
try { return JSON.stringify(val, null, 2); } catch { return String(val); }
}
/** -------------------------
* ST context helpers
* --------------------------*/
function getContextSafe() {
try { return window.SillyTavern?.getContext?.() ?? null; } catch { return null; }
}
function getCurrentCharSafe() {
try {
// Method 1: via getContext()
const ctx = getContextSafe();
if (ctx) {
const cid = ctx.characterId ?? ctx.this_chid;
const chars = ctx.characters;
if (chars && cid != null && chars[cid]) return chars[cid];
}
// Method 2: global this_chid + characters
const st = window.SillyTavern;
if (st) {
const chid = st.this_chid ?? window.this_chid;
const chars = st.characters ?? window.characters;
if (chars && chid != null && chars[chid]) return chars[chid];
}
// Method 3: bare globals (some ST versions)
if (window.this_chid != null && window.characters) {
return window.characters[window.this_chid] ?? null;
}
} catch {}
return null;
}
/** -------------------------
* Character card always include desc/personality/scenario
* --------------------------*/
function formatCharCardBlock(charObj) {
if (!charObj) return '';
const name = charObj?.name ?? '';
const description = charObj?.description ?? '';
const personality = charObj?.personality ?? '';
const scenario = charObj?.scenario ?? '';
const parts = [];
parts.push(`【角色卡】${name}`.trim());
if (description) parts.push(`【description】\n${description}`);
if (personality) parts.push(`【personality】\n${personality}`);
if (scenario) parts.push(`【scenario】\n${scenario}`);
return parts.join('\n\n');
}
/** -------------------------
* Chat history ALL unhidden, AI responses ONLY
* Strip: unclosed think blocks, configurable tags
* --------------------------*/
function cleanAiMessageText(text) {
let out = String(text ?? '');
// 1) Strip everything before and including </think> (handles unclosed think blocks)
// Pattern: content without opening <think> followed by </think>
out = out.replace(/^[\s\S]*?<\/think>/i, '');
// 2) Also strip properly wrapped <think>...</think> blocks
out = out.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '');
out = out.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '');
// 3) Strip user-configured exclude tags
// NOTE: JS \b does NOT work after CJK characters, so we use [^>]*> instead.
// Order matters: try block match first (greedy), then mop up orphan open/close tags.
const s = ensureSettings();
const tags = s.chatExcludeTags ?? [];
for (const tag of tags) {
if (!tag) continue;
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// First: match full block <tag ...>...</tag>
const blockRe = new RegExp(`<${escaped}[^>]*>[\\s\\S]*?<\\/${escaped}>`, 'gi');
out = out.replace(blockRe, '');
// Then: mop up any orphan closing tags </tag>
const closeRe = new RegExp(`<\\/${escaped}>`, 'gi');
out = out.replace(closeRe, '');
// Finally: mop up orphan opening or self-closing tags <tag ...> or <tag/>
const openRe = new RegExp(`<${escaped}(?:[^>]*)\\/?>`, 'gi');
out = out.replace(openRe, '');
}
return out.trim();
}
function collectRecentChatSnippet(chat, maxMessages) {
if (!Array.isArray(chat) || chat.length === 0) return '';
// Filter: not system, not hidden, and NOT user messages (AI only)
const aiMessages = chat.filter(m =>
!m?.is_system && !m?.is_user && !m?.extra?.hidden
);
if (!aiMessages.length) return '';
// If maxMessages specified, only take the last N
const selected = (maxMessages && maxMessages > 0)
? aiMessages.slice(-maxMessages)
: aiMessages;
const lines = [];
for (const m of selected) {
const name = m?.name ? `${m.name}` : 'assistant';
const raw = (m?.mes ?? '').trim();
if (!raw) continue;
const cleaned = cleanAiMessageText(raw);
if (!cleaned) continue;
lines.push(`[${name}] ${cleaned}`);
}
if (!lines.length) return '';
return `<chat_history>\n${lines.join('\n')}\n</chat_history>`;
}
function getCachedStorySummary() {
return getStorySummaryForEna();
}
/** -------------------------
* Plot extraction
* --------------------------*/
function extractLastNPlots(chat, n) {
if (!Array.isArray(chat) || chat.length === 0) return [];
const want = Math.max(0, Number(n) || 0);
if (!want) return [];
const plots = [];
const plotRe = /<plot\b[^>]*>[\s\S]*?<\/plot>/gi;
for (let i = chat.length - 1; i >= 0; i--) {
const text = chat[i]?.mes ?? '';
if (!text) continue;
const matches = [...text.matchAll(plotRe)];
for (let j = matches.length - 1; j >= 0; j--) {
plots.push(matches[j][0]);
if (plots.length >= want) return plots;
}
}
return plots;
}
function formatPlotsBlock(plotList) {
if (!Array.isArray(plotList) || plotList.length === 0) return '';
// plotList is [newest, ..., oldest] from extractLastNPlots
// Reverse to chronological: oldest first, newest last
const chrono = [...plotList].reverse();
const lines = [];
chrono.forEach((p, idx) => {
lines.push(`【plot -${chrono.length - idx}\n${p}`);
});
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
}
/** -------------------------
* Vector recall always include if present
* --------------------------*/
function formatVectorRecallBlock(extensionPrompts) {
// ST's extensionPrompts is actually an object (key-value map), not an array.
// Most entries are ST internals — we only want actual vector recall / RAG data.
if (!extensionPrompts) return '';
// Known ST internal keys to skip (handled elsewhere or irrelevant)
const skipKeys = new Set([
'QUIET_PROMPT', 'PERSONA_DESCRIPTION', 'TEMP_USER_MESSAGE',
'DEPTH_PROMPT', '2_floating_prompt', 'main', '__STORY_STRING__',
'LWB_varevent_display'
]);
const entries = Array.isArray(extensionPrompts)
? extensionPrompts.map((v, i) => [String(i), v])
: Object.entries(extensionPrompts);
if (!entries.length) return '';
const lines = [];
for (const [key, p] of entries) {
if (!p) continue;
if (typeof key === 'string' && skipKeys.has(key)) continue;
// Skip worldbook depth entries — handled by worldbook block
if (typeof key === 'string' && /^customDepthWI/i.test(key)) continue;
// Skip 小白X (LittleWhiteBox) compressed chat/memory keys
// These start with 'ÿ' (U+00FF) or 'LWB' and contain chat history already handled elsewhere
if (typeof key === 'string' && (key.startsWith('ÿ') || key.startsWith('\u00ff') || key.startsWith('LWB'))) continue;
// Skip long hex-like keys (worldbook entries injected via ST internal mechanism)
if (typeof key === 'string' && /^\u0001/.test(key)) continue;
// Extract text content — handle string, .value, .content, or nested content array
let textContent = '';
if (typeof p === 'string') {
textContent = p;
} else if (typeof p?.value === 'string') {
textContent = p.value;
} else if (typeof p?.content === 'string') {
textContent = p.content;
} else if (Array.isArray(p?.content)) {
const parts = [];
for (const seg of p.content) {
if (seg?.type === 'text' && seg?.text) parts.push(seg.text);
else if (seg?.type === 'image_url') parts.push('[image_url]');
else if (seg?.type === 'video_url') parts.push('[video_url]');
}
textContent = parts.join(' ');
}
const t = textContent.trim();
// Skip short/garbage entries (e.g. "---", empty strings)
if (!t || t.length < 30) continue;
const role = typeof p?.role === 'number'
? ['system', 'user', 'assistant'][p.role] ?? 'system'
: (p?.role ?? 'system');
lines.push(`[${role}] ${t}`);
}
if (!lines.length) return '';
return `<vector_recall>\n${lines.join('\n')}\n</vector_recall>`;
}
/** -------------------------
* Worldbook read via ST API (like idle-watcher)
* Always read character-linked worldbooks.
* Optionally include global worldbooks.
* Activation: constant (blue) + keyword scan (green) only.
* --------------------------*/
async function getCharacterWorldbooks() {
const ctx = getContextSafe();
const charObj = getCurrentCharSafe();
const worldNames = [];
// From character object (multiple paths)
if (charObj) {
const paths = [
charObj?.data?.extensions?.world,
charObj?.world,
charObj?.data?.character_book?.name,
];
for (const w of paths) {
if (w && !worldNames.includes(w)) worldNames.push(w);
}
}
// From context
if (ctx) {
try {
const cid = ctx.characterId ?? ctx.this_chid;
const chars = ctx.characters ?? window.characters;
if (chars && cid != null) {
const c = chars[cid];
const paths = [
c?.data?.extensions?.world,
c?.world,
];
for (const w of paths) {
if (w && !worldNames.includes(w)) worldNames.push(w);
}
}
} catch {}
// ST context may expose chat-linked worldbooks via world_names
try {
if (ctx.worldNames && Array.isArray(ctx.worldNames)) {
for (const w of ctx.worldNames) {
if (w && !worldNames.includes(w)) worldNames.push(w);
}
}
} catch {}
}
// Fallback: try ST's selected character world info
try {
const sw = window.selected_world_info;
if (typeof sw === 'string' && sw && !worldNames.includes(sw)) {
worldNames.push(sw);
}
} catch {}
// Fallback: try reading from chat metadata
try {
const chat = ctx?.chat ?? [];
if (chat.length > 0 && chat[0]?.extra?.world) {
const w = chat[0].extra.world;
if (!worldNames.includes(w)) worldNames.push(w);
}
} catch {}
console.log('[EnaPlanner] Character worldbook names found:', worldNames);
return worldNames.filter(Boolean);
}
async function getGlobalWorldbooks() {
// Try to get the list of currently active global worldbooks
try {
// ST stores active worldbooks in world_info settings
const ctx = getContextSafe();
if (ctx?.world_info?.globalSelect) {
return Array.isArray(ctx.world_info.globalSelect) ? ctx.world_info.globalSelect : [];
}
} catch {}
// Fallback: try window.selected_world_info
try {
if (window.selected_world_info && Array.isArray(window.selected_world_info)) {
return window.selected_world_info;
}
} catch {}
return [];
}
async function getWorldbookData(worldName) {
if (!worldName) return null;
try {
const response = await fetch('/api/worldinfo/get', {
method: 'POST',
headers: getRequestHeaders(),
body: JSON.stringify({ name: worldName }),
});
if (response.ok) {
const data = await response.json();
// ST returns { entries: {...} } or { entries: [...] }
let entries = data?.entries;
if (entries && !Array.isArray(entries)) {
entries = Object.values(entries);
}
return { name: worldName, entries: entries || [] };
}
} catch (e) {
console.warn(`[EnaPlanner] Failed to load worldbook "${worldName}":`, e);
}
return null;
}
function keywordPresent(text, kw) {
if (!kw) return false;
return text.toLowerCase().includes(kw.toLowerCase());
}
function matchSelective(entry, scanText) {
const keys = Array.isArray(entry?.key) ? entry.key.filter(Boolean) : [];
const keys2 = Array.isArray(entry?.keysecondary) ? entry.keysecondary.filter(Boolean) : [];
const total = keys.length;
const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
let ok = false;
const logic = entry?.selectiveLogic ?? 0;
if (logic === 0) ok = (total === 0) ? true : hit > 0; // and_any
else if (logic === 1) ok = (total === 0) ? true : hit < total; // not_all
else if (logic === 2) ok = (total === 0) ? true : hit === 0; // not_any
else if (logic === 3) ok = (total === 0) ? true : hit === total; // and_all
if (!ok) return false;
if (keys2.length) {
const hit2 = keys2.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
if (hit2 <= 0) return false;
}
return true;
}
function sortWorldEntries(entries) {
// Sort to mimic ST insertion order within our worldbook block.
// Position priority: 0 (before char def) → 1 (after char def) → 4 (system depth)
// Within pos=4: depth descending (bigger depth = further from chat = earlier)
// Same position+depth: order ascending (higher order = closer to chat_history = later)
const posPriority = { 0: 0, 1: 1, 2: 2, 3: 3, 4: 4 };
return [...entries].sort((a, b) => {
const pa = posPriority[Number(a?.position ?? 0)] ?? 99;
const pb = posPriority[Number(b?.position ?? 0)] ?? 99;
if (pa !== pb) return pa - pb;
// For same position (especially pos=4): bigger depth = earlier
const da = Number(a?.depth ?? 0);
const db = Number(b?.depth ?? 0);
if (da !== db) return db - da;
// Same position+depth: order ascending (smaller order first, bigger order later)
const oa = Number(a?.order ?? 0);
const ob = Number(b?.order ?? 0);
return oa - ob;
});
}
async function buildWorldbookBlock(scanText) {
const s = ensureSettings();
// 1. Always get character-linked worldbooks
const charWorldNames = await getCharacterWorldbooks();
// 2. Optionally get global worldbooks
let globalWorldNames = [];
if (s.includeGlobalWorldbooks) {
globalWorldNames = await getGlobalWorldbooks();
}
// Deduplicate
const allWorldNames = [...new Set([...charWorldNames, ...globalWorldNames])];
if (!allWorldNames.length) {
console.log('[EnaPlanner] No worldbooks to load');
return '';
}
console.log('[EnaPlanner] Loading worldbooks:', allWorldNames);
// Fetch all worldbook data
const worldbookResults = await Promise.all(allWorldNames.map(name => getWorldbookData(name)));
const allEntries = [];
for (const wb of worldbookResults) {
if (!wb || !wb.entries) continue;
for (const entry of wb.entries) {
if (!entry) continue;
allEntries.push({ ...entry, _worldName: wb.name });
}
}
// Filter: not disabled
let entries = allEntries.filter(e => !e?.disable && !e?.disabled);
// Filter: exclude entries whose name contains any of the configured exclude patterns
const nameExcludes = s.worldbookExcludeNames ?? ['mvu_update'];
entries = entries.filter(e => {
const comment = String(e?.comment || e?.name || e?.title || '');
for (const pat of nameExcludes) {
if (pat && comment.includes(pat)) return false;
}
return true;
});
// Filter: exclude position=4 if configured
if (s.excludeWorldbookPosition4) {
entries = entries.filter(e => Number(e?.position) !== 4);
}
if (!entries.length) return '';
// Activation: constant (blue) + keyword scan (green) only
const active = [];
for (const e of entries) {
// Blue light: constant entries always included
if (e?.constant) {
active.push(e);
continue;
}
// Green light: keyword-triggered entries
if (matchSelective(e, scanText)) {
active.push(e);
continue;
}
}
if (!active.length) return '';
// Build EJS context for rendering worldbook templates
const ejsCtx = buildEjsContext();
const sorted = sortWorldEntries(active);
const parts = [];
for (const e of sorted) {
const comment = e?.comment || e?.name || e?.title || '';
const head = `【WorldBook:${e._worldName}${comment ? ' ' + comment : ''}`.trim();
let body = String(e?.content ?? '').trim();
if (!body) continue;
// Try EJS rendering if the entry contains EJS tags
if (body.includes('<%')) {
body = renderEjsTemplate(body, ejsCtx);
}
parts.push(`${head}\n${body}`);
}
if (!parts.length) return '';
return `<worldbook>\n${parts.join('\n\n---\n\n')}\n</worldbook>`;
}
/** -------------------------
* EJS rendering for worldbook entries
* --------------------------*/
function getChatVariables() {
// Try multiple paths to get ST chat variables
try {
const ctx = getContextSafe();
if (ctx?.chatMetadata?.variables) return ctx.chatMetadata.variables;
} catch {}
try {
if (window.chat_metadata?.variables) return window.chat_metadata.variables;
} catch {}
try {
const ctx = getContextSafe();
if (ctx?.chat_metadata?.variables) return ctx.chat_metadata.variables;
} catch {}
return {};
}
function buildEjsContext() {
const vars = getChatVariables();
// getvar: read a chat variable (supports dot-path for nested objects)
function getvar(name) {
if (!name) return '';
2026-02-21 22:03:52 +08:00
let val;
if (vars[name] !== undefined) {
val = vars[name];
} else {
const parts = String(name).split('.');
let cur = vars;
for (const p of parts) {
if (cur == null || typeof cur !== 'object') return '';
cur = cur[p];
}
val = cur ?? '';
2026-02-18 22:32:13 +08:00
}
2026-02-21 22:03:52 +08:00
// 处理字符串布尔值
if (val === 'false' || val === 'False' || val === '0') return '';
return val;
2026-02-18 22:32:13 +08:00
}
// setvar: write a chat variable (no-op for our purposes, just to avoid errors)
function setvar(name, value) {
if (name) vars[name] = value;
return value;
}
// Compute common derived values that entries might reference
const fire = Number(getvar('stat_data.蒂娜.火')) || 0;
const ice = Number(getvar('stat_data.蒂娜.冰')) || 0;
const dark = Number(getvar('stat_data.蒂娜.暗')) || 0;
const light = Number(getvar('stat_data.蒂娜.光')) || 0;
const maxAttrValue = Math.max(fire, ice, dark, light);
return {
getvar, setvar,
fire, ice, dark, light,
maxAttrValue,
Number, Math, JSON, String, Array, Object, parseInt, parseFloat,
console: { log: () => {}, warn: () => {}, error: () => {} },
};
}
function renderEjsTemplate(template, ctx) {
// Try window.ejs first (ST loads this library)
if (window.ejs?.render) {
try {
return window.ejs.render(template, ctx, { async: false });
} catch (e) {
console.warn('[EnaPlanner] EJS render failed, trying fallback:', e?.message);
}
}
// Fallback: manual <%_ ... _%> / <%= ... %> processing
try {
return evalEjsFallback(template, ctx);
} catch (e) {
console.warn('[EnaPlanner] EJS fallback failed:', e?.message);
return template; // Return raw if all fails
}
}
function evalEjsFallback(template, ctx) {
// Build a function from the EJS template
let fnBody = 'let __out = "";\n';
// Create local variable declarations from context
for (const [k, v] of Object.entries(ctx)) {
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k)) {
fnBody += `const ${k} = __ctx['${k}'];\n`;
}
}
// Also add _ object with set method for _.set() patterns
fnBody += 'const _ = { set: function() {} };\n';
// Parse EJS template
let pos = 0;
const src = template;
while (pos < src.length) {
const tagStart = src.indexOf('<%', pos);
if (tagStart === -1) {
// Rest is plain text
fnBody += `__out += ${JSON.stringify(src.slice(pos))};\n`;
break;
}
// Text before tag
if (tagStart > pos) {
fnBody += `__out += ${JSON.stringify(src.slice(pos, tagStart))};\n`;
}
const tagEnd = src.indexOf('%>', tagStart);
if (tagEnd === -1) {
fnBody += `__out += ${JSON.stringify(src.slice(tagStart))};\n`;
break;
}
let inner = src.slice(tagStart + 2, tagEnd);
// Strip whitespace control chars
if (inner.startsWith('_')) inner = inner.slice(1);
if (inner.endsWith('_')) inner = inner.slice(0, -1);
if (inner.startsWith('=')) {
// Output expression
fnBody += `__out += String(${inner.slice(1).trim()});\n`;
} else if (inner.startsWith('-')) {
// HTML-escaped output
fnBody += `__out += String(${inner.slice(1).trim()});\n`;
} else {
// Code block
fnBody += inner.trim() + '\n';
}
pos = tagEnd + 2;
}
fnBody += 'return __out;';
const fn = new Function('__ctx', fnBody);
return fn(ctx);
}
/** -------------------------
* Template rendering helpers
* --------------------------*/
async function prepareEjsEnv() {
try {
const et = window.EjsTemplate;
if (!et) return null;
const fn = et.prepareContext || et.preparecontext;
if (typeof fn !== 'function') return null;
return await fn.call(et, {});
} catch { return null; }
}
async function evalEjsIfPossible(text, env) {
try {
const et = window.EjsTemplate;
if (!et || !env) return text;
const fn = et.evalTemplate || et.evaltemplate;
if (typeof fn !== 'function') return text;
return await fn.call(et, text, env);
} catch { return text; }
}
function substituteMacrosViaST(text) {
try { return substituteParamsExtended(text); } catch { return text; }
}
function deepGet(obj, path) {
if (!obj || !path) return undefined;
const parts = path.split('.').filter(Boolean);
let cur = obj;
for (const p of parts) {
if (cur == null) return undefined;
cur = cur[p];
}
return cur;
}
function resolveGetMessageVariableMacros(text, messageVars) {
return text.replace(/{{\s*get_message_variable::([^}]+)\s*}}/g, (_, rawPath) => {
const path = String(rawPath || '').trim();
if (!path) return '';
return safeStringify(deepGet(messageVars, path));
});
}
function getLatestMessageVarTable() {
try {
if (window.Mvu?.getMvuData) {
return window.Mvu.getMvuData({ type: 'message', message_id: 'latest' });
}
} catch {}
try {
const getVars = window.TavernHelper?.getVariables || window.Mvu?.getMvuData;
if (typeof getVars === 'function') {
return getVars({ type: 'message', message_id: 'latest' });
}
} catch {}
return {};
}
async function renderTemplateAll(text, env, messageVars) {
let out = String(text ?? '');
out = await evalEjsIfPossible(out, env);
out = substituteMacrosViaST(out);
out = resolveGetMessageVariableMacros(out, messageVars);
return out;
}
/** -------------------------
* Planner response filtering
* --------------------------*/
function stripThinkBlocks(text) {
let out = String(text ?? '');
out = out.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '');
out = out.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '');
return out.trim();
}
function extractPlotAndNoteInOrder(text) {
const src = String(text ?? '');
const blocks = [];
const re = /<(plot|note)\b[^>]*>[\s\S]*?<\/\1>/gi;
let m;
while ((m = re.exec(src)) !== null) {
blocks.push(m[0]);
}
return blocks.join('\n\n').trim();
}
function filterPlannerForInput(rawFull) {
const noThink = stripThinkBlocks(rawFull);
const onlyPN = extractPlotAndNoteInOrder(noThink);
if (onlyPN) return onlyPN;
return noThink;
}
/** -------------------------
* 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');
if (!s.api.apiKey) throw new Error('未配置 API KEY');
if (!s.api.model) throw new Error('未选择模型');
const url = buildUrl('/chat/completions');
const body = {
model: s.api.model,
messages,
stream: !!s.api.stream
};
const t = Number(s.api.temperature);
if (!Number.isNaN(t)) body.temperature = t;
const tp = Number(s.api.top_p);
if (!Number.isNaN(tp)) body.top_p = tp;
const tk = Number(s.api.top_k);
if (!Number.isNaN(tk) && tk > 0) body.top_k = tk;
const pp = s.api.presence_penalty === '' ? null : Number(s.api.presence_penalty);
if (pp != null && !Number.isNaN(pp)) body.presence_penalty = pp;
const fp = s.api.frequency_penalty === '' ? null : Number(s.api.frequency_penalty);
if (fp != null && !Number.isNaN(fp)) body.frequency_penalty = fp;
const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens);
if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt;
const res = await fetch(url, {
method: 'POST',
headers: {
...getRequestHeaders(),
Authorization: `Bearer ${s.api.apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body)
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500));
}
if (!s.api.stream) {
const data = await res.json();
return String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
}
// SSE stream
const reader = res.body.getReader();
const decoder = new TextDecoder('utf-8');
let buf = '';
let full = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const chunks = buf.split('\n\n');
buf = chunks.pop() ?? '';
for (const ch of chunks) {
const lines = ch.split('\n').map(x => x.trim()).filter(Boolean);
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') continue;
try {
const j = JSON.parse(payload);
const delta = j?.choices?.[0]?.delta;
const piece = delta?.content ?? delta?.text ?? '';
if (piece) full += piece;
} catch {}
}
}
}
return full;
}
/** -------------------------
* Build planner messages
* --------------------------*/
function getPromptBlocksByRole(role) {
const s = ensureSettings();
return (s.promptBlocks || []).filter(b => b?.role === role && String(b?.content ?? '').trim());
}
async function buildPlannerMessages(rawUserInput) {
const s = ensureSettings();
const ctx = getContextSafe();
const chat = ctx?.chat ?? window.SillyTavern?.chat ?? [];
const extPrompts = ctx?.extensionPrompts ?? {};
const charObj = getCurrentCharSafe();
const env = await prepareEjsEnv();
const messageVars = getLatestMessageVarTable();
const enaSystemBlocks = getPromptBlocksByRole('system');
const enaAssistantBlocks = getPromptBlocksByRole('assistant');
const enaUserBlocks = getPromptBlocksByRole('user');
const charBlockRaw = formatCharCardBlock(charObj);
// --- Story summary (cached from previous generation via interceptor) ---
const cachedSummary = getCachedStorySummary();
// --- Chat history: last 2 AI messages (floors N-1 & N-3) ---
// Two messages instead of one to avoid cross-device cache miss:
// story_summary cache is captured during main AI generation, so if
// user switches device and triggers Ena before a new generation,
// having N-3 as backup context prevents a gap.
const recentChatRaw = collectRecentChatSnippet(chat, 2);
const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount));
const vectorRaw = formatVectorRecallBlock(extPrompts);
// Build scanText for worldbook keyword activation
const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
const worldbookRaw = await buildWorldbookBlock(scanText);
// Render templates/macros
const charBlock = await renderTemplateAll(charBlockRaw, env, messageVars);
const recentChat = await renderTemplateAll(recentChatRaw, env, messageVars);
const plots = await renderTemplateAll(plotsRaw, env, messageVars);
const vector = await renderTemplateAll(vectorRaw, env, messageVars);
const storySummary = cachedSummary.trim().length > 30 ? await renderTemplateAll(cachedSummary, env, messageVars) : '';
const worldbook = await renderTemplateAll(worldbookRaw, env, messageVars);
const userInput = await renderTemplateAll(rawUserInput, env, messageVars);
const messages = [];
// 1) Ena system prompts
for (const b of enaSystemBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars);
messages.push({ role: 'system', content });
}
// 2) Character card
if (String(charBlock).trim()) messages.push({ role: 'system', content: charBlock });
// 3) Worldbook
if (String(worldbook).trim()) messages.push({ role: 'system', content: worldbook });
// 3.5) Cached story summary (小白X 剧情记忆 from previous turn)
if (storySummary.trim()) {
messages.push({ role: 'system', content: `<story_summary>\n${storySummary}\n</story_summary>` });
}
// 4) Chat history (last 2 AI responses — floors N-1 & N-3)
if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat });
// 5) Vector recall
if (String(vector).trim()) messages.push({ role: 'system', content: vector });
// 6) Previous plots
if (String(plots).trim()) messages.push({ role: 'system', content: plots });
// 7) User input (with friendly framing)
const userMsgContent = `以下是玩家的最新指令哦~:\n[${userInput}]`;
messages.push({ role: 'user', content: userMsgContent });
// Extra user blocks before user message
for (const b of enaUserBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars);
messages.splice(Math.max(0, messages.length - 1), 0, { role: 'system', content: `【extra-user-block】\n${content}` });
}
// 8) Assistant blocks
for (const b of enaAssistantBlocks) {
const content = await renderTemplateAll(b.content, env, messageVars);
messages.push({ role: 'assistant', content });
}
return { messages, meta: { charBlockRaw, worldbookRaw, recentChatRaw, vectorRaw, cachedSummaryLen: cachedSummary.length, plotsRaw } };
}
/** -------------------------
* Logs UI Issue #3: proper formatting
* --------------------------*/
function createLogModalHTML() {
return `
<style>
.ep-log-modal {
display:none; position:fixed; inset:0; z-index:99999;
background:rgba(0,0,0,0.6); overflow:hidden;
}
.ep-log-modal.open { display:flex; align-items:center; justify-content:center; }
.ep-log-card {
background:var(--SmartThemeBotMesBlurTintColor, #1a1a2e);
color:var(--SmartThemeBodyColor, #ccc);
border:1px solid var(--SmartThemeBorderColor, #444);
border-radius:10px; display:flex; flex-direction:column;
width:min(95vw, 820px); max-height:85vh; overflow:hidden;
}
.ep-log-head {
display:flex; justify-content:space-between; align-items:center;
padding:10px 14px; border-bottom:1px solid var(--SmartThemeBorderColor, #444);
flex-shrink:0; flex-wrap:wrap; gap:8px;
}
.ep-log-head .title { font-weight:bold; font-size:15px; }
.ep-log-body {
overflow-y:auto; padding:10px 14px; flex:1; min-height:0;
-webkit-overflow-scrolling:touch;
}
.ep-log-item { margin-bottom:12px; border-bottom:1px solid rgba(255,255,255,0.1); padding-bottom:10px; }
.ep-log-item .meta { font-size:12px; opacity:0.8; display:flex; justify-content:space-between; }
.ep-log-error { color:#f66; font-size:13px; margin:4px 0; }
.ep-log-pre {
white-space:pre-wrap; word-break:break-all; font-size:12px;
max-height:300px; overflow:auto; background:rgba(0,0,0,0.3);
padding:8px; border-radius:6px; margin-top:4px;
-webkit-overflow-scrolling:touch;
}
@media (max-width:600px) {
.ep-log-card { width:100vw; max-height:100vh; border-radius:0; }
.ep-log-head { padding:8px 10px; }
.ep-log-head .title { font-size:14px; }
.ep-log-body { padding:8px 10px; }
.ep-log-pre { font-size:11px; max-height:200px; }
}
/* Mobile-friendly touch targets for all Ena buttons */
.ep-touch-btn {
touch-action: manipulation;
-webkit-tap-highlight-color: rgba(255,255,255,0.15);
cursor: pointer;
user-select: none;
-webkit-user-select: none;
min-height: 38px;
position: relative;
z-index: 10;
}
</style>
<div class="ep-log-modal" id="ep_log_modal">
<div class="ep-log-card">
<div class="ep-log-head">
<div class="title">Ena Planner Logs</div>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="menu_button ep-touch-btn" id="ep_log_export">导出 JSON</button>
<button class="menu_button ep-touch-btn" id="ep_log_clear">清空</button>
<button class="menu_button ep-touch-btn" id="ep_log_close"> 关闭</button>
</div>
</div>
<div class="ep-log-body" id="ep_log_body"></div>
</div>
</div>`;
}
function renderLogs() {
const body = document.getElementById('ep_log_body');
if (!body) return;
if (!state.logs.length) {
body.innerHTML = `<div style="opacity:.75;">暂无日志(发送一次消息后就会记录)。</div>`;
return;
}
const html = state.logs.map((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');
return `
<div class="ep-log-item">
<div class="meta">
<span>#${idx + 1} · ${title} · ${t} · ${model}</span>
<span>${log.ok ? '✅' : '❌'}</span>
</div>
${err ? `<div class="ep-log-error">${escapeHtml(err)}</div>` : ''}
<details>
<summary>发出去的 messages完整</summary>
<pre class="ep-log-pre">${escapeHtml(reqDisplay)}</pre>
</details>
<details>
<summary>规划 AI 原始完整回复 &lt;think&gt;</summary>
<pre class="ep-log-pre">${escapeHtml(String(log.rawReply ?? ''))}</pre>
</details>
<details open>
<summary>写回输入框的版本已剔除 think只保留 plot+note</summary>
<pre class="ep-log-pre">${escapeHtml(String(log.filteredReply ?? ''))}</pre>
</details>
</div>`;
}).join('');
body.innerHTML = html;
}
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 `
2026-02-21 21:32:55 +08:00
<style>
.ep-tabs { display:flex; gap:4px; margin-bottom:10px; flex-wrap:wrap; }
.ep-tab { padding:6px 14px; cursor:pointer; border:1px solid var(--SmartThemeBorderColor,#444); border-radius:6px; font-size:13px; opacity:0.7; }
.ep-tab.active { opacity:1; background:var(--SmartThemeBorderColor,#444); }
.ep-panel { display:none; }
.ep-panel.active { display:block; }
.ep-row { display:flex; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
.ep-col { flex:1; min-width:140px; }
.ep-col.wide { flex:2; min-width:200px; }
.ep-hint { font-size:11px; opacity:0.6; margin-top:2px; }
.ep-hint-box { font-size:12px; opacity:0.75; background:rgba(255,255,255,0.05); padding:8px; border-radius:6px; margin:8px 0; }
.ep-divider { border-top:1px solid var(--SmartThemeBorderColor,#444); margin:10px 0; }
.ep-actions { display:flex; gap:8px; margin:10px 0; flex-wrap:wrap; }
.ep-badge-inline { display:inline-flex; align-items:center; gap:4px; font-size:12px; margin-left:8px; }
.ep-badge-inline .dot { width:8px; height:8px; border-radius:50%; }
.ep-badge-inline.ok .dot { background:#4caf50; }
.ep-badge-inline.warn .dot { background:#ff9800; }
#ena_planner_panel label { display:block; font-size:13px; margin-bottom:3px; }
#ena_planner_panel select,
#ena_planner_panel input[type="text"],
#ena_planner_panel input[type="password"],
#ena_planner_panel input[type="number"] {
width:100%; padding:6px 8px; border-radius:4px; border:1px solid var(--SmartThemeBorderColor,#444);
2026-02-21 21:47:21 +08:00
background:#1a1a2e; color:#e0e0e0; font-size:13px;
2026-02-21 21:32:55 +08:00
}
#ena_planner_panel .menu_button { display:inline-block; white-space:nowrap; }
.ep-prompt-block { border:1px solid var(--SmartThemeBorderColor,#444); border-radius:6px; padding:8px; margin-bottom:8px; }
.ep-prompt-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:6px; flex-wrap:wrap; gap:6px; }
.ep-prompt-block textarea { width:100%; background:var(--SmartThemeBotMesBlurTintColor,#1a1a2e); color:var(--SmartThemeBodyColor,#ccc); border:1px solid var(--SmartThemeBorderColor,#444); border-radius:4px; padding:6px; font-size:12px; }
2026-02-21 22:03:52 +08:00
#ena_planner_panel .ep-prompt-block textarea {
background:#1a1a2e; color:#e0e0e0; border:1px solid var(--SmartThemeBorderColor,#444);
}
.ep-log-pre {
background:rgba(20,20,30,0.95) !important; color:#e0e0e0 !important;
}
.ep-log-modal .ep-log-card {
background:rgba(20,20,30,0.97); color:#e0e0e0;
}
2026-02-21 21:32:55 +08:00
</style>
<div style="padding:4px 0;">
<div style="display:flex;align-items:center;margin-bottom:8px;">
<b style="font-size:15px;">Ena Planner</b>
<span class="ep-badge-inline ${s.enabled ? 'ok' : 'warn'}">
<span class="dot"></span>
<span>${s.enabled ? 'Enabled' : 'Disabled'}</span>
</span>
</div>
<div class="ep-tabs">
<div class="ep-tab active" data-ep-tab="general">总览</div>
<div class="ep-tab" data-ep-tab="api">API</div>
<div class="ep-tab" data-ep-tab="prompt">提示词</div>
<div class="ep-tab" data-ep-tab="debug">调试</div>
</div>
<!-- GENERAL TAB -->
<div class="ep-panel active" data-ep-panel="general">
<div class="ep-row">
<div class="ep-col">
<label>启用</label>
<select id="ep_enabled">
<option value="true" ${s.enabled ? 'selected' : ''}>启用</option>
<option value="false" ${!s.enabled ? 'selected' : ''}>关闭</option>
</select>
<div class="ep-hint">开启后你点发送/回车会先走"规划模型"把规划结果写回输入框再发送</div>
</div>
<div class="ep-col">
<label>避免重复规划推荐开启</label>
<select id="ep_skip_plot">
<option value="true" ${s.skipIfPlotPresent ? 'selected' : ''}>输入含 &lt;plot&gt; 就跳过规划</option>
<option value="false" ${!s.skipIfPlotPresent ? 'selected' : ''}>不跳过不建议</option>
</select>
<div class="ep-hint">防止"原始+规划文本"再次被拦截规划</div>
</div>
</div>
<div class="ep-divider"></div>
<div class="ep-row">
<div class="ep-col">
<label>全局世界书</label>
<select id="ep_include_global_wb">
<option value="false" ${!s.includeGlobalWorldbooks ? 'selected' : ''}>仅角色绑定世界书</option>
<option value="true" ${s.includeGlobalWorldbooks ? 'selected' : ''}>额外包含全局世界书</option>
</select>
<div class="ep-hint">角色绑定的世界书总是会读取这里选择是否额外包含全局世界书</div>
</div>
<div class="ep-col">
<label>排除 position=4 (深度注入)</label>
<select id="ep_wb_pos4">
<option value="true" ${s.excludeWorldbookPosition4 ? 'selected' : ''}>排除</option>
<option value="false" ${!s.excludeWorldbookPosition4 ? 'selected' : ''}>不排除</option>
</select>
</div>
</div>
<div class="ep-row">
<div class="ep-col wide">
<label>排除条目名称逗号分隔</label>
<input type="text" id="ep_wb_exclude_names" placeholder="mvu_update" value="${escapeHtml((s.worldbookExcludeNames ?? []).join(', '))}" />
<div class="ep-hint">条目名称/备注包含这些字符串的条目会被排除</div>
</div>
</div>
<div class="ep-row">
<div class="ep-col">
<label>抓取最近 plot 数量倒序抓</label>
<input type="number" id="ep_plot_n" min="0" step="1" value="${Number(s.plotCount) || 0}" />
</div>
</div>
<div class="ep-hint-box">
<b>自动行为说明</b><br/>
· 聊天片段自动读取所有未隐藏的 AI 回复不含用户输入<br/>
· 自动剔除 &lt;think&gt; 以前的内容含未包裹的思考段落<br/>
· 角色卡字段desc/personality/scenario有就全部加入<br/>
· 向量召回extensionPrompts有就自动加入<br/>
· 世界书激活常驻蓝灯+ 关键词触发绿灯
</div>
<div class="ep-divider"></div>
<div class="ep-row">
<div class="ep-col wide">
<label>前文排除标签逗号分隔</label>
<input type="text" id="ep_exclude_tags" placeholder="行动选项, UpdateVariable, StatusPlaceHolderImpl" value="${escapeHtml((s.chatExcludeTags ?? []).join(', '))}" />
<div class="ep-hint">这些 XML 标签及其内容会从聊天历史中剔除自闭合标签 &lt;Tag/&gt;也会被移除</div>
</div>
</div>
<div class="ep-actions">
<button class="menu_button ep-touch-btn" id="ep_open_logs">查看 Logs</button>
<button class="menu_button ep-touch-btn" id="ep_test_planner">测试规划请求</button>
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
</div>
2026-02-18 22:32:13 +08:00
2026-02-21 21:32:55 +08:00
<!-- API TAB -->
<div class="ep-panel" data-ep-panel="api">
<div class="ep-row">
<div class="ep-col">
<label>渠道</label>
<select id="ep_api_channel">
<option value="openai" ${channel === 'openai' ? 'selected' : ''}>OpenAI 兼容</option>
<option value="gemini" ${channel === 'gemini' ? 'selected' : ''}>Gemini 兼容</option>
<option value="claude" ${channel === 'claude' ? 'selected' : ''}>Claude 兼容</option>
</select>
<div class="ep-hint">影响默认前缀OpenAI/Claude /v1Gemini /v1beta</div>
</div>
<div class="ep-col wide">
<label>API URLbase</label>
<input type="text" id="ep_api_base" placeholder="https://your-api.example.com" value="${escapeHtml(s.api.baseUrl)}" />
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
</div>
<div class="ep-row">
<div class="ep-col">
<label>端点前缀</label>
<select id="ep_prefix_mode">
<option value="auto" ${s.api.prefixMode === 'auto' ? 'selected' : ''}>自动按渠道</option>
<option value="custom" ${s.api.prefixMode === 'custom' ? 'selected' : ''}>自定义</option>
</select>
</div>
<div class="ep-col">
<label>自定义前缀</label>
<input type="text" id="ep_prefix_custom" placeholder="/v1" value="${escapeHtml(s.api.customPrefix)}" />
</div>
</div>
2026-02-18 22:32:13 +08:00
2026-02-21 21:32:55 +08:00
<div class="ep-row">
<div class="ep-col wide">
<label>API KEY</label>
<input type="password" id="ep_api_key" placeholder="sk-..." value="${escapeHtml(s.api.apiKey)}" />
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
</div>
2026-02-18 22:32:13 +08:00
2026-02-21 21:32:55 +08:00
<div class="ep-row">
<div class="ep-col">
<label>模型</label>
<input type="text" id="ep_model" placeholder="gpt-4.1-mini / gemini-3-flash ..." value="${escapeHtml(s.api.model)}" />
</div>
<div class="ep-col">
<label>流式</label>
<select id="ep_stream">
<option value="true" ${s.api.stream ? 'selected' : ''}>开启</option>
<option value="false" ${!s.api.stream ? 'selected' : ''}>关闭</option>
</select>
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
</div>
2026-02-18 22:32:13 +08:00
2026-02-21 21:32:55 +08:00
<div class="ep-actions">
<button class="menu_button" id="ep_fetch_models">拉取模型列表</button>
<button class="menu_button" id="ep_test_conn">测试连接</button>
</div>
<div class="ep-divider"></div>
<div class="ep-row">
<div class="ep-col">
<label>temperature</label>
<input type="number" id="ep_temp" step="0.1" value="${Number(s.api.temperature)}" />
</div>
<div class="ep-col">
<label>top_p</label>
<input type="number" id="ep_top_p" step="0.05" value="${Number(s.api.top_p)}" />
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
<div class="ep-col">
<label>top_k</label>
<input type="number" id="ep_top_k" step="1" value="${Number(s.api.top_k) || 0}" />
</div>
</div>
2026-02-18 22:32:13 +08:00
2026-02-21 21:32:55 +08:00
<div class="ep-row">
<div class="ep-col">
<label>presence_penalty</label>
<input type="text" id="ep_pp" value="${escapeHtml(s.api.presence_penalty)}" />
</div>
<div class="ep-col">
<label>frequency_penalty</label>
<input type="text" id="ep_fp" value="${escapeHtml(s.api.frequency_penalty)}" />
</div>
<div class="ep-col">
<label>max_tokens</label>
<input type="text" id="ep_mt" value="${escapeHtml(s.api.max_tokens)}" />
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
</div>
</div>
<!-- PROMPT TAB -->
<div class="ep-panel" data-ep-panel="prompt">
<div class="ep-hint">新增多条提示词块选择 rolesystem/user/assistant系统块放最前面assistant 块放最后</div>
2026-02-18 22:32:13 +08:00
2026-02-21 21:32:55 +08:00
<div class="ep-row" style="gap:4px;flex-wrap:wrap;margin-bottom:6px;">
<select id="ep_tpl_select" style="flex:1;min-width:120px;">
<option value="">-- 选择模板 --</option>
${Object.keys(s.promptTemplates || {}).map(n => `<option value="${escapeHtml(n)}">${escapeHtml(n)}</option>`).join('')}
</select>
<button class="menu_button" id="ep_tpl_save" title="覆盖现在模板">储存</button>
<button class="menu_button" id="ep_tpl_saveas" title="另存模板">另存</button>
<button class="menu_button" id="ep_tpl_delete" title="删除现在模板">删除</button>
</div>
<div id="ep_prompt_list"></div>
<div class="ep-actions">
<button class="menu_button" id="ep_add_prompt">新增提示词块</button>
<button class="menu_button" id="ep_reset_prompt">重置为默认</button>
</div>
</div>
<!-- DEBUG TAB -->
<div class="ep-panel" data-ep-panel="debug">
<div class="ep-hint-box">
<b>工作原理</b><br/>
· 规划时会锁定发送按钮<br/>
· Log 静默记录只有出错才弹提示<br/>
· 写回版本剔除 &lt;think&gt;只保留 &lt;plot&gt;+&lt;note&gt;<br/>
· 前文自动剔除 &lt;think&gt; 以前内容和排除标签内容
</div>
<div class="ep-actions">
<button class="menu_button" id="ep_debug_worldbook">诊断世界书读取</button>
<button class="menu_button" id="ep_debug_char">诊断角色卡读取</button>
2026-02-18 22:32:13 +08:00
</div>
2026-02-21 21:32:55 +08:00
<pre class="ep-log-pre" id="ep_debug_output" style="max-height:300px;overflow:auto;font-size:12px;display:none;"></pre>
2026-02-18 22:32:13 +08:00
</div>
</div>`;
}
function renderPromptDesigner() {
const s = ensureSettings();
const list = document.getElementById('ep_prompt_list');
if (!list) return;
const blocks = s.promptBlocks || [];
const rows = blocks.map((b, idx) => {
const role = b.role || 'system';
return `
<div class="ep-prompt-block">
<div class="ep-prompt-head">
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<input type="text" class="text_pole ep_pb_name" data-id="${b.id}" placeholder="名称" value="${escapeHtml(b.name ?? '')}" style="min-width:180px;" />
<select class="ep_pb_role" data-id="${b.id}">
<option value="system" ${role === 'system' ? 'selected' : ''}>system</option>
<option value="user" ${role === 'user' ? 'selected' : ''}>user</option>
<option value="assistant" ${role === 'assistant' ? 'selected' : ''}>assistant</option>
</select>
</div>
<div style="display:flex; gap:6px;">
<button class="menu_button ep_pb_up" data-id="${b.id}" ${idx === 0 ? 'disabled' : ''}></button>
<button class="menu_button ep_pb_down" data-id="${b.id}" ${idx === blocks.length - 1 ? 'disabled' : ''}></button>
<button class="menu_button ep_pb_del" data-id="${b.id}">删除</button>
</div>
</div>
<textarea class="text_pole ep_pb_content" data-id="${b.id}" rows="6" placeholder="内容...">${escapeHtml(b.content ?? '')}</textarea>
</div>`;
}).join('');
list.innerHTML = rows || `<div style="opacity:.75;">暂无提示词块</div>`;
}
function bindSettingsUI() {
2026-02-21 21:36:01 +08:00
const settingsEl = document.getElementById('ena_planner_panel');
2026-02-18 22:32:13 +08:00
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.innerHTML = '<option value="">-- 选择模板 --</option>' +
names.map(n => `<option value="${escapeHtml(n)}" ${n === selectName ? 'selected' : ''}>${escapeHtml(n)}</option>`).join('');
}
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;
2026-02-21 21:22:47 +08:00
// 动态注入 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;');
tabDiv.innerHTML = '<span class="vertical-text">剧情规划</span>';
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';
2026-02-21 21:47:21 +08:00
panel.style.display = 'none';
2026-02-21 21:22:47 +08:00
contentArea.appendChild(panel);
}
2026-02-18 22:32:13 +08:00
const container = document.getElementById('ena_planner_panel');
if (!container) return;
const wrap = document.createElement('div');
wrap.innerHTML = createSettingsHTML();
2026-02-21 21:40:27 +08:00
while (wrap.firstChild) container.appendChild(wrap.firstChild);
2026-02-18 22:32:13 +08:00
// Log modal
if (!document.getElementById('ep_log_modal')) {
const modalWrap = document.createElement('div');
modalWrap.innerHTML = createLogModalHTML();
while (modalWrap.firstChild) document.body.appendChild(modalWrap.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 && /<plot\b/i.test(txt)) return false;
return true;
}
async function doInterceptAndPlanThenSend() {
const ta = getSendTextarea();
const btn = getSendButton();
if (!ta || !btn) return;
const raw = String(ta.value ?? '').trim();
if (!raw) return;
state.isPlanning = true;
setSendUIBusy(true);
try {
toastInfo('Ena Planner正在规划…');
const { filtered } = await runPlanningOnce(raw, false);
const merged = `${raw}\n\n${filtered}`.trim();
ta.value = merged;
state.lastInjectedText = merged;
state.bypassNextSend = true;
btn.click();
} finally {
state.isPlanning = false;
setSendUIBusy(false);
setTimeout(() => { state.bypassNextSend = false; }, 800);
}
}
function installSendInterceptors() {
document.addEventListener('click', (e) => {
const btn = getSendButton();
if (!btn) return;
if (e.target !== btn && !btn.contains(e.target)) return;
if (!shouldInterceptNow()) return;
e.preventDefault();
e.stopImmediatePropagation();
doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err)));
}, true);
document.addEventListener('keydown', (e) => {
const ta = getSendTextarea();
if (!ta || e.target !== ta) return;
if (e.key === 'Enter' && !e.shiftKey) {
if (!shouldInterceptNow()) return;
e.preventDefault();
e.stopImmediatePropagation();
doInterceptAndPlanThenSend().catch(err => toastErr(String(err?.message ?? err)));
}
}, true);
}
export function initEnaPlanner() {
ensureSettings();
loadPersistedLogsMaybe();
const tryInject = () => {
2026-02-21 21:22:47 +08:00
if (document.querySelector('.settings-menu-vertical')) {
2026-02-18 22:32:13 +08:00
injectUI();
installSendInterceptors();
} else {
setTimeout(tryInject, 500);
}
};
tryInject();
}