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);