From dfcf1ba38e576fe0606cb4bb5eefe2f060f9e187 Mon Sep 17 00:00:00 2001 From: youzini Date: Sun, 31 May 2026 10:41:26 +0000 Subject: [PATCH] refactor(ui): extract message-render-limit module, migrate test off index.js slicing --- index.js | 184 +++++------------------- tests/index-slicing-ratchet.mjs | 1 - tests/message-render-limit.mjs | 245 +++++++++++++++----------------- ui/message-render-limit.js | 208 +++++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 281 deletions(-) create mode 100644 ui/message-render-limit.js diff --git a/index.js b/index.js index 7809c4e..8d3ddfc 100644 --- a/index.js +++ b/index.js @@ -251,6 +251,13 @@ import { estimateTokens, formatInjection } from "./retrieval/injector.js"; import { fetchMemoryLLMModels, testLLMConnection } from "./llm/llm.js"; import { getNodeDisplayName } from "./graph/node-labels.js"; import { showManagedBmeNotice } from "./ui/notice.js"; +import { + applyMessageRenderLimit as applyMessageRenderLimitCore, + getActiveMessageRenderLimitForHistoryGuard as getActiveMessageRenderLimitForHistoryGuardCore, + getHighestTrackedProcessedHistoryFloor as getHighestTrackedProcessedHistoryFloorCore, + getMessageRenderLimitSettings as getMessageRenderLimitSettingsCore, + getRenderLimitedHistoryRecoveryGuard as getRenderLimitedHistoryRecoveryGuardCore, +} from "./ui/message-render-limit.js"; import { createNoticePanelActionController, initializePanelBridgeController, @@ -7820,24 +7827,10 @@ function getMessageHideSettings(settings = null) { } function getMessageRenderLimitSettings(settings = null) { - let sourceSettings = settings; - if (!sourceSettings || typeof sourceSettings !== "object") { - try { - sourceSettings = - typeof getSettings === "function" ? getSettings() : {}; - } catch { - sourceSettings = {}; - } - } - return { - enabled: - sourceSettings.enabled !== false && - Boolean(sourceSettings.hideOldMessagesRenderLimitEnabled), - render_last_n: Math.max( - 0, - Math.trunc(Number(sourceSettings.hideOldMessagesRenderLimit ?? 0) || 0), - ), - }; + return getMessageRenderLimitSettingsCore( + settings, + typeof getSettings === "function" ? getSettings : null, + ); } function getHostPowerUserSettings() { @@ -7854,152 +7847,49 @@ function getHostPowerUserSettings() { } } -function applyMessageRenderLimit(settings = null, options = {}) { - const normalized = getMessageRenderLimitSettings(settings); - const shouldClear = options.clearWhenDisabled === true; - if (!normalized.enabled && !shouldClear) { - return { - active: false, - renderLimit: 0, - applied: false, - skipped: true, - }; - } - - const renderLimit = - normalized.enabled && normalized.render_last_n > 0 - ? normalized.render_last_n - : 0; - let applied = false; - const powerUserSettings = getHostPowerUserSettings(); - if (powerUserSettings && typeof powerUserSettings === "object") { - powerUserSettings.chat_truncation = renderLimit; - applied = true; - } - - try { - const jq = typeof $ === "function" ? $ : null; - if (jq) { - const value = String(renderLimit); - const truncationInput = jq("#chat_truncation"); - if ( - truncationInput && - Number(truncationInput.length || 0) > 0 && - typeof truncationInput.val === "function" - ) { - truncationInput.val(value); - if (typeof truncationInput.trigger === "function") { - truncationInput.trigger("change"); - } - applied = true; - } - const truncationCounter = jq("#chat_truncation_counter"); - if ( - truncationCounter && - Number(truncationCounter.length || 0) > 0 && - typeof truncationCounter.val === "function" - ) { - truncationCounter.val(value); - applied = true; - } - } - } catch (error) { - console.warn("[ST-BME] 同步聊天区渲染楼层限制失败:", error); - } - - if (options.reloadCurrentChat === true) { - try { +function getMessageRenderLimitHostAdapter() { + return { + getPowerUser: getHostPowerUserSettings, + jq: typeof $ === "function" ? $ : null, + reloadCurrentChat: () => { const context = typeof getContext === "function" ? getContext() : null; if (typeof context?.reloadCurrentChat === "function") { context.reloadCurrentChat(); } - } catch (error) { - console.warn("[ST-BME] 重新加载聊天区渲染楼层失败:", error); - } - } - - return { - active: renderLimit > 0, - renderLimit, - applied, - skipped: false, + }, + resolveSettings: typeof getSettings === "function" ? getSettings : null, + console, }; } -function getActiveMessageRenderLimitForHistoryGuard(settings = null) { - const normalized = getMessageRenderLimitSettings(settings); - const configuredLimit = - normalized.enabled && normalized.render_last_n > 0 - ? normalized.render_last_n - : 0; - let hostLimit = 0; - try { - const powerUserSettings = getHostPowerUserSettings(); - hostLimit = Math.max( - 0, - Math.trunc(Number(powerUserSettings?.chat_truncation ?? 0) || 0), - ); - } catch { - hostLimit = 0; - } +function applyMessageRenderLimit(settings = null, options = {}) { + return applyMessageRenderLimitCore( + settings, + options, + getMessageRenderLimitHostAdapter(), + ); +} - if (configuredLimit > 0 && hostLimit > 0) { - return Math.min(configuredLimit, hostLimit); - } - return Math.max(configuredLimit, hostLimit); +function getActiveMessageRenderLimitForHistoryGuard(settings = null) { + return getActiveMessageRenderLimitForHistoryGuardCore( + settings, + getMessageRenderLimitHostAdapter(), + ); } function getHighestTrackedProcessedHistoryFloor(historyState = {}) { - const lastProcessed = Number.isFinite( - Number(historyState?.lastProcessedAssistantFloor), - ) - ? Math.floor(Number(historyState.lastProcessedAssistantFloor)) - : -1; - const hashes = - historyState?.processedMessageHashes && - typeof historyState.processedMessageHashes === "object" && - !Array.isArray(historyState.processedMessageHashes) - ? historyState.processedMessageHashes - : {}; - const maxHashFloor = Object.keys(hashes).reduce((maxFloor, key) => { - const floor = Number.parseInt(key, 10); - return Number.isFinite(floor) ? Math.max(maxFloor, floor) : maxFloor; - }, -1); - - return Math.max(lastProcessed, maxHashFloor); + return getHighestTrackedProcessedHistoryFloorCore(historyState); } function getRenderLimitedHistoryRecoveryGuard( chat, { settings = null, historyState = currentGraph?.historyState } = {}, ) { - const renderLimit = getActiveMessageRenderLimitForHistoryGuard(settings); - if (!Array.isArray(chat) || renderLimit <= 0) { - return { blocked: false }; - } - - const chatLength = chat.length; - const highestProcessedFloor = - getHighestTrackedProcessedHistoryFloor(historyState); - const renderWindowTolerance = renderLimit + 1; - if ( - chatLength > renderWindowTolerance || - highestProcessedFloor < chatLength - ) { - return { blocked: false }; - } - - return { - blocked: true, - chatLength, - highestProcessedFloor, - renderLimit, - reason: "render-limited-chat-slice", - message: - `当前聊天区最多只渲染最近 ${renderLimit} 条消息,当前可见 ${chatLength} 条;` + - `图谱已处理到楼层 ${highestProcessedFloor}。为避免把截断视图误判为历史删除并清空运行时图谱,已暂停历史恢复。` + - "请临时关闭“限制聊天区渲染楼层”或调大渲染数量并刷新后再提取。", - }; + return getRenderLimitedHistoryRecoveryGuardCore(chat, { + settings, + historyState, + host: getMessageRenderLimitHostAdapter(), + }); } function notifyRenderLimitedHistoryRecoveryBlocked(guard, trigger = "") { diff --git a/tests/index-slicing-ratchet.mjs b/tests/index-slicing-ratchet.mjs index 2d40366..a6fee71 100644 --- a/tests/index-slicing-ratchet.mjs +++ b/tests/index-slicing-ratchet.mjs @@ -30,7 +30,6 @@ const ALLOWLIST = Object.freeze({ "tests/graph-persistence.mjs": { maxMarkerCalls: 7, stage: "Phase 5" }, "tests/p0-regressions.mjs": { maxMarkerCalls: 13, stage: "Phase 3" }, "tests/helpers/generation-recall-harness.mjs": { maxMarkerCalls: 3, stage: "Phase 4" }, - "tests/message-render-limit.mjs": { maxMarkerCalls: 4, stage: "Phase 2" }, "tests/index-esm-entry-smoke.mjs": { maxMarkerCalls: 4, stage: "Phase 5" }, }); diff --git a/tests/message-render-limit.mjs b/tests/message-render-limit.mjs index dc17dec..0d1db54 100644 --- a/tests/message-render-limit.mjs +++ b/tests/message-render-limit.mjs @@ -1,131 +1,96 @@ import assert from "node:assert/strict"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -const moduleDir = path.dirname(fileURLToPath(import.meta.url)); -const indexPath = path.resolve(moduleDir, "../index.js"); -const indexSource = await fs.readFile(indexPath, "utf8"); - -function extractSnippet(startMarker, endMarker) { - const start = indexSource.indexOf(startMarker); - const end = indexSource.indexOf(endMarker, start); - if (start < 0 || end < 0 || end <= start) { - throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`); - } - return indexSource.slice(start, end).replace(/^export\s+/gm, ""); -} - -const renderLimitSnippet = extractSnippet( - "function getMessageRenderLimitSettings(", - "function getHideRuntimeAdapters(", -); - -const tempModulePath = path.resolve( - moduleDir, - "../.tmp-message-render-limit.mjs", -); - -await fs.writeFile( - tempModulePath, - ` -let powerUser = { chat_truncation: 0 }; -let reloadCount = 0; -let inputValue = ""; -let counterValue = ""; -let currentGraph = null; -const triggeredEvents = []; - -function getContext() { - return { - power_user: powerUser, - reloadCurrentChat() { - reloadCount += 1; - }, - }; -} - -function makeInput(kind) { - return { - length: 1, - val(value) { - if (arguments.length > 0) { - if (kind === "counter") { - counterValue = value; - } else { - inputValue = value; - } - return this; - } - return kind === "counter" ? counterValue : inputValue; - }, - trigger(eventName) { - triggeredEvents.push(eventName); - return this; - }, - }; -} - -function $(selector) { - if (selector === "#chat_truncation") return makeInput("input"); - if (selector === "#chat_truncation_counter") return makeInput("counter"); - return { length: 0 }; -} - -${renderLimitSnippet} - -function getState() { - return { - counterValue, - inputValue, - powerUserChatTruncation: powerUser.chat_truncation, - reloadCount, - triggeredEvents: [...triggeredEvents], - }; -} - -function setCurrentGraph(graph) { - currentGraph = graph; -} - -export { +import { applyMessageRenderLimit, - getRenderLimitedHistoryRecoveryGuard, getMessageRenderLimitSettings, - getState, - setCurrentGraph, -}; -`, - "utf8", + getRenderLimitedHistoryRecoveryGuard, +} from "../ui/message-render-limit.js"; + +// Builds a fake host adapter mirroring index.js getMessageRenderLimitHostAdapter, +// so we test the real extracted module by import (no index.js slicing). +function createHostHarness() { + const state = { + powerUser: { chat_truncation: 0 }, + reloadCount: 0, + inputValue: "", + counterValue: "", + triggeredEvents: [], + }; + + function makeInput(kind) { + return { + length: 1, + val(value) { + if (arguments.length > 0) { + if (kind === "counter") state.counterValue = value; + else state.inputValue = value; + return this; + } + return kind === "counter" ? state.counterValue : state.inputValue; + }, + trigger(eventName) { + state.triggeredEvents.push(eventName); + return this; + }, + }; + } + + const host = { + getPowerUser() { + return state.powerUser; + }, + jq(selector) { + if (selector === "#chat_truncation") return makeInput("input"); + if (selector === "#chat_truncation_counter") return makeInput("counter"); + return { length: 0 }; + }, + reloadCurrentChat() { + state.reloadCount += 1; + }, + console, + }; + + return { host, state }; +} + +function getState(state) { + return { + counterValue: state.counterValue, + inputValue: state.inputValue, + powerUserChatTruncation: state.powerUser.chat_truncation, + reloadCount: state.reloadCount, + triggeredEvents: [...state.triggeredEvents], + }; +} + +// ── normalization ──────────────────────────────────────────────── +assert.deepEqual( + getMessageRenderLimitSettings({ + enabled: true, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: "24", + }), + { enabled: true, render_last_n: 24 }, +); +assert.deepEqual( + getMessageRenderLimitSettings({ + enabled: false, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: 24, + }), + { enabled: false, render_last_n: 24 }, ); -try { - const module = await import(`${pathToFileURL(tempModulePath).href}?t=${Date.now()}`); - - assert.deepEqual( - module.getMessageRenderLimitSettings({ - enabled: true, - hideOldMessagesRenderLimitEnabled: true, - hideOldMessagesRenderLimit: "24", - }), - { enabled: true, render_last_n: 24 }, - ); - assert.deepEqual( - module.getMessageRenderLimitSettings({ - enabled: false, - hideOldMessagesRenderLimitEnabled: true, - hideOldMessagesRenderLimit: 24, - }), - { enabled: false, render_last_n: 24 }, - ); - - const applied = module.applyMessageRenderLimit( +// ── apply (active) ─────────────────────────────────────────────── +{ + const { host, state } = createHostHarness(); + const applied = applyMessageRenderLimit( { enabled: true, hideOldMessagesRenderLimitEnabled: true, hideOldMessagesRenderLimit: 24, }, { reloadCurrentChat: true }, + host, ); assert.deepEqual(applied, { active: true, @@ -133,14 +98,18 @@ try { applied: true, skipped: false, }); - assert.deepEqual(module.getState(), { + assert.deepEqual(getState(state), { counterValue: "24", inputValue: "24", powerUserChatTruncation: 24, reloadCount: 1, triggeredEvents: ["change"], }); - const guarded = module.getRenderLimitedHistoryRecoveryGuard( +} + +// ── history recovery guard ─────────────────────────────────────── +{ + const guarded = getRenderLimitedHistoryRecoveryGuard( new Array(10).fill({ mes: "visible" }), { settings: { @@ -159,7 +128,7 @@ try { assert.equal(guarded.highestProcessedFloor, 30); const notGuardedWhenFullerThanRenderWindow = - module.getRenderLimitedHistoryRecoveryGuard(new Array(20).fill({}), { + getRenderLimitedHistoryRecoveryGuard(new Array(20).fill({}), { settings: { enabled: true, hideOldMessagesRenderLimitEnabled: true, @@ -173,7 +142,7 @@ try { assert.equal(notGuardedWhenFullerThanRenderWindow.blocked, false); const notGuardedWhenHistoryFitsVisibleChat = - module.getRenderLimitedHistoryRecoveryGuard(new Array(10).fill({}), { + getRenderLimitedHistoryRecoveryGuard(new Array(10).fill({}), { settings: { enabled: true, hideOldMessagesRenderLimitEnabled: true, @@ -185,22 +154,32 @@ try { }, }); assert.equal(notGuardedWhenHistoryFitsVisibleChat.blocked, false); +} - const skipped = module.applyMessageRenderLimit({ - enabled: true, - hideOldMessagesRenderLimitEnabled: false, - hideOldMessagesRenderLimit: 24, - }); +// ── apply (skipped vs cleared) ─────────────────────────────────── +{ + const { host, state } = createHostHarness(); + state.powerUser.chat_truncation = 24; + const skipped = applyMessageRenderLimit( + { + enabled: true, + hideOldMessagesRenderLimitEnabled: false, + hideOldMessagesRenderLimit: 24, + }, + {}, + host, + ); assert.equal(skipped.skipped, true); - assert.equal(module.getState().powerUserChatTruncation, 24); + assert.equal(getState(state).powerUserChatTruncation, 24); - const cleared = module.applyMessageRenderLimit( + const cleared = applyMessageRenderLimit( { enabled: true, hideOldMessagesRenderLimitEnabled: false, hideOldMessagesRenderLimit: 24, }, { clearWhenDisabled: true, reloadCurrentChat: true }, + host, ); assert.deepEqual(cleared, { active: false, @@ -208,13 +187,13 @@ try { applied: true, skipped: false, }); - assert.deepEqual(module.getState(), { + assert.deepEqual(getState(state), { counterValue: "0", inputValue: "0", powerUserChatTruncation: 0, - reloadCount: 2, - triggeredEvents: ["change", "change"], + reloadCount: 1, + triggeredEvents: ["change"], }); -} finally { - await fs.unlink(tempModulePath).catch(() => {}); } + +console.log("message-render-limit tests passed"); diff --git a/ui/message-render-limit.js b/ui/message-render-limit.js new file mode 100644 index 0000000..be21f39 --- /dev/null +++ b/ui/message-render-limit.js @@ -0,0 +1,208 @@ +// ST-BME message render-limit policy. +// +// Extracted from index.js so it can be unit-tested by direct import instead of +// slicing index.js into a temp module. Pure decisions take plain arguments; +// the one side-effecting entry (applyMessageRenderLimit) receives an explicit +// host adapter, so this module owns no module-level mutable state and never +// reaches for globals on its own. + +/** + * Normalizes render-limit settings into {enabled, render_last_n}. + * @param {object|null} settings + * @param {() => object} [resolveSettings] fallback settings source when none passed + */ +export function getMessageRenderLimitSettings(settings = null, resolveSettings = null) { + let sourceSettings = settings; + if (!sourceSettings || typeof sourceSettings !== "object") { + try { + sourceSettings = + typeof resolveSettings === "function" ? resolveSettings() : {}; + } catch { + sourceSettings = {}; + } + } + return { + enabled: + sourceSettings.enabled !== false && + Boolean(sourceSettings.hideOldMessagesRenderLimitEnabled), + render_last_n: Math.max( + 0, + Math.trunc(Number(sourceSettings.hideOldMessagesRenderLimit ?? 0) || 0), + ), + }; +} + +/** + * Applies the render limit to the host (power_user.chat_truncation + jQuery + * truncation inputs), optionally reloading the chat. + * + * @param {object|null} settings + * @param {object} [options] {clearWhenDisabled, reloadCurrentChat} + * @param {object} [host] injected host adapter + * @param {() => object|null} [host.getPowerUser] + * @param {(selector: string) => any} [host.jq] jQuery-like selector + * @param {() => void} [host.reloadCurrentChat] + * @param {() => object|null} [host.resolveSettings] + * @param {Console} [host.console] + */ +export function applyMessageRenderLimit(settings = null, options = {}, host = {}) { + const logger = host.console || console; + const normalized = getMessageRenderLimitSettings(settings, host.resolveSettings); + const shouldClear = options.clearWhenDisabled === true; + if (!normalized.enabled && !shouldClear) { + return { + active: false, + renderLimit: 0, + applied: false, + skipped: true, + }; + } + + const renderLimit = + normalized.enabled && normalized.render_last_n > 0 + ? normalized.render_last_n + : 0; + let applied = false; + const powerUserSettings = + typeof host.getPowerUser === "function" ? host.getPowerUser() : null; + if (powerUserSettings && typeof powerUserSettings === "object") { + powerUserSettings.chat_truncation = renderLimit; + applied = true; + } + + try { + const jq = typeof host.jq === "function" ? host.jq : null; + if (jq) { + const value = String(renderLimit); + const truncationInput = jq("#chat_truncation"); + if ( + truncationInput && + Number(truncationInput.length || 0) > 0 && + typeof truncationInput.val === "function" + ) { + truncationInput.val(value); + if (typeof truncationInput.trigger === "function") { + truncationInput.trigger("change"); + } + applied = true; + } + const truncationCounter = jq("#chat_truncation_counter"); + if ( + truncationCounter && + Number(truncationCounter.length || 0) > 0 && + typeof truncationCounter.val === "function" + ) { + truncationCounter.val(value); + applied = true; + } + } + } catch (error) { + logger.warn("[ST-BME] 同步聊天区渲染楼层限制失败:", error); + } + + if (options.reloadCurrentChat === true) { + try { + if (typeof host.reloadCurrentChat === "function") { + host.reloadCurrentChat(); + } + } catch (error) { + logger.warn("[ST-BME] 重新加载聊天区渲染楼层失败:", error); + } + } + + return { + active: renderLimit > 0, + renderLimit, + applied, + skipped: false, + }; +} + +/** + * Returns the effective render limit used for the history-recovery guard, + * combining configured settings with the host power_user truncation. + */ +export function getActiveMessageRenderLimitForHistoryGuard( + settings = null, + host = {}, +) { + const normalized = getMessageRenderLimitSettings(settings, host.resolveSettings); + const configuredLimit = + normalized.enabled && normalized.render_last_n > 0 + ? normalized.render_last_n + : 0; + let hostLimit = 0; + try { + const powerUserSettings = + typeof host.getPowerUser === "function" ? host.getPowerUser() : null; + hostLimit = Math.max( + 0, + Math.trunc(Number(powerUserSettings?.chat_truncation ?? 0) || 0), + ); + } catch { + hostLimit = 0; + } + + if (configuredLimit > 0 && hostLimit > 0) { + return Math.min(configuredLimit, hostLimit); + } + return Math.max(configuredLimit, hostLimit); +} + +/** Highest floor index tracked in processed-history state. Pure. */ +export function getHighestTrackedProcessedHistoryFloor(historyState = {}) { + const lastProcessed = Number.isFinite( + Number(historyState?.lastProcessedAssistantFloor), + ) + ? Math.floor(Number(historyState.lastProcessedAssistantFloor)) + : -1; + const hashes = + historyState?.processedMessageHashes && + typeof historyState.processedMessageHashes === "object" && + !Array.isArray(historyState.processedMessageHashes) + ? historyState.processedMessageHashes + : {}; + const maxHashFloor = Object.keys(hashes).reduce((maxFloor, key) => { + const floor = Number.parseInt(key, 10); + return Number.isFinite(floor) ? Math.max(maxFloor, floor) : maxFloor; + }, -1); + + return Math.max(lastProcessed, maxHashFloor); +} + +/** + * Decides whether history recovery must be blocked because the chat view is + * render-limited (a truncated view must not be mistaken for deleted history). + * + * @param {Array} chat + * @param {object} [opts] {settings, historyState, host} + */ +export function getRenderLimitedHistoryRecoveryGuard( + chat, + { settings = null, historyState = {}, host = {} } = {}, +) { + const renderLimit = getActiveMessageRenderLimitForHistoryGuard(settings, host); + if (!Array.isArray(chat) || renderLimit <= 0) { + return { blocked: false }; + } + + const chatLength = chat.length; + const highestProcessedFloor = + getHighestTrackedProcessedHistoryFloor(historyState); + const renderWindowTolerance = renderLimit + 1; + if (chatLength > renderWindowTolerance || highestProcessedFloor < chatLength) { + return { blocked: false }; + } + + return { + blocked: true, + chatLength, + highestProcessedFloor, + renderLimit, + reason: "render-limited-chat-slice", + message: + `当前聊天区最多只渲染最近 ${renderLimit} 条消息,当前可见 ${chatLength} 条;` + + `图谱已处理到楼层 ${highestProcessedFloor}。为避免把截断视图误判为历史删除并清空运行时图谱,已暂停历史恢复。` + + "请临时关闭“限制聊天区渲染楼层”或调大渲染数量并刷新后再提取。", + }; +}