mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
refactor(vector): extract syncVectorState controller, migrate mobile-status off index.js slicing
This commit is contained in:
100
index.js
100
index.js
@@ -410,6 +410,7 @@ import {
|
||||
validateVectorConfig,
|
||||
} from "./vector/vector-index.js";
|
||||
import { planVectorReadyCheck } from "./vector/vector-gate.js";
|
||||
import { syncVectorStateController } from "./vector/vector-sync-controller.js";
|
||||
import { createAuthorityTriviumClient } from "./vector/authority-vector-primary-adapter.js";
|
||||
import {
|
||||
buildAuthorityJobIdempotencyKey,
|
||||
@@ -17089,87 +17090,26 @@ function computePostProcessArtifacts(
|
||||
return [...tags];
|
||||
}
|
||||
|
||||
async function syncVectorState({
|
||||
force = false,
|
||||
purge = false,
|
||||
range = null,
|
||||
signal = undefined,
|
||||
} = {}) {
|
||||
ensureCurrentGraphRuntimeState();
|
||||
const scopeLabel =
|
||||
range && Number.isFinite(range.start) && Number.isFinite(range.end)
|
||||
? `范围 ${Math.min(range.start, range.end)}-${Math.max(range.start, range.end)}`
|
||||
: "当前聊天";
|
||||
setLastVectorStatus(
|
||||
"向量处理中",
|
||||
`${scopeLabel} · ${force ? "强制同步" : "增量同步"}`,
|
||||
"running",
|
||||
{ syncRuntime: true },
|
||||
async function syncVectorState(options = {}) {
|
||||
return await syncVectorStateController(
|
||||
{
|
||||
ensureCurrentGraphRuntimeState,
|
||||
getCurrentGraph: () => currentGraph,
|
||||
setLastVectorStatus,
|
||||
getEmbeddingConfig,
|
||||
validateVectorConfig,
|
||||
getVectorIndexStats,
|
||||
syncGraphVectorIndex,
|
||||
resolveOperationalChatId,
|
||||
getContext,
|
||||
markVectorStateDirty,
|
||||
isAbortError,
|
||||
getRequestHeaders:
|
||||
typeof getRequestHeaders === "function" ? getRequestHeaders : undefined,
|
||||
console,
|
||||
},
|
||||
options,
|
||||
);
|
||||
const config = getEmbeddingConfig();
|
||||
const validation = validateVectorConfig(config);
|
||||
|
||||
if (!validation.valid) {
|
||||
currentGraph.vectorIndexState.lastWarning = validation.error;
|
||||
currentGraph.vectorIndexState.dirty = true;
|
||||
setLastVectorStatus("向量不可用", validation.error, "warning", {
|
||||
syncRuntime: true,
|
||||
});
|
||||
return {
|
||||
insertedHashes: [],
|
||||
stats: getVectorIndexStats(currentGraph),
|
||||
error: validation.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncGraphVectorIndex(currentGraph, config, {
|
||||
chatId: resolveOperationalChatId(getContext(), currentGraph),
|
||||
force,
|
||||
purge,
|
||||
range,
|
||||
signal,
|
||||
headerProvider:
|
||||
typeof getRequestHeaders === "function" ? () => getRequestHeaders() : null,
|
||||
});
|
||||
if (result?.error) {
|
||||
setLastVectorStatus("向量待修复", result.error, "warning", {
|
||||
syncRuntime: true,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
setLastVectorStatus(
|
||||
"向量完成",
|
||||
`${scopeLabel} · indexed ${result.stats?.indexed ?? 0} · pending ${result.stats?.pending ?? 0}`,
|
||||
"success",
|
||||
{ syncRuntime: true },
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
setLastVectorStatus("向量已终止", scopeLabel, "warning", {
|
||||
syncRuntime: true,
|
||||
});
|
||||
return {
|
||||
insertedHashes: [],
|
||||
stats: getVectorIndexStats(currentGraph),
|
||||
error: error?.message || "向量任务已终止",
|
||||
aborted: true,
|
||||
};
|
||||
}
|
||||
const message = error?.message || String(error) || "向量同步失败";
|
||||
markVectorStateDirty(message);
|
||||
console.error("[ST-BME] 向量同步失败:", error);
|
||||
setLastVectorStatus("向量失败", message, "error", {
|
||||
syncRuntime: true,
|
||||
toastKind: "error",
|
||||
});
|
||||
return {
|
||||
insertedHashes: [],
|
||||
stats: getVectorIndexStats(currentGraph),
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBackgroundVectorSync(task = null, settings = {}) {
|
||||
|
||||
@@ -29,7 +29,6 @@ const SELF_RELATIVE = "tests/index-slicing-ratchet.mjs";
|
||||
const ALLOWLIST = Object.freeze({
|
||||
"tests/graph-persistence.mjs": { maxMarkerCalls: 7, stage: "Phase 5" },
|
||||
"tests/p0-regressions.mjs": { maxMarkerCalls: 13, stage: "Phase 3" },
|
||||
"tests/mobile-status-regressions.mjs": { maxMarkerCalls: 7, stage: "Phase 1" },
|
||||
"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,43 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
import { onManualExtractController } from "../maintenance/extraction-controller.js";
|
||||
import { onRebuildController } from "../ui/ui-actions-controller.js";
|
||||
import { syncVectorStateController } from "../vector/vector-sync-controller.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const indexPath = path.resolve(__dirname, "../index.js");
|
||||
const indexSource = await fs.readFile(indexPath, "utf8");
|
||||
|
||||
function extractSnippet(startMarker, endMarker) {
|
||||
const start = indexSource.indexOf(startMarker);
|
||||
const end = indexSource.indexOf(endMarker);
|
||||
if (start < 0 || end < 0 || end <= start) {
|
||||
throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`);
|
||||
}
|
||||
return indexSource.slice(start, end).replace(/^export\s+/gm, "");
|
||||
}
|
||||
|
||||
const statusSnippet = extractSnippet(
|
||||
"function setRuntimeStatus(",
|
||||
"function notifyExtractionIssue(",
|
||||
);
|
||||
const vectorSnippet = extractSnippet(
|
||||
"async function syncVectorState({",
|
||||
"async function ensureVectorReadyIfNeeded(",
|
||||
);
|
||||
const manualExtractSnippet = extractSnippet(
|
||||
"async function onManualExtract(options = {}) {",
|
||||
"async function onReroll(",
|
||||
);
|
||||
const rebuildSnippet = extractSnippet(
|
||||
"async function onRebuild() {",
|
||||
"async function onManualCompress() {",
|
||||
);
|
||||
|
||||
// Shared status facade that mirrors index.js setRuntimeStatus / setLastXStatus
|
||||
// semantics the way controllers consume them (status setters are injected
|
||||
// dependencies). No index.js slicing — controllers are imported directly.
|
||||
function createBaseStatusContext() {
|
||||
return {
|
||||
const context = {
|
||||
console,
|
||||
Date,
|
||||
createUiStatus(text = "待命", meta = "", level = "idle") {
|
||||
@@ -58,8 +28,6 @@ function createBaseStatusContext() {
|
||||
return {};
|
||||
},
|
||||
resolveOperationalChatId(context, graph, explicitChatId = "") {
|
||||
// VM snippet calls this as a free function (no `this`); derive only from
|
||||
// arguments so it never depends on per-test getCurrentChatId closures.
|
||||
return (
|
||||
String(explicitChatId || "").trim() ||
|
||||
String(graph?.historyState?.chatId || "").trim() ||
|
||||
@@ -79,13 +47,39 @@ function createBaseStatusContext() {
|
||||
error() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function testIndexDefinesLastProcessedAssistantFloorHelper() {
|
||||
assert.match(
|
||||
indexSource,
|
||||
/function\s+getLastProcessedAssistantFloor\s*\(/,
|
||||
);
|
||||
context.setRuntimeStatus = function (text, meta, level = "info") {
|
||||
this.runtimeStatus = this.createUiStatus(text, meta, level);
|
||||
};
|
||||
context.setLastExtractionStatus = function (
|
||||
text,
|
||||
meta,
|
||||
level = "info",
|
||||
{ syncRuntime = true } = {},
|
||||
) {
|
||||
this.lastExtractionStatus = this.createUiStatus(text, meta, level);
|
||||
if (syncRuntime) this.setRuntimeStatus(text, meta, level);
|
||||
};
|
||||
context.setLastVectorStatus = function (
|
||||
text,
|
||||
meta,
|
||||
level = "info",
|
||||
{ syncRuntime = false } = {},
|
||||
) {
|
||||
this.lastVectorStatus = this.createUiStatus(text, meta, level);
|
||||
if (syncRuntime) this.setRuntimeStatus(text, meta, level);
|
||||
};
|
||||
context.setLastRecallStatus = function (
|
||||
text,
|
||||
meta,
|
||||
level = "info",
|
||||
{ syncRuntime = true } = {},
|
||||
) {
|
||||
this.lastRecallStatus = this.createUiStatus(text, meta, level);
|
||||
if (syncRuntime) this.setRuntimeStatus(text, meta, level);
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function testVectorSyncTerminalStateUpdatesRuntime() {
|
||||
@@ -100,6 +94,9 @@ async function testVectorSyncTerminalStateUpdatesRuntime() {
|
||||
ensureCurrentGraphRuntimeState() {
|
||||
return context.currentGraph;
|
||||
},
|
||||
getCurrentGraph() {
|
||||
return context.currentGraph;
|
||||
},
|
||||
getEmbeddingConfig() {
|
||||
return { mode: "direct" };
|
||||
},
|
||||
@@ -125,16 +122,9 @@ async function testVectorSyncTerminalStateUpdatesRuntime() {
|
||||
return false;
|
||||
},
|
||||
markVectorStateDirty() {},
|
||||
result: null,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(
|
||||
`${statusSnippet}\n${vectorSnippet}\nresult = { syncVectorState };`,
|
||||
context,
|
||||
{ filename: indexPath },
|
||||
);
|
||||
|
||||
const result = await context.result.syncVectorState({ force: true });
|
||||
const result = await syncVectorStateController(context, { force: true });
|
||||
assert.equal(result.stats.indexed, 12);
|
||||
assert.equal(context.lastVectorStatus.text, "向量完成");
|
||||
assert.equal(context.runtimeStatus.text, "向量完成");
|
||||
@@ -157,6 +147,15 @@ async function testManualExtractNoBatchesDoesNotStayRunning() {
|
||||
getGraphPersistenceState() {
|
||||
return { pendingPersist: false };
|
||||
},
|
||||
getCurrentGraph() {
|
||||
return context.currentGraph;
|
||||
},
|
||||
getIsExtracting() {
|
||||
return context.isExtracting;
|
||||
},
|
||||
setIsExtracting(value) {
|
||||
context.isExtracting = value;
|
||||
},
|
||||
ensureGraphMutationReady() {
|
||||
return true;
|
||||
},
|
||||
@@ -166,6 +165,9 @@ async function testManualExtractNoBatchesDoesNotStayRunning() {
|
||||
normalizeGraphRuntimeState(graph) {
|
||||
return graph;
|
||||
},
|
||||
setCurrentGraph(graph) {
|
||||
context.currentGraph = graph;
|
||||
},
|
||||
createEmptyGraph() {
|
||||
return {};
|
||||
},
|
||||
@@ -202,16 +204,9 @@ async function testManualExtractNoBatchesDoesNotStayRunning() {
|
||||
},
|
||||
onManualExtractController,
|
||||
finishStageAbortController() {},
|
||||
result: null,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(
|
||||
`${statusSnippet}\n${manualExtractSnippet}\nresult = { onManualExtract };`,
|
||||
context,
|
||||
{ filename: indexPath },
|
||||
);
|
||||
|
||||
await context.result.onManualExtract();
|
||||
await onManualExtractController(context, { drainAll: false });
|
||||
assert.equal(context.isExtracting, false);
|
||||
assert.equal(context.lastExtractionStatus.text, "无待提取内容");
|
||||
assert.equal(context.runtimeStatus.text, "无待提取内容");
|
||||
@@ -617,6 +612,12 @@ async function testManualRebuildSetsTerminalRuntimeStatus() {
|
||||
assert.equal(this?.__confirmHost, true);
|
||||
return true;
|
||||
},
|
||||
getCurrentGraph() {
|
||||
return context.currentGraph;
|
||||
},
|
||||
setCurrentGraph(graph) {
|
||||
context.currentGraph = graph;
|
||||
},
|
||||
ensureGraphMutationReady() {
|
||||
return true;
|
||||
},
|
||||
@@ -684,16 +685,9 @@ async function testManualRebuildSetsTerminalRuntimeStatus() {
|
||||
return await task();
|
||||
},
|
||||
onRebuildController,
|
||||
result: null,
|
||||
};
|
||||
vm.createContext(context);
|
||||
vm.runInContext(
|
||||
`${statusSnippet}\n${rebuildSnippet}\nresult = { onRebuild };`,
|
||||
context,
|
||||
{ filename: indexPath },
|
||||
);
|
||||
|
||||
await context.result.onRebuild();
|
||||
await onRebuildController(context);
|
||||
assert.equal(context.lastExtractionStatus.text, "图谱重建完成");
|
||||
assert.equal(context.runtimeStatus.text, "图谱重建完成");
|
||||
assert.equal(context.runtimeStatus.level, "success");
|
||||
@@ -704,7 +698,6 @@ async function testManualRebuildSetsTerminalRuntimeStatus() {
|
||||
assert.equal(savedNeedRefresh, false);
|
||||
}
|
||||
|
||||
testIndexDefinesLastProcessedAssistantFloorHelper();
|
||||
await testVectorSyncTerminalStateUpdatesRuntime();
|
||||
await testManualExtractNoBatchesDoesNotStayRunning();
|
||||
await testManualExtractIgnoresSupersededPendingPersistence();
|
||||
|
||||
136
vector/vector-sync-controller.js
Normal file
136
vector/vector-sync-controller.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// ST-BME vector sync orchestration controller.
|
||||
//
|
||||
// Extracted from index.js syncVectorState so it can be unit-tested by direct
|
||||
// import instead of slicing index.js into a vm sandbox. All runtime
|
||||
// dependencies (current graph, status setters, embedding config, vector index
|
||||
// engine, host context) are injected explicitly; this module owns no
|
||||
// module-level mutable state.
|
||||
|
||||
/**
|
||||
* Runs a vector index sync for the current graph and reports terminal status.
|
||||
*
|
||||
* @param {object} runtime injected dependencies
|
||||
* @param {() => void} runtime.ensureCurrentGraphRuntimeState
|
||||
* @param {() => object} runtime.getCurrentGraph
|
||||
* @param {(text: string, meta: string, level: string, opts?: object) => void} runtime.setLastVectorStatus
|
||||
* @param {() => object} runtime.getEmbeddingConfig
|
||||
* @param {(config: object) => {valid: boolean, error?: string}} runtime.validateVectorConfig
|
||||
* @param {(graph: object) => object} runtime.getVectorIndexStats
|
||||
* @param {(graph: object, config: object, opts: object) => Promise<object>} runtime.syncGraphVectorIndex
|
||||
* @param {(context: object, graph: object) => string} runtime.resolveOperationalChatId
|
||||
* @param {() => object} runtime.getContext
|
||||
* @param {(message: string) => void} runtime.markVectorStateDirty
|
||||
* @param {(error: unknown) => boolean} runtime.isAbortError
|
||||
* @param {(() => object)} [runtime.getRequestHeaders]
|
||||
* @param {Console} [runtime.console]
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.force]
|
||||
* @param {boolean} [options.purge]
|
||||
* @param {{start: number, end: number}|null} [options.range]
|
||||
* @param {AbortSignal} [options.signal]
|
||||
*/
|
||||
export async function syncVectorStateController(runtime, options = {}) {
|
||||
const {
|
||||
ensureCurrentGraphRuntimeState,
|
||||
getCurrentGraph,
|
||||
getEmbeddingConfig,
|
||||
validateVectorConfig,
|
||||
getVectorIndexStats,
|
||||
syncGraphVectorIndex,
|
||||
resolveOperationalChatId,
|
||||
getContext,
|
||||
markVectorStateDirty,
|
||||
isAbortError,
|
||||
getRequestHeaders,
|
||||
console: logger = console,
|
||||
} = runtime;
|
||||
|
||||
// Status setters are invoked via `runtime` (method-call style) so the runtime
|
||||
// object stays the single owner of status state, matching the extraction and
|
||||
// rebuild controllers.
|
||||
const setLastVectorStatus = (...args) => runtime.setLastVectorStatus(...args);
|
||||
|
||||
const { force = false, purge = false, range = null, signal = undefined } =
|
||||
options || {};
|
||||
|
||||
ensureCurrentGraphRuntimeState();
|
||||
const currentGraph = getCurrentGraph();
|
||||
|
||||
const scopeLabel =
|
||||
range && Number.isFinite(range.start) && Number.isFinite(range.end)
|
||||
? `范围 ${Math.min(range.start, range.end)}-${Math.max(range.start, range.end)}`
|
||||
: "当前聊天";
|
||||
setLastVectorStatus(
|
||||
"向量处理中",
|
||||
`${scopeLabel} · ${force ? "强制同步" : "增量同步"}`,
|
||||
"running",
|
||||
{ syncRuntime: true },
|
||||
);
|
||||
|
||||
const config = getEmbeddingConfig();
|
||||
const validation = validateVectorConfig(config);
|
||||
|
||||
if (!validation.valid) {
|
||||
currentGraph.vectorIndexState.lastWarning = validation.error;
|
||||
currentGraph.vectorIndexState.dirty = true;
|
||||
setLastVectorStatus("向量不可用", validation.error, "warning", {
|
||||
syncRuntime: true,
|
||||
});
|
||||
return {
|
||||
insertedHashes: [],
|
||||
stats: getVectorIndexStats(currentGraph),
|
||||
error: validation.error,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await syncGraphVectorIndex(currentGraph, config, {
|
||||
chatId: resolveOperationalChatId(getContext(), currentGraph),
|
||||
force,
|
||||
purge,
|
||||
range,
|
||||
signal,
|
||||
headerProvider:
|
||||
typeof getRequestHeaders === "function"
|
||||
? () => getRequestHeaders()
|
||||
: null,
|
||||
});
|
||||
if (result?.error) {
|
||||
setLastVectorStatus("向量待修复", result.error, "warning", {
|
||||
syncRuntime: true,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
setLastVectorStatus(
|
||||
"向量完成",
|
||||
`${scopeLabel} · indexed ${result.stats?.indexed ?? 0} · pending ${result.stats?.pending ?? 0}`,
|
||||
"success",
|
||||
{ syncRuntime: true },
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) {
|
||||
setLastVectorStatus("向量已终止", scopeLabel, "warning", {
|
||||
syncRuntime: true,
|
||||
});
|
||||
return {
|
||||
insertedHashes: [],
|
||||
stats: getVectorIndexStats(currentGraph),
|
||||
error: error?.message || "向量任务已终止",
|
||||
aborted: true,
|
||||
};
|
||||
}
|
||||
const message = error?.message || String(error) || "向量同步失败";
|
||||
markVectorStateDirty(message);
|
||||
logger.error("[ST-BME] 向量同步失败:", error);
|
||||
setLastVectorStatus("向量失败", message, "error", {
|
||||
syncRuntime: true,
|
||||
toastKind: "error",
|
||||
});
|
||||
return {
|
||||
insertedHashes: [],
|
||||
stats: getVectorIndexStats(currentGraph),
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user