mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat: recall card UI v2 — inline card with force-graph sub-graph, sidebar editor, no more prompt/alert/confirm
This commit is contained in:
14
README.md
14
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()` 浏览器原生对话框。
|
||||
|
||||
兼容性说明:
|
||||
|
||||
|
||||
@@ -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
233
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 ?? "");
|
||||
|
||||
517
recall-message-ui.js
Normal file
517
recall-message-ui.js
Normal 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
431
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user