diff --git a/index.js b/index.js index de015db..778ec51 100644 --- a/index.js +++ b/index.js @@ -4953,11 +4953,119 @@ 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), + ), + }; +} + +function getHostPowerUserSettings() { + try { + const context = typeof getContext === "function" ? getContext() : null; + return ( + context?.power_user || + context?.powerUserSettings || + globalThis.power_user || + null + ); + } catch { + return globalThis.power_user || null; + } +} + +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 { + 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, + }; +} + function getHideRuntimeAdapters() { return { $, clearTimeout, getContext, + refreshPanelLiveState, setTimeout, }; } @@ -4969,6 +5077,7 @@ async function applyMessageHideNow(reason = "manual-apply") { getHideRuntimeAdapters(), ); debugLog("[ST-BME] 已应用旧楼层隐藏:", reason, result); + refreshPanelLiveState(); return result; } catch (error) { console.warn("[ST-BME] 应用旧楼层隐藏失败:", reason, error); @@ -5000,6 +5109,7 @@ async function runIncrementalMessageHide(reason = "incremental") { if (result?.active) { debugLog("[ST-BME] 已增量更新旧楼层隐藏:", reason, result); } + refreshPanelLiveState(); return result; } catch (error) { console.warn("[ST-BME] 增量更新旧楼层隐藏失败:", reason, error); @@ -5014,6 +5124,7 @@ function clearMessageHideState(reason = "reset") { try { resetHideState(getHideRuntimeAdapters()); debugLog("[ST-BME] 已重置旧楼层隐藏状态:", reason); + refreshPanelLiveState(); } catch (error) { console.warn("[ST-BME] 重置旧楼层隐藏状态失败:", reason, error); } @@ -5023,6 +5134,7 @@ async function clearAllHiddenMessages(reason = "manual-clear") { try { const result = await unhideAll(getHideRuntimeAdapters()); debugLog("[ST-BME] 已取消全部旧楼层隐藏:", reason, result); + refreshPanelLiveState(); return result; } catch (error) { console.warn("[ST-BME] 取消全部旧楼层隐藏失败:", reason, error); @@ -12368,6 +12480,10 @@ function refreshPanelLiveState() { }); } +function getMessageHideStateSnapshotForPanel() { + return getHideStateSnapshot(); +} + function notifyStatusToast(key, kind, message, title = "ST-BME") { const now = Date.now(); if (now - (lastStatusToastAt[key] || 0) < STATUS_TOAST_THROTTLE_MS) return; @@ -13080,6 +13196,11 @@ function updateModuleSettings(patch = {}) { "hideOldMessagesEnabled", "hideOldMessagesKeepLastN", ]); + const messageRenderLimitKeys = new Set([ + "enabled", + "hideOldMessagesRenderLimitEnabled", + "hideOldMessagesRenderLimit", + ]); const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]); const noticeUiKeys = new Set(["noticeDisplayMode"]); const settings = getSettings(); @@ -13147,6 +13268,14 @@ function updateModuleSettings(patch = {}) { } } + if (Object.keys(patch).some((key) => messageRenderLimitKeys.has(key))) { + const renderResult = applyMessageRenderLimit(settings, { + clearWhenDisabled: true, + reloadCurrentChat: true, + }); + debugLog("[ST-BME] 已同步聊天区渲染楼层限制:", renderResult); + } + if (Object.keys(patch).some((key) => recallUiKeys.has(key))) { schedulePersistedRecallMessageUiRefresh(30); } @@ -19834,6 +19963,7 @@ async function onCompactLukerSidecar() { document, getGraph: () => currentGraph, getGraphPersistenceState: () => getGraphPersistenceLiveState(), + getHideStateSnapshot: () => getMessageHideStateSnapshotForPanel(), getLastBatchStatus: () => currentGraph?.historyState?.lastBatchStatus || null, getLastExtract: () => lastExtractedItems, @@ -19864,6 +19994,7 @@ async function onCompactLukerSidecar() { scheduleBmeIndexedDbWarmup("init"); initializeHostCapabilityBridge(); installSendIntentHooks(); + applyMessageRenderLimit(getSettings()); autoSyncOnVisibility(buildBmeSyncRuntimeOptions()); scheduleMessageHideApply("init", 180); diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index 0fd41f6..7b14539 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -19,6 +19,8 @@ export const defaultSettings = { timeoutMs: 300000, hideOldMessagesEnabled: false, hideOldMessagesKeepLastN: 12, + hideOldMessagesRenderLimitEnabled: false, + hideOldMessagesRenderLimit: 0, // 提取设置 extractEvery: 1, diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index 4560502..c1c78dd 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -8,6 +8,8 @@ import { assert.equal(defaultSettings.extractContextTurns, 2); assert.equal(defaultSettings.extractActionMode, "pending"); assert.equal(defaultSettings.extractAutoDelayLatestAssistant, false); +assert.equal(defaultSettings.hideOldMessagesRenderLimitEnabled, false); +assert.equal(defaultSettings.hideOldMessagesRenderLimit, 0); assert.equal(defaultSettings.recallTopK, 20); assert.equal(defaultSettings.recallMaxNodes, 12); assert.equal(defaultSettings.recallEnableVectorPrefilter, true); diff --git a/tests/hide-engine.mjs b/tests/hide-engine.mjs index 229f6c5..ea93701 100644 --- a/tests/hide-engine.mjs +++ b/tests/hide-engine.mjs @@ -5,6 +5,7 @@ import { getHideStateSnapshot, resetHideState, runIncrementalHideCheck, + scheduleHideSettingsApply, unhideAll, } from "../ui/hide-engine.js"; @@ -174,10 +175,45 @@ async function testUnhideAllRecoversPersistedManagedMarkersAfterStateLoss() { assert.equal(chat[1].extra, undefined); } +async function testScheduledApplyRefreshesPanelState() { + resetHideState(); + const chat = [ + { mes: "user-1", is_user: true, is_system: false }, + { mes: "assistant-1", is_user: false, is_system: false }, + { mes: "user-2", is_user: true, is_system: false }, + ]; + const runtime = createRuntime(chat); + const timers = []; + let refreshCount = 0; + runtime.setTimeout = (callback, delayMs) => { + timers.push({ callback, delayMs }); + return timers.length; + }; + runtime.clearTimeout = () => {}; + runtime.refreshPanelLiveState = () => { + refreshCount += 1; + }; + + scheduleHideSettingsApply({ enabled: true, hide_last_n: 1 }, runtime, 25); + + assert.equal(refreshCount, 1); + assert.equal(getHideStateSnapshot().scheduled, true); + assert.equal(timers.length, 1); + assert.equal(timers[0].delayMs, 25); + + timers[0].callback(); + await new Promise((resolve) => globalThis.setTimeout(resolve, 0)); + + assert.equal(getHideStateSnapshot().scheduled, false); + assert.equal(getHideStateSnapshot().managedHiddenCount, 2); + assert.equal(refreshCount, 2); +} + await testApplyUsesNativeHide(); await testDisableUnhidesManagedRange(); await testIncrementalOnlyHidesOverflowDelta(); await testResetClearsStateWithoutIssuingCommands(); await testUnhideAllRecoversPersistedManagedMarkersAfterStateLoss(); +await testScheduledApplyRefreshesPanelState(); console.log("hide-engine tests passed"); diff --git a/tests/message-render-limit.mjs b/tests/message-render-limit.mjs new file mode 100644 index 0000000..cde5448 --- /dev/null +++ b/tests/message-render-limit.mjs @@ -0,0 +1,168 @@ +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 = ""; +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], + }; +} + +export { + applyMessageRenderLimit, + getMessageRenderLimitSettings, + getState, +}; +`, + "utf8", +); + +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( + { + enabled: true, + hideOldMessagesRenderLimitEnabled: true, + hideOldMessagesRenderLimit: 24, + }, + { reloadCurrentChat: true }, + ); + assert.deepEqual(applied, { + active: true, + renderLimit: 24, + applied: true, + skipped: false, + }); + assert.deepEqual(module.getState(), { + counterValue: "24", + inputValue: "24", + powerUserChatTruncation: 24, + reloadCount: 1, + triggeredEvents: ["change"], + }); + + const skipped = module.applyMessageRenderLimit({ + enabled: true, + hideOldMessagesRenderLimitEnabled: false, + hideOldMessagesRenderLimit: 24, + }); + assert.equal(skipped.skipped, true); + assert.equal(module.getState().powerUserChatTruncation, 24); + + const cleared = module.applyMessageRenderLimit( + { + enabled: true, + hideOldMessagesRenderLimitEnabled: false, + hideOldMessagesRenderLimit: 24, + }, + { clearWhenDisabled: true, reloadCurrentChat: true }, + ); + assert.deepEqual(cleared, { + active: false, + renderLimit: 0, + applied: true, + skipped: false, + }); + assert.deepEqual(module.getState(), { + counterValue: "0", + inputValue: "0", + powerUserChatTruncation: 0, + reloadCount: 2, + triggeredEvents: ["change", "change"], + }); +} finally { + await fs.unlink(tempModulePath).catch(() => {}); +} diff --git a/ui/hide-engine.js b/ui/hide-engine.js index 97292f4..1ef9fd8 100644 --- a/ui/hide-engine.js +++ b/ui/hide-engine.js @@ -34,6 +34,13 @@ function getTimerApi(runtime = {}) { }; } +function notifyRuntimeHideStateChanged(runtime = {}) { + try { + runtime.refreshPanelLiveState?.(); + } catch { + } +} + function getCurrentContext(runtime = {}) { try { return typeof runtime.getContext === "function" ? runtime.getContext() : null; @@ -487,10 +494,15 @@ export function scheduleHideSettingsApply( const snapshot = normalizeHideSettings(settings); hideState.scheduledTimer = timers.setTimeout(() => { hideState.scheduledTimer = null; - void applyHideSettings(snapshot, runtime).catch((error) => { - console.warn?.("[ST-BME] scheduled hide apply failed", error); - }); + void applyHideSettings(snapshot, runtime) + .then(() => { + notifyRuntimeHideStateChanged(runtime); + }) + .catch((error) => { + console.warn?.("[ST-BME] scheduled hide apply failed", error); + }); }, Math.max(0, Math.trunc(Number(delayMs) || 0))); + notifyRuntimeHideStateChanged(runtime); } export async function unhideAll(runtime = {}) { diff --git a/ui/panel-bridge.js b/ui/panel-bridge.js index 92b886f..0d73b4f 100644 --- a/ui/panel-bridge.js +++ b/ui/panel-bridge.js @@ -151,6 +151,7 @@ async function ensurePanelBridgeReady(runtime) { getLastInjection: runtime.getLastInjection, getRuntimeDebugSnapshot: runtime.getRuntimeDebugSnapshot, getGraphPersistenceState: runtime.getGraphPersistenceState, + getHideStateSnapshot: runtime.getHideStateSnapshot, updateSettings: (patch) => { const nextSettings = runtime.updateSettings(patch); if (Object.prototype.hasOwnProperty.call(patch || {}, "panelTheme")) { diff --git a/ui/panel.html b/ui/panel.html index 1633e7f..309ce67 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -1366,6 +1366,41 @@