From 72c06d8e0f2bfb3b79f9e1d79fb6e7bdfd928d1c Mon Sep 17 00:00:00 2001 From: Hao19911125 <99091644+Hao19911125@users.noreply.github.com> Date: Wed, 18 Feb 2026 22:32:13 +0800 Subject: [PATCH] Add files via upload --- modules/ena-planner/ena-planner.css.css | 233 +++ modules/ena-planner/ena-planner.js.js | 1979 +++++++++++++++++++++++ 2 files changed, 2212 insertions(+) create mode 100644 modules/ena-planner/ena-planner.css.css create mode 100644 modules/ena-planner/ena-planner.js.js diff --git a/modules/ena-planner/ena-planner.css.css b/modules/ena-planner/ena-planner.css.css new file mode 100644 index 0000000..b532ba4 --- /dev/null +++ b/modules/ena-planner/ena-planner.css.css @@ -0,0 +1,233 @@ +/* Ena Planner v0.5 — collapsible, clean */ + +/* ===== Settings panel inside inline-drawer ===== */ +#ena_planner_settings { + padding: 8px 0; +} + +#ena_planner_settings .ep-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 8px 0; +} + +#ena_planner_settings label { + font-size: 12px; + opacity: .9; + display: block; + margin-bottom: 4px; +} + +#ena_planner_settings input[type="text"], +#ena_planner_settings input[type="password"], +#ena_planner_settings input[type="number"], +#ena_planner_settings select, +#ena_planner_settings textarea { + width: 100%; + box-sizing: border-box; +} + +#ena_planner_settings .ep-col { + flex: 1 1 220px; + min-width: 220px; +} + +#ena_planner_settings .ep-col.wide { + flex: 1 1 100%; + min-width: 260px; +} + +/* Tabs */ +#ena_planner_settings .ep-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +#ena_planner_settings .ep-tab { + padding: 6px 10px; + border-radius: 999px; + cursor: pointer; + border: 1px solid var(--SmartThemeBorderColor, #333); + opacity: .85; + user-select: none; + font-size: 13px; +} + +#ena_planner_settings .ep-tab.active { + opacity: 1; + background: rgba(255,255,255,.06); +} + +#ena_planner_settings .ep-panel { + display: none; +} + +#ena_planner_settings .ep-panel.active { + display: block; +} + +#ena_planner_settings .ep-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} + +#ena_planner_settings .ep-hint { + font-size: 11px; + opacity: .7; + margin-top: 4px; +} + +#ena_planner_settings .ep-hint-box { + font-size: 12px; + opacity: .85; + margin: 10px 0; + padding: 10px; + border-radius: 8px; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.08); + line-height: 1.6; +} + +#ena_planner_settings .ep-divider { + margin: 10px 0; + border-top: 1px dashed rgba(255,255,255,.15); +} + +/* Inline badge (in drawer header) */ +.ep-badge-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + opacity: .9; + margin-left: 8px; +} + +.ep-badge-inline .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #888; + display: inline-block; +} + +.ep-badge-inline.ok .dot { background: #2ecc71; } +.ep-badge-inline.warn .dot { background: #f39c12; } + +/* Prompt block */ +.ep-prompt-block { + border: 1px solid rgba(255,255,255,.12); + border-radius: 10px; + padding: 10px; + margin: 10px 0; +} + +.ep-prompt-head { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +/* ===== Log modal ===== */ +.ep-log-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,.65); + z-index: 99999; + display: none; +} + +.ep-log-modal.open { display: block; } + +.ep-log-modal .ep-log-card { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%,-50%); + width: min(980px, 96vw); + height: min(82vh, 900px); + background: rgba(20,20,20,.95); + border: 1px solid rgba(255,255,255,.15); + border-radius: 12px; + padding: 14px; + display: flex; + flex-direction: column; +} + +.ep-log-modal .ep-log-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.ep-log-modal .ep-log-head .title { + font-weight: 700; + font-size: 15px; +} + +.ep-log-modal .ep-log-body { + overflow: auto; + flex: 1 1 auto; + border: 1px solid rgba(255,255,255,.08); + border-radius: 10px; + padding: 10px; +} + +.ep-log-item { + border-bottom: 1px solid rgba(255,255,255,.08); + padding: 12px 0; +} + +.ep-log-item:last-child { border-bottom: none; } + +.ep-log-item .meta { + display: flex; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + opacity: .85; + font-size: 12px; + margin-bottom: 8px; +} + +.ep-log-item .ep-log-error { + color: #ffb3b3; + font-size: 12px; + white-space: pre-wrap; + margin-bottom: 6px; +} + +.ep-log-item details { + margin: 6px 0; +} + +.ep-log-item details summary { + cursor: pointer; + font-size: 12px; + opacity: .85; + padding: 4px 0; +} + +/* Issue #3: proper log formatting with line breaks */ +.ep-log-pre { + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.5; + padding: 10px; + border-radius: 8px; + background: rgba(255,255,255,.04); + border: 1px solid rgba(255,255,255,.06); + max-height: 400px; + overflow: auto; +} diff --git a/modules/ena-planner/ena-planner.js.js b/modules/ena-planner/ena-planner.js.js new file mode 100644 index 0000000..42ff459 --- /dev/null +++ b/modules/ena-planner/ena-planner.js.js @@ -0,0 +1,1979 @@ +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() { + 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 = /]*>[\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 ''; + // Direct lookup first + if (vars[name] !== undefined) return vars[name]; + // Try dot-path traversal + const parts = String(name).split('.'); + let cur = vars; + for (const p of parts) { + if (cur == null || typeof cur !== 'object') return ''; + cur = cur[p]; + } + return cur ?? ''; + } + + // 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(/]*>[\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; + + if (!state.logs.length) { + body.innerHTML = `
暂无日志(发送一次消息后就会记录)。
`; + 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 ` +
+
+ #${idx + 1} · ${title} · ${t} · ${model} + ${log.ok ? '✅' : '❌'} +
+ ${err ? `
${escapeHtml(err)}
` : ''} + +
+ 发出去的 messages(完整) +
${escapeHtml(reqDisplay)}
+
+ +
+ 规划 AI 原始完整回复(含 <think>) +
${escapeHtml(String(log.rawReply ?? ''))}
+
+ +
+ 写回输入框的版本(已剔除 think,只保留 plot+note) +
${escapeHtml(String(log.filteredReply ?? ''))}
+
+
`; + }).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 ` +
+
+
+ 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 || []; + const rows = blocks.map((b, idx) => { + const role = b.role || 'system'; + return ` +
+
+
+ + +
+
+ + + +
+
+ +
`; + }).join(''); + + list.innerHTML = rows || `
暂无提示词块
`; +} + +function bindSettingsUI() { + const settingsEl = document.getElementById('ena_planner_settings'); + 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 = '' + + names.map(n => ``).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; + + const container = document.getElementById('ena_planner_panel'); + if (!container) return; + + const wrap = document.createElement('div'); + wrap.innerHTML = createSettingsHTML(); + container.appendChild(wrap.firstElementChild); + + // Log modal + if (!document.getElementById('ep_log_modal')) { + const modalWrap = document.createElement('div'); + modalWrap.innerHTML = createLogModalHTML(); + // Append all children (style + modal div) + while (modalWrap.firstChild) document.body.appendChild(modalWrap.firstChild); + + _addUniversalTap(document.getElementById('ep_log_close'), () => closeLogModal()); + // Backdrop tap to close + 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(); + + // Wait for DOM to be ready with the panel + const tryInject = () => { + if (document.getElementById('ena_planner_panel')) { + injectUI(); + installSendInterceptors(); + } else { + setTimeout(tryInject, 500); + } + }; + tryInject(); +} \ No newline at end of file