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:
|
消息级 UI:
|
||||||
|
|
||||||
- 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge)。
|
- 带有 `bme_recall` 的用户消息会显示内联卡片(含用户消息 + 🧠 召回条 + 记忆数 badge)。
|
||||||
|
- 显示前提:必须同时满足 **用户楼层**、`message.extra.bme_recall` 存在、且 `injectionText` 为非空字符串。
|
||||||
- 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`)。
|
- 点击召回条展开,显示**力导向子图**(仅渲染被召回的节点和它们之间的边,复用 `GraphRenderer`)。
|
||||||
- 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情。
|
- 子图中节点可拖拽/缩放,点击节点打开**右侧边栏**查看节点详情。
|
||||||
- 操作按钮(展开态底部):
|
- 操作按钮(展开态底部):
|
||||||
@@ -375,11 +376,22 @@ ST-BME/
|
|||||||
- **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。
|
- **🗑 删除**:二次确认(按钮变红 3s 超时重置),确认后移除持久召回记录。
|
||||||
- **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。
|
- **🔄 重新召回**:重新执行召回并覆盖记录,`manuallyEdited` 重置为 `false`。
|
||||||
- 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。
|
- 不再使用 `prompt()` / `alert()` / `confirm()` 浏览器原生对话框。
|
||||||
|
- 当聊天 DOM 延迟插入时,插件会执行**有界重试 + 短生命周期 MutationObserver 补偿**,避免单次刷新错过挂载。
|
||||||
|
|
||||||
兼容性说明:
|
兼容性说明:
|
||||||
|
|
||||||
- 旧聊天(无 `extra` 或无 `bme_recall`)会自动按“无持久记录”处理,不会报错。
|
- 旧聊天(无 `extra` 或无 `bme_recall`)会自动按“无持久记录”处理,不会报错。
|
||||||
- badge 依赖酒馆消息 DOM 的楼层索引属性;若第三方主题重写消息结构,可能需要额外适配。
|
- Recall Card 依赖消息楼层存在稳定索引属性(如 `mesid` / `data-mesid` / `data-message-id`),不会再回退到 DOM 顺序猜测,以避免误挂载到错误楼层。
|
||||||
|
- 第三方主题至少需要保留 `#chat .mes` 外层消息节点;卡片会优先尝试挂载到 `.mes_block`,其次 `.mes_text` 的父节点,最后回退到 `.mes` 根节点。
|
||||||
|
- 若第三方主题完全移除了这些锚点或稳定索引属性,插件会选择**跳过挂载并输出 `[ST-BME]` 调试日志**,而不是静默挂到错误位置。
|
||||||
|
|
||||||
|
排障建议(数据存在但 UI 不显示时):
|
||||||
|
|
||||||
|
1. 打开浏览器控制台,搜索 `[ST-BME] Recall Card UI` 或 `[ST-BME] Recall Card persist` 调试日志。
|
||||||
|
2. 确认目标楼层是否为**用户消息**,并检查 `message.extra.bme_recall.injectionText` 是否非空。
|
||||||
|
3. 检查消息 DOM 是否仍带有稳定楼层索引属性(`mesid`、`data-mesid`、`data-message-id` 等)。
|
||||||
|
4. 若使用第三方主题,确认消息节点仍包含 `#chat .mes`,且消息内容区域未完全移除 `.mes_block` / `.mes_text` 相关结构。
|
||||||
|
5. 如果聊天是异步渲染的,等待一小段时间后再次观察;插件会在短时间内自动补偿重试,而不是只尝试一次。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
414
index.js
414
index.js
@@ -464,6 +464,12 @@ let lastPreGenerationRecallKey = "";
|
|||||||
let lastPreGenerationRecallAt = 0;
|
let lastPreGenerationRecallAt = 0;
|
||||||
const generationRecallTransactions = new Map();
|
const generationRecallTransactions = new Map();
|
||||||
let persistedRecallUiRefreshTimer = null;
|
let persistedRecallUiRefreshTimer = null;
|
||||||
|
let persistedRecallUiRefreshObserver = null;
|
||||||
|
let persistedRecallUiRefreshSession = 0;
|
||||||
|
const PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS = [0, 80, 180, 320, 500];
|
||||||
|
const PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS = 1500;
|
||||||
|
const persistedRecallUiDiagnosticTimestamps = new Map();
|
||||||
|
const persistedRecallPersistDiagnosticTimestamps = new Map();
|
||||||
const GENERATION_RECALL_TRANSACTION_TTL_MS = 15000;
|
const GENERATION_RECALL_TRANSACTION_TTL_MS = 15000;
|
||||||
const stageNoticeHandles = {
|
const stageNoticeHandles = {
|
||||||
extraction: null,
|
extraction: null,
|
||||||
@@ -965,6 +971,32 @@ function getMessageRecallRecord(messageIndex) {
|
|||||||
return readPersistedRecallFromUserMessage(chat, messageIndex);
|
return readPersistedRecallFromUserMessage(chat, messageIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function debugWithThrottle(cache, key, ...args) {
|
||||||
|
const now = Date.now();
|
||||||
|
const lastAt = cache.get(key) || 0;
|
||||||
|
if (now - lastAt < PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS) return;
|
||||||
|
cache.set(key, now);
|
||||||
|
console.debug(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugPersistedRecallUi(reason, details = null, throttleKey = reason) {
|
||||||
|
const suffix = details ? ` ${JSON.stringify(details)}` : "";
|
||||||
|
debugWithThrottle(
|
||||||
|
persistedRecallUiDiagnosticTimestamps,
|
||||||
|
`ui:${throttleKey}`,
|
||||||
|
`[ST-BME] Recall Card UI: ${reason}${suffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugPersistedRecallPersistence(reason, details = null, throttleKey = reason) {
|
||||||
|
const suffix = details ? ` ${JSON.stringify(details)}` : "";
|
||||||
|
debugWithThrottle(
|
||||||
|
persistedRecallPersistDiagnosticTimestamps,
|
||||||
|
`persist:${throttleKey}`,
|
||||||
|
`[ST-BME] Recall Card persist: ${reason}${suffix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function persistRecallInjectionRecord({
|
function persistRecallInjectionRecord({
|
||||||
recallInput = {},
|
recallInput = {},
|
||||||
result = {},
|
result = {},
|
||||||
@@ -987,7 +1019,22 @@ function persistRecallInjectionRecord({
|
|||||||
resolvedTargetIndex = lastRecallSentUserMessage.messageId;
|
resolvedTargetIndex = lastRecallSentUserMessage.messageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Number.isFinite(resolvedTargetIndex)) return null;
|
if (!Number.isFinite(resolvedTargetIndex)) {
|
||||||
|
debugPersistedRecallPersistence("目标 user 楼层解析失败", {
|
||||||
|
generationType,
|
||||||
|
explicitTargetUserMessageIndex: recallInput?.targetUserMessageIndex,
|
||||||
|
lastSentUserMessageId: lastRecallSentUserMessage?.messageId,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chat[resolvedTargetIndex]?.is_user) {
|
||||||
|
debugPersistedRecallPersistence("目标楼层不是 user 消息,跳过持久化", {
|
||||||
|
targetUserMessageIndex: resolvedTargetIndex,
|
||||||
|
messageKeys: Object.keys(chat[resolvedTargetIndex] || {}),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const record = buildPersistedRecallRecord(
|
const record = buildPersistedRecallRecord(
|
||||||
{
|
{
|
||||||
@@ -1001,7 +1048,17 @@ function persistRecallInjectionRecord({
|
|||||||
},
|
},
|
||||||
readPersistedRecallFromUserMessage(chat, resolvedTargetIndex),
|
readPersistedRecallFromUserMessage(chat, resolvedTargetIndex),
|
||||||
);
|
);
|
||||||
|
if (!String(record?.injectionText || "").trim()) {
|
||||||
|
debugPersistedRecallPersistence("无有效 injectionText,跳过持久化", {
|
||||||
|
targetUserMessageIndex: resolvedTargetIndex,
|
||||||
|
selectedNodeCount: Array.isArray(result?.selectedNodeIds) ? result.selectedNodeIds.length : 0,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
if (!writePersistedRecallToUserMessage(chat, resolvedTargetIndex, record)) {
|
if (!writePersistedRecallToUserMessage(chat, resolvedTargetIndex, record)) {
|
||||||
|
debugPersistedRecallPersistence("写入 user 楼层失败", {
|
||||||
|
targetUserMessageIndex: resolvedTargetIndex,
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,8 +1168,61 @@ function applyFinalRecallInjectionForGeneration({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) {
|
function clearPersistedRecallMessageUiObserver() {
|
||||||
if (!messageElement) return Number.isFinite(fallbackIndex) ? fallbackIndex : null;
|
try {
|
||||||
|
persistedRecallUiRefreshObserver?.disconnect?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ST-BME] Recall Card UI observer disconnect 失败:", error);
|
||||||
|
}
|
||||||
|
persistedRecallUiRefreshObserver = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDomNodeAttached(node) {
|
||||||
|
if (!node) return false;
|
||||||
|
if (node.isConnected === true) return true;
|
||||||
|
return typeof document?.contains === "function" ? document.contains(node) : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRecallCardElement(cardElement) {
|
||||||
|
if (!cardElement) return;
|
||||||
|
try {
|
||||||
|
cardElement._bmeDestroyRenderer?.();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[ST-BME] Recall Card renderer 清理失败:", error);
|
||||||
|
}
|
||||||
|
cardElement.remove?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupLegacyRecallBadges(messageElement) {
|
||||||
|
if (!messageElement?.querySelectorAll) return;
|
||||||
|
const oldBadges = Array.from(messageElement.querySelectorAll(".st-bme-recall-badge") || []);
|
||||||
|
for (const oldBadge of oldBadges) oldBadge.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRecallArtifacts(messageElement, keepMessageIndex = null) {
|
||||||
|
if (!messageElement?.querySelectorAll) return;
|
||||||
|
|
||||||
|
cleanupLegacyRecallBadges(messageElement);
|
||||||
|
|
||||||
|
const existingCards = Array.from(messageElement.querySelectorAll(".bme-recall-card") || []);
|
||||||
|
for (const card of existingCards) {
|
||||||
|
if (keepMessageIndex !== null && card.dataset?.messageIndex === String(keepMessageIndex)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cleanupRecallCardElement(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStableMessageIndex(candidate) {
|
||||||
|
const normalized = String(candidate ?? "").trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (!/^\d+$/.test(normalized)) return null;
|
||||||
|
const parsed = Number.parseInt(normalized, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMessageIndexFromElement(messageElement) {
|
||||||
|
if (!messageElement) return null;
|
||||||
|
|
||||||
const candidates = [
|
const candidates = [
|
||||||
messageElement.getAttribute?.("mesid"),
|
messageElement.getAttribute?.("mesid"),
|
||||||
@@ -1121,12 +1231,173 @@ function resolveMessageIndexFromElement(messageElement, fallbackIndex = null) {
|
|||||||
messageElement.dataset?.mesid,
|
messageElement.dataset?.mesid,
|
||||||
messageElement.dataset?.messageId,
|
messageElement.dataset?.messageId,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const parsed = Number.parseInt(candidate, 10);
|
const parsed = parseStableMessageIndex(candidate);
|
||||||
if (Number.isFinite(parsed)) return parsed;
|
if (parsed !== null) return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Number.isFinite(fallbackIndex) ? fallbackIndex : null;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecallCardAnchor(messageElement) {
|
||||||
|
if (!messageElement || !isDomNodeAttached(messageElement)) return null;
|
||||||
|
const mesBlock = messageElement.querySelector?.(".mes_block");
|
||||||
|
if (isDomNodeAttached(mesBlock)) return mesBlock;
|
||||||
|
|
||||||
|
const mesTextParent = messageElement.querySelector?.(".mes_text")?.parentElement;
|
||||||
|
if (isDomNodeAttached(mesTextParent)) return mesTextParent;
|
||||||
|
|
||||||
|
return isDomNodeAttached(messageElement) ? messageElement : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPersistedRecallUiRetryDelays(initialDelayMs = 0) {
|
||||||
|
const normalizedInitial = Math.max(0, Number.parseInt(initialDelayMs, 10) || 0);
|
||||||
|
if (!normalizedInitial) return [...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS];
|
||||||
|
return [
|
||||||
|
normalizedInitial,
|
||||||
|
...PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS.filter((delay) => delay > normalizedInitial),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizePersistedRecallRefreshStatus(summary) {
|
||||||
|
if (summary.renderedCount > 0) return "rendered";
|
||||||
|
if (summary.waitingMessageIndices.length > 0) return "waiting_dom";
|
||||||
|
if (summary.anchorFailureIndices.length > 0) return "missing_message_anchor";
|
||||||
|
if (summary.skippedNonUserIndices.length > 0) return "skipped_non_user";
|
||||||
|
if (summary.persistedRecordCount === 0) return "missing_recall_record";
|
||||||
|
return "missing_message_anchor";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPersistedRecallMessageUi() {
|
||||||
|
const context = getContext();
|
||||||
|
const chat = context?.chat;
|
||||||
|
if (!Array.isArray(chat) || typeof document?.getElementById !== "function") {
|
||||||
|
return {
|
||||||
|
status: "missing_chat_root",
|
||||||
|
renderedCount: 0,
|
||||||
|
persistedRecordCount: 0,
|
||||||
|
waitingMessageIndices: [],
|
||||||
|
anchorFailureIndices: [],
|
||||||
|
skippedNonUserIndices: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatRoot = document.getElementById("chat");
|
||||||
|
if (!chatRoot) {
|
||||||
|
debugPersistedRecallUi("缺少 #chat 根节点");
|
||||||
|
return {
|
||||||
|
status: "missing_chat_root",
|
||||||
|
renderedCount: 0,
|
||||||
|
persistedRecordCount: 0,
|
||||||
|
waitingMessageIndices: [],
|
||||||
|
anchorFailureIndices: [],
|
||||||
|
skippedNonUserIndices: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeName = getSettings()?.panelTheme || "crimson";
|
||||||
|
const callbacks = getRecallCardCallbacks();
|
||||||
|
const messageElementMap = new Map();
|
||||||
|
const messageElements = Array.from(chatRoot.querySelectorAll(".mes"));
|
||||||
|
for (const messageElement of messageElements) {
|
||||||
|
cleanupLegacyRecallBadges(messageElement);
|
||||||
|
const messageIndex = resolveMessageIndexFromElement(messageElement);
|
||||||
|
if (!Number.isFinite(messageIndex)) {
|
||||||
|
debugPersistedRecallUi("消息 DOM 缺少稳定索引属性,跳过挂载", {
|
||||||
|
className: messageElement.className || "",
|
||||||
|
}, "missing-stable-message-index");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (messageElementMap.has(messageIndex)) {
|
||||||
|
debugPersistedRecallUi("检测到重复消息 DOM 索引,保留首个锚点", {
|
||||||
|
messageIndex,
|
||||||
|
}, `duplicate-message-index:${messageIndex}`);
|
||||||
|
cleanupRecallArtifacts(messageElement);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messageElementMap.set(messageIndex, messageElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
status: "missing_recall_record",
|
||||||
|
renderedCount: 0,
|
||||||
|
persistedRecordCount: 0,
|
||||||
|
waitingMessageIndices: [],
|
||||||
|
anchorFailureIndices: [],
|
||||||
|
skippedNonUserIndices: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let messageIndex = 0; messageIndex < chat.length; messageIndex++) {
|
||||||
|
const message = chat[messageIndex];
|
||||||
|
const messageElement = messageElementMap.get(messageIndex) || null;
|
||||||
|
const existingCard = messageElement?.querySelector?.(
|
||||||
|
`.bme-recall-card[data-message-index="${messageIndex}"]`,
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
if (!message?.is_user) {
|
||||||
|
if (existingCard) cleanupRecallCardElement(existingCard);
|
||||||
|
const unexpectedRecord = readPersistedRecallFromUserMessage(chat, messageIndex);
|
||||||
|
if (unexpectedRecord) {
|
||||||
|
summary.skippedNonUserIndices.push(messageIndex);
|
||||||
|
debugPersistedRecallUi("非 user 楼层存在持久召回记录,已跳过挂载", {
|
||||||
|
messageIndex,
|
||||||
|
}, `skipped-non-user:${messageIndex}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = readPersistedRecallFromUserMessage(chat, messageIndex);
|
||||||
|
if (!record?.injectionText) {
|
||||||
|
if (existingCard) cleanupRecallCardElement(existingCard);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.persistedRecordCount += 1;
|
||||||
|
if (!messageElement) {
|
||||||
|
summary.waitingMessageIndices.push(messageIndex);
|
||||||
|
debugPersistedRecallUi("目标 user 楼层 DOM 未就绪,等待后续刷新", {
|
||||||
|
messageIndex,
|
||||||
|
}, `waiting-dom:${messageIndex}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = resolveRecallCardAnchor(messageElement);
|
||||||
|
if (!anchor) {
|
||||||
|
cleanupRecallCardElement(existingCard);
|
||||||
|
summary.anchorFailureIndices.push(messageIndex);
|
||||||
|
debugPersistedRecallUi("目标 user 楼层锚点解析失败,跳过挂载", {
|
||||||
|
messageIndex,
|
||||||
|
}, `missing-anchor:${messageIndex}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupRecallArtifacts(messageElement, messageIndex);
|
||||||
|
const currentCard = messageElement.querySelector?.(
|
||||||
|
`.bme-recall-card[data-message-index="${messageIndex}"]`,
|
||||||
|
) || null;
|
||||||
|
|
||||||
|
if (currentCard) {
|
||||||
|
updateRecallCardData(currentCard, record);
|
||||||
|
} else {
|
||||||
|
const card = createRecallCardElement({
|
||||||
|
messageIndex,
|
||||||
|
record,
|
||||||
|
userMessageText: message.mes || "",
|
||||||
|
graph: currentGraph,
|
||||||
|
themeName,
|
||||||
|
callbacks,
|
||||||
|
});
|
||||||
|
anchor.appendChild(card);
|
||||||
|
}
|
||||||
|
summary.renderedCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary.status = summarizePersistedRecallRefreshStatus(summary);
|
||||||
|
if (summary.status === "missing_recall_record") {
|
||||||
|
debugPersistedRecallUi("当前无有效持久召回记录可渲染");
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRecallCardCallbacks() {
|
function getRecallCardCallbacks() {
|
||||||
@@ -1190,96 +1461,67 @@ function getRecallCardCallbacks() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function armPersistedRecallMessageUiObserver(sessionId, runAttempt) {
|
||||||
|
clearPersistedRecallMessageUiObserver();
|
||||||
|
const chatRoot = document?.getElementById?.("chat");
|
||||||
|
const ObserverCtor = globalThis.MutationObserver;
|
||||||
|
if (!chatRoot || typeof ObserverCtor !== "function") return false;
|
||||||
|
|
||||||
function refreshPersistedRecallMessageUi() {
|
persistedRecallUiRefreshObserver = new ObserverCtor(() => {
|
||||||
const context = getContext();
|
if (sessionId !== persistedRecallUiRefreshSession) return;
|
||||||
const chat = context?.chat;
|
clearPersistedRecallMessageUiObserver();
|
||||||
if (!Array.isArray(chat) || typeof document?.getElementById !== "function") return;
|
runAttempt();
|
||||||
|
});
|
||||||
const chatRoot = document.getElementById("chat");
|
persistedRecallUiRefreshObserver.observe(chatRoot, { childList: true, subtree: true });
|
||||||
if (!chatRoot) return;
|
return true;
|
||||||
|
|
||||||
const themeName = getSettings()?.panelTheme || "crimson";
|
|
||||||
const callbacks = getRecallCardCallbacks();
|
|
||||||
|
|
||||||
const messageElements = Array.from(chatRoot.querySelectorAll(".mes"));
|
|
||||||
for (let fallbackIndex = 0; fallbackIndex < messageElements.length; fallbackIndex++) {
|
|
||||||
const messageElement = messageElements[fallbackIndex];
|
|
||||||
const messageIndex = resolveMessageIndexFromElement(messageElement, fallbackIndex);
|
|
||||||
if (!Number.isFinite(messageIndex)) continue;
|
|
||||||
|
|
||||||
// Clean up old-style badges (migration from v1)
|
|
||||||
const oldBadges = Array.from(
|
|
||||||
messageElement.querySelectorAll?.(".st-bme-recall-badge") || [],
|
|
||||||
);
|
|
||||||
for (const oldBadge of oldBadges) oldBadge.remove();
|
|
||||||
|
|
||||||
// Find existing card
|
|
||||||
const existingCards = Array.from(
|
|
||||||
messageElement.querySelectorAll?.(".bme-recall-card") || [],
|
|
||||||
);
|
|
||||||
let existingCard = null;
|
|
||||||
for (const card of existingCards) {
|
|
||||||
if (card.dataset.messageIndex === String(messageIndex)) {
|
|
||||||
existingCard = card;
|
|
||||||
} else {
|
|
||||||
card._bmeDestroyRenderer?.();
|
|
||||||
card.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = chat[messageIndex];
|
|
||||||
if (!message?.is_user) {
|
|
||||||
if (existingCard) {
|
|
||||||
existingCard._bmeDestroyRenderer?.();
|
|
||||||
existingCard.remove();
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = readPersistedRecallFromUserMessage(chat, messageIndex);
|
|
||||||
if (!record?.injectionText) {
|
|
||||||
if (existingCard) {
|
|
||||||
existingCard._bmeDestroyRenderer?.();
|
|
||||||
existingCard.remove();
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingCard) {
|
|
||||||
// Update data without rebuilding (preserves expanded state)
|
|
||||||
updateRecallCardData(existingCard, record);
|
|
||||||
} else {
|
|
||||||
// Create new card
|
|
||||||
const card = createRecallCardElement({
|
|
||||||
messageIndex,
|
|
||||||
record,
|
|
||||||
userMessageText: message.mes || "",
|
|
||||||
graph: currentGraph,
|
|
||||||
themeName,
|
|
||||||
callbacks,
|
|
||||||
});
|
|
||||||
|
|
||||||
const anchor =
|
|
||||||
messageElement.querySelector?.(".mes_block") ||
|
|
||||||
messageElement.querySelector?.(".mes_text")?.parentElement ||
|
|
||||||
messageElement;
|
|
||||||
anchor.appendChild(card);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function schedulePersistedRecallMessageUiRefresh(delayMs = 120) {
|
function schedulePersistedRecallMessageUiRefresh(delayMs = 0) {
|
||||||
clearTimeout(persistedRecallUiRefreshTimer);
|
clearTimeout(persistedRecallUiRefreshTimer);
|
||||||
persistedRecallUiRefreshTimer = setTimeout(() => {
|
clearPersistedRecallMessageUiObserver();
|
||||||
|
|
||||||
|
const retryDelays = buildPersistedRecallUiRetryDelays(delayMs);
|
||||||
|
const sessionId = ++persistedRecallUiRefreshSession;
|
||||||
|
let attemptIndex = 0;
|
||||||
|
|
||||||
|
const runAttempt = () => {
|
||||||
|
if (sessionId !== persistedRecallUiRefreshSession) return;
|
||||||
persistedRecallUiRefreshTimer = null;
|
persistedRecallUiRefreshTimer = null;
|
||||||
refreshPersistedRecallMessageUi();
|
const summary = refreshPersistedRecallMessageUi();
|
||||||
}, Math.max(16, Number.parseInt(delayMs, 10) || 120));
|
const shouldRetry =
|
||||||
|
(summary.status === "missing_chat_root" ||
|
||||||
|
summary.status === "waiting_dom" ||
|
||||||
|
summary.status === "missing_message_anchor") &&
|
||||||
|
attemptIndex < retryDelays.length - 1;
|
||||||
|
|
||||||
|
if (!shouldRetry) {
|
||||||
|
clearPersistedRecallMessageUiObserver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
armPersistedRecallMessageUiObserver(sessionId, runAttempt);
|
||||||
|
attemptIndex += 1;
|
||||||
|
persistedRecallUiRefreshTimer = setTimeout(runAttempt, retryDelays[attemptIndex]);
|
||||||
|
};
|
||||||
|
|
||||||
|
persistedRecallUiRefreshTimer = setTimeout(runAttempt, retryDelays[attemptIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPersistedRecallMessageUi() {
|
||||||
|
clearTimeout(persistedRecallUiRefreshTimer);
|
||||||
|
persistedRecallUiRefreshTimer = null;
|
||||||
|
clearPersistedRecallMessageUiObserver();
|
||||||
|
const chatRoot = document.getElementById("chat");
|
||||||
|
if (!chatRoot?.querySelectorAll) return;
|
||||||
|
for (const messageElement of Array.from(chatRoot.querySelectorAll(".mes"))) {
|
||||||
|
cleanupRecallArtifacts(messageElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rerunRecallForMessage(messageIndex) {
|
async function rerunRecallForMessage(messageIndex) {
|
||||||
const chat = getContext()?.chat;
|
const chat = getContext()?.chat;
|
||||||
const message = Array.isArray(chat) ? chat[messageIndex] : null;
|
const message = Array.isArray(chat) ? chat[messageIndex] : null;
|
||||||
|
cleanupPersistedRecallMessageUi();
|
||||||
if (!message?.is_user) {
|
if (!message?.is_user) {
|
||||||
toastr.info("仅用户消息支持重新召回");
|
toastr.info("仅用户消息支持重新召回");
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import {
|
|||||||
markPersistedRecallManualEdit,
|
markPersistedRecallManualEdit,
|
||||||
} from "../recall-persistence.js";
|
} from "../recall-persistence.js";
|
||||||
|
|
||||||
|
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
const extensionsShimSource = [
|
const extensionsShimSource = [
|
||||||
"export const extension_settings = globalThis.__p0ExtensionSettings || {};",
|
"export const extension_settings = globalThis.__p0ExtensionSettings || {};",
|
||||||
"export function getContext(...args) {",
|
"export function getContext(...args) {",
|
||||||
@@ -549,6 +550,521 @@ function pushTestOverrides(patch = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClassList {
|
||||||
|
constructor(owner) {
|
||||||
|
this.owner = owner;
|
||||||
|
this.tokens = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
setFromString(value = "") {
|
||||||
|
this.tokens = new Set(String(value || "").split(/\s+/).filter(Boolean));
|
||||||
|
}
|
||||||
|
|
||||||
|
add(...tokens) {
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token) this.tokens.add(token);
|
||||||
|
}
|
||||||
|
this.owner._syncClassName();
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(...tokens) {
|
||||||
|
for (const token of tokens) this.tokens.delete(token);
|
||||||
|
this.owner._syncClassName();
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(token) {
|
||||||
|
return this.tokens.has(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle(token, force) {
|
||||||
|
if (force === true) {
|
||||||
|
this.tokens.add(token);
|
||||||
|
this.owner._syncClassName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (force === false) {
|
||||||
|
this.tokens.delete(token);
|
||||||
|
this.owner._syncClassName();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.tokens.has(token)) {
|
||||||
|
this.tokens.delete(token);
|
||||||
|
this.owner._syncClassName();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.tokens.add(token);
|
||||||
|
this.owner._syncClassName();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString() {
|
||||||
|
return [...this.tokens].join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(tagName, ownerDocument) {
|
||||||
|
this.tagName = String(tagName || "div").toUpperCase();
|
||||||
|
this.ownerDocument = ownerDocument;
|
||||||
|
this.children = [];
|
||||||
|
this.parentElement = null;
|
||||||
|
this.dataset = {};
|
||||||
|
this.attributes = new Map();
|
||||||
|
this.eventListeners = new Map();
|
||||||
|
this.classList = new FakeClassList(this);
|
||||||
|
this._className = "";
|
||||||
|
this.id = "";
|
||||||
|
this.textContent = "";
|
||||||
|
this.innerHTML = "";
|
||||||
|
this.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_syncClassName() {
|
||||||
|
this._className = this.classList.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get className() {
|
||||||
|
return this._className;
|
||||||
|
}
|
||||||
|
|
||||||
|
set className(value) {
|
||||||
|
this._className = String(value || "");
|
||||||
|
this.classList.setFromString(this._className);
|
||||||
|
this._className = this.classList.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
get parentNode() {
|
||||||
|
return this.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name, value) {
|
||||||
|
const key = String(name || "");
|
||||||
|
const normalized = String(value ?? "");
|
||||||
|
this.attributes.set(key, normalized);
|
||||||
|
if (key === "id") {
|
||||||
|
this.id = normalized;
|
||||||
|
} else if (key === "class") {
|
||||||
|
this.classList.setFromString(normalized);
|
||||||
|
this.className = this.classList.toString();
|
||||||
|
} else if (key.startsWith("data-")) {
|
||||||
|
const datasetKey = key
|
||||||
|
.slice(5)
|
||||||
|
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
this.dataset[datasetKey] = normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAttribute(name) {
|
||||||
|
const key = String(name || "");
|
||||||
|
if (this.attributes.has(key)) return this.attributes.get(key);
|
||||||
|
if (key === "id") return this.id || null;
|
||||||
|
if (key === "class") return this.className || null;
|
||||||
|
if (key.startsWith("data-")) {
|
||||||
|
const datasetKey = key
|
||||||
|
.slice(5)
|
||||||
|
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
return this.dataset[datasetKey] ?? null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child) {
|
||||||
|
if (!child) return child;
|
||||||
|
if (child.parentElement) {
|
||||||
|
child.parentElement.removeChild(child);
|
||||||
|
}
|
||||||
|
child.parentElement = this;
|
||||||
|
child.ownerDocument = this.ownerDocument;
|
||||||
|
this.children.push(child);
|
||||||
|
this.ownerDocument?._notifyMutation({ type: "childList", target: this, addedNodes: [child] });
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChild(child) {
|
||||||
|
const index = this.children.indexOf(child);
|
||||||
|
if (index >= 0) {
|
||||||
|
this.children.splice(index, 1);
|
||||||
|
child.parentElement = null;
|
||||||
|
this.ownerDocument?._notifyMutation({ type: "childList", target: this, removedNodes: [child] });
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove() {
|
||||||
|
this.parentElement?.removeChild(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type, handler) {
|
||||||
|
const key = String(type || "");
|
||||||
|
const handlers = this.eventListeners.get(key) || [];
|
||||||
|
handlers.push(handler);
|
||||||
|
this.eventListeners.set(key, handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchEvent(event = {}) {
|
||||||
|
const key = String(event.type || "");
|
||||||
|
const handlers = this.eventListeners.get(key) || [];
|
||||||
|
for (const handler of handlers) {
|
||||||
|
handler({
|
||||||
|
stopPropagation() {},
|
||||||
|
preventDefault() {},
|
||||||
|
...event,
|
||||||
|
target: this,
|
||||||
|
currentTarget: this,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.dispatchEvent({ type: "click" });
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected() {
|
||||||
|
return Boolean(this.parentElement) || this === this.ownerDocument?.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector) {
|
||||||
|
return this.querySelectorAll(selector)[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return this.ownerDocument?._querySelectorAll(selector, this) || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
constructor() {
|
||||||
|
this.body = new FakeElement("body", this);
|
||||||
|
this._observers = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
createElement(tagName) {
|
||||||
|
return new FakeElement(tagName, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
contains(node) {
|
||||||
|
return Boolean(this._flatten(this.body).includes(node));
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementById(id) {
|
||||||
|
return this._flatten(this.body).find((node) => node.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector) {
|
||||||
|
return this.body.querySelector(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
return this.body.querySelectorAll(selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
_flatten(root) {
|
||||||
|
const nodes = [];
|
||||||
|
const visit = (node) => {
|
||||||
|
nodes.push(node);
|
||||||
|
for (const child of node.children) visit(child);
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
_matchesSimple(node, selector) {
|
||||||
|
if (!selector) return false;
|
||||||
|
if (selector.startsWith("#")) {
|
||||||
|
return node.id === selector.slice(1);
|
||||||
|
}
|
||||||
|
const attrMatches = [...selector.matchAll(/\[([^=\]]+)="([^\]]*)"\]/g)];
|
||||||
|
const attrless = selector.replace(/\[[^\]]+\]/g, "");
|
||||||
|
const classMatches = [...attrless.matchAll(/\.([A-Za-z0-9_-]+)/g)].map((m) => m[1]);
|
||||||
|
const tagMatch = attrless.match(/^[A-Za-z][A-Za-z0-9_-]*/);
|
||||||
|
if (tagMatch && node.tagName.toLowerCase() !== tagMatch[0].toLowerCase()) return false;
|
||||||
|
for (const className of classMatches) {
|
||||||
|
if (!node.classList.contains(className)) return false;
|
||||||
|
}
|
||||||
|
for (const [, rawName, expected] of attrMatches) {
|
||||||
|
const actual = node.getAttribute(rawName);
|
||||||
|
if (String(actual ?? "") !== expected) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_matchesSelectorChain(node, segments) {
|
||||||
|
if (!segments.length) return false;
|
||||||
|
if (!this._matchesSimple(node, segments[segments.length - 1])) return false;
|
||||||
|
let current = node.parentElement;
|
||||||
|
for (let index = segments.length - 2; index >= 0; index--) {
|
||||||
|
while (current && !this._matchesSimple(current, segments[index])) {
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
if (!current) return false;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_querySelectorAll(selector, scopeRoot) {
|
||||||
|
const segments = String(selector || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
const nodes = this._flatten(scopeRoot);
|
||||||
|
return nodes.filter((node) => node !== scopeRoot && this._matchesSelectorChain(node, segments));
|
||||||
|
}
|
||||||
|
|
||||||
|
_registerObserver(observer) {
|
||||||
|
this._observers.add(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_unregisterObserver(observer) {
|
||||||
|
this._observers.delete(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyMutation(record) {
|
||||||
|
for (const observer of this._observers) {
|
||||||
|
observer._notify(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeMutationObserver {
|
||||||
|
constructor(callback, documentRef) {
|
||||||
|
this.callback = callback;
|
||||||
|
this.documentRef = documentRef;
|
||||||
|
this.active = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
observe() {
|
||||||
|
this.active = true;
|
||||||
|
this.documentRef._registerObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.active = false;
|
||||||
|
this.documentRef._unregisterObserver(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
_notify(record) {
|
||||||
|
if (!this.active) return;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (this.active) this.callback([record]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDomHarness(chat) {
|
||||||
|
const document = new FakeDocument();
|
||||||
|
const chatRoot = document.createElement("div");
|
||||||
|
chatRoot.setAttribute("id", "chat");
|
||||||
|
document.body.appendChild(chatRoot);
|
||||||
|
const observerClass = class extends FakeMutationObserver {
|
||||||
|
constructor(callback) {
|
||||||
|
super(callback, document);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { document, chatRoot, MutationObserver: observerClass, chat };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMessageElement(document, messageIndex, { stableId = true, withMesBlock = true, isUser = true } = {}) {
|
||||||
|
const mes = document.createElement("div");
|
||||||
|
mes.classList.add("mes");
|
||||||
|
if (stableId) mes.setAttribute("mesid", String(messageIndex));
|
||||||
|
if (isUser) mes.classList.add("user_mes");
|
||||||
|
const block = document.createElement("div");
|
||||||
|
block.classList.add("mes_block");
|
||||||
|
const textWrap = document.createElement("div");
|
||||||
|
textWrap.classList.add("mes_text");
|
||||||
|
if (withMesBlock) {
|
||||||
|
block.appendChild(textWrap);
|
||||||
|
mes.appendChild(block);
|
||||||
|
} else {
|
||||||
|
mes.appendChild(textWrap);
|
||||||
|
}
|
||||||
|
return mes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLegacyBadge(document, messageElement) {
|
||||||
|
const badge = document.createElement("div");
|
||||||
|
badge.classList.add("st-bme-recall-badge");
|
||||||
|
messageElement.appendChild(badge);
|
||||||
|
return badge;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecallUiHarness({ chat, graph = { nodes: [], edges: [] } } = {}) {
|
||||||
|
const harness = createDomHarness(chat);
|
||||||
|
const previousDocument = globalThis.document;
|
||||||
|
globalThis.document = harness.document;
|
||||||
|
const source = await fs.readFile(indexPath, "utf8");
|
||||||
|
const start = source.indexOf("function debugWithThrottle(");
|
||||||
|
const end = source.indexOf("async function rerunRecallForMessage(");
|
||||||
|
if (start < 0 || end < 0 || end <= start) {
|
||||||
|
throw new Error("无法从 index.js 提取 Recall UI 逻辑");
|
||||||
|
}
|
||||||
|
const snippet = source.slice(start, end).replace(/^export\s+/gm, "");
|
||||||
|
const context = {
|
||||||
|
console,
|
||||||
|
Date,
|
||||||
|
JSON,
|
||||||
|
Math,
|
||||||
|
Map,
|
||||||
|
Set,
|
||||||
|
Array,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Object,
|
||||||
|
RegExp,
|
||||||
|
parseInt: Number.parseInt,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
queueMicrotask,
|
||||||
|
document: harness.document,
|
||||||
|
currentGraph: graph,
|
||||||
|
persistedRecallUiRefreshTimer: null,
|
||||||
|
persistedRecallUiRefreshObserver: null,
|
||||||
|
persistedRecallUiRefreshSession: 0,
|
||||||
|
PERSISTED_RECALL_UI_REFRESH_RETRY_DELAYS_MS: [0, 10, 20],
|
||||||
|
PERSISTED_RECALL_UI_DIAGNOSTIC_THROTTLE_MS: 0,
|
||||||
|
persistedRecallUiDiagnosticTimestamps: new Map(),
|
||||||
|
persistedRecallPersistDiagnosticTimestamps: new Map(),
|
||||||
|
getContext: () => ({ chat }),
|
||||||
|
getSettings: () => ({ panelTheme: "crimson" }),
|
||||||
|
triggerChatMetadataSave: () => "debounced",
|
||||||
|
estimateTokens: (text = "") => String(text || "").trim().split(/\s+/).filter(Boolean).length || 1,
|
||||||
|
toastr: {
|
||||||
|
success() {},
|
||||||
|
warning() {},
|
||||||
|
info() {},
|
||||||
|
},
|
||||||
|
openRecallSidebar() {},
|
||||||
|
readPersistedRecallFromUserMessage,
|
||||||
|
removePersistedRecallFromUserMessage,
|
||||||
|
writePersistedRecallToUserMessage,
|
||||||
|
buildPersistedRecallRecord,
|
||||||
|
markPersistedRecallManualEdit,
|
||||||
|
createRecallCardElement: null,
|
||||||
|
updateRecallCardData: null,
|
||||||
|
globalThis: null,
|
||||||
|
result: null,
|
||||||
|
};
|
||||||
|
context.globalThis = context;
|
||||||
|
const recallUiModule = await import("../recall-message-ui.js");
|
||||||
|
context.createRecallCardElement = recallUiModule.createRecallCardElement;
|
||||||
|
context.updateRecallCardData = recallUiModule.updateRecallCardData;
|
||||||
|
context.MutationObserver = harness.MutationObserver;
|
||||||
|
vm.createContext(context);
|
||||||
|
vm.runInContext(
|
||||||
|
`${snippet}\nresult = { refreshPersistedRecallMessageUi, schedulePersistedRecallMessageUiRefresh, cleanupPersistedRecallMessageUi, resolveMessageIndexFromElement, resolveRecallCardAnchor };`,
|
||||||
|
context,
|
||||||
|
{ filename: indexPath },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...harness,
|
||||||
|
context,
|
||||||
|
api: context.result,
|
||||||
|
restoreGlobals() {
|
||||||
|
globalThis.document = previousDocument;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRecallCardMountsOnStandardUserMessageDom() {
|
||||||
|
const chat = [
|
||||||
|
{ is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
|
||||||
|
];
|
||||||
|
const harness = await createRecallUiHarness({ chat });
|
||||||
|
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
|
||||||
|
harness.chatRoot.appendChild(messageElement);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = harness.api.refreshPersistedRecallMessageUi();
|
||||||
|
assert.equal(summary.status, "rendered");
|
||||||
|
assert.equal(summary.renderedCount, 1);
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1);
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".mes_block .bme-recall-card").length, 1);
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRecallCardSkipsMountWithoutStableMessageIndex() {
|
||||||
|
const chat = [
|
||||||
|
{ is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
|
||||||
|
];
|
||||||
|
const harness = await createRecallUiHarness({ chat });
|
||||||
|
const messageElement = createMessageElement(harness.document, 0, { stableId: false, withMesBlock: true, isUser: true });
|
||||||
|
harness.chatRoot.appendChild(messageElement);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = harness.api.refreshPersistedRecallMessageUi();
|
||||||
|
assert.equal(summary.status, "waiting_dom");
|
||||||
|
assert.deepEqual(Array.from(summary.waitingMessageIndices), [0]);
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 0);
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRecallCardDelayedDomInsertionEventuallyRenders() {
|
||||||
|
const chat = [
|
||||||
|
{ is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
|
||||||
|
];
|
||||||
|
const harness = await createRecallUiHarness({ chat });
|
||||||
|
try {
|
||||||
|
harness.api.schedulePersistedRecallMessageUiRefresh();
|
||||||
|
await waitForTick();
|
||||||
|
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
|
||||||
|
harness.chatRoot.appendChild(messageElement);
|
||||||
|
await waitForTick();
|
||||||
|
await waitForTick();
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1);
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRecallCardDoesNotMountOnNonUserFloor() {
|
||||||
|
const chat = [
|
||||||
|
{ is_user: false, mes: "assistant-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
|
||||||
|
];
|
||||||
|
const harness = await createRecallUiHarness({ chat });
|
||||||
|
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: false });
|
||||||
|
harness.chatRoot.appendChild(messageElement);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = harness.api.refreshPersistedRecallMessageUi();
|
||||||
|
assert.equal(summary.status, "skipped_non_user");
|
||||||
|
assert.deepEqual(Array.from(summary.skippedNonUserIndices), [0]);
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 0);
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates() {
|
||||||
|
const chat = [
|
||||||
|
{ is_user: true, mes: "user-0", extra: { bme_recall: buildPersistedRecallRecord({ injectionText: "recall-0", selectedNodeIds: ["n1", "n2"], nowIso: "2026-01-01T00:00:00.000Z" }) } },
|
||||||
|
];
|
||||||
|
const harness = await createRecallUiHarness({ chat });
|
||||||
|
const messageElement = createMessageElement(harness.document, 0, { stableId: true, withMesBlock: true, isUser: true });
|
||||||
|
const staleCard = harness.document.createElement("div");
|
||||||
|
staleCard.classList.add("bme-recall-card");
|
||||||
|
staleCard.dataset.messageIndex = "999";
|
||||||
|
staleCard._bmeDestroyRenderer = () => {
|
||||||
|
staleCard.dataset.destroyed = "1";
|
||||||
|
};
|
||||||
|
appendLegacyBadge(harness.document, messageElement);
|
||||||
|
messageElement.appendChild(staleCard);
|
||||||
|
harness.chatRoot.appendChild(messageElement);
|
||||||
|
|
||||||
|
try {
|
||||||
|
harness.api.refreshPersistedRecallMessageUi();
|
||||||
|
harness.api.refreshPersistedRecallMessageUi();
|
||||||
|
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".st-bme-recall-badge").length, 0);
|
||||||
|
assert.equal(harness.chatRoot.querySelectorAll(".bme-recall-card").length, 1);
|
||||||
|
assert.equal(staleCard.dataset.destroyed, "1");
|
||||||
|
} finally {
|
||||||
|
harness.restoreGlobals();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function makeEvent(seq, title) {
|
function makeEvent(seq, title) {
|
||||||
return createNode({
|
return createNode({
|
||||||
type: "event",
|
type: "event",
|
||||||
@@ -1950,6 +2466,11 @@ await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();
|
|||||||
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
|
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
|
||||||
await testPersistentRecallDataLayerLifecycleAndCompatibility();
|
await testPersistentRecallDataLayerLifecycleAndCompatibility();
|
||||||
await testPersistentRecallSourceResolutionAndTargetRouting();
|
await testPersistentRecallSourceResolutionAndTargetRouting();
|
||||||
|
await testRecallCardMountsOnStandardUserMessageDom();
|
||||||
|
await testRecallCardSkipsMountWithoutStableMessageIndex();
|
||||||
|
await testRecallCardDelayedDomInsertionEventuallyRenders();
|
||||||
|
await testRecallCardDoesNotMountOnNonUserFloor();
|
||||||
|
await testRecallCardRefreshCleansLegacyBadgeAndAvoidsDuplicates();
|
||||||
await testRecallSubGraphAndDataLayerEntryPoints();
|
await testRecallSubGraphAndDataLayerEntryPoints();
|
||||||
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
|
await testRerollUsesBatchBoundaryRollbackAndPersistsState();
|
||||||
await testRerollRejectsMissingRecoveryPoint();
|
await testRerollRejectsMissingRecoveryPoint();
|
||||||
|
|||||||
Reference in New Issue
Block a user