Fix manual maintenance action execution feedback

This commit is contained in:
Youzini-afk
2026-04-05 19:11:15 +08:00
parent 3446a44387
commit 28049d89bc
3 changed files with 479 additions and 42 deletions

View File

@@ -9347,6 +9347,7 @@ async function onManualCompress() {
getEmbeddingConfig, getEmbeddingConfig,
getSchema, getSchema,
getSettings, getSettings,
inspectCompressionCandidates: inspectAutoCompressionCandidates,
recordMaintenanceAction, recordMaintenanceAction,
recordGraphMutation, recordGraphMutation,
toastr, toastr,
@@ -9531,6 +9532,7 @@ async function onManualEvolve() {
recordMaintenanceAction, recordMaintenanceAction,
recordGraphMutation, recordGraphMutation,
toastr, toastr,
validateVectorConfig,
}); });
} }

View File

@@ -61,6 +61,11 @@ import {
setBatchStageOutcome, setBatchStageOutcome,
shouldRunRecallForTransaction, shouldRunRecallForTransaction,
} from "../ui-status.js"; } from "../ui-status.js";
import {
onManualCompressController,
onManualEvolveController,
onManualSleepController,
} from "../ui-actions-controller.js";
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0)); const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
const extensionsShimSource = [ 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 testCompressorMigratesEdgesToCompressedNode();
await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure(); await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure();
await testExtractorFailsOnUnknownOperation(); await testExtractorFailsOnUnknownOperation();
@@ -5358,5 +5612,10 @@ await testRerollPreservesPrefixHashesWhenReextractDoesNotAdvance();
await testLlmDebugSnapshotRedactsSecretsBeforeStorage(); await testLlmDebugSnapshotRedactsSecretsBeforeStorage();
await testEmbeddingUsesConfigTimeoutInsteadOfDefault(); await testEmbeddingUsesConfigTimeoutInsteadOfDefault();
await testLlmOutputRegexCleansResponseBeforeJsonParse(); await testLlmOutputRegexCleansResponseBeforeJsonParse();
await testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan();
await testManualCompressUsesForcedCompressionAndPersistsRealMutation();
await testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh();
await testManualEvolveWarnsOnInvalidVectorConfigInsteadOfPretendingComplete();
await testManualSleepExplainsThatItIsLocalOnlyWhenNothingChanges();
console.log("p0-regressions tests passed"); console.log("p0-regressions tests passed");

View File

@@ -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) { export async function onViewGraphController(runtime) {
const graph = runtime.getCurrentGraph(); const graph = runtime.getCurrentGraph();
if (!graph) { if (!graph) {
@@ -111,28 +212,55 @@ export async function onManualCompressController(runtime) {
if (!graph) return; if (!graph) return;
if (!runtime.ensureGraphMutationReady("手动压缩")) 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 beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = await runtime.compressAll( const result = await runtime.compressAll(
graph, graph,
runtime.getSchema(), schema,
runtime.getEmbeddingConfig(), runtime.getEmbeddingConfig(),
false, true,
undefined, undefined,
undefined, undefined,
runtime.getSettings(), runtime.getSettings(),
); );
runtime.recordMaintenanceAction?.({ const mutated = hasCompressionMutation(result);
action: "compress", if (mutated) {
beforeSnapshot, runtime.recordMaintenanceAction?.({
mode: "manual", action: "compress",
summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"), beforeSnapshot,
}); mode: "manual",
await runtime.recordGraphMutation({ summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"),
beforeSnapshot, });
artifactTags: ["compression"], 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) { export async function onExportGraphController(runtime) {
@@ -413,17 +541,30 @@ export async function onManualSleepController(runtime) {
const beforeSnapshot = runtime.cloneGraphSnapshot(graph); const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = runtime.sleepCycle(graph, runtime.getSettings()); const result = runtime.sleepCycle(graph, runtime.getSettings());
runtime.recordMaintenanceAction?.({ const mutated = hasSleepMutation(result);
action: "sleep", if (mutated) {
beforeSnapshot, runtime.recordMaintenanceAction?.({
mode: "manual", action: "sleep",
summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"), beforeSnapshot,
}); mode: "manual",
await runtime.recordGraphMutation({ summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"),
beforeSnapshot, });
artifactTags: ["sleep"], await runtime.recordGraphMutation({
}); beforeSnapshot,
runtime.toastr.info(`执行完成:归档 ${result.forgotten} 个节点`); artifactTags: ["sleep"],
});
runtime.toastr.success(`执行遗忘完成:归档 ${result.forgotten} 个节点`);
} else {
runtime.toastr.info(
"当前没有符合遗忘条件的节点。本操作只做本地图清理,不会发送 LLM 请求。",
);
}
return {
handledToast: true,
requestDispatched: false,
mutated,
result,
};
} }
export async function onManualSynopsisController(runtime) { export async function onManualSynopsisController(runtime) {
@@ -451,12 +592,28 @@ export async function onManualEvolveController(runtime) {
if (!graph) return; if (!graph) return;
if (!runtime.ensureGraphMutationReady("强制进化")) return; if (!runtime.ensureGraphMutationReady("强制进化")) return;
const candidateIds = runtime.getLastExtractedItems() const embeddingConfig = runtime.getEmbeddingConfig();
.map((item) => item.id) const vectorValidation = runtime.validateVectorConfig?.(embeddingConfig);
.filter(Boolean); 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) { if (candidateIds.length === 0) {
runtime.toastr.info("暂无最近提取节点可用于进化"); runtime.toastr.info("当前没有可用于进化的最近提取节点,本次未发起整合请求");
return; return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason: "no-candidates",
};
} }
const beforeSnapshot = runtime.cloneGraphSnapshot(graph); const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
@@ -464,7 +621,7 @@ export async function onManualEvolveController(runtime) {
const result = await runtime.consolidateMemories({ const result = await runtime.consolidateMemories({
graph, graph,
newNodeIds: candidateIds, newNodeIds: candidateIds,
embeddingConfig: runtime.getEmbeddingConfig(), embeddingConfig,
customPrompt: undefined, customPrompt: undefined,
settings, settings,
options: { options: {
@@ -472,19 +629,38 @@ export async function onManualEvolveController(runtime) {
conflictThreshold: settings.consolidationThreshold, conflictThreshold: settings.consolidationThreshold,
}, },
}); });
runtime.recordMaintenanceAction?.({ const mutated = hasConsolidationMutation(result);
action: "consolidate", const sourceLabel = describeManualEvolutionSource(
beforeSnapshot, candidateResolution.source,
mode: "manual", candidateIds.length,
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}`,
); );
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) { export async function onUndoLastMaintenanceController(runtime) {