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:
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");
|
||||
}
|
||||
Reference in New Issue
Block a user