From 28616fc1770c758c1b9ff12838159874965f8fb9 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Thu, 26 Mar 2026 23:15:35 +0800 Subject: [PATCH] feat: add runtime debug snapshots and injection planning --- compressor.js | 30 ++-- embedding.js | 20 +++ index.js | 102 +++++++++++--- llm.js | 58 ++++++++ panel.js | 292 ++++++++++++++++++++++++++++++++++++++- prompt-builder.js | 119 +++++++++++++++- runtime-debug.js | 94 +++++++++++++ style.css | 116 +++++++++++++++- tests/p0-regressions.mjs | 262 +++++++++++++++++++++++++++-------- tests/task-worldinfo.mjs | 10 ++ 10 files changed, 1011 insertions(+), 92 deletions(-) create mode 100644 runtime-debug.js diff --git a/compressor.js b/compressor.js index bff24b0..dd97282 100644 --- a/compressor.js +++ b/compressor.js @@ -51,12 +51,15 @@ export async function compressType({ if (!compression || compression.mode !== "hierarchical") { return { created: 0, archived: 0 }; } + const maxDepth = Number.isFinite(Number(compression.maxDepth)) + ? Math.max(1, Number(compression.maxDepth)) + : 1; let totalCreated = 0; let totalArchived = 0; // 从最低层级开始逐层压缩 - for (let level = 0; level < compression.maxDepth; level++) { + for (let level = 0; level < maxDepth; level++) { throwIfAborted(signal); const result = await compressLevel({ graph, @@ -93,6 +96,9 @@ async function compressLevel({ settings = {}, }) { const compression = typeDef.compression; + const fanIn = Number.isFinite(Number(compression.fanIn)) + ? Math.max(2, Number(compression.fanIn)) + : 2; throwIfAborted(signal); // 获取该层级的活跃叶子节点 @@ -101,18 +107,24 @@ async function compressLevel({ .sort((a, b) => a.seq - b.seq); const threshold = force - ? Math.max(2, compression.fanIn) - : compression.threshold; - const keepRecent = force ? 0 : compression.keepRecentLeaves; + ? fanIn + : Number.isFinite(Number(compression.threshold)) + ? Math.max(2, Number(compression.threshold)) + : fanIn; + const keepRecent = force + ? 0 + : Number.isFinite(Number(compression.keepRecentLeaves)) + ? Math.max(0, Number(compression.keepRecentLeaves)) + : 0; - // 不够阈值,无需压缩 - if (levelNodes.length <= threshold) { + // 不够阈值,无需压缩;强制压缩时只要求满足 fanIn + if (force ? levelNodes.length < fanIn : levelNodes.length <= threshold) { return { created: 0, archived: 0 }; } // 排除最近的节点 const compressible = levelNodes.slice(0, levelNodes.length - keepRecent); - if (compressible.length < compression.fanIn) { + if (compressible.length < fanIn) { return { created: 0, archived: 0 }; } @@ -120,8 +132,8 @@ async function compressLevel({ let archived = 0; // 按 fanIn 分组压缩 - for (let i = 0; i < compressible.length; i += compression.fanIn) { - const batch = compressible.slice(i, i + compression.fanIn); + for (let i = 0; i < compressible.length; i += fanIn) { + const batch = compressible.slice(i, i + fanIn); if (batch.length < 2) break; // 至少 2 个才压缩 // 调用 LLM 总结 diff --git a/embedding.js b/embedding.js index 2a1a6a3..38ebac1 100644 --- a/embedding.js +++ b/embedding.js @@ -11,6 +11,11 @@ import { extension_settings } from "../../../extensions.js"; const MODULE_NAME = "st_bme"; const EMBEDDING_REQUEST_TIMEOUT_MS = 300000; +function getEmbeddingTestOverride(name) { + const override = globalThis.__stBmeTestOverrides?.embedding?.[name]; + return typeof override === "function" ? override : null; +} + function getConfiguredTimeoutMs( settings = extension_settings[MODULE_NAME] || {}, ) { @@ -98,6 +103,11 @@ async function fetchWithTimeout( * @returns {Promise} 向量或 null */ export async function embedText(text, config, { signal } = {}) { + const override = getEmbeddingTestOverride("embedText"); + if (override) { + return await override(text, config, { signal }); + } + const apiUrl = normalizeOpenAICompatibleBaseUrl(config?.apiUrl); if (!text || !apiUrl || !config?.model) { console.warn("[ST-BME] Embedding 配置不完整,跳过"); @@ -159,6 +169,11 @@ export async function embedText(text, config, { signal } = {}) { * @returns {Promise<(Float64Array|null)[]>} */ export async function embedBatch(texts, config, { signal } = {}) { + const override = getEmbeddingTestOverride("embedBatch"); + if (override) { + return await override(texts, config, { signal }); + } + const apiUrl = normalizeOpenAICompatibleBaseUrl(config?.apiUrl); if (!texts.length || !apiUrl || !config?.model) { return texts.map(() => null); @@ -256,6 +271,11 @@ export function cosineSimilarity(vecA, vecB) { * @returns {Array<{nodeId: string, score: number}>} 按相似度降序 */ export function searchSimilar(queryVec, candidates, topK = 20) { + const override = getEmbeddingTestOverride("searchSimilar"); + if (override) { + return override(queryVec, candidates, topK); + } + if (!queryVec || candidates.length === 0) return []; const scored = candidates diff --git a/index.js b/index.js index 7712c28..aaddbf5 100644 --- a/index.js +++ b/index.js @@ -60,6 +60,11 @@ import { rollbackBatch, snapshotProcessedMessageHashes, } from "./runtime-state.js"; +import { + getRuntimeDebugSnapshot as readRuntimeDebugSnapshot, + recordHostCapabilitySnapshot, + recordInjectionSnapshot, +} from "./runtime-debug.js"; import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js"; import { deleteBackendVectorHashesForRecovery, @@ -671,7 +676,7 @@ function initializeHostCapabilityBridge(options = {}) { } function buildHostCapabilityErrorStatus(error) { - return { + const snapshot = { available: false, mode: "error", fallbackReason: @@ -685,6 +690,8 @@ function buildHostCapabilityErrorStatus(error) { snapshotRevision: -1, snapshotCreatedAt: "", }; + recordHostCapabilitySnapshot(snapshot); + return snapshot; } export function getHostCapabilityStatus(options = {}) { @@ -695,9 +702,11 @@ export function getHostCapabilityStatus(options = {}) { delete normalizedOptions.refresh; try { - return shouldRefresh + const snapshot = shouldRefresh ? refreshHostCapabilitySnapshot(normalizedOptions) : getHostCapabilitySnapshot(); + recordHostCapabilitySnapshot(snapshot); + return snapshot; } catch (error) { console.warn("[ST-BME] 读取宿主桥接状态失败:", error); return buildHostCapabilityErrorStatus(error); @@ -723,6 +732,18 @@ export function getHostCapability(name, options = {}) { } } +export function getPanelRuntimeDebugSnapshot(options = {}) { + const shouldRefreshHost = options?.refreshHost === true; + const hostCapabilities = shouldRefreshHost + ? refreshHostCapabilityStatus() + : getHostCapabilityStatus(); + + return { + hostCapabilities, + runtimeDebug: readRuntimeDebugSnapshot(), + }; +} + function getSchema() { const settings = getSettings(); const schema = settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA; @@ -857,6 +878,17 @@ function clearInjectionState() { lastRecalledItems = []; lastRecallStatus = createUiStatus("待命", "当前无有效注入内容", "idle"); runtimeStatus = createUiStatus("待命", "当前无有效注入内容", "idle"); + recordInjectionSnapshot("recall", { + injectionText: "", + selectedNodeIds: [], + retrievalMeta: {}, + llmMeta: {}, + transport: { + applied: false, + source: "cleared", + mode: "cleared", + }, + }); if (!isRecalling) { dismissStageNotice("recall"); } @@ -1773,25 +1805,31 @@ function buildGenerationAfterCommandsRecallInput(type, params = {}, chat) { return null; } - if (generationType === "normal") { - const lastNonSystemMessage = getLastNonSystemChatMessage(chat); - const tailUserText = lastNonSystemMessage?.is_user - ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") - : ""; - const textareaText = normalizeRecallInputText( - pendingRecallSendIntent.text || getSendTextareaValue(), - ); - const userMessage = tailUserText || textareaText; - if (!userMessage) return null; + return generationType === "normal" + ? buildNormalGenerationRecallInput(chat) + : buildHistoryGenerationRecallInput(chat); +} - return { - overrideUserMessage: userMessage, - overrideSource: tailUserText ? "chat-tail-user" : "send-intent", - overrideSourceLabel: tailUserText ? "当前用户楼层" : "发送意图", - includeSyntheticUserMessage: !tailUserText, - }; - } +function buildNormalGenerationRecallInput(chat) { + const lastNonSystemMessage = getLastNonSystemChatMessage(chat); + const tailUserText = lastNonSystemMessage?.is_user + ? normalizeRecallInputText(lastNonSystemMessage?.mes || "") + : ""; + const textareaText = normalizeRecallInputText( + pendingRecallSendIntent.text || getSendTextareaValue(), + ); + const userMessage = tailUserText || textareaText; + if (!userMessage) return null; + return { + overrideUserMessage: userMessage, + overrideSource: tailUserText ? "chat-tail-user" : "send-intent", + overrideSourceLabel: tailUserText ? "当前用户楼层" : "发送意图", + includeSyntheticUserMessage: !tailUserText, + }; +} + +function buildHistoryGenerationRecallInput(chat) { const latestUserText = normalizeRecallInputText( getLatestUserChatMessage(chat)?.mes || lastRecallSentUserMessage.text, ); @@ -3179,7 +3217,20 @@ function applyRecallInjection(settings, recallInput, recentMessages, result) { ); } - applyModuleInjectionPrompt(injectionText, settings); + const injectionTransport = applyModuleInjectionPrompt(injectionText, settings); + recordInjectionSnapshot("recall", { + taskType: "recall", + source: recallInput.source, + sourceLabel: recallInput.sourceLabel, + hookName: recallInput.hookName, + recentMessages, + selectedNodeIds: result.selectedNodeIds || [], + retrievalMeta, + llmMeta, + stats: result.stats || {}, + injectionText, + transport: injectionTransport, + }); currentGraph.lastRecallResult = result.selectedNodeIds; updateLastRecalledItems(result.selectedNodeIds || []); @@ -3455,10 +3506,16 @@ async function onGenerationAfterCommands(type, params = {}, dryRun = false) { } async function onBeforeCombinePrompts() { + const context = getContext(); + const chat = context?.chat; + const recallOptions = + buildNormalGenerationRecallInput(chat) || + buildHistoryGenerationRecallInput(chat) || + {}; const recallContext = createGenerationRecallContext({ hookName: "GENERATE_BEFORE_COMBINE_PROMPTS", generationType: "normal", - recallOptions: {}, + recallOptions, }); if (!recallContext.shouldRun) { return; @@ -3470,6 +3527,7 @@ async function onBeforeCombinePrompts() { "running", ); const didRecall = await runRecall({ + ...recallOptions, recallKey: recallContext.recallKey, hookName: recallContext.hookName, }); @@ -4107,6 +4165,8 @@ async function onReembedDirect() { getLastBatchStatus: () => currentGraph?.historyState?.lastBatchStatus || null, getLastInjection: () => lastInjectionContent, + getRuntimeDebugSnapshot: (options = {}) => + getPanelRuntimeDebugSnapshot(options), updateSettings: (patch) => { const settings = updateModuleSettings(patch); if (Object.prototype.hasOwnProperty.call(patch, "panelTheme")) { diff --git a/llm.js b/llm.js index be758ce..f584729 100644 --- a/llm.js +++ b/llm.js @@ -5,6 +5,7 @@ import { getRequestHeaders } from "../../../../script.js"; import { extension_settings } from "../../../extensions.js"; import { chat_completion_sources, sendOpenAIRequest } from "../../../openai.js"; import { resolveTaskGenerationOptions } from "./generation-options.js"; +import { recordTaskLlmRequest } from "./runtime-debug.js"; const MODULE_NAME = "st_bme"; const LLM_REQUEST_TIMEOUT_MS = 300000; @@ -12,6 +13,11 @@ const DEFAULT_TEXT_COMPLETION_TOKENS = 64000; const DEFAULT_JSON_COMPLETION_TOKENS = 64000; const RETRY_JSON_COMPLETION_TOKENS = 3200; +function getLlmTestOverride(name) { + const override = globalThis.__stBmeTestOverrides?.llm?.[name]; + return typeof override === "function" ? override : null; +} + function getMemoryLLMConfig() { const settings = extension_settings[MODULE_NAME] || {}; return { @@ -364,6 +370,23 @@ async function callDedicatedOpenAICompatible( filtered: {}, removed: [], }; + recordTaskLlmRequest(taskType || privateRequestSource, { + requestSource: privateRequestSource, + taskType: String(taskType || "").trim(), + jsonMode, + dedicatedConfig: hasDedicatedConfig, + route: hasDedicatedConfig + ? "dedicated-openai-compatible" + : "sillytavern-current-model", + model: hasDedicatedConfig ? config.model : "sillytavern-current-model", + apiUrl: hasDedicatedConfig ? config.apiUrl : "", + messages, + generation: generationResolved.generation || {}, + filteredGeneration: generationResolved.filtered || {}, + removedGeneration: generationResolved.removed || [], + capabilityMode: generationResolved.capabilityMode || "", + maxCompletionTokens, + }); if (!hasDedicatedConfig) { const payload = await sendOpenAIRequest( "quiet", @@ -446,6 +469,23 @@ async function callDedicatedOpenAICompatible( }); } + recordTaskLlmRequest(taskType || privateRequestSource, { + requestSource: privateRequestSource, + taskType: String(taskType || "").trim(), + jsonMode, + dedicatedConfig: true, + route: "dedicated-openai-compatible", + model: config.model, + apiUrl: config.apiUrl, + messages, + generation: generationResolved.generation || {}, + filteredGeneration, + removedGeneration: generationResolved.removed || [], + capabilityMode: generationResolved.capabilityMode || "", + resolvedCompletionTokens, + requestBody: body, + }); + const response = await fetchWithTimeout( "/api/backends/chat-completions/generate", { @@ -528,6 +568,19 @@ export async function callLLMForJSON({ requestSource = "", additionalMessages = [], } = {}) { + const override = getLlmTestOverride("callLLMForJSON"); + if (override) { + return await override({ + systemPrompt, + userPrompt, + maxRetries, + signal, + taskType, + requestSource, + additionalMessages, + }); + } + const privateRequestSource = resolvePrivateRequestSource( taskType, requestSource, @@ -597,6 +650,11 @@ export async function callLLMForJSON({ * @returns {Promise} */ export async function callLLM(systemPrompt, userPrompt, options = {}) { + const override = getLlmTestOverride("callLLM"); + if (override) { + return await override(systemPrompt, userPrompt, options); + } + const messages = [ { role: "system", content: systemPrompt }, { role: "user", content: userPrompt }, diff --git a/panel.js b/panel.js index 318ee91..f465b2b 100644 --- a/panel.js +++ b/panel.js @@ -37,6 +37,7 @@ const TASK_PROFILE_TABS = [ { id: "generation", label: "生成参数" }, { id: "prompt", label: "Prompt 编排" }, { id: "regex", label: "正则" }, + { id: "debug", label: "调试预览" }, ]; const TASK_PROFILE_ROLE_OPTIONS = [ @@ -136,6 +137,7 @@ let _getLastExtractionStatus = null; let _getLastVectorStatus = null; let _getLastRecallStatus = null; let _getLastInjection = null; +let _getRuntimeDebugSnapshot = null; let _updateSettings = null; let _actionHandlers = {}; @@ -162,6 +164,7 @@ export async function initPanel({ getLastVectorStatus, getLastRecallStatus, getLastInjection, + getRuntimeDebugSnapshot, updateSettings, actions, }) { @@ -174,6 +177,7 @@ export async function initPanel({ _getLastVectorStatus = getLastVectorStatus; _getLastRecallStatus = getLastRecallStatus; _getLastInjection = getLastInjection; + _getRuntimeDebugSnapshot = getRuntimeDebugSnapshot; _updateSettings = updateSettings; _actionHandlers = actions || {}; @@ -275,6 +279,14 @@ export function refreshLiveState() { break; } + if ( + currentTabId === "config" && + currentConfigSectionId === "prompts" && + currentTaskProfileTabId === "debug" + ) { + _refreshTaskProfileWorkspace(); + } + _refreshGraph(); } @@ -1561,6 +1573,10 @@ function _handleTaskProfileWorkspaceChange(event) { function _getTaskProfileWorkspaceState(settings = _getSettings?.() || {}) { const taskProfiles = ensureTaskProfiles(settings); const taskTypeOptions = getTaskTypeOptions(); + const runtimeDebug = _getRuntimeDebugSnapshot?.() || { + hostCapabilities: null, + runtimeDebug: null, + }; if (!taskTypeOptions.some((item) => item.id === currentTaskProfileTaskType)) { currentTaskProfileTaskType = taskTypeOptions[0]?.id || "extract"; @@ -1605,6 +1621,7 @@ function _getTaskProfileWorkspaceState(settings = _getSettings?.() || {}) { selectedRule: regexRules.find((rule) => rule.id === currentTaskProfileRuleId) || null, builtinBlockDefinitions: getBuiltinBlockDefinitions(), + runtimeDebug, }; } @@ -1651,6 +1668,12 @@ async function _handleTaskProfileWorkspaceClick(event) { actionEl.dataset.taskTab || currentTaskProfileTabId; _refreshTaskProfileWorkspace(); return; + case "refresh-task-debug": + if (typeof _getRuntimeDebugSnapshot === "function") { + _getRuntimeDebugSnapshot({ refreshHost: true }); + } + _refreshTaskProfileWorkspace(); + return; case "select-block": currentTaskProfileBlockId = actionEl.dataset.blockId || ""; _refreshTaskProfileWorkspace(); @@ -1911,7 +1934,9 @@ function _renderTaskProfileWorkspace(state) { ? _renderTaskGenerationTab(state) : state.taskTabId === "regex" ? _renderTaskRegexTab(state) - : _renderTaskPromptTab(state) + : state.taskTabId === "debug" + ? _renderTaskDebugTab(state) + : _renderTaskPromptTab(state) } @@ -2126,6 +2151,271 @@ function _renderTaskRegexTab(state) { `; } +function _renderTaskDebugTab(state) { + const hostCapabilities = state.runtimeDebug?.hostCapabilities || null; + const runtimeDebug = state.runtimeDebug?.runtimeDebug || {}; + const promptBuild = runtimeDebug?.taskPromptBuilds?.[state.taskType] || null; + const llmRequest = runtimeDebug?.taskLlmRequests?.[state.taskType] || null; + const recallInjection = runtimeDebug?.injections?.recall || null; + + return ` +
+
+
+ 这里展示的是最近一次真实运行留下的调试快照,不是静态配置推演。没有数据时,先跑一次对应任务即可。 +
+ +
+ +
+
+ ${_renderTaskDebugHostCard(hostCapabilities)} +
+
+ ${_renderTaskDebugPromptCard(state.taskType, promptBuild)} +
+
+ ${_renderTaskDebugLlmCard(state.taskType, llmRequest)} +
+
+ ${_renderTaskDebugInjectionCard(recallInjection)} +
+
+
+ `; +} + +function _renderTaskDebugHostCard(hostCapabilities) { + if (!hostCapabilities) { + return ` +
宿主桥接状态
+
当前还没有宿主桥接快照。
+ `; + } + + const capabilityNames = ["context", "worldbook", "regex", "injection"]; + return ` +
+
+
宿主桥接状态
+
+ 当前插件和 SillyTavern 的接轨情况。 +
+
+ + ${hostCapabilities.mode || (hostCapabilities.available ? "available" : "unavailable")} + +
+
+
+ 总状态 + ${_escHtml(hostCapabilities.available ? "可用" : "不可用")} +
+
+ 说明 + ${_escHtml(hostCapabilities.fallbackReason || "无")} +
+
+ 快照版本 + ${_escHtml(String(hostCapabilities.snapshotRevision ?? "—"))} +
+
+ 快照时间 + ${_escHtml(_formatTaskProfileTime(hostCapabilities.snapshotCreatedAt))} +
+
+ +
+ ${capabilityNames + .map((name) => { + const capability = hostCapabilities[name] || {}; + return ` +
+
+ ${_escHtml(name)} + + ${_escHtml(capability.mode || (capability.available ? "available" : "unavailable"))} + +
+
+ ${_escHtml(capability.fallbackReason || "无")} +
+
+ `; + }) + .join("")} +
+ `; +} + +function _renderTaskDebugPromptCard(taskType, promptBuild) { + if (!promptBuild) { + return ` +
最近 Prompt 组装
+
当前任务还没有最近一次 prompt 组装快照。
+ `; + } + + return ` +
+
+
最近 Prompt 组装
+
+ 任务 ${_escHtml(taskType)} 最近一次真实编排结果。 +
+
+ ${_escHtml(_formatTaskProfileTime(promptBuild.updatedAt))} +
+
+
+ 预设 + ${_escHtml(promptBuild.profileName || promptBuild.profileId || "—")} +
+
+ 块数量 + ${_escHtml(String(promptBuild.debug?.renderedBlockCount ?? promptBuild.renderedBlocks?.length ?? 0))} +
+
+ 宿主注入 + ${_escHtml(String(promptBuild.debug?.hostInjectionPlanCount ?? promptBuild.debug?.hostInjectionCount ?? 0))} +
+
+ 私有消息 + ${_escHtml(String(promptBuild.debug?.privateTaskMessageCount ?? promptBuild.privateTaskMessages?.length ?? 0))} +
+
+ ${_renderDebugDetails("渲染后的块", promptBuild.renderedBlocks)} + ${_renderDebugDetails("宿主注入计划", promptBuild.hostInjectionPlan || null)} + ${_renderDebugDetails("宿主注入描述", promptBuild.hostInjections)} + ${_renderDebugDetails("私有任务消息", promptBuild.privateTaskMessages)} + ${_renderDebugDetails("系统提示词", promptBuild.systemPrompt || "")} + `; +} + +function _renderTaskDebugLlmCard(taskType, llmRequest) { + if (!llmRequest) { + return ` +
最近实际下发参数
+
当前任务还没有最近一次 LLM 请求快照。
+ `; + } + + return ` +
+
+
最近实际下发参数
+
+ 任务 ${_escHtml(taskType)} 最近一次走私有请求层时的实际发送信息。 +
+
+ ${_escHtml(_formatTaskProfileTime(llmRequest.updatedAt))} +
+
+
+ 请求来源 + ${_escHtml(llmRequest.requestSource || "—")} +
+
+ 请求路径 + ${_escHtml(llmRequest.route || "—")} +
+
+ 模型 + ${_escHtml(llmRequest.model || "—")} +
+
+ 能力过滤模式 + ${_escHtml(llmRequest.capabilityMode || "—")} +
+
+ ${_renderDebugDetails("实际保留参数", llmRequest.filteredGeneration || {})} + ${_renderDebugDetails("被过滤掉的参数", llmRequest.removedGeneration || [])} + ${_renderDebugDetails("最终消息列表", llmRequest.messages || [])} + ${_renderDebugDetails("最终请求体", llmRequest.requestBody || null)} + `; +} + +function _renderTaskDebugInjectionCard(injectionSnapshot) { + if (!injectionSnapshot) { + return ` +
最近注入结果
+
还没有最近一次召回注入快照。
+ `; + } + + return ` +
+
+
最近注入结果
+
+ 展示最近一次召回后的注入文本和宿主投递方式。 +
+
+ ${_escHtml(_formatTaskProfileTime(injectionSnapshot.updatedAt))} +
+
+
+ 来源 + ${_escHtml(injectionSnapshot.sourceLabel || injectionSnapshot.source || "—")} +
+
+ 触发钩子 + ${_escHtml(injectionSnapshot.hookName || "—")} +
+
+ 选中节点数 + ${_escHtml(String(injectionSnapshot.selectedNodeIds?.length ?? 0))} +
+
+ 宿主投递 + ${_escHtml(injectionSnapshot.transport?.source || "—")} / ${_escHtml(injectionSnapshot.transport?.mode || "—")} +
+
+ ${_renderDebugDetails("召回统计", { + retrievalMeta: injectionSnapshot.retrievalMeta || {}, + llmMeta: injectionSnapshot.llmMeta || {}, + stats: injectionSnapshot.stats || {}, + transport: injectionSnapshot.transport || {}, + })} + ${_renderDebugDetails("最终注入文本", injectionSnapshot.injectionText || "")} + `; +} + +function _renderDebugDetails(title, value) { + const isEmptyArray = Array.isArray(value) && value.length === 0; + const isEmptyObject = + value && + typeof value === "object" && + !Array.isArray(value) && + Object.keys(value).length === 0; + const isEmpty = value == null || value === "" || isEmptyArray || isEmptyObject; + + return ` +
+ ${_escHtml(title)} + ${ + isEmpty + ? '
暂无内容
' + : `
${_escHtml(_stringifyDebugValue(value))}
` + } +
+ `; +} + +function _stringifyDebugValue(value) { + if (typeof value === "string") { + return value; + } + + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + function _renderTaskBlockListItem(block, index, state) { const isSelected = block.id === state.selectedBlock?.id; return ` diff --git a/prompt-builder.js b/prompt-builder.js index 52eafe7..8e8dede 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -2,6 +2,7 @@ // 统一负责任务预设块排序、变量渲染,以及世界书/EJS 上下文接入。 import { getActiveTaskProfile, getLegacyPromptForTask } from "./prompt-profiles.js"; +import { recordTaskPromptBuild } from "./runtime-debug.js"; import { resolveTaskWorldInfo } from "./task-worldinfo.js"; const WORLD_INFO_VARIABLE_KEYS = [ @@ -122,6 +123,98 @@ function buildWorldInfoResolution(worldInfoContext = {}) { }; } +function sortInjectionEntries(entries = []) { + return [...entries].sort((left, right) => { + const orderLeft = Number.isFinite(Number(left?.order)) + ? Number(left.order) + : 0; + const orderRight = Number.isFinite(Number(right?.order)) + ? Number(right.order) + : 0; + return orderLeft - orderRight; + }); +} + +function createHostInjectionPlanEntry(block = {}, position, extra = {}) { + return { + source: "block", + origin: "profile-block", + position, + role: normalizeRole(block.role), + content: String(block.content || "").trim(), + blockId: String(block.id || ""), + blockName: String(block.name || ""), + sourceKey: String(block.sourceKey || ""), + injectionMode: normalizeInjectionMode(block.injectionMode), + order: Number.isFinite(Number(block.order)) ? Number(block.order) : 0, + ...extra, + }; +} + +function buildHostInjectionPlan(renderedBlocks = [], worldInfoResolution = {}) { + const beforeEntryNames = ( + Array.isArray(worldInfoResolution.beforeEntries) + ? worldInfoResolution.beforeEntries + : [] + ) + .map((entry) => String(entry?.name || entry?.sourceName || "").trim()) + .filter(Boolean); + const afterEntryNames = ( + Array.isArray(worldInfoResolution.afterEntries) + ? worldInfoResolution.afterEntries + : [] + ) + .map((entry) => String(entry?.name || entry?.sourceName || "").trim()) + .filter(Boolean); + const atDepthEntries = Array.isArray(worldInfoResolution.injections?.atDepth) + ? worldInfoResolution.injections.atDepth + : []; + + const plan = { + before: [], + after: [], + atDepth: [], + }; + + for (const block of renderedBlocks) { + if (!block?.content) continue; + + if (block.delivery === "host.before") { + plan.before.push( + createHostInjectionPlanEntry(block, "before", { + entryNames: beforeEntryNames, + entryCount: beforeEntryNames.length, + }), + ); + continue; + } + + if (block.delivery === "host.after") { + plan.after.push( + createHostInjectionPlanEntry(block, "after", { + entryNames: afterEntryNames, + entryCount: afterEntryNames.length, + }), + ); + } + } + + for (const entry of atDepthEntries) { + if (!entry?.content) continue; + plan.atDepth.push({ + ...entry, + origin: "worldInfo-entry", + entryName: String(entry.name || entry.sourceName || "").trim(), + }); + } + + return { + before: sortInjectionEntries(plan.before), + after: sortInjectionEntries(plan.after), + atDepth: sortInjectionEntries(plan.atDepth), + }; +} + function resolveBlockDelivery(block = {}) { if ( block.type === "builtin" && @@ -281,10 +374,15 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { ...customMessages, ...worldInfoResolution.additionalMessages, ]; + const hostInjectionPlan = buildHostInjectionPlan( + renderedBlocks, + worldInfoResolution, + ); - return { + const result = { profile, hostInjections: worldInfoResolution.injections, + hostInjectionPlan, privateTaskPrompt: { systemPrompt, messages: privateTaskMessages, @@ -318,11 +416,30 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { worldInfoResolution.injections.before.length + worldInfoResolution.injections.after.length + worldInfoResolution.injections.atDepth.length, + hostInjectionPlanCount: + hostInjectionPlan.before.length + + hostInjectionPlan.after.length + + hostInjectionPlan.atDepth.length, customMessageCount: customMessages.length, additionalMessageCount: worldInfoResolution.additionalMessages.length, privateTaskMessageCount: privateTaskMessages.length, }, }; + + recordTaskPromptBuild(taskType, { + taskType, + profileId: profile?.id || "", + profileName: profile?.name || "", + systemPrompt, + privateTaskMessages, + renderedBlocks, + hostInjections: worldInfoResolution.injections, + hostInjectionPlan, + worldInfoResolution, + debug: result.debug, + }); + + return result; } export function interpolateVariables(template, context = {}) { diff --git a/runtime-debug.js b/runtime-debug.js new file mode 100644 index 0000000..f2f8437 --- /dev/null +++ b/runtime-debug.js @@ -0,0 +1,94 @@ +function safeClone(value, fallback = null) { + if (value == null) { + return fallback; + } + + try { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + } catch { + // ignore and fall through + } + + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return fallback ?? value; + } +} + +function nowIso() { + return new Date().toISOString(); +} + +const runtimeDebugState = { + hostCapabilities: null, + taskPromptBuilds: {}, + taskLlmRequests: {}, + injections: {}, + updatedAt: "", +}; + +function touchRuntimeDebugState() { + runtimeDebugState.updatedAt = nowIso(); +} + +export function resetRuntimeDebugSnapshot() { + runtimeDebugState.hostCapabilities = null; + runtimeDebugState.taskPromptBuilds = {}; + runtimeDebugState.taskLlmRequests = {}; + runtimeDebugState.injections = {}; + runtimeDebugState.updatedAt = nowIso(); +} + +export function recordHostCapabilitySnapshot(snapshot = null) { + runtimeDebugState.hostCapabilities = safeClone(snapshot, null); + touchRuntimeDebugState(); +} + +export function recordTaskPromptBuild(taskType, snapshot = {}) { + const normalizedTaskType = String(taskType || "").trim() || "unknown"; + runtimeDebugState.taskPromptBuilds[normalizedTaskType] = { + updatedAt: nowIso(), + ...safeClone(snapshot, {}), + }; + touchRuntimeDebugState(); +} + +export function recordTaskLlmRequest(taskType, snapshot = {}) { + const normalizedTaskType = String(taskType || "").trim() || "unknown"; + runtimeDebugState.taskLlmRequests[normalizedTaskType] = { + updatedAt: nowIso(), + ...safeClone(snapshot, {}), + }; + touchRuntimeDebugState(); +} + +export function recordInjectionSnapshot(kind, snapshot = {}) { + const normalizedKind = String(kind || "").trim() || "default"; + runtimeDebugState.injections[normalizedKind] = { + updatedAt: nowIso(), + ...safeClone(snapshot, {}), + }; + touchRuntimeDebugState(); +} + +export function getRuntimeDebugSnapshot() { + return safeClone( + { + hostCapabilities: runtimeDebugState.hostCapabilities, + taskPromptBuilds: runtimeDebugState.taskPromptBuilds, + taskLlmRequests: runtimeDebugState.taskLlmRequests, + injections: runtimeDebugState.injections, + updatedAt: runtimeDebugState.updatedAt, + }, + { + hostCapabilities: null, + taskPromptBuilds: {}, + taskLlmRequests: {}, + injections: {}, + updatedAt: "", + }, + ); +} diff --git a/style.css b/style.css index acbd031..c84840d 100644 --- a/style.css +++ b/style.css @@ -1669,6 +1669,114 @@ min-height: 160px; } +.bme-task-debug-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.bme-debug-kv-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bme-debug-kv-item { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 10px; + align-items: start; +} + +.bme-debug-kv-key { + font-size: 11px; + color: var(--bme-on-surface-dim); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.bme-debug-kv-value { + font-size: 12px; + line-height: 1.5; + color: var(--bme-on-surface); + word-break: break-word; +} + +.bme-debug-capability-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.bme-debug-capability-item { + padding: 12px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.bme-debug-capability-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 6px; +} + +.bme-debug-capability-title { + font-size: 13px; + font-weight: 700; + color: var(--bme-on-surface); +} + +.bme-debug-capability-desc { + font-size: 12px; + line-height: 1.5; + color: var(--bme-on-surface-dim); +} + +.bme-debug-details { + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.bme-debug-details summary { + cursor: pointer; + padding: 12px 14px; + font-size: 12px; + font-weight: 700; + color: var(--bme-on-surface); + list-style: none; +} + +.bme-debug-details summary::-webkit-details-marker { + display: none; +} + +.bme-debug-pre { + margin: 0; + padding: 0 14px 14px; + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.55; + color: var(--bme-on-surface); + font-family: + "JetBrains Mono", + "Cascadia Code", + "Fira Code", + Consolas, + monospace; +} + +.bme-debug-empty { + padding: 0 14px 14px; + font-size: 12px; + color: var(--bme-on-surface-dim); +} + .bme-theme-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1932,10 +2040,16 @@ .bme-theme-card-grid, .bme-task-field-grid, .bme-task-editor-grid, - .bme-task-regex-top { + .bme-task-regex-top, + .bme-task-debug-grid { grid-template-columns: 1fr; } + .bme-debug-kv-item { + grid-template-columns: 1fr; + gap: 4px; + } + .bme-config-card-head, .bme-prompt-card-head { flex-direction: column; diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 8f0360f..130ce7a 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -1,11 +1,73 @@ import assert from "node:assert/strict"; import fs from "node:fs/promises"; -import { createRequire } from "node:module"; +import { createRequire, registerHooks } from "node:module"; import path from "node:path"; import vm from "node:vm"; +const extensionsShimSource = [ + "export const extension_settings = globalThis.__p0ExtensionSettings || {};", + "export function getContext(...args) {", + " return globalThis.SillyTavern?.getContext?.(...args) || null;", + "}", +].join("\n"); +const scriptShimSource = [ + "export function getRequestHeaders() {", + " return { 'Content-Type': 'application/json' };", + "}", +].join("\n"); +const openAiShimSource = [ + "export const chat_completion_sources = { CUSTOM: 'custom', OPENAI: 'openai' };", + "export async function sendOpenAIRequest(...args) {", + " if (typeof globalThis.__p0SendOpenAIRequest === 'function') {", + " return await globalThis.__p0SendOpenAIRequest(...args);", + " }", + " return { choices: [{ message: { content: '{}' } }] };", + "}", +].join("\n"); + +const extensionsShimUrl = `data:text/javascript,${encodeURIComponent( + extensionsShimSource, +)}`; +const scriptShimUrl = `data:text/javascript,${encodeURIComponent( + scriptShimSource, +)}`; +const openAiShimUrl = `data:text/javascript,${encodeURIComponent( + openAiShimSource, +)}`; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "../../../extensions.js") { + return { + shortCircuit: true, + url: extensionsShimUrl, + }; + } + if (specifier === "../../../../script.js") { + return { + shortCircuit: true, + url: scriptShimUrl, + }; + } + if (specifier === "../../../openai.js") { + return { + shortCircuit: true, + url: openAiShimUrl, + }; + } + return nextResolve(specifier, context); + }, +}); + const require = createRequire(import.meta.url); const originalRequire = globalThis.require; +const originalP0ExtensionSettings = globalThis.__p0ExtensionSettings; +const originalP0SendOpenAIRequest = globalThis.__p0SendOpenAIRequest; +const originalStBmeTestOverrides = globalThis.__stBmeTestOverrides; +globalThis.__p0ExtensionSettings = { + st_bme: {}, +}; +globalThis.__stBmeTestOverrides = {}; globalThis.require = require; const { createEmptyGraph, createNode, addNode, createEdge, addEdge } = @@ -29,6 +91,24 @@ if (originalRequire === undefined) { globalThis.require = originalRequire; } +if (originalP0ExtensionSettings === undefined) { + delete globalThis.__p0ExtensionSettings; +} else { + globalThis.__p0ExtensionSettings = originalP0ExtensionSettings; +} + +if (originalP0SendOpenAIRequest === undefined) { + delete globalThis.__p0SendOpenAIRequest; +} else { + globalThis.__p0SendOpenAIRequest = originalP0SendOpenAIRequest; +} + +if (originalStBmeTestOverrides === undefined) { + delete globalThis.__stBmeTestOverrides; +} else { + globalThis.__stBmeTestOverrides = originalStBmeTestOverrides; +} + const schema = [ { id: "event", @@ -56,12 +136,12 @@ function createBatchStageHarness() { const indexPath = path.resolve("./index.js"); return fs.readFile(indexPath, "utf8").then((source) => { const marker = "function isAssistantChatMessage(message) {"; - const start = source.indexOf("const BATCH_STAGE_ORDER ="); + const start = source.indexOf("function shouldAdvanceProcessedHistory("); const end = source.indexOf(marker); if (start < 0 || end < 0 || end <= start) { throw new Error("无法从 index.js 提取批次状态机定义"); } - const snippet = source.slice(start, end); + const snippet = source.slice(start, end).replace(/^export\s+/gm, ""); const context = { console, result: null, @@ -103,13 +183,18 @@ function createGenerationRecallHarness() { if (start < 0 || end < 0 || end <= start) { throw new Error("无法从 index.js 提取生成召回事务定义"); } - const snippet = source.slice(start, end); + const snippet = source.slice(start, end).replace(/^export\s+/gm, ""); const context = { console, Date, Map, setTimeout, clearTimeout, + document: { + getElementById() { + return null; + }, + }, result: null, currentGraph: {}, isRecalling: false, @@ -132,14 +217,11 @@ function createGenerationRecallHarness() { ? [...chat, { is_user: true, mes: syntheticUserMessage }] : [...chat], getContext: () => ({ + chatId: "chat-main", chat: context.chat, }), chat: [], runRecallCalls: [], - runRecall: async (options = {}) => { - context.runRecallCalls.push({ ...options }); - return true; - }, }; vm.createContext(context); vm.runInContext( @@ -147,10 +229,34 @@ function createGenerationRecallHarness() { context, { filename: indexPath }, ); + context.runRecall = async (options = {}) => { + context.runRecallCalls.push({ ...options }); + return true; + }; return context; }); } +function pushTestOverrides(patch = {}) { + const previous = globalThis.__stBmeTestOverrides || {}; + globalThis.__stBmeTestOverrides = { + ...previous, + ...patch, + llm: { + ...(previous.llm || {}), + ...(patch.llm || {}), + }, + embedding: { + ...(previous.embedding || {}), + ...(patch.embedding || {}), + }, + }; + + return () => { + globalThis.__stBmeTestOverrides = previous; + }; +} + function makeEvent(seq, title) { return createNode({ type: "event", @@ -186,13 +292,18 @@ async function testCompressorMigratesEdgesToCompressedNode() { }), ); - const originalSummarize = llm.callLLMForJSON; - llm.callLLMForJSON = async () => ({ - fields: { - title: "压缩事件", - summary: "合并摘要", - participants: "Alice", - status: "done", + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON() { + return { + fields: { + title: "压缩事件", + summary: "合并摘要", + participants: "Alice", + status: "done", + }, + }; + }, }, }); @@ -220,7 +331,7 @@ async function testCompressorMigratesEdgesToCompressedNode() { ); assert.ok(migrated); } finally { - llm.callLLMForJSON = originalSummarize; + restoreOverrides(); } } @@ -233,8 +344,13 @@ async function testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure() { graph.vectorIndexState.dirty = true; graph.vectorIndexState.lastWarning = "旧 warning"; - const originalEmbedBatch = embedding.embedBatch; - embedding.embedBatch = async () => [[0.1, 0.2], null]; + const restoreOverrides = pushTestOverrides({ + embedding: { + async embedBatch() { + return [[0.1, 0.2], null]; + }, + }, + }); try { const result = await syncGraphVectorIndex( @@ -262,7 +378,7 @@ async function testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure() { ); assert.equal(second.embedding, null); } finally { - embedding.embedBatch = originalEmbedBatch; + restoreOverrides(); } } @@ -292,20 +408,29 @@ async function testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing() { addNode(graph, target); addNode(graph, incoming); - const originalFindSimilar = embedding.searchSimilar; - const originalEmbedBatch = embedding.embedBatch; - const originalCall = llm.callLLMForJSON; - embedding.embedBatch = async () => [[0.2, 0.3]]; - embedding.searchSimilar = async () => [{ nodeId: target.id, score: 0.99 }]; - llm.callLLMForJSON = async () => ({ - results: [ - { - node_id: incoming.id, - action: "merge", - merge_target_id: "missing-node-id", - reason: "故意触发无效 merge target 回退", + const restoreOverrides = pushTestOverrides({ + embedding: { + async embedBatch() { + return [[0.2, 0.3]]; }, - ], + searchSimilar() { + return [{ nodeId: target.id, score: 0.99 }]; + }, + }, + llm: { + async callLLMForJSON() { + return { + results: [ + { + node_id: incoming.id, + action: "merge", + merge_target_id: "missing-node-id", + reason: "故意触发无效 merge target 回退", + }, + ], + }; + }, + }, }); try { @@ -326,17 +451,20 @@ async function testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing() { assert.equal(incoming.archived, false); assert.deepEqual(target.embedding, [0.9, 0.1]); } finally { - embedding.searchSimilar = originalFindSimilar; - embedding.embedBatch = originalEmbedBatch; - llm.callLLMForJSON = originalCall; + restoreOverrides(); } } async function testExtractorFailsOnUnknownOperation() { const graph = createEmptyGraph(); - const originalCall = llm.callLLMForJSON; - llm.callLLMForJSON = async () => ({ - operations: [{ action: "nonsense", foo: 1 }], + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON() { + return { + operations: [{ action: "nonsense", foo: 1 }], + }; + }, + }, }); try { @@ -354,7 +482,7 @@ async function testExtractorFailsOnUnknownOperation() { assert.match(result.error, /未知操作类型/); assert.equal(graph.lastProcessedSeq, -1); } finally { - llm.callLLMForJSON = originalCall; + restoreOverrides(); } } @@ -371,6 +499,7 @@ async function testConsolidatorMergeUpdatesSeqRange() { status: "active", }, }); + target.embedding = [0.8, 0.2]; const incoming = createNode({ type: "event", seq: 8, @@ -385,25 +514,41 @@ async function testConsolidatorMergeUpdatesSeqRange() { addNode(graph, target); addNode(graph, incoming); - const originalFindSimilar = embedding.searchSimilar; - const originalCall = llm.callLLMForJSON; - embedding.searchSimilar = async () => [{ nodeId: target.id, score: 0.99 }]; - llm.callLLMForJSON = async () => ({ - results: [ - { - node_id: incoming.id, - action: "merge", - merge_target_id: target.id, - merged_fields: { summary: "合并后摘要" }, + const restoreOverrides = pushTestOverrides({ + embedding: { + async embedBatch() { + return [[0.4, 0.5]]; }, - ], + searchSimilar() { + return [{ nodeId: target.id, score: 0.99 }]; + }, + }, + llm: { + async callLLMForJSON() { + return { + results: [ + { + node_id: incoming.id, + action: "merge", + merge_target_id: target.id, + merged_fields: { summary: "合并后摘要" }, + }, + ], + }; + }, + }, }); try { const stats = await consolidateMemories({ graph, newNodeIds: [incoming.id], - embeddingConfig: null, + embeddingConfig: { + mode: "direct", + source: "direct", + apiUrl: "https://example.com/v1", + model: "text-embedding-3-small", + }, settings: {}, }); @@ -414,8 +559,7 @@ async function testConsolidatorMergeUpdatesSeqRange() { assert.equal(target.embedding, null); assert.equal(incoming.archived, true); } finally { - embedding.searchSimilar = originalFindSimilar; - llm.callLLMForJSON = originalCall; + restoreOverrides(); } } @@ -423,17 +567,17 @@ async function testBatchJournalVectorDeltaCapturesRecoveryFields() { const before = normalizeGraphRuntimeState(createEmptyGraph(), "chat-a"); const after = normalizeGraphRuntimeState(createEmptyGraph(), "chat-a"); const beforeNode = createNode({ - id: "node-before", type: "event", seq: 1, fields: { title: "旧", summary: "旧", participants: "A", status: "old" }, }); + beforeNode.id = "node-before"; const afterNode = createNode({ - id: "node-before", type: "event", seq: 1, fields: { title: "新", summary: "新", participants: "A", status: "new" }, }); + afterNode.id = "node-before"; addNode(before, beforeNode); addNode(after, afterNode); before.vectorIndexState.hashToNodeId = { hash_old: "node-before" }; @@ -550,7 +694,6 @@ async function testReverseJournalRollbackStateFormsReplayClosure() { const before = normalizeGraphRuntimeState(createEmptyGraph(), "chat-replay"); const after = normalizeGraphRuntimeState(createEmptyGraph(), "chat-replay"); const stableNode = createNode({ - id: "node-stable", type: "event", seq: 1, fields: { @@ -560,8 +703,8 @@ async function testReverseJournalRollbackStateFormsReplayClosure() { status: "stable", }, }); + stableNode.id = "node-stable"; const touchedBefore = createNode({ - id: "node-touched", type: "event", seq: 2, fields: { @@ -571,8 +714,8 @@ async function testReverseJournalRollbackStateFormsReplayClosure() { status: "old", }, }); + touchedBefore.id = "node-touched"; const touchedAfter = createNode({ - id: "node-touched", type: "event", seq: 5, fields: { @@ -582,8 +725,8 @@ async function testReverseJournalRollbackStateFormsReplayClosure() { status: "updated", }, }); + touchedAfter.id = "node-touched"; const appendedNode = createNode({ - id: "node-appended", type: "event", seq: 6, fields: { @@ -593,6 +736,7 @@ async function testReverseJournalRollbackStateFormsReplayClosure() { status: "new", }, }); + appendedNode.id = "node-appended"; addNode(before, stableNode); addNode(before, touchedBefore); addNode(after, stableNode); diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs index e846d63..f7576ec 100644 --- a/tests/task-worldinfo.mjs +++ b/tests/task-worldinfo.mjs @@ -237,9 +237,19 @@ try { promptBuild.hostInjections.before.map((entry) => entry.name), ["常驻设定", "EW/Controller/Main", "线索条目"], ); + assert.equal(promptBuild.hostInjectionPlan.before.length, 1); + assert.equal(promptBuild.hostInjectionPlan.before[0].blockId, "b1"); + assert.equal(promptBuild.hostInjectionPlan.before[0].sourceKey, "worldInfoBefore"); + assert.deepEqual(promptBuild.hostInjectionPlan.before[0].entryNames, [ + "常驻设定", + "EW/Controller/Main", + "线索条目", + ]); assert.equal(promptBuild.hostInjections.after.length, 0); assert.equal(promptBuild.hostInjections.atDepth.length, 1); assert.equal(promptBuild.hostInjections.atDepth[0].depth, 2); + assert.equal(promptBuild.hostInjectionPlan.atDepth.length, 1); + assert.equal(promptBuild.hostInjectionPlan.atDepth[0].entryName, "深度注入"); assert.deepEqual( promptBuild.renderedBlocks.map((block) => block.delivery), ["host.before", "private.message"],