mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
518 lines
15 KiB
JavaScript
518 lines
15 KiB
JavaScript
// 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");
|
||
}
|