From 90e14011aecb76a7dd68774d79c8d78b1ec492c2 Mon Sep 17 00:00:00 2001 From: youzini Date: Sun, 31 May 2026 10:37:17 +0000 Subject: [PATCH] refactor(vector): extract syncVectorState controller, migrate mobile-status off index.js slicing --- index.js | 100 ++++---------------- tests/index-slicing-ratchet.mjs | 1 - tests/mobile-status-regressions.mjs | 129 +++++++++++++------------- vector/vector-sync-controller.js | 136 ++++++++++++++++++++++++++++ 4 files changed, 217 insertions(+), 149 deletions(-) create mode 100644 vector/vector-sync-controller.js diff --git a/index.js b/index.js index f7df8c8..7809c4e 100644 --- a/index.js +++ b/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 = {}) { diff --git a/tests/index-slicing-ratchet.mjs b/tests/index-slicing-ratchet.mjs index 68c9152..2d40366 100644 --- a/tests/index-slicing-ratchet.mjs +++ b/tests/index-slicing-ratchet.mjs @@ -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" }, diff --git a/tests/mobile-status-regressions.mjs b/tests/mobile-status-regressions.mjs index ef698bb..ee3c304 100644 --- a/tests/mobile-status-regressions.mjs +++ b/tests/mobile-status-regressions.mjs @@ -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(); diff --git a/vector/vector-sync-controller.js b/vector/vector-sync-controller.js new file mode 100644 index 0000000..b216c18 --- /dev/null +++ b/vector/vector-sync-controller.js @@ -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} 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, + }; + } +}