mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
fix: stabilize recall card mounting and refresh timing
This commit is contained in:
14
README.md
14
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. 如果聊天是异步渲染的,等待一小段时间后再次观察;插件会在短时间内自动补偿重试,而不是只尝试一次。
|
||||
|
||||
---
|
||||
|
||||
|
||||
414
index.js
414
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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user