mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
refactor(ui): extract message-render-limit module, migrate test off index.js slicing
This commit is contained in:
184
index.js
184
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 = "") {
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -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
208
ui/message-render-limit.js
Normal 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}。为避免把截断视图误判为历史删除并清空运行时图谱,已暂停历史恢复。` +
|
||||
"请临时关闭“限制聊天区渲染楼层”或调大渲染数量并刷新后再提取。",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user