diff --git a/modules/ena-planner/ena-planner.js b/modules/ena-planner/ena-planner.js
index 52aa1a1..a28f2cc 100644
--- a/modules/ena-planner/ena-planner.js
+++ b/modules/ena-planner/ena-planner.js
@@ -1,6 +1,8 @@
import { extension_settings } from '../../../../../extensions.js';
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js';
import { getStorySummaryForEna } from '../story-summary/story-summary.js';
+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';
@@ -9,6 +11,8 @@ import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.
const EXT_NAME = 'ena-planner';
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;
/**
* -------------------------
@@ -188,6 +192,17 @@ function nowISO() {
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));
+ });
+}
+
function normalizeUrlBase(u) {
if (!u) return '';
return u.replace(/\/+$/g, '');
@@ -408,71 +423,6 @@ function formatPlotsBlock(plotList) {
return `\n${lines.join('\n\n')}\n`;
}
-/**
- * -------------------------
- * Vector recall — always include if present
- * --------------------------
- */
-function formatVectorRecallBlock(extensionPrompts) {
- // ST's extensionPrompts is actually an object (key-value map), not an array.
- // Most entries are ST internals — we only want actual vector recall / RAG data.
- if (!extensionPrompts) return '';
-
- // Known ST internal keys to skip (handled elsewhere or irrelevant)
- const skipKeys = new Set([
- 'QUIET_PROMPT', 'PERSONA_DESCRIPTION', 'TEMP_USER_MESSAGE',
- 'DEPTH_PROMPT', '2_floating_prompt', 'main', '__STORY_STRING__',
- 'LWB_varevent_display'
- ]);
-
- const entries = Array.isArray(extensionPrompts)
- ? extensionPrompts.map((v, i) => [String(i), v])
- : Object.entries(extensionPrompts);
- if (!entries.length) return '';
-
- const lines = [];
- for (const [key, p] of entries) {
- if (!p) continue;
- if (typeof key === 'string' && skipKeys.has(key)) continue;
- // Skip worldbook depth entries — handled by worldbook block
- if (typeof key === 'string' && /^customDepthWI/i.test(key)) continue;
- // Skip 小白X (LittleWhiteBox) compressed chat/memory keys
- // These start with 'ÿ' (U+00FF) or 'LWB' and contain chat history already handled elsewhere
- if (typeof key === 'string' && (key.startsWith('ÿ') || key.startsWith('\u00ff') || key.startsWith('LWB'))) continue;
- // Skip long hex-like keys (worldbook entries injected via ST internal mechanism)
- if (typeof key === 'string' && /^\u0001/.test(key)) continue;
-
- // Extract text content — handle string, .value, .content, or nested content array
- let textContent = '';
- if (typeof p === 'string') {
- textContent = p;
- } else if (typeof p?.value === 'string') {
- textContent = p.value;
- } else if (typeof p?.content === 'string') {
- textContent = p.content;
- } else if (Array.isArray(p?.content)) {
- const parts = [];
- for (const seg of p.content) {
- if (seg?.type === 'text' && seg?.text) parts.push(seg.text);
- else if (seg?.type === 'image_url') parts.push('[image_url]');
- else if (seg?.type === 'video_url') parts.push('[video_url]');
- }
- textContent = parts.join(' ');
- }
-
- const t = textContent.trim();
- // Skip short/garbage entries (e.g. "---", empty strings)
- if (!t || t.length < 30) continue;
- const role = typeof p?.role === 'number'
- ? ['system', 'user', 'assistant'][p.role] ?? 'system'
- : (p?.role ?? 'system');
- lines.push(`[${role}] ${t}`);
- }
-
- if (!lines.length) return '';
- return `\n${lines.join('\n')}\n`;
-}
-
/**
* -------------------------
* Worldbook — read via ST API (like idle-watcher)
@@ -956,60 +906,72 @@ async function callPlanner(messages, options = {}) {
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)
- });
+ 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));
- }
+ 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();
- const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
- if (text) options?.onDelta?.(text, text);
- return text;
- }
+ 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;
+ }
- // SSE stream
- const reader = res.body.getReader();
- const decoder = new TextDecoder('utf-8');
- let buf = '';
- let full = '';
+ // 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() ?? '';
+ 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 { }
+ 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);
}
- return full;
}
async function fetchModelsForUi() {
@@ -1100,7 +1062,6 @@ 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();
@@ -1111,8 +1072,30 @@ async function buildPlannerMessages(rawUserInput) {
const charBlockRaw = formatCharCardBlock(charObj);
- // --- Story summary (cached from previous generation via interceptor) ---
- const cachedSummary = getCachedStorySummary();
+ // --- 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);
+ }
+ 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:
@@ -1122,7 +1105,7 @@ async function buildPlannerMessages(rawUserInput) {
const recentChatRaw = collectRecentChatSnippet(chat, 2);
const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount));
- const vectorRaw = formatVectorRecallBlock(extPrompts);
+ const vectorRaw = '';
// Build scanText for worldbook keyword activation
const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
@@ -1152,15 +1135,16 @@ async function buildPlannerMessages(rawUserInput) {
// 3) Worldbook
if (String(worldbook).trim()) messages.push({ role: 'system', content: worldbook });
- // 3.5) Cached story summary (小白X 剧情记忆 from previous turn)
+ // 4) Chat history (last 2 AI responses — floors N-1 & N-3)
+ if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat });
+
+ // 4.5) Story memory (小白X <剧情记忆> — after chat context, before plots)
if (storySummary.trim()) {
messages.push({ role: 'system', content: `\n${storySummary}\n` });
}
- // 4) Chat history (last 2 AI responses — floors N-1 & N-3)
- if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat });
-
- // 5) Vector recall
+ // 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 });
// 6) Previous plots
@@ -1266,6 +1250,10 @@ async function doInterceptAndPlanThenSend() {
state.bypassNextSend = true;
btn.click();
+ } catch (err) {
+ ta.value = raw;
+ state.lastInjectedText = '';
+ throw err;
} finally {
state.isPlanning = false;
setSendUIBusy(false);