mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-14 02:40:45 +08:00
Merge branch 'dev'
# Conflicts: # manifest.json
This commit is contained in:
@@ -28,11 +28,15 @@
|
||||
|
||||
召回输入按优先级解析(`resolveRecallInputController`):override → 待发送意图(send intent)→ 聊天尾部用户楼层 → 已发送用户 → 最新用户楼层。
|
||||
|
||||
控制器里的来源/类型判定保持为小型纯 helper:active input source、no-new-user generation type、可信 user-floor source、持久复用输入构造分别独立测试。它们只做字符串规范化和布尔判定,不调用 `retrieve()`、不写消息、也不触碰生成事务。
|
||||
|
||||
**持久召回复用有两条路径:**
|
||||
|
||||
1. **no-new-user 主路径**(`reapplyPersistedRecallBlock`):reroll / swipe / regenerate / continue 由宿主 `type` 判定为 no-new-user 后,`GENERATION_AFTER_COMMANDS` 不计算召回;`GENERATE_BEFORE_COMBINE_PROMPTS` 直接读取父 user 楼层的 `message.extra.bme_recall`,校验绑定文本未过期后确定性重放注入块。命中后不会进入 transaction / `runRecall` / 新检索。
|
||||
2. **compute fallback 内部复用**(`resolveReusablePersistedRecallRecord`):当主路径没有可用记录(例如无记录或陈旧)而落回 `runRecallController()` 时,如果当前输入匹配某条已持久化的用户楼层召回记录,可在控制器内复用已存注入内容,跳过新检索,返回 `llm.status="persisted"`。
|
||||
|
||||
内部复用命中后,控制器只重写本次 effective recall input 的来源为 `persisted-user-floor`,并保留原 delivery mode / hook / source candidates 等上下文字段;真正注入、generation count bump、metadata save 仍由原路径执行。
|
||||
|
||||
fresh `normal` 发送仍走正常输入选择与召回计算路径;no-new-user 的父楼层绑定来自宿主生成上下文,而不是根据 textarea / send-intent 等输入源猜测(见 [`../architecture/control-plane.md`](../architecture/control-plane.md) 的 reroll 不变量)。
|
||||
|
||||
## 5. 向量预筛
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
|
||||
- `vector/vector-gate.js` — 向量准备/修复前置门禁,决定 skip / repair / blocked / sync。
|
||||
- `runtime/generation-context.js` — 记录宿主本轮生成的 `type`(`normal` / `swipe` / `regenerate` / `continue` 等),并解析本轮应绑定的父 user 楼层。
|
||||
- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入;不再用一次性 marker 猜测 reroll。
|
||||
- `runtime/reroll-recall-input.js` — 基于代际上下文构造召回输入,并保存 planner recall handoff / plot record handoff;不再用一次性 marker 猜测 reroll。
|
||||
- `retrieval/recall-controller.js` — 召回控制器;来源/类型/持久复用输入构造是纯 helper,检索执行和注入副作用仍留在控制器热路径里。
|
||||
|
||||
**reroll 不变量:**
|
||||
|
||||
@@ -96,6 +97,8 @@ no-new-user 的稳定路径分两段:
|
||||
|
||||
旧的召回事务机制仍保留为 fresh normal 和 fallback compute 的基础设施;它不再是 reroll 已存召回注入的唯一门闸。
|
||||
|
||||
ENA Planner 另有一条 plot record handoff:它只负责把 planner 产出的剧情推进记录绑定到新 user 楼层的 `message.extra.st_bme_plot`,不参与召回决策。这样剧情历史持久化不依赖 planner recall 是否成功。
|
||||
|
||||
## 副本一致性模型
|
||||
|
||||
Authority 场景下有三处存储,它们**不是平级的版本副本**:
|
||||
|
||||
@@ -9,7 +9,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable`
|
||||
| 控制平面 | `identity-resolver` / `persistence-reducer` | 身份解析、持久化状态机不变量 |
|
||||
| 数据格式 | `graph-snapshot-schema` / `graph-snapshot-upgrade` / `snapshot-forward-compat` | 快照契约、宽容解析、向前兼容往返 |
|
||||
| 持久化 | `graph-persistence` / `indexeddb-*` | 图谱持久化、IndexedDB 快照/增量/hydrate |
|
||||
| 检索/召回 | `p0-regressions` 内相关、`trivial-user-input` | 召回、reroll 复用、注入 |
|
||||
| 检索/召回 | `p0-regressions` 内相关、`recall-controller-helpers`、`recall-reroll-reuse`、`trivial-user-input` | 召回来源判定、reroll 复用、注入 |
|
||||
| 向量 | `vector-gate` / `vector-connection-probe` / `vector-sync-coalescer` | 向量门禁、连接探测、后台同步合并 |
|
||||
| Native | `native-layout-parity` / `native-rollout-matrix` | native/JS 一致性、灰度门控 |
|
||||
| 防线 | `index-slicing-ratchet` / `runtime-deps-completeness` / `i18n-user-visible-ratchet` | 见下 |
|
||||
@@ -53,6 +53,7 @@ ST-BME 的测试是 Node 回归测试(`tests/*.mjs`),`npm run test:stable`
|
||||
## 重要测试文件
|
||||
|
||||
- **`tests/p0-regressions.mjs`** — 主回归集合,覆盖提取、召回、恢复、UI 关键路径。
|
||||
- **`tests/recall-controller-helpers.mjs`** — 召回控制器的纯来源/类型/持久复用输入 helper。
|
||||
- **`tests/runtime-history.mjs`** — 消息 hash、历史 dirty、恢复状态。
|
||||
- **`tests/message-render-limit.mjs`** — 聊天区渲染限制和渲染切片历史保护。
|
||||
- **`tests/graph-persistence.mjs`** — 图谱持久化基础行为。
|
||||
|
||||
@@ -20,7 +20,7 @@ ENA Planner 是一个**可选的、发送前剧情规划**子系统。它独立
|
||||
```
|
||||
拦截发送(点击发送/回车)
|
||||
→ 构建规划消息(buildPlannerMessages)
|
||||
→ 收集上下文:角色卡 + BME 记忆召回 + 近期 AI 对话 + 历史 <plot> + 世界书 + 用户输入
|
||||
→ 收集上下文:角色卡 + BME 记忆召回 + 近期 AI 对话 + 结构化/旧式 plot 历史 + 世界书 + 用户输入
|
||||
→ 渲染模板/宏(EJS、ST 宏)
|
||||
→ 组装提示词块(优先用 planner 任务预设,回退遗留块)
|
||||
→ 调用规划师 LLM(callPlanner,可流式)
|
||||
@@ -34,10 +34,11 @@ ENA Planner 是一个**可选的、发送前剧情规划**子系统。它独立
|
||||
|
||||
## 与 ST-BME 的集成
|
||||
|
||||
ENA Planner 集成的是**召回**,不是提取:
|
||||
ENA Planner 集成的是**召回**和**剧情历史记录**,不是提取:
|
||||
|
||||
- 它调用 BME 召回获取记忆块作为规划上下文(`runPlannerRecallForEna`)。
|
||||
- 规划输出注入用户文本后,主生成会把规划标签当作用户消息的一部分看到。
|
||||
- 规划输出会以结构化记录写入用户楼层 `message.extra.st_bme_plot`;后续规划优先读取这个记录,读不到时再回退扫描历史文本中的 `<plot>`。
|
||||
- 它**不**直接运行提取,也**不**把规划结果写进记忆图谱。后续提取走正常聊天/提取路径。
|
||||
|
||||
### 召回交接(handoff)
|
||||
@@ -48,6 +49,32 @@ ENA Planner 集成的是**召回**,不是提取:
|
||||
|
||||
这套机制的实现见 `runtime/planner-recall-controller.js`、`runtime/reroll-recall-input.js`、`runtime/generation-recall-transactions.js`。
|
||||
|
||||
### 结构化剧情历史
|
||||
|
||||
历史 plot 不再只依赖“从聊天文本里扫描 `<plot>`”。Planner 发送时会准备一条独立的 plot record handoff;`MESSAGE_SENT` 绑定到新 user 楼层后写入:
|
||||
|
||||
```js
|
||||
message.extra.st_bme_plot = {
|
||||
version: 1,
|
||||
rawUserInput,
|
||||
plannerAugmentedMessage,
|
||||
plotText,
|
||||
plotBlocks,
|
||||
inputHash,
|
||||
createdAt,
|
||||
recallHandoffId,
|
||||
taskResults: []
|
||||
}
|
||||
```
|
||||
|
||||
读取顺序:
|
||||
|
||||
1. 优先读取 `message.extra.st_bme_plot` 中的结构化 `<plot>` 块。
|
||||
2. 若结构化记录不足 `plotCount`,用历史消息文本里的旧式 `<plot>` 补足。
|
||||
3. 只把 `<plot>` 内容喂回 planner;`<note>` / `<state>` 等标签不会因为结构化记录而混入历史 plot 区。
|
||||
|
||||
plot record handoff 和 recall handoff 是两条独立通道:即使 planner 召回失败或被禁用,只要 planner 产出了 `<plot>`,剧情历史仍可持久化。这避免了“剧情推进记录依赖召回成功”的隐式耦合。
|
||||
|
||||
## 规划召回 vs 正常召回
|
||||
|
||||
| 维度 | 规划召回 | 正常召回 |
|
||||
|
||||
102
ena-planner/ena-planner-runtime-utils.js
Normal file
102
ena-planner/ena-planner-runtime-utils.js
Normal file
@@ -0,0 +1,102 @@
|
||||
export function extractLastNPlots(chat, n) {
|
||||
if (!Array.isArray(chat) || chat.length === 0) return [];
|
||||
const want = Math.max(0, Number(n) || 0);
|
||||
if (!want) return [];
|
||||
|
||||
const plots = [];
|
||||
const plotRe = /<plot\b[^>]*>[\s\S]*?<\/plot>/gi;
|
||||
|
||||
for (let i = chat.length - 1; i >= 0; i--) {
|
||||
const text = chat[i]?.mes ?? '';
|
||||
if (!text) continue;
|
||||
const matches = [...text.matchAll(plotRe)];
|
||||
for (let j = matches.length - 1; j >= 0; j--) {
|
||||
plots.push(matches[j][0]);
|
||||
if (plots.length >= want) return plots;
|
||||
}
|
||||
}
|
||||
return plots;
|
||||
}
|
||||
|
||||
export function formatPlotsBlock(plotList) {
|
||||
if (!Array.isArray(plotList) || plotList.length === 0) return '';
|
||||
const chrono = [...plotList].reverse();
|
||||
const lines = [];
|
||||
chrono.forEach((p, idx) => {
|
||||
lines.push(`【plot -${chrono.length - idx}】\n${p}`);
|
||||
});
|
||||
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
|
||||
}
|
||||
|
||||
export function applyPlannerResultAndSend({
|
||||
textarea,
|
||||
button,
|
||||
rawUserInput = '',
|
||||
filtered = '',
|
||||
plannerRecall = null,
|
||||
plannerPlotRecord = null,
|
||||
runtime = null,
|
||||
plannerState = null,
|
||||
} = {}) {
|
||||
if (!textarea || !button) return { applied: false, reason: 'missing-target' };
|
||||
|
||||
const raw = String(rawUserInput ?? '').trim();
|
||||
const merged = `${raw}\n\n${String(filtered ?? '')}`.trim();
|
||||
textarea.value = merged;
|
||||
if (plannerState && typeof plannerState === 'object') {
|
||||
plannerState.lastInjectedText = merged;
|
||||
}
|
||||
|
||||
const plotRecordPayload = plannerPlotRecord && typeof plannerPlotRecord === 'object'
|
||||
? {
|
||||
...plannerPlotRecord,
|
||||
rawUserInput: raw,
|
||||
plannerAugmentedMessage: merged,
|
||||
}
|
||||
: null;
|
||||
|
||||
let plotHandoffPrepared = false;
|
||||
if (runtime?.preparePlannerPlotRecordHandoff && plotRecordPayload) {
|
||||
runtime.preparePlannerPlotRecordHandoff(plotRecordPayload);
|
||||
plotHandoffPrepared = true;
|
||||
}
|
||||
|
||||
let handoffPrepared = false;
|
||||
if (runtime?.preparePlannerRecallHandoff && plannerRecall?.result) {
|
||||
runtime.preparePlannerRecallHandoff({
|
||||
rawUserInput: raw,
|
||||
plannerAugmentedMessage: merged,
|
||||
plannerRecall,
|
||||
plannerPlotRecord: plotRecordPayload,
|
||||
});
|
||||
handoffPrepared = true;
|
||||
}
|
||||
|
||||
if (plannerState && typeof plannerState === 'object') {
|
||||
plannerState.bypassNextSend = true;
|
||||
}
|
||||
button.click();
|
||||
return { applied: true, merged, handoffPrepared, plotHandoffPrepared };
|
||||
}
|
||||
|
||||
export function shouldInterceptPlannerSend({
|
||||
enabled = false,
|
||||
isPlanning = false,
|
||||
hasTextarea = false,
|
||||
textareaValue = '',
|
||||
isTrivial = false,
|
||||
bypassNextSend = false,
|
||||
skipIfPlotPresent = false,
|
||||
} = {}) {
|
||||
if (!enabled) return { shouldIntercept: false, reason: 'disabled' };
|
||||
if (isPlanning) return { shouldIntercept: false, reason: 'planning' };
|
||||
if (!hasTextarea) return { shouldIntercept: false, reason: 'missing-textarea' };
|
||||
const text = String(textareaValue ?? '').trim();
|
||||
if (!text) return { shouldIntercept: false, reason: 'empty-input' };
|
||||
if (isTrivial) return { shouldIntercept: false, reason: 'trivial' };
|
||||
if (bypassNextSend) return { shouldIntercept: false, reason: 'bypass' };
|
||||
if (skipIfPlotPresent && /<plot\b/i.test(text)) {
|
||||
return { shouldIntercept: false, reason: 'plot-present' };
|
||||
}
|
||||
return { shouldIntercept: true, reason: 'ok' };
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { extension_settings } from '../../../../extensions.js';
|
||||
import { getRequestHeaders, saveSettingsDebounced, substituteParamsExtended } from '../../../../../script.js';
|
||||
import { EnaPlannerStorage, migrateFromLWBIfNeeded } from './ena-planner-storage.js';
|
||||
import {
|
||||
applyPlannerResultAndSend,
|
||||
shouldInterceptPlannerSend,
|
||||
} from './ena-planner-runtime-utils.js';
|
||||
import { readPlannerPlotHistory } from './planner-plot-history.js';
|
||||
import { DEFAULT_PROMPT_BLOCKS, BUILTIN_TEMPLATES } from './ena-planner-presets.js';
|
||||
import {
|
||||
createBuiltinPromptBlock,
|
||||
@@ -794,38 +799,6 @@ function collectRecentChatSnippet(chat, maxMessages) {
|
||||
* Plot extraction
|
||||
* --------------------------
|
||||
*/
|
||||
function extractLastNPlots(chat, n) {
|
||||
if (!Array.isArray(chat) || chat.length === 0) return [];
|
||||
const want = Math.max(0, Number(n) || 0);
|
||||
if (!want) return [];
|
||||
|
||||
const plots = [];
|
||||
const plotRe = /<plot\b[^>]*>[\s\S]*?<\/plot>/gi;
|
||||
|
||||
for (let i = chat.length - 1; i >= 0; i--) {
|
||||
const text = chat[i]?.mes ?? '';
|
||||
if (!text) continue;
|
||||
const matches = [...text.matchAll(plotRe)];
|
||||
for (let j = matches.length - 1; j >= 0; j--) {
|
||||
plots.push(matches[j][0]);
|
||||
if (plots.length >= want) return plots;
|
||||
}
|
||||
}
|
||||
return plots;
|
||||
}
|
||||
|
||||
function formatPlotsBlock(plotList) {
|
||||
if (!Array.isArray(plotList) || plotList.length === 0) return '';
|
||||
// plotList is [newest, ..., oldest] from extractLastNPlots
|
||||
// Reverse to chronological: oldest first, newest last
|
||||
const chrono = [...plotList].reverse();
|
||||
const lines = [];
|
||||
chrono.forEach((p, idx) => {
|
||||
lines.push(`【plot -${chrono.length - idx}】\n${p}`);
|
||||
});
|
||||
return `<previous_plots>\n${lines.join('\n\n')}\n</previous_plots>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* -------------------------
|
||||
* Worldbook — read via ST API (like idle-watcher)
|
||||
@@ -1770,7 +1743,7 @@ async function buildPlannerMessages(rawUserInput) {
|
||||
// a little continuity even when memory recall returns empty.
|
||||
const recentChatRaw = collectRecentChatSnippet(chat, 2);
|
||||
|
||||
const plotsRaw = formatPlotsBlock(extractLastNPlots(chat, s.plotCount));
|
||||
const plotsRaw = readPlannerPlotHistory(chat, { count: s.plotCount }).block;
|
||||
|
||||
// Build scanText for worldbook keyword activation
|
||||
const scanText = [charBlockRaw, recentChatRaw, plotsRaw, rawUserInput].join('\n\n');
|
||||
@@ -1908,15 +1881,17 @@ function isTrivialPlannerInput(text) {
|
||||
|
||||
function shouldInterceptNow() {
|
||||
const s = ensureSettings();
|
||||
if (!s.enabled || state.isPlanning) return false;
|
||||
const ta = getSendTextarea();
|
||||
if (!ta) return false;
|
||||
const txt = String(ta.value ?? '').trim();
|
||||
if (!txt) return false;
|
||||
if (isTrivialPlannerInput(txt)) return false;
|
||||
if (state.bypassNextSend) return false;
|
||||
if (s.skipIfPlotPresent && /<plot\b/i.test(txt)) return false;
|
||||
return true;
|
||||
const txt = String(ta?.value ?? '').trim();
|
||||
return shouldInterceptPlannerSend({
|
||||
enabled: Boolean(s.enabled),
|
||||
isPlanning: Boolean(state.isPlanning),
|
||||
hasTextarea: Boolean(ta),
|
||||
textareaValue: txt,
|
||||
isTrivial: Boolean(txt && isTrivialPlannerInput(txt)),
|
||||
bypassNextSend: Boolean(state.bypassNextSend),
|
||||
skipIfPlotPresent: Boolean(s.skipIfPlotPresent),
|
||||
}).shouldIntercept;
|
||||
}
|
||||
|
||||
async function doInterceptAndPlanThenSend() {
|
||||
@@ -1941,22 +1916,22 @@ async function doInterceptAndPlanThenSend() {
|
||||
ta.value = `${raw}\n\n${preview}`.trim();
|
||||
}
|
||||
});
|
||||
const merged = `${raw}\n\n${filtered}`.trim();
|
||||
ta.value = merged;
|
||||
state.lastInjectedText = merged;
|
||||
|
||||
// Ordering requirement: register the one-shot planner recall handoff
|
||||
// synchronously before btn.click(), with no await/timer hop in between.
|
||||
if (_bmeRuntime?.preparePlannerRecallHandoff && plannerRecall?.result) {
|
||||
_bmeRuntime.preparePlannerRecallHandoff({
|
||||
// Ordering requirement: write the merged textarea, register the
|
||||
// one-shot planner recall handoff synchronously, then click send with
|
||||
// no await/timer hop in between.
|
||||
applyPlannerResultAndSend({
|
||||
textarea: ta,
|
||||
button: btn,
|
||||
rawUserInput: raw,
|
||||
filtered,
|
||||
plannerRecall,
|
||||
plannerPlotRecord: {
|
||||
rawUserInput: raw,
|
||||
plannerAugmentedMessage: merged,
|
||||
plannerRecall,
|
||||
});
|
||||
}
|
||||
|
||||
state.bypassNextSend = true;
|
||||
btn.click();
|
||||
plotText: filtered,
|
||||
},
|
||||
runtime: _bmeRuntime,
|
||||
plannerState: state,
|
||||
});
|
||||
} catch (err) {
|
||||
ta.value = raw;
|
||||
state.lastInjectedText = '';
|
||||
|
||||
154
ena-planner/planner-plot-history.js
Normal file
154
ena-planner/planner-plot-history.js
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
extractLastNPlots,
|
||||
formatPlotsBlock,
|
||||
} from './ena-planner-runtime-utils.js';
|
||||
|
||||
export const ST_BME_PLOT_HISTORY_KEY = 'st_bme_plot';
|
||||
export const ST_BME_PLOT_HISTORY_VERSION = 1;
|
||||
|
||||
export function hashPlannerPlotInput(text = '') {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(text || '')) {
|
||||
hash ^= char.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return String(Math.abs(hash >>> 0));
|
||||
}
|
||||
|
||||
export function createStructuredPlotRecord({
|
||||
rawUserInput = '',
|
||||
plannerAugmentedMessage = '',
|
||||
plotText = '',
|
||||
plotBlocks = null,
|
||||
promptProfileId = '',
|
||||
recallHandoffId = '',
|
||||
taskResults = [],
|
||||
createdAt = Date.now(),
|
||||
inputHash = '',
|
||||
} = {}) {
|
||||
const normalizedRaw = String(rawUserInput || '').trim();
|
||||
const normalizedPlot = String(plotText || '').trim();
|
||||
const blocks = Array.isArray(plotBlocks)
|
||||
? plotBlocks.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: extractLastNPlots([{ mes: normalizedPlot }], 99);
|
||||
return {
|
||||
version: ST_BME_PLOT_HISTORY_VERSION,
|
||||
inputHash: String(inputHash || hashPlannerPlotInput(normalizedRaw)),
|
||||
rawUserInput: normalizedRaw,
|
||||
plannerAugmentedMessage: String(plannerAugmentedMessage || '').trim(),
|
||||
plotText: normalizedPlot,
|
||||
plotBlocks: blocks,
|
||||
promptProfileId: String(promptProfileId || ''),
|
||||
recallHandoffId: String(recallHandoffId || ''),
|
||||
taskResults: Array.isArray(taskResults) ? taskResults : [],
|
||||
createdAt: Number.isFinite(Number(createdAt)) ? Number(createdAt) : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStructuredPlotRecord(value) {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
if (Number(value.version) !== ST_BME_PLOT_HISTORY_VERSION) return null;
|
||||
const plotText = String(value.plotText || '').trim();
|
||||
const plotBlocks = Array.isArray(value.plotBlocks)
|
||||
? value.plotBlocks.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: [];
|
||||
if (!plotText && plotBlocks.length === 0) return null;
|
||||
return createStructuredPlotRecord({
|
||||
...value,
|
||||
plotText,
|
||||
plotBlocks,
|
||||
createdAt: value.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function readStructuredPlotRecordFromMessage(message) {
|
||||
return normalizeStructuredPlotRecord(message?.extra?.[ST_BME_PLOT_HISTORY_KEY]);
|
||||
}
|
||||
|
||||
export function collectStructuredPlotRecords(chat, count = 2) {
|
||||
if (!Array.isArray(chat) || chat.length === 0) return [];
|
||||
const want = Math.max(0, Number(count) || 0);
|
||||
if (!want) return [];
|
||||
const records = [];
|
||||
for (let index = chat.length - 1; index >= 0; index--) {
|
||||
const record = readStructuredPlotRecordFromMessage(chat[index]);
|
||||
if (!record) continue;
|
||||
records.push(record);
|
||||
if (records.length >= want) break;
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
export function readPlannerPlotHistory(chat, { count = 2 } = {}) {
|
||||
const want = Math.max(0, Number(count) || 0);
|
||||
if (!want) {
|
||||
return { source: 'empty', records: [], plots: [], block: '' };
|
||||
}
|
||||
const structuredRecords = collectStructuredPlotRecords(chat, count);
|
||||
const seen = new Set();
|
||||
const plots = [];
|
||||
let usedLegacy = false;
|
||||
if (structuredRecords.length > 0) {
|
||||
for (const record of structuredRecords) {
|
||||
const recordBlocks = record.plotBlocks.length > 0
|
||||
? record.plotBlocks
|
||||
: extractLastNPlots([{ mes: record.plotText || '' }], want);
|
||||
const plot = recordBlocks.join('\n').trim();
|
||||
if (!plot || seen.has(plot)) continue;
|
||||
plots.push(plot);
|
||||
seen.add(plot);
|
||||
if (plots.length >= want) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (plots.length < want) {
|
||||
for (const legacyPlot of extractLastNPlots(chat, want)) {
|
||||
if (!legacyPlot || seen.has(legacyPlot)) continue;
|
||||
plots.push(legacyPlot);
|
||||
seen.add(legacyPlot);
|
||||
usedLegacy = true;
|
||||
if (plots.length >= want) break;
|
||||
}
|
||||
}
|
||||
|
||||
const source = structuredRecords.length > 0
|
||||
? (usedLegacy ? 'structured+legacy' : 'structured')
|
||||
: (plots.length > 0 ? 'legacy' : 'empty');
|
||||
return {
|
||||
source,
|
||||
records: structuredRecords,
|
||||
plots,
|
||||
block: formatPlotsBlock(plots),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeStructuredPlotRecordToMessage(message, recordInput) {
|
||||
if (!message || typeof message !== 'object' || !message.is_user) return false;
|
||||
const record = normalizeStructuredPlotRecord(
|
||||
recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput),
|
||||
);
|
||||
if (!record) return false;
|
||||
message.extra = message.extra && typeof message.extra === 'object'
|
||||
? message.extra
|
||||
: {};
|
||||
message.extra[ST_BME_PLOT_HISTORY_KEY] = record;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function writeStructuredPlotRecordToMatchingUserMessage(chat, recordInput) {
|
||||
if (!Array.isArray(chat)) return null;
|
||||
const record = normalizeStructuredPlotRecord(
|
||||
recordInput?.version ? recordInput : createStructuredPlotRecord(recordInput),
|
||||
);
|
||||
if (!record) return null;
|
||||
const inputHash = String(record.inputHash || hashPlannerPlotInput(record.rawUserInput));
|
||||
for (let index = chat.length - 1; index >= 0; index--) {
|
||||
const message = chat[index];
|
||||
if (!message?.is_user) continue;
|
||||
if (hashPlannerPlotInput(message.mes || '') !== inputHash) continue;
|
||||
if (writeStructuredPlotRecordToMessage(message, record)) {
|
||||
return { index, record };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -327,6 +327,7 @@ export function onMessageSentController(runtime, messageId) {
|
||||
resolvedMessageId,
|
||||
message.mes || "",
|
||||
);
|
||||
runtime.persistPlannerPlotRecordToUserMessage?.(resolvedMessageId);
|
||||
// GENERATION_AFTER_COMMANDS 在 sendMessageAsUser 之前触发,此时新用户消息
|
||||
// 尚未进入 chat,recall 记录会被写到上一条 user 上。这里用户消息刚入场,
|
||||
// transaction 仍在桥接窗口内,立即把记录重新绑定到正确的楼层。
|
||||
|
||||
43
index.js
43
index.js
@@ -111,6 +111,7 @@ import {
|
||||
registerGenerationAfterCommandsController,
|
||||
scheduleSendIntentHookRetryController,
|
||||
} from "./host/event-binding.js";
|
||||
import { writeStructuredPlotRecordToMessage } from "./ena-planner/planner-plot-history.js";
|
||||
import {
|
||||
BME_HOST_PROFILE_LUKER,
|
||||
getBmeHostAdapter,
|
||||
@@ -14734,16 +14735,56 @@ function preparePlannerRecallHandoff({
|
||||
rawUserInput = "",
|
||||
plannerAugmentedMessage = "",
|
||||
plannerRecall = null,
|
||||
plannerPlotRecord = null,
|
||||
chatId = getCurrentChatId(),
|
||||
} = {}) {
|
||||
return rerollRecallInput.preparePlannerRecallHandoff({
|
||||
rawUserInput,
|
||||
plannerAugmentedMessage,
|
||||
plannerRecall,
|
||||
plannerPlotRecord,
|
||||
chatId,
|
||||
});
|
||||
}
|
||||
|
||||
function preparePlannerPlotRecordHandoff(plannerPlotRecord = null) {
|
||||
if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") {
|
||||
return null;
|
||||
}
|
||||
return rerollRecallInput.preparePlannerPlotRecordHandoff({
|
||||
...plannerPlotRecord,
|
||||
chatId: getCurrentChatId(),
|
||||
});
|
||||
}
|
||||
|
||||
function persistPlannerPlotRecordToUserMessage(newUserMessageIndex) {
|
||||
const context = getContext();
|
||||
const chat = context?.chat;
|
||||
if (
|
||||
!Array.isArray(chat) ||
|
||||
!Number.isFinite(newUserMessageIndex) ||
|
||||
!chat[newUserMessageIndex]?.is_user
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const chatId = context?.chatId || getCurrentChatId();
|
||||
const plotHandoff = rerollRecallInput.peekPlannerPlotRecordHandoff?.(chatId);
|
||||
const handoff = peekPlannerRecallHandoff(chatId);
|
||||
const plannerPlotRecord = plotHandoff || handoff?.plannerPlotRecord;
|
||||
if (!plannerPlotRecord || typeof plannerPlotRecord !== "object") {
|
||||
return false;
|
||||
}
|
||||
const wrote = writeStructuredPlotRecordToMessage(chat[newUserMessageIndex], {
|
||||
...plannerPlotRecord,
|
||||
recallHandoffId: handoff?.id || plannerPlotRecord.recallHandoffId || "",
|
||||
});
|
||||
if (wrote) {
|
||||
rerollRecallInput.consumePlannerPlotRecordHandoff?.(chatId);
|
||||
triggerChatMetadataSave(context, { immediate: false });
|
||||
}
|
||||
return wrote;
|
||||
}
|
||||
|
||||
function buildPreGenerationRecallKey(type, options = {}) {
|
||||
return generationRecallTransactionRuntime.buildPreGenerationRecallKey(
|
||||
type,
|
||||
@@ -16051,6 +16092,7 @@ function onMessageSent(messageId) {
|
||||
getContext,
|
||||
isTrivialUserInput,
|
||||
markCurrentGenerationTrivialSkip,
|
||||
persistPlannerPlotRecordToUserMessage,
|
||||
recordRecallSentUserMessage,
|
||||
rebindRecallRecordToNewUserMessage,
|
||||
refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh,
|
||||
@@ -17889,6 +17931,7 @@ async function onCompactLukerSidecar() {
|
||||
getExtensionPath: () => `scripts/extensions/third-party/${MODULE_NAME}`,
|
||||
getPlannerRecallTimeoutMs,
|
||||
isTrivialUserInput,
|
||||
preparePlannerPlotRecordHandoff,
|
||||
preparePlannerRecallHandoff,
|
||||
runPlannerRecallForEna,
|
||||
});
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"js": "index.js",
|
||||
"css": "style.css",
|
||||
"author": "Youzini",
|
||||
"version": "7.6.4",
|
||||
"version": "7.6.9",
|
||||
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
|
||||
}
|
||||
|
||||
@@ -91,40 +91,81 @@ function buildPersistedRecallReuseResult(record = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
|
||||
const generationType = String(recallInput?.generationType || "normal").trim() || "normal";
|
||||
export function normalizeRecallGenerationType(value = "normal") {
|
||||
return String(value || "normal").trim() || "normal";
|
||||
}
|
||||
|
||||
let targetUserMessageIndex = Number.isFinite(recallInput?.targetUserMessageIndex)
|
||||
? Math.floor(Number(recallInput.targetUserMessageIndex))
|
||||
: null;
|
||||
export function normalizeRecallTargetUserMessageIndex(value) {
|
||||
return Number.isFinite(value) ? Math.floor(Number(value)) : null;
|
||||
}
|
||||
|
||||
const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage;
|
||||
if (typeof readPersistedRecallFromUserMessage !== "function") return null;
|
||||
export function normalizeRecallTextForRuntime(runtime, value = "") {
|
||||
return typeof runtime?.normalizeRecallInputText === "function"
|
||||
? runtime.normalizeRecallInputText(value)
|
||||
: String(value ?? "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const normalizeText = (value = "") =>
|
||||
typeof runtime.normalizeRecallInputText === "function"
|
||||
? runtime.normalizeRecallInputText(value)
|
||||
: String(value ?? "")
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim();
|
||||
const currentRecallInputText = normalizeText(recallInput?.userMessage || "");
|
||||
const recallSource = String(recallInput?.source || "").trim();
|
||||
const activeInputSources = new Set([
|
||||
export function isActiveRecallInputSource(source = "") {
|
||||
return new Set([
|
||||
"send-intent",
|
||||
"generation-started-send-intent",
|
||||
"generation-started-textarea",
|
||||
"host-generation-lifecycle",
|
||||
"textarea-live",
|
||||
"planner-handoff",
|
||||
]);
|
||||
const isActiveInputSource = activeInputSources.has(recallSource);
|
||||
const noNewUserGenerationTypes = new Set([
|
||||
"swipe",
|
||||
"regenerate",
|
||||
"continue",
|
||||
"history",
|
||||
]);
|
||||
const isNoNewUserGeneration = noNewUserGenerationTypes.has(generationType);
|
||||
]).has(String(source || "").trim());
|
||||
}
|
||||
|
||||
export function isNoNewUserGenerationType(generationType = "normal") {
|
||||
return new Set(["swipe", "regenerate", "continue", "history"]).has(
|
||||
normalizeRecallGenerationType(generationType),
|
||||
);
|
||||
}
|
||||
|
||||
export function isTrustedUserFloorRecallSource(source = "") {
|
||||
return new Set([
|
||||
"chat-last-user",
|
||||
"chat-latest-user",
|
||||
"chat-tail-user",
|
||||
"message-sent",
|
||||
"persisted-user-floor",
|
||||
]).has(String(source || "").trim());
|
||||
}
|
||||
|
||||
export function buildPersistedReuseRecallInput(recallInput = {}, record = {}, runtime) {
|
||||
const boundUserFloorText = normalizeRecallTextForRuntime(
|
||||
runtime,
|
||||
record.boundUserFloorText || recallInput.boundUserFloorText || "",
|
||||
);
|
||||
return {
|
||||
...recallInput,
|
||||
source: "persisted-user-floor",
|
||||
sourceLabel: "复用用户楼层召回",
|
||||
reason: "persisted-user-floor-reuse",
|
||||
authoritativeInputUsed: Boolean(
|
||||
record.authoritativeInputUsed || recallInput.authoritativeInputUsed,
|
||||
),
|
||||
boundUserFloorText,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
|
||||
const generationType = normalizeRecallGenerationType(recallInput?.generationType);
|
||||
|
||||
let targetUserMessageIndex = normalizeRecallTargetUserMessageIndex(
|
||||
recallInput?.targetUserMessageIndex,
|
||||
);
|
||||
|
||||
const readPersistedRecallFromUserMessage = runtime.readPersistedRecallFromUserMessage;
|
||||
if (typeof readPersistedRecallFromUserMessage !== "function") return null;
|
||||
|
||||
const normalizeText = (value = "") => normalizeRecallTextForRuntime(runtime, value);
|
||||
const currentRecallInputText = normalizeText(recallInput?.userMessage || "");
|
||||
const recallSource = String(recallInput?.source || "").trim();
|
||||
const isActiveInputSource = isActiveRecallInputSource(recallSource);
|
||||
const isNoNewUserGeneration = isNoNewUserGenerationType(generationType);
|
||||
if (isActiveInputSource && !isNoNewUserGeneration) {
|
||||
return null;
|
||||
}
|
||||
@@ -213,18 +254,11 @@ function resolveReusablePersistedRecallRecord(chat, recallInput, runtime) {
|
||||
!isActiveInputSource &&
|
||||
String(record?.injectionText || "").trim(),
|
||||
);
|
||||
const userFloorSources = new Set([
|
||||
"chat-last-user",
|
||||
"chat-latest-user",
|
||||
"chat-tail-user",
|
||||
"message-sent",
|
||||
"persisted-user-floor",
|
||||
]);
|
||||
const canTrustUserFloorRecord = Boolean(
|
||||
(!isActiveInputSource || isNoNewUserGeneration) &&
|
||||
!boundUserFloorText &&
|
||||
!recordRecallInputMismatch &&
|
||||
(generationType !== "normal" || userFloorSources.has(recallSource)),
|
||||
(generationType !== "normal" || isTrustedUserFloorRecallSource(recallSource)),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -618,31 +652,11 @@ export async function runRecallController(runtime, options = {}) {
|
||||
runtime,
|
||||
);
|
||||
if (earlyPersistedReuse) {
|
||||
const normalizedBoundUserFloorText =
|
||||
typeof runtime.normalizeRecallInputText === "function"
|
||||
? runtime.normalizeRecallInputText(
|
||||
earlyPersistedReuse.record.boundUserFloorText ||
|
||||
recallInput.boundUserFloorText ||
|
||||
"",
|
||||
)
|
||||
: String(
|
||||
earlyPersistedReuse.record.boundUserFloorText ||
|
||||
recallInput.boundUserFloorText ||
|
||||
"",
|
||||
)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim();
|
||||
const effectiveRecallInput = {
|
||||
...recallInput,
|
||||
source: "persisted-user-floor",
|
||||
sourceLabel: "复用用户楼层召回",
|
||||
reason: "persisted-user-floor-reuse",
|
||||
authoritativeInputUsed: Boolean(
|
||||
earlyPersistedReuse.record.authoritativeInputUsed ||
|
||||
recallInput.authoritativeInputUsed,
|
||||
),
|
||||
boundUserFloorText: normalizedBoundUserFloorText,
|
||||
};
|
||||
const effectiveRecallInput = buildPersistedReuseRecallInput(
|
||||
recallInput,
|
||||
earlyPersistedReuse.record,
|
||||
runtime,
|
||||
);
|
||||
const reusedResult = buildPersistedRecallReuseResult(earlyPersistedReuse.record);
|
||||
const applied = runtime.applyRecallInjection(
|
||||
settings,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export function createRerollRecallInput(deps = {}) {
|
||||
const plannerRecallHandoffs = new Map();
|
||||
const plannerPlotRecordHandoffs = new Map();
|
||||
|
||||
const getCurrentChatId = (...args) => deps.getCurrentChatId?.(...args);
|
||||
const normalizeChatIdCandidate = (value = "") =>
|
||||
@@ -250,6 +251,16 @@ export function createRerollRecallInput(deps = {}) {
|
||||
plannerRecallHandoffs.delete(chatId);
|
||||
}
|
||||
}
|
||||
for (const [chatId, handoff] of plannerPlotRecordHandoffs.entries()) {
|
||||
if (
|
||||
!handoff ||
|
||||
String(handoff.chatId || "") !== String(chatId || "") ||
|
||||
now - Number(handoff.updatedAt || handoff.createdAt || 0) >
|
||||
getPlannerRecallHandoffTtlMs()
|
||||
) {
|
||||
plannerPlotRecordHandoffs.delete(chatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function peekPlannerRecallHandoff(
|
||||
@@ -278,14 +289,37 @@ export function createRerollRecallInput(deps = {}) {
|
||||
) {
|
||||
cleanupPlannerRecallHandoffs();
|
||||
if (clearAll) {
|
||||
const removed = plannerRecallHandoffs.size;
|
||||
const removed = plannerRecallHandoffs.size + plannerPlotRecordHandoffs.size;
|
||||
plannerRecallHandoffs.clear();
|
||||
plannerPlotRecordHandoffs.clear();
|
||||
return removed;
|
||||
}
|
||||
|
||||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||||
if (!normalizedChatId) return 0;
|
||||
return plannerRecallHandoffs.delete(normalizedChatId) ? 1 : 0;
|
||||
let removed = 0;
|
||||
if (plannerRecallHandoffs.delete(normalizedChatId)) removed += 1;
|
||||
if (plannerPlotRecordHandoffs.delete(normalizedChatId)) removed += 1;
|
||||
return removed;
|
||||
}
|
||||
|
||||
function peekPlannerPlotRecordHandoff(
|
||||
chatId = getCurrentChatId(),
|
||||
now = Date.now(),
|
||||
) {
|
||||
cleanupPlannerRecallHandoffs(now);
|
||||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||||
if (!normalizedChatId) return null;
|
||||
return plannerPlotRecordHandoffs.get(normalizedChatId) || null;
|
||||
}
|
||||
|
||||
function consumePlannerPlotRecordHandoff(chatId = getCurrentChatId()) {
|
||||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||||
if (!normalizedChatId) return null;
|
||||
const handoff = peekPlannerPlotRecordHandoff(normalizedChatId);
|
||||
if (!handoff) return null;
|
||||
plannerPlotRecordHandoffs.delete(normalizedChatId);
|
||||
return handoff;
|
||||
}
|
||||
|
||||
function consumePlannerRecallHandoff(
|
||||
@@ -309,6 +343,7 @@ export function createRerollRecallInput(deps = {}) {
|
||||
rawUserInput = "",
|
||||
plannerAugmentedMessage = "",
|
||||
plannerRecall = null,
|
||||
plannerPlotRecord = null,
|
||||
chatId = getCurrentChatId(),
|
||||
} = {}) {
|
||||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||||
@@ -340,6 +375,10 @@ export function createRerollRecallInput(deps = {}) {
|
||||
? plannerRecall.recentMessages.map((item) => String(item || ""))
|
||||
: [],
|
||||
injectionText,
|
||||
plannerPlotRecord:
|
||||
plannerPlotRecord && typeof plannerPlotRecord === "object"
|
||||
? { ...plannerPlotRecord }
|
||||
: null,
|
||||
source: "planner-handoff",
|
||||
sourceLabel: "Planner handoff",
|
||||
createdAt,
|
||||
@@ -349,12 +388,56 @@ export function createRerollRecallInput(deps = {}) {
|
||||
return handoff;
|
||||
}
|
||||
|
||||
function preparePlannerPlotRecordHandoff({
|
||||
rawUserInput = "",
|
||||
plannerAugmentedMessage = "",
|
||||
plotText = "",
|
||||
plotBlocks = null,
|
||||
promptProfileId = "",
|
||||
taskResults = [],
|
||||
chatId = getCurrentChatId(),
|
||||
} = {}) {
|
||||
const normalizedChatId = normalizeChatIdCandidate(chatId);
|
||||
const normalizedRawUserInput = normalizeRecallInputText(rawUserInput);
|
||||
const normalizedPlannerAugmentedMessage = normalizeRecallInputText(
|
||||
plannerAugmentedMessage,
|
||||
);
|
||||
const normalizedPlotText = normalizeRecallInputText(plotText);
|
||||
if (!normalizedChatId || !normalizedRawUserInput || !normalizedPlotText) {
|
||||
return null;
|
||||
}
|
||||
cleanupPlannerRecallHandoffs();
|
||||
const createdAt = Date.now();
|
||||
const handoff = {
|
||||
id: [
|
||||
normalizedChatId,
|
||||
hashRecallInput(normalizedRawUserInput),
|
||||
"plot",
|
||||
createdAt,
|
||||
].join(":"),
|
||||
chatId: normalizedChatId,
|
||||
rawUserInput: normalizedRawUserInput,
|
||||
plannerAugmentedMessage: normalizedPlannerAugmentedMessage,
|
||||
plotText: normalizedPlotText,
|
||||
plotBlocks: Array.isArray(plotBlocks) ? [...plotBlocks] : null,
|
||||
promptProfileId: String(promptProfileId || ""),
|
||||
taskResults: Array.isArray(taskResults) ? taskResults : [],
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
};
|
||||
plannerPlotRecordHandoffs.set(normalizedChatId, handoff);
|
||||
return handoff;
|
||||
}
|
||||
|
||||
return {
|
||||
clearPendingRerollRecallReuse,
|
||||
buildNormalGenerationRecallInput,
|
||||
buildHistoryGenerationRecallInput,
|
||||
buildGenerationAfterCommandsRecallInput,
|
||||
preparePlannerRecallHandoff,
|
||||
preparePlannerPlotRecordHandoff,
|
||||
peekPlannerPlotRecordHandoff,
|
||||
consumePlannerPlotRecordHandoff,
|
||||
cleanupPlannerRecallHandoffs,
|
||||
peekPlannerRecallHandoff,
|
||||
clearPlannerRecallHandoffsForChat,
|
||||
|
||||
219
tests/ena-planner-plots.mjs
Normal file
219
tests/ena-planner-plots.mjs
Normal file
@@ -0,0 +1,219 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
applyPlannerResultAndSend,
|
||||
extractLastNPlots,
|
||||
formatPlotsBlock,
|
||||
shouldInterceptPlannerSend,
|
||||
} from '../ena-planner/ena-planner-runtime-utils.js';
|
||||
import { createRerollRecallInput } from '../runtime/reroll-recall-input.js';
|
||||
import {
|
||||
createStructuredPlotRecord,
|
||||
readPlannerPlotHistory,
|
||||
writeStructuredPlotRecordToMatchingUserMessage,
|
||||
writeStructuredPlotRecordToMessage,
|
||||
} from '../ena-planner/planner-plot-history.js';
|
||||
|
||||
{
|
||||
const chat = [
|
||||
{ mes: 'no plot here' },
|
||||
{ mes: '<plot>old one</plot>\n<plot>old two</plot>' },
|
||||
{ mes: 'assistant says <plot>new one</plot>' },
|
||||
];
|
||||
assert.deepEqual(extractLastNPlots(chat, 2), [
|
||||
'<plot>new one</plot>',
|
||||
'<plot>old two</plot>',
|
||||
]);
|
||||
assert.deepEqual(extractLastNPlots(chat, 0), []);
|
||||
assert.deepEqual(extractLastNPlots(null, 3), []);
|
||||
}
|
||||
|
||||
{
|
||||
const block = formatPlotsBlock([
|
||||
'<plot>newest</plot>',
|
||||
'<plot>older</plot>',
|
||||
]);
|
||||
assert.equal(
|
||||
block,
|
||||
'<previous_plots>\n【plot -2】\n<plot>older</plot>\n\n【plot -1】\n<plot>newest</plot>\n</previous_plots>',
|
||||
);
|
||||
assert.equal(formatPlotsBlock([]), '');
|
||||
}
|
||||
|
||||
{
|
||||
const order = [];
|
||||
const textarea = { value: 'raw' };
|
||||
const button = { click: () => order.push('click') };
|
||||
const plannerState = { bypassNextSend: false, lastInjectedText: '' };
|
||||
const plannerRecall = { result: { selected: ['memory-a'] } };
|
||||
const runtime = {
|
||||
preparePlannerRecallHandoff(payload) {
|
||||
order.push('handoff');
|
||||
assert.equal(payload.rawUserInput, 'raw input');
|
||||
assert.equal(payload.plannerAugmentedMessage, 'raw input\n\n<plot>next</plot>');
|
||||
assert.equal(payload.plannerRecall, plannerRecall);
|
||||
assert.deepEqual(payload.plannerPlotRecord, {
|
||||
rawUserInput: 'raw input',
|
||||
plannerAugmentedMessage: 'raw input\n\n<plot>next</plot>',
|
||||
plotText: '<plot>next</plot>',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyPlannerResultAndSend({
|
||||
textarea,
|
||||
button,
|
||||
rawUserInput: 'raw input',
|
||||
filtered: '<plot>next</plot>',
|
||||
plannerRecall,
|
||||
plannerPlotRecord: { plotText: '<plot>next</plot>' },
|
||||
runtime,
|
||||
plannerState,
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ['handoff', 'click']);
|
||||
assert.equal(result.applied, true);
|
||||
assert.equal(result.handoffPrepared, true);
|
||||
assert.equal(textarea.value, 'raw input\n\n<plot>next</plot>');
|
||||
assert.equal(plannerState.lastInjectedText, textarea.value);
|
||||
assert.equal(plannerState.bypassNextSend, true);
|
||||
}
|
||||
|
||||
{
|
||||
const order = [];
|
||||
const textarea = { value: 'raw' };
|
||||
const button = { click: () => order.push('click') };
|
||||
const plannerState = { bypassNextSend: false, lastInjectedText: '' };
|
||||
const runtime = {
|
||||
preparePlannerPlotRecordHandoff(payload) {
|
||||
order.push('plot-handoff');
|
||||
assert.deepEqual(payload, {
|
||||
rawUserInput: 'raw input',
|
||||
plannerAugmentedMessage: 'raw input\n\n<plot>next</plot>',
|
||||
plotText: '<plot>next</plot>',
|
||||
});
|
||||
},
|
||||
preparePlannerRecallHandoff() {
|
||||
order.push('recall-handoff');
|
||||
},
|
||||
};
|
||||
|
||||
const result = applyPlannerResultAndSend({
|
||||
textarea,
|
||||
button,
|
||||
rawUserInput: 'raw input',
|
||||
filtered: '<plot>next</plot>',
|
||||
plannerRecall: null,
|
||||
plannerPlotRecord: { plotText: '<plot>next</plot>' },
|
||||
runtime,
|
||||
plannerState,
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ['plot-handoff', 'click']);
|
||||
assert.equal(result.applied, true);
|
||||
assert.equal(result.plotHandoffPrepared, true);
|
||||
assert.equal(result.handoffPrepared, false);
|
||||
assert.equal(textarea.value, 'raw input\n\n<plot>next</plot>');
|
||||
assert.equal(plannerState.lastInjectedText, textarea.value);
|
||||
assert.equal(plannerState.bypassNextSend, true);
|
||||
}
|
||||
|
||||
{
|
||||
const cases = [
|
||||
[{ enabled: false, hasTextarea: true, textareaValue: 'go' }, false, 'disabled'],
|
||||
[{ enabled: true, isPlanning: true, hasTextarea: true, textareaValue: 'go' }, false, 'planning'],
|
||||
[{ enabled: true, hasTextarea: false, textareaValue: 'go' }, false, 'missing-textarea'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: ' ' }, false, 'empty-input'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: 'go', isTrivial: true }, false, 'trivial'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: 'go', bypassNextSend: true }, false, 'bypass'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: '<plot>done</plot>', skipIfPlotPresent: true }, false, 'plot-present'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: '<plotter>not a plot tag</plotter>', skipIfPlotPresent: true }, true, 'ok'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: '<plot id="x">done</plot>', skipIfPlotPresent: false }, true, 'ok'],
|
||||
[{ enabled: true, hasTextarea: true, textareaValue: 'continue the scene' }, true, 'ok'],
|
||||
];
|
||||
for (const [input, expectedShouldIntercept, expectedReason] of cases) {
|
||||
const result = shouldInterceptPlannerSend(input);
|
||||
assert.equal(result.shouldIntercept, expectedShouldIntercept, expectedReason);
|
||||
assert.equal(result.reason, expectedReason);
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const chat = [
|
||||
{ is_user: true, mes: 'raw old', extra: {} },
|
||||
{ is_user: false, mes: '<plot>legacy stale</plot>' },
|
||||
{ is_user: true, mes: 'raw latest', extra: {} },
|
||||
];
|
||||
writeStructuredPlotRecordToMessage(chat[2], createStructuredPlotRecord({
|
||||
rawUserInput: 'raw latest',
|
||||
plannerAugmentedMessage: 'raw latest\n\n<note>private</note>\n<plot>structured</plot>\n<state>hidden</state>',
|
||||
plotText: '<note>private</note>\n<plot>structured</plot>\n<state>hidden</state>',
|
||||
}));
|
||||
const history = readPlannerPlotHistory(chat, { count: 2 });
|
||||
assert.equal(history.source, 'structured+legacy');
|
||||
assert.deepEqual(history.plots, ['<plot>structured</plot>', '<plot>legacy stale</plot>']);
|
||||
assert.ok(history.block.includes('<plot>structured</plot>'));
|
||||
assert.ok(history.block.includes('legacy stale'));
|
||||
assert.ok(!history.block.includes('<note>private</note>'));
|
||||
assert.ok(!history.block.includes('<state>hidden</state>'));
|
||||
}
|
||||
|
||||
{
|
||||
const chat = [
|
||||
{ is_user: true, mes: 'raw old', extra: {} },
|
||||
{ is_user: false, mes: '<plot>legacy old</plot>' },
|
||||
];
|
||||
chat[0].extra.st_bme_plot = { version: 999, plotText: '<plot>bad</plot>' };
|
||||
const history = readPlannerPlotHistory(chat, { count: 1 });
|
||||
assert.equal(history.source, 'legacy');
|
||||
assert.deepEqual(history.plots, ['<plot>legacy old</plot>']);
|
||||
}
|
||||
|
||||
{
|
||||
const chat = [
|
||||
{ is_user: true, mes: 'first input', extra: {} },
|
||||
{ is_user: false, mes: 'assistant' },
|
||||
{ is_user: true, mes: 'second input', extra: {} },
|
||||
];
|
||||
const result = writeStructuredPlotRecordToMatchingUserMessage(chat, {
|
||||
rawUserInput: 'first input',
|
||||
plannerAugmentedMessage: 'first input\n\n<plot>first plan</plot>',
|
||||
plotText: '<plot>first plan</plot>',
|
||||
});
|
||||
assert.equal(result.index, 0);
|
||||
assert.equal(chat[0].extra.st_bme_plot.plotText, '<plot>first plan</plot>');
|
||||
assert.equal(chat[2].extra.st_bme_plot, undefined);
|
||||
}
|
||||
|
||||
{
|
||||
const runtime = createRerollRecallInput({
|
||||
getCurrentChatId: () => 'chat-a',
|
||||
normalizeChatIdCandidate: (value) => String(value || '').trim(),
|
||||
normalizeRecallInputText: (value) => String(value || '').trim(),
|
||||
hashRecallInput: (value) => `hash:${String(value || '').length}`,
|
||||
});
|
||||
const handoff = runtime.preparePlannerPlotRecordHandoff({
|
||||
chatId: 'chat-a',
|
||||
rawUserInput: 'raw input',
|
||||
plannerAugmentedMessage: 'raw input\n\n<plot>next</plot>',
|
||||
plotText: '<plot>next</plot>',
|
||||
});
|
||||
assert.ok(handoff?.id?.includes(':plot:'));
|
||||
assert.equal(handoff.plotText, '<plot>next</plot>');
|
||||
assert.equal(runtime.peekPlannerRecallHandoff('chat-a'), null);
|
||||
assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a')?.plotText, '<plot>next</plot>');
|
||||
assert.equal(runtime.consumePlannerPlotRecordHandoff('chat-a')?.plotText, '<plot>next</plot>');
|
||||
assert.equal(runtime.peekPlannerPlotRecordHandoff('chat-a'), null);
|
||||
}
|
||||
|
||||
{
|
||||
const order = [];
|
||||
const result = applyPlannerResultAndSend({
|
||||
textarea: null,
|
||||
button: { click: () => order.push('click') },
|
||||
});
|
||||
assert.deepEqual(result, { applied: false, reason: 'missing-target' });
|
||||
assert.deepEqual(order, []);
|
||||
}
|
||||
|
||||
console.log('ena-planner-plots tests passed');
|
||||
89
tests/recall-controller-helpers.mjs
Normal file
89
tests/recall-controller-helpers.mjs
Normal file
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
buildPersistedReuseRecallInput,
|
||||
isActiveRecallInputSource,
|
||||
isNoNewUserGenerationType,
|
||||
isTrustedUserFloorRecallSource,
|
||||
normalizeRecallGenerationType,
|
||||
normalizeRecallTargetUserMessageIndex,
|
||||
normalizeRecallTextForRuntime,
|
||||
} from '../retrieval/recall-controller.js';
|
||||
|
||||
assert.equal(normalizeRecallGenerationType(' regenerate '), 'regenerate');
|
||||
assert.equal(normalizeRecallGenerationType(''), 'normal');
|
||||
assert.equal(normalizeRecallGenerationType(null), 'normal');
|
||||
|
||||
assert.equal(normalizeRecallTargetUserMessageIndex(3.9), 3);
|
||||
assert.equal(normalizeRecallTargetUserMessageIndex(Number.NaN), null);
|
||||
assert.equal(normalizeRecallTargetUserMessageIndex('3'), null);
|
||||
|
||||
assert.equal(normalizeRecallTextForRuntime(null, ' a\r\nb '), 'a\nb');
|
||||
assert.equal(
|
||||
normalizeRecallTextForRuntime({ normalizeRecallInputText: (value) => `x:${String(value).trim()}` }, ' a '),
|
||||
'x:a',
|
||||
);
|
||||
|
||||
for (const source of [
|
||||
'send-intent',
|
||||
'generation-started-send-intent',
|
||||
'generation-started-textarea',
|
||||
'host-generation-lifecycle',
|
||||
'textarea-live',
|
||||
'planner-handoff',
|
||||
]) {
|
||||
assert.equal(isActiveRecallInputSource(source), true, source);
|
||||
}
|
||||
assert.equal(isActiveRecallInputSource('chat-last-user'), false);
|
||||
|
||||
for (const generationType of ['swipe', 'regenerate', 'continue', 'history']) {
|
||||
assert.equal(isNoNewUserGenerationType(generationType), true, generationType);
|
||||
}
|
||||
assert.equal(isNoNewUserGenerationType('normal'), false);
|
||||
|
||||
for (const source of [
|
||||
'chat-last-user',
|
||||
'chat-latest-user',
|
||||
'chat-tail-user',
|
||||
'message-sent',
|
||||
'persisted-user-floor',
|
||||
]) {
|
||||
assert.equal(isTrustedUserFloorRecallSource(source), true, source);
|
||||
}
|
||||
assert.equal(isTrustedUserFloorRecallSource('textarea-live'), false);
|
||||
|
||||
{
|
||||
const recallInput = {
|
||||
source: 'chat-last-user',
|
||||
sourceLabel: '历史最后用户楼层',
|
||||
reason: 'chat-tail-fallback',
|
||||
authoritativeInputUsed: false,
|
||||
boundUserFloorText: ' fallback floor ',
|
||||
deliveryMode: 'deferred',
|
||||
};
|
||||
const record = {
|
||||
authoritativeInputUsed: true,
|
||||
boundUserFloorText: ' persisted floor ',
|
||||
};
|
||||
const result = buildPersistedReuseRecallInput(recallInput, record, {
|
||||
normalizeRecallInputText: (value) => String(value || '').trim().toUpperCase(),
|
||||
});
|
||||
assert.equal(result.source, 'persisted-user-floor');
|
||||
assert.equal(result.sourceLabel, '复用用户楼层召回');
|
||||
assert.equal(result.reason, 'persisted-user-floor-reuse');
|
||||
assert.equal(result.authoritativeInputUsed, true);
|
||||
assert.equal(result.boundUserFloorText, 'PERSISTED FLOOR');
|
||||
assert.equal(result.deliveryMode, 'deferred');
|
||||
}
|
||||
|
||||
{
|
||||
const result = buildPersistedReuseRecallInput(
|
||||
{ authoritativeInputUsed: true, boundUserFloorText: 'input\r\ntext' },
|
||||
{},
|
||||
null,
|
||||
);
|
||||
assert.equal(result.authoritativeInputUsed, true);
|
||||
assert.equal(result.boundUserFloorText, 'input\ntext');
|
||||
}
|
||||
|
||||
console.log('recall-controller-helpers tests passed');
|
||||
Reference in New Issue
Block a user