diff --git a/README.md b/README.md index 6b2265b..cc237f2 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,7 @@ ST-BME/ 消息级 UI: - 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge)。 +- 显示前提:必须同时满足 **用户楼层**、`message.extra.bme_recall` 存在、且 `injectionText` 为非空字符串。 - 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`)。 - 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情。 - 操作按钮(展开态底部): @@ -375,11 +376,22 @@ ST-BME/ - **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。 - **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。 - 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。 +- 当聊天 DOM 延迟插入时,插件会执行**有界重试 + 短生命周期 MutationObserver 补偿**,避免单次刷新错过挂载。 兼容性说明: - 旧聊天(无 `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. 如果聊天是异步渲染的,等待一小段时间后再次观察;插件会在短时间内自动补偿重试,而不是只尝试一次。 --- diff --git a/index.js b/index.js index e73de9a..6405955 100644 --- a/index.js +++ b/index.js @@ -464,6 +464,12 @@ let lastPreGenerationRecallKey = ""; let lastPreGenerationRecallAt = 0; const generationRecallTransactions = new Map(); 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 stageNoticeHandles = { extraction: null, @@ -965,6 +971,32 @@ function getMessageRecallRecord(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({ recallInput = {}, result = {}, @@ -987,7 +1019,22 @@ function persistRecallInjectionRecord({ 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( { @@ -1001,7 +1048,17 @@ function persistRecallInjectionRecord({ }, 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)) { + debugPersistedRecallPersistence("写入 user 楼层失败", { + targetUserMessageIndex: resolvedTargetIndex, + }); return null; } @@ -1111,8 +1168,61 @@ function applyFinalRecallInjectionForGeneration({ }; } -function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) { - if (!messageElement) return Number.isFinite(fallbackIndex) ? fallbackIndex : null; +function clearPersistedRecallMessageUiObserver() { + 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 = [ messageElement.getAttribute?.("mesid"), @@ -1121,12 +1231,173 @@ function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) { messageElement.dataset?.mesid, messageElement.dataset?.messageId, ]; + for (const candidate of candidates) { - const parsed = Number.parseInt(candidate, 10); - if (Number.isFinite(parsed)) return parsed; + const parsed = parseStableMessageIndex(candidate); + 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() { @@ -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() { - const context = getContext(); - const chat = context?.chat; - if (!Array.isArray(chat) || typeof document?.getElementById !== "function") return; - - 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; - - // 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); - } - } + persistedRecallUiRefreshObserver = new ObserverCtor(() => { + if (sessionId !== persistedRecallUiRefreshSession) return; + clearPersistedRecallMessageUiObserver(); + runAttempt(); + }); + persistedRecallUiRefreshObserver.observe(chatRoot, { childList: true, subtree: true }); + return true; } -function schedulePersistedRecallMessageUiRefresh(delayMs = 120) { +function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { clearTimeout(persistedRecallUiRefreshTimer); - persistedRecallUiRefreshTimer = setTimeout(() => { + clearPersistedRecallMessageUiObserver(); + + const retryDelays = buildPersistedRecallUiRetryDelays(delayMs); + const sessionId = ++persistedRecallUiRefreshSession; + let attemptIndex = 0; + + const runAttempt = () => { + if (sessionId !== persistedRecallUiRefreshSession) return; persistedRecallUiRefreshTimer = null; - refreshPersistedRecallMessageUi(); - }, Math.max(16, Number.parseInt(delayMs, 10) || 120)); + const summary = refreshPersistedRecallMessageUi(); + 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) { const chat = getContext()?.chat; const message = Array.isArray(chat) ? chat[messageIndex] : null; + cleanupPersistedRecallMessageUi(); if (!message?.is_user) { toastr.info("仅用户消息支持重新召回"); return null; diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index ab7ee3e..d572d5c 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -69,6 +69,7 @@ import { markPersistedRecallManualEdit, } from "../recall-persistence.js"; +const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); const extensionsShimSource = [ "export const extension_settings = globalThis.__p0ExtensionSettings || {};", "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) { return createNode({ type: "event", @@ -1950,6 +2466,11 @@ await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine(); await testGenerationRecallAppliesFinalInjectionOncePerTransaction(); await testPersistentRecallDataLayerLifecycleAndCompatibility(); await testPersistentRecallSourceResolutionAndTargetRouting(); +await testRecallCardMountsOnStandardUserMessageDom(); +await testRecallCardSkipsMountWithoutStableMessageIndex(); +await testRecallCardDelayedDomInsertionEventuallyRenders(); +await testRecallCardDoesNotMountOnNonUserFloor(); +await testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates(); await testRecallSubGraphAndDataLayerEntryPoints(); await testRerollUsesBatchBoundaryRollbackAndPersistsState(); await testRerollRejectsMissingRecoveryPoint();