feat: recall card UI v2 — inline card with force-graph sub-graph, sidebar editor, no more prompt/alert/confirm

This commit is contained in:
Youzini-afk
2026-03-29 20:02:02 +08:00
parent fc2e9abc72
commit 4aeba55e90
6 changed files with 1143 additions and 298 deletions

View File

@@ -290,6 +290,7 @@ ST-BME/
├── injector.js # 召回结果格式化注入
├── runtime-state.js # 运行时状态:楼层 hash、dirty 标记、恢复日志
├── recall-persistence.js # 持久召回记录message.extra.bme_recall
├── recall-message-ui.js # 消息级召回卡片 UI子图渲染 + 侧边栏编辑)
├── vector-index.js # 向量索引管理backend / direct 双模式)
├── embedding.js # 直连 Embedding API 封装
├── llm.js # 记忆 LLM 请求封装
@@ -366,11 +367,14 @@ ST-BME/
消息级 UI
- 带有 `bme_recall` 的用户气泡会显示 🧠 badge。
- 点击 badge 可进行:查看详情 / 手动编辑 / 删除 / 重新召回
- 手动编辑后会将 `manuallyEdited=true`
- 重新召回成功后会覆盖记录并重置 `manuallyEdited=false`。
- 删除会移除该楼层的持久召回记录
- 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge
- 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`
- 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情
- 操作按钮(展开态底部):
- **✏️ 编辑**:打开侧边栏编辑注入文本(实时 token 计数),保存后标记 `manuallyEdited=true`
- **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。
- **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。
- 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。
兼容性说明:

View File

@@ -17,7 +17,7 @@ import { getGraphNodeLabel, getNodeDisplayName } from './node-labels.js';
* @property {boolean} pinned
*/
const FORCE_CONFIG = {
const DEFAULT_FORCE_CONFIG = {
repulsion: 500, // 库仑斥力常数
springLength: 120, // 弹簧自然长度
springK: 0.08, // 弹簧刚度
@@ -34,9 +34,17 @@ const FORCE_CONFIG = {
export class GraphRenderer {
/**
* @param {HTMLCanvasElement} canvas
* @param {string} themeName
* @param {string|object} [options] - 主题名称字符串(向后兼容)或配置对象
* options.theme {string} - 主题名称
* options.forceConfig {object} - 力导向参数覆盖
* options.onNodeClick {function} - 节点点击回调
* options.onNodeDoubleClick {function} - 节点双击回调
*/
constructor(canvas, themeName = 'crimson') {
constructor(canvas, options = 'crimson') {
const isLegacy = typeof options === 'string';
const themeName = isLegacy ? options : (options?.theme || 'crimson');
const forceOverride = isLegacy ? {} : (options?.forceConfig || {});
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.nodes = [];
@@ -44,6 +52,7 @@ export class GraphRenderer {
this.nodeMap = new Map();
this.colors = getNodeColors(themeName);
this.themeName = themeName;
this.config = { ...DEFAULT_FORCE_CONFIG, ...forceOverride };
// View transform
this.scale = 1;
@@ -64,7 +73,9 @@ export class GraphRenderer {
this.animId = null;
// Callbacks
this.onNodeSelect = null;
this.onNodeSelect = isLegacy ? null : (options?.onNodeSelect || null);
this.onNodeClick = isLegacy ? null : (options?.onNodeClick || null);
this.onNodeDoubleClick = isLegacy ? null : (options?.onNodeDoubleClick || null);
this._bindEvents();
this._resizeObserver = new ResizeObserver(() => this._resize());
@@ -138,7 +149,7 @@ export class GraphRenderer {
// ==================== 力导向计算 ====================
_applyForces() {
const { nodes, edges } = this;
const { nodes, edges, config } = this;
const W = this.canvas.width / window.devicePixelRatio;
const H = this.canvas.height / window.devicePixelRatio;
const cx = W / 2, cy = H / 2;
@@ -149,7 +160,7 @@ export class GraphRenderer {
const a = nodes[i], b = nodes[j];
let dx = b.x - a.x, dy = b.y - a.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let force = FORCE_CONFIG.repulsion / (dist * dist);
let force = config.repulsion / (dist * dist);
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
if (!a.pinned) { a.vx -= fx; a.vy -= fy; }
@@ -162,8 +173,8 @@ export class GraphRenderer {
const { from, to, strength } = edge;
let dx = to.x - from.x, dy = to.y - from.y;
let dist = Math.sqrt(dx * dx + dy * dy) || 1;
let displacement = dist - FORCE_CONFIG.springLength;
let force = FORCE_CONFIG.springK * displacement * strength;
let displacement = dist - config.springLength;
let force = config.springK * displacement * strength;
let fx = (dx / dist) * force;
let fy = (dy / dist) * force;
if (!from.pinned) { from.vx += fx; from.vy += fy; }
@@ -173,15 +184,15 @@ export class GraphRenderer {
// 向心力
for (const node of nodes) {
if (node.pinned) continue;
node.vx += (cx - node.x) * FORCE_CONFIG.centerGravity;
node.vy += (cy - node.y) * FORCE_CONFIG.centerGravity;
node.vx += (cx - node.x) * config.centerGravity;
node.vy += (cy - node.y) * config.centerGravity;
}
// 更新位置
for (const node of nodes) {
if (node.pinned) continue;
node.vx *= FORCE_CONFIG.damping;
node.vy *= FORCE_CONFIG.damping;
node.vx *= config.damping;
node.vy *= config.damping;
node.x += node.vx;
node.y += node.vy;
// 边界约束
@@ -252,7 +263,7 @@ export class GraphRenderer {
// 标签
ctx.fillStyle = `rgba(255,255,255,${isHovered || isSelected ? 0.95 : 0.65})`;
ctx.font = `${FORCE_CONFIG.labelFontSize}px Inter, sans-serif`;
ctx.font = `${this.config.labelFontSize}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.fillText(node.label || node.name, node.x, node.y + r + 14);
}
@@ -261,9 +272,11 @@ export class GraphRenderer {
}
_drawGrid(W, H) {
const sp = this.config.gridSpacing;
if (!sp || sp <= 0) return;
const ctx = this.ctx;
const sp = FORCE_CONFIG.gridSpacing;
ctx.strokeStyle = FORCE_CONFIG.gridColor;
ctx.strokeStyle = this.config.gridColor;
ctx.lineWidth = 0.5;
const startX = Math.floor(-this.offsetX / this.scale / sp) * sp;
const startY = Math.floor(-this.offsetY / this.scale / sp) * sp;
@@ -285,8 +298,8 @@ export class GraphRenderer {
}
_nodeRadius(node) {
const min = FORCE_CONFIG.minNodeRadius;
const max = FORCE_CONFIG.maxNodeRadius;
const min = this.config.minNodeRadius;
const max = this.config.maxNodeRadius;
return min + ((node.importance || 5) / 10) * (max - min);
}
@@ -305,7 +318,7 @@ export class GraphRenderer {
_tick() {
if (!this.animating) return;
if (this.iteration < FORCE_CONFIG.maxIterations) {
if (this.iteration < this.config.maxIterations) {
this._applyForces();
this.iteration++;
}
@@ -361,6 +374,7 @@ export class GraphRenderer {
const { x, y } = this._canvasToWorld(e.clientX, e.clientY);
const node = this._findNodeAt(x, y);
this.lastMouse = { x: e.clientX, y: e.clientY };
this._dragStartMouse = { x: e.clientX, y: e.clientY };
if (node) {
this.dragNode = node;
@@ -399,14 +413,22 @@ export class GraphRenderer {
if (this.dragNode) {
this.dragNode.pinned = false;
if (this.isDragging) {
const start = this._dragStartMouse || { x: 0, y: 0 };
const dx = (this.lastMouse.x - start.x);
const dy = (this.lastMouse.y - start.y);
const movedDistance = Math.sqrt(dx * dx + dy * dy);
// 如果拖动距离很小,视为点击选中
this.selectedNode = this.dragNode;
if (this.onNodeSelect) this.onNodeSelect(this.dragNode);
if (movedDistance < 6) {
this.selectedNode = this.dragNode;
if (this.onNodeSelect) this.onNodeSelect(this.dragNode);
if (this.onNodeClick) this.onNodeClick(this.dragNode);
}
}
}
this.dragNode = null;
this.isDragging = false;
this.isPanning = false;
this._dragStartMouse = null;
}
_onWheel(e) {
@@ -431,6 +453,7 @@ export class GraphRenderer {
if (node) {
this.selectedNode = node;
if (this.onNodeSelect) this.onNodeSelect(node);
if (this.onNodeDoubleClick) this.onNodeDoubleClick(node);
this._render();
}
}

233
index.js
View File

@@ -196,6 +196,12 @@ import {
resolveGenerationTargetUserMessageIndex,
writePersistedRecallToUserMessage,
} from "./recall-persistence.js";
import {
createRecallCardElement,
openRecallSidebar,
updateRecallCardData,
} from "./recall-message-ui.js";
// 操控面板模块(动态加载,防止加载失败崩溃整个扩展)
let _panelModule = null;
@@ -1123,48 +1129,67 @@ function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) {
return Number.isFinite(fallbackIndex) ? fallbackIndex : null;
}
function buildMessageRecallBadgeTitle(messageIndex, record) {
const lines = [`ST-BME 持久召回 · 楼层 ${messageIndex}`];
if (record?.manuallyEdited) {
lines.push("来源:手动编辑");
}
if (Number.isFinite(record?.generationCount)) {
lines.push(`已回退使用:${record.generationCount}`);
}
if (record?.updatedAt) {
lines.push(`更新:${record.updatedAt}`);
}
return lines.join("\n");
function getRecallCardCallbacks() {
return {
onEdit: (messageIndex) => {
const record = getMessageRecallRecord(messageIndex);
if (!record) return;
openRecallSidebar({
mode: "edit",
messageIndex,
record,
node: null,
graph: currentGraph,
callbacks: {
onSave: (idx, newText) => {
const edited = editMessageRecallRecord(idx, newText);
if (edited) {
toastr.success("已保存手动编辑");
} else {
toastr.warning("编辑失败:注入文本不能为空");
}
schedulePersistedRecallMessageUiRefresh();
},
estimateTokens,
},
});
},
onDelete: (messageIndex) => {
if (removeMessageRecallRecord(messageIndex)) {
toastr.success("已删除持久召回注入");
schedulePersistedRecallMessageUiRefresh();
}
},
onRerunRecall: async (messageIndex) => {
const result = await rerunRecallForMessage(messageIndex);
if (result?.status === "completed") {
toastr.success("重新召回完成");
}
schedulePersistedRecallMessageUiRefresh();
},
onNodeClick: (messageIndex, node) => {
const record = getMessageRecallRecord(messageIndex);
if (!record) return;
openRecallSidebar({
mode: "view",
messageIndex,
record,
node,
graph: currentGraph,
callbacks: {
onSave: (idx, newText) => {
const edited = editMessageRecallRecord(idx, newText);
if (edited) toastr.success("已保存手动编辑");
else toastr.warning("编辑失败:注入文本不能为空");
schedulePersistedRecallMessageUiRefresh();
},
estimateTokens,
},
});
},
};
}
function createMessageRecallBadgeElement(messageIndex, record) {
const badge = document.createElement("button");
badge.type = "button";
badge.className = "st-bme-recall-badge";
badge.textContent = "🧠";
badge.dataset.messageIndex = String(messageIndex);
badge.style.marginInlineStart = "6px";
badge.style.padding = "0 4px";
badge.style.borderRadius = "10px";
badge.style.border = "1px solid var(--SmartThemeBorderColor, #666)";
badge.style.background = "var(--SmartThemeQuoteColor, rgba(120, 120, 120, 0.18))";
badge.style.cursor = "pointer";
badge.style.fontSize = "12px";
badge.style.lineHeight = "1.4";
badge.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const indexFromDataset = Number.parseInt(badge.dataset.messageIndex, 10);
void onMessageRecallBadgeClick(
Number.isFinite(indexFromDataset) ? indexFromDataset : messageIndex,
);
});
badge.title = buildMessageRecallBadgeTitle(messageIndex, record);
badge.dataset.updatedAt = String(record?.updatedAt || "");
return badge;
}
function refreshPersistedRecallMessageUi() {
const context = getContext();
@@ -1174,47 +1199,72 @@ function refreshPersistedRecallMessageUi() {
const chatRoot = document.getElementById("chat");
if (!chatRoot) return;
const themeName = getSettings()?.panelTheme || "crimson";
const callbacks = getRecallCardCallbacks();
const messageElements = Array.from(chatRoot.querySelectorAll(".mes"));
for (let fallbackIndex = 0; fallbackIndex < messageElements.length; fallbackIndex++) {
const messageElement = messageElements[fallbackIndex];
const messageIndex = resolveMessageIndexFromElement(messageElement, fallbackIndex);
if (!Number.isFinite(messageIndex)) continue;
const existingBadges = Array.from(
// Clean up old-style badges (migration from v1)
const oldBadges = Array.from(
messageElement.querySelectorAll?.(".st-bme-recall-badge") || [],
);
const existingBadge = existingBadges[0] || null;
for (let index = 1; index < existingBadges.length; index++) {
existingBadges[index].remove();
for (const oldBadge of oldBadges) oldBadge.remove();
// Find existing card
const existingCards = Array.from(
messageElement.querySelectorAll?.(".bme-recall-card") || [],
);
let existingCard = null;
for (const card of existingCards) {
if (card.dataset.messageIndex === String(messageIndex)) {
existingCard = card;
} else {
card._bmeDestroyRenderer?.();
card.remove();
}
}
const message = chat[messageIndex];
if (!message?.is_user) {
existingBadge?.remove();
if (existingCard) {
existingCard._bmeDestroyRenderer?.();
existingCard.remove();
}
continue;
}
const record = readPersistedRecallFromUserMessage(chat, messageIndex);
if (!record?.injectionText) {
existingBadge?.remove();
if (existingCard) {
existingCard._bmeDestroyRenderer?.();
existingCard.remove();
}
continue;
}
const badge = existingBadge || createMessageRecallBadgeElement(messageIndex, record);
badge.dataset.messageIndex = String(messageIndex);
const nextUpdatedAt = String(record.updatedAt || "");
if (badge.dataset.updatedAt !== nextUpdatedAt) {
badge.dataset.updatedAt = nextUpdatedAt;
badge.title = buildMessageRecallBadgeTitle(messageIndex, record);
}
if (existingCard) {
// Update data without rebuilding (preserves expanded state)
updateRecallCardData(existingCard, record);
} else {
// Create new card
const card = createRecallCardElement({
messageIndex,
record,
userMessageText: message.mes || "",
graph: currentGraph,
themeName,
callbacks,
});
if (!existingBadge) {
const anchor =
messageElement.querySelector?.(".mes_buttons") ||
messageElement.querySelector?.(".mes_title") ||
messageElement.querySelector?.(".mes_header") ||
messageElement.querySelector?.(".mes_block") ||
messageElement.querySelector?.(".mes_text")?.parentElement ||
messageElement;
anchor.appendChild(badge);
anchor.appendChild(card);
}
}
}
@@ -1227,22 +1277,6 @@ function schedulePersistedRecallMessageUiRefresh(delayMs = 120) {
}, Math.max(16, Number.parseInt(delayMs, 10) || 120));
}
function showMessageRecallDetail(messageIndex, record) {
const details = [
`楼层: ${messageIndex}`,
`来源: ${record.recallSource || "unknown"}`,
`Hook: ${record.hookName || "-"}`,
`tokenEstimate: ${record.tokenEstimate || 0}`,
`generationCount: ${record.generationCount || 0}`,
`manuallyEdited: ${record.manuallyEdited ? "true" : "false"}`,
`updatedAt: ${record.updatedAt || "-"}`,
"",
"注入内容:",
record.injectionText || "(empty)",
].join("\n");
globalThis.alert?.(details);
}
async function rerunRecallForMessage(messageIndex) {
const chat = getContext()?.chat;
const message = Array.isArray(chat) ? chat[messageIndex] : null;
@@ -1273,57 +1307,6 @@ async function rerunRecallForMessage(messageIndex) {
return result;
}
async function onMessageRecallBadgeClick(messageIndex) {
const record = getMessageRecallRecord(messageIndex);
if (!record) {
toastr.info("该楼层暂无持久召回记录");
schedulePersistedRecallMessageUiRefresh();
return;
}
const choiceRaw = globalThis.prompt?.(
[
`ST-BME 持久召回(楼层 ${messageIndex}`,
"1 查看详情",
"2 手动编辑",
"3 删除",
"4 重新召回",
"请输入序号:",
].join("\n"),
"1",
);
const choice = String(choiceRaw || "").trim().toLowerCase();
if (!choice) return;
if (choice === "1" || choice === "view" || choice === "detail") {
showMessageRecallDetail(messageIndex, record);
} else if (choice === "2" || choice === "edit") {
const nextText = globalThis.prompt?.(
`编辑楼层 ${messageIndex} 的持久召回注入文本:`,
record.injectionText || "",
);
if (nextText !== null && nextText !== undefined) {
const edited = editMessageRecallRecord(messageIndex, nextText);
if (edited) {
toastr.success("已保存手动编辑并标记 manuallyEdited=true");
} else {
toastr.warning("编辑失败:注入文本不能为空");
}
}
} else if (choice === "3" || choice === "delete") {
const confirmed = globalThis.confirm?.(`确认删除楼层 ${messageIndex} 的持久召回注入?`);
if (confirmed && removeMessageRecallRecord(messageIndex)) {
toastr.success("已删除持久召回注入");
}
} else if (choice === "4" || choice === "reroll" || choice === "recall") {
const rerunResult = await rerunRecallForMessage(messageIndex);
if (rerunResult?.status === "completed") {
toastr.success("重新召回完成,已覆盖持久召回记录");
}
}
schedulePersistedRecallMessageUiRefresh();
}
function getSendTextareaValue() {
return String(document.getElementById("send_textarea")?.value ?? "");

517
recall-message-ui.js Normal file
View File

@@ -0,0 +1,517 @@
// ST-BME: 消息级召回卡片 UI
// 纯 DOM 构建模块,不含模块级 mutable state
import { GraphRenderer } from "./graph-renderer.js";
// ==================== 常量 ====================
export const RECALL_CARD_FORCE_CONFIG = {
repulsion: 1200,
springLength: 50,
springK: 0.04,
damping: 0.85,
centerGravity: 0.08,
maxIterations: 80,
minNodeRadius: 6,
maxNodeRadius: 14,
labelFontSize: 11,
gridSpacing: 0,
gridColor: "transparent",
};
const DELETE_CONFIRM_TIMEOUT_MS = 3000;
// ==================== 子图构建 ====================
/**
* 从完整图谱中提取召回节点子图
* @param {object} graph - currentGraph
* @param {string[]} selectedNodeIds
* @returns {{ nodes: Array, edges: Array }}
*/
export function buildRecallSubGraph(graph, selectedNodeIds) {
if (!graph || !Array.isArray(graph.nodes) || !Array.isArray(selectedNodeIds)) {
return { nodes: [], edges: [] };
}
const idSet = new Set(selectedNodeIds);
const nodes = graph.nodes
.filter((n) => idSet.has(n.id) && !n.archived)
.map((n) => ({ ...n }));
const edges = (graph.edges || [])
.filter(
(e) =>
!e.invalidAt &&
!e.expiredAt &&
idSet.has(e.fromId) &&
idSet.has(e.toId),
);
return { nodes, edges };
}
// ==================== 辅助 DOM ====================
function el(tag, className, textContent) {
const element = document.createElement(tag);
if (className) element.className = className;
if (textContent !== undefined) element.textContent = textContent;
return element;
}
function formatTokenHint(tokenEstimate) {
if (!Number.isFinite(tokenEstimate) || tokenEstimate <= 0) return "";
return `~${tokenEstimate} tokens`;
}
function formatMetaLine(record) {
const parts = [];
if (record.recallSource) parts.push(`来源: ${record.recallSource}`);
if (record.tokenEstimate > 0) parts.push(`~${record.tokenEstimate} tokens`);
if (Number.isFinite(record.generationCount) && record.generationCount > 0) {
parts.push(`回退 ${record.generationCount}`);
}
if (record.updatedAt) {
const dateStr = String(record.updatedAt).replace(/T/, " ").replace(/\.\d+Z$/, "");
parts.push(dateStr);
}
return parts.join(" · ");
}
// ==================== 卡片 DOM 构建 ====================
/**
* 创建消息级召回卡片 DOM
* @param {object} params
* @param {number} params.messageIndex
* @param {object} params.record - bme_recall record
* @param {string} params.userMessageText
* @param {object|null} params.graph - currentGraph
* @param {string} params.themeName
* @param {object} params.callbacks
* @returns {HTMLElement}
*/
export function createRecallCardElement({
messageIndex,
record,
userMessageText = "",
graph = null,
themeName = "crimson",
callbacks = {},
}) {
const card = el("div", "bme-recall-card");
card.dataset.messageIndex = String(messageIndex);
card.dataset.updatedAt = String(record?.updatedAt || "");
// -- 用户消息区 --
const userLabel = el("div", "bme-recall-user-label");
userLabel.innerHTML = "💬 <span>本轮用户输入</span>";
card.appendChild(userLabel);
const userText = el("div", "bme-recall-user-text", userMessageText || "(empty)");
card.appendChild(userText);
// -- 召回条 --
const nodeCount = Array.isArray(record?.selectedNodeIds)
? record.selectedNodeIds.length
: 0;
const bar = el("div", "bme-recall-bar");
const barIcon = el("span", "bme-recall-bar-icon", "🧠");
bar.appendChild(barIcon);
const barTitle = el("span", "bme-recall-bar-title", "相关记忆召回");
bar.appendChild(barTitle);
const badge = el(
"span",
"bme-recall-count-badge",
nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓",
);
bar.appendChild(badge);
const tokenHint = el(
"span",
"bme-recall-token-hint",
formatTokenHint(record?.tokenEstimate),
);
bar.appendChild(tokenHint);
const arrow = el("span", "bme-recall-expand-arrow", "▶");
bar.appendChild(arrow);
card.appendChild(bar);
// -- 展开内容区 --
const body = el("div", "bme-recall-body");
card.appendChild(body);
// renderer 实例管理
let renderer = null;
function destroyRenderer() {
if (renderer) {
renderer.stopAnimation();
renderer.destroy();
renderer = null;
}
}
function buildExpandedContent() {
body.innerHTML = "";
const subGraph = graph
? buildRecallSubGraph(graph, record?.selectedNodeIds || [])
: { nodes: [], edges: [] };
if (subGraph.nodes.length === 0) {
const emptyMsg = el(
"div",
"bme-recall-empty",
graph ? "召回节点已不存在或图谱已重建" : "图谱未就绪",
);
body.appendChild(emptyMsg);
} else {
// Canvas 容器
const canvasWrap = el("div", "bme-recall-canvas-wrap");
const canvas = document.createElement("canvas");
canvasWrap.appendChild(canvas);
body.appendChild(canvasWrap);
// 创建小画布 GraphRenderer
renderer = new GraphRenderer(canvas, {
theme: themeName,
forceConfig: RECALL_CARD_FORCE_CONFIG,
onNodeClick: (node) => {
if (typeof callbacks.onNodeClick === "function") {
callbacks.onNodeClick(messageIndex, node);
}
},
onNodeDoubleClick: (node) => {
if (typeof callbacks.onNodeClick === "function") {
callbacks.onNodeClick(messageIndex, node);
}
},
});
renderer.loadGraph(subGraph);
}
// 元信息行
const meta = el("div", "bme-recall-meta", formatMetaLine(record || {}));
if (record?.manuallyEdited) {
const tag = el("span", "bme-recall-meta-tag", "✍ 手动编辑");
meta.appendChild(tag);
}
body.appendChild(meta);
// 操作按钮行
const actions = el("div", "bme-recall-actions");
const editBtn = el("button", "bme-recall-action-btn");
editBtn.innerHTML = '<span class="bme-recall-btn-icon">✏️</span> 编辑';
editBtn.type = "button";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
callbacks.onEdit?.(messageIndex);
});
actions.appendChild(editBtn);
const deleteBtn = el("button", "bme-recall-action-btn");
deleteBtn.innerHTML = '<span class="bme-recall-btn-icon">🗑</span> 删除';
deleteBtn.type = "button";
setupDeleteConfirmation(deleteBtn, () => {
callbacks.onDelete?.(messageIndex);
});
actions.appendChild(deleteBtn);
const recallBtn = el("button", "bme-recall-action-btn");
recallBtn.innerHTML = '<span class="bme-recall-btn-icon">🔄</span> 重新召回';
recallBtn.type = "button";
recallBtn.addEventListener("click", async (e) => {
e.stopPropagation();
setRecallButtonLoading(recallBtn, true);
try {
await callbacks.onRerunRecall?.(messageIndex);
} finally {
setRecallButtonLoading(recallBtn, false);
}
});
actions.appendChild(recallBtn);
body.appendChild(actions);
}
// 点击召回条 toggle 展开/折叠
bar.addEventListener("click", (e) => {
e.stopPropagation();
const isExpanded = card.classList.toggle("expanded");
if (isExpanded) {
buildExpandedContent();
} else {
destroyRenderer();
body.innerHTML = "";
}
});
// 暴露清理方法
card._bmeDestroyRenderer = destroyRenderer;
return card;
}
/**
* 更新已有卡片的 badge / token hint / meta不重建整个卡片
*/
export function updateRecallCardData(cardElement, record) {
if (!cardElement || !record) return;
cardElement.dataset.updatedAt = String(record.updatedAt || "");
const badge = cardElement.querySelector(".bme-recall-count-badge");
if (badge) {
const nodeCount = Array.isArray(record.selectedNodeIds)
? record.selectedNodeIds.length
: 0;
badge.textContent = nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓";
}
const tokenHint = cardElement.querySelector(".bme-recall-token-hint");
if (tokenHint) {
tokenHint.textContent = formatTokenHint(record.tokenEstimate);
}
}
// ==================== 删除二次确认 ====================
export function setupDeleteConfirmation(button, onConfirm) {
let confirmTimer = null;
let pendingConfirm = false;
const originalHTML = button.innerHTML;
function reset() {
clearTimeout(confirmTimer);
confirmTimer = null;
pendingConfirm = false;
button.innerHTML = originalHTML;
button.classList.remove("danger");
}
button.addEventListener("click", (e) => {
e.stopPropagation();
if (pendingConfirm) {
reset();
onConfirm();
return;
}
pendingConfirm = true;
button.textContent = "确认删除?";
button.classList.add("danger");
confirmTimer = setTimeout(reset, DELETE_CONFIRM_TIMEOUT_MS);
});
}
// ==================== Loading 状态 ====================
export function setRecallButtonLoading(button, loading) {
if (loading) {
button._bmeOriginalHTML = button.innerHTML;
button.innerHTML =
'<span class="bme-recall-btn-icon" style="display:inline-block">⟳</span> 召回中...';
button.classList.add("loading");
button.disabled = true;
} else {
button.innerHTML = button._bmeOriginalHTML || button.innerHTML;
button.classList.remove("loading");
button.disabled = false;
}
}
// ==================== 侧边栏 ====================
let sidebarBackdrop = null;
let sidebarElement = null;
function ensureSidebarDOM() {
if (sidebarBackdrop && sidebarElement) return;
sidebarBackdrop = el("div", "bme-recall-sidebar-backdrop");
sidebarBackdrop.addEventListener("click", () => closeRecallSidebar());
sidebarElement = el("div", "bme-recall-sidebar");
document.body.appendChild(sidebarBackdrop);
document.body.appendChild(sidebarElement);
}
/**
* 打开召回编辑/查看侧边栏
* @param {object} params
* @param {'view'|'edit'} params.mode
* @param {number} params.messageIndex
* @param {object} params.record
* @param {object|null} params.node - 点击的节点view 模式)
* @param {object|null} params.graph
* @param {object} params.callbacks
*/
export function openRecallSidebar({
mode = "edit",
messageIndex,
record,
node = null,
graph = null,
callbacks = {},
}) {
ensureSidebarDOM();
sidebarElement.innerHTML = "";
// Header
const header = el("div", "bme-recall-sidebar-header");
const headerTitle = el("div", "bme-recall-sidebar-header-title");
headerTitle.textContent =
mode === "edit" ? "📝 编辑召回注入" : "🔍 节点详情";
header.appendChild(headerTitle);
const closeBtn = el("button", "bme-recall-sidebar-close");
closeBtn.innerHTML = "✕";
closeBtn.type = "button";
closeBtn.addEventListener("click", () => closeRecallSidebar());
header.appendChild(closeBtn);
sidebarElement.appendChild(header);
// Node info (if viewing a specific node)
if (node && mode === "view") {
const nodeInfo = el("div", "bme-recall-sidebar-node-info");
const rows = [
["类型", node.type || node.raw?.type || "-"],
["名称", node.name || node.raw?.name || "-"],
["重要度", String(node.importance ?? node.raw?.importance ?? "-")],
];
for (const [label, value] of rows) {
const row = el("div", "bme-recall-sidebar-node-info-row");
const labelEl = el("span", "bme-recall-sidebar-node-info-label", label);
const valueEl = el("span", "", value);
row.appendChild(labelEl);
row.appendChild(valueEl);
nodeInfo.appendChild(row);
}
// Show edges to other recalled nodes
if (graph && record?.selectedNodeIds) {
const idSet = new Set(record.selectedNodeIds);
const relatedEdges = (graph.edges || []).filter(
(e) =>
!e.invalidAt &&
!e.expiredAt &&
((e.fromId === node.id && idSet.has(e.toId)) ||
(e.toId === node.id && idSet.has(e.fromId))),
);
if (relatedEdges.length > 0) {
const edgeRow = el("div", "bme-recall-sidebar-node-info-row");
const edgeLabel = el("span", "bme-recall-sidebar-node-info-label", "关联");
const edgeValue = el("span", "", `${relatedEdges.length} 条边`);
edgeRow.appendChild(edgeLabel);
edgeRow.appendChild(edgeValue);
nodeInfo.appendChild(edgeRow);
}
}
sidebarElement.appendChild(nodeInfo);
}
// Body
const body = el("div", "bme-recall-sidebar-body");
const sectionLabel = el(
"div",
"bme-recall-sidebar-section-label",
mode === "edit" ? "注入文本(可编辑)" : "注入文本",
);
body.appendChild(sectionLabel);
let textarea = null;
const injectionText = record?.injectionText || "";
if (mode === "edit") {
textarea = document.createElement("textarea");
textarea.className = "bme-recall-sidebar-textarea";
textarea.value = injectionText;
textarea.placeholder = "输入注入文本...";
body.appendChild(textarea);
const tokenHint = el("div", "bme-recall-sidebar-token-hint");
const updateTokenHint = () => {
const count =
typeof callbacks.estimateTokens === "function"
? callbacks.estimateTokens(textarea.value)
: textarea.value.length;
tokenHint.textContent = `~${count} tokens`;
};
updateTokenHint();
let debounceTimer = null;
textarea.addEventListener("input", () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(updateTokenHint, 300);
});
body.appendChild(tokenHint);
} else {
const readonlyEl = el("div", "bme-recall-sidebar-readonly", injectionText || "(empty)");
body.appendChild(readonlyEl);
}
sidebarElement.appendChild(body);
// Footer
const footer = el("div", "bme-recall-sidebar-footer");
if (mode === "edit") {
const saveBtn = el("button", "bme-recall-sidebar-btn primary", "保存");
saveBtn.type = "button";
saveBtn.addEventListener("click", () => {
const newText = textarea?.value || "";
callbacks.onSave?.(messageIndex, newText);
closeRecallSidebar();
});
footer.appendChild(saveBtn);
const cancelBtn = el("button", "bme-recall-sidebar-btn secondary", "取消");
cancelBtn.type = "button";
cancelBtn.addEventListener("click", () => closeRecallSidebar());
footer.appendChild(cancelBtn);
} else {
// View mode: offer edit button
const editBtn = el("button", "bme-recall-sidebar-btn primary", "✏️ 编辑");
editBtn.type = "button";
editBtn.addEventListener("click", () => {
openRecallSidebar({
mode: "edit",
messageIndex,
record,
node: null,
graph,
callbacks,
});
});
footer.appendChild(editBtn);
const closeFooterBtn = el("button", "bme-recall-sidebar-btn secondary", "关闭");
closeFooterBtn.type = "button";
closeFooterBtn.addEventListener("click", () => closeRecallSidebar());
footer.appendChild(closeFooterBtn);
}
sidebarElement.appendChild(footer);
// Animate in
requestAnimationFrame(() => {
sidebarBackdrop.classList.add("open");
sidebarElement.classList.add("open");
if (textarea) textarea.focus();
});
}
export function closeRecallSidebar() {
if (sidebarBackdrop) sidebarBackdrop.classList.remove("open");
if (sidebarElement) sidebarElement.classList.remove("open");
}

431
style.css
View File

@@ -2705,3 +2705,434 @@
}
}
/* --- Recall Message Card --- */
.bme-recall-card {
margin-top: 8px;
background: var(--bme-surface-container, #1f1f22);
border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
border-left: 3px solid var(--bme-primary, #e94560);
border-radius: 8px;
overflow: hidden;
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
}
.bme-recall-user-label {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px 4px;
font-size: 12px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
font-weight: 500;
}
.bme-recall-user-text {
padding: 4px 14px 10px;
font-size: 14px;
line-height: 1.6;
color: var(--bme-on-surface, #e4e1e6);
word-break: break-word;
}
/* --- Recall Bar (collapse/expand trigger) --- */
.bme-recall-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--bme-surface-low, #1b1b1e);
cursor: pointer;
user-select: none;
transition: background 0.15s;
border-top: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
}
.bme-recall-bar:hover {
background: var(--bme-surface-high, #2a2a2d);
}
.bme-recall-bar-icon {
font-size: 14px;
flex-shrink: 0;
}
.bme-recall-bar-title {
font-size: 12px;
font-weight: 600;
color: var(--bme-on-surface, #e4e1e6);
flex-shrink: 0;
}
.bme-recall-count-badge {
display: inline-flex;
align-items: center;
padding: 1px 8px;
font-size: 11px;
font-weight: 600;
border-radius: 10px;
background: var(--bme-primary-dim, rgba(233, 69, 96, 0.15));
color: var(--bme-primary-text, #ffb2b7);
flex-shrink: 0;
}
.bme-recall-token-hint {
font-size: 11px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
margin-left: auto;
flex-shrink: 0;
}
.bme-recall-expand-arrow {
font-size: 12px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
transition: transform 0.25s ease;
flex-shrink: 0;
margin-left: 4px;
}
.bme-recall-card.expanded .bme-recall-expand-arrow {
transform: rotate(90deg);
}
/* --- Recall Body (expanded content) --- */
.bme-recall-body {
max-height: 0;
opacity: 0;
overflow: hidden;
transition:
max-height 0.3s ease,
opacity 0.25s ease;
}
.bme-recall-card.expanded .bme-recall-body {
max-height: 600px;
opacity: 1;
}
.bme-recall-canvas-wrap {
width: 100%;
height: 250px;
position: relative;
background: var(--bme-surface-lowest, #0e0e11);
border-radius: 0;
}
.bme-recall-canvas-wrap canvas {
display: block;
width: 100%;
height: 100%;
}
.bme-recall-empty {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
font-size: 12px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
font-style: italic;
}
/* --- Recall Meta --- */
.bme-recall-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 11px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
border-top: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
}
.bme-recall-meta-tag {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
border-radius: 4px;
background: var(--bme-accent3, #ffc107);
color: #000;
}
/* --- Recall Actions --- */
.bme-recall-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 8px 14px 10px;
}
.bme-recall-action-btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 10px;
font-size: 11px;
font-weight: 500;
border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
border-radius: 6px;
background: var(--bme-surface-low, #1b1b1e);
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s,
color 0.15s;
white-space: nowrap;
}
.bme-recall-action-btn:hover {
background: var(--bme-surface-high, #2a2a2d);
border-color: var(--bme-border-active, rgba(233, 69, 96, 0.4));
color: var(--bme-on-surface, #e4e1e6);
}
.bme-recall-action-btn.danger {
background: rgba(233, 69, 96, 0.2);
border-color: var(--bme-primary, #e94560);
color: var(--bme-primary, #e94560);
}
.bme-recall-action-btn.loading {
pointer-events: none;
opacity: 0.7;
}
@keyframes bme-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.bme-recall-action-btn.loading .bme-recall-btn-icon {
animation: bme-spin 1s linear infinite;
}
/* --- Recall Sidebar --- */
.bme-recall-sidebar-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;
}
.bme-recall-sidebar-backdrop.open {
opacity: 1;
pointer-events: auto;
}
.bme-recall-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(400px, 90vw);
height: 100vh;
z-index: 10001;
background: var(--bme-surface-container, #1f1f22);
border-left: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
display: flex;
flex-direction: column;
transform: translateX(100%);
transition: transform 0.25s ease;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.4);
}
.bme-recall-sidebar.open {
transform: translateX(0);
}
.bme-recall-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
flex-shrink: 0;
}
.bme-recall-sidebar-header-title {
font-size: 14px;
font-weight: 600;
color: var(--bme-primary, #e94560);
display: flex;
align-items: center;
gap: 6px;
}
.bme-recall-sidebar-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
cursor: pointer;
border-radius: 4px;
font-size: 14px;
transition: all 0.15s;
}
.bme-recall-sidebar-close:hover {
background: var(--bme-surface-highest, #353438);
color: var(--bme-on-surface, #e4e1e6);
}
.bme-recall-sidebar-node-info {
padding: 12px 16px;
border-bottom: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
font-size: 12px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
display: flex;
flex-direction: column;
gap: 4px;
}
.bme-recall-sidebar-node-info-row {
display: flex;
gap: 8px;
}
.bme-recall-sidebar-node-info-label {
font-weight: 600;
color: var(--bme-on-surface, #e4e1e6);
min-width: 48px;
}
.bme-recall-sidebar-body {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.bme-recall-sidebar-section-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
}
.bme-recall-sidebar-textarea {
width: 100%;
min-height: 200px;
padding: 10px;
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px;
line-height: 1.6;
background: var(--bme-surface-low, #1b1b1e);
border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
border-radius: 6px;
color: var(--bme-on-surface, #e4e1e6);
resize: vertical;
transition: border-color 0.15s;
}
.bme-recall-sidebar-textarea:focus {
outline: none;
border-color: var(--bme-border-active, rgba(233, 69, 96, 0.4));
}
.bme-recall-sidebar-readonly {
width: 100%;
min-height: 120px;
padding: 10px;
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
font-size: 13px;
line-height: 1.6;
background: var(--bme-surface-low, #1b1b1e);
border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
border-radius: 6px;
color: var(--bme-on-surface, #e4e1e6);
white-space: pre-wrap;
word-break: break-word;
overflow-y: auto;
max-height: 40vh;
}
.bme-recall-sidebar-token-hint {
font-size: 11px;
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
font-feature-settings: 'tnum';
}
.bme-recall-sidebar-footer {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
flex-shrink: 0;
}
.bme-recall-sidebar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 7px 16px;
font-size: 12px;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
white-space: nowrap;
}
.bme-recall-sidebar-btn.primary {
background: var(--bme-primary-dim, rgba(233, 69, 96, 0.15));
border: 1px solid var(--bme-primary, #e94560);
color: var(--bme-primary-text, #ffb2b7);
}
.bme-recall-sidebar-btn.primary:hover {
background: var(--bme-primary-glow, rgba(233, 69, 96, 0.35));
}
.bme-recall-sidebar-btn.secondary {
background: transparent;
border: 1px solid var(--bme-border, rgba(255, 255, 255, 0.08));
color: var(--bme-on-surface-dim, rgba(228, 225, 230, 0.6));
}
.bme-recall-sidebar-btn.secondary:hover {
background: var(--bme-surface-high, #2a2a2d);
color: var(--bme-on-surface, #e4e1e6);
}
/* --- Recall Card Responsive --- */
@media (max-width: 768px) {
.bme-recall-canvas-wrap {
height: 180px;
}
.bme-recall-sidebar {
width: 100vw;
}
.bme-recall-actions {
gap: 4px;
}
.bme-recall-action-btn {
padding: 4px 8px;
font-size: 10px;
}
}

View File

@@ -361,138 +361,6 @@ function createGenerationRecallHarness() {
});
}
function createMessageRecallUiHarness() {
return fs.readFile(indexPath, "utf8").then((source) => {
const start = source.indexOf("function getMessageRecallRecord(messageIndex) {");
const end = source.indexOf("function getSendTextareaValue() {");
if (start < 0 || end < 0 || end <= start) {
throw new Error("无法从 index.js 提取消息级召回 UI 定义");
}
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
const chat = [
{
is_user: true,
mes: "u0",
extra: {
bme_recall: {
version: 1,
injectionText: "persisted-memory",
selectedNodeIds: ["n1"],
recallInput: "u0",
recallSource: "chat-last-user",
hookName: "GENERATION_AFTER_COMMANDS",
tokenEstimate: 16,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
generationCount: 0,
manuallyEdited: false,
},
},
},
];
const badgeHost = {
children: [],
appendChild(child) {
child.parent = this;
this.children.push(child);
},
};
const messageEl = {
dataset: {},
getAttribute(name) {
if (name === "mesid" || name === "data-mesid") return "0";
return null;
},
querySelector(selector) {
if (selector === ".mes_buttons") return badgeHost;
return null;
},
};
const chatRoot = {
querySelectorAll(selector) {
if (selector === ".mes") return [messageEl];
if (selector === ".st-bme-recall-badge") {
return badgeHost.children.filter(
(child) => child.className === "st-bme-recall-badge",
);
}
return [];
},
};
const context = {
console,
Date,
clearTimeout,
setTimeout,
result: null,
persistedRecallUiRefreshTimer: null,
lastInjectionContent: "",
lastRecallSentUserMessage: createRecallInputRecord(),
runtimeStatus: createUiStatus("待命", "", "idle"),
getContext: () => ({ chat }),
document: {
getElementById(id) {
return id === "chat" ? chatRoot : null;
},
createElement() {
return {
className: "",
textContent: "",
dataset: {},
style: {},
listeners: {},
parent: null,
addEventListener(type, handler) {
this.listeners[type] = handler;
},
remove() {
if (!this.parent?.children) return;
this.parent.children = this.parent.children.filter((item) => item !== this);
},
};
},
},
readPersistedRecallFromUserMessage,
writePersistedRecallToUserMessage,
removePersistedRecallFromUserMessage,
markPersistedRecallManualEdit,
bumpPersistedRecallGenerationCount,
buildPersistedRecallRecord,
resolveGenerationTargetUserMessageIndex,
resolveFinalRecallInjectionSource,
normalizeRecallInputText,
estimateTokens: (text = "") => String(text || "").length,
triggerChatMetadataSave: () => "debounced",
getSettings: () => ({}),
applyModuleInjectionPrompt: () => ({}),
createUiStatus,
refreshPanelLiveState: () => {},
runRecall: async () => ({ status: "completed", didRecall: true, injectionText: "fresh" }),
applyFinalRecallInjectionForGeneration: () => ({ source: "fresh" }),
toastr: { info() {}, success() {}, warning() {}, error() {} },
promptResponses: [],
prompt(defaultText = "") {
return context.promptResponses.length > 0 ? context.promptResponses.shift() : defaultText;
},
confirm: () => true,
alertMessages: [],
alert(message) {
context.alertMessages.push(String(message || ""));
},
};
vm.createContext(context);
vm.runInContext(
`${snippet}\nresult = { refreshPersistedRecallMessageUi, onMessageRecallBadgeClick };`,
context,
{ filename: indexPath },
);
return { context, chat, badgeHost };
});
}
function createRerollHarness() {
return fs.readFile(indexPath, "utf8").then((source) => {
const rollbackStart = source.indexOf("async function rollbackGraphForReroll(");
@@ -1690,24 +1558,43 @@ async function testPersistentRecallSourceResolutionAndTargetRouting() {
assert.equal(fallback.injectionText, "persisted");
}
async function testMessageRecallUiBadgeEntryPoints() {
const { context, chat, badgeHost } = await createMessageRecallUiHarness();
context.result.refreshPersistedRecallMessageUi();
assert.equal(badgeHost.children.length, 1);
assert.equal(typeof badgeHost.children[0].listeners.click, "function");
async function testRecallSubGraphAndDataLayerEntryPoints() {
// Sub-graph build test (pure function, no DOM needed)
const { buildRecallSubGraph } = await import("../recall-message-ui.js");
context.promptResponses = ["1"];
await context.result.onMessageRecallBadgeClick(0);
assert.equal(context.alertMessages.length, 1);
const graph = {
nodes: [
{ id: "n1", type: "character", name: "赵管家", importance: 7 },
{ id: "n2", type: "event", name: "喂食", importance: 5 },
{ id: "n3", type: "location", name: "厨房", importance: 3, archived: true },
{ id: "n4", type: "thread", name: "主线", importance: 8 },
],
edges: [
{ fromId: "n1", toId: "n2", strength: 0.8, relation: "related" },
{ fromId: "n2", toId: "n3", strength: 0.5, relation: "located" },
{ fromId: "n1", toId: "n4", strength: 0.6, relation: "participates" },
],
};
context.promptResponses = ["2", "edited-by-user"];
await context.result.onMessageRecallBadgeClick(0);
const edited = readPersistedRecallFromUserMessage(chat, 0);
assert.equal(edited?.injectionText, "edited-by-user");
assert.equal(edited?.manuallyEdited, true);
const sub1 = buildRecallSubGraph(graph, ["n1", "n2"]);
assert.equal(sub1.nodes.length, 2);
assert.equal(sub1.edges.length, 1);
assert.equal(sub1.edges[0].fromId, "n1");
context.promptResponses = ["3"];
await context.result.onMessageRecallBadgeClick(0);
// archived node should be excluded
const sub2 = buildRecallSubGraph(graph, ["n1", "n3"]);
assert.equal(sub2.nodes.length, 1);
assert.equal(sub2.edges.length, 0);
// empty/null safety
assert.equal(buildRecallSubGraph(null, ["n1"]).nodes.length, 0);
assert.equal(buildRecallSubGraph(graph, null).nodes.length, 0);
assert.equal(buildRecallSubGraph(graph, []).nodes.length, 0);
// Data layer: edit and delete still work
const chat = [{ is_user: true, mes: "u0", extra: { bme_recall: { version: 1, injectionText: "test", selectedNodeIds: ["n1"], generationCount: 0, manuallyEdited: false, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", recallInput: "u0", recallSource: "test", hookName: "TEST", tokenEstimate: 4 } } }];
assert.ok(readPersistedRecallFromUserMessage(chat, 0));
assert.equal(removePersistedRecallFromUserMessage(chat, 0), true);
assert.equal(readPersistedRecallFromUserMessage(chat, 0), null);
}
@@ -2063,7 +1950,7 @@ await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
await testPersistentRecallDataLayerLifecycleAndCompatibility();
await testPersistentRecallSourceResolutionAndTargetRouting();
await testMessageRecallUiBadgeEntryPoints();
await testRecallSubGraphAndDataLayerEntryPoints();
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
await testRerollRejectsMissingRecoveryPoint();
await testRerollFallsBackToDirectExtractForUnprocessedFloor();