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