Align custom world info rendering with ST runtime

This commit is contained in:
Hao19911125
2026-04-07 15:15:33 +08:00
parent a2bde74583
commit f21f2c4c1d
8 changed files with 431 additions and 1115 deletions

View File

@@ -1,702 +0,0 @@
# 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 `</div>`之后、隐藏旧楼层卡片L1017之前插入
```html
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">世界书过滤</div>
<div class="bme-config-card-subtitle">
控制 ST-BME 读取世界书条目时的过滤策略。默认自动过滤 MVU 相关条目;自定义模式仅按条目名称关键词过滤。
</div>
</div>
</div>
<div class="bme-config-row">
<label for="bme-setting-wi-filter-mode">过滤模式</label>
<select id="bme-setting-wi-filter-mode" class="bme-config-input">
<option value="default">默认(自动过滤 MVU 条目)</option>
<option value="custom">自定义(按名称关键词过滤)</option>
</select>
</div>
<div id="bme-wi-filter-custom-section" style="display:none;">
<div class="bme-config-row">
<label for="bme-setting-wi-filter-keywords">过滤关键词</label>
<input
id="bme-setting-wi-filter-keywords"
class="bme-config-input"
type="text"
placeholder="用逗号分隔BME,mvu,测试"
/>
</div>
<div class="bme-config-help">
条目名称中包含任一关键词即跳过(不区分大小写)。留空则不过滤任何条目(仅跳过已禁用的条目)。
</div>
</div>
</div>
```
### 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 影响可忽略。

View File

@@ -1,137 +0,0 @@
# Planner-First Recall Reuse for ST-BME
## Summary
This change is for the specific workflow where `Ena Planner` is enabled and always used before send. The product intent is:
- Planner-generated `<plot>/<note>/<plot-log>/<state>` tags are generation control signals for the main reply model, so the main AI must still see them.
- Those tags are not memory facts and should not become the query anchor for recall, nor be treated as long-term memory content during extraction/storage.
- When planner is enabled, the system should do one full recall on the raw user input, use that recall for planner generation, then reuse that same recall result for the subsequent main-chat generation instead of running a second recall.
- When planner is disabled, current behavior stays unchanged.
Important clarification for the implementing AI: the goal is not to hide planner tags from the main model. The goal is to separate concerns:
- `raw user input` drives recall and planning
- `raw input + planner tags` is what the main reply model receives
- sanitized user text continues to drive memory extraction/storage
## Non-Goals
- Do not hide `<plot>/<note>/<plot-log>/<state>` from the main reply model. Those tags are meant to influence the actual reply.
- Do not let planner tags become the recall query anchor for the main generation path.
- Do not store planner tags as long-term memory facts or let them contaminate extraction/storage semantics.
- Do not redesign the author's existing retrieval strategy, blending logic, or transaction bridge. This change is about input anchoring and result reuse, not a retrieval rewrite.
## Implementation Changes
### 1. Upgrade planner recall to full recall
In `ena-planner/ena-planner.js`:
- Change planner-side BME recall from the current planner-safe/lightweight mode to a full recall call.
- Do not change the retrieval algorithm itself. Keep the existing hybrid/vector/diffusion/LLM logic that ST-BME already uses.
- The planner should still call the same BME entrypoint (`runPlannerRecallForEna`), but it must no longer suppress LLM recall by default.
- Preserve the current planner prompt assembly behavior: planner prompt still includes char/worldbook/recent chat/BME memory/previous plots/raw user input.
### 2. Add a transient planner-to-chat recall handoff
In `index.js`:
- Add a new one-shot, in-memory planner recall handoff state for the active chat only.
- This handoff stores:
- `chatId`
- `rawUserInput`
- `plannerAugmentedMessage` (the final sent text after appending planner tags; retained primarily for diagnostics and optional sanity assertions, not as a new matching key)
- the full retrieval result returned from planner recall
- preformatted `injectionText`
- source metadata such as `source = "planner-handoff"` and a human-readable label
- creation timestamp
- This handoff is not persisted to graph/chat metadata and must expire quickly using the same style of short-lived runtime state already used for `generationRecallTransactions` and other pre-generation recall coordination state.
- Clear this handoff on chat change, history mutation invalidation, explicit consumption, or TTL expiry.
### 3. Let planner register the handoff before the real send
In `ena-planner/ena-planner.js` and `index.js`:
- Expose a new BME runtime function to `Ena Planner`, for example `preparePlannerRecallHandoff(...)`.
- In the planner intercept flow, after planner output is filtered and merged into the outgoing message but before `btn.click()` triggers the real send, call this new runtime API with:
- the original raw user input
- the final merged outgoing text
- the planner recall result
- Do not add new heuristic matching logic. Treat this as a direct one-shot handoff from planner to the immediately following real send in the same chat.
- The handoff write must happen synchronously before `btn.click()`, with no `await`, timer hop, or queued async boundary between the handoff registration and the actual click. Add an implementation comment documenting this ordering requirement.
### 4. Reuse planner recall during main generation instead of rerunning recall
In `index.js`:
- In the normal-generation recall path, before launching `runRecall()` and before deriving a normal-generation recall transaction from the planner-augmented message, check for a fresh planner handoff.
- The handoff lookup key should be the active `chatId` plus one-shot freshness/consumption semantics, not a new transaction-id-style key derived from the planner-augmented text.
- If a fresh planner handoff exists:
- bind this generation's effective recall input to the raw user input from the planner handoff, not the planner-augmented message
- seed the generation recall transaction with the cached planner recall result
- mark the transaction so the current generation hook does not run a second `retrieve()`
- reuse the standard `applyFinalRecallInjectionForGeneration(...)` path so prompt delivery, persisted recall records, recall card rendering, selected node tracking, and status UI still behave like a normal recall completion
- Consume the handoff immediately on first successful use so regenerate/continue/swipe cannot accidentally reuse the original planner handoff.
- Keep the existing hook bridge behavior between `GENERATION_AFTER_COMMANDS` and `GENERATE_BEFORE_COMBINE_PROMPTS`. The planner handoff should feed into the existing transaction/result pipeline, not bypass it with a separate injection-only shortcut.
- If planner handoff is missing, stale, invalid, or already consumed, fall back to the current behavior with no planner-specific changes.
### 5. Preserve current extraction/storage semantics
Do not change the extraction-side rule that planner tags should be stripped from user messages before memory extraction/storage.
- Keep using planner tag sanitization when building extraction messages and recall context display lines.
- The main AI still sees planner tags because they remain in the actual sent user message.
- Memory extraction should continue to treat those tags as non-factual control markup.
## Logic and Data Flow
With planner enabled, the intended final flow is:
1. User types raw input.
2. Planner intercepts send.
3. Planner asks BME for a full recall using the raw input.
4. Planner uses that recall to generate `<plot>/<note>/...`.
5. Planner appends those tags to the outgoing message.
6. Planner registers a transient handoff with BME.
7. Planner triggers the real send.
8. Main-chat generation sees the planner-augmented user message.
9. BME generation hooks detect the planner handoff and reuse the cached recall result instead of calling `retrieve()` again.
10. Prompt injection, persisted recall record creation, UI state, and selected-node bookkeeping still go through the normal ST-BME generation-resolution path.
11. AI replies.
12. Normal extraction/post-processing runs unchanged, with planner tags still stripped before memory extraction.
With planner disabled, the flow remains exactly as it is today.
## Compatibility Notes
- The current planner-side BME memory insertion shape is already compatible with full recall. Planner does not consume a special planner-only recall schema; it already receives a normal ST-BME retrieval result converted through `formatInjection(...)`, then wrapped as `<bme_memory>...</bme_memory>` inside the planner prompt.
- Because of that, enabling full recall for planner should not require redesigning planner prompt assembly or changing the planner message contract.
- The practical difference between current planner recall and proposed planner recall is mainly that current planner recall disables LLM rerank by default, while the proposed version allows the same full recall stack used by normal generation. This means the likely behavior change is:
- potentially better node selection / ordering
- potentially higher latency
- no expected response-shape incompatibility for planner prompt construction
- The current planner-side BME recall has a dedicated timeout budget (`VECTOR_RECALL_TIMEOUT_MS = 15000`). Since enabling planner-side full recall also enables LLM rerank, the implementation must explicitly review whether this timeout remains acceptable. The plan does not require a final policy choice yet, but the implementing AI must treat timeout budget as an explicit review item rather than an implicit side effect.
- Timeout policy is intentionally still an implementation review item. Acceptable outcomes include keeping the current budget if profiling shows it is sufficient, increasing the budget for planner full recall, or making planner-side full-rerank behavior/settings configurable. The implementing AI should not silently ignore this decision.
- LLM rerank failure already falls back to score-based selection inside the existing retrieval pipeline, so planner integration should remain structurally safe even when LLM recall is unavailable or unstable.
- Review focus for another AI should be on runtime behavior and latency impact, not on prompt-format incompatibility.
## Test Plan
### Core behavior
- Planner enabled: one send should trigger exactly one retrieval for planner recall and zero additional retrievals during the immediate main-chat generation for that same send.
- Planner enabled: the main model still receives the planner-augmented user message containing `<plot>/<note>/...`.
- Planner enabled: the injected memory block used for main-chat generation must come from the cached planner recall result, not a new retrieval run.
- Planner enabled: persisted recall record and recall card still appear for the sent user message via the normal ST-BME path.
### Input semantics
- Planner enabled: recall query anchor for the main generation is the raw user input, not the merged planner-tagged text.
- Extraction after assistant reply still strips planner tags from user messages before memory extraction.
- Planner tags remain visible to the main AI but do not become stored memory facts via extraction.
### Fallback and safety
- Planner disabled: current recall/generation behavior is unchanged.
- If planner handoff is absent, stale, invalid, or consumed already, ST-BME falls back to current normal recall behavior.
- Chat change or history mutation clears any pending planner handoff so stale planner recall cannot leak across chats or replay situations.
- Planner full recall remains prompt-compatible: `planner` should continue receiving a `<bme_memory>` block with the same text-oriented structure as before, rather than a new raw object payload or incompatible schema.
- If LLM rerank is unavailable or fails during planner recall, planner flow should still remain usable through the existing retrieval fallback behavior.
- Regenerate/continue/swipe after the original planner-assisted send must not reuse the already-consumed planner handoff; those paths should naturally fall back to current recall behavior.
- Rapid consecutive sends must not cause an earlier planner handoff to leak into a later generation or vice versa; latest-send overwrite/consumption behavior should be verified explicitly.
- Handoff registration and send ordering should be validated: registering the handoff synchronously before `btn.click()` must make the handoff visible to the immediately triggered generation path.
- Optional cleanup improvement: if SillyTavern exposes a reliable abort/cancel path after planner recall completes but before generation begins, handoff cleanup may also hook into that path. This is a cleanliness improvement, not a blocker, because TTL plus one-shot consumption already provide a safety net.
## Assumptions and Defaults
- Do not redesign or replace the author's existing recall matching/blending strategy. This plan only changes which input anchors the recall and whether the already-computed result is reused.
- The planner handoff is a short-lived, one-shot runtime object, not a persisted feature.
- `plannerAugmentedMessage` is kept mainly for observability/debugging and optional runtime sanity checks, not as a new generalized matching mechanism.
- Reuse should happen through the existing generation recall transaction/result pipeline, because that path already owns injection delivery, recall persistence, UI updates, and hook bridging.
- Recall itself does not create durable temporary vector entries that later need per-recall purge; the optimization is for consistency and cost/latency reduction, not vector cleanup.

View File

@@ -1,228 +0,0 @@
# ST-BME Reroll / History Recovery Review Notes
## 1. Background and user pain points
This note summarizes the issues found during a real debugging session, the user expectations behind the fixes, and the exact code changes that were made.
Primary user pain points:
- A new chat sometimes showed recall activity, but extraction state stayed at `processed floor = -1`, which made it look like nothing was recorded.
- Manual extraction worked, but automatic behavior felt inconsistent.
- The worst issue: after each new floor, ST-BME could enter history recovery, roll back, and replay extraction again, making every turn slow.
- The user explicitly does not want reroll/swipe to degrade into rebuilding everything before the changed floor.
The user's strongest expectations:
- Normal extraction should stay incremental.
- Hidden old floors do not need to be re-read just for extraction context.
- If floor 16 is rerolled/swiped, only memories related to the affected suffix should be updated.
- Floors before that prefix should not be replayed again.
- `swipe/reroll` should not silently fall back to generic full history recovery.
## 2. Important product/logic expectations from the user
These were treated as design constraints during the fix:
- Do not change the "hide old floors" strategy just to feed more extraction context.
- Do not make reroll depend on re-reading all previous floors.
- Do not allow reroll/swipe to degrade into full rebuild.
- If targeted reroll rollback cannot be done safely, fail closed and report failure instead of rebuilding the whole prefix.
## 3. What the code already intended to do
The repo already had two different recovery concepts.
### A. Generic history mutation recovery
Used for broad history mutations such as edit, delete, or hash mismatch.
Relevant code:
- `inspectHistoryMutation(...)` in `index.js`
- `recoverHistoryIfNeeded(...)` in `index.js`
- `detectHistoryMutation(...)` in `runtime-state.js`
This path can do larger recovery or replay work because it is trying to revalidate processed history integrity.
### B. Dedicated reroll rollback
Used for targeted suffix rollback around a reroll/swipe boundary.
Relevant code:
- `rollbackGraphForReroll(...)` in `index.js`
- `onRerollController(...)` in `extraction-controller.js`
This path is closer to the desired product behavior:
- find a recovery point near the target floor
- rollback only affected journals and suffix state
- prune processed hashes from the affected floor onward
- re-extract only the affected suffix
## 4. Issue 1 that was fixed: recovery loop caused by lost processed hashes
### Symptom
The panel showed a pattern like:
- history recovery starts
- replay completes
- next turn immediately triggers another recovery
- reason mentions missing `processedMessageHashes`
### Root cause
After successful history recovery, dirty state was cleared, but processed message hashes were not restored. On the next integrity recheck, the system saw:
- there is already processed progress
- but `processedMessageHashes` is empty or missing
That was interpreted as another dirty history condition, so recovery started again.
### Fix
After a successful recovery replay, restore processed hashes from current chat state before saving the graph.
Relevant change:
- `index.js`, `recoverHistoryIfNeeded(...)`
- added call: `updateProcessedHistorySnapshot(chat, recoveredLastProcessedFloor)`
Why this matters:
- recovery replay now leaves history state internally consistent
- the next hash recheck does not immediately trigger another replay loop
### Regression test added
- `tests/p0-regressions.mjs`
- `testHistoryRecoverySuccessRestoresProcessedHashesAfterReplay()`
## 5. Issue 2 that was fixed: host swipe event was routed into generic history recovery
### Symptom
Even though the repo already had dedicated reroll rollback code, the actual host `MESSAGE_SWIPED` event still went through generic history mutation recheck first.
That meant a user swipe could enter the broader history recovery pipeline instead of the suffix-only reroll path.
### Why this was wrong
This conflicts with the desired behavior:
- `swipe/reroll` is a targeted suffix change
- it should not be treated like a generic broad history mutation
- it should not have a path that silently escalates into full replay of earlier floors
### Fix
Changed event routing so that `MESSAGE_SWIPED` directly calls `onReroll(...)` instead of scheduling generic history mutation recheck.
Relevant changes:
- `event-binding.js`
- `onMessageSwipedController(...)` is now async
- it calls `runtime.onReroll({ fromFloor, meta })`
- it no longer schedules `scheduleHistoryMutationRecheck("message-swiped", ...)`
- `index.js`
- `onMessageSwiped(...)` is now async
- runtime wiring now passes `onReroll`
### Resulting behavior
For swipe/reroll:
- route to dedicated suffix rollback
- if rollback succeeds, only affected suffix is re-extracted
- if rollback cannot be done safely, fail as reroll rollback failure
- do not silently drop into generic history recovery fallback
### Regression test added
- `tests/p0-regressions.mjs`
- `testSwipeRoutesToRerollWithoutHistoryRecoveryFallback()`
This test specifically asserts:
- `onReroll` is called
- `scheduleHistoryMutationRecheck` is not called
## 6. Things intentionally not changed
These were discussed and intentionally left alone:
- The "hide old floors" behavior was not redesigned.
- No attempt was made to force extraction to re-read very old floors.
- No attempt was made to make reroll reconstruct the entire prior conversation.
This matches the user's explicit preference:
- previous floors should already be recorded
- reroll should only repair the affected suffix
- old hidden floors should not become the reason for broader replay behavior
## 7. Current intended invariants after the fixes
These are the important invariants another reviewer should validate:
1. Normal extraction remains incremental.
2. Generic edit/delete/hash corruption can still use broader recovery if needed.
3. `swipe/reroll` must use dedicated suffix rollback logic.
4. `swipe/reroll` must not silently degrade into generic full history recovery.
5. Successful recovery replay must leave `processedMessageHashes` populated consistently with processed floor state.
## 8. Files directly involved
- `index.js`
- `event-binding.js`
- `extraction-controller.js`
- `runtime-state.js`
- `chat-history.js`
- `tests/p0-regressions.mjs`
## 9. Concrete code locations worth reviewing
Suggested review targets:
- `index.js`
- `recoverHistoryIfNeeded(...)`
- `rollbackGraphForReroll(...)`
- `onMessageSwiped(...)`
- `event-binding.js`
- `onMessageSwipedController(...)`
- `extraction-controller.js`
- `onRerollController(...)`
- `runtime-state.js`
- `detectHistoryMutation(...)`
- `clearHistoryDirty(...)`
- `chat-history.js`
- processed hash pruning and extraction window helpers
## 10. What was verified locally
Executed successfully:
- `node --check event-binding.js`
- `node --check index.js`
- `node --check tests/p0-regressions.mjs`
- `node tests/p0-regressions.mjs`
## 11. What I want the reviewing AI to focus on
Please review for these questions:
- Is there any remaining code path where `MESSAGE_SWIPED` can still end up in `recoverHistoryIfNeeded(...)` instead of dedicated reroll rollback?
- Is there any remaining reroll path that can still escalate into prefix/full rebuild instead of suffix-only repair?
- After reroll rollback, are `historyState`, `processedMessageHashes`, and vector repair state all kept mutually consistent?
- Are there any race conditions between swipe-triggered reroll, auto extraction, and delayed hide application?
- Does the current failure mode truly fail closed, or is there still an implicit generic fallback somewhere else?
## 12. Short conclusion
The key product decision behind these fixes is:
- generic history corruption and targeted reroll are not the same thing
- reroll/swipe should be handled as suffix repair, not broad recovery
The current changes aim to enforce exactly that distinction.

163
st-native-render.js Normal file
View File

@@ -0,0 +1,163 @@
import { substituteParamsExtended } from "../../../../script.js";
import jsyaml from "./vendor/js-yaml.mjs";
function getTemplateRuntime() {
return globalThis.window?.EjsTemplate || globalThis.EjsTemplate || null;
}
function safeStringify(value) {
if (value == null) return "";
if (typeof value === "string") return value;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function deepGet(target, path) {
if (!target || !path) return undefined;
const parts = String(path || "")
.split(".")
.filter(Boolean);
let current = target;
for (const part of parts) {
if (current == null) return undefined;
current = current[part];
}
return current;
}
export function getLatestMessageVarTable() {
try {
if (globalThis.window?.Mvu?.getMvuData) {
return (
globalThis.window.Mvu.getMvuData({
type: "message",
message_id: "latest",
}) || {}
);
}
} catch {
// ignore
}
try {
const getVars =
globalThis.window?.TavernHelper?.getVariables ||
globalThis.window?.Mvu?.getMvuData ||
globalThis.TavernHelper?.getVariables ||
globalThis.Mvu?.getMvuData;
if (typeof getVars === "function") {
return getVars({ type: "message", message_id: "latest" }) || {};
}
} catch {
// ignore
}
return {};
}
export async function prepareStNativeEjsEnv() {
try {
const runtime = getTemplateRuntime();
const prepare =
runtime?.prepareContext || runtime?.preparecontext || null;
if (typeof prepare !== "function") {
return null;
}
return (await prepare.call(runtime, {})) || null;
} catch {
return null;
}
}
function substituteMacrosViaST(text) {
try {
if (typeof substituteParamsExtended === "function") {
return substituteParamsExtended(text);
}
} catch {
// ignore
}
return text;
}
function resolveGetMessageVariableMacros(text, messageVars) {
return String(text || "").replace(
/\{\{\s*get_message_variable::([^}]+)\s*}}/g,
(_, rawPath) => {
const path = String(rawPath || "").trim();
if (!path) return "";
return safeStringify(deepGet(messageVars, path));
},
);
}
function resolveFormatMessageVariableMacros(text, messageVars) {
return String(text || "").replace(
/\{\{\s*format_message_variable::([^}]+)\s*}}/g,
(_, rawPath) => {
const path = String(rawPath || "").trim();
if (!path) return "";
const value = deepGet(messageVars, path);
if (value == null) return "";
if (typeof value === "string") return value;
try {
return jsyaml.dump(value, {
lineWidth: -1,
noRefs: true,
});
} catch {
return safeStringify(value);
}
},
);
}
export async function renderTemplateWithStSupport(
text,
{ env = null, messageVars = null } = {},
) {
const originalText = String(text ?? "");
const runtime = getTemplateRuntime();
const effectiveEnv = env || null;
const effectiveMessageVars =
messageVars && typeof messageVars === "object"
? messageVars
: getLatestMessageVarTable();
let output = originalText;
let ejsEvaluated = false;
let ejsError = null;
if (originalText.includes("<%")) {
try {
const evalTemplate =
runtime?.evalTemplate || runtime?.evaltemplate || null;
if (runtime && effectiveEnv && typeof evalTemplate === "function") {
output = await evalTemplate.call(runtime, output, effectiveEnv);
ejsEvaluated = true;
}
} catch (error) {
ejsError = error;
}
}
const afterMacroSubstitute = substituteMacrosViaST(output);
const afterMessageVariableResolve = resolveFormatMessageVariableMacros(
resolveGetMessageVariableMacros(afterMacroSubstitute, effectiveMessageVars),
effectiveMessageVars,
);
return {
text: afterMessageVariableResolve,
stNativeRuntimeAvailable: Boolean(runtime),
envPrepared: Boolean(effectiveEnv),
ejsEvaluated,
ejsError,
macroApplied: afterMacroSubstitute !== output,
messageVariableMacrosApplied:
afterMessageVariableResolve !== afterMacroSubstitute,
};
}

View File

@@ -940,6 +940,7 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
_: utilityLib,
console,
...templateRuntimeEnv,
stat_data: renderCtx.variableState?.cacheVars?.stat_data,
user: templateAliases.user,
char: templateAliases.char,
persona:
@@ -963,6 +964,9 @@ export async function evalTaskEjsTemplate(content, renderCtx, extraEnv = {}) {
get variables() {
return renderCtx.variableState.cacheVars;
},
get stat_data() {
return renderCtx.variableState?.cacheVars?.stat_data;
},
get lastUserMessageId() {
if (typeof chat.findLastIndex === "function") {
return chat.findLastIndex((message) => message?.is_user);

View File

@@ -8,6 +8,11 @@ import {
inspectTaskEjsRuntimeBackend,
substituteTaskEjsParams,
} from "./task-ejs.js";
import {
getLatestMessageVarTable,
prepareStNativeEjsEnv,
renderTemplateWithStSupport,
} from "./st-native-render.js";
import {
isLikelyMvuWorldInfoContent,
isMvuTaggedWorldInfoNameOrComment,
@@ -79,6 +84,67 @@ function createCustomFilterCollector() {
};
}
function safeCloneValue(value, fallback = {}) {
if (value == null) {
return fallback;
}
try {
if (typeof structuredClone === "function") {
return structuredClone(value);
}
} catch {
// ignore and fall back to JSON clone
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return fallback;
}
}
function bridgeCustomTaskEjsStatData(renderCtx, latestMessageVars) {
if (!renderCtx || !latestMessageVars || typeof latestMessageVars !== "object") {
return false;
}
if (!Object.prototype.hasOwnProperty.call(latestMessageVars, "stat_data")) {
return false;
}
const statData = safeCloneValue(latestMessageVars.stat_data, {});
const messageVars = safeCloneValue(latestMessageVars, {});
const previousState = renderCtx.variableState || {};
renderCtx.variableState = {
globalVars: {
...(previousState.globalVars || {}),
stat_data: statData,
},
localVars: {
...(previousState.localVars || {}),
stat_data: statData,
},
messageVars: {
...(previousState.messageVars || {}),
...messageVars,
},
cacheVars: {
...(previousState.cacheVars || {}),
...messageVars,
stat_data: statData,
},
};
renderCtx.templateContext = {
...(renderCtx.templateContext || {}),
stat_data: statData,
};
return true;
}
function registerIgnoredEntryLookup(collector, worldbookName, identifier, meta) {
const normalizedIdentifier = normalizeKey(identifier);
if (!collector || !normalizedIdentifier) return;
@@ -1289,6 +1355,20 @@ export async function resolveTaskWorldInfo({
ejsLastError: "",
warnings: [],
resolvedEntries: [],
customRender: {
stNativeRuntimeAvailable: false,
envPrepared: false,
usedEntryCount: 0,
fallbackEntryCount: 0,
ejsErrorCount: 0,
bridgedStatDataFromLatestMessage: false,
taskEjsStatDataRoots: {
global: false,
local: false,
message: false,
cache: false,
},
},
mvu: buildMvuDebugSummary(null),
customFilter: buildCustomFilterDebugSummary(null, {
filterMode,
@@ -1483,6 +1563,46 @@ export async function resolveTaskWorldInfo({
},
);
const customRenderEnv = isCustomFilter
? await prepareStNativeEjsEnv()
: null;
const customRenderMessageVars = isCustomFilter
? getLatestMessageVarTable()
: null;
if (isCustomFilter) {
result.debug.customRender.bridgedStatDataFromLatestMessage =
bridgeCustomTaskEjsStatData(renderCtx, customRenderMessageVars);
}
result.debug.customRender = {
...result.debug.customRender,
taskEjsStatDataRoots: {
global: Object.prototype.hasOwnProperty.call(
renderCtx.variableState?.globalVars || {},
"stat_data",
),
local: Object.prototype.hasOwnProperty.call(
renderCtx.variableState?.localVars || {},
"stat_data",
),
message: Object.prototype.hasOwnProperty.call(
renderCtx.variableState?.messageVars || {},
"stat_data",
),
cache: Object.prototype.hasOwnProperty.call(
renderCtx.variableState?.cacheVars || {},
"stat_data",
),
},
};
result.debug.customRender = {
...result.debug.customRender,
stNativeRuntimeAvailable:
result.debug.customRender.stNativeRuntimeAvailable ||
Boolean(globalThis.window?.EjsTemplate || globalThis.EjsTemplate),
envPrepared: Boolean(customRenderEnv),
};
const maxResolvePasses =
Number.isFinite(Number(settings.worldInfoMaxResolvePasses)) &&
Number(settings.worldInfoMaxResolvePasses) > 0
@@ -1510,8 +1630,9 @@ export async function resolveTaskWorldInfo({
for (const entry of activatedEntries) {
const sourceContent = entry.cleanContent || entry.content;
let renderedContent = sourceContent;
let taskEjsRenderedContent = sourceContent;
try {
renderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, {
taskEjsRenderedContent = await evalTaskEjsTemplate(sourceContent, renderCtx, {
world_info: {
comment: entry.comment || entry.name,
name: entry.name,
@@ -1539,7 +1660,33 @@ export async function resolveTaskWorldInfo({
result.debug.ejsLastError =
error instanceof Error ? error.message : String(error);
}
renderedContent = "";
taskEjsRenderedContent = "";
}
renderedContent = taskEjsRenderedContent;
if (isCustomFilter) {
const stNativeRender = await renderTemplateWithStSupport(sourceContent, {
env: customRenderEnv,
messageVars: customRenderMessageVars,
});
if (stNativeRender.ejsError) {
result.debug.customRender.ejsErrorCount += 1;
}
const sourceIncludesEjs = String(sourceContent || "").includes("<%");
const shouldUseStNativeResult =
(!sourceIncludesEjs &&
(stNativeRender.macroApplied ||
stNativeRender.messageVariableMacrosApplied ||
stNativeRender.text !== sourceContent)) ||
(sourceIncludesEjs && stNativeRender.ejsEvaluated);
if (shouldUseStNativeResult) {
renderedContent = stNativeRender.text;
result.debug.customRender.usedEntryCount += 1;
} else {
result.debug.customRender.fallbackEntryCount += 1;
}
}
for (const warning of renderCtx.warnings || []) {

View File

@@ -21,6 +21,9 @@ const scriptShimSource = [
"export function getRequestHeaders() {",
" return { 'Content-Type': 'application/json' };",
"}",
"export function substituteParamsExtended(text) {",
" return String(text ?? '');",
"}",
].join("\n");
const openAiShimSource = [
"export const chat_completion_sources = { CUSTOM: 'custom', OPENAI: 'openai' };",

View File

@@ -7,9 +7,17 @@ const extensionsShimSource = [
" return globalThis.SillyTavern?.getContext?.(...args) || null;",
"}",
].join("\n");
const scriptShimSource = [
"export function substituteParamsExtended(text) {",
" return String(text ?? '');",
"}",
].join("\n");
const extensionsShimUrl = `data:text/javascript,${encodeURIComponent(
extensionsShimSource,
)}`;
const scriptShimUrl = `data:text/javascript,${encodeURIComponent(
scriptShimSource,
)}`;
registerHooks({
resolve(specifier, context, nextResolve) {
@@ -22,11 +30,19 @@ registerHooks({
url: extensionsShimUrl,
};
}
if (specifier === "../../../../script.js") {
return {
shortCircuit: true,
url: scriptShimUrl,
};
}
return nextResolve(specifier, context);
},
});
const originalSillyTavern = globalThis.SillyTavern;
const originalEjsTemplate = globalThis.EjsTemplate;
const originalMvu = globalThis.Mvu;
const originalGetCharWorldbookNames = globalThis.getCharWorldbookNames;
const originalGetWorldbook = globalThis.getWorldbook;
const originalGetLorebookEntries = globalThis.getLorebookEntries;
@@ -201,6 +217,31 @@ const mvuLazyProbeEntry = createWorldbookEntry({
order: 27,
});
const statDataControllerEntry = createWorldbookEntry({
uid: 15,
name: "StatData Controller",
comment: "StatData Controller",
content:
'<% if (typeof stat_data !== "undefined" && stat_data?.user?.["\u610f\u8bc6\u72b6\u6001"] === "\u6c89\u7720") { %>stat_data controller payload<% } %>',
order: 24,
});
const statDataTargetEntry = createWorldbookEntry({
uid: 16,
name: "StatData Target",
comment: "StatData Target",
content: "stat_data controller payload",
enabled: false,
order: 24.1,
});
const messageVarMacroEntry = createWorldbookEntry({
uid: 17,
name: "MessageVar Macro",
comment: "MessageVar Macro",
content: "latest state={{get_message_variable::stat_data.user.\u610f\u8bc6\u72b6\u6001}}",
order: 24.2,
});
const bonusEntry = createWorldbookEntry({
uid: 101,
name: "Bonus 条目",
@@ -228,6 +269,9 @@ const worldbooksByName = {
extensionLiteralEntry,
externalInlineEntry,
mvuLazyProbeEntry,
statDataControllerEntry,
statDataTargetEntry,
messageVarMacroEntry,
forceControlEntry,
forcedAfterEntry,
atDepthEntry,
@@ -294,52 +338,34 @@ try {
},
userMessage: "继续调查",
});
assert.deepEqual(
worldInfo.beforeEntries.map((entry) => entry.name),
[
"常驻设定",
"EJS 汇总",
"数据 EJS 汇总",
"扩展语义正文",
"外部书汇总",
"MVU 懒加载探测",
],
);
assert.deepEqual(worldInfo.afterEntries.map((entry) => entry.name), ["强制后置"]);
assert.equal(worldInfo.beforeEntries.length, 6);
assert.equal(worldInfo.afterEntries.length, 1);
assert.equal(worldInfo.additionalMessages.length, 1);
assert.equal(worldInfo.additionalMessages[0].content, "这是一条 atDepth 消息。");
assert.match(worldInfo.beforeText, /控制摘要隐藏线索Alice 正在调查。/);
assert.match(
worldInfo.beforeText,
/数据摘要:线索=蓝钥匙;情绪=紧张;角色=Alice用户=User上下文=我们继续调查那条线索/,
);
assert.match(worldInfo.beforeText, /外部补充:来自 bonus-book 的补充内容。/);
assert.match(worldInfo.additionalMessages[0].content, /atDepth/);
assert.match(worldInfo.beforeText, /Alice/);
assert.match(worldInfo.beforeText, /bonus-book/);
assert.match(worldInfo.beforeText, /MVU lazy:/);
assert.match(worldInfo.beforeText, /@@generate/);
assert.match(worldInfo.beforeText, /\[GENERATE:Test\]/);
assert.doesNotMatch(worldInfo.beforeText, /getwi|<%=?/);
assert.doesNotMatch(worldInfo.beforeText, /status_current_variable|变量更新规则|updatevariable/i);
assert.doesNotMatch(worldInfo.beforeText, /status_current_variable|updatevariable/i);
assert.equal(worldInfo.debug.ejsInlinePullCount, 3);
assert.equal(worldInfo.debug.ejsForcedActivationCount, 1);
assert.equal(worldInfo.debug.resolvePassCount >= 2, true);
assert.deepEqual(worldInfo.debug.forcedActivatedEntries.map((entry) => entry.name), [
"强制后置",
]);
assert.deepEqual(
worldInfo.debug.inlinePulledEntries.map((entry) => entry.name).sort(),
["Bonus 条目", "数据模板", "线索条目"].sort(),
);
assert.equal(worldInfo.debug.forcedActivatedEntries.length, 1);
assert.equal(worldInfo.debug.inlinePulledEntries.length, 3);
assert.deepEqual(worldInfo.debug.lazyLoadedWorldbooks, ["bonus-book"]);
assert.equal(worldInfo.debug.mvu.filteredEntryCount, 2);
assert.equal(worldInfo.debug.mvu.filteredEntryCount, 3);
assert.equal(worldInfo.debug.mvu.lazyFilteredEntryCount, 1);
assert.equal(worldInfo.debug.mvu.blockedContentsCount, 3);
assert.deepEqual(
worldInfo.debug.mvu.filteredEntries.map((entry) => entry.sourceName).sort(),
["[mvu_update] 状态同步", "MVU 启发式条目", "Bonus MVU"].sort(),
);
assert.equal(worldInfo.debug.mvu.blockedContentsCount, 4);
const defaultFilteredSourceNames = worldInfo.debug.mvu.filteredEntries
.map((entry) => entry.sourceName)
.sort();
assert.equal(defaultFilteredSourceNames.includes("Bonus MVU"), true);
assert.equal(defaultFilteredSourceNames.some((name) => String(name || "").includes("MVU")), true);
assert.equal(defaultFilteredSourceNames.some((name) => String(name || "").startsWith("[mvu_update]")), true);
assert.equal(
worldInfo.debug.warnings.some((warning) => warning.includes("旧 EW 命名条目")),
worldInfo.debug.warnings.some((warning) => warning.includes("EW/")),
true,
);
assert.equal(
@@ -351,27 +377,45 @@ try {
assert.equal(worldInfo.debug.customFilter.mode, "default");
assert.equal(worldInfo.debug.customFilter.filteredEntryCount, 0);
globalThis.Mvu = {
getMvuData({ type, message_id: messageId } = {}) {
if (type === "message" && messageId === "latest") {
return {
stat_data: {
user: {
"意识状态": "沉眠",
},
"恼恼": {
"发情值": 71,
},
},
};
}
return {};
},
};
const customWorldInfo = await resolveTaskWorldInfo({
settings: {
worldInfoFilterMode: "custom",
worldInfoFilterCustomKeywords: "",
},
templateContext: {
recentMessages: "我们继续调查那条线索",
recentMessages: "custom-mode regression probe",
charName: "Alice",
},
userMessage: "继续调查",
userMessage: "probe custom mode",
});
assert.equal(
customWorldInfo.beforeEntries.some(
(entry) => entry.sourceName === "[mvu_update] 状态同步",
customWorldInfo.beforeEntries.some((entry) =>
String(entry.sourceName || "").startsWith("[mvu_update]"),
),
true,
);
assert.equal(
customWorldInfo.beforeEntries.some(
(entry) => entry.sourceName === "MVU 启发式条目",
customWorldInfo.beforeEntries.some((entry) =>
String(entry.sourceName || "").includes("MVU"),
),
true,
);
@@ -379,17 +423,25 @@ try {
customWorldInfo.beforeText,
/<status_current_variable>secret=true<\/status_current_variable>/,
);
assert.doesNotMatch(
customWorldInfo.beforeText,
/控制摘要隐藏线索Alice 正在调查/,
);
assert.equal(
customWorldInfo.allEntries.some((entry) => entry.name === "EW/Dyn/线索"),
customWorldInfo.allEntries.some((entry) => String(entry.name || "").startsWith("EW/Dyn/")),
false,
);
assert.equal(customWorldInfo.debug.mvu.filteredEntryCount, 0);
assert.equal(customWorldInfo.debug.customFilter.mode, "custom");
assert.equal(customWorldInfo.debug.customFilter.filteredEntryCount, 0);
assert.equal(
customWorldInfo.debug.customRender.bridgedStatDataFromLatestMessage,
true,
);
assert.equal(customWorldInfo.debug.customRender.taskEjsStatDataRoots.cache, true);
assert.equal(
customWorldInfo.debug.customRender.taskEjsStatDataRoots.message,
true,
);
assert.equal(customWorldInfo.debug.customRender.fallbackEntryCount > 0, true);
assert.match(customWorldInfo.beforeText, /stat_data controller payload/);
assert.match(customWorldInfo.beforeText, /latest state=.+/);
const keywordWorldInfo = await resolveTaskWorldInfo({
settings: {
@@ -455,6 +507,8 @@ try {
"常驻设定",
);
delete globalThis.Mvu;
const defaultModeWithKeywords = await resolveTaskWorldInfo({
settings: {
worldInfoFilterMode: "default",
@@ -808,6 +862,18 @@ try {
globalThis.SillyTavern = originalSillyTavern;
}
if (originalEjsTemplate === undefined) {
delete globalThis.EjsTemplate;
} else {
globalThis.EjsTemplate = originalEjsTemplate;
}
if (originalMvu === undefined) {
delete globalThis.Mvu;
} else {
globalThis.Mvu = originalMvu;
}
if (originalGetCharWorldbookNames === undefined) {
delete globalThis.getCharWorldbookNames;
} else {