fix: rerender expanded recall card content on persisted update

This commit is contained in:
Youzini-afk
2026-03-31 13:13:16 +08:00
parent b0f9d191bd
commit 4925810064
3 changed files with 293 additions and 37 deletions

View File

@@ -1443,7 +1443,12 @@ function refreshPersistedRecallMessageUi() {
) || null;
if (currentCard) {
updateRecallCardData(currentCard, record);
updateRecallCardData(currentCard, record, {
userMessageText: message.mes || "",
graph: currentGraph,
themeName,
callbacks,
});
} else {
const card = createRecallCardElement({
messageIndex,

View File

@@ -79,6 +79,94 @@ function formatMetaLine(record) {
return parts.join(" · ");
}
function stableSerialize(value) {
if (value === null || value === undefined) return "null";
const type = typeof value;
if (type === "number") {
return Number.isFinite(value) ? String(value) : "null";
}
if (type === "boolean") return value ? "true" : "false";
if (type === "string") return JSON.stringify(value);
if (Array.isArray(value)) {
return `[${value.map((item) => stableSerialize(item)).join(",")}]`;
}
if (type === "object") {
const keys = Object.keys(value).sort();
return `{${keys
.map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`)
.join(",")}}`;
}
return "null";
}
function normalizeSelectedNodeIds(selectedNodeIds = []) {
return Array.isArray(selectedNodeIds)
? selectedNodeIds
.map((id) => String(id || "").trim())
.filter(Boolean)
.sort()
: [];
}
function summarizeSubGraphForSignature(subGraph) {
const nodes = Array.isArray(subGraph?.nodes)
? subGraph.nodes
.map((node) => ({
id: String(node?.id || ""),
type: String(node?.type || ""),
archived: Boolean(node?.archived),
seq: Number.isFinite(node?.seq) ? node.seq : 0,
seqRange: Array.isArray(node?.seqRange)
? [
Number.isFinite(node.seqRange[0]) ? node.seqRange[0] : 0,
Number.isFinite(node.seqRange[1]) ? node.seqRange[1] : 0,
]
: [],
fields: node?.fields && typeof node.fields === "object" ? { ...node.fields } : {},
}))
.sort((left, right) => left.id.localeCompare(right.id))
: [];
const edges = Array.isArray(subGraph?.edges)
? subGraph.edges
.map((edge) => ({
fromId: String(edge?.fromId || ""),
toId: String(edge?.toId || ""),
relation: String(edge?.relation || ""),
strength: Number.isFinite(edge?.strength) ? edge.strength : 0,
}))
.sort((left, right) => {
const leftKey = `${left.fromId}->${left.toId}:${left.relation}`;
const rightKey = `${right.fromId}->${right.toId}:${right.relation}`;
return leftKey.localeCompare(rightKey);
})
: [];
return { nodes, edges };
}
function buildExpandedRenderSignature({
record,
userMessageText,
selectedNodeIds,
subGraph,
} = {}) {
return stableSerialize({
updatedAt: String(record?.updatedAt || ""),
manuallyEdited: Boolean(record?.manuallyEdited),
generationCount: Number.isFinite(record?.generationCount)
? record.generationCount
: 0,
tokenEstimate: Number.isFinite(record?.tokenEstimate) ? record.tokenEstimate : 0,
recallSource: String(record?.recallSource || ""),
hookName: String(record?.hookName || ""),
injectionText: String(record?.injectionText || ""),
selectedNodeIds: normalizeSelectedNodeIds(selectedNodeIds),
userMessageText: String(userMessageText || ""),
subGraph: summarizeSubGraphForSignature(subGraph),
});
}
// ==================== 卡片 DOM 构建 ====================
/**
@@ -103,18 +191,25 @@ export function createRecallCardElement({
const card = el("div", "bme-recall-card");
card.dataset.messageIndex = String(messageIndex);
card.dataset.updatedAt = String(record?.updatedAt || "");
card.dataset.expandedRenderSignature = "";
let activeRecord = record || {};
let activeUserMessageText = String(userMessageText || "");
let activeGraph = graph || null;
let activeCallbacks = callbacks || {};
let expandedRenderSignature = "";
// -- 用户消息区 --
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)");
const userText = el("div", "bme-recall-user-text", activeUserMessageText || "(empty)");
card.appendChild(userText);
// -- 召回条 --
const nodeCount = Array.isArray(record?.selectedNodeIds)
? record.selectedNodeIds.length
const initialNodeCount = Array.isArray(activeRecord?.selectedNodeIds)
? activeRecord.selectedNodeIds.length
: 0;
const bar = el("div", "bme-recall-bar");
@@ -127,15 +222,16 @@ export function createRecallCardElement({
const badge = el(
"span",
"bme-recall-count-badge",
nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓",
initialNodeCount > 0 ? `记忆 ${initialNodeCount}` : "记忆 ✓",
);
bar.appendChild(badge);
const tokenHint = el(
"span",
"bme-recall-token-hint",
formatTokenHint(record?.tokenEstimate),
formatTokenHint(activeRecord?.tokenEstimate),
);
bar.appendChild(tokenHint);
const arrow = el("span", "bme-recall-expand-arrow", "▶");
@@ -158,18 +254,20 @@ export function createRecallCardElement({
}
}
function buildExpandedContent() {
function buildExpandedContent(subGraph = null, nextSignature = "") {
body.innerHTML = "";
const subGraph = graph
? buildRecallSubGraph(graph, record?.selectedNodeIds || [])
: { nodes: [], edges: [] };
const resolvedSubGraph =
subGraph ||
(activeGraph
? buildRecallSubGraph(activeGraph, activeRecord?.selectedNodeIds || [])
: { nodes: [], edges: [] });
if (subGraph.nodes.length === 0) {
if (resolvedSubGraph.nodes.length === 0) {
const emptyMsg = el(
"div",
"bme-recall-empty",
graph ? "召回节点已不存在或图谱已重建" : "图谱未就绪",
activeGraph ? "召回节点已不存在或图谱已重建" : "图谱未就绪",
);
body.appendChild(emptyMsg);
} else {
@@ -184,22 +282,22 @@ export function createRecallCardElement({
theme: themeName,
forceConfig: RECALL_CARD_FORCE_CONFIG,
onNodeClick: (node) => {
if (typeof callbacks.onNodeClick === "function") {
callbacks.onNodeClick(messageIndex, node);
if (typeof activeCallbacks.onNodeClick === "function") {
activeCallbacks.onNodeClick(messageIndex, node);
}
},
onNodeDoubleClick: (node) => {
if (typeof callbacks.onNodeClick === "function") {
callbacks.onNodeClick(messageIndex, node);
if (typeof activeCallbacks.onNodeClick === "function") {
activeCallbacks.onNodeClick(messageIndex, node);
}
},
});
renderer.loadGraph(subGraph);
renderer.loadGraph(resolvedSubGraph);
}
// 元信息行
const meta = el("div", "bme-recall-meta", formatMetaLine(record || {}));
if (record?.manuallyEdited) {
const meta = el("div", "bme-recall-meta", formatMetaLine(activeRecord || {}));
if (activeRecord?.manuallyEdited) {
const tag = el("span", "bme-recall-meta-tag", "✍ 手动编辑");
meta.appendChild(tag);
}
@@ -213,7 +311,7 @@ export function createRecallCardElement({
editBtn.type = "button";
editBtn.addEventListener("click", (e) => {
e.stopPropagation();
callbacks.onEdit?.(messageIndex);
activeCallbacks.onEdit?.(messageIndex);
});
actions.appendChild(editBtn);
@@ -221,7 +319,7 @@ export function createRecallCardElement({
deleteBtn.innerHTML = '<span class="bme-recall-btn-icon">🗑</span> 删除';
deleteBtn.type = "button";
setupDeleteConfirmation(deleteBtn, () => {
callbacks.onDelete?.(messageIndex);
activeCallbacks.onDelete?.(messageIndex);
});
actions.appendChild(deleteBtn);
@@ -232,7 +330,7 @@ export function createRecallCardElement({
e.stopPropagation();
setRecallButtonLoading(recallBtn, true);
try {
await callbacks.onRerunRecall?.(messageIndex);
await activeCallbacks.onRerunRecall?.(messageIndex);
} finally {
setRecallButtonLoading(recallBtn, false);
}
@@ -240,46 +338,106 @@ export function createRecallCardElement({
actions.appendChild(recallBtn);
body.appendChild(actions);
expandedRenderSignature =
nextSignature ||
buildExpandedRenderSignature({
record: activeRecord,
userMessageText: activeUserMessageText,
selectedNodeIds: activeRecord?.selectedNodeIds || [],
subGraph: resolvedSubGraph,
});
card.dataset.expandedRenderSignature = expandedRenderSignature;
}
function applyCardRuntimeData(next = {}, { skipExpandedRerender = false } = {}) {
if (next.record && typeof next.record === "object") {
activeRecord = next.record;
}
if (Object.prototype.hasOwnProperty.call(next, "userMessageText")) {
activeUserMessageText = String(next.userMessageText || "");
}
if (Object.prototype.hasOwnProperty.call(next, "graph")) {
activeGraph = next.graph || null;
}
if (next.callbacks && typeof next.callbacks === "object") {
activeCallbacks = next.callbacks;
}
card.dataset.updatedAt = String(activeRecord?.updatedAt || "");
card.dataset.expandedRenderSignature = expandedRenderSignature;
userText.textContent = activeUserMessageText || "(empty)";
const nodeCount = Array.isArray(activeRecord?.selectedNodeIds)
? activeRecord.selectedNodeIds.length
: 0;
badge.textContent = nodeCount > 0 ? `记忆 ${nodeCount}` : "记忆 ✓";
tokenHint.textContent = formatTokenHint(activeRecord?.tokenEstimate);
if (skipExpandedRerender || !card.classList.contains("expanded")) return;
const nextSubGraph = activeGraph
? buildRecallSubGraph(activeGraph, activeRecord?.selectedNodeIds || [])
: { nodes: [], edges: [] };
const nextSignature = buildExpandedRenderSignature({
record: activeRecord,
userMessageText: activeUserMessageText,
selectedNodeIds: activeRecord?.selectedNodeIds || [],
subGraph: nextSubGraph,
});
if (nextSignature === expandedRenderSignature) return;
destroyRenderer();
buildExpandedContent(nextSubGraph, nextSignature);
}
card._bmeUpdateRecallCard = applyCardRuntimeData;
// 点击召回条 toggle 展开/折叠
bar.addEventListener("click", (e) => {
e.stopPropagation();
const isExpanded = card.classList.toggle("expanded");
if (isExpanded) {
applyCardRuntimeData({}, { skipExpandedRerender: true });
buildExpandedContent();
} else {
destroyRenderer();
body.innerHTML = "";
expandedRenderSignature = "";
card.dataset.expandedRenderSignature = "";
}
});
applyCardRuntimeData({}, { skipExpandedRerender: true });
// 暴露清理方法
card._bmeDestroyRenderer = destroyRenderer;
card._bmeDestroyRenderer = () => {
destroyRenderer();
expandedRenderSignature = "";
card.dataset.expandedRenderSignature = "";
};
return card;
}
/**
* 更新已有卡片的 badge / token hint / meta不重建整个卡片
*/
export function updateRecallCardData(cardElement, record) {
export function updateRecallCardData(cardElement, record, options = {}) {
if (!cardElement || !record) return;
if (typeof cardElement._bmeUpdateRecallCard === "function") {
cardElement._bmeUpdateRecallCard({
record,
userMessageText: options?.userMessageText,
graph: options?.graph,
callbacks: options?.callbacks,
});
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);
}
}
// ==================== 删除二次确认 ====================

View File

@@ -1076,6 +1076,97 @@ async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() {
}
}
async function testRecallCardExpandedContentRerendersAfterRecordUpdate() {
const chat = [
{
is_user: true,
mes: "user-0",
extra: {
bme_recall: buildPersistedRecallRecord({
injectionText: "recall-0",
selectedNodeIds: ["n1"],
recallSource: "before",
tokenEstimate: 8,
nowIso: "2026-01-01T00:00:00.000Z",
}),
},
},
];
const harness = await createRecallUiHarness({ chat });
const messageElement = createMessageElement(harness.document, 0, {
stableId: true,
withMesBlock: true,
isUser: true,
});
harness.chatRoot.appendChild(messageElement);
try {
let summary = harness.api.refreshPersistedRecallMessageUi();
assert.equal(summary.status, "rendered");
let card = harness.chatRoot.querySelector(".bme-recall-card");
card.querySelector(".bme-recall-bar")?.click();
assert.equal(card.classList.contains("expanded"), true);
const signatureBefore = card.dataset.expandedRenderSignature || "";
assert.equal(card.querySelector(".bme-recall-meta-tag"), null);
chat[0].extra.bme_recall = buildPersistedRecallRecord(
{
injectionText: "recall-1",
selectedNodeIds: ["n1", "n2"],
recallSource: "after",
tokenEstimate: 13,
manuallyEdited: true,
nowIso: "2026-01-01T00:01:00.000Z",
},
chat[0].extra.bme_recall,
);
summary = harness.api.refreshPersistedRecallMessageUi();
assert.equal(summary.status, "rendered");
card = harness.chatRoot.querySelector(".bme-recall-card");
assert.equal(card.dataset.updatedAt, "2026-01-01T00:01:00.000Z");
assert.equal(card.querySelector(".bme-recall-count-badge")?.textContent, "记忆 2");
assert.equal(
card.querySelector(".bme-recall-token-hint")?.textContent,
"~13 tokens",
);
const metaElements = card.querySelectorAll(".bme-recall-meta");
const latestMeta = metaElements[metaElements.length - 1] || null;
const latestTag = card.querySelectorAll(".bme-recall-meta-tag").pop() || null;
assert.ok(latestMeta?.textContent.includes("来源: after"));
assert.equal(latestTag?.textContent, "✍ 手动编辑");
assert.notEqual(card.dataset.expandedRenderSignature, signatureBefore);
} finally {
harness.restoreGlobals();
}
}
async function testRecallCardUserTextRefreshesWithoutCardRecreate() {
const chat = [
{ is_user: true, mes: "before-user", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
];
const harness = await createRecallUiHarness({ chat });
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
harness.chatRoot.appendChild(messageElement);
try {
harness.api.refreshPersistedRecallMessageUi();
const firstCard = harness.chatRoot.querySelector(".bme-recall-card");
assert.equal(firstCard.querySelector(".bme-recall-user-text")?.textContent, "before-user");
chat[0].mes = "after-user";
harness.api.refreshPersistedRecallMessageUi();
const secondCard = harness.chatRoot.querySelector(".bme-recall-card");
assert.equal(secondCard, firstCard);
assert.equal(secondCard.querySelector(".bme-recall-user-text")?.textContent, "after-user");
} finally {
harness.restoreGlobals();
}
}
function makeEvent(seq, title) {
return createNode({
type: "event",
@@ -2573,6 +2664,8 @@ await testRecallCardSkipsMountWithoutStableMessageIndex();
await testRecallCardDelayedDomInsertionEventuallyRenders();
await testRecallCardDoesNotMountOnNonUserFloor();
await testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates();
await testRecallCardExpandedContentRerendersAfterRecordUpdate();
await testRecallCardUserTextRefreshesWithoutCardRecreate();
await testRecallSubGraphAndDataLayerEntryPoints();
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
await testRerollRejectsMissingRecoveryPoint();