From 28049d89bc0c2fea96d7b3bca7d35d814b13c653 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Sun, 5 Apr 2026 19:11:15 +0800 Subject: [PATCH] Fix manual maintenance action execution feedback --- index.js | 2 + tests/p0-regressions.mjs | 259 ++++++++++++++++++++++++++++++++++++++ ui-actions-controller.js | 260 ++++++++++++++++++++++++++++++++------- 3 files changed, 479 insertions(+), 42 deletions(-) diff --git a/index.js b/index.js index ff1c4eb..ee60b04 100644 --- a/index.js +++ b/index.js @@ -9347,6 +9347,7 @@ async function onManualCompress() { getEmbeddingConfig, getSchema, getSettings, + inspectCompressionCandidates: inspectAutoCompressionCandidates, recordMaintenanceAction, recordGraphMutation, toastr, @@ -9531,6 +9532,7 @@ async function onManualEvolve() { recordMaintenanceAction, recordGraphMutation, toastr, + validateVectorConfig, }); } diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index f5ba000..5b67ec7 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -61,6 +61,11 @@ import { setBatchStageOutcome, shouldRunRecallForTransaction, } from "../ui-status.js"; +import { + onManualCompressController, + onManualEvolveController, + onManualSleepController, +} from "../ui-actions-controller.js"; const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); const extensionsShimSource = [ @@ -5276,6 +5281,255 @@ async function testLlmOutputRegexCleansResponseBeforeJsonParse() { } } +async function testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan() { + const calls = { + compressAll: 0, + recordGraphMutation: 0, + recordMaintenanceAction: 0, + }; + const toastMessages = []; + const graph = { nodes: [], historyState: {} }; + + const result = await onManualCompressController({ + getCurrentGraph: () => graph, + ensureGraphMutationReady: () => true, + getSchema: () => [], + inspectCompressionCandidates: () => ({ + hasCandidates: false, + reason: "当前没有可压缩候选组,本次未发起 LLM 压缩", + }), + cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)), + compressAll: async () => { + calls.compressAll += 1; + return { created: 1, archived: 1 }; + }, + getEmbeddingConfig: () => ({}), + getSettings: () => ({}), + recordMaintenanceAction() { + calls.recordMaintenanceAction += 1; + }, + recordGraphMutation: async () => { + calls.recordGraphMutation += 1; + }, + toastr: { + info(message) { + toastMessages.push(["info", message]); + }, + success(message) { + toastMessages.push(["success", message]); + }, + }, + }); + + assert.equal(calls.compressAll, 0); + assert.equal(calls.recordMaintenanceAction, 0); + assert.equal(calls.recordGraphMutation, 0); + assert.equal(result?.handledToast, true); + assert.equal(result?.requestDispatched, false); + assert.match(String(toastMessages[0]?.[1] || ""), /未发起 LLM 压缩/); +} + +async function testManualCompressUsesForcedCompressionAndPersistsRealMutation() { + const calls = { + forceFlag: null, + recordGraphMutation: 0, + recordMaintenanceAction: 0, + }; + const graph = { nodes: [], historyState: {} }; + + const result = await onManualCompressController({ + getCurrentGraph: () => graph, + ensureGraphMutationReady: () => true, + getSchema: () => [{ id: "event", compression: { mode: "hierarchical" } }], + inspectCompressionCandidates: () => ({ + hasCandidates: true, + reason: "", + }), + cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)), + compressAll: async (_graph, _schema, _embeddingConfig, force) => { + calls.forceFlag = force; + return { created: 1, archived: 2 }; + }, + getEmbeddingConfig: () => ({}), + getSettings: () => ({}), + recordMaintenanceAction() { + calls.recordMaintenanceAction += 1; + }, + recordGraphMutation: async () => { + calls.recordGraphMutation += 1; + }, + buildMaintenanceSummary: () => "手动压缩", + toastr: { + info() {}, + success() {}, + }, + }); + + assert.equal(calls.forceFlag, true); + assert.equal(calls.recordMaintenanceAction, 1); + assert.equal(calls.recordGraphMutation, 1); + assert.equal(result?.handledToast, true); + assert.equal(result?.requestDispatched, true); + assert.equal(result?.mutated, true); +} + +async function testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh() { + const graph = { + nodes: [ + { + id: "evt-1", + type: "event", + archived: false, + level: 0, + }, + ], + historyState: { + extractionCount: 3, + }, + batchJournal: [ + { + stateBefore: { + extractionCount: 3, + }, + createdNodeIds: ["compression-1"], + }, + { + stateBefore: { + extractionCount: 2, + }, + createdNodeIds: ["evt-1"], + }, + ], + }; + let receivedCandidateIds = null; + let recordGraphMutationCalls = 0; + const toastMessages = []; + + const result = await onManualEvolveController({ + getCurrentGraph: () => graph, + ensureGraphMutationReady: () => true, + getEmbeddingConfig: () => ({ mode: "direct" }), + validateVectorConfig: () => ({ valid: true }), + getLastExtractedItems: () => [], + cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)), + getSettings: () => ({ + consolidationNeighborCount: 5, + consolidationThreshold: 0.85, + }), + consolidateMemories: async ({ newNodeIds }) => { + receivedCandidateIds = [...newNodeIds]; + return { + merged: 0, + skipped: 0, + kept: 1, + evolved: 0, + connections: 0, + updates: 0, + }; + }, + recordMaintenanceAction() { + throw new Error("keep-only 结果不应写入维护账本"); + }, + recordGraphMutation: async () => { + recordGraphMutationCalls += 1; + }, + toastr: { + info(message) { + toastMessages.push(["info", message]); + }, + success(message) { + toastMessages.push(["success", message]); + }, + warning(message) { + toastMessages.push(["warning", message]); + }, + }, + }); + + assert.deepEqual(receivedCandidateIds, ["evt-1"]); + assert.equal(recordGraphMutationCalls, 0); + assert.equal(result?.handledToast, true); + assert.equal(result?.requestDispatched, true); + assert.equal(result?.mutated, false); + assert.match(String(toastMessages[0]?.[1] || ""), /最近一批提取落盘/); +} + +async function testManualEvolveWarnsOnInvalidVectorConfigInsteadOfPretendingComplete() { + let consolidateCalls = 0; + const toastMessages = []; + + const result = await onManualEvolveController({ + getCurrentGraph: () => ({ + nodes: [{ id: "evt-2", type: "event", archived: false, level: 0 }], + historyState: { extractionCount: 1 }, + batchJournal: [], + }), + ensureGraphMutationReady: () => true, + getEmbeddingConfig: () => ({ mode: "direct" }), + validateVectorConfig: () => ({ + valid: false, + error: "Embedding 配置无效", + }), + getLastExtractedItems: () => [{ id: "evt-2" }], + consolidateMemories: async () => { + consolidateCalls += 1; + return { + merged: 1, + skipped: 0, + kept: 0, + evolved: 0, + connections: 0, + updates: 0, + }; + }, + toastr: { + warning(message) { + toastMessages.push(["warning", message]); + }, + info(message) { + toastMessages.push(["info", message]); + }, + }, + }); + + assert.equal(consolidateCalls, 0); + assert.equal(result?.handledToast, true); + assert.equal(result?.requestDispatched, false); + assert.match(String(toastMessages[0]?.[1] || ""), /配置无效/); +} + +async function testManualSleepExplainsThatItIsLocalOnlyWhenNothingChanges() { + let recordGraphMutationCalls = 0; + const toastMessages = []; + + const result = await onManualSleepController({ + getCurrentGraph: () => ({ nodes: [] }), + ensureGraphMutationReady: () => true, + cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)), + sleepCycle: () => ({ forgotten: 0 }), + getSettings: () => ({ forgetThreshold: 0.5 }), + recordMaintenanceAction() { + throw new Error("无归档时不应写入维护账本"); + }, + recordGraphMutation: async () => { + recordGraphMutationCalls += 1; + }, + toastr: { + info(message) { + toastMessages.push(["info", message]); + }, + success(message) { + toastMessages.push(["success", message]); + }, + }, + }); + + assert.equal(recordGraphMutationCalls, 0); + assert.equal(result?.handledToast, true); + assert.equal(result?.requestDispatched, false); + assert.match(String(toastMessages[0]?.[1] || ""), /不会发送 LLM 请求/); +} + await testCompressorMigratesEdgesToCompressedNode(); await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure(); await testExtractorFailsOnUnknownOperation(); @@ -5358,5 +5612,10 @@ await testRerollPreservesPrefixHashesWhenReextractDoesNotAdvance(); await testLlmDebugSnapshotRedactsSecretsBeforeStorage(); await testEmbeddingUsesConfigTimeoutInsteadOfDefault(); await testLlmOutputRegexCleansResponseBeforeJsonParse(); +await testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan(); +await testManualCompressUsesForcedCompressionAndPersistsRealMutation(); +await testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh(); +await testManualEvolveWarnsOnInvalidVectorConfigInsteadOfPretendingComplete(); +await testManualSleepExplainsThatItIsLocalOnlyWhenNothingChanges(); console.log("p0-regressions tests passed"); diff --git a/ui-actions-controller.js b/ui-actions-controller.js index 290b78b..6b8d04a 100644 --- a/ui-actions-controller.js +++ b/ui-actions-controller.js @@ -18,6 +18,107 @@ function getTimerApi(runtime = {}) { }; } +function hasCompressionMutation(result = {}) { + return ( + Math.max(0, Number(result?.created) || 0) > 0 || + Math.max(0, Number(result?.archived) || 0) > 0 + ); +} + +function hasSleepMutation(result = {}) { + return Math.max(0, Number(result?.forgotten) || 0) > 0; +} + +function hasConsolidationMutation(result = {}) { + return ( + Math.max(0, Number(result?.merged) || 0) > 0 || + Math.max(0, Number(result?.skipped) || 0) > 0 || + Math.max(0, Number(result?.evolved) || 0) > 0 || + Math.max(0, Number(result?.connections) || 0) > 0 || + Math.max(0, Number(result?.updates) || 0) > 0 + ); +} + +function findGraphNode(graph, nodeId) { + if (!graph || !Array.isArray(graph.nodes)) return null; + return graph.nodes.find((node) => node?.id === nodeId) || null; +} + +function isManualEvolutionCandidateNode(node) { + if (!node || node.archived) return false; + if (Number(node.level || 0) > 0) return false; + return !["synopsis", "reflection"].includes(String(node.type || "")); +} + +function normalizeManualEvolutionCandidateIds(graph, nodeIds = []) { + const unique = new Set(); + for (const rawId of Array.isArray(nodeIds) ? nodeIds : []) { + const nodeId = String(rawId || "").trim(); + if (!nodeId || unique.has(nodeId)) continue; + const node = findGraphNode(graph, nodeId); + if (!isManualEvolutionCandidateNode(node)) continue; + unique.add(nodeId); + } + return [...unique]; +} + +function resolveManualEvolutionCandidates(runtime, graph) { + const liveRecentIds = normalizeManualEvolutionCandidateIds( + graph, + runtime.getLastExtractedItems?.() + ?.map((item) => item?.id) + .filter(Boolean) || [], + ); + if (liveRecentIds.length > 0) { + return { + ids: liveRecentIds, + source: "recent-extract", + }; + } + + const currentExtractionCount = Math.max( + 0, + Number(graph?.historyState?.extractionCount) || 0, + ); + const batchJournal = Array.isArray(graph?.batchJournal) ? graph.batchJournal : []; + for (let index = batchJournal.length - 1; index >= 0; index -= 1) { + const entry = batchJournal[index]; + const beforeExtractionCount = Math.max( + 0, + Number(entry?.stateBefore?.extractionCount) || 0, + ); + if (beforeExtractionCount >= currentExtractionCount) { + continue; + } + const fallbackIds = normalizeManualEvolutionCandidateIds( + graph, + entry?.createdNodeIds || [], + ); + if (fallbackIds.length > 0) { + return { + ids: fallbackIds, + source: "latest-extraction-batch", + }; + } + } + + return { + ids: [], + source: "none", + }; +} + +function describeManualEvolutionSource(source, count) { + switch (String(source || "")) { + case "recent-extract": + return `使用最近提取的 ${count} 个节点`; + case "latest-extraction-batch": + return `使用最近一批提取落盘的 ${count} 个节点`; + default: + return `候选节点 ${count} 个`; + } +} + export async function onViewGraphController(runtime) { const graph = runtime.getCurrentGraph(); if (!graph) { @@ -111,28 +212,55 @@ export async function onManualCompressController(runtime) { if (!graph) return; if (!runtime.ensureGraphMutationReady("手动压缩")) return; + const schema = runtime.getSchema(); + const inspection = runtime.inspectCompressionCandidates?.(graph, schema, true); + if (inspection && !inspection.hasCandidates) { + runtime.toastr.info( + String(inspection.reason || "当前没有可压缩候选组,本次未发起 LLM 压缩"), + ); + return { + handledToast: true, + requestDispatched: false, + mutated: false, + reason: String(inspection.reason || ""), + }; + } + const beforeSnapshot = runtime.cloneGraphSnapshot(graph); const result = await runtime.compressAll( graph, - runtime.getSchema(), + schema, runtime.getEmbeddingConfig(), - false, + true, undefined, undefined, runtime.getSettings(), ); - runtime.recordMaintenanceAction?.({ - action: "compress", - beforeSnapshot, - mode: "manual", - summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"), - }); - await runtime.recordGraphMutation({ - beforeSnapshot, - artifactTags: ["compression"], - }); + const mutated = hasCompressionMutation(result); + if (mutated) { + runtime.recordMaintenanceAction?.({ + action: "compress", + beforeSnapshot, + mode: "manual", + summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"), + }); + await runtime.recordGraphMutation({ + beforeSnapshot, + artifactTags: ["compression"], + }); + runtime.toastr.success( + `手动压缩完成:新建 ${result.created},归档 ${result.archived}`, + ); + } else { + runtime.toastr.info("已尝试手动压缩,但本轮没有产生可持久化变化"); + } - runtime.toastr.info(`压缩完成: 新建 ${result.created}, 归档 ${result.archived}`); + return { + handledToast: true, + requestDispatched: true, + mutated, + result, + }; } export async function onExportGraphController(runtime) { @@ -413,17 +541,30 @@ export async function onManualSleepController(runtime) { const beforeSnapshot = runtime.cloneGraphSnapshot(graph); const result = runtime.sleepCycle(graph, runtime.getSettings()); - runtime.recordMaintenanceAction?.({ - action: "sleep", - beforeSnapshot, - mode: "manual", - summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"), - }); - await runtime.recordGraphMutation({ - beforeSnapshot, - artifactTags: ["sleep"], - }); - runtime.toastr.info(`执行完成:归档 ${result.forgotten} 个节点`); + const mutated = hasSleepMutation(result); + if (mutated) { + runtime.recordMaintenanceAction?.({ + action: "sleep", + beforeSnapshot, + mode: "manual", + summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"), + }); + await runtime.recordGraphMutation({ + beforeSnapshot, + artifactTags: ["sleep"], + }); + runtime.toastr.success(`执行遗忘完成:归档 ${result.forgotten} 个节点`); + } else { + runtime.toastr.info( + "当前没有符合遗忘条件的节点。本操作只做本地图清理,不会发送 LLM 请求。", + ); + } + return { + handledToast: true, + requestDispatched: false, + mutated, + result, + }; } export async function onManualSynopsisController(runtime) { @@ -451,12 +592,28 @@ export async function onManualEvolveController(runtime) { if (!graph) return; if (!runtime.ensureGraphMutationReady("强制进化")) return; - const candidateIds = runtime.getLastExtractedItems() - .map((item) => item.id) - .filter(Boolean); + const embeddingConfig = runtime.getEmbeddingConfig(); + const vectorValidation = runtime.validateVectorConfig?.(embeddingConfig); + if (vectorValidation && !vectorValidation.valid) { + runtime.toastr.warning(vectorValidation.error); + return { + handledToast: true, + requestDispatched: false, + mutated: false, + reason: vectorValidation.error, + }; + } + + const candidateResolution = resolveManualEvolutionCandidates(runtime, graph); + const candidateIds = candidateResolution.ids; if (candidateIds.length === 0) { - runtime.toastr.info("暂无最近提取节点可用于进化"); - return; + runtime.toastr.info("当前没有可用于进化的最近提取节点,本次未发起整合请求"); + return { + handledToast: true, + requestDispatched: false, + mutated: false, + reason: "no-candidates", + }; } const beforeSnapshot = runtime.cloneGraphSnapshot(graph); @@ -464,7 +621,7 @@ export async function onManualEvolveController(runtime) { const result = await runtime.consolidateMemories({ graph, newNodeIds: candidateIds, - embeddingConfig: runtime.getEmbeddingConfig(), + embeddingConfig, customPrompt: undefined, settings, options: { @@ -472,19 +629,38 @@ export async function onManualEvolveController(runtime) { conflictThreshold: settings.consolidationThreshold, }, }); - runtime.recordMaintenanceAction?.({ - action: "consolidate", - beforeSnapshot, - mode: "manual", - summary: runtime.buildMaintenanceSummary?.("consolidate", result, "manual"), - }); - await runtime.recordGraphMutation({ - beforeSnapshot, - artifactTags: ["consolidation"], - }); - runtime.toastr.success( - `整合完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}`, + const mutated = hasConsolidationMutation(result); + const sourceLabel = describeManualEvolutionSource( + candidateResolution.source, + candidateIds.length, ); + if (mutated) { + runtime.recordMaintenanceAction?.({ + action: "consolidate", + beforeSnapshot, + mode: "manual", + summary: runtime.buildMaintenanceSummary?.("consolidate", result, "manual"), + }); + await runtime.recordGraphMutation({ + beforeSnapshot, + artifactTags: ["consolidation"], + }); + runtime.toastr.success( + `强制进化完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}。${sourceLabel}。`, + ); + } else { + runtime.toastr.info( + `已完成整合判定,但本轮没有产生图谱变更。${sourceLabel}。`, + ); + } + + return { + handledToast: true, + requestDispatched: true, + mutated, + result, + candidateSource: candidateResolution.source, + }; } export async function onUndoLastMaintenanceController(runtime) {