fix(ena-planner): add planning timeouts and input rollback
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
import { extension_settings } from '../../../../../extensions.js';
|
import { extension_settings } from '../../../../../extensions.js';
|
||||||
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js';
|
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../../script.js';
|
||||||
import { getStorySummaryForEna } from '../story-summary/story-summary.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 { extensionFolderPath } from '../../core/constants.js';
|
||||||
import { EnaPlannerStorage } from '../../core/server-storage.js';
|
import { EnaPlannerStorage } from '../../core/server-storage.js';
|
||||||
import { postToIframe, isTrustedIframeEvent } from '../../core/iframe-messaging.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 EXT_NAME = 'ena-planner';
|
||||||
const OVERLAY_ID = 'xiaobaix-ena-planner-overlay';
|
const OVERLAY_ID = 'xiaobaix-ena-planner-overlay';
|
||||||
const HTML_PATH = `${extensionFolderPath}/modules/ena-planner/ena-planner.html`;
|
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();
|
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) {
|
function normalizeUrlBase(u) {
|
||||||
if (!u) return '';
|
if (!u) return '';
|
||||||
return u.replace(/\/+$/g, '');
|
return u.replace(/\/+$/g, '');
|
||||||
@@ -408,71 +423,6 @@ function formatPlotsBlock(plotList) {
|
|||||||
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
|
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* -------------------------
|
|
||||||
* Vector recall — always include if present
|
|
||||||
* --------------------------
|
|
||||||
*/
|
|
||||||
function formatVectorRecallBlock(extensionPrompts) {
|
|
||||||
// ST's extensionPrompts is actually an object (key-value map), not an array.
|
|
||||||
// Most entries are ST internals — we only want actual vector recall / RAG data.
|
|
||||||
if (!extensionPrompts) return '';
|
|
||||||
|
|
||||||
// Known ST internal keys to skip (handled elsewhere or irrelevant)
|
|
||||||
const skipKeys = new Set([
|
|
||||||
'QUIET_PROMPT', 'PERSONA_DESCRIPTION', 'TEMP_USER_MESSAGE',
|
|
||||||
'DEPTH_PROMPT', '2_floating_prompt', 'main', '__STORY_STRING__',
|
|
||||||
'LWB_varevent_display'
|
|
||||||
]);
|
|
||||||
|
|
||||||
const entries = Array.isArray(extensionPrompts)
|
|
||||||
? extensionPrompts.map((v, i) => [String(i), v])
|
|
||||||
: Object.entries(extensionPrompts);
|
|
||||||
if (!entries.length) return '';
|
|
||||||
|
|
||||||
const lines = [];
|
|
||||||
for (const [key, p] of entries) {
|
|
||||||
if (!p) continue;
|
|
||||||
if (typeof key === 'string' && skipKeys.has(key)) continue;
|
|
||||||
// Skip worldbook depth entries — handled by worldbook block
|
|
||||||
if (typeof key === 'string' && /^customDepthWI/i.test(key)) continue;
|
|
||||||
// Skip 小白X (LittleWhiteBox) compressed chat/memory keys
|
|
||||||
// These start with 'ÿ' (U+00FF) or 'LWB' and contain chat history already handled elsewhere
|
|
||||||
if (typeof key === 'string' && (key.startsWith('ÿ') || key.startsWith('\u00ff') || key.startsWith('LWB'))) continue;
|
|
||||||
// Skip long hex-like keys (worldbook entries injected via ST internal mechanism)
|
|
||||||
if (typeof key === 'string' && /^\u0001/.test(key)) continue;
|
|
||||||
|
|
||||||
// Extract text content — handle string, .value, .content, or nested content array
|
|
||||||
let textContent = '';
|
|
||||||
if (typeof p === 'string') {
|
|
||||||
textContent = p;
|
|
||||||
} else if (typeof p?.value === 'string') {
|
|
||||||
textContent = p.value;
|
|
||||||
} else if (typeof p?.content === 'string') {
|
|
||||||
textContent = p.content;
|
|
||||||
} else if (Array.isArray(p?.content)) {
|
|
||||||
const parts = [];
|
|
||||||
for (const seg of p.content) {
|
|
||||||
if (seg?.type === 'text' && seg?.text) parts.push(seg.text);
|
|
||||||
else if (seg?.type === 'image_url') parts.push('[image_url]');
|
|
||||||
else if (seg?.type === 'video_url') parts.push('[video_url]');
|
|
||||||
}
|
|
||||||
textContent = parts.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = textContent.trim();
|
|
||||||
// Skip short/garbage entries (e.g. "---", empty strings)
|
|
||||||
if (!t || t.length < 30) continue;
|
|
||||||
const role = typeof p?.role === 'number'
|
|
||||||
? ['system', 'user', 'assistant'][p.role] ?? 'system'
|
|
||||||
: (p?.role ?? 'system');
|
|
||||||
lines.push(`[${role}] ${t}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lines.length) return '';
|
|
||||||
return `<vector_recall>\n${lines.join('\n')}\n</vector_recall>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* -------------------------
|
* -------------------------
|
||||||
* Worldbook — read via ST API (like idle-watcher)
|
* 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);
|
const mt = s.api.max_tokens === '' ? null : Number(s.api.max_tokens);
|
||||||
if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt;
|
if (mt != null && !Number.isNaN(mt) && mt > 0) body.max_tokens = mt;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const controller = new AbortController();
|
||||||
method: 'POST',
|
const timeoutId = setTimeout(() => controller.abort(), PLANNER_REQUEST_TIMEOUT_MS);
|
||||||
headers: {
|
try {
|
||||||
...getRequestHeaders(),
|
const res = await fetch(url, {
|
||||||
Authorization: `Bearer ${s.api.apiKey}`,
|
method: 'POST',
|
||||||
'Content-Type': 'application/json'
|
headers: {
|
||||||
},
|
...getRequestHeaders(),
|
||||||
body: JSON.stringify(body)
|
Authorization: `Bearer ${s.api.apiKey}`,
|
||||||
});
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500));
|
throw new Error(`规划请求失败: ${res.status} ${text}`.slice(0, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!s.api.stream) {
|
if (!s.api.stream) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
|
const text = String(data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? '');
|
||||||
if (text) options?.onDelta?.(text, text);
|
if (text) options?.onDelta?.(text, text);
|
||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE stream
|
// SSE stream
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
let buf = '';
|
let buf = '';
|
||||||
let full = '';
|
let full = '';
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { value, done } = await reader.read();
|
const { value, done } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
buf += decoder.decode(value, { stream: true });
|
buf += decoder.decode(value, { stream: true });
|
||||||
const chunks = buf.split('\n\n');
|
const chunks = buf.split('\n\n');
|
||||||
buf = chunks.pop() ?? '';
|
buf = chunks.pop() ?? '';
|
||||||
|
|
||||||
for (const ch of chunks) {
|
for (const ch of chunks) {
|
||||||
const lines = ch.split('\n').map(x => x.trim()).filter(Boolean);
|
const lines = ch.split('\n').map(x => x.trim()).filter(Boolean);
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!line.startsWith('data:')) continue;
|
if (!line.startsWith('data:')) continue;
|
||||||
const payload = line.slice(5).trim();
|
const payload = line.slice(5).trim();
|
||||||
if (payload === '[DONE]') continue;
|
if (payload === '[DONE]') continue;
|
||||||
try {
|
try {
|
||||||
const j = JSON.parse(payload);
|
const j = JSON.parse(payload);
|
||||||
const delta = j?.choices?.[0]?.delta;
|
const delta = j?.choices?.[0]?.delta;
|
||||||
const piece = delta?.content ?? delta?.text ?? '';
|
const piece = delta?.content ?? delta?.text ?? '';
|
||||||
if (piece) {
|
if (piece) {
|
||||||
full += piece;
|
full += piece;
|
||||||
options?.onDelta?.(piece, full);
|
options?.onDelta?.(piece, full);
|
||||||
}
|
}
|
||||||
} catch { }
|
} 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() {
|
async function fetchModelsForUi() {
|
||||||
@@ -1100,7 +1062,6 @@ async function buildPlannerMessages(rawUserInput) {
|
|||||||
const s = ensureSettings();
|
const s = ensureSettings();
|
||||||
const ctx = getContextSafe();
|
const ctx = getContextSafe();
|
||||||
const chat = ctx?.chat ?? window.SillyTavern?.chat ?? [];
|
const chat = ctx?.chat ?? window.SillyTavern?.chat ?? [];
|
||||||
const extPrompts = ctx?.extensionPrompts ?? {};
|
|
||||||
const charObj = getCurrentCharSafe();
|
const charObj = getCurrentCharSafe();
|
||||||
const env = await prepareEjsEnv();
|
const env = await prepareEjsEnv();
|
||||||
const messageVars = getLatestMessageVarTable();
|
const messageVars = getLatestMessageVarTable();
|
||||||
@@ -1111,8 +1072,30 @@ async function buildPlannerMessages(rawUserInput) {
|
|||||||
|
|
||||||
const charBlockRaw = formatCharCardBlock(charObj);
|
const charBlockRaw = formatCharCardBlock(charObj);
|
||||||
|
|
||||||
// --- Story summary (cached from previous generation via interceptor) ---
|
// --- Story memory: try fresh vector recall with current user input ---
|
||||||
const cachedSummary = getCachedStorySummary();
|
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) ---
|
// --- Chat history: last 2 AI messages (floors N-1 & N-3) ---
|
||||||
// Two messages instead of one to avoid cross-device cache miss:
|
// 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 recentChatRaw = collectRecentChatSnippet(chat, 2);
|
||||||
|
|
||||||
const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount));
|
const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount));
|
||||||
const vectorRaw = formatVectorRecallBlock(extPrompts);
|
const vectorRaw = '';
|
||||||
|
|
||||||
// Build scanText for worldbook keyword activation
|
// Build scanText for worldbook keyword activation
|
||||||
const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
|
const scanText = [charBlockRaw, cachedSummary, recentChatRaw, vectorRaw, plotsRaw, rawUserInput].join('\n\n');
|
||||||
@@ -1152,15 +1135,16 @@ async function buildPlannerMessages(rawUserInput) {
|
|||||||
// 3) Worldbook
|
// 3) Worldbook
|
||||||
if (String(worldbook).trim()) messages.push({ role: 'system', content: 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()) {
|
if (storySummary.trim()) {
|
||||||
messages.push({ role: 'system', content: `<story_summary>\n${storySummary}\n</story_summary>` });
|
messages.push({ role: 'system', content: `<story_summary>\n${storySummary}\n</story_summary>` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Chat history (last 2 AI responses — floors N-1 & N-3)
|
// 5) Vector recall — merged into story_summary above, kept for compatibility
|
||||||
if (String(recentChat).trim()) messages.push({ role: 'system', content: recentChat });
|
// (vectorRaw is empty; this block intentionally does nothing)
|
||||||
|
|
||||||
// 5) Vector recall
|
|
||||||
if (String(vector).trim()) messages.push({ role: 'system', content: vector });
|
if (String(vector).trim()) messages.push({ role: 'system', content: vector });
|
||||||
|
|
||||||
// 6) Previous plots
|
// 6) Previous plots
|
||||||
@@ -1266,6 +1250,10 @@ async function doInterceptAndPlanThenSend() {
|
|||||||
|
|
||||||
state.bypassNextSend = true;
|
state.bypassNextSend = true;
|
||||||
btn.click();
|
btn.click();
|
||||||
|
} catch (err) {
|
||||||
|
ta.value = raw;
|
||||||
|
state.lastInjectedText = '';
|
||||||
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
state.isPlanning = false;
|
state.isPlanning = false;
|
||||||
setSendUIBusy(false);
|
setSendUIBusy(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user