import { extension_settings } from '../../../../../extensions.js'; import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js'; 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 ) 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"粘贴到这里) 要求:输出 ......,如有思考请放在 ...(会被自动剔除)。` }, { 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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } /** * Universal tap handler — works on both desktop (click) and mobile (touch). * Prevents ghost double-fires by tracking the last trigger time. * On touch devices, fires on touchend for zero delay; on desktop, fires on click. */ function _addUniversalTap(el, fn) { if (!el) return; let lastTrigger = 0; const guard = (e) => { const now = Date.now(); if (now - lastTrigger < 400) return; // debounce lastTrigger = now; e.preventDefault(); e.stopPropagation(); fn(e); }; el.addEventListener('click', guard); el.addEventListener('touchend', guard, { passive: false }); } function safeStringify(val) { if (val == null) return ''; if (typeof val === 'string') return val; 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 (handles unclosed think blocks) // Pattern: content without opening followed by out = out.replace(/^[\s\S]*?<\/think>/i, ''); // 2) Also strip properly wrapped ... blocks out = out.replace(/]*>[\s\S]*?<\/think>/gi, ''); out = out.replace(/]*>[\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 ... const blockRe = new RegExp(`<${escaped}[^>]*>[\\s\\S]*?<\\/${escaped}>`, 'gi'); out = out.replace(blockRe, ''); // Then: mop up any orphan closing tags const closeRe = new RegExp(`<\\/${escaped}>`, 'gi'); out = out.replace(closeRe, ''); // Finally: mop up orphan opening or self-closing tags or 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 `\n${lines.join('\n')}\n`; } function getCachedStorySummary() { const live = getStorySummaryForEna(); const ctx = getContextSafe(); const meta = ctx?.chatMetadata ?? window.chat_metadata; if (live && live.trim().length > 30) { // 拿到了新的,存起来 if (meta) { meta.ena_cached_story_summary = live; saveSettingsDebounced(); } return live; } // 没拿到(首轮/重启),从 chat_metadata 读上次的 if (meta?.ena_cached_story_summary) { console.log('[EnaPlanner] Using persisted story summary from chat_metadata'); return meta.ena_cached_story_summary; } return ''; } /** * ------------------------- * 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 = /]*>[\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 `\n${lines.join('\n\n')}\n`; } /** * ------------------------- * 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 `\n${lines.join('\n')}\n`; } /** * ------------------------- * 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 `\n${parts.join('\n\n---\n\n')}\n`; } /** * ------------------------- * 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 ''; 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 ?? ''; } // 字符串布尔值转为真正的布尔值 if (val === 'false' || val === 'False' || val === 'FALSE') return false; if (val === 'true' || val === 'True' || val === 'TRUE') return true; return val; } // 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); } } // Safe degradation when ejs is not available. console.warn('[EnaPlanner] window.ejs not available, skipping EJS rendering. Template returned as-is.'); return template; } /** * ------------------------- * 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(/]*>[\s\S]*?<\/think>/gi, ''); out = out.replace(/]*>[\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: `\n${storySummary}\n` }); } // 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 `
Ena Planner Logs
`; } function renderLogs() { const body = document.getElementById('ep_log_body'); if (!body) return; body.textContent = ''; if (!state.logs.length) { const empty = document.createElement('div'); empty.style.opacity = '.75'; empty.textContent = '暂无日志(发送一次消息后就会记录)。'; body.appendChild(empty); return; } state.logs.forEach((log, idx) => { const t = log.time ?? ''; const title = log.ok ? 'OK' : 'ERROR'; const model = log.model ?? ''; const err = log.error ?? ''; // Format request messages for readable display. const reqDisplay = (log.requestMessages ?? []).map((m, i) => { return `--- Message #${i + 1} [${m.role}] ---\n${m.content ?? '(empty)'}`; }).join('\n\n'); const item = document.createElement('div'); item.className = 'ep-log-item'; const meta = document.createElement('div'); meta.className = 'meta'; const metaLeft = document.createElement('span'); metaLeft.textContent = `#${idx + 1} · ${title} · ${t} · ${model}`; const metaRight = document.createElement('span'); metaRight.textContent = log.ok ? '✅' : '❌'; meta.append(metaLeft, metaRight); item.appendChild(meta); if (err) { const errDiv = document.createElement('div'); errDiv.className = 'ep-log-error'; errDiv.textContent = err; item.appendChild(errDiv); } const buildDetails = (summaryText, contentText, open = false) => { const details = document.createElement('details'); if (open) details.open = true; const summary = document.createElement('summary'); summary.textContent = summaryText; const pre = document.createElement('pre'); pre.className = 'ep-log-pre'; pre.textContent = contentText; details.append(summary, pre); return details; }; item.appendChild(buildDetails('发出去的 messages(完整)', reqDisplay)); item.appendChild(buildDetails('规划 AI 原始完整回复(含 )', String(log.rawReply ?? ''))); item.appendChild(buildDetails('写回输入框的版本(已剔除 think,只保留 plot+note)', String(log.filteredReply ?? ''), true)); body.appendChild(item); }); } function openLogModal() { const m = document.getElementById('ep_log_modal'); if (!m) return; m.classList.add('open'); renderLogs(); } function closeLogModal() { const m = document.getElementById('ep_log_modal'); if (!m) return; m.classList.remove('open'); } /** * ------------------------- * Settings UI — Issue #1: use inline-drawer for collapsible * -------------------------- */ function createSettingsHTML() { const s = ensureSettings(); const channel = s.api.channel; return `
Ena Planner ${s.enabled ? 'Enabled' : 'Disabled'}
总览
API
提示词
调试
开启后:你点发送/回车,会先走"规划模型",把规划结果写回输入框再发送。
防止"原始+规划文本"再次被拦截规划。
角色绑定的世界书总是会读取。这里选择是否额外包含全局世界书。
条目名称/备注包含这些字符串的条目会被排除。
自动行为说明:
· 聊天片段:自动读取所有未隐藏的 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 && / { 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 = () => { if (document.querySelector('.settings-menu-vertical')) { injectUI(); installSendInterceptors(); } else { setTimeout(tryInject, 500); } }; tryInject(); }