2026-02-25 23:58:05 +08:00
|
|
|
|
import { extension_settings } from '../../../../../extensions.js';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js';
|
|
|
|
|
|
import { getStorySummaryForEna } from '../story-summary/story-summary.js';
|
2026-02-25 23:58:05 +08:00
|
|
|
|
import { buildVectorPromptText } from '../story-summary/generate/prompt.js';
|
|
|
|
|
|
import { getVectorConfig } from '../story-summary/data/config.js';
|
|
|
|
|
|
import { extensionFolderPath } from '../../core/constants.js';
|
|
|
|
|
|
import { EnaPlannerStorage } from '../../core/server-storage.js';
|
|
|
|
|
|
import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.js';
|
|
|
|
|
|
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
|
2026-03-02 23:37:51 +08:00
|
|
|
|
import { formatOutlinePrompt } from '../story-outline/story-outline.js';
|
2026-03-19 00:50:14 +08:00
|
|
|
|
import jsyaml from '../../libs/js-yaml.mjs';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
|
|
|
|
|
const EXT_NAME = 'ena-planner';
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const OVERLAY_ID = 'xiaobaix-ena-planner-overlay';
|
|
|
|
|
|
const HTML_PATH = `${extensionFolderPath}/modules/ena-planner/ena-planner.html`;
|
|
|
|
|
|
const VECTOR_RECALL_TIMEOUT_MS = 15000;
|
|
|
|
|
|
const PLANNER_REQUEST_TIMEOUT_MS = 90000;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Default settings
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function getDefaultSettings() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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 response tags to keep, in source order (empty = keep full response)
|
|
|
|
|
|
responseKeepTags: ['plot', 'note', 'plot-log', 'state'],
|
|
|
|
|
|
|
|
|
|
|
|
// Planner prompts (designer)
|
|
|
|
|
|
promptBlocks: structuredClone(DEFAULT_PROMPT_BLOCKS),
|
|
|
|
|
|
// Saved prompt templates: { name: promptBlocks[] }
|
|
|
|
|
|
promptTemplates: structuredClone(BUILTIN_TEMPLATES),
|
|
|
|
|
|
// Currently selected prompt template name in UI
|
|
|
|
|
|
activePromptTemplate: '',
|
|
|
|
|
|
|
|
|
|
|
|
// Planner API
|
|
|
|
|
|
api: {
|
|
|
|
|
|
channel: 'openai',
|
|
|
|
|
|
baseUrl: '',
|
|
|
|
|
|
prefixMode: 'auto',
|
|
|
|
|
|
customPrefix: '',
|
|
|
|
|
|
apiKey: '',
|
|
|
|
|
|
model: '',
|
|
|
|
|
|
stream: true,
|
|
|
|
|
|
temperature: 1,
|
|
|
|
|
|
top_p: 1,
|
|
|
|
|
|
top_k: 0,
|
|
|
|
|
|
presence_penalty: '',
|
|
|
|
|
|
frequency_penalty: '',
|
|
|
|
|
|
max_tokens: ''
|
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
|
|
// Logs
|
|
|
|
|
|
logsPersist: true,
|
|
|
|
|
|
logsMax: 20
|
|
|
|
|
|
};
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Local state
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const state = {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
isPlanning: false,
|
|
|
|
|
|
bypassNextSend: false,
|
|
|
|
|
|
lastInjectedText: '',
|
|
|
|
|
|
logs: []
|
2026-02-24 18:20:22 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
let config = null;
|
|
|
|
|
|
let overlay = null;
|
|
|
|
|
|
let iframeMessageBound = false;
|
|
|
|
|
|
let sendListenersInstalled = false;
|
|
|
|
|
|
let sendClickHandler = null;
|
|
|
|
|
|
let sendKeydownHandler = null;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Helpers
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function ensureSettings() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const d = getDefaultSettings();
|
|
|
|
|
|
const s = config || structuredClone(d);
|
|
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
deepMerge(s, d);
|
|
|
|
|
|
if (!Array.isArray(s.responseKeepTags)) s.responseKeepTags = structuredClone(d.responseKeepTags);
|
|
|
|
|
|
else s.responseKeepTags = normalizeResponseKeepTags(s.responseKeepTags);
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
|
|
config = s;
|
|
|
|
|
|
return s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeResponseKeepTags(tags) {
|
|
|
|
|
|
const src = Array.isArray(tags) ? tags : [];
|
|
|
|
|
|
const cleaned = [];
|
|
|
|
|
|
for (const raw of src) {
|
|
|
|
|
|
const t = String(raw || '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(/^<+|>+$/g, '')
|
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
|
if (!/^[a-z][a-z0-9_-]*$/.test(t)) continue;
|
|
|
|
|
|
if (!cleaned.includes(t)) cleaned.push(t);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return cleaned;
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
async function loadConfig() {
|
|
|
|
|
|
const loaded = await EnaPlannerStorage.get('config', null);
|
|
|
|
|
|
config = (loaded && typeof loaded === 'object') ? loaded : getDefaultSettings();
|
|
|
|
|
|
ensureSettings();
|
|
|
|
|
|
state.logs = Array.isArray(await EnaPlannerStorage.get('logs', [])) ? await EnaPlannerStorage.get('logs', []) : [];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (extension_settings?.[EXT_NAME]) {
|
|
|
|
|
|
delete extension_settings[EXT_NAME];
|
|
|
|
|
|
saveSettingsDebounced?.();
|
|
|
|
|
|
}
|
|
|
|
|
|
return config;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
async function saveConfigNow() {
|
|
|
|
|
|
ensureSettings();
|
|
|
|
|
|
await EnaPlannerStorage.set('config', config);
|
|
|
|
|
|
await EnaPlannerStorage.set('logs', state.logs);
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await EnaPlannerStorage.saveNow({ silent: false });
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toastInfo(msg) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (window.toastr?.info) return window.toastr.info(msg);
|
|
|
|
|
|
console.log('[EnaPlanner]', msg);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
function toastErr(msg) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (window.toastr?.error) return window.toastr.error(msg);
|
|
|
|
|
|
console.error('[EnaPlanner]', msg);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clampLogs() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
if (state.logs.length > s.logsMax) state.logs = state.logs.slice(0, s.logsMax);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function persistLogsMaybe() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
if (!s.logsPersist) return;
|
|
|
|
|
|
state.logs = state.logs.slice(0, s.logsMax);
|
|
|
|
|
|
EnaPlannerStorage.set('logs', state.logs).catch(() => {});
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function loadPersistedLogsMaybe() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
if (!s.logsPersist) state.logs = [];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function nowISO() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return new Date().toISOString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function runWithTimeout(taskFactory, timeoutMs, timeoutMessage) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
|
|
|
|
|
|
Promise.resolve()
|
|
|
|
|
|
.then(taskFactory)
|
|
|
|
|
|
.then(resolve)
|
|
|
|
|
|
.catch(reject)
|
|
|
|
|
|
.finally(() => clearTimeout(timer));
|
|
|
|
|
|
});
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function normalizeUrlBase(u) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!u) return '';
|
|
|
|
|
|
return u.replace(/\/+$/g, '');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getDefaultPrefixByChannel(channel) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (channel === 'gemini') return '/v1beta';
|
|
|
|
|
|
return '/v1';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildApiPrefix() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
if (s.api.prefixMode === 'custom' && s.api.customPrefix?.trim()) return s.api.customPrefix.trim();
|
|
|
|
|
|
return getDefaultPrefixByChannel(s.api.channel);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildUrl(path) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
const escapedPrefix = finalPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
|
const hasSameSuffix = !!finalPrefix && new RegExp(`${escapedPrefix}$`, 'i').test(base);
|
|
|
|
|
|
const normalizedBase = hasSameSuffix ? base.slice(0, -finalPrefix.length) : base;
|
|
|
|
|
|
return `${normalizedBase}${finalPrefix}${finalPath}`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setSendUIBusy(busy) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function safeStringify(val) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (val == null) return '';
|
|
|
|
|
|
if (typeof val === 'string') return val;
|
|
|
|
|
|
try { return JSON.stringify(val, null, 2); } catch { return String(val); }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* ST context helpers
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function getContextSafe() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
try { return window.SillyTavern?.getContext?.() ?? null; } catch { return null; }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getCurrentCharSafe() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Character card — always include desc/personality/scenario
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function formatCharCardBlock(charObj) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!charObj) return '';
|
|
|
|
|
|
const name = charObj?.name ?? '';
|
|
|
|
|
|
const description = charObj?.description ?? '';
|
|
|
|
|
|
const personality = charObj?.personality ?? '';
|
|
|
|
|
|
const scenario = charObj?.scenario ?? '';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Chat history — ALL unhidden, AI responses ONLY
|
|
|
|
|
|
* Strip: unclosed think blocks, configurable tags
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function cleanAiMessageText(text) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
let out = String(text ?? '');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-03-02 23:37:51 +08:00
|
|
|
|
// 1) Strip everything before and including </think> (handles unclosed think blocks)
|
|
|
|
|
|
out = out.replace(/^[\s\S]*?<\/think>/i, '');
|
2026-02-25 23:58:05 +08:00
|
|
|
|
out = out.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '');
|
|
|
|
|
|
out = out.replace(/<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi, '');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 2) 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, '');
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return out.trim();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function collectRecentChatSnippet(chat, maxMessages) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!lines.length) return '';
|
|
|
|
|
|
return `<chat_history>\n${lines.join('\n')}\n</chat_history>`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Plot extraction
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function extractLastNPlots(chat, n) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return plots;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatPlotsBlock(plotList) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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>`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Worldbook — read via ST API (like idle-watcher)
|
|
|
|
|
|
* Always read character-linked worldbooks.
|
|
|
|
|
|
* Optionally include global worldbooks.
|
|
|
|
|
|
* Activation: constant (blue) + keyword scan (green) only.
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
|
|
|
|
|
async function getCharacterWorldbooks() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const ctx = getContextSafe();
|
|
|
|
|
|
const charObj = getCurrentCharSafe();
|
|
|
|
|
|
const worldNames = [];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// From character object (multiple paths)
|
|
|
|
|
|
if (charObj) {
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const paths = [
|
2026-02-25 23:58:05 +08:00
|
|
|
|
charObj?.data?.extensions?.world,
|
|
|
|
|
|
charObj?.world,
|
|
|
|
|
|
charObj?.data?.character_book?.name,
|
2026-02-24 18:20:22 +08:00
|
|
|
|
];
|
|
|
|
|
|
for (const w of paths) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (w && !worldNames.includes(w)) worldNames.push(w);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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 { }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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 { }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Fallback: try ST's selected character world info
|
2026-02-24 18:20:22 +08:00
|
|
|
|
try {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const sw = window.selected_world_info;
|
|
|
|
|
|
if (typeof sw === 'string' && sw && !worldNames.includes(sw)) {
|
|
|
|
|
|
worldNames.push(sw);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
} 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 { }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
console.log('[EnaPlanner] Character worldbook names found:', worldNames);
|
|
|
|
|
|
return worldNames.filter(Boolean);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getGlobalWorldbooks() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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 { }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Fallback: try window.selected_world_info
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.selected_world_info && Array.isArray(window.selected_world_info)) {
|
|
|
|
|
|
return window.selected_world_info;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return [];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function getWorldbookData(worldName) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return null;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function keywordPresent(text, kw) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!kw) return false;
|
|
|
|
|
|
return text.toLowerCase().includes(kw.toLowerCase());
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function matchSelective(entry, scanText) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const keys = Array.isArray(entry?.key) ? entry.key.filter(Boolean) : [];
|
|
|
|
|
|
const keys2 = Array.isArray(entry?.keysecondary) ? entry.keysecondary.filter(Boolean) : [];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const total = keys.length;
|
2026-03-19 00:50:14 +08:00
|
|
|
|
if (total === 0) return false;
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const hit = keys.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!ok) return false;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (keys2.length) {
|
|
|
|
|
|
const hit2 = keys2.reduce((acc, kw) => acc + (keywordPresent(scanText, kw) ? 1 : 0), 0);
|
|
|
|
|
|
if (hit2 <= 0) return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function sortWorldEntries(entries) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
});
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function buildWorldbookBlock(scanText) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 1. Always get character-linked worldbooks
|
|
|
|
|
|
const charWorldNames = await getCharacterWorldbooks();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 2. Optionally get global worldbooks
|
|
|
|
|
|
let globalWorldNames = [];
|
|
|
|
|
|
if (s.includeGlobalWorldbooks) {
|
|
|
|
|
|
globalWorldNames = await getGlobalWorldbooks();
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Deduplicate
|
|
|
|
|
|
const allWorldNames = [...new Set([...charWorldNames, ...globalWorldNames])];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!allWorldNames.length) {
|
|
|
|
|
|
console.log('[EnaPlanner] No worldbooks to load');
|
|
|
|
|
|
return '';
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
console.log('[EnaPlanner] Loading worldbooks:', allWorldNames);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Fetch all worldbook data
|
|
|
|
|
|
const worldbookResults = await Promise.all(allWorldNames.map(name => getWorldbookData(name)));
|
|
|
|
|
|
const allEntries = [];
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Filter: not disabled
|
|
|
|
|
|
let entries = allEntries.filter(e => !e?.disable && !e?.disabled);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
});
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Filter: exclude position=4 if configured
|
|
|
|
|
|
if (s.excludeWorldbookPosition4) {
|
|
|
|
|
|
entries = entries.filter(e => Number(e?.position) !== 4);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!entries.length) return '';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!active.length) return '';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Build EJS context for rendering worldbook templates
|
|
|
|
|
|
const ejsCtx = buildEjsContext();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Try EJS rendering if the entry contains EJS tags
|
|
|
|
|
|
if (body.includes('<%')) {
|
|
|
|
|
|
body = renderEjsTemplate(body, ejsCtx);
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
parts.push(`${head}\n${body}`);
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!parts.length) return '';
|
|
|
|
|
|
return `<worldbook>\n${parts.join('\n\n---\n\n')}\n</worldbook>`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* EJS rendering for worldbook entries
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function getChatVariables() {
|
2026-02-28 11:50:03 +08:00
|
|
|
|
let vars = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Chat-level variables
|
2026-02-24 18:20:22 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const ctx = getContextSafe();
|
2026-02-28 11:50:03 +08:00
|
|
|
|
if (ctx?.chatMetadata?.variables) vars = { ...ctx.chatMetadata.variables };
|
2026-02-24 18:20:22 +08:00
|
|
|
|
} catch {}
|
2026-02-28 11:50:03 +08:00
|
|
|
|
if (!Object.keys(vars).length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (window.chat_metadata?.variables) vars = { ...window.chat_metadata.variables };
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!Object.keys(vars).length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const ctx = getContextSafe();
|
|
|
|
|
|
if (ctx?.chat_metadata?.variables) vars = { ...ctx.chat_metadata.variables };
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
}
|
2026-02-28 11:32:00 +08:00
|
|
|
|
|
2026-02-28 11:50:03 +08:00
|
|
|
|
// 2) Always merge message-level variables (some presets store vars here instead of chat-level)
|
2026-02-28 11:32:00 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const msgVars = getLatestMessageVarTable();
|
2026-02-28 11:50:03 +08:00
|
|
|
|
if (msgVars && typeof msgVars === 'object') {
|
|
|
|
|
|
for (const key of Object.keys(msgVars)) {
|
|
|
|
|
|
// Skip MVU internal metadata keys
|
|
|
|
|
|
if (key === 'schema' || key === 'display_data' || key === 'delta_data') continue;
|
|
|
|
|
|
if (vars[key] === undefined) {
|
|
|
|
|
|
// Chat-level doesn't have this key at all — take from message-level
|
|
|
|
|
|
vars[key] = msgVars[key];
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
vars[key] && typeof vars[key] === 'object' && !Array.isArray(vars[key]) &&
|
|
|
|
|
|
msgVars[key] && typeof msgVars[key] === 'object' && !Array.isArray(msgVars[key])
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Both have this key as objects — shallow merge (message-level fills gaps)
|
|
|
|
|
|
for (const subKey of Object.keys(msgVars[key])) {
|
|
|
|
|
|
if (vars[key][subKey] === undefined) {
|
|
|
|
|
|
vars[key][subKey] = msgVars[key][subKey];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-28 11:32:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch {}
|
|
|
|
|
|
|
2026-02-28 11:50:03 +08:00
|
|
|
|
return vars;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildEjsContext() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// setvar: write a chat variable (no-op for our purposes, just to avoid errors)
|
|
|
|
|
|
function setvar(name, value) {
|
|
|
|
|
|
if (name) vars[name] = value;
|
|
|
|
|
|
return value;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return {
|
|
|
|
|
|
getvar, setvar,
|
2026-02-28 21:44:17 +08:00
|
|
|
|
vars,
|
2026-02-25 23:58:05 +08:00
|
|
|
|
Number, Math, JSON, String, Array, Object, parseInt, parseFloat,
|
|
|
|
|
|
console: { log: () => { }, warn: () => { }, error: () => { } },
|
|
|
|
|
|
};
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// Safe degradation when ejs is not available.
|
|
|
|
|
|
console.warn('[EnaPlanner] window.ejs not available, skipping EJS rendering. Template returned as-is.');
|
|
|
|
|
|
return template;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Template rendering helpers
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
async function prepareEjsEnv() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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; }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function evalEjsIfPossible(text, env) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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; }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function substituteMacrosViaST(text) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
try { return substituteParamsExtended(text); } catch { return text; }
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function deepGet(obj, path) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveGetMessageVariableMacros(text, messageVars) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return text.replace(/{{\s*get_message_variable::([^}]+)\s*}}/g, (_, rawPath) => {
|
|
|
|
|
|
const path = String(rawPath || '').trim();
|
|
|
|
|
|
if (!path) return '';
|
|
|
|
|
|
return safeStringify(deepGet(messageVars, path));
|
|
|
|
|
|
});
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 00:50:14 +08:00
|
|
|
|
function resolveFormatMessageVariableMacros(text, messageVars) {
|
|
|
|
|
|
return text.replace(/{{\s*format_message_variable::([^}]+)\s*}}/g, (_, rawPath) => {
|
|
|
|
|
|
const path = String(rawPath || '').trim();
|
|
|
|
|
|
if (!path) return '';
|
|
|
|
|
|
const val = deepGet(messageVars, path);
|
|
|
|
|
|
if (val == null) return '';
|
|
|
|
|
|
if (typeof val === 'string') return val;
|
|
|
|
|
|
try { return jsyaml.dump(val, { lineWidth: -1, noRefs: true }); } catch { return safeStringify(val); }
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function getLatestMessageVarTable() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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 {};
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function renderTemplateAll(text, env, messageVars) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
let out = String(text ?? '');
|
|
|
|
|
|
out = await evalEjsIfPossible(out, env);
|
|
|
|
|
|
out = substituteMacrosViaST(out);
|
|
|
|
|
|
out = resolveGetMessageVariableMacros(out, messageVars);
|
2026-03-19 00:50:14 +08:00
|
|
|
|
out = resolveFormatMessageVariableMacros(out, messageVars);
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return out;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Planner response filtering
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function stripThinkBlocks(text) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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 extractSelectedBlocksInOrder(text, tagNames) {
|
|
|
|
|
|
const names = normalizeResponseKeepTags(tagNames);
|
|
|
|
|
|
if (!Array.isArray(names) || names.length === 0) return '';
|
|
|
|
|
|
const src = String(text ?? '');
|
|
|
|
|
|
const blocks = [];
|
|
|
|
|
|
const escapedNames = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
|
|
|
|
const re = new RegExp(`<(${escapedNames.join('|')})\\b[^>]*>[\\s\\S]*?<\\/\\1>`, 'gi');
|
|
|
|
|
|
let m;
|
|
|
|
|
|
while ((m = re.exec(src)) !== null) {
|
|
|
|
|
|
blocks.push(m[0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
return blocks.join('\n\n').trim();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function filterPlannerForInput(rawFull) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const noThink = stripThinkBlocks(rawFull);
|
|
|
|
|
|
const tags = ensureSettings().responseKeepTags;
|
|
|
|
|
|
const selected = extractSelectedBlocksInOrder(noThink, tags);
|
|
|
|
|
|
if (selected) return selected;
|
|
|
|
|
|
return noThink;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
function filterPlannerPreview(rawPartial) {
|
|
|
|
|
|
return stripThinkBlocks(rawPartial);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Planner API calls
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function callPlanner(messages, options = {}) {
|
|
|
|
|
|
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('未选择模型');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const url = buildUrl('/chat/completions');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const body = {
|
|
|
|
|
|
model: s.api.model,
|
|
|
|
|
|
messages,
|
|
|
|
|
|
stream: !!s.api.stream
|
|
|
|
|
|
};
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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 controller = new AbortController();
|
|
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), PLANNER_REQUEST_TIMEOUT_MS);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: {
|
|
|
|
|
|
...getRequestHeaders(),
|
|
|
|
|
|
Authorization: `Bearer ${s.api.apiKey}`,
|
|
|
|
|
|
'Content-Type': 'application/json'
|
|
|
|
|
|
},
|
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
|
signal: controller.signal
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const text = await res.text().catch(() => '');
|
|
|
|
|
|
throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500));
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!s.api.stream) {
|
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
|
const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
|
|
|
|
|
|
if (text) options?.onDelta?.(text, text);
|
|
|
|
|
|
return text;
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
options?.onDelta?.(piece, full);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch { }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return full;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if (controller.signal.aborted || err?.name === 'AbortError') {
|
|
|
|
|
|
throw new Error(`规划请求超时(>${Math.floor(PLANNER_REQUEST_TIMEOUT_MS / 1000)}s)`);
|
|
|
|
|
|
}
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
clearTimeout(timeoutId);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
async function fetchModelsForUi() {
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const s = ensureSettings();
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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}`
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
});
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
const text = await res.text().catch(() => '');
|
|
|
|
|
|
throw new Error(`拉取模型失败: ${res.status} ${text}`.slice(0, 300));
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
const data = await res.json();
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const list = Array.isArray(data?.data) ? data.data : [];
|
|
|
|
|
|
return list.map(x => x?.id).filter(Boolean);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function debugWorldbookForUi() {
|
|
|
|
|
|
let out = '正在诊断世界书读取...\n';
|
|
|
|
|
|
const charWb = await getCharacterWorldbooks();
|
|
|
|
|
|
out += `角色世界书名称: ${JSON.stringify(charWb)}\n`;
|
|
|
|
|
|
const globalWb = await getGlobalWorldbooks();
|
|
|
|
|
|
out += `全局世界书名称: ${JSON.stringify(globalWb)}\n`;
|
|
|
|
|
|
const all = [...new Set([...charWb, ...globalWb])];
|
|
|
|
|
|
for (const name of all) {
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const data = await getWorldbookData(name);
|
|
|
|
|
|
const count = data?.entries?.length ?? 0;
|
|
|
|
|
|
const enabled = data?.entries?.filter(e => !e?.disable && !e?.disabled)?.length ?? 0;
|
2026-02-25 23:58:05 +08:00
|
|
|
|
out += ` "${name}": ${count} 条目, ${enabled} 已启用\n`;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!all.length) {
|
|
|
|
|
|
out += '⚠️ 未找到任何世界书。请检查角色卡是否绑定了世界书。\n';
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const charObj = getCurrentCharSafe();
|
2026-02-25 23:58:05 +08:00
|
|
|
|
out += `charObj存在: ${!!charObj}\n`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
if (charObj) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
out += `charObj.world: ${charObj?.world}\n`;
|
|
|
|
|
|
out += `charObj.data.extensions.world: ${charObj?.data?.extensions?.world}\n`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
const ctx = getContextSafe();
|
2026-02-25 23:58:05 +08:00
|
|
|
|
out += `ctx存在: ${!!ctx}\n`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
if (ctx) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
out += `ctx.characterId: ${ctx?.characterId}\n`;
|
|
|
|
|
|
out += `ctx.this_chid: ${ctx?.this_chid}\n`;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
function debugCharForUi() {
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const charObj = getCurrentCharSafe();
|
|
|
|
|
|
if (!charObj) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const ctx = getContextSafe();
|
|
|
|
|
|
return [
|
|
|
|
|
|
'⚠️ 未检测到角色。',
|
|
|
|
|
|
`ctx: ${!!ctx}, ctx.characterId: ${ctx?.characterId}, ctx.this_chid: ${ctx?.this_chid}`,
|
|
|
|
|
|
`window.this_chid: ${window.this_chid}`,
|
|
|
|
|
|
`window.characters count: ${window.characters?.length ?? 'N/A'}`
|
|
|
|
|
|
].join('\n');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
const block = formatCharCardBlock(charObj);
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return [
|
|
|
|
|
|
`角色名: ${charObj?.name}`,
|
|
|
|
|
|
`desc长度: ${(charObj?.description ?? '').length}`,
|
|
|
|
|
|
`personality长度: ${(charObj?.personality ?? '').length}`,
|
|
|
|
|
|
`scenario长度: ${(charObj?.scenario ?? '').length}`,
|
|
|
|
|
|
`world: ${charObj?.world ?? charObj?.data?.extensions?.world ?? '(无)'}`,
|
|
|
|
|
|
`---\n${block.slice(0, 500)}...`
|
|
|
|
|
|
].join('\n');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Build planner messages
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function getPromptBlocksByRole(role) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
return (s.promptBlocks || []).filter(b => b?.role === role && String(b?.content ?? '').trim());
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function buildPlannerMessages(rawUserInput) {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
const ctx = getContextSafe();
|
|
|
|
|
|
const chat = ctx?.chat ?? window.SillyTavern?.chat ?? [];
|
|
|
|
|
|
const charObj = getCurrentCharSafe();
|
|
|
|
|
|
const env = await prepareEjsEnv();
|
|
|
|
|
|
const messageVars = getLatestMessageVarTable();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const enaSystemBlocks = getPromptBlocksByRole('system');
|
|
|
|
|
|
const enaAssistantBlocks = getPromptBlocksByRole('assistant');
|
|
|
|
|
|
const enaUserBlocks = getPromptBlocksByRole('user');
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const charBlockRaw = formatCharCardBlock(charObj);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// --- Story memory: try fresh vector recall with current user input ---
|
|
|
|
|
|
let cachedSummary = '';
|
|
|
|
|
|
let recallSource = 'none';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const vectorCfg = getVectorConfig();
|
|
|
|
|
|
if (vectorCfg?.enabled) {
|
|
|
|
|
|
const result = await runWithTimeout(
|
|
|
|
|
|
() => buildVectorPromptText(false, {
|
|
|
|
|
|
pendingUserMessage: rawUserInput,
|
|
|
|
|
|
}),
|
|
|
|
|
|
VECTOR_RECALL_TIMEOUT_MS,
|
|
|
|
|
|
`向量召回超时(>${Math.floor(VECTOR_RECALL_TIMEOUT_MS / 1000)}s)`
|
|
|
|
|
|
);
|
|
|
|
|
|
cachedSummary = result?.text?.trim() || '';
|
|
|
|
|
|
if (cachedSummary) recallSource = 'fresh';
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[Ena] Fresh vector recall failed, falling back to cached data:', e);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (!cachedSummary) {
|
|
|
|
|
|
cachedSummary = getCachedStorySummary();
|
|
|
|
|
|
if (cachedSummary) recallSource = 'stale';
|
|
|
|
|
|
}
|
|
|
|
|
|
console.log(`[Ena] Story memory source: ${recallSource}`);
|
|
|
|
|
|
|
|
|
|
|
|
// --- 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 = '';
|
|
|
|
|
|
|
|
|
|
|
|
// Build scanText for worldbook keyword activation
|
2026-03-19 00:50:14 +08:00
|
|
|
|
const scanText = [charBlockRaw, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
|
2026-02-25 23:58:05 +08:00
|
|
|
|
|
|
|
|
|
|
const worldbookRaw = await buildWorldbookBlock(scanText);
|
2026-03-02 23:37:51 +08:00
|
|
|
|
const outlineRaw = typeof formatOutlinePrompt === 'function' ? (formatOutlinePrompt() || '') : '';
|
2026-02-25 23:58:05 +08:00
|
|
|
|
|
|
|
|
|
|
// 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);
|
2026-03-02 23:37:51 +08:00
|
|
|
|
const storyOutline = outlineRaw.trim().length > 10 ? await renderTemplateAll(outlineRaw, env, messageVars) : '';
|
2026-02-25 23:58:05 +08:00
|
|
|
|
|
|
|
|
|
|
const messages = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 1) Ena system prompts
|
|
|
|
|
|
for (const b of enaSystemBlocks) {
|
|
|
|
|
|
const content = await renderTemplateAll(b.content, env, messageVars);
|
|
|
|
|
|
messages.push({ role: 'system', content });
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 2) Character card
|
|
|
|
|
|
if (String(charBlock).trim()) messages.push({ role: 'system', content: charBlock });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 3) Worldbook
|
|
|
|
|
|
if (String(worldbook).trim()) messages.push({ role: 'system', content: worldbook });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-03-02 23:37:51 +08:00
|
|
|
|
// 3.5) Story Outline / 剧情地图(小白板世界架构)
|
|
|
|
|
|
if (storyOutline.trim()) {
|
|
|
|
|
|
messages.push({ role: 'system', content: `<plot_map>\n${storyOutline}\n</plot_map>` });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 4) Chat history (last 2 AI responses — floors N-1 & N-3)
|
|
|
|
|
|
if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 4.5) Story memory (小白X <剧情记忆> — after chat context, before plots)
|
|
|
|
|
|
if (storySummary.trim()) {
|
|
|
|
|
|
messages.push({ role: 'system', content: `<story_summary>\n${storySummary}\n</story_summary>` });
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 5) Vector recall — merged into story_summary above, kept for compatibility
|
|
|
|
|
|
// (vectorRaw is empty; this block intentionally does nothing)
|
|
|
|
|
|
if (String(vector).trim()) messages.push({ role: 'system', content: vector });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 6) Previous plots
|
|
|
|
|
|
if (String(plots).trim()) messages.push({ role: 'system', content: plots });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 7) User input (with friendly framing)
|
|
|
|
|
|
const userMsgContent = `以下是玩家的最新指令哦~:\n[${userInput}]`;
|
|
|
|
|
|
messages.push({ role: 'user', content: userMsgContent });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 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}` });
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
// 8) Assistant blocks
|
|
|
|
|
|
for (const b of enaAssistantBlocks) {
|
|
|
|
|
|
const content = await renderTemplateAll(b.content, env, messageVars);
|
|
|
|
|
|
messages.push({ role: 'assistant', content });
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
return { messages, meta: { charBlockRaw, worldbookRaw, recentChatRaw, vectorRaw, cachedSummaryLen: cachedSummary.length, plotsRaw } };
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Planning runner + logging
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function runPlanningOnce(rawUserInput, silent = false, options = {}) {
|
2026-02-24 18:20:22 +08:00
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const log = {
|
|
|
|
|
|
time: nowISO(), ok: false, model: s.api.model,
|
|
|
|
|
|
requestMessages: [], rawReply: '', filteredReply: '', error: ''
|
|
|
|
|
|
};
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
|
|
|
|
|
try {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const { messages } = await buildPlannerMessages(rawUserInput);
|
|
|
|
|
|
log.requestMessages = messages;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const rawReply = await callPlanner(messages, options);
|
|
|
|
|
|
log.rawReply = rawReply;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const filtered = filterPlannerForInput(rawReply);
|
|
|
|
|
|
log.filteredReply = filtered;
|
|
|
|
|
|
log.ok = true;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* -------------------------
|
2026-02-24 18:20:22 +08:00
|
|
|
|
* Intercept send
|
2026-02-25 23:58:05 +08:00
|
|
|
|
* --------------------------
|
|
|
|
|
|
*/
|
2026-02-24 18:20:22 +08:00
|
|
|
|
function getSendTextarea() { return document.getElementById('send_textarea'); }
|
|
|
|
|
|
function getSendButton() { return document.getElementById('send_but') || document.getElementById('send_button'); }
|
|
|
|
|
|
|
|
|
|
|
|
function shouldInterceptNow() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
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;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function doInterceptAndPlanThenSend() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const ta = getSendTextarea();
|
|
|
|
|
|
const btn = getSendButton();
|
|
|
|
|
|
if (!ta || !btn) return;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
const raw = String(ta.value ?? '').trim();
|
|
|
|
|
|
if (!raw) return;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
state.isPlanning = true;
|
|
|
|
|
|
setSendUIBusy(true);
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
try {
|
|
|
|
|
|
toastInfo('Ena Planner:正在规划…');
|
|
|
|
|
|
const { filtered } = await runPlanningOnce(raw, false, {
|
|
|
|
|
|
onDelta(_piece, full) {
|
|
|
|
|
|
if (!state.isPlanning) return;
|
|
|
|
|
|
if (!ensureSettings().api.stream) return;
|
|
|
|
|
|
const preview = filterPlannerPreview(full);
|
|
|
|
|
|
ta.value = `${raw}\n\n${preview}`.trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
const merged = `${raw}\n\n${filtered}`.trim();
|
|
|
|
|
|
ta.value = merged;
|
|
|
|
|
|
state.lastInjectedText = merged;
|
|
|
|
|
|
|
|
|
|
|
|
state.bypassNextSend = true;
|
|
|
|
|
|
btn.click();
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
ta.value = raw;
|
|
|
|
|
|
state.lastInjectedText = '';
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
state.isPlanning = false;
|
|
|
|
|
|
setSendUIBusy(false);
|
|
|
|
|
|
setTimeout(() => { state.bypassNextSend = false; }, 800);
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function installSendInterceptors() {
|
2026-02-25 23:58:05 +08:00
|
|
|
|
if (sendListenersInstalled) return;
|
|
|
|
|
|
sendClickHandler = (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)));
|
|
|
|
|
|
};
|
|
|
|
|
|
sendKeydownHandler = (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)));
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener('click', sendClickHandler, true);
|
|
|
|
|
|
document.addEventListener('keydown', sendKeydownHandler, true);
|
|
|
|
|
|
sendListenersInstalled = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function uninstallSendInterceptors() {
|
|
|
|
|
|
if (!sendListenersInstalled) return;
|
|
|
|
|
|
if (sendClickHandler) document.removeEventListener('click', sendClickHandler, true);
|
|
|
|
|
|
if (sendKeydownHandler) document.removeEventListener('keydown', sendKeydownHandler, true);
|
|
|
|
|
|
sendClickHandler = null;
|
|
|
|
|
|
sendKeydownHandler = null;
|
|
|
|
|
|
sendListenersInstalled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getIframeConfigPayload() {
|
|
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
return {
|
|
|
|
|
|
...s,
|
|
|
|
|
|
logs: state.logs,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openSettings() {
|
|
|
|
|
|
if (document.getElementById(OVERLAY_ID)) return;
|
|
|
|
|
|
|
|
|
|
|
|
overlay = document.createElement('div');
|
|
|
|
|
|
overlay.id = OVERLAY_ID;
|
|
|
|
|
|
overlay.style.cssText = `
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
width: 100vw;
|
|
|
|
|
|
height: ${window.innerHeight}px;
|
|
|
|
|
|
background: rgba(0,0,0,0.5);
|
|
|
|
|
|
z-index: 99999;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
const iframe = document.createElement('iframe');
|
|
|
|
|
|
iframe.src = HTML_PATH;
|
|
|
|
|
|
iframe.style.cssText = `
|
|
|
|
|
|
width: min(1200px, 96vw);
|
|
|
|
|
|
height: min(980px, 94vh);
|
|
|
|
|
|
max-height: calc(100% - 24px);
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
background: #1a1a1a;
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
overlay.appendChild(iframe);
|
|
|
|
|
|
document.body.appendChild(overlay);
|
|
|
|
|
|
|
|
|
|
|
|
if (!iframeMessageBound) {
|
|
|
|
|
|
// Guarded by isTrustedIframeEvent (origin + source).
|
|
|
|
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
|
|
|
|
window.addEventListener('message', handleIframeMessage);
|
|
|
|
|
|
iframeMessageBound = true;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
function closeSettings() {
|
|
|
|
|
|
const overlayEl = document.getElementById(OVERLAY_ID);
|
|
|
|
|
|
if (overlayEl) overlayEl.remove();
|
|
|
|
|
|
overlay = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleIframeMessage(ev) {
|
|
|
|
|
|
const iframe = overlay?.querySelector('iframe');
|
|
|
|
|
|
if (!isTrustedIframeEvent(ev, iframe)) return;
|
|
|
|
|
|
if (!ev.data?.type?.startsWith('xb-ena:')) return;
|
|
|
|
|
|
|
|
|
|
|
|
const { type, payload } = ev.data;
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'xb-ena:ready':
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:config', payload: getIframeConfigPayload() });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'xb-ena:close':
|
|
|
|
|
|
closeSettings();
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'xb-ena:save-config': {
|
|
|
|
|
|
const requestId = payload?.requestId || '';
|
|
|
|
|
|
const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload;
|
|
|
|
|
|
Object.assign(ensureSettings(), patch || {});
|
|
|
|
|
|
const ok = await saveConfigNow();
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
postToIframe(iframe, {
|
|
|
|
|
|
type: 'xb-ena:config-saved',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
...getIframeConfigPayload(),
|
|
|
|
|
|
requestId
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
postToIframe(iframe, {
|
|
|
|
|
|
type: 'xb-ena:config-save-error',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
message: '保存失败',
|
|
|
|
|
|
requestId
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'xb-ena:reset-prompt-default': {
|
|
|
|
|
|
const requestId = payload?.requestId || '';
|
|
|
|
|
|
const s = ensureSettings();
|
|
|
|
|
|
s.promptBlocks = getDefaultSettings().promptBlocks;
|
|
|
|
|
|
const ok = await saveConfigNow();
|
|
|
|
|
|
if (ok) {
|
|
|
|
|
|
postToIframe(iframe, {
|
|
|
|
|
|
type: 'xb-ena:config-saved',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
...getIframeConfigPayload(),
|
|
|
|
|
|
requestId
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
postToIframe(iframe, {
|
|
|
|
|
|
type: 'xb-ena:config-save-error',
|
|
|
|
|
|
payload: {
|
|
|
|
|
|
message: '重置失败',
|
|
|
|
|
|
requestId
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'xb-ena:run-test': {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fake = payload?.text || '(测试输入)我想让你帮我规划下一步剧情。';
|
|
|
|
|
|
await runPlanningOnce(fake, true);
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:test-done' });
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:test-error', payload: { message: String(err?.message ?? err) } });
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'xb-ena:logs-request':
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'xb-ena:logs-clear':
|
|
|
|
|
|
state.logs = [];
|
|
|
|
|
|
await saveConfigNow();
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:logs', payload: { logs: state.logs } });
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'xb-ena:fetch-models': {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const models = await fetchModelsForUi();
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:models', payload: { models } });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:models-error', payload: { message: String(err?.message ?? err) } });
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'xb-ena:debug-worldbook': {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const output = await debugWorldbookForUi();
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output } });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output: String(err?.message ?? err) } });
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
case 'xb-ena:debug-char': {
|
|
|
|
|
|
const output = debugCharForUi();
|
|
|
|
|
|
postToIframe(iframe, { type: 'xb-ena:debug-output', payload: { output } });
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function initEnaPlanner() {
|
|
|
|
|
|
await loadConfig();
|
2026-02-24 18:20:22 +08:00
|
|
|
|
loadPersistedLogsMaybe();
|
2026-02-25 23:58:05 +08:00
|
|
|
|
installSendInterceptors();
|
|
|
|
|
|
window.xiaobaixEnaPlanner = { openSettings, closeSettings };
|
|
|
|
|
|
}
|
2026-02-24 18:20:22 +08:00
|
|
|
|
|
2026-02-25 23:58:05 +08:00
|
|
|
|
export function cleanupEnaPlanner() {
|
|
|
|
|
|
uninstallSendInterceptors();
|
|
|
|
|
|
closeSettings();
|
|
|
|
|
|
if (iframeMessageBound) {
|
|
|
|
|
|
window.removeEventListener('message', handleIframeMessage);
|
|
|
|
|
|
iframeMessageBound = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
delete window.xiaobaixEnaPlanner;
|
2026-02-24 18:20:22 +08:00
|
|
|
|
}
|