refactor(ui): extract message-render-limit module, migrate test off index.js slicing

This commit is contained in:
youzini
2026-05-31 10:41:26 +00:00
parent f0e3adc99b
commit dfcf1ba38e
4 changed files with 357 additions and 281 deletions

184
index.js
View File

@@ -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 = "") {

View File

@@ -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" },
});

View File

@@ -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");

208
ui/message-render-limit.js Normal file
View File

@@ -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}。为避免把截断视图误判为历史删除并清空运行时图谱,已暂停历史恢复。` +
"请临时关闭“限制聊天区渲染楼层”或调大渲染数量并刷新后再提取。",
};
}