diff --git a/README.md b/README.md
index 79ffee9..6b2265b 100644
--- a/README.md
+++ b/README.md
@@ -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()` 浏览器原生对话框。
兼容性说明:
diff --git a/graph-renderer.js b/graph-renderer.js
index 00fe40f..2d24cbb 100644
--- a/graph-renderer.js
+++ b/graph-renderer.js
@@ -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();
}
}
diff --git a/index.js b/index.js
index 336c56e..e73de9a 100644
--- a/index.js
+++ b/index.js
@@ -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 ?? "");
diff --git a/recall-message-ui.js b/recall-message-ui.js
new file mode 100644
index 0000000..9d012a0
--- /dev/null
+++ b/recall-message-ui.js
@@ -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 = "💬 本轮用户输入";
+ 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 = '✏️ 编辑';
+ 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 = '🗑 删除';
+ deleteBtn.type = "button";
+ setupDeleteConfirmation(deleteBtn, () => {
+ callbacks.onDelete?.(messageIndex);
+ });
+ actions.appendChild(deleteBtn);
+
+ const recallBtn = el("button", "bme-recall-action-btn");
+ recallBtn.innerHTML = '🔄 重新召回';
+ 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 =
+ '⟳ 召回中...';
+ 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");
+}
diff --git a/style.css b/style.css
index 597c79c..a1767b1 100644
--- a/style.css
+++ b/style.css
@@ -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;
+ }
+}
diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs
index 122844d..ab7ee3e 100644
--- a/tests/p0-regressions.mjs
+++ b/tests/p0-regressions.mjs
@@ -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();