diff --git a/index.js b/index.js index 48dd690..1e75df7 100644 --- a/index.js +++ b/index.js @@ -595,6 +595,45 @@ function getCurrentChatId(context = getContext()) { return resolveCurrentChatIdentity(context).chatId; } +function getRuntimeGraphChatIdFallback(graph = currentGraph) { + const graphMeta = getGraphPersistenceMeta(graph) || {}; + const fallbackCandidates = [ + graph?.historyState?.chatId, + graphMeta.chatId, + graphPersistenceState.chatId, + graphPersistenceState.queuedPersistChatId, + graphPersistenceState.commitMarker?.chatId, + ]; + + return ( + fallbackCandidates + .map((candidate) => normalizeChatIdCandidate(candidate)) + .find(Boolean) || "" + ); +} + +function getGraphOwnedChatId(graph = currentGraph) { + const graphMeta = getGraphPersistenceMeta(graph) || {}; + const ownedCandidates = [graph?.historyState?.chatId, graphMeta.chatId]; + return ( + ownedCandidates + .map((candidate) => normalizeChatIdCandidate(candidate)) + .find(Boolean) || "" + ); +} + +function resolveOperationalChatId( + context = getContext(), + graph = currentGraph, + explicitChatId = "", +) { + return ( + normalizeChatIdCandidate(explicitChatId) || + normalizeChatIdCandidate(getCurrentChatId(context)) || + getRuntimeGraphChatIdFallback(graph) + ); +} + function resolvePersistenceChatId( context = getContext(), graph = currentGraph, @@ -4417,6 +4456,52 @@ function hasMeaningfulRuntimeGraphForChat( return !isGraphEffectivelyEmpty(currentGraph); } +function hasRuntimeGraphMutationContext( + context = getContext(), + graph = currentGraph, + { allowNoChatState = false } = {}, +) { + if ( + !graph || + typeof graph !== "object" || + !graph.historyState || + typeof graph.historyState !== "object" || + Array.isArray(graph.historyState) + ) { + return false; + } + + const identity = resolveCurrentChatIdentity(context); + const liveChatId = normalizeChatIdCandidate(identity.chatId); + const graphOwnedChatId = getGraphOwnedChatId(graph); + if (!graphOwnedChatId) return false; + + if (liveChatId) { + return ( + areChatIdsEquivalentForResolvedIdentity(graphOwnedChatId, liveChatId, identity) || + areChatIdsEquivalentForResolvedIdentity(liveChatId, graphOwnedChatId, identity) + ); + } + + const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId); + if (!stateChatId || stateChatId !== graphOwnedChatId) { + return false; + } + + const markerChatId = normalizeChatIdCandidate(graphPersistenceState.commitMarker?.chatId); + if (markerChatId && markerChatId !== graphOwnedChatId) return false; + + if ( + graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADED || + graphPersistenceState.loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED || + graphPersistenceState.dbReady === true + ) { + return true; + } + + return allowNoChatState === true && graphPersistenceState.loadState === GRAPH_LOAD_STATES.NO_CHAT; +} + function isGraphReadableForRecall( loadState = graphPersistenceState.loadState, chatId = getCurrentChatId(), @@ -4437,6 +4522,15 @@ function createGraphLoadUiStatus() { const chatId = graphPersistenceState.chatId || getCurrentChatId(); switch (state) { case GRAPH_LOAD_STATES.NO_CHAT: + if (hasMeaningfulRuntimeGraphForChat(chatId)) { + return createUiStatus( + "图谱已加载", + chatId + ? `已读取聊天 ${chatId} 的图谱;宿主当前聊天 ID 暂不可用,维护操作会使用图谱身份继续` + : "已读取当前图谱;宿主当前聊天 ID 暂不可用,维护操作会使用图谱身份继续", + "warning", + ); + } return createUiStatus("待命", "当前尚未进入聊天", "idle"); case GRAPH_LOAD_STATES.LOADING: if (hasMeaningfulRuntimeGraphForChat(chatId)) { @@ -4498,11 +4592,16 @@ function getGraphMutationBlockReason(operationLabel = "当前操作") { return getRestoreLockMessage(operationLabel); } const loadState = graphPersistenceState.loadState; - if (!getCurrentChatId()) { + const hasRuntimeFallback = hasRuntimeGraphMutationContext(getContext()); + if (!getCurrentChatId() && !hasRuntimeFallback) { return `${operationLabel}已暂停:当前尚未进入聊天。`; } - if (graphPersistenceState.dbReady || isGraphLoadStateDbReady(loadState)) { + if ( + graphPersistenceState.dbReady || + isGraphLoadStateDbReady(loadState) || + hasRuntimeFallback + ) { return `${operationLabel}暂不可用。`; } @@ -4524,7 +4623,7 @@ function getGraphMutationBlockReason(operationLabel = "当前操作") { function ensureGraphMutationReady( operationLabel = "当前操作", - { notify = true, ignoreRestoreLock = false } = {}, + { notify = true, ignoreRestoreLock = false, allowRuntimeGraphFallback = false } = {}, ) { if (!ignoreRestoreLock && isRestoreLockActive()) { if (notify) { @@ -4532,7 +4631,16 @@ function ensureGraphMutationReady( } return false; } - if (graphPersistenceState.dbReady || isGraphLoadStateDbReady()) return true; + if ( + graphPersistenceState.dbReady || + isGraphLoadStateDbReady() || + (allowRuntimeGraphFallback === true && + hasRuntimeGraphMutationContext(getContext(), currentGraph, { + allowNoChatState: true, + })) + ) { + return true; + } if (notify) { toastr.info(getGraphMutationBlockReason(operationLabel), "ST-BME"); } @@ -16530,6 +16638,7 @@ function markVectorStateDirty(reason = "向量状态已标记为待重建") { if (!currentGraph) return; ensureCurrentGraphRuntimeState(); currentGraph.vectorIndexState.dirty = true; + currentGraph.vectorIndexState.dirtyReason = reason; currentGraph.vectorIndexState.lastWarning = reason; } @@ -17068,7 +17177,7 @@ async function syncVectorState({ try { const result = await syncGraphVectorIndex(currentGraph, config, { - chatId: getCurrentChatId(), + chatId: resolveOperationalChatId(getContext(), currentGraph), force, purge, range, @@ -17336,7 +17445,14 @@ async function ensureVectorReadyIfNeeded( ) { if (!currentGraph) return; ensureCurrentGraphRuntimeState(); - if (!isGraphMetadataWriteAllowed()) return; + if ( + !isGraphMetadataWriteAllowed() && + !hasRuntimeGraphMutationContext(getContext(), currentGraph, { + allowNoChatState: true, + }) + ) { + return; + } if (!currentGraph.vectorIndexState?.dirty) return; @@ -17367,14 +17483,36 @@ async function resetVectorStateForConfigChange(reason = "向量配置已变更") if (!currentGraph) return; ensureCurrentGraphRuntimeState(); markVectorStateDirty(reason); + for (const node of currentGraph.nodes || []) { + if (Array.isArray(node?.embedding) && node.embedding.length > 0) { + node.embedding = null; + } + } currentGraph.vectorIndexState.hashToNodeId = {}; currentGraph.vectorIndexState.nodeToHash = {}; + currentGraph.vectorIndexState.currentVectorSpace = null; + if ( + currentGraph.vectorIndexState.manifest && + typeof currentGraph.vectorIndexState.manifest === "object" + ) { + currentGraph.vectorIndexState.manifest = { + ...currentGraph.vectorIndexState.manifest, + status: "stale", + lastError: "vector-config-changed", + }; + } currentGraph.vectorIndexState.lastStats = { - total: 0, + total: Array.isArray(currentGraph.nodes) ? currentGraph.nodes.length : 0, indexed: 0, stale: 0, - pending: 0, + pending: Array.isArray(currentGraph.nodes) ? currentGraph.nodes.length : 0, }; + setLastVectorStatus( + "向量需要重建", + `${reason};旧向量已停用,请点击“重建向量”。如果重建失败,先用“测试 Embedding”检查模型/API key/余额。`, + "warning", + { syncRuntime: false }, + ); saveGraphToChat({ reason: "vector-config-reset" }); } @@ -24010,6 +24148,8 @@ async function onRebuildVectorIndex(range = null) { ensureGraphMutationReady, finishStageAbortController, getEmbeddingConfig, + getGraphPersistenceState: () => graphPersistenceState, + getGraphMutationBlockReason, isAuthorityVectorConfig, isBackendVectorConfig, refreshPanelLiveState, @@ -24099,10 +24239,12 @@ const _cleanupRuntime = () => ({ exportDiagnosticsBundle: async (options = {}) => await exportAuthorityDiagnosticsBundle(options), getCurrentChatId, getCurrentGraph: () => currentGraph, + setLastVectorStatus, markVectorStateDirty: (reason) => { if (currentGraph?.vectorIndexState) { currentGraph.vectorIndexState.dirty = true; currentGraph.vectorIndexState.dirtyReason = reason; + currentGraph.vectorIndexState.lastWarning = reason; } }, normalizeGraphRuntimeState, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index de68927..ea10a00 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -1691,6 +1691,9 @@ result = { getGraphPersistenceState() { return graphPersistenceState; }, + getPanelRuntimeStatus, + getGraphMutationBlockReason, + ensureGraphMutationReady, setLocalStoreCapabilitySnapshot(patch = {}) { bmeLocalStoreCapabilitySnapshot = { ...bmeLocalStoreCapabilitySnapshot, @@ -4199,6 +4202,70 @@ result = { assert.equal(unrepairedStatus.persistence.blocked, true); } +{ + const graph = createMeaningfulGraph( + "chat-runtime-fallback-vector-maintenance", + "runtime-fallback-vector-maintenance", + ); + graph.historyState.chatId = "chat-runtime-fallback-vector-maintenance"; + const harness = await createGraphPersistenceHarness({ + chatId: "", + globalChatId: "", + chat: [ + { is_user: true, mes: "已有聊天" }, + { is_user: false, mes: "已有回复" }, + ], + }); + harness.api.setCurrentGraph(graph); + harness.api.setGraphPersistenceState({ + loadState: GRAPH_LOAD_STATES.NO_CHAT, + chatId: "chat-runtime-fallback-vector-maintenance", + dbReady: false, + writesBlocked: true, + }); + + assert.equal( + harness.api.ensureGraphMutationReady("重建向量", { + notify: false, + allowRuntimeGraphFallback: true, + }), + true, + "live chat id 暂空但 runtime graph 已明确绑定聊天时,向量维护不应被误判为未进入聊天", + ); + const status = harness.api.getPanelRuntimeStatus(); + assert.equal(status.text, "图谱已加载"); + assert.match(status.meta, /维护操作会使用图谱身份继续/); +} + +{ + const graph = createMeaningfulGraph( + "chat-runtime-fallback-denied", + "runtime-fallback-denied", + ); + graph.historyState.chatId = "chat-runtime-fallback-denied"; + const harness = await createGraphPersistenceHarness({ + chatId: "", + globalChatId: "", + chat: [{ is_user: true, mes: "其它上下文" }], + }); + harness.api.setCurrentGraph(graph); + harness.api.setGraphPersistenceState({ + loadState: GRAPH_LOAD_STATES.NO_CHAT, + chatId: "", + dbReady: false, + writesBlocked: true, + }); + + assert.equal( + harness.api.ensureGraphMutationReady("重建向量", { + notify: false, + allowRuntimeGraphFallback: true, + }), + false, + "没有 graphPersistenceState.chatId 强绑定时,不应仅凭 runtimeGraph/chat 内容放开写入", + ); +} + { const harness = await createGraphPersistenceHarness({ chatId: "chat-pending-persist-already-accepted", diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index 56ed305..b3f9d5e 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -69,10 +69,12 @@ import { } from "../ui/ui-status.js"; import { onClearGraphController, + onClearVectorCacheController, onDeleteCurrentIdbController, onManualCompressController, onManualEvolveController, onManualSleepController, + onRebuildVectorIndexController, } from "../ui/ui-actions-controller.js"; import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs"; @@ -7980,6 +7982,97 @@ async function testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan() assert.match(String(toastMessages[0]?.[1] || ""), /未发起 LLM 压缩/); } +async function testClearVectorCacheLeavesActionableRebuildState() { + const statuses = []; + const toasts = []; + let savedReason = ""; + const graph = { + nodes: [ + { id: "n1", embedding: [1, 2, 3] }, + { id: "n2", embedding: [4, 5, 6] }, + ], + vectorIndexState: { + hashToNodeId: { h1: "n1", h2: "n2" }, + nodeToHash: { n1: "h1", n2: "h2" }, + currentVectorSpace: { vectorSpaceId: "old", observedDim: 3 }, + manifest: { status: "clean", observedDim: 3, lastError: "" }, + dirty: false, + dirtyReason: "", + lastWarning: "", + lastStats: { total: 2, indexed: 2, stale: 0, pending: 0 }, + }, + }; + + const result = await onClearVectorCacheController({ + confirm: () => true, + getCurrentGraph: () => graph, + saveGraphToChat(payload = {}) { + savedReason = payload.reason; + }, + setLastVectorStatus(...args) { + statuses.push(args); + }, + refreshPanelLiveState() {}, + toastr: { + warning(message) { + toasts.push(message); + }, + success(message) { + toasts.push(message); + }, + }, + }); + + assert.equal(result?.handledToast, true); + assert.deepEqual(graph.vectorIndexState.hashToNodeId, {}); + assert.deepEqual(graph.vectorIndexState.nodeToHash, {}); + assert.equal(graph.nodes[0].embedding, null); + assert.equal(graph.nodes[1].embedding, null); + assert.equal(graph.vectorIndexState.currentVectorSpace, null); + assert.equal(graph.vectorIndexState.manifest.status, "stale"); + assert.equal(graph.vectorIndexState.manifest.lastError, "manual-clear-vector-cache"); + assert.equal(graph.vectorIndexState.dirty, true); + assert.equal(graph.vectorIndexState.dirtyReason, "manual-clear-vector-cache"); + assert.equal(graph.vectorIndexState.lastStats.total, 2); + assert.equal(graph.vectorIndexState.lastStats.indexed, 0); + assert.equal(graph.vectorIndexState.lastStats.pending, 2); + assert.equal(statuses[0]?.[0], "向量需要重建"); + assert.match(String(statuses[0]?.[1] || ""), /降级为非向量召回/); + assert.equal(savedReason, "manual-clear-vector-cache"); + assert.match(String(toasts[0] || ""), /重建向量/); +} + +async function testRebuildVectorBlockedExplainsVisibleChatMismatch() { + const toasts = []; + const calls = []; + + const result = await onRebuildVectorIndexController({ + ensureGraphMutationReady(label, options = {}) { + calls.push(["ensureGraphMutationReady", label, options]); + return false; + }, + getGraphMutationBlockReason: () => "重建向量已暂停:当前尚未进入聊天。", + getGraphPersistenceState: () => ({ chatId: "chat-visible" }), + toastr: { + warning(message) { + toasts.push(message); + }, + info(message) { + toasts.push(message); + }, + }, + }); + + assert.equal(result?.blocked, true); + assert.equal(result?.handledToast, true); + assert.equal(calls[0]?.[0], "ensureGraphMutationReady"); + assert.equal(calls[0]?.[1], "重建向量"); + assert.equal(calls[0]?.[2]?.notify, false); + assert.equal(calls[0]?.[2]?.allowRuntimeGraphFallback, true); + assert.match(String(toasts[0] || ""), /chat-visible/); + assert.match(String(toasts[0] || ""), /重新探测图谱/); +} + async function testManualCompressUsesForcedCompressionAndPersistsRealMutation() { const calls = { forceFlag: null, @@ -8339,6 +8432,8 @@ await testSynopsisUsesPromptMessagesWithoutFallbackSystemPrompt(); await testRecallUsesSectionedPromptMessagesForContextAndTarget(); await testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt(); await testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan(); +await testClearVectorCacheLeavesActionableRebuildState(); +await testRebuildVectorBlockedExplainsVisibleChatMismatch(); await testManualCompressUsesForcedCompressionAndPersistsRealMutation(); await testManualCompressUpdatesRuntimeStatusForPanelUi(); await testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh(); diff --git a/tests/vector-manifest.mjs b/tests/vector-manifest.mjs index 3579d27..3624d0f 100644 --- a/tests/vector-manifest.mjs +++ b/tests/vector-manifest.mjs @@ -17,13 +17,16 @@ installResolveHooks([ ]); let embeddingDim = 3; +let embeddingFailureIndexes = new Set(); globalThis.__stBmeTestOverrides = { embedding: { async embedBatch(texts = []) { return texts.map((text, index) => - Array.from({ length: embeddingDim }, (_, dimIndex) => - dimIndex === 0 ? 1 : (index + dimIndex + String(text || "").length) / 100, - ), + embeddingFailureIndexes.has(index) + ? null + : Array.from({ length: embeddingDim }, (_, dimIndex) => + dimIndex === 0 ? 1 : (index + dimIndex + String(text || "").length) / 100, + ), ); }, async embedText(text = "") { @@ -103,6 +106,7 @@ const baseConfig = { const graph = createVectorGraph(); graph.nodes[0].embedding = [0.1, 0.2, 0.3]; embeddingDim = 3; + embeddingFailureIndexes = new Set(); const changedModelConfig = { ...baseConfig, model: "text-embedding-3-large" }; await syncGraphVectorIndex(graph, changedModelConfig, { chatId: graph.historyState.chatId }); assert.equal(graph.vectorIndexState.manifest.status, "clean"); @@ -112,4 +116,63 @@ const baseConfig = { assert.notDeepEqual(graph.nodes[0].embedding, [0.1, 0.2, 0.3]); } +{ + const graph = createVectorGraph(); + graph.nodes[0].embedding = [0.1, 0.2, 0.3]; + graph.vectorIndexState.mode = "direct"; + graph.vectorIndexState.modelScope = getVectorModelScope(baseConfig); + graph.vectorIndexState.collectionId = "st-bme-vector-chat-vector-manifest"; + graph.vectorIndexState.manifest = { + status: "clean", + vectorSpaceId: "old-space", + observedDim: 3, + model: baseConfig.model, + }; + embeddingDim = 4; + embeddingFailureIndexes = new Set([0]); + const changedModelConfig = { ...baseConfig, model: "text-embedding-3-large" }; + await syncGraphVectorIndex(graph, changedModelConfig, { chatId: graph.historyState.chatId }); + assert.equal(graph.nodes[0].embedding, null); + assert.equal(graph.vectorIndexState.dirty, true); + assert.equal(graph.vectorIndexState.dirtyReason, "partial-embedding-failure"); + assert.equal(graph.vectorIndexState.lastStats.indexed, 0); + + embeddingFailureIndexes = new Set([0]); + await syncGraphVectorIndex(graph, changedModelConfig, { chatId: graph.historyState.chatId }); + assert.equal( + graph.vectorIndexState.lastStats.indexed, + 0, + "模型变化后的旧 embedding 不应在后续非 force 同步中被重新登记", + ); + assert.equal(graph.nodes[0].embedding, null); +} + +{ + const graph = createVectorGraph(); + graph.nodes[0].embedding = [0.1, 0.2, 0.3]; + graph.vectorIndexState.mode = "direct"; + graph.vectorIndexState.source = "direct"; + graph.vectorIndexState.modelScope = getVectorModelScope(baseConfig); + graph.vectorIndexState.collectionId = "st-bme-vector-chat-vector-manifest"; + graph.vectorIndexState.hashToNodeId = { oldHash: "node-a" }; + graph.vectorIndexState.nodeToHash = { "node-a": "oldHash" }; + graph.vectorIndexState.manifest = { + status: "clean", + vectorSpaceId: "old-space", + observedDim: 3, + model: baseConfig.model, + }; + embeddingDim = 4; + embeddingFailureIndexes = new Set([0]); + await syncGraphVectorIndex(graph, baseConfig, { + chatId: graph.historyState.chatId, + force: true, + }); + assert.equal(graph.nodes[0].embedding, null); + assert.deepEqual(graph.vectorIndexState.nodeToHash, {}); + assert.deepEqual(graph.vectorIndexState.hashToNodeId, {}); + assert.equal(graph.vectorIndexState.lastStats.indexed, 0); + assert.equal(graph.vectorIndexState.lastStats.pending, 1); +} + console.log("vector-manifest tests passed"); diff --git a/ui/ui-actions-controller.js b/ui/ui-actions-controller.js index f4cd672..0f9d586 100644 --- a/ui/ui-actions-controller.js +++ b/ui/ui-actions-controller.js @@ -581,7 +581,31 @@ export async function onImportGraphController(runtime) { } export async function onRebuildVectorIndexController(runtime, range = null) { - if (!runtime.ensureGraphMutationReady(range ? "范围重建向量" : "重建向量")) return; + const operationLabel = range ? "范围重建向量" : "重建向量"; + if ( + !runtime.ensureGraphMutationReady(operationLabel, { + notify: false, + allowRuntimeGraphFallback: true, + }) + ) { + const state = runtime.getGraphPersistenceState?.() || {}; + const visibleChatId = String(state.chatId || "").trim(); + const blockReason = + typeof runtime.getGraphMutationBlockReason === "function" + ? runtime.getGraphMutationBlockReason(operationLabel) + : ""; + if (visibleChatId) { + runtime.toastr.warning( + String(blockReason || "").includes("当前尚未进入聊天") + ? `重建向量被暂停:当前图谱显示聊天 ${visibleChatId},但宿主当前聊天上下文尚未确认。请先点“重新探测图谱”,或切换到其它聊天再切回来。` + : blockReason || `${operationLabel}已暂停。`, + "ST-BME 向量", + ); + } else if (blockReason) { + runtime.toastr.info(blockReason, "ST-BME"); + } + return { handledToast: true, blocked: true }; + } runtime.ensureCurrentGraphRuntimeState(); const config = runtime.getEmbeddingConfig(); @@ -1172,7 +1196,7 @@ export async function onClearGraphRangeController(runtime, startSeq, endSeq) { } export async function onClearVectorCacheController(runtime) { - if (!runtime.confirm("确定要清空向量缓存?\n\n清空后需要重新构建向量索引。")) { + if (!runtime.confirm("确定要清空向量缓存?\n\n清空后只会删除向量索引/embedding,不会删除图谱记忆。之后需要重新构建向量索引;在重建完成前会自动降级为非向量召回。")) { return { cancelled: true }; } @@ -1185,14 +1209,44 @@ export async function onClearVectorCacheController(runtime) { if (graph.vectorIndexState) { graph.vectorIndexState.hashToNodeId = {}; graph.vectorIndexState.nodeToHash = {}; + graph.vectorIndexState.currentVectorSpace = null; + if ( + graph.vectorIndexState.manifest && + typeof graph.vectorIndexState.manifest === "object" + ) { + graph.vectorIndexState.manifest = { + ...graph.vectorIndexState.manifest, + status: "stale", + lastError: "manual-clear-vector-cache", + }; + } graph.vectorIndexState.dirty = true; graph.vectorIndexState.dirtyReason = "manual-clear-vector-cache"; - graph.vectorIndexState.lastWarning = "向量缓存已手动清空,需要重建索引"; + graph.vectorIndexState.lastWarning = "向量缓存已手动清空,需要重建索引;重建前会降级为非向量召回"; + graph.vectorIndexState.lastStats = { + total: Array.isArray(graph.nodes) ? graph.nodes.length : 0, + indexed: 0, + stale: 0, + pending: Array.isArray(graph.nodes) ? graph.nodes.length : 0, + }; } + for (const node of graph.nodes || []) { + if (Array.isArray(node?.embedding) && node.embedding.length > 0) { + node.embedding = null; + } + } + + runtime.setLastVectorStatus?.( + "向量需要重建", + "向量缓存已清空;记忆图谱仍保留,重建前会自动降级为非向量召回。请点击“重建向量”。", + "warning", + { syncRuntime: false }, + ); + runtime.saveGraphToChat({ reason: "manual-clear-vector-cache" }); runtime.refreshPanelLiveState(); - runtime.toastr.success("向量缓存已清空,请重建向量索引"); + runtime.toastr.warning("向量缓存已清空;请点击“重建向量”重新索引。重建前会降级为非向量召回。", "ST-BME 向量"); return { handledToast: true }; } diff --git a/vector/vector-index.js b/vector/vector-index.js index 79a2c8a..c50399c 100644 --- a/vector/vector-index.js +++ b/vector/vector-index.js @@ -1254,8 +1254,36 @@ export async function syncGraphVectorIndex( summarizeVectorSpaceChange(previous, current), ); } + const clearStaleDirectEmbeddings = + directScopeChanged || purge || state.dirty || force; + if (clearStaleDirectEmbeddings) { + const rangedIds = hasConcreteRange ? rangedNodeIds : null; + for (const node of graph.nodes || []) { + if (rangedIds && !rangedIds.has(node.id)) continue; + if (Array.isArray(node?.embedding) && node.embedding.length > 0) { + node.embedding = null; + } + } + for (const [nodeId, hash] of Object.entries(state.nodeToHash || {})) { + if (rangedIds && !rangedIds.has(nodeId)) continue; + delete state.nodeToHash[nodeId]; + if (state.hashToNodeId) { + delete state.hashToNodeId[hash]; + } + } + } const entriesToEmbed = []; const hashByNodeId = {}; + const currentDim = Number(state.currentVectorSpace?.observedDim || state.manifest?.observedDim || 0); + const currentVectorSpace = currentDim > 0 + ? deriveVectorSpace(config, currentDim) + : state.currentVectorSpace; + const mayReuseExistingEmbeddings = + !directScopeChanged && + !force && + !purge && + state.dirty !== true && + isVectorManifestCompatible(state.manifest, currentVectorSpace); for (const entry of desiredEntries) { hashByNodeId[entry.nodeId] = entry.hash; @@ -1266,7 +1294,7 @@ export async function syncGraphVectorIndex( const hasEmbedding = Array.isArray(node?.embedding) && node.embedding.length > 0; - if (!directScopeChanged && !force && !currentHash && hasEmbedding) { + if (mayReuseExistingEmbeddings && !currentHash && hasEmbedding) { state.hashToNodeId[entry.hash] = entry.nodeId; state.nodeToHash[entry.nodeId] = entry.hash; continue;