diff --git a/index.js b/index.js index 6681156..d44eed7 100644 --- a/index.js +++ b/index.js @@ -397,6 +397,8 @@ const defaultSettings = { // 召回设置 recallEnabled: true, recallCardUserInputDisplayMode: "beautify_only", + worldInfoFilterMode: "default", + worldInfoFilterCustomKeywords: "", recallTopK: 20, // 向量预筛 Top-K recallMaxNodes: 8, // LLM 召回最大节点数 recallEnableLLM: true, // 是否启用 LLM 精确召回 diff --git a/panel.html b/panel.html index 4da089b..b391fe8 100644 --- a/panel.html +++ b/panel.html @@ -617,6 +617,43 @@ +
+
+
+
世界书过滤
+
+ 控制 ST-BME 读取世界书条目时的过滤策略。默认模式自动过滤 MVU 相关条目;自定义模式仅按条目名称关键词过滤。 +
+
+
+
+ + +
+ +
+
diff --git a/panel.js b/panel.js index 65132a1..06c68ae 100644 --- a/panel.js +++ b/panel.js @@ -1983,6 +1983,21 @@ function _refreshConfigTab() { "bme-setting-notice-display-mode", settings.noticeDisplayMode ?? "normal", ); + _setInputValue( + "bme-setting-wi-filter-mode", + settings.worldInfoFilterMode || "default", + ); + _setInputValue( + "bme-setting-wi-filter-keywords", + settings.worldInfoFilterCustomKeywords || "", + ); + const wiFilterCustomSection = panelEl?.querySelector( + "#bme-wi-filter-custom-section", + ); + if (wiFilterCustomSection) { + wiFilterCustomSection.style.display = + (settings.worldInfoFilterMode || "default") === "custom" ? "" : "none"; + } _setInputValue("bme-setting-extract-every", settings.extractEvery ?? 1); _setInputValue( @@ -2337,6 +2352,29 @@ function _bindConfigControls() { }); noticeDisplayModeEl.dataset.bmeBound = "true"; } + const wiFilterModeEl = document.getElementById("bme-setting-wi-filter-mode"); + if (wiFilterModeEl && wiFilterModeEl.dataset.bmeBound !== "true") { + wiFilterModeEl.addEventListener("change", () => { + const nextValue = wiFilterModeEl.value || "default"; + _patchSettings({ worldInfoFilterMode: nextValue }); + const section = panelEl?.querySelector("#bme-wi-filter-custom-section"); + if (section) { + section.style.display = nextValue === "custom" ? "" : "none"; + } + }); + wiFilterModeEl.dataset.bmeBound = "true"; + } + const wiFilterKeywordsEl = document.getElementById( + "bme-setting-wi-filter-keywords", + ); + if (wiFilterKeywordsEl && wiFilterKeywordsEl.dataset.bmeBound !== "true") { + wiFilterKeywordsEl.addEventListener("change", () => { + _patchSettings({ + worldInfoFilterCustomKeywords: wiFilterKeywordsEl.value || "", + }); + }); + wiFilterKeywordsEl.dataset.bmeBound = "true"; + } bindNumber("bme-setting-extract-every", 1, 1, 50, (value) => _patchSettings({ extractEvery: value }), diff --git a/plan-custom-worldinfo-filter.md b/plan-custom-worldinfo-filter.md new file mode 100644 index 0000000..1323c2a --- /dev/null +++ b/plan-custom-worldinfo-filter.md @@ -0,0 +1,702 @@ +# Plan: 世界书自定义过滤模式(v4) + +## 需求 + +- **默认模式**:保持现有全部行为不变 +- **自定义模式**:完全替代 MVU 过滤链路: + 1. disabled 条目不进入读取链路 + 2. 条目 `name`(仅 name)包含用户指定关键词的条目被跳过 + 3. 不做任何 MVU heuristic / sanitize / blockedContents 处理 + - 关键词留空 → 除 disabled 外全部可读 + +## 行为护栏 + +- 这次改动的目标只是给世界书读取新增一条 **custom 跑道**,不是重写默认逻辑 +- `worldInfoFilterMode !== "custom"` 时,行为必须与当前版本逐字节等价: + - 仍走 `getMvuIgnoreReason()` + - 仍写入 `blockedContents` + - 仍保留默认模式下 disabled 条目可被 EJS `getwi` 读取的现有语义 + - 仍保持现有 `debug.mvu`、缓存、warning 文案和 prompt-builder 清洗行为 +- 只有在 `worldInfoFilterMode === "custom"` 时,才切到自定义分支 +- 任何实现如果让默认模式下“本来能读到的条目读不到”,都视为回归 + +--- + +## 需要切断的 MVU 链路(自定义模式下全部跳过) + +| # | 位置 | 作用 | 自定义模式处理 | +|---|------|------|---------------| +| A | `task-worldinfo.js` L779 `getMvuIgnoreReason()` | 按 name/comment 标签 + 内容特征过滤条目 | 替换为自定义关键词按 `entry.name` 过滤 | +| B | `task-worldinfo.js` L781 `registerIgnoredWorldInfoEntry()` | 被过滤条目 content 推入 `blockedContents` | 自定义过滤条目不推入 `blockedContents` | +| C | `task-worldinfo.js` L1387 `sanitizeMvuContent()` | 渲染后条目再过一轮 MVU 内容检测 | 跳过,直接使用渲染结果 | +| D | `task-worldinfo.js` L1190 `__mvuBlockedContents` | 传递给 prompt-builder | 自定义模式下为空数组 | +| E | `prompt-builder.js` L700 `runtimeBlockedContents` | 读取 `__mvuBlockedContents` 对条目做内容删除 | 空数组 → 不删除 | +| F | `prompt-builder.js` L651,733 `sanitizeTaskPromptText()` | 默认 `applyMvu: true`,对世界书条目和 additionalMessages 跑 `sanitizeMvuContent()` | 传 `applyMvu: false` | +| G | `prompt-builder.js` L1137 `sanitizeTaskPromptText()` | 最终 block 组装阶段还会再跑一次 MVU 清洗 | 自定义模式下,对世界书来源 block 传 `applyMvu: false` | +| H | `prompt-builder.js` L1445 `sanitizePromptMessages()` | fallback 路径会再次清洗 `privateTaskMessages` | 自定义模式下,对世界书来源 message 传 `applyMvu: false` | +| I | `task-ejs.js` L564-568 `resolveIgnoredEntry` 回调 | 被过滤条目 warning 文案硬编码为 `"mvu filtered world info blocked"` | 自定义过滤条目不走这条 warning | +| J | `task-worldinfo.js` L159 `buildMvuDebugSummary()` | 把 mvuCollector 数据归入 `debug.mvu` | 自定义过滤条目归入独立的 `debug.customFilter`,不混入 `debug.mvu` | + +--- + +## 涉及文件与改动 + +### 1. `index.js` — 新增默认设置字段(~L378 `defaultSettings`) + +```js +worldInfoFilterMode: "default", // "default" | "custom" +worldInfoFilterCustomKeywords: "", // 逗号分隔,如 "BME,测试" +``` + +### 2. `panel.html` — 功能开关页新增卡片 + +在 `data-config-section="toggles"` 的 `bme-config-grid` 中,增强能力卡片(L1015 `
`)之后、隐藏旧楼层卡片(L1017)之前,插入: + +```html +
+
+
+
世界书过滤
+
+ 控制 ST-BME 读取世界书条目时的过滤策略。默认自动过滤 MVU 相关条目;自定义模式仅按条目名称关键词过滤。 +
+
+
+
+ + +
+ +
+``` + +### 3. `panel.js` — 绑定 UI 与设置 + +**`_refreshConfigTab`(L1848)** 中追加: + +```js +_setInputValue("bme-setting-wi-filter-mode", settings.worldInfoFilterMode || "default"); +_setInputValue("bme-setting-wi-filter-keywords", settings.worldInfoFilterCustomKeywords || ""); +const wiFilterCustomSection = panelEl?.querySelector("#bme-wi-filter-custom-section"); +if (wiFilterCustomSection) { + wiFilterCustomSection.style.display = + (settings.worldInfoFilterMode || "default") === "custom" ? "" : "none"; +} +``` + +**事件绑定区域(~L2318 附近,`noticeDisplayModeEl` 绑定之后)** 追加: + +```js +const wiFilterModeEl = document.getElementById("bme-setting-wi-filter-mode"); +if (wiFilterModeEl && wiFilterModeEl.dataset.bmeBound !== "true") { + wiFilterModeEl.addEventListener("change", () => { + _patchSettings({ worldInfoFilterMode: wiFilterModeEl.value || "default" }); + const section = panelEl?.querySelector("#bme-wi-filter-custom-section"); + if (section) { + section.style.display = wiFilterModeEl.value === "custom" ? "" : "none"; + } + }); + wiFilterModeEl.dataset.bmeBound = "true"; +} +const wiFilterKeywordsEl = document.getElementById("bme-setting-wi-filter-keywords"); +if (wiFilterKeywordsEl && wiFilterKeywordsEl.dataset.bmeBound !== "true") { + wiFilterKeywordsEl.addEventListener("change", () => { + _patchSettings({ worldInfoFilterCustomKeywords: wiFilterKeywordsEl.value || "" }); + }); + wiFilterKeywordsEl.dataset.bmeBound = "true"; +} +``` + +### 4. `task-worldinfo.js` — 核心过滤逻辑 + +#### 4a. 新增独立的自定义过滤 collector(不复用 mvuCollector) + +```js +function createCustomFilterCollector() { + return { + filteredEntries: [], + lazyFilteredEntries: [], + }; +} + +function registerCustomFilteredEntry(collector, entry, matchedKeyword, { lazy = false } = {}) { + if (!collector || !entry) return; + const meta = { + worldbook: normalizeKey(entry.worldbook), + name: entry.name, + matchedKeyword, + reason: "custom_keyword", + }; + if (lazy) { + collector.lazyFilteredEntries.push(meta); + } else { + collector.filteredEntries.push(meta); + } +} + +function buildCustomFilterDebugSummary(collector, { + filterMode = "default", + customFilterKeywords = [], +} = {}) { + const filteredEntries = Array.isArray(collector?.filteredEntries) + ? collector.filteredEntries + : []; + const lazyFilteredEntries = Array.isArray(collector?.lazyFilteredEntries) + ? collector.lazyFilteredEntries + : []; + + return { + mode: filterMode, + keywords: [...customFilterKeywords], + filteredEntryCount: filteredEntries.length + lazyFilteredEntries.length, + filteredEntries: [...filteredEntries, ...lazyFilteredEntries], + lazyFilteredEntryCount: lazyFilteredEntries.length, + }; +} +``` + +不推入 `blockedContents`,不注册进 `ignoredLookup`。 + +#### 4b. `resolveTaskWorldInfo` 入口(L1128)解析设置并透传 + +```js +const filterMode = String(settings.worldInfoFilterMode || "default").trim(); +const isCustomFilter = filterMode === "custom"; +const customFilterKeywords = isCustomFilter + ? String(settings.worldInfoFilterCustomKeywords || "") + .split(",") + .map(kw => kw.trim().toLowerCase()) + .filter(Boolean) + : []; +``` + +传给 `collectAllWorldbookEntries`:`{ filterMode, customFilterKeywords }` + +同时给 `result.debug` 预留默认结构,避免无世界书命中时 `debug.customFilter` 缺失: + +```js +result.debug.customFilter = { + mode: filterMode, + keywords: customFilterKeywords, + filteredEntryCount: 0, + filteredEntries: [], + lazyFilteredEntryCount: 0, +}; +``` + +#### 4c. `collectAllWorldbookEntries`(L792)透传 + +函数签名增加 `{ filterMode = "default", customFilterKeywords = [] } = {}`。 + +- 创建 `const customFilterCollector = createCustomFilterCollector();` +- `loadWorldbookOnce` 调用 `loadNormalizedWorldbookEntries` 时传入 `{ mvuCollector, filterMode, customFilterKeywords, customFilterCollector }` +- 缓存 key 中加入 `filterMode` 和 `customFilterKeywords` + +```js +const cacheKey = JSON.stringify({ + // ... 现有字段 ... + filterMode, + customFilterKeywords, +}); +``` + +- `debug` 和缓存对象都带上 `customFilter` + +```js +const customFilterDebug = buildCustomFilterDebugSummary(customFilterCollector, { + filterMode, + customFilterKeywords, +}); + +worldbookEntriesCache = { + // ...现有字段... + debug: { + ...debug, + mvu: buildMvuDebugSummary(mvuCollector), + customFilter: customFilterDebug, + }, +}; + +return { + entries: allEntries, + blockedContents: [...mvuCollector.blockedContents], + ignoredEntries: [...debug.mvu.filteredEntries], + ignoredLookup: new Map(mvuCollector.ignoredLookup), + debug: { + ...debug, + mvu: buildMvuDebugSummary(mvuCollector), + customFilter: customFilterDebug, + }, +}; +``` + +缓存命中分支也要把 `worldbookEntriesCache.debug?.customFilter` 原样回传。 + +#### 4d. `loadNormalizedWorldbookEntries`(L741)—— 核心改造 + +```js +async function loadNormalizedWorldbookEntries( + worldbookHost, worldbookName, + { mvuCollector = null, lazy = false, filterMode = "default", + customFilterKeywords = [], customFilterCollector = null } = {}, +) { + // ... 现有逻辑读取 entries 和 commentByUid ... + + const normalizedEntries = []; + for (const entry of Array.isArray(entries) ? entries : []) { + const normalizedEntry = normalizeEntry(/* 不变 */); + + if (filterMode === "custom") { + // 自定义模式:disabled 条目直接跳过,不进入 allEntries + if (!normalizedEntry.enabled) continue; + + // 按 entry.name 关键词过滤 + if (customFilterKeywords.length > 0) { + const nameLower = normalizedEntry.name.toLowerCase(); + const matched = customFilterKeywords.find(kw => nameLower.includes(kw)); + if (matched) { + registerCustomFilteredEntry(customFilterCollector, normalizedEntry, matched, { lazy }); + continue; + } + } + // 通过 → 加入结果,不做 MVU 检测 + } else { + // 默认模式:现有 MVU 逻辑,完全不动 + const ignoreReason = getMvuIgnoreReason(normalizedEntry); + if (ignoreReason) { + registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, { lazy }); + continue; + } + } + + normalizedEntries.push(normalizedEntry); + } + return normalizedEntries; +} +``` + +**关于 disabled 条目**:仅自定义模式下在此处跳过。默认模式保持原样(disabled 条目进入 `allEntries`,在 L588 激活阶段跳过,但 EJS `getwi` 仍可按需拉取——这是现有有意设计)。 + +#### 4e. `resolveTaskWorldInfo` 中渲染后清洗(~L1387) + +```js +// 原: +const mvuSanitized = sanitizeMvuContent(renderedContent, { + mode: "aggressive", + blockedContents, +}); + +// 改为: +const mvuSanitized = isCustomFilter + ? { text: renderedContent, changed: false, dropped: false, + reasons: [], blockedHitCount: 0, artifactRemovedCount: 0 } + : sanitizeMvuContent(renderedContent, { mode: "aggressive", blockedContents }); +``` + +#### 4f. `__mvuBlockedContents` 与 `blockedContents`(~L1190) + +自定义模式下 `mvuCollector.blockedContents` 本来就不会被填入(因为 4d 不走 `registerIgnoredWorldInfoEntry`),所以 `blockedContents` 自然为空数组。`__mvuBlockedContents` 赋值逻辑不需要改动,空数组传过去 prompt-builder 就不会做任何内容删除。 + +#### 4g. 懒加载世界书透传(~L1269) + +```js +const lazyCustomFilterCollector = createCustomFilterCollector(); + +const lazyEntries = await loadNormalizedWorldbookEntries( + worldbookHost, normalizedWorldbook, + { mvuCollector: lazyMvuCollector, lazy: true, + filterMode, customFilterKeywords, customFilterCollector: lazyCustomFilterCollector }, +); +``` + +这里改为**仿照现有 `lazyMvuCollector` 模式**,在 `resolveTaskWorldInfo()` 内新建一个局部 `lazyCustomFilterCollector`,原因是: + +- `collectAllWorldbookEntries()` 里的 `customFilterCollector` 是其内部局部变量,`resolveTaskWorldInfo()` 闭包里拿不到 +- 懒加载发生在 `resolveTaskWorldInfo()` 内部,不在 `collectAllWorldbookEntries()` 的作用域里 +- 直接照着现有 `lazyMvuCollector` 的合并写法做,最贴近现有代码结构,风险最低 + +回调后立刻合并到 `result.debug.customFilter`: + +```js +const newLazyEntries = [...lazyCustomFilterCollector.lazyFilteredEntries]; +if (newLazyEntries.length > 0) { + result.debug.customFilter = { + ...result.debug.customFilter, + filteredEntries: [ + ...(Array.isArray(result.debug.customFilter?.filteredEntries) + ? result.debug.customFilter.filteredEntries + : []), + ...newLazyEntries, + ], + filteredEntryCount: + Number(result.debug.customFilter?.filteredEntryCount || 0) + + newLazyEntries.length, + lazyFilteredEntryCount: + Number(result.debug.customFilter?.lazyFilteredEntryCount || 0) + + newLazyEntries.length, + }; +} +lazyCustomFilterCollector.lazyFilteredEntries = []; +``` + +这和当前 `L1278-L1294` 对 `lazyMvuCollector` 的处理是同构的,语义也最直观: + +- 首轮加载的自定义过滤统计来自 `collectAllWorldbookEntries()` +- 懒加载追加过滤统计来自 `lazyCustomFilterCollector` +- 两者最终都汇总到同一个 `result.debug.customFilter` + +#### 4h. 调试信息——独立的 `debug.customFilter` + +```js +result.debug = { + ...result.debug, + ...(collected?.debug || {}), + customFilter: + collected?.debug?.customFilter && typeof collected.debug.customFilter === "object" + ? { ...collected.debug.customFilter } + : buildCustomFilterDebugSummary(null, { filterMode, customFilterKeywords }), +}; +``` + +不混入 `debug.mvu`。自定义模式下 `debug.mvu` 保持初始空值(`buildMvuDebugSummary(null)` 返回的全零结构),`debug.customFilter` 始终由 `collectAllWorldbookEntries()` 汇总后统一返回。 + +#### 4i. `resolveIgnoredEntry` 回调与 EJS warning(~L1315) + +自定义模式下传给 `createTaskEjsRenderContext` 的 `resolveIgnoredEntry` 改为查自定义 collector: + +```js +resolveIgnoredEntry: isCustomFilter + ? (worldbookName, identifier) => { + // 自定义过滤条目不注册进 ignoredLookup,所以 EJS getwi 找不到时不会报 MVU warning + return null; + } + : (worldbookName, identifier) => + findIgnoredWorldInfoEntry({ ignoredLookup }, worldbookName, identifier), +``` + +自定义模式下 `getwi` 找不到条目只会走正常的 "target not found" 逻辑(L639),不会出现 "mvu filtered world info blocked" 字样。 + +### 5. `prompt-builder.js` — 自定义模式下跳过世界书条目的 MVU 清洗 + +#### 5a. `sanitizeWorldInfoContext`(L682) + +已有参数 `settings`,读取 `settings.worldInfoFilterMode`: + +```js +const isCustomFilter = String(settings.worldInfoFilterMode || "default").trim() === "custom"; +``` + +传给 `sanitizeWorldInfoEntries` 和 `sanitizeTaskPromptText`: + +```js +const beforeEntries = sanitizeWorldInfoEntries( + settings, taskType, worldInfo?.beforeEntries, + runtimeBlockedContents, debugState, regexCollector, + { applyMvu: !isCustomFilter }, // 新增 +); +// afterEntries, atDepthEntries 同理 +``` + +additionalMessages 的 `sanitizeTaskPromptText` 调用也传 `applyMvu: !isCustomFilter`。 + +#### 5b. `sanitizeWorldInfoEntries`(L641) + +签名增加 `options = {}` 参数: + +```js +function sanitizeWorldInfoEntries( + settings, taskType, entries, blockedContents, + debugState, regexCollector, + { applyMvu = true } = {}, +) { +``` + +内部调用 `sanitizeTaskPromptText` 时透传 `applyMvu`: + +```js +const sanitized = sanitizeTaskPromptText(settings, taskType, content, { + mode: "aggressive", + blockedContents, + regexStage: "", + role: entry?.role || "system", + regexCollector, + applyMvu, // 新增 +}); +``` + +#### 5c. `buildTaskPrompt` 最终 block 清洗(L1137)也要跳过世界书来源内容 + +这是 v3 漏掉的关键点。即使 `resolveTaskWorldInfo()` 和 `sanitizeWorldInfoContext()` 都不做 MVU 清洗,`buildTaskPrompt()` 在把 block 内容组装成最终 `systemPrompt` / `executionMessages` 时,还会再调用一次 `sanitizeTaskPromptText()`。如果不改,这一层仍会把自定义模式下的世界书内容删掉。 + +新增 helper: + +```js +function blockUsesWorldInfoContent(block = {}) { + const sourceKey = String(block?.sourceKey || ""); + if ( + sourceKey === "worldInfoBefore" || + sourceKey === "worldInfoAfter" || + sourceKey === "worldInfoBeforeEntries" || + sourceKey === "worldInfoAfterEntries" || + sourceKey === "worldInfoAtDepthEntries" || + sourceKey === "activatedWorldInfoNames" || + sourceKey === "taskAdditionalMessages" + ) { + return true; + } + + const content = String(block?.content || ""); + return /\{\{\s*(worldInfoBefore|worldInfoAfter|worldInfoBeforeEntries|worldInfoAfterEntries|worldInfoAtDepthEntries|activatedWorldInfoNames|taskAdditionalMessages)\s*\}\}/.test(content); +} +``` + +在 `buildTaskPrompt()` 里计算: + +```js +const isCustomFilter = String(settings.worldInfoFilterMode || "default").trim() === "custom"; +const blockApplyMvu = !(isCustomFilter && blockUsesWorldInfoContent(block)); + +const sanitizedBlockContent = sanitizeTaskPromptText(settings, taskType, content, { + mode: "final-safe", + blockedContents: worldInfoRuntimeBlockedContents, + regexStage: "", + role, + regexCollector: promptRegexInput, + applyMvu: blockApplyMvu, +}); +``` + +这里**只**对“世界书来源 block”关闭 MVU;其他普通 block 仍保持现有清洗行为,不扩大改动面。 + +#### 5d. `buildTaskLlmPayload` fallback 路径(L1445)保持同样规则 + +当 `executionMessages.length === 0` 时,代码会回退到 `privateTaskMessages` 并再次调用 `sanitizePromptMessages()`。这条 fallback 也必须遵守和 5c 一样的规则,否则仍然可能在极端路径里把自定义模式的世界书内容清掉。 + +这里收敛成**单一路径**,不要再给 message 打额外标记: + +1. 给 `sanitizePromptMessages()` 增加 `applyMvu = true` 选项,并向下透传到 `sanitizeStructuredPromptValue()` +2. fallback 调用时直接按模式传参: + +```js +const additionalMessages = + executionMessages.length > 0 + ? [] + : sanitizePromptMessages( + {}, + taskType, + Array.isArray(promptBuild?.privateTaskMessages) + ? promptBuild.privateTaskMessages + : [], + { + blockedContents, + regexStage: "", + applyMvu: !isCustomFilter, + }, + ); +``` + +这样做的理由: + +- fallback 本身就是极端兜底路径,没必要再做“按 message 来源细分”的复杂逻辑 +- 自定义模式要求“世界书链路不做 MVU sanitize”,fallback 时直接整体关闭 MVU 更符合预期 +- 默认模式不受影响,仍保持 `applyMvu: true` + +这样可保证: + +- 正常路径不会二次清洗世界书内容 +- fallback 路径也不会偷偷恢复 MVU 清洗 +- 默认模式仍保持原行为 + +### 6. 测试(`tests/task-worldinfo.mjs`) + +#### 6a. 自定义模式替代 MVU——MVU 条目不再被过滤 + +```js +const customResult = await resolveTaskWorldInfo({ + settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "" }, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); +// MVU tagged/heuristic 条目应出现在激活结果中 +assert.equal( + customResult.beforeEntries.some(e => e.sourceName === "[mvu_update] 状态同步"), + true, + "custom filter mode should not filter MVU tagged entries", +); +assert.equal( + customResult.beforeEntries.some(e => e.sourceName === "MVU 启发式条目"), + true, + "custom filter mode should not filter MVU heuristic entries", +); +// debug.mvu 应为空 +assert.equal(customResult.debug.mvu.filteredEntryCount, 0); +// debug.customFilter 存在且 mode 正确 +assert.equal(customResult.debug.customFilter.mode, "custom"); +assert.equal(customResult.debug.customFilter.filteredEntryCount, 0); +``` + +#### 6b. 自定义关键词仅匹配 name + +```js +const keywordResult = await resolveTaskWorldInfo({ + settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "常驻" }, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); +// name 为 "常驻设定" → 被过滤 +assert.equal( + keywordResult.beforeEntries.some(e => e.sourceName === "常驻设定"), + false, +); +// comment 包含某关键词但 name 不包含 → 不被过滤(用现有 fixture 验证) +assert.equal(keywordResult.debug.customFilter.filteredEntryCount, 1); +assert.equal(keywordResult.debug.customFilter.filteredEntries[0].name, "常驻设定"); +assert.equal(keywordResult.debug.customFilter.filteredEntries[0].matchedKeyword, "常驻"); +``` + +#### 6c. disabled 条目在自定义模式下不可被 getwi 读到 + +```js +// dynEntry (enabled=false, name="EW/Dyn/线索") 在自定义模式下不应进入 allEntries +// inlineSummaryEntry 调用 getwi("EW/Dyn/线索") 应该返回空 +assert.equal( + customResult.allEntries.some(e => e.name === "EW/Dyn/线索"), + false, + "disabled entries should not enter allEntries in custom filter mode", +); +``` + +#### 6d. 缓存 key 区分模式和关键词 + +```js +// 先跑一次默认模式预热缓存 +const defaultResult = await resolveTaskWorldInfo({ + settings: {}, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); +assert.equal(defaultResult.debug.cache.hit, false); + +// 再跑自定义模式,不应命中缓存 +const customResult2 = await resolveTaskWorldInfo({ + settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "" }, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); +assert.equal(customResult2.debug.cache.hit, false, + "switching filter mode should not hit default mode cache"); +``` + +#### 6e. prompt-builder 层不做 MVU 清洗(直接 worldInfo block) + +```js +// 用 buildTaskPrompt 验证自定义模式下 MVU 内容条目不被清洗 +const customPromptBuild = await buildTaskPrompt( + { ...settings, worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "" }, + "recall", + { taskName: "recall", userMessage: "继续调查", recentMessages: "...", charName: "Alice" }, +); +// MVU heuristic 条目(content 含 status_current_variable)应出现在 systemPrompt 中 +assert.match(customPromptBuild.systemPrompt, /status_current_variable/, + "custom filter mode should not strip MVU content in prompt-builder"); +``` + +#### 6f. 最终 block 清洗对 `{{worldInfoBefore}}` 插值块也不应删内容 + +补一个 profile fixture,不用 `sourceKey: "worldInfoBefore"`,而是: + +```js +{ + id: "b-interp", + type: "custom", + content: "世界书插值:\\n{{worldInfoBefore}}", + role: "system", + enabled: true, + order: 0, + injectionMode: "append", +} +``` + +断言: + +```js +assert.match(customInterpolatedPromptBuild.systemPrompt, /status_current_variable/); +``` + +这能确保 5c 的 `blockUsesWorldInfoContent()` 不是只覆盖 `sourceKey`,而是真的覆盖到了插值路径。 + +#### 6g. cache hit 时 `debug.customFilter` 仍正确返回 + +```js +const keywordResult1 = await resolveTaskWorldInfo({ + settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "常驻" }, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); +assert.equal(keywordResult1.debug.cache.hit, false); +assert.equal(keywordResult1.debug.customFilter.filteredEntryCount, 1); + +const keywordResult2 = await resolveTaskWorldInfo({ + settings: { worldInfoFilterMode: "custom", worldInfoFilterCustomKeywords: "常驻" }, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); +assert.equal(keywordResult2.debug.cache.hit, true); +assert.equal(keywordResult2.debug.customFilter.filteredEntryCount, 1); +assert.equal(keywordResult2.debug.customFilter.filteredEntries[0].name, "常驻设定"); +``` + +#### 6h. 默认模式回归保护 + +必须补一条明确的“默认模式没变”回归测试,避免实现时误伤现有链路: + +```js +const defaultModeResult = await resolveTaskWorldInfo({ + settings: { worldInfoFilterMode: "default", worldInfoFilterCustomKeywords: "常驻" }, + userMessage: "继续调查", + templateContext: { recentMessages: "...", charName: "Alice" }, +}); + +// 默认模式仍忽略自定义关键词设置 +assert.equal( + defaultModeResult.beforeEntries.some(e => e.sourceName === "常驻设定"), + true, +); + +// 默认模式仍保留原有 MVU 过滤 +assert.equal(defaultModeResult.debug.mvu.filteredEntryCount > 0, true); +assert.equal(defaultModeResult.debug.customFilter.filteredEntryCount, 0); +``` + +--- + +## 不做的事 + +- 不修改 `mvu-compat.js` +- 不支持正则匹配(只做子串包含) +- 不按 `comment` 或 content 过滤(只按 `name`) +- 不改变默认模式下的任何现有行为(包括 disabled 条目可被 EJS getwi 读取) + +--- + +## 风险点 + +1. **自定义模式下 disabled 条目不可被 EJS getwi 读取** — 与默认模式行为不同(默认允许)。这是有意设计,符合用户 "未激活状态也不要读取" 的需求。 +2. **自定义模式完全跳过 MVU 清洗** — 如果用户世界书有 MVU 变量标签但没用关键词过滤掉,这些内容会原样进入 prompt。这是自定义模式的预期行为。 +3. **缓存 key 包含关键词** — 每次修改关键词后第一次请求会重新加载。3 秒 TTL 影响可忽略。 diff --git a/prompt-builder.js b/prompt-builder.js index ac2bddc..ef011fa 100644 --- a/prompt-builder.js +++ b/prompt-builder.js @@ -211,6 +211,39 @@ function createExecutionMessage( }; } +function isCustomWorldInfoFilterEnabled(settings = {}) { + return String(settings?.worldInfoFilterMode || "default").trim() === "custom"; +} + +function usesWorldInfoSourceKey(sourceKey = "") { + return [ + "worldInfoBefore", + "worldInfoAfter", + "worldInfoBeforeEntries", + "worldInfoAfterEntries", + "worldInfoAtDepthEntries", + "activatedWorldInfoNames", + "taskAdditionalMessages", + ].includes(String(sourceKey || "")); +} + +function blockUsesWorldInfoContent(block = {}) { + if (usesWorldInfoSourceKey(block?.sourceKey)) { + return true; + } + const content = String(block?.content || ""); + return /\{\{\s*(worldInfoBefore|worldInfoAfter|worldInfoBeforeEntries|worldInfoAfterEntries|worldInfoAtDepthEntries|activatedWorldInfoNames|taskAdditionalMessages)\s*\}\}/.test( + content, + ); +} + +function messageUsesWorldInfoContent(message = {}) { + if (usesWorldInfoSourceKey(message?.sourceKey)) { + return true; + } + return String(message?.source || "") === "worldInfo-atDepth"; +} + function stringifyInterpolatedValue(value) { if (value == null) return ""; if (typeof value === "string") return value; @@ -537,10 +570,13 @@ function sanitizePromptMessages( regexStage = "", debugState = null, regexCollector = null, + applyMvu = true, } = {}, ) { return (Array.isArray(messages) ? messages : []) .map((message, index) => { + const messageApplyMvu = + typeof applyMvu === "function" ? applyMvu(message, index) : applyMvu; const sanitized = sanitizeStructuredPromptValue( settings, taskType, @@ -554,6 +590,8 @@ function sanitizePromptMessages( role: message?.role || "system", debugState, regexCollector, + applyMvu: messageApplyMvu, + stripMvuContainers: messageApplyMvu, }, ); if (debugState && (sanitized.changed || sanitized.omit)) { @@ -645,6 +683,7 @@ function sanitizeWorldInfoEntries( blockedContents = [], debugState = null, regexCollector = null, + { applyMvu = true } = {}, ) { return (Array.isArray(entries) ? entries : []) .map((entry, index) => { @@ -658,6 +697,7 @@ function sanitizeWorldInfoEntries( regexStage: "", role: entry?.role || "system", regexCollector, + applyMvu, }, ); debugState.worldInfoBlockedContentHits += sanitized.blockedHitCount; @@ -686,6 +726,7 @@ function sanitizeWorldInfoContext( debugState = null, regexCollector = null, ) { + const isCustomFilter = isCustomWorldInfoFilterEnabled(settings); const rawDebug = worldInfo?.debug && typeof worldInfo.debug === "object" ? worldInfo.debug @@ -708,6 +749,7 @@ function sanitizeWorldInfoContext( runtimeBlockedContents, debugState, regexCollector, + { applyMvu: !isCustomFilter }, ); const afterEntries = sanitizeWorldInfoEntries( settings, @@ -716,6 +758,7 @@ function sanitizeWorldInfoContext( runtimeBlockedContents, debugState, regexCollector, + { applyMvu: !isCustomFilter }, ); const atDepthEntries = sanitizeWorldInfoEntries( settings, @@ -724,6 +767,7 @@ function sanitizeWorldInfoContext( runtimeBlockedContents, debugState, regexCollector, + { applyMvu: !isCustomFilter }, ); const additionalMessages = (Array.isArray(worldInfo?.additionalMessages) ? worldInfo.additionalMessages @@ -740,6 +784,7 @@ function sanitizeWorldInfoContext( regexStage: "", role: message?.role || "system", regexCollector, + applyMvu: !isCustomFilter, }, ); debugState.worldInfoBlockedContentHits += sanitized.blockedHitCount; @@ -752,6 +797,8 @@ function sanitizeWorldInfoContext( return { ...message, content: sanitized.text, + source: String(message?.source || "worldInfo-atDepth"), + sourceKey: String(message?.sourceKey || "taskAdditionalMessages"), }; }) .filter(Boolean); @@ -1012,6 +1059,7 @@ function extractWorldInfoChatMessages(context = {}) { } export async function buildTaskPrompt(settings = {}, taskType, context = {}) { + const isCustomFilter = isCustomWorldInfoFilterEnabled(settings); const profile = getActiveTaskProfile(settings, taskType); const legacyPrompt = getLegacyPromptForTask(settings, taskType); const promptRegexInput = { entries: [] }; @@ -1134,6 +1182,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { ); } + const blockApplyMvu = !(isCustomFilter && blockUsesWorldInfoContent(block)); const sanitizedBlockContent = sanitizeTaskPromptText( settings, taskType, @@ -1144,6 +1193,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { regexStage: "", role, regexCollector: promptRegexInput, + applyMvu: blockApplyMvu, }, ); mvuPromptDebug.worldInfoBlockedContentHits += @@ -1234,6 +1284,7 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { message.content, { source: "worldInfo-atDepth", + sourceKey: "taskAdditionalMessages", }, ); if (executionMessage) { @@ -1366,6 +1417,12 @@ export async function buildTaskPrompt(settings = {}, taskType, context = {}) { export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") { const runtimeMvu = promptBuild?.__mvuRuntime || {}; const taskType = String(promptBuild?.debug?.taskType || ""); + const isCustomFilter = + String( + promptBuild?.worldInfo?.debug?.customFilter?.mode || + promptBuild?.worldInfoResolution?.debug?.customFilter?.mode || + "default", + ).trim() === "custom"; const blockedContents = Array.isArray(runtimeMvu?.blockedContents) ? runtimeMvu.blockedContents : []; @@ -1390,6 +1447,8 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") { blockedContents, regexStage: "", + applyMvu: (message) => + !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); @@ -1451,6 +1510,8 @@ export function buildTaskLlmPayload(promptBuild = null, fallbackUserPrompt = "") { blockedContents, regexStage: "", + applyMvu: (message) => + !(isCustomFilter && messageUsesWorldInfoContent(message)), }, ); diff --git a/task-worldinfo.js b/task-worldinfo.js index 1d9bca9..46432c5 100644 --- a/task-worldinfo.js +++ b/task-worldinfo.js @@ -71,6 +71,14 @@ function createMvuCollector() { }; } +function createCustomFilterCollector() { + return { + filteredEntries: [], + lazyFilteredEntries: [], + seenEntries: new Set(), + }; +} + function registerIgnoredEntryLookup(collector, worldbookName, identifier, meta) { const normalizedIdentifier = normalizeKey(identifier); if (!collector || !normalizedIdentifier) return; @@ -80,6 +88,36 @@ function registerIgnoredEntryLookup(collector, worldbookName, identifier, meta) ); } +function registerCustomFilteredEntry( + collector, + entry = {}, + matchedKeyword = "", + { lazy = false } = {}, +) { + if (!collector || !entry) return; + + const worldbook = normalizeKey(entry.worldbook); + const name = normalizeKey(entry.name); + const identity = `${worldbook}:${entry.uid || 0}:${name}:${String(matchedKeyword || "")}`; + if (collector.seenEntries.has(identity)) { + return; + } + collector.seenEntries.add(identity); + + const meta = { + worldbook, + name, + matchedKeyword: String(matchedKeyword || ""), + reason: "custom_keyword", + }; + + if (lazy) { + collector.lazyFilteredEntries.push(meta); + } else { + collector.filteredEntries.push(meta); + } +} + function registerIgnoredWorldInfoEntry( collector, entry = {}, @@ -175,6 +213,26 @@ function buildMvuDebugSummary(collector) { }; } +function buildCustomFilterDebugSummary( + collector, + { filterMode = "default", customFilterKeywords = [] } = {}, +) { + const filteredEntries = Array.isArray(collector?.filteredEntries) + ? collector.filteredEntries + : []; + const lazyFilteredEntries = Array.isArray(collector?.lazyFilteredEntries) + ? collector.lazyFilteredEntries + : []; + + return { + mode: String(filterMode || "default"), + keywords: [...(Array.isArray(customFilterKeywords) ? customFilterKeywords : [])], + filteredEntryCount: filteredEntries.length + lazyFilteredEntries.length, + filteredEntries: [...filteredEntries, ...lazyFilteredEntries], + lazyFilteredEntryCount: lazyFilteredEntries.length, + }; +} + function getStContext() { try { return globalThis.SillyTavern?.getContext?.() || {}; @@ -741,7 +799,13 @@ function selectActivatedEntries( async function loadNormalizedWorldbookEntries( worldbookHost, worldbookName, - { mvuCollector = null, lazy = false } = {}, + { + mvuCollector = null, + lazy = false, + filterMode = "default", + customFilterKeywords = [], + customFilterCollector = null, + } = {}, ) { const normalizedName = normalizeKey(worldbookName); if (!normalizedName || typeof worldbookHost?.getWorldbook !== "function") { @@ -776,12 +840,33 @@ async function loadNormalizedWorldbookEntries( }, normalizedName, ); - const ignoreReason = getMvuIgnoreReason(normalizedEntry); - if (ignoreReason) { - registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, { - lazy, - }); - continue; + if (String(filterMode || "default") === "custom") { + if (!normalizedEntry.enabled) { + continue; + } + if (Array.isArray(customFilterKeywords) && customFilterKeywords.length > 0) { + const nameLower = normalizedEntry.name.toLowerCase(); + const matchedKeyword = customFilterKeywords.find((keyword) => + nameLower.includes(String(keyword || "")), + ); + if (matchedKeyword) { + registerCustomFilteredEntry( + customFilterCollector, + normalizedEntry, + matchedKeyword, + { lazy }, + ); + continue; + } + } + } else { + const ignoreReason = getMvuIgnoreReason(normalizedEntry); + if (ignoreReason) { + registerIgnoredWorldInfoEntry(mvuCollector, normalizedEntry, ignoreReason, { + lazy, + }); + continue; + } } normalizedEntries.push(normalizedEntry); } @@ -789,7 +874,10 @@ async function loadNormalizedWorldbookEntries( return normalizedEntries; } -async function collectAllWorldbookEntries(worldbookHost = null) { +async function collectAllWorldbookEntries( + worldbookHost = null, + { filterMode = "default", customFilterKeywords = [] } = {}, +) { const resolvedWorldbookHost = worldbookHost || (await getWorldbookHost()); const { getWorldbook, @@ -883,6 +971,10 @@ async function collectAllWorldbookEntries(worldbookHost = null) { sourceLabel, fallback, snapshotRevision: Number(snapshotRevision || 0), + filterMode: String(filterMode || "default"), + customFilterKeywords: Array.isArray(customFilterKeywords) + ? customFilterKeywords + : [], }); debug.cache.key = cacheKey; @@ -902,6 +994,12 @@ async function collectAllWorldbookEntries(worldbookHost = null) { worldbookCount: worldbookEntriesCache.entries.length, loadMs: worldbookEntriesCache.debug?.loadMs || 0, mvu: worldbookEntriesCache.debug?.mvu || buildMvuDebugSummary(null), + customFilter: + worldbookEntriesCache.debug?.customFilter || + buildCustomFilterDebugSummary(null, { + filterMode, + customFilterKeywords, + }), cache: { ...debug.cache, hit: true, @@ -915,6 +1013,7 @@ async function collectAllWorldbookEntries(worldbookHost = null) { const loadedNames = new Set(); const startedAt = Date.now(); const mvuCollector = createMvuCollector(); + const customFilterCollector = createCustomFilterCollector(); async function loadWorldbookOnce(worldbookName) { const normalizedName = normalizeKey(worldbookName); @@ -925,7 +1024,12 @@ async function collectAllWorldbookEntries(worldbookHost = null) { const entries = await loadNormalizedWorldbookEntries( resolvedWorldbookHost, normalizedName, - { mvuCollector }, + { + mvuCollector, + filterMode, + customFilterKeywords, + customFilterCollector, + }, ); allEntries.push(...entries); } catch (error) { @@ -944,6 +1048,10 @@ async function collectAllWorldbookEntries(worldbookHost = null) { debug.worldbookCount = allEntries.length; debug.loadMs = Date.now() - startedAt; debug.mvu = buildMvuDebugSummary(mvuCollector); + debug.customFilter = buildCustomFilterDebugSummary(customFilterCollector, { + filterMode, + customFilterKeywords, + }); worldbookEntriesCache = { key: cacheKey, createdAt: Date.now(), @@ -1024,6 +1132,8 @@ function buildAdditionalMessages(entries = []) { .map((entry) => ({ role: entry.role, content: String(entry.content || "").trim(), + source: "worldInfo-atDepth", + sourceKey: "taskAdditionalMessages", })) .filter((entry) => entry.content); } @@ -1131,6 +1241,14 @@ export async function resolveTaskWorldInfo({ userMessage = "", templateContext = {}, } = {}) { + const filterMode = String(settings.worldInfoFilterMode || "default").trim(); + const isCustomFilter = filterMode === "custom"; + const customFilterKeywords = isCustomFilter + ? String(settings.worldInfoFilterCustomKeywords || "") + .split(",") + .map((keyword) => keyword.trim().toLowerCase()) + .filter(Boolean) + : []; const result = { beforeEntries: [], afterEntries: [], @@ -1172,12 +1290,19 @@ export async function resolveTaskWorldInfo({ warnings: [], resolvedEntries: [], mvu: buildMvuDebugSummary(null), + customFilter: buildCustomFilterDebugSummary(null, { + filterMode, + customFilterKeywords, + }), }, }; try { const worldbookHost = await getWorldbookHost(); - const collected = await collectAllWorldbookEntries(worldbookHost); + const collected = await collectAllWorldbookEntries(worldbookHost, { + filterMode, + customFilterKeywords, + }); const allEntries = Array.isArray(collected?.entries) ? collected.entries : []; const blockedContents = Array.isArray(collected?.blockedContents) ? collected.blockedContents @@ -1210,6 +1335,14 @@ export async function resolveTaskWorldInfo({ collected?.debug?.mvu && typeof collected.debug.mvu === "object" ? { ...collected.debug.mvu } : buildMvuDebugSummary(null), + customFilter: + collected?.debug?.customFilter && + typeof collected.debug.customFilter === "object" + ? { ...collected.debug.customFilter } + : buildCustomFilterDebugSummary(null, { + filterMode, + customFilterKeywords, + }), }; if (allEntries.length === 0) { return result; @@ -1258,6 +1391,7 @@ export async function resolveTaskWorldInfo({ ignoredLookup, seenEntries: new Set(), }; + const lazyCustomFilterCollector = createCustomFilterCollector(); const knownWorldbooks = new Set( allEntries.map((entry) => entry.worldbook).filter(Boolean), ); @@ -1272,6 +1406,9 @@ export async function resolveTaskWorldInfo({ { mvuCollector: lazyMvuCollector, lazy: true, + filterMode, + customFilterKeywords, + customFilterCollector: lazyCustomFilterCollector, }, ); knownWorldbooks.add(normalizedWorldbook); @@ -1292,6 +1429,29 @@ export async function resolveTaskWorldInfo({ newLazyIgnoredEntries.length, }; lazyMvuCollector.lazyFilteredEntries = []; + if (isCustomFilter) { + const newLazyCustomEntries = [ + ...lazyCustomFilterCollector.lazyFilteredEntries, + ]; + if (newLazyCustomEntries.length > 0) { + result.debug.customFilter = { + ...result.debug.customFilter, + filteredEntries: [ + ...(Array.isArray(result.debug.customFilter?.filteredEntries) + ? result.debug.customFilter.filteredEntries + : []), + ...newLazyCustomEntries, + ], + filteredEntryCount: + Number(result.debug.customFilter?.filteredEntryCount || 0) + + newLazyCustomEntries.length, + lazyFilteredEntryCount: + Number(result.debug.customFilter?.lazyFilteredEntryCount || 0) + + newLazyCustomEntries.length, + }; + } + lazyCustomFilterCollector.lazyFilteredEntries = []; + } return lazyEntries; }; @@ -1312,12 +1472,14 @@ export async function resolveTaskWorldInfo({ templateContext: normalizedTemplateContext, currentActivatedEntries: [...allActivated.values()], loadWorldbookEntries: lazyLoadWorldbookEntries, - resolveIgnoredEntry: (worldbookName, identifier) => - findIgnoredWorldInfoEntry( - { ignoredLookup }, - worldbookName, - identifier, - ), + resolveIgnoredEntry: isCustomFilter + ? () => null + : (worldbookName, identifier) => + findIgnoredWorldInfoEntry( + { ignoredLookup }, + worldbookName, + identifier, + ), }, ); @@ -1384,10 +1546,19 @@ export async function resolveTaskWorldInfo({ recursionWarnings.add(String(warning || "")); } - const mvuSanitized = sanitizeMvuContent(renderedContent, { - mode: "aggressive", - blockedContents, - }); + const mvuSanitized = isCustomFilter + ? { + text: renderedContent, + changed: false, + dropped: false, + reasons: [], + blockedHitCount: 0, + artifactRemovedCount: 0, + } + : sanitizeMvuContent(renderedContent, { + mode: "aggressive", + blockedContents, + }); if (mvuSanitized.dropped) { const warning = `世界书条目 ${entry.name} 渲染结果命中 MVU 规则,已跳过`; if (!result.debug.warnings.includes(warning)) { diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index 4a40dc6..d744a33 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -129,6 +129,8 @@ assert.equal(defaultSettings.enableReflection, true); assert.equal(defaultSettings.consolidationAutoMinNewNodes, 2); assert.equal(defaultSettings.enableAutoCompression, true); assert.equal(defaultSettings.compressionEveryN, 10); +assert.equal(defaultSettings.worldInfoFilterMode, "default"); +assert.equal(defaultSettings.worldInfoFilterCustomKeywords, ""); assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false); assert.equal(defaultSettings.embeddingTransportMode, "direct"); assert.equal(defaultSettings.taskProfilesVersion, 3); diff --git a/tests/task-worldinfo.mjs b/tests/task-worldinfo.mjs index a8786fe..ed115dc 100644 --- a/tests/task-worldinfo.mjs +++ b/tests/task-worldinfo.mjs @@ -122,6 +122,16 @@ const inlineDataTemplateEntry = createWorldbookEntry({ order: 22, }); +const commentKeywordProbeEntry = createWorldbookEntry({ + uid: 14, + name: "备注命中测试", + comment: "常驻备注", + content: "这条只用于验证 comment 不参与自定义过滤。", + strategyType: "selective", + keys: ["绝不会匹配到这里"], + order: 23, +}); + const extensionLiteralEntry = createWorldbookEntry({ uid: 4, name: "扩展语义正文", @@ -214,6 +224,7 @@ const worldbooksByName = { inlineSummaryEntry, inlineDataSummaryEntry, inlineDataTemplateEntry, + commentKeywordProbeEntry, extensionLiteralEntry, externalInlineEntry, mvuLazyProbeEntry, @@ -251,7 +262,9 @@ try { })); const { resolveTaskWorldInfo } = await import("../task-worldinfo.js"); - const { buildTaskPrompt } = await import("../prompt-builder.js"); + const { buildTaskPrompt, buildTaskLlmPayload } = await import( + "../prompt-builder.js" + ); const emptyTriggerWorldInfo = await resolveTaskWorldInfo({ chatMessages: [], @@ -335,6 +348,132 @@ try { ), true, ); + assert.equal(worldInfo.debug.customFilter.mode, "default"); + assert.equal(worldInfo.debug.customFilter.filteredEntryCount, 0); + + const customWorldInfo = await resolveTaskWorldInfo({ + settings: { + worldInfoFilterMode: "custom", + worldInfoFilterCustomKeywords: "", + }, + templateContext: { + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + userMessage: "继续调查", + }); + + assert.equal( + customWorldInfo.beforeEntries.some( + (entry) => entry.sourceName === "[mvu_update] 状态同步", + ), + true, + ); + assert.equal( + customWorldInfo.beforeEntries.some( + (entry) => entry.sourceName === "MVU 启发式条目", + ), + true, + ); + assert.match( + customWorldInfo.beforeText, + /secret=true<\/status_current_variable>/, + ); + assert.doesNotMatch( + customWorldInfo.beforeText, + /控制摘要:隐藏线索:Alice 正在调查/, + ); + assert.equal( + customWorldInfo.allEntries.some((entry) => entry.name === "EW/Dyn/线索"), + false, + ); + assert.equal(customWorldInfo.debug.mvu.filteredEntryCount, 0); + assert.equal(customWorldInfo.debug.customFilter.mode, "custom"); + assert.equal(customWorldInfo.debug.customFilter.filteredEntryCount, 0); + + const keywordWorldInfo = await resolveTaskWorldInfo({ + settings: { + worldInfoFilterMode: "custom", + worldInfoFilterCustomKeywords: "常驻", + }, + templateContext: { + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + userMessage: "继续调查", + }); + + assert.equal( + keywordWorldInfo.beforeEntries.some( + (entry) => entry.sourceName === "常驻设定", + ), + false, + ); + assert.equal( + keywordWorldInfo.allEntries.some((entry) => entry.name === "备注命中测试"), + true, + ); + assert.equal(keywordWorldInfo.debug.customFilter.filteredEntryCount, 1); + assert.equal( + keywordWorldInfo.debug.customFilter.filteredEntries[0].name, + "常驻设定", + ); + assert.equal( + keywordWorldInfo.debug.customFilter.filteredEntries[0].matchedKeyword, + "常驻", + ); + + const keywordCachePrime = await resolveTaskWorldInfo({ + settings: { + worldInfoFilterMode: "custom", + worldInfoFilterCustomKeywords: "常驻,缓存探针", + }, + templateContext: { + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + userMessage: "继续调查", + }); + assert.equal(keywordCachePrime.debug.cache.hit, false); + assert.equal(keywordCachePrime.debug.customFilter.filteredEntryCount, 1); + + const keywordCacheHit = await resolveTaskWorldInfo({ + settings: { + worldInfoFilterMode: "custom", + worldInfoFilterCustomKeywords: "常驻,缓存探针", + }, + templateContext: { + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + userMessage: "继续调查", + }); + assert.equal(keywordCacheHit.debug.cache.hit, true); + assert.equal(keywordCacheHit.debug.customFilter.filteredEntryCount, 1); + assert.equal( + keywordCacheHit.debug.customFilter.filteredEntries[0].name, + "常驻设定", + ); + + const defaultModeWithKeywords = await resolveTaskWorldInfo({ + settings: { + worldInfoFilterMode: "default", + worldInfoFilterCustomKeywords: "常驻", + }, + templateContext: { + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + userMessage: "继续调查", + }); + assert.equal( + defaultModeWithKeywords.beforeEntries.some( + (entry) => entry.sourceName === "常驻设定", + ), + true, + ); + assert.equal(defaultModeWithKeywords.debug.mvu.filteredEntryCount > 0, true); + assert.equal(defaultModeWithKeywords.debug.customFilter.filteredEntryCount, 0); const settings = { taskProfiles: { @@ -456,6 +595,82 @@ try { assert.equal(promptBuild.additionalMessages[0].content, "这是一条 atDepth 消息。"); assert.equal(promptBuild.debug.mvu.sanitizedFieldCount >= 0, true); + const customPromptBuild = await buildTaskPrompt( + { + ...settings, + worldInfoFilterMode: "custom", + worldInfoFilterCustomKeywords: "", + }, + "recall", + { + taskName: "recall", + userMessage: "继续调查", + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + ); + assert.match( + customPromptBuild.systemPrompt, + /secret=true<\/status_current_variable>/, + ); + assert.match(customPromptBuild.systemPrompt, /这一条不应该进入结果/); + assert.doesNotMatch( + customPromptBuild.systemPrompt, + /控制摘要:隐藏线索:Alice 正在调查/, + ); + const customPayload = buildTaskLlmPayload(customPromptBuild, "unused fallback"); + assert.equal( + customPayload.promptMessages.some((message) => + /secret=true<\/status_current_variable>/.test( + message.content, + ), + ), + true, + ); + + const interpolatedSettings = { + taskProfiles: { + recall: { + activeProfileId: "interpolated", + profiles: [ + { + id: "interpolated", + name: "插值预设", + taskType: "recall", + builtin: false, + blocks: [ + { + id: "interp-system", + type: "custom", + content: "世界书插值:\\n{{worldInfoBefore}}", + role: "system", + enabled: true, + order: 0, + injectionMode: "append", + }, + ], + }, + ], + }, + }, + worldInfoFilterMode: "custom", + worldInfoFilterCustomKeywords: "", + }; + const customInterpolatedPromptBuild = await buildTaskPrompt( + interpolatedSettings, + "recall", + { + taskName: "recall", + userMessage: "继续调查", + recentMessages: "我们继续调查那条线索", + charName: "Alice", + }, + ); + assert.match( + customInterpolatedPromptBuild.systemPrompt, + /secret=true<\/status_current_variable>/, + ); + const noWorldInfoBlockSettings = { taskProfiles: { recall: {