fix: stabilize recall card mounting and refresh timing

This commit is contained in:
root
2026-03-29 20:45:08 +08:00
parent 4aeba55e90
commit e09b116a71
3 changed files with 862 additions and 87 deletions

View File

@@ -368,6 +368,7 @@ ST-BME/
消息级 UI 消息级 UI
- 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge - 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge
- 显示前提:必须同时满足 **用户楼层**、`message.extra.bme_recall` 存在、且 `injectionText` 为非空字符串。
- 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`)。 - 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`)。
- 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情。 - 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情。
- 操作按钮(展开态底部): - 操作按钮(展开态底部):
@@ -375,11 +376,22 @@ ST-BME/
- **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。 - **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。
- **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。 - **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。
- 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。 - 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。
- 当聊天 DOM 延迟插入时,插件会执行**有界重试 + 短生命周期 MutationObserver 补偿**,避免单次刷新错过挂载。
兼容性说明: 兼容性说明:
- 旧聊天(无 `extra` 或无 `bme_recall`)会自动按“无持久记录”处理,不会报错。 - 旧聊天(无 `extra` 或无 `bme_recall`)会自动按“无持久记录”处理,不会报错。
- badge 依赖酒馆消息 DOM 的楼层索引属性;若第三方主题重写消息结构,可能需要额外适配 - Recall Card 依赖消息楼层存在稳定索引属性(如 `mesid` / `data-mesid` / `data-message-id`),不会再回退到 DOM 顺序猜测,以避免误挂载到错误楼层
- 第三方主题至少需要保留 `#chat .mes` 外层消息节点;卡片会优先尝试挂载到 `.mes_block`,其次 `.mes_text` 的父节点,最后回退到 `.mes` 根节点。
- 若第三方主题完全移除了这些锚点或稳定索引属性,插件会选择**跳过挂载并输出 `[ST-BME]` 调试日志**,而不是静默挂到错误位置。
排障建议(数据存在但 UI 不显示时):
1. 打开浏览器控制台,搜索 `[ST-BME] Recall Card UI` 或 `[ST-BME] Recall Card persist` 调试日志。
2. 确认目标楼层是否为**用户消息**,并检查 `message.extra.bme_recall.injectionText` 是否非空。
3. 检查消息 DOM 是否仍带有稳定楼层索引属性(`mesid`、`data-mesid`、`data-message-id` 等)。
4. 若使用第三方主题,确认消息节点仍包含 `#chat .mes`,且消息内容区域未完全移除 `.mes_block` / `.mes_text` 相关结构。
5. 如果聊天是异步渲染的,等待一小段时间后再次观察;插件会在短时间内自动补偿重试,而不是只尝试一次。
--- ---

414
index.js
View File

@@ -464,6 +464,12 @@ let lastPreGenerationRecallKey = "";
let lastPreGenerationRecallAt = 0; let lastPreGenerationRecallAt = 0;
const generationRecallTransactions = new Map(); const generationRecallTransactions = new Map();
let persistedRecallUiRefreshTimer = null; let persistedRecallUiRefreshTimer = null;
let persistedRecallUiRefreshObserver = null;
let persistedRecallUiRefreshSession = 0;
const PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS = [0, 80, 180, 320, 500];
const PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS = 1500;
const persistedRecallUiDiagnosticTimestamps = new Map();
const persistedRecallPersistDiagnosticTimestamps = new Map();
const GENERATION_RECALL_TRANSACTION_TTL_MS = 15000; const GENERATION_RECALL_TRANSACTION_TTL_MS = 15000;
const stageNoticeHandles = { const stageNoticeHandles = {
extraction: null, extraction: null,
@@ -965,6 +971,32 @@ function getMessageRecallRecord(messageIndex) {
return readPersistedRecallFromUserMessage(chat, messageIndex); return readPersistedRecallFromUserMessage(chat, messageIndex);
} }
function debugWithThrottle(cache, key, ...args) {
const now = Date.now();
const lastAt = cache.get(key) || 0;
if (now - lastAt < PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS) return;
cache.set(key, now);
console.debug(...args);
}
function debugPersistedRecallUi(reason, details = null, throttleKey = reason) {
const suffix = details ? ` ${JSON.stringify(details)}` : "";
debugWithThrottle(
persistedRecallUiDiagnosticTimestamps,
`ui:${throttleKey}`,
`[ST-BME] Recall Card UI: ${reason}${suffix}`,
);
}
function debugPersistedRecallPersistence(reason, details = null, throttleKey = reason) {
const suffix = details ? ` ${JSON.stringify(details)}` : "";
debugWithThrottle(
persistedRecallPersistDiagnosticTimestamps,
`persist:${throttleKey}`,
`[ST-BME] Recall Card persist: ${reason}${suffix}`,
);
}
function persistRecallInjectionRecord({ function persistRecallInjectionRecord({
recallInput = {}, recallInput = {},
result = {}, result = {},
@@ -987,7 +1019,22 @@ function persistRecallInjectionRecord({
resolvedTargetIndex = lastRecallSentUserMessage.messageId; resolvedTargetIndex = lastRecallSentUserMessage.messageId;
} }
if (!Number.isFinite(resolvedTargetIndex)) return null; if (!Number.isFinite(resolvedTargetIndex)) {
debugPersistedRecallPersistence("目标 user 楼层解析失败", {
generationType,
explicitTargetUserMessageIndex: recallInput?.targetUserMessageIndex,
lastSentUserMessageId: lastRecallSentUserMessage?.messageId,
});
return null;
}
if (!chat[resolvedTargetIndex]?.is_user) {
debugPersistedRecallPersistence("目标楼层不是 user 消息,跳过持久化", {
targetUserMessageIndex: resolvedTargetIndex,
messageKeys: Object.keys(chat[resolvedTargetIndex] || {}),
});
return null;
}
const record = buildPersistedRecallRecord( const record = buildPersistedRecallRecord(
{ {
@@ -1001,7 +1048,17 @@ function persistRecallInjectionRecord({
}, },
readPersistedRecallFromUserMessage(chat, resolvedTargetIndex), readPersistedRecallFromUserMessage(chat, resolvedTargetIndex),
); );
if (!String(record?.injectionText || "").trim()) {
debugPersistedRecallPersistence("无有效 injectionText跳过持久化", {
targetUserMessageIndex: resolvedTargetIndex,
selectedNodeCount: Array.isArray(result?.selectedNodeIds) ? result.selectedNodeIds.length : 0,
});
return null;
}
if (!writePersistedRecallToUserMessage(chat, resolvedTargetIndex, record)) { if (!writePersistedRecallToUserMessage(chat, resolvedTargetIndex, record)) {
debugPersistedRecallPersistence("写入 user 楼层失败", {
targetUserMessageIndex: resolvedTargetIndex,
});
return null; return null;
} }
@@ -1111,8 +1168,61 @@ function applyFinalRecallInjectionForGeneration({
}; };
} }
function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) { function clearPersistedRecallMessageUiObserver() {
if (!messageElement) return Number.isFinite(fallbackIndex) ? fallbackIndex : null; try {
persistedRecallUiRefreshObserver?.disconnect?.();
} catch (error) {
console.warn("[ST-BME] Recall Card UI observer disconnect 失败:", error);
}
persistedRecallUiRefreshObserver = null;
}
function isDomNodeAttached(node) {
if (!node) return false;
if (node.isConnected === true) return true;
return typeof document?.contains === "function" ? document.contains(node) : true;
}
function cleanupRecallCardElement(cardElement) {
if (!cardElement) return;
try {
cardElement._bmeDestroyRenderer?.();
} catch (error) {
console.warn("[ST-BME] Recall Card renderer 清理失败:", error);
}
cardElement.remove?.();
}
function cleanupLegacyRecallBadges(messageElement) {
if (!messageElement?.querySelectorAll) return;
const oldBadges = Array.from(messageElement.querySelectorAll(".st-bme-recall-badge") || []);
for (const oldBadge of oldBadges) oldBadge.remove();
}
function cleanupRecallArtifacts(messageElement, keepMessageIndex = null) {
if (!messageElement?.querySelectorAll) return;
cleanupLegacyRecallBadges(messageElement);
const existingCards = Array.from(messageElement.querySelectorAll(".bme-recall-card") || []);
for (const card of existingCards) {
if (keepMessageIndex !== null && card.dataset?.messageIndex === String(keepMessageIndex)) {
continue;
}
cleanupRecallCardElement(card);
}
}
function parseStableMessageIndex(candidate) {
const normalized = String(candidate ?? "").trim();
if (!normalized) return null;
if (!/^\d+$/.test(normalized)) return null;
const parsed = Number.parseInt(normalized, 10);
return Number.isFinite(parsed) ? parsed : null;
}
function resolveMessageIndexFromElement(messageElement) {
if (!messageElement) return null;
const candidates = [ const candidates = [
messageElement.getAttribute?.("mesid"), messageElement.getAttribute?.("mesid"),
@@ -1121,12 +1231,173 @@ function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) {
messageElement.dataset?.mesid, messageElement.dataset?.mesid,
messageElement.dataset?.messageId, messageElement.dataset?.messageId,
]; ];
for (const candidate of candidates) { for (const candidate of candidates) {
const parsed = Number.parseInt(candidate, 10); const parsed = parseStableMessageIndex(candidate);
if (Number.isFinite(parsed)) return parsed; if (parsed !== null) return parsed;
} }
return Number.isFinite(fallbackIndex) ? fallbackIndex : null; return null;
}
function resolveRecallCardAnchor(messageElement) {
if (!messageElement || !isDomNodeAttached(messageElement)) return null;
const mesBlock = messageElement.querySelector?.(".mes_block");
if (isDomNodeAttached(mesBlock)) return mesBlock;
const mesTextParent = messageElement.querySelector?.(".mes_text")?.parentElement;
if (isDomNodeAttached(mesTextParent)) return mesTextParent;
return isDomNodeAttached(messageElement) ? messageElement : null;
}
function buildPersistedRecallUiRetryDelays(initialDelayMs = 0) {
const normalizedInitial = Math.max(0, Number.parseInt(initialDelayMs, 10) || 0);
if (!normalizedInitial) return [...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS];
return [
normalizedInitial,
...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS.filter((delay) => delay > normalizedInitial),
];
}
function summarizePersistedRecallRefreshStatus(summary) {
if (summary.renderedCount > 0) return "rendered";
if (summary.waitingMessageIndices.length > 0) return "waiting_dom";
if (summary.anchorFailureIndices.length > 0) return "missing_message_anchor";
if (summary.skippedNonUserIndices.length > 0) return "skipped_non_user";
if (summary.persistedRecordCount === 0) return "missing_recall_record";
return "missing_message_anchor";
}
function refreshPersistedRecallMessageUi() {
const context = getContext();
const chat = context?.chat;
if (!Array.isArray(chat) || typeof document?.getElementById !== "function") {
return {
status: "missing_chat_root",
renderedCount: 0,
persistedRecordCount: 0,
waitingMessageIndices: [],
anchorFailureIndices: [],
skippedNonUserIndices: [],
};
}
const chatRoot = document.getElementById("chat");
if (!chatRoot) {
debugPersistedRecallUi("缺少 #chat 根节点");
return {
status: "missing_chat_root",
renderedCount: 0,
persistedRecordCount: 0,
waitingMessageIndices: [],
anchorFailureIndices: [],
skippedNonUserIndices: [],
};
}
const themeName = getSettings()?.panelTheme || "crimson";
const callbacks = getRecallCardCallbacks();
const messageElementMap = new Map();
const messageElements = Array.from(chatRoot.querySelectorAll(".mes"));
for (const messageElement of messageElements) {
cleanupLegacyRecallBadges(messageElement);
const messageIndex = resolveMessageIndexFromElement(messageElement);
if (!Number.isFinite(messageIndex)) {
debugPersistedRecallUi("消息 DOM 缺少稳定索引属性,跳过挂载", {
className: messageElement.className || "",
}, "missing-stable-message-index");
continue;
}
if (messageElementMap.has(messageIndex)) {
debugPersistedRecallUi("检测到重复消息 DOM 索引,保留首个锚点", {
messageIndex,
}, `duplicate-message-index:${messageIndex}`);
cleanupRecallArtifacts(messageElement);
continue;
}
messageElementMap.set(messageIndex, messageElement);
}
const summary = {
status: "missing_recall_record",
renderedCount: 0,
persistedRecordCount: 0,
waitingMessageIndices: [],
anchorFailureIndices: [],
skippedNonUserIndices: [],
};
for (let messageIndex = 0; messageIndex < chat.length; messageIndex++) {
const message = chat[messageIndex];
const messageElement = messageElementMap.get(messageIndex) || null;
const existingCard = messageElement?.querySelector?.(
`.bme-recall-card[data-message-index="${messageIndex}"]`,
) || null;
if (!message?.is_user) {
if (existingCard) cleanupRecallCardElement(existingCard);
const unexpectedRecord = readPersistedRecallFromUserMessage(chat, messageIndex);
if (unexpectedRecord) {
summary.skippedNonUserIndices.push(messageIndex);
debugPersistedRecallUi("非 user 楼层存在持久召回记录,已跳过挂载", {
messageIndex,
}, `skipped-non-user:${messageIndex}`);
}
continue;
}
const record = readPersistedRecallFromUserMessage(chat, messageIndex);
if (!record?.injectionText) {
if (existingCard) cleanupRecallCardElement(existingCard);
continue;
}
summary.persistedRecordCount += 1;
if (!messageElement) {
summary.waitingMessageIndices.push(messageIndex);
debugPersistedRecallUi("目标 user 楼层 DOM 未就绪,等待后续刷新", {
messageIndex,
}, `waiting-dom:${messageIndex}`);
continue;
}
const anchor = resolveRecallCardAnchor(messageElement);
if (!anchor) {
cleanupRecallCardElement(existingCard);
summary.anchorFailureIndices.push(messageIndex);
debugPersistedRecallUi("目标 user 楼层锚点解析失败,跳过挂载", {
messageIndex,
}, `missing-anchor:${messageIndex}`);
continue;
}
cleanupRecallArtifacts(messageElement, messageIndex);
const currentCard = messageElement.querySelector?.(
`.bme-recall-card[data-message-index="${messageIndex}"]`,
) || null;
if (currentCard) {
updateRecallCardData(currentCard, record);
} else {
const card = createRecallCardElement({
messageIndex,
record,
userMessageText: message.mes || "",
graph: currentGraph,
themeName,
callbacks,
});
anchor.appendChild(card);
}
summary.renderedCount += 1;
}
summary.status = summarizePersistedRecallRefreshStatus(summary);
if (summary.status === "missing_recall_record") {
debugPersistedRecallUi("当前无有效持久召回记录可渲染");
}
return summary;
} }
function getRecallCardCallbacks() { function getRecallCardCallbacks() {
@@ -1190,96 +1461,67 @@ function getRecallCardCallbacks() {
}; };
} }
function armPersistedRecallMessageUiObserver(sessionId, runAttempt) {
clearPersistedRecallMessageUiObserver();
const chatRoot = document?.getElementById?.("chat");
const ObserverCtor = globalThis.MutationObserver;
if (!chatRoot || typeof ObserverCtor !== "function") return false;
function refreshPersistedRecallMessageUi() { persistedRecallUiRefreshObserver = new ObserverCtor(() => {
const context = getContext(); if (sessionId !== persistedRecallUiRefreshSession) return;
const chat = context?.chat; clearPersistedRecallMessageUiObserver();
if (!Array.isArray(chat) || typeof document?.getElementById !== "function") return; runAttempt();
});
const chatRoot = document.getElementById("chat"); persistedRecallUiRefreshObserver.observe(chatRoot, { childList: true, subtree: true });
if (!chatRoot) return; return true;
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;
// Clean up old-style badges (migration from v1)
const oldBadges = Array.from(
messageElement.querySelectorAll?.(".st-bme-recall-badge") || [],
);
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) {
if (existingCard) {
existingCard._bmeDestroyRenderer?.();
existingCard.remove();
}
continue;
}
const record = readPersistedRecallFromUserMessage(chat, messageIndex);
if (!record?.injectionText) {
if (existingCard) {
existingCard._bmeDestroyRenderer?.();
existingCard.remove();
}
continue;
}
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,
});
const anchor =
messageElement.querySelector?.(".mes_block") ||
messageElement.querySelector?.(".mes_text")?.parentElement ||
messageElement;
anchor.appendChild(card);
}
}
} }
function schedulePersistedRecallMessageUiRefresh(delayMs = 120) { function schedulePersistedRecallMessageUiRefresh(delayMs = 0) {
clearTimeout(persistedRecallUiRefreshTimer); clearTimeout(persistedRecallUiRefreshTimer);
persistedRecallUiRefreshTimer = setTimeout(() => { clearPersistedRecallMessageUiObserver();
const retryDelays = buildPersistedRecallUiRetryDelays(delayMs);
const sessionId = ++persistedRecallUiRefreshSession;
let attemptIndex = 0;
const runAttempt = () => {
if (sessionId !== persistedRecallUiRefreshSession) return;
persistedRecallUiRefreshTimer = null; persistedRecallUiRefreshTimer = null;
refreshPersistedRecallMessageUi(); const summary = refreshPersistedRecallMessageUi();
}, Math.max(16, Number.parseInt(delayMs, 10) || 120)); const shouldRetry =
(summary.status === "missing_chat_root" ||
summary.status === "waiting_dom" ||
summary.status === "missing_message_anchor") &&
attemptIndex < retryDelays.length - 1;
if (!shouldRetry) {
clearPersistedRecallMessageUiObserver();
return;
}
armPersistedRecallMessageUiObserver(sessionId, runAttempt);
attemptIndex += 1;
persistedRecallUiRefreshTimer = setTimeout(runAttempt, retryDelays[attemptIndex]);
};
persistedRecallUiRefreshTimer = setTimeout(runAttempt, retryDelays[attemptIndex]);
}
function cleanupPersistedRecallMessageUi() {
clearTimeout(persistedRecallUiRefreshTimer);
persistedRecallUiRefreshTimer = null;
clearPersistedRecallMessageUiObserver();
const chatRoot = document.getElementById("chat");
if (!chatRoot?.querySelectorAll) return;
for (const messageElement of Array.from(chatRoot.querySelectorAll(".mes"))) {
cleanupRecallArtifacts(messageElement);
}
} }
async function rerunRecallForMessage(messageIndex) { async function rerunRecallForMessage(messageIndex) {
const chat = getContext()?.chat; const chat = getContext()?.chat;
const message = Array.isArray(chat) ? chat[messageIndex] : null; const message = Array.isArray(chat) ? chat[messageIndex] : null;
cleanupPersistedRecallMessageUi();
if (!message?.is_user) { if (!message?.is_user) {
toastr.info("仅用户消息支持重新召回"); toastr.info("仅用户消息支持重新召回");
return null; return null;

View File

@@ -69,6 +69,7 @@ import {
markPersistedRecallManualEdit, markPersistedRecallManualEdit,
} from "../recall-persistence.js"; } from "../recall-persistence.js";
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
const extensionsShimSource = [ const extensionsShimSource = [
"export const extension_settings = globalThis.__p0ExtensionSettings || {};", "export const extension_settings = globalThis.__p0ExtensionSettings || {};",
"export function getContext(...args) {", "export function getContext(...args) {",
@@ -549,6 +550,521 @@ function pushTestOverrides(patch = {}) {
}; };
} }
class FakeClassList {
constructor(owner) {
this.owner = owner;
this.tokens = new Set();
}
setFromString(value = "") {
this.tokens = new Set(String(value || "").split(/\s+/).filter(Boolean));
}
add(...tokens) {
for (const token of tokens) {
if (token) this.tokens.add(token);
}
this.owner._syncClassName();
}
remove(...tokens) {
for (const token of tokens) this.tokens.delete(token);
this.owner._syncClassName();
}
contains(token) {
return this.tokens.has(token);
}
toggle(token, force) {
if (force === true) {
this.tokens.add(token);
this.owner._syncClassName();
return true;
}
if (force === false) {
this.tokens.delete(token);
this.owner._syncClassName();
return false;
}
if (this.tokens.has(token)) {
this.tokens.delete(token);
this.owner._syncClassName();
return false;
}
this.tokens.add(token);
this.owner._syncClassName();
return true;
}
toString() {
return [...this.tokens].join(" ");
}
}
class FakeElement {
constructor(tagName, ownerDocument) {
this.tagName = String(tagName || "div").toUpperCase();
this.ownerDocument = ownerDocument;
this.children = [];
this.parentElement = null;
this.dataset = {};
this.attributes = new Map();
this.eventListeners = new Map();
this.classList = new FakeClassList(this);
this._className = "";
this.id = "";
this.textContent = "";
this.innerHTML = "";
this.disabled = false;
}
_syncClassName() {
this._className = this.classList.toString();
}
get className() {
return this._className;
}
set className(value) {
this._className = String(value || "");
this.classList.setFromString(this._className);
this._className = this.classList.toString();
}
get parentNode() {
return this.parentElement;
}
setAttribute(name, value) {
const key = String(name || "");
const normalized = String(value ?? "");
this.attributes.set(key, normalized);
if (key === "id") {
this.id = normalized;
} else if (key === "class") {
this.classList.setFromString(normalized);
this.className = this.classList.toString();
} else if (key.startsWith("data-")) {
const datasetKey = key
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
this.dataset[datasetKey] = normalized;
}
}
getAttribute(name) {
const key = String(name || "");
if (this.attributes.has(key)) return this.attributes.get(key);
if (key === "id") return this.id || null;
if (key === "class") return this.className || null;
if (key.startsWith("data-")) {
const datasetKey = key
.slice(5)
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
return this.dataset[datasetKey] ?? null;
}
return null;
}
appendChild(child) {
if (!child) return child;
if (child.parentElement) {
child.parentElement.removeChild(child);
}
child.parentElement = this;
child.ownerDocument = this.ownerDocument;
this.children.push(child);
this.ownerDocument?._notifyMutation({ type: "childList", target: this, addedNodes: [child] });
return child;
}
removeChild(child) {
const index = this.children.indexOf(child);
if (index >= 0) {
this.children.splice(index, 1);
child.parentElement = null;
this.ownerDocument?._notifyMutation({ type: "childList", target: this, removedNodes: [child] });
}
return child;
}
remove() {
this.parentElement?.removeChild(this);
}
addEventListener(type, handler) {
const key = String(type || "");
const handlers = this.eventListeners.get(key) || [];
handlers.push(handler);
this.eventListeners.set(key, handlers);
}
dispatchEvent(event = {}) {
const key = String(event.type || "");
const handlers = this.eventListeners.get(key) || [];
for (const handler of handlers) {
handler({
stopPropagation() {},
preventDefault() {},
...event,
target: this,
currentTarget: this,
});
}
}
click() {
this.dispatchEvent({ type: "click" });
}
get isConnected() {
return Boolean(this.parentElement) || this === this.ownerDocument?.body;
}
querySelector(selector) {
return this.querySelectorAll(selector)[0] || null;
}
querySelectorAll(selector) {
return this.ownerDocument?._querySelectorAll(selector, this) || [];
}
}
class FakeDocument {
constructor() {
this.body = new FakeElement("body", this);
this._observers = new Set();
}
createElement(tagName) {
return new FakeElement(tagName, this);
}
contains(node) {
return Boolean(this._flatten(this.body).includes(node));
}
getElementById(id) {
return this._flatten(this.body).find((node) => node.id === id) || null;
}
querySelector(selector) {
return this.body.querySelector(selector);
}
querySelectorAll(selector) {
return this.body.querySelectorAll(selector);
}
_flatten(root) {
const nodes = [];
const visit = (node) => {
nodes.push(node);
for (const child of node.children) visit(child);
};
visit(root);
return nodes;
}
_matchesSimple(node, selector) {
if (!selector) return false;
if (selector.startsWith("#")) {
return node.id === selector.slice(1);
}
const attrMatches = [...selector.matchAll(/\[([^=\]]+)="([^\]]*)"\]/g)];
const attrless = selector.replace(/\[[^\]]+\]/g, "");
const classMatches = [...attrless.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
const tagMatch = attrless.match(/^[A-Za-z][A-Za-z0-9_-]*/);
if (tagMatch && node.tagName.toLowerCase() !== tagMatch[0].toLowerCase()) return false;
for (const className of classMatches) {
if (!node.classList.contains(className)) return false;
}
for (const [, rawName, expected] of attrMatches) {
const actual = node.getAttribute(rawName);
if (String(actual ?? "") !== expected) return false;
}
return true;
}
_matchesSelectorChain(node, segments) {
if (!segments.length) return false;
if (!this._matchesSimple(node, segments[segments.length - 1])) return false;
let current = node.parentElement;
for (let index = segments.length - 2; index >= 0; index--) {
while (current && !this._matchesSimple(current, segments[index])) {
current = current.parentElement;
}
if (!current) return false;
current = current.parentElement;
}
return true;
}
_querySelectorAll(selector, scopeRoot) {
const segments = String(selector || "").trim().split(/\s+/).filter(Boolean);
const nodes = this._flatten(scopeRoot);
return nodes.filter((node) => node !== scopeRoot && this._matchesSelectorChain(node, segments));
}
_registerObserver(observer) {
this._observers.add(observer);
}
_unregisterObserver(observer) {
this._observers.delete(observer);
}
_notifyMutation(record) {
for (const observer of this._observers) {
observer._notify(record);
}
}
}
class FakeMutationObserver {
constructor(callback, documentRef) {
this.callback = callback;
this.documentRef = documentRef;
this.active = false;
}
observe() {
this.active = true;
this.documentRef._registerObserver(this);
}
disconnect() {
this.active = false;
this.documentRef._unregisterObserver(this);
}
_notify(record) {
if (!this.active) return;
queueMicrotask(() => {
if (this.active) this.callback([record]);
});
}
}
function createDomHarness(chat) {
const document = new FakeDocument();
const chatRoot = document.createElement("div");
chatRoot.setAttribute("id", "chat");
document.body.appendChild(chatRoot);
const observerClass = class extends FakeMutationObserver {
constructor(callback) {
super(callback, document);
}
};
return { document, chatRoot, MutationObserver: observerClass, chat };
}
function createMessageElement(document, messageIndex, { stableId = true, withMesBlock = true, isUser = true } = {}) {
const mes = document.createElement("div");
mes.classList.add("mes");
if (stableId) mes.setAttribute("mesid", String(messageIndex));
if (isUser) mes.classList.add("user_mes");
const block = document.createElement("div");
block.classList.add("mes_block");
const textWrap = document.createElement("div");
textWrap.classList.add("mes_text");
if (withMesBlock) {
block.appendChild(textWrap);
mes.appendChild(block);
} else {
mes.appendChild(textWrap);
}
return mes;
}
function appendLegacyBadge(document, messageElement) {
const badge = document.createElement("div");
badge.classList.add("st-bme-recall-badge");
messageElement.appendChild(badge);
return badge;
}
async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } } = {}) {
const harness = createDomHarness(chat);
const previousDocument = globalThis.document;
globalThis.document = harness.document;
const source = await fs.readFile(indexPath, "utf8");
const start = source.indexOf("function debugWithThrottle(");
const end = source.indexOf("async function rerunRecallForMessage(");
if (start < 0 || end < 0 || end <= start) {
throw new Error("无法从 index.js 提取 Recall UI 逻辑");
}
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
const context = {
console,
Date,
JSON,
Math,
Map,
Set,
Array,
Number,
String,
Object,
RegExp,
parseInt: Number.parseInt,
setTimeout,
clearTimeout,
queueMicrotask,
document: harness.document,
currentGraph: graph,
persistedRecallUiRefreshTimer: null,
persistedRecallUiRefreshObserver: null,
persistedRecallUiRefreshSession: 0,
PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS: [0, 10, 20],
PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS: 0,
persistedRecallUiDiagnosticTimestamps: new Map(),
persistedRecallPersistDiagnosticTimestamps: new Map(),
getContext: () => ({ chat }),
getSettings: () => ({ panelTheme: "crimson" }),
triggerChatMetadataSave: () => "debounced",
estimateTokens: (text = "") => String(text || "").trim().split(/\s+/).filter(Boolean).length || 1,
toastr: {
success() {},
warning() {},
info() {},
},
openRecallSidebar() {},
readPersistedRecallFromUserMessage,
removePersistedRecallFromUserMessage,
writePersistedRecallToUserMessage,
buildPersistedRecallRecord,
markPersistedRecallManualEdit,
createRecallCardElement: null,
updateRecallCardData: null,
globalThis: null,
result: null,
};
context.globalThis = context;
const recallUiModule = await import("../recall-message-ui.js");
context.createRecallCardElement = recallUiModule.createRecallCardElement;
context.updateRecallCardData = recallUiModule.updateRecallCardData;
context.MutationObserver = harness.MutationObserver;
vm.createContext(context);
vm.runInContext(
`${snippet}\nresult = { refreshPersistedRecallMessageUi, schedulePersistedRecallMessageUiRefresh, cleanupPersistedRecallMessageUi, resolveMessageIndexFromElement, resolveRecallCardAnchor };`,
context,
{ filename: indexPath },
);
return {
...harness,
context,
api: context.result,
restoreGlobals() {
globalThis.document = previousDocument;
},
};
}
async function testRecallCardMountsOnStandardUserMessageDom() {
const chat = [
{ is_user: true, mes: "user-0", 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 {
const summary = harness.api.refreshPersistedRecallMessageUi();
assert.equal(summary.status, "rendered");
assert.equal(summary.renderedCount, 1);
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1);
assert.equal(harness.chatRoot.querySelectorAll(".mes_block .bme-recall-card").length, 1);
} finally {
harness.restoreGlobals();
}
}
async function testRecallCardSkipsMountWithoutStableMessageIndex() {
const chat = [
{ is_user: true, mes: "user-0", 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: false, withMesBlock: true, isUser: true });
harness.chatRoot.appendChild(messageElement);
try {
const summary = harness.api.refreshPersistedRecallMessageUi();
assert.equal(summary.status, "waiting_dom");
assert.deepEqual(Array.from(summary.waitingMessageIndices), [0]);
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 0);
} finally {
harness.restoreGlobals();
}
}
async function testRecallCardDelayedDomInsertionEventuallyRenders() {
const chat = [
{ is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
];
const harness = await createRecallUiHarness({ chat });
try {
harness.api.schedulePersistedRecallMessageUiRefresh();
await waitForTick();
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
harness.chatRoot.appendChild(messageElement);
await waitForTick();
await waitForTick();
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1);
} finally {
harness.restoreGlobals();
}
}
async function testRecallCardDoesNotMountOnNonUserFloor() {
const chat = [
{ is_user: false, mes: "assistant-0", 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: false });
harness.chatRoot.appendChild(messageElement);
try {
const summary = harness.api.refreshPersistedRecallMessageUi();
assert.equal(summary.status, "skipped_non_user");
assert.deepEqual(Array.from(summary.skippedNonUserIndices), [0]);
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 0);
} finally {
harness.restoreGlobals();
}
}
async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() {
const chat = [
{ is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1", "n2"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
];
const harness = await createRecallUiHarness({ chat });
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
const staleCard = harness.document.createElement("div");
staleCard.classList.add("bme-recall-card");
staleCard.dataset.messageIndex = "999";
staleCard._bmeDestroyRenderer = () => {
staleCard.dataset.destroyed = "1";
};
appendLegacyBadge(harness.document, messageElement);
messageElement.appendChild(staleCard);
harness.chatRoot.appendChild(messageElement);
try {
harness.api.refreshPersistedRecallMessageUi();
harness.api.refreshPersistedRecallMessageUi();
assert.equal(harness.chatRoot.querySelectorAll(".st-bme-recall-badge").length, 0);
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1);
assert.equal(staleCard.dataset.destroyed, "1");
} finally {
harness.restoreGlobals();
}
}
function makeEvent(seq, title) { function makeEvent(seq, title) {
return createNode({ return createNode({
type: "event", type: "event",
@@ -1950,6 +2466,11 @@ await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();
await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallDataLayerLifecycleAndCompatibility();
await testPersistentRecallSourceResolutionAndTargetRouting(); await testPersistentRecallSourceResolutionAndTargetRouting();
await testRecallCardMountsOnStandardUserMessageDom();
await testRecallCardSkipsMountWithoutStableMessageIndex();
await testRecallCardDelayedDomInsertionEventuallyRenders();
await testRecallCardDoesNotMountOnNonUserFloor();
await testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates();
await testRecallSubGraphAndDataLayerEntryPoints(); await testRecallSubGraphAndDataLayerEntryPoints();
await testRerollUsesBatchBoundaryRollbackAndPersistsState(); await testRerollUsesBatchBoundaryRollbackAndPersistsState();
await testRerollRejectsMissingRecoveryPoint(); await testRerollRejectsMissingRecoveryPoint();