diff --git a/index.js b/index.js index 84ff24b..8718b7c 100644 --- a/index.js +++ b/index.js @@ -281,6 +281,7 @@ import { resolveRecallInputController, runRecallController, } from "./retrieval/recall-controller.js"; +import { createRecallMessageUiController } from "./ui/recall-message-ui-controller.js"; import { createRecallCardElement, openRecallSidebar, @@ -1383,9 +1384,6 @@ let lastPreGenerationRecallKey = ""; let lastPreGenerationRecallAt = 0; const generationRecallTransactions = new Map(); const plannerRecallHandoffs = new Map(); -let persistedRecallUiRefreshTimer = null; -let persistedRecallUiRefreshObserver = null; -let persistedRecallUiRefreshSession = 0; const PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS = [ 0, 80, @@ -1399,8 +1397,31 @@ const PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS = [ 4200, ]; const PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS = 1500; -const persistedRecallUiDiagnosticTimestamps = new Map(); -const persistedRecallPersistDiagnosticTimestamps = new Map(); +const recallMessageUiController = createRecallMessageUiController({ + getContext, + getSettings, + getCurrentGraph: () => currentGraph, + get document() { return globalThis.document; }, + get MutationObserver() { return globalThis.MutationObserver; }, + console, + setTimeout, + clearTimeout, + toastr, + estimateTokens, + triggerChatMetadataSave, + openRecallSidebar, + readPersistedRecallFromUserMessage, + removePersistedRecallFromUserMessage, + writePersistedRecallToUserMessage, + buildPersistedRecallRecord, + markPersistedRecallManualEdit, + createRecallCardElement, + updateRecallCardData, + normalizeRecallInputText, + rerunRecallForMessage: (messageIndex) => rerunRecallForMessage(messageIndex), + PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS, + PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS, +}); const GENERATION_RECALL_TRANSACTION_TTL_MS = 15000; const PLANNER_RECALL_HANDOFF_TTL_MS = GENERATION_RECALL_TRANSACTION_TTL_MS; const GENERATION_RECALL_HOOK_BRIDGE_MS = 1200; @@ -5361,9 +5382,10 @@ function debugWithThrottle(cache, key, ...args) { } function debugPersistedRecallUi(reason, details = null, throttleKey = reason) { + if (!globalThis.__stBmeDebugLoggingEnabled) return; const suffix = details ? ` ${JSON.stringify(details)}` : ""; debugWithThrottle( - persistedRecallUiDiagnosticTimestamps, + recallMessageUiController.uiDiagnosticTimestamps || new Map(), `ui:${throttleKey}`, `[ST-BME] Recall Card UI: ${reason}${suffix}`, ); @@ -5374,9 +5396,10 @@ function debugPersistedRecallPersistence( details = null, throttleKey = reason, ) { + if (!globalThis.__stBmeDebugLoggingEnabled) return; const suffix = details ? ` ${JSON.stringify(details)}` : ""; debugWithThrottle( - persistedRecallPersistDiagnosticTimestamps, + recallMessageUiController.persistDiagnosticTimestamps || new Map(), `persist:${throttleKey}`, `[ST-BME] Recall Card persist: ${reason}${suffix}`, ); @@ -6582,554 +6605,25 @@ function clearLiveRecallInjectionPromptForRewrite() { } } -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; - const messageElement = cardElement.closest?.(".mes") || null; - if (messageElement) { - restoreRecallCardUserInputDisplay(messageElement); - } - 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); - restoreRecallCardUserInputDisplay(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"), - messageElement.getAttribute?.("data-mesid"), - messageElement.getAttribute?.("data-message-id"), - messageElement.dataset?.mesid, - messageElement.dataset?.messageId, - ]; - - for (const candidate of candidates) { - const parsed = parseStableMessageIndex(candidate); - if (parsed !== null) return parsed; - } - - return null; + return recallMessageUiController.resolveMessageIndexFromElement(messageElement); } 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 getRecallMessageElementPriority(messageElement) { - if (!messageElement || !isDomNodeAttached(messageElement)) return -1; - - let priority = 0; - const anchor = resolveRecallCardAnchor(messageElement); - if (anchor === messageElement) priority += 1; - else if (anchor) priority += 3; - - if (messageElement.querySelector?.(".mes_text")) priority += 1; - if (messageElement.classList?.contains("last_mes")) priority += 2; - if ( - messageElement.getAttribute?.("is_user") === "true" || - messageElement.dataset?.isUser === "true" || - messageElement.classList?.contains("user_mes") - ) { - priority += 1; - } - - return priority; -} - -function normalizeRecallCardUserInputDisplayMode(mode) { - const normalized = String(mode || "").trim(); - if ( - normalized === "off" || - normalized === "beautify_only" || - normalized === "mirror" - ) { - return normalized; - } - return "beautify_only"; -} - -function applyRecallCardUserInputDisplayMode(messageElement, mode) { - if (!messageElement?.querySelector) return; - const userTextElement = messageElement.querySelector(".mes_text"); - if (!userTextElement) return; - userTextElement.classList.toggle( - "bme-hide-original-user-text", - normalizeRecallCardUserInputDisplayMode(mode) === "beautify_only", - ); -} - -function restoreRecallCardUserInputDisplay(messageElement) { - if (!messageElement?.querySelector) return; - const userTextElement = messageElement.querySelector(".mes_text"); - userTextElement?.classList?.remove("bme-hide-original-user-text"); -} - -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.waitingMessageIndices.length > 0) return "waiting_dom"; - if (summary.anchorFailureIndices.length > 0) return "missing_message_anchor"; - if (summary.renderedCount > 0) return "rendered"; - if (summary.skippedNonUserIndices.length > 0) return "skipped_non_user"; - if (summary.persistedRecordCount === 0) return "missing_recall_record"; - return "missing_message_anchor"; + return recallMessageUiController.resolveRecallCardAnchor(messageElement); } 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 settings = getSettings(); - const themeName = settings?.panelTheme || "crimson"; - const recallCardUserInputDisplayMode = - normalizeRecallCardUserInputDisplayMode( - settings?.recallCardUserInputDisplayMode, - ); - 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)) { - const previousElement = messageElementMap.get(messageIndex) || null; - const previousPriority = getRecallMessageElementPriority(previousElement); - const nextPriority = getRecallMessageElementPriority(messageElement); - const shouldReplace = nextPriority >= previousPriority; - debugPersistedRecallUi( - "检测到重复消息 DOM 索引,已挑选更可靠的锚点", - { - messageIndex, - previousPriority, - nextPriority, - replaced: shouldReplace, - }, - `duplicate-message-index:${messageIndex}`, - ); - if (shouldReplace) { - cleanupRecallArtifacts(previousElement); - messageElementMap.set(messageIndex, messageElement); - } else { - 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 (messageElement) { - restoreRecallCardUserInputDisplay(messageElement); - } - 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 (messageElement) { - restoreRecallCardUserInputDisplay(messageElement); - } - 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) { - restoreRecallCardUserInputDisplay(messageElement); - 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, { - userMessageText: message.mes || "", - userInputDisplayMode: recallCardUserInputDisplayMode, - graph: currentGraph, - themeName, - callbacks, - }); - } else { - const card = createRecallCardElement({ - messageIndex, - record, - userMessageText: message.mes || "", - userInputDisplayMode: recallCardUserInputDisplayMode, - graph: currentGraph, - themeName, - callbacks, - }); - anchor.appendChild(card); - } - applyRecallCardUserInputDisplayMode( - messageElement, - recallCardUserInputDisplayMode, - ); - summary.renderedCount += 1; - } - - summary.status = summarizePersistedRecallRefreshStatus(summary); - if (summary.status === "missing_recall_record") { - debugPersistedRecallUi("当前无有效持久召回记录可渲染"); - } else if (summary.renderedCount > 0) { - debugPersistedRecallUi( - "Recall Card 挂载完成", - { - renderedCount: summary.renderedCount, - persistedRecordCount: summary.persistedRecordCount, - waitingDom: summary.waitingMessageIndices.length, - }, - `rendered:${summary.renderedCount}`, - ); - } - return summary; -} - -function getRecallCardCallbacks() { - return { - onEdit: (messageIndex) => { - const record = getMessageRecallRecord(messageIndex); - if (!record) return; - openRecallSidebar({ - mode: "edit", - messageIndex, - record, - node: null, - graph: currentGraph, - callbacks: { - onSave: (idx, newText) => { - const edited = editMessageRecallRecord(idx, newText); - if (edited) { - toastr.success("已保存手动编辑"); - } else { - toastr.warning("编辑失败:注入文本不能为空"); - } - schedulePersistedRecallMessageUiRefresh(); - }, - estimateTokens, - }, - }); - }, - onEditUserInput: (messageIndex, nextUserInputText) => { - const result = editMessageUserInputText(messageIndex, nextUserInputText); - if (!result?.ok) { - toastr.warning("编辑失败:内容不能为空或此楼层非用户消息"); - return result; - } - - if (result.unchanged) { - toastr.info("用户输入未变化"); - } else { - toastr.success("已更新本轮用户输入"); - } - if (result.recallMayBeStale) { - toastr.info("输入已改,当前召回结果可能需要重新召回"); - } - schedulePersistedRecallMessageUiRefresh(); - return result; - }, - onDelete: (messageIndex) => { - if (removeMessageRecallRecord(messageIndex)) { - toastr.success("已删除持久召回注入"); - schedulePersistedRecallMessageUiRefresh(); - } - }, - onRerunRecall: async (messageIndex) => { - const result = await rerunRecallForMessage(messageIndex); - if (result?.status === "completed") { - toastr.success("重新召回完成"); - } - schedulePersistedRecallMessageUiRefresh(); - }, - onNodeClick: (messageIndex, node) => { - const record = getMessageRecallRecord(messageIndex); - if (!record) return; - openRecallSidebar({ - mode: "view", - messageIndex, - record, - node, - graph: currentGraph, - callbacks: { - onSave: (idx, newText) => { - const edited = editMessageRecallRecord(idx, newText); - if (edited) toastr.success("已保存手动编辑"); - else toastr.warning("编辑失败:注入文本不能为空"); - schedulePersistedRecallMessageUiRefresh(); - }, - estimateTokens, - }, - }); - }, - }; -} - -function armPersistedRecallMessageUiObserver(sessionId, runAttempt) { - clearPersistedRecallMessageUiObserver(); - const chatRoot = document?.getElementById?.("chat"); - const ObserverCtor = globalThis.MutationObserver; - if (!chatRoot || typeof ObserverCtor !== "function") return false; - - persistedRecallUiRefreshObserver = new ObserverCtor(() => { - if (sessionId !== persistedRecallUiRefreshSession) return; - clearPersistedRecallMessageUiObserver(); - runAttempt(); - }); - persistedRecallUiRefreshObserver.observe(chatRoot, { - childList: true, - subtree: true, - attributes: true, - attributeFilter: [ - "mesid", - "data-mesid", - "data-message-id", - "class", - "is_user", - ], - }); - return true; + return recallMessageUiController.refreshPersistedRecallMessageUi(); } function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { - clearTimeout(persistedRecallUiRefreshTimer); - clearPersistedRecallMessageUiObserver(); - - const retryDelays = buildPersistedRecallUiRetryDelays(delayMs); - const sessionId = ++persistedRecallUiRefreshSession; - let attemptIndex = 0; - - const runAttempt = () => { - if (sessionId !== persistedRecallUiRefreshSession) return; - if (persistedRecallUiRefreshTimer) { - clearTimeout(persistedRecallUiRefreshTimer); - persistedRecallUiRefreshTimer = null; - } - - const summary = refreshPersistedRecallMessageUi(); - - const shouldRetryForPending = - (summary.status === "missing_chat_root" || - summary.status === "waiting_dom" || - summary.status === "missing_message_anchor") && - attemptIndex < retryDelays.length - 1; - - // 勿在「已成功渲染」时长期监听 MutationObserver:chat 的 class/流式更新会疯狂触发 - // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新。 - const shouldWatchForRepaint = false; - - if (!shouldRetryForPending && !shouldWatchForRepaint) { - clearPersistedRecallMessageUiObserver(); - return; - } - - armPersistedRecallMessageUiObserver(sessionId, runAttempt); - if (shouldRetryForPending) { - attemptIndex += 1; - persistedRecallUiRefreshTimer = setTimeout( - runAttempt, - retryDelays[attemptIndex], - ); - return; - } - - const lingerMs = retryDelays[retryDelays.length - 1] || 0; - if (lingerMs <= 0) { - clearPersistedRecallMessageUiObserver(); - return; - } - persistedRecallUiRefreshTimer = setTimeout(() => { - if (sessionId !== persistedRecallUiRefreshSession) return; - clearPersistedRecallMessageUiObserver(); - persistedRecallUiRefreshTimer = null; - }, lingerMs); - }; - - persistedRecallUiRefreshTimer = setTimeout( - runAttempt, - retryDelays[attemptIndex], - ); + return recallMessageUiController.schedulePersistedRecallMessageUiRefresh(delayMs); } 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); - } + return recallMessageUiController.cleanupPersistedRecallMessageUi(); } - async function rerunRecallForMessage(messageIndex) { const chat = getContext()?.chat; const message = Array.isArray(chat) ? chat[messageIndex] : null; diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 8566ce1..7f25583 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -781,6 +781,31 @@ async function createGraphPersistenceHarness({ return null; }, }, + createRecallMessageUiController() { + return { + refreshPersistedRecallMessageUi: () => ({ + status: "missing_recall_record", + renderedCount: 0, + persistedRecordCount: 0, + waitingMessageIndices: [], + anchorFailureIndices: [], + skippedNonUserIndices: [], + }), + schedulePersistedRecallMessageUiRefresh() {}, + cleanupPersistedRecallMessageUi() {}, + resolveMessageIndexFromElement: () => null, + resolveRecallCardAnchor: () => null, + }; + }, + openRecallSidebar() {}, + removePersistedRecallFromUserMessage: () => false, + writePersistedRecallToUserMessage: () => false, + buildPersistedRecallRecord: (record = {}) => ({ ...record }), + markPersistedRecallManualEdit: () => null, + createRecallCardElement: () => null, + updateRecallCardData() {}, + estimateTokens: (text = "") => + String(text || "").trim().split(/\s+/).filter(Boolean).length || 1, SillyTavern: { getCurrentChatId() { return runtimeContext.__globalChatId; diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 0be7228..b985bbb 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -240,6 +240,33 @@ export function createGenerationRecallHarness(options = {}) { context.metadataSaveCalls += 1; return "debounced"; }, + toastr: { + success() {}, + warning() {}, + info() {}, + error() {}, + }, + openRecallSidebar() {}, + removePersistedRecallFromUserMessage: () => false, + markPersistedRecallManualEdit: () => null, + createRecallCardElement: () => null, + updateRecallCardData: () => {}, + createRecallMessageUiController: () => ({ + refreshPersistedRecallMessageUi: () => ({ + status: "missing_recall_record", + renderedCount: 0, + persistedRecordCount: 0, + waitingMessageIndices: [], + anchorFailureIndices: [], + skippedNonUserIndices: [], + }), + schedulePersistedRecallMessageUiRefresh: () => { + context.recallUiRefreshCalls += 1; + }, + cleanupPersistedRecallMessageUi: () => {}, + resolveMessageIndexFromElement: () => null, + resolveRecallCardAnchor: () => null, + }), refreshPanelLiveState: () => { context.refreshPanelCalls += 1; }, diff --git a/tests/index-slicing-ratchet.mjs b/tests/index-slicing-ratchet.mjs index ff05662..a4c72d0 100644 --- a/tests/index-slicing-ratchet.mjs +++ b/tests/index-slicing-ratchet.mjs @@ -28,7 +28,7 @@ const SELF_RELATIVE = "tests/index-slicing-ratchet.mjs"; // Remove the entry entirely once a file no longer reads index.js as text. const ALLOWLIST = Object.freeze({ "tests/graph-persistence.mjs": { maxMarkerCalls: 7, stage: "Phase 5" }, - "tests/p0-regressions.mjs": { maxMarkerCalls: 5, stage: "Phase 3" }, + "tests/p0-regressions.mjs": { maxMarkerCalls: 3, stage: "Phase 3" }, "tests/helpers/generation-recall-harness.mjs": { maxMarkerCalls: 3, stage: "Phase 4" }, "tests/index-esm-entry-smoke.mjs": { maxMarkerCalls: 4, stage: "Phase 5" }, }); diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index e7f5344..7f2c3b7 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -15,6 +15,7 @@ import { shouldAdvanceProcessedHistory, } from "../maintenance/extraction-success-controller.js"; import { notifyHistoryDirtyNotice } from "../ui/history-notice.js"; +import { createRecallMessageUiController } from "../ui/recall-message-ui-controller.js"; import { onBeforeCombinePromptsController, onCharacterMessageRenderedController, @@ -1336,13 +1337,6 @@ async function createRecallUiHarness({ 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, @@ -1361,13 +1355,8 @@ async function createRecallUiHarness({ 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", @@ -1389,20 +1378,48 @@ async function createRecallUiHarness({ markPersistedRecallManualEdit, createRecallCardElement: null, updateRecallCardData: null, - globalThis: null, result: null, }; - context.globalThis = context; const recallUiModule = await import("../ui/recall-message-ui.js"); - context.createRecallCardElement = recallUiModule.createRecallCardElement; - context.updateRecallCardData = recallUiModule.updateRecallCardData; + context.createRecallCardElement = (...args) => + context.createRecallCardElementImpl(...args); + context.createRecallCardElementImpl = recallUiModule.createRecallCardElement; + context.updateRecallCardData = (...args) => context.updateRecallCardDataImpl(...args); + context.updateRecallCardDataImpl = recallUiModule.updateRecallCardData; context.MutationObserver = harness.MutationObserver; - vm.createContext(context); - vm.runInContext( - `${snippet}\nresult = { refreshPersistedRecallMessageUi, schedulePersistedRecallMessageUiRefresh, cleanupPersistedRecallMessageUi, resolveMessageIndexFromElement, resolveRecallCardAnchor };`, - context, - { filename: indexPath }, - ); + context.result = createRecallMessageUiController({ + getContext: () => context.getContext(), + getSettings: () => context.getSettings(), + getCurrentGraph: () => context.currentGraph, + get document() { return context.document; }, + get MutationObserver() { return context.MutationObserver; }, + console: context.console, + setTimeout: context.setTimeout, + clearTimeout: context.clearTimeout, + toastr: context.toastr, + estimateTokens: (...args) => context.estimateTokens(...args), + triggerChatMetadataSave: (...args) => context.triggerChatMetadataSave(...args), + openRecallSidebar: (...args) => context.openRecallSidebar(...args), + readPersistedRecallFromUserMessage: (...args) => + context.readPersistedRecallFromUserMessage(...args), + removePersistedRecallFromUserMessage: (...args) => + context.removePersistedRecallFromUserMessage(...args), + writePersistedRecallToUserMessage: (...args) => + context.writePersistedRecallToUserMessage(...args), + buildPersistedRecallRecord: (...args) => context.buildPersistedRecallRecord(...args), + markPersistedRecallManualEdit: (...args) => + context.markPersistedRecallManualEdit(...args), + createRecallCardElement: (...args) => context.createRecallCardElement(...args), + updateRecallCardData: (...args) => context.updateRecallCardData(...args), + normalizeRecallInputText, + rerunRecallForMessage: async () => null, + get PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS() { + return context.PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS; + }, + get PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS() { + return context.PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS; + }, + }); return { ...harness, context, diff --git a/ui/recall-message-ui-controller.js b/ui/recall-message-ui-controller.js new file mode 100644 index 0000000..247cbee --- /dev/null +++ b/ui/recall-message-ui-controller.js @@ -0,0 +1,740 @@ +export function createRecallMessageUiController(deps = {}) { + let persistedRecallUiRefreshTimer = null; + let persistedRecallUiRefreshObserver = null; + let persistedRecallUiRefreshSession = 0; + const persistedRecallUiDiagnosticTimestamps = new Map(); + const persistedRecallPersistDiagnosticTimestamps = new Map(); + + const getContextValue = () => deps.getContext?.() || null; + const getSettingsValue = () => deps.getSettings?.() || {}; + const getCurrentGraphValue = () => deps.getCurrentGraph?.() || null; + const getDocument = () => deps.document || globalThis.document; + const getMutationObserver = () => deps.MutationObserver || globalThis.MutationObserver; + const getToastr = () => deps.toastr || {}; + const getConsole = () => deps.console || console; + const getSetTimeout = () => deps.setTimeout || setTimeout; + const getClearTimeout = () => deps.clearTimeout || clearTimeout; + const getRefreshRetryDelays = () => + Array.isArray(deps.PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS) + ? deps.PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS + : [0, 80, 180, 320, 500, 850, 1300, 2000, 3000, 4200]; + const getDiagnosticThrottleMs = () => + Number.isFinite(Number(deps.PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS)) + ? Number(deps.PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS) + : 1500; + +function getMessageRecallRecord(messageIndex) { + const chat = getContextValue()?.chat; + return deps.readPersistedRecallFromUserMessage(chat, messageIndex); +} + +function debugWithThrottle(cache, key, ...args) { + if (!globalThis.__stBmeDebugLoggingEnabled) return; + const now = Date.now(); + const lastAt = cache.get(key) || 0; + if (now - lastAt < getDiagnosticThrottleMs()) return; + cache.set(key, now); + getConsole().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 removeMessageRecallRecord(messageIndex) { + const chat = getContextValue()?.chat; + if (!Array.isArray(chat)) return false; + const removed = deps.removePersistedRecallFromUserMessage(chat, messageIndex); + if (removed) { + deps.triggerChatMetadataSave(getContextValue(), { immediate: false }); + } + return removed; +} + +function editMessageRecallRecord(messageIndex, nextInjectionText) { + const chat = getContextValue()?.chat; + if (!Array.isArray(chat)) return null; + const current = deps.readPersistedRecallFromUserMessage(chat, messageIndex); + if (!current) return null; + + const normalizedText = deps.normalizeRecallInputText(nextInjectionText); + if (!normalizedText) return null; + const nowIso = new Date().toISOString(); + const nextRecord = { + ...current, + injectionText: normalizedText, + tokenEstimate: deps.estimateTokens(normalizedText), + updatedAt: nowIso, + }; + if (!deps.writePersistedRecallToUserMessage(chat, messageIndex, nextRecord)) { + return null; + } + const edited = deps.markPersistedRecallManualEdit( + chat, + messageIndex, + true, + nowIso, + ); + if (!edited) return null; + + deps.triggerChatMetadataSave(getContextValue(), { immediate: false }); + return edited; +} + +function syncEditedUserMessageDom(messageIndex, nextText) { + const chatRoot = getDocument()?.getElementById?.("chat"); + if (!chatRoot?.querySelectorAll) return false; + + for (const messageElement of Array.from(chatRoot.querySelectorAll(".mes") || [])) { + if (resolveMessageIndexFromElement(messageElement) !== messageIndex) continue; + const userTextElement = messageElement.querySelector?.(".mes_text"); + if (!userTextElement) return false; + userTextElement.textContent = String(nextText || ""); + return true; + } + return false; +} + +function persistEditedUserMessage(context = getContextValue()) { + const candidates = [ + ["saveChatConditional", context?.saveChatConditional], + ["saveChat", context?.saveChat], + ]; + + for (const [label, handler] of candidates) { + if (typeof handler !== "function") continue; + try { + const result = handler.call(context); + if (result && typeof result.catch === "function") { + result.catch((error) => { + getConsole().error(`[ST-BME] 保存用户输入编辑失败 (${label}):`, error); + }); + } + return label; + } catch (error) { + getConsole().error(`[ST-BME] 调用 ${label} 保存用户输入编辑失败:`, error); + } + } + + return deps.triggerChatMetadataSave(context, { immediate: true }); +} + +function editMessageUserInputText(messageIndex, nextUserInputText) { + const context = getContextValue(); + const chat = context?.chat; + if (!Array.isArray(chat)) { + return { ok: false, error: "missing-chat" }; + } + + const message = chat[messageIndex]; + if (!message?.is_user) { + return { ok: false, error: "not-user-message" }; + } + + const normalizedText = deps.normalizeRecallInputText(nextUserInputText); + if (!normalizedText) { + return { ok: false, error: "empty-user-input" }; + } + + const previousText = deps.normalizeRecallInputText(message.mes || ""); + const currentRecord = deps.readPersistedRecallFromUserMessage(chat, messageIndex); + const recallBoundText = deps.normalizeRecallInputText( + currentRecord?.boundUserFloorText || previousText, + ); + const recallMayBeStale = Boolean(currentRecord) && recallBoundText !== normalizedText; + + message.mes = normalizedText; + const swipeIndex = Number.isFinite(Number(message?.swipe_id)) + ? Math.max(0, Math.floor(Number(message.swipe_id))) + : null; + if ( + Array.isArray(message?.swipes) && + swipeIndex !== null && + swipeIndex < message.swipes.length + ) { + message.swipes[swipeIndex] = normalizedText; + } + + if (message.extra && typeof message.extra === "object") { + if (typeof message.extra.display_text === "string") { + message.extra.display_text = normalizedText; + } + if (typeof message.extra.current_display_text === "string") { + message.extra.current_display_text = normalizedText; + } + } + + const saveMode = persistEditedUserMessage(context); + const domSynced = syncEditedUserMessageDom(messageIndex, normalizedText); + + return { + ok: true, + nextText: normalizedText, + recallMayBeStale, + unchanged: previousText === normalizedText, + saveMode, + domSynced, + }; +} + +function clearPersistedRecallMessageUiObserver() { + try { + persistedRecallUiRefreshObserver?.disconnect?.(); + } catch (error) { + getConsole().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 getDocument()?.contains === "function" + ? getDocument().contains(node) + : true; +} + +function cleanupRecallCardElement(cardElement) { + if (!cardElement) return; + const messageElement = cardElement.closest?.(".mes") || null; + if (messageElement) { + restoreRecallCardUserInputDisplay(messageElement); + } + try { + cardElement._bmeDestroyRenderer?.(); + } catch (error) { + getConsole().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); + restoreRecallCardUserInputDisplay(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"), + messageElement.getAttribute?.("data-mesid"), + messageElement.getAttribute?.("data-message-id"), + messageElement.dataset?.mesid, + messageElement.dataset?.messageId, + ]; + + for (const candidate of candidates) { + const parsed = parseStableMessageIndex(candidate); + if (parsed !== null) return parsed; + } + + 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 getRecallMessageElementPriority(messageElement) { + if (!messageElement || !isDomNodeAttached(messageElement)) return -1; + + let priority = 0; + const anchor = resolveRecallCardAnchor(messageElement); + if (anchor === messageElement) priority += 1; + else if (anchor) priority += 3; + + if (messageElement.querySelector?.(".mes_text")) priority += 1; + if (messageElement.classList?.contains("last_mes")) priority += 2; + if ( + messageElement.getAttribute?.("is_user") === "true" || + messageElement.dataset?.isUser === "true" || + messageElement.classList?.contains("user_mes") + ) { + priority += 1; + } + + return priority; +} + +function normalizeRecallCardUserInputDisplayMode(mode) { + const normalized = String(mode || "").trim(); + if ( + normalized === "off" || + normalized === "beautify_only" || + normalized === "mirror" + ) { + return normalized; + } + return "beautify_only"; +} + +function applyRecallCardUserInputDisplayMode(messageElement, mode) { + if (!messageElement?.querySelector) return; + const userTextElement = messageElement.querySelector(".mes_text"); + if (!userTextElement) return; + userTextElement.classList.toggle( + "bme-hide-original-user-text", + normalizeRecallCardUserInputDisplayMode(mode) === "beautify_only", + ); +} + +function restoreRecallCardUserInputDisplay(messageElement) { + if (!messageElement?.querySelector) return; + const userTextElement = messageElement.querySelector(".mes_text"); + userTextElement?.classList?.remove("bme-hide-original-user-text"); +} + +function buildPersistedRecallUiRetryDelays(initialDelayMs = 0) { + const normalizedInitial = Math.max( + 0, + Number.parseInt(initialDelayMs, 10) || 0, + ); + if (!normalizedInitial) + return [...getRefreshRetryDelays()]; + return [ + normalizedInitial, + ...getRefreshRetryDelays().filter( + (delay) => delay > normalizedInitial, + ), + ]; +} + +function summarizePersistedRecallRefreshStatus(summary) { + if (summary.waitingMessageIndices.length > 0) return "waiting_dom"; + if (summary.anchorFailureIndices.length > 0) return "missing_message_anchor"; + if (summary.renderedCount > 0) return "rendered"; + 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 = getContextValue(); + const chat = context?.chat; + if (!Array.isArray(chat) || typeof getDocument()?.getElementById !== "function") { + return { + status: "missing_chat_root", + renderedCount: 0, + persistedRecordCount: 0, + waitingMessageIndices: [], + anchorFailureIndices: [], + skippedNonUserIndices: [], + }; + } + + const chatRoot = getDocument().getElementById("chat"); + if (!chatRoot) { + debugPersistedRecallUi("缺少 #chat 根节点"); + return { + status: "missing_chat_root", + renderedCount: 0, + persistedRecordCount: 0, + waitingMessageIndices: [], + anchorFailureIndices: [], + skippedNonUserIndices: [], + }; + } + + const settings = getSettingsValue(); + const themeName = settings?.panelTheme || "crimson"; + const recallCardUserInputDisplayMode = + normalizeRecallCardUserInputDisplayMode( + settings?.recallCardUserInputDisplayMode, + ); + 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)) { + const previousElement = messageElementMap.get(messageIndex) || null; + const previousPriority = getRecallMessageElementPriority(previousElement); + const nextPriority = getRecallMessageElementPriority(messageElement); + const shouldReplace = nextPriority >= previousPriority; + debugPersistedRecallUi( + "检测到重复消息 DOM 索引,已挑选更可靠的锚点", + { + messageIndex, + previousPriority, + nextPriority, + replaced: shouldReplace, + }, + `duplicate-message-index:${messageIndex}`, + ); + if (shouldReplace) { + cleanupRecallArtifacts(previousElement); + messageElementMap.set(messageIndex, messageElement); + } else { + 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 (messageElement) { + restoreRecallCardUserInputDisplay(messageElement); + } + if (existingCard) cleanupRecallCardElement(existingCard); + const unexpectedRecord = deps.readPersistedRecallFromUserMessage( + chat, + messageIndex, + ); + if (unexpectedRecord) { + summary.skippedNonUserIndices.push(messageIndex); + debugPersistedRecallUi( + "非 user 楼层存在持久召回记录,已跳过挂载", + { + messageIndex, + }, + `skipped-non-user:${messageIndex}`, + ); + } + continue; + } + + const record = deps.readPersistedRecallFromUserMessage(chat, messageIndex); + if (!record?.injectionText) { + if (messageElement) { + restoreRecallCardUserInputDisplay(messageElement); + } + 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) { + restoreRecallCardUserInputDisplay(messageElement); + 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) { + deps.updateRecallCardData(currentCard, record, { + userMessageText: message.mes || "", + userInputDisplayMode: recallCardUserInputDisplayMode, + graph: getCurrentGraphValue(), + themeName, + callbacks, + }); + } else { + const card = deps.createRecallCardElement({ + messageIndex, + record, + userMessageText: message.mes || "", + userInputDisplayMode: recallCardUserInputDisplayMode, + graph: getCurrentGraphValue(), + themeName, + callbacks, + }); + anchor.appendChild(card); + } + applyRecallCardUserInputDisplayMode( + messageElement, + recallCardUserInputDisplayMode, + ); + summary.renderedCount += 1; + } + + summary.status = summarizePersistedRecallRefreshStatus(summary); + if (summary.status === "missing_recall_record") { + debugPersistedRecallUi("当前无有效持久召回记录可渲染"); + } else if (summary.renderedCount > 0) { + debugPersistedRecallUi( + "Recall Card 挂载完成", + { + renderedCount: summary.renderedCount, + persistedRecordCount: summary.persistedRecordCount, + waitingDom: summary.waitingMessageIndices.length, + }, + `rendered:${summary.renderedCount}`, + ); + } + return summary; +} + +function getRecallCardCallbacks() { + return { + onEdit: (messageIndex) => { + const record = getMessageRecallRecord(messageIndex); + if (!record) return; + deps.openRecallSidebar({ + mode: "edit", + messageIndex, + record, + node: null, + graph: getCurrentGraphValue(), + callbacks: { + onSave: (idx, newText) => { + const edited = editMessageRecallRecord(idx, newText); + if (edited) { + getToastr().success("已保存手动编辑"); + } else { + getToastr().warning("编辑失败:注入文本不能为空"); + } + schedulePersistedRecallMessageUiRefresh(); + }, + estimateTokens: deps.estimateTokens, + }, + }); + }, + onEditUserInput: (messageIndex, nextUserInputText) => { + const result = editMessageUserInputText(messageIndex, nextUserInputText); + if (!result?.ok) { + getToastr().warning("编辑失败:内容不能为空或此楼层非用户消息"); + return result; + } + + if (result.unchanged) { + getToastr().info("用户输入未变化"); + } else { + getToastr().success("已更新本轮用户输入"); + } + if (result.recallMayBeStale) { + getToastr().info("输入已改,当前召回结果可能需要重新召回"); + } + schedulePersistedRecallMessageUiRefresh(); + return result; + }, + onDelete: (messageIndex) => { + if (removeMessageRecallRecord(messageIndex)) { + getToastr().success("已删除持久召回注入"); + schedulePersistedRecallMessageUiRefresh(); + } + }, + onRerunRecall: async (messageIndex) => { + const result = await deps.rerunRecallForMessage(messageIndex); + if (result?.status === "completed") { + getToastr().success("重新召回完成"); + } + schedulePersistedRecallMessageUiRefresh(); + }, + onNodeClick: (messageIndex, node) => { + const record = getMessageRecallRecord(messageIndex); + if (!record) return; + deps.openRecallSidebar({ + mode: "view", + messageIndex, + record, + node, + graph: getCurrentGraphValue(), + callbacks: { + onSave: (idx, newText) => { + const edited = editMessageRecallRecord(idx, newText); + if (edited) getToastr().success("已保存手动编辑"); + else getToastr().warning("编辑失败:注入文本不能为空"); + schedulePersistedRecallMessageUiRefresh(); + }, + estimateTokens: deps.estimateTokens, + }, + }); + }, + }; +} + +function armPersistedRecallMessageUiObserver(sessionId, runAttempt) { + clearPersistedRecallMessageUiObserver(); + const chatRoot = getDocument()?.getElementById?.("chat"); + const ObserverCtor = getMutationObserver(); + if (!chatRoot || typeof ObserverCtor !== "function") return false; + + persistedRecallUiRefreshObserver = new ObserverCtor(() => { + if (sessionId !== persistedRecallUiRefreshSession) return; + clearPersistedRecallMessageUiObserver(); + runAttempt(); + }); + persistedRecallUiRefreshObserver.observe(chatRoot, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [ + "mesid", + "data-mesid", + "data-message-id", + "class", + "is_user", + ], + }); + return true; +} + +function schedulePersistedRecallMessageUiRefresh(delayMs = 0) { + getClearTimeout()(persistedRecallUiRefreshTimer); + clearPersistedRecallMessageUiObserver(); + + const retryDelays = buildPersistedRecallUiRetryDelays(delayMs); + const sessionId = ++persistedRecallUiRefreshSession; + let attemptIndex = 0; + + const runAttempt = () => { + if (sessionId !== persistedRecallUiRefreshSession) return; + if (persistedRecallUiRefreshTimer) { + getClearTimeout()(persistedRecallUiRefreshTimer); + persistedRecallUiRefreshTimer = null; + } + + const summary = refreshPersistedRecallMessageUi(); + + const shouldRetryForPending = + (summary.status === "missing_chat_root" || + summary.status === "waiting_dom" || + summary.status === "missing_message_anchor") && + attemptIndex < retryDelays.length - 1; + + // 勿在「已成功渲染」时长期监听 MutationObserver:chat 的 class/流式更新会疯狂触发 + // runAttempt,造成满屏刷新与日志;显式事件(USER_MESSAGE_RENDERED 等)仍会 schedule 刷新。 + const shouldWatchForRepaint = false; + + if (!shouldRetryForPending && !shouldWatchForRepaint) { + clearPersistedRecallMessageUiObserver(); + return; + } + + armPersistedRecallMessageUiObserver(sessionId, runAttempt); + if (shouldRetryForPending) { + attemptIndex += 1; + persistedRecallUiRefreshTimer = getSetTimeout()( + runAttempt, + retryDelays[attemptIndex], + ); + return; + } + + const lingerMs = retryDelays[retryDelays.length - 1] || 0; + if (lingerMs <= 0) { + clearPersistedRecallMessageUiObserver(); + return; + } + persistedRecallUiRefreshTimer = getSetTimeout()(() => { + if (sessionId !== persistedRecallUiRefreshSession) return; + clearPersistedRecallMessageUiObserver(); + persistedRecallUiRefreshTimer = null; + }, lingerMs); + }; + + persistedRecallUiRefreshTimer = getSetTimeout()( + runAttempt, + retryDelays[attemptIndex], + ); +} + +function cleanupPersistedRecallMessageUi() { + getClearTimeout()(persistedRecallUiRefreshTimer); + persistedRecallUiRefreshTimer = null; + clearPersistedRecallMessageUiObserver(); + const chatRoot = getDocument().getElementById("chat"); + if (!chatRoot?.querySelectorAll) return; + for (const messageElement of Array.from(chatRoot.querySelectorAll(".mes"))) { + cleanupRecallArtifacts(messageElement); + } +} + + return { + refreshPersistedRecallMessageUi, + schedulePersistedRecallMessageUiRefresh, + cleanupPersistedRecallMessageUi, + resolveMessageIndexFromElement, + resolveRecallCardAnchor, + }; +}