mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
fix: finalize phase0 and phase1 persistence regressions
This commit is contained in:
386
bme-db.js
386
bme-db.js
@@ -1,5 +1,8 @@
|
||||
import { createEmptyGraph, deserializeGraph } from "./graph.js";
|
||||
import { buildVectorCollectionId, normalizeGraphRuntimeState } from "./runtime-state.js";
|
||||
import {
|
||||
buildVectorCollectionId,
|
||||
normalizeGraphRuntimeState,
|
||||
} from "./runtime-state.js";
|
||||
|
||||
const DEXIE_LOAD_PROMISE_KEY = "__stBmeDexieLoadPromise";
|
||||
const DEXIE_SCRIPT_MARKER = "data-st-bme-dexie";
|
||||
@@ -16,7 +19,8 @@ export const BME_RUNTIME_HISTORY_META_KEY = "runtimeHistoryState";
|
||||
export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState";
|
||||
export const BME_RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal";
|
||||
export const BME_RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult";
|
||||
export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq";
|
||||
export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY =
|
||||
"runtimeLastProcessedSeq";
|
||||
export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion";
|
||||
|
||||
export const BME_DB_TABLE_SCHEMAS = Object.freeze({
|
||||
@@ -125,11 +129,15 @@ function sanitizeSnapshot(snapshot = {}) {
|
||||
}
|
||||
|
||||
const safeMeta =
|
||||
snapshot.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta)
|
||||
snapshot.meta &&
|
||||
typeof snapshot.meta === "object" &&
|
||||
!Array.isArray(snapshot.meta)
|
||||
? { ...snapshot.meta }
|
||||
: {};
|
||||
const safeState =
|
||||
snapshot.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state)
|
||||
snapshot.state &&
|
||||
typeof snapshot.state === "object" &&
|
||||
!Array.isArray(snapshot.state)
|
||||
? { ...snapshot.state }
|
||||
: {};
|
||||
|
||||
@@ -138,13 +146,17 @@ function sanitizeSnapshot(snapshot = {}) {
|
||||
state: safeState,
|
||||
nodes: toArray(snapshot.nodes).map((item) => ({ ...(item || {}) })),
|
||||
edges: toArray(snapshot.edges).map((item) => ({ ...(item || {}) })),
|
||||
tombstones: toArray(snapshot.tombstones).map((item) => ({ ...(item || {}) })),
|
||||
tombstones: toArray(snapshot.tombstones).map((item) => ({
|
||||
...(item || {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStateSnapshot(snapshot = {}) {
|
||||
const state =
|
||||
snapshot?.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state)
|
||||
snapshot?.state &&
|
||||
typeof snapshot.state === "object" &&
|
||||
!Array.isArray(snapshot.state)
|
||||
? { ...snapshot.state }
|
||||
: {};
|
||||
|
||||
@@ -228,7 +240,10 @@ export function buildSnapshotFromGraph(graph, options = {}) {
|
||||
if (!graphInput.historyState || typeof graphInput.historyState !== "object") {
|
||||
graphInput.historyState = {};
|
||||
}
|
||||
if (!graphInput.vectorIndexState || typeof graphInput.vectorIndexState !== "object") {
|
||||
if (
|
||||
!graphInput.vectorIndexState ||
|
||||
typeof graphInput.vectorIndexState !== "object"
|
||||
) {
|
||||
graphInput.vectorIndexState = {};
|
||||
}
|
||||
if (chatId) {
|
||||
@@ -269,7 +284,8 @@ export function buildSnapshotFromGraph(graph, options = {}) {
|
||||
|
||||
const tombstones = toArray(options.tombstones ?? baseSnapshot.tombstones)
|
||||
.map((record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
||||
if (!record || typeof record !== "object" || Array.isArray(record))
|
||||
return null;
|
||||
const id = normalizeRecordId(record.id);
|
||||
if (!id) return null;
|
||||
return {
|
||||
@@ -290,8 +306,12 @@ export function buildSnapshotFromGraph(graph, options = {}) {
|
||||
Number(runtimeGraph?.historyState?.lastProcessedAssistantFloor),
|
||||
)
|
||||
? Number(runtimeGraph.historyState.lastProcessedAssistantFloor)
|
||||
: Number(runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR),
|
||||
extractionCount: Number.isFinite(Number(runtimeGraph?.historyState?.extractionCount))
|
||||
: Number(
|
||||
runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||||
),
|
||||
extractionCount: Number.isFinite(
|
||||
Number(runtimeGraph?.historyState?.extractionCount),
|
||||
)
|
||||
? Number(runtimeGraph.historyState.extractionCount)
|
||||
: META_DEFAULT_EXTRACTION_COUNT,
|
||||
};
|
||||
@@ -301,7 +321,9 @@ export function buildSnapshotFromGraph(graph, options = {}) {
|
||||
...(options.meta || {}),
|
||||
schemaVersion: BME_DB_SCHEMA_VERSION,
|
||||
chatId,
|
||||
revision: normalizeRevision(options.revision ?? baseSnapshot.meta?.revision),
|
||||
revision: normalizeRevision(
|
||||
options.revision ?? baseSnapshot.meta?.revision,
|
||||
),
|
||||
lastModified: normalizeTimestamp(
|
||||
options.lastModified ?? baseSnapshot.meta?.lastModified,
|
||||
nowMs,
|
||||
@@ -309,9 +331,18 @@ export function buildSnapshotFromGraph(graph, options = {}) {
|
||||
nodeCount: nodes.length,
|
||||
edgeCount: edges.length,
|
||||
tombstoneCount: tombstones.length,
|
||||
[BME_RUNTIME_HISTORY_META_KEY]: toPlainData(runtimeGraph?.historyState || {}, {}),
|
||||
[BME_RUNTIME_VECTOR_META_KEY]: toPlainData(runtimeGraph?.vectorIndexState || {}, {}),
|
||||
[BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData(runtimeGraph?.batchJournal || [], []),
|
||||
[BME_RUNTIME_HISTORY_META_KEY]: toPlainData(
|
||||
runtimeGraph?.historyState || {},
|
||||
{},
|
||||
),
|
||||
[BME_RUNTIME_VECTOR_META_KEY]: toPlainData(
|
||||
runtimeGraph?.vectorIndexState || {},
|
||||
{},
|
||||
),
|
||||
[BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData(
|
||||
runtimeGraph?.batchJournal || [],
|
||||
[],
|
||||
),
|
||||
[BME_RUNTIME_LAST_RECALL_META_KEY]: toPlainData(
|
||||
runtimeGraph?.lastRecallResult ?? null,
|
||||
null,
|
||||
@@ -321,7 +352,9 @@ export function buildSnapshotFromGraph(graph, options = {}) {
|
||||
)
|
||||
? Number(runtimeGraph.lastProcessedSeq)
|
||||
: state.lastProcessedFloor,
|
||||
[BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite(Number(runtimeGraph?.version))
|
||||
[BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite(
|
||||
Number(runtimeGraph?.version),
|
||||
)
|
||||
? Number(runtimeGraph.version)
|
||||
: Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0),
|
||||
};
|
||||
@@ -348,8 +381,12 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
|
||||
)
|
||||
? Number(normalizedSnapshot.meta[BME_RUNTIME_GRAPH_VERSION_META_KEY])
|
||||
: runtimeGraph.version;
|
||||
runtimeGraph.nodes = toArray(normalizedSnapshot.nodes).map((node) => ({ ...(node || {}) }));
|
||||
runtimeGraph.edges = toArray(normalizedSnapshot.edges).map((edge) => ({ ...(edge || {}) }));
|
||||
runtimeGraph.nodes = toArray(normalizedSnapshot.nodes).map((node) => ({
|
||||
...(node || {}),
|
||||
}));
|
||||
runtimeGraph.edges = toArray(normalizedSnapshot.edges).map((edge) => ({
|
||||
...(edge || {}),
|
||||
}));
|
||||
runtimeGraph.batchJournal = toArray(
|
||||
normalizedSnapshot.meta?.[BME_RUNTIME_BATCH_JOURNAL_META_KEY],
|
||||
);
|
||||
@@ -361,17 +398,21 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
|
||||
runtimeGraph.historyState = {
|
||||
...(runtimeGraph.historyState || {}),
|
||||
...(normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY] || {}),
|
||||
lastProcessedAssistantFloor: Number.isFinite(Number(normalizedSnapshot.state?.lastProcessedFloor))
|
||||
lastProcessedAssistantFloor: Number.isFinite(
|
||||
Number(normalizedSnapshot.state?.lastProcessedFloor),
|
||||
)
|
||||
? Number(normalizedSnapshot.state.lastProcessedFloor)
|
||||
: Number(
|
||||
normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY]
|
||||
?.lastProcessedAssistantFloor ?? META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||||
),
|
||||
extractionCount: Number.isFinite(Number(normalizedSnapshot.state?.extractionCount))
|
||||
extractionCount: Number.isFinite(
|
||||
Number(normalizedSnapshot.state?.extractionCount),
|
||||
)
|
||||
? Number(normalizedSnapshot.state.extractionCount)
|
||||
: Number(
|
||||
normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY]?.extractionCount ??
|
||||
META_DEFAULT_EXTRACTION_COUNT,
|
||||
normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY]
|
||||
?.extractionCount ?? META_DEFAULT_EXTRACTION_COUNT,
|
||||
),
|
||||
};
|
||||
runtimeGraph.vectorIndexState = {
|
||||
@@ -391,7 +432,54 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
|
||||
? Number(normalizedSnapshot.meta[BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY])
|
||||
: Number(runtimeGraph.historyState.lastProcessedAssistantFloor);
|
||||
|
||||
return normalizeGraphRuntimeState(runtimeGraph, chatId);
|
||||
const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId);
|
||||
const historyState = normalizedGraph.historyState || {};
|
||||
const vectorState = normalizedGraph.vectorIndexState || {};
|
||||
const resolvedLastProcessedFloor = Number.isFinite(
|
||||
Number(historyState.lastProcessedAssistantFloor),
|
||||
)
|
||||
? Number(historyState.lastProcessedAssistantFloor)
|
||||
: META_DEFAULT_LAST_PROCESSED_FLOOR;
|
||||
const resolvedLastProcessedSeq = Number.isFinite(
|
||||
Number(normalizedGraph.lastProcessedSeq),
|
||||
)
|
||||
? Number(normalizedGraph.lastProcessedSeq)
|
||||
: resolvedLastProcessedFloor;
|
||||
const collectionId = String(vectorState.collectionId || "");
|
||||
const expectedCollectionId = buildVectorCollectionId(
|
||||
chatId || historyState.chatId || "",
|
||||
);
|
||||
const inconsistentReasons = [];
|
||||
|
||||
if (
|
||||
Number.isFinite(resolvedLastProcessedFloor) &&
|
||||
Number.isFinite(resolvedLastProcessedSeq) &&
|
||||
resolvedLastProcessedFloor !== resolvedLastProcessedSeq
|
||||
) {
|
||||
inconsistentReasons.push("last-processed-seq-mismatch");
|
||||
}
|
||||
if (
|
||||
chatId &&
|
||||
historyState.chatId &&
|
||||
String(historyState.chatId) !== String(chatId)
|
||||
) {
|
||||
inconsistentReasons.push("history-chat-id-mismatch");
|
||||
}
|
||||
if (collectionId && collectionId !== expectedCollectionId) {
|
||||
inconsistentReasons.push("vector-collection-mismatch");
|
||||
}
|
||||
|
||||
if (inconsistentReasons.length > 0) {
|
||||
const error = new Error(
|
||||
`图谱快照完整性校验失败: ${inconsistentReasons.join(", ")}`,
|
||||
);
|
||||
error.code = "BME_SNAPSHOT_INTEGRITY_ERROR";
|
||||
error.reasons = inconsistentReasons;
|
||||
error.snapshotChatId = chatId;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return normalizedGraph;
|
||||
}
|
||||
|
||||
async function loadDexieFromNodeFallback() {
|
||||
@@ -417,7 +505,9 @@ async function loadDexieByScriptInjection() {
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const existingScript = doc.querySelector?.(`script[${DEXIE_SCRIPT_MARKER}="true"]`);
|
||||
const existingScript = doc.querySelector?.(
|
||||
`script[${DEXIE_SCRIPT_MARKER}="true"]`,
|
||||
);
|
||||
if (existingScript) {
|
||||
existingScript.addEventListener("load", () => resolve(), { once: true });
|
||||
existingScript.addEventListener(
|
||||
@@ -519,7 +609,9 @@ export class BmeDatabase {
|
||||
if (!this._openPromise) {
|
||||
this._openPromise = (async () => {
|
||||
const DexieCtor =
|
||||
this.options.dexieClass || globalThis.Dexie || (await ensureDexieLoaded());
|
||||
this.options.dexieClass ||
|
||||
globalThis.Dexie ||
|
||||
(await ensureDexieLoaded());
|
||||
if (typeof DexieCtor !== "function") {
|
||||
throw new Error("Dexie 构造函数不可用");
|
||||
}
|
||||
@@ -588,7 +680,9 @@ export class BmeDatabase {
|
||||
|
||||
const db = await this.open();
|
||||
const nowMs = Date.now();
|
||||
const entries = Object.entries(record).filter(([key]) => normalizeRecordId(key));
|
||||
const entries = Object.entries(record).filter(([key]) =>
|
||||
normalizeRecordId(key),
|
||||
);
|
||||
|
||||
if (!entries.length) {
|
||||
return {};
|
||||
@@ -624,7 +718,12 @@ export class BmeDatabase {
|
||||
const nowMs = Date.now();
|
||||
await db.transaction("rw", db.table("meta"), async () => {
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(db, "syncDirtyReason", String(reason || "mutation"), nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
String(reason || "mutation"),
|
||||
nowMs,
|
||||
);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -649,11 +748,20 @@ export class BmeDatabase {
|
||||
db.table("tombstones"),
|
||||
db.table("meta"),
|
||||
async () => {
|
||||
await db.table("nodes").bulkPut(records);
|
||||
await this._updateCountMetaInTx(db, nowMs);
|
||||
nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertNodes", nowMs);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertNodes", nowMs);
|
||||
await db.table("nodes").bulkPut(records);
|
||||
await this._updateCountMetaInTx(db, nowMs);
|
||||
nextRevision = await this._bumpRevisionInTx(
|
||||
db,
|
||||
"bulkUpsertNodes",
|
||||
nowMs,
|
||||
);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
"bulkUpsertNodes",
|
||||
nowMs,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -683,11 +791,20 @@ export class BmeDatabase {
|
||||
db.table("tombstones"),
|
||||
db.table("meta"),
|
||||
async () => {
|
||||
await db.table("edges").bulkPut(records);
|
||||
await this._updateCountMetaInTx(db, nowMs);
|
||||
nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertEdges", nowMs);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertEdges", nowMs);
|
||||
await db.table("edges").bulkPut(records);
|
||||
await this._updateCountMetaInTx(db, nowMs);
|
||||
nextRevision = await this._bumpRevisionInTx(
|
||||
db,
|
||||
"bulkUpsertEdges",
|
||||
nowMs,
|
||||
);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
"bulkUpsertEdges",
|
||||
nowMs,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -717,11 +834,20 @@ export class BmeDatabase {
|
||||
db.table("tombstones"),
|
||||
db.table("meta"),
|
||||
async () => {
|
||||
await db.table("tombstones").bulkPut(records);
|
||||
await this._updateCountMetaInTx(db, nowMs);
|
||||
nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertTombstones", nowMs);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertTombstones", nowMs);
|
||||
await db.table("tombstones").bulkPut(records);
|
||||
await this._updateCountMetaInTx(db, nowMs);
|
||||
nextRevision = await this._bumpRevisionInTx(
|
||||
db,
|
||||
"bulkUpsertTombstones",
|
||||
nowMs,
|
||||
);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
"bulkUpsertTombstones",
|
||||
nowMs,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -739,7 +865,9 @@ export class BmeDatabase {
|
||||
let records = await db.table("nodes").toArray();
|
||||
|
||||
if (!includeDeleted) {
|
||||
records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt)));
|
||||
records = records.filter(
|
||||
(item) => !Number.isFinite(Number(item?.deletedAt)),
|
||||
);
|
||||
}
|
||||
|
||||
if (!includeArchived) {
|
||||
@@ -747,7 +875,9 @@ export class BmeDatabase {
|
||||
}
|
||||
|
||||
if (typeof options.type === "string" && options.type.trim()) {
|
||||
records = records.filter((item) => String(item?.type || "") === options.type);
|
||||
records = records.filter(
|
||||
(item) => String(item?.type || "") === options.type,
|
||||
);
|
||||
}
|
||||
|
||||
return this._applyListOptions(records, options);
|
||||
@@ -760,7 +890,9 @@ export class BmeDatabase {
|
||||
let records = await db.table("edges").toArray();
|
||||
|
||||
if (!includeDeleted) {
|
||||
records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt)));
|
||||
records = records.filter(
|
||||
(item) => !Number.isFinite(Number(item?.deletedAt)),
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof options.relation === "string" && options.relation.trim()) {
|
||||
@@ -777,7 +909,9 @@ export class BmeDatabase {
|
||||
let records = await db.table("tombstones").toArray();
|
||||
|
||||
if (typeof options.kind === "string" && options.kind.trim()) {
|
||||
records = records.filter((item) => String(item?.kind || "") === options.kind);
|
||||
records = records.filter(
|
||||
(item) => String(item?.kind || "") === options.kind,
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof options.targetId === "string" && options.targetId.trim()) {
|
||||
@@ -849,16 +983,23 @@ export class BmeDatabase {
|
||||
});
|
||||
|
||||
const nodeSourceFloorById = new Map();
|
||||
const nodes = this._normalizeNodeRecords(snapshot.nodes, nowMs).map((node) => {
|
||||
const sourceFloor = deriveNodeSourceFloor(node);
|
||||
nodeSourceFloorById.set(node.id, sourceFloor);
|
||||
return sourceFloor == null ? node : { ...node, sourceFloor };
|
||||
});
|
||||
const edges = this._normalizeEdgeRecords(snapshot.edges, nowMs).map((edge) => {
|
||||
const sourceFloor = deriveEdgeSourceFloor(edge, nodeSourceFloorById);
|
||||
return sourceFloor == null ? edge : { ...edge, sourceFloor };
|
||||
});
|
||||
const tombstones = this._normalizeTombstoneRecords(snapshot.tombstones, nowMs);
|
||||
const nodes = this._normalizeNodeRecords(snapshot.nodes, nowMs).map(
|
||||
(node) => {
|
||||
const sourceFloor = deriveNodeSourceFloor(node);
|
||||
nodeSourceFloorById.set(node.id, sourceFloor);
|
||||
return sourceFloor == null ? node : { ...node, sourceFloor };
|
||||
},
|
||||
);
|
||||
const edges = this._normalizeEdgeRecords(snapshot.edges, nowMs).map(
|
||||
(edge) => {
|
||||
const sourceFloor = deriveEdgeSourceFloor(edge, nodeSourceFloorById);
|
||||
return sourceFloor == null ? edge : { ...edge, sourceFloor };
|
||||
},
|
||||
);
|
||||
const tombstones = this._normalizeTombstoneRecords(
|
||||
snapshot.tombstones,
|
||||
nowMs,
|
||||
);
|
||||
|
||||
let migrated = false;
|
||||
let skipReason = "";
|
||||
@@ -882,7 +1023,9 @@ export class BmeDatabase {
|
||||
);
|
||||
if (migrationCompletedAt > 0) {
|
||||
skipReason = "migration-already-completed";
|
||||
nextRevision = normalizeRevision((await db.table("meta").get("revision"))?.value);
|
||||
nextRevision = normalizeRevision(
|
||||
(await db.table("meta").get("revision"))?.value,
|
||||
);
|
||||
counts = {
|
||||
nodes: await db.table("nodes").count(),
|
||||
edges: await db.table("edges").count(),
|
||||
@@ -897,7 +1040,9 @@ export class BmeDatabase {
|
||||
]);
|
||||
if (nodeCount > 0 || edgeCount > 0) {
|
||||
skipReason = "indexeddb-not-empty";
|
||||
nextRevision = normalizeRevision((await db.table("meta").get("revision"))?.value);
|
||||
nextRevision = normalizeRevision(
|
||||
(await db.table("meta").get("revision"))?.value,
|
||||
);
|
||||
counts = {
|
||||
nodes: nodeCount,
|
||||
edges: edgeCount,
|
||||
@@ -953,9 +1098,19 @@ export class BmeDatabase {
|
||||
nextRevision = Math.max(currentRevision + 1, requestedRevision, 1);
|
||||
await this._setMetaInTx(db, "revision", nextRevision, nowMs);
|
||||
await this._setMetaInTx(db, "lastModified", nowMs, nowMs);
|
||||
await this._setMetaInTx(db, "lastMutationReason", "importLegacyGraph", nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"lastMutationReason",
|
||||
"importLegacyGraph",
|
||||
nowMs,
|
||||
);
|
||||
await this._setMetaInTx(db, "syncDirty", true, nowMs);
|
||||
await this._setMetaInTx(db, "syncDirtyReason", "legacy-migration", nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
"legacy-migration",
|
||||
nowMs,
|
||||
);
|
||||
|
||||
migrated = true;
|
||||
},
|
||||
@@ -1043,7 +1198,9 @@ export class BmeDatabase {
|
||||
db.table("tombstones"),
|
||||
db.table("meta"),
|
||||
async () => {
|
||||
revisionFloor = normalizeRevision((await db.table("meta").get("revision"))?.value);
|
||||
revisionFloor = normalizeRevision(
|
||||
(await db.table("meta").get("revision"))?.value,
|
||||
);
|
||||
|
||||
if (mode === "replace") {
|
||||
await Promise.all([
|
||||
@@ -1054,8 +1211,14 @@ export class BmeDatabase {
|
||||
]);
|
||||
}
|
||||
|
||||
const nodes = this._normalizeNodeRecords(normalizedSnapshot.nodes, nowMs);
|
||||
const edges = this._normalizeEdgeRecords(normalizedSnapshot.edges, nowMs);
|
||||
const nodes = this._normalizeNodeRecords(
|
||||
normalizedSnapshot.nodes,
|
||||
nowMs,
|
||||
);
|
||||
const edges = this._normalizeEdgeRecords(
|
||||
normalizedSnapshot.edges,
|
||||
nowMs,
|
||||
);
|
||||
const tombstones = this._normalizeTombstoneRecords(
|
||||
normalizedSnapshot.tombstones,
|
||||
nowMs,
|
||||
@@ -1072,7 +1235,9 @@ export class BmeDatabase {
|
||||
}
|
||||
|
||||
const metaPatch = {
|
||||
...(mode === "replace" ? createDefaultMetaValues(this.chatId, nowMs) : {}),
|
||||
...(mode === "replace"
|
||||
? createDefaultMetaValues(this.chatId, nowMs)
|
||||
: {}),
|
||||
...normalizedSnapshot.meta,
|
||||
...(normalizedSnapshot.state || {}),
|
||||
chatId: this.chatId,
|
||||
@@ -1092,9 +1257,13 @@ export class BmeDatabase {
|
||||
(await db.table("meta").get("revision"))?.value,
|
||||
);
|
||||
const currentRevision =
|
||||
mode === "replace" ? Math.max(revisionFloor, persistedRevision) : persistedRevision;
|
||||
mode === "replace"
|
||||
? Math.max(revisionFloor, persistedRevision)
|
||||
: persistedRevision;
|
||||
|
||||
const incomingRevision = normalizeRevision(normalizedSnapshot.meta?.revision);
|
||||
const incomingRevision = normalizeRevision(
|
||||
normalizedSnapshot.meta?.revision,
|
||||
);
|
||||
const explicitRevision = normalizeRevision(options.revision);
|
||||
const requestedRevision = Number.isFinite(Number(options.revision))
|
||||
? explicitRevision
|
||||
@@ -1105,7 +1274,12 @@ export class BmeDatabase {
|
||||
nextRevision = Math.max(currentRevision + 1, requestedRevision);
|
||||
await this._setMetaInTx(db, "revision", nextRevision, nowMs);
|
||||
await this._setMetaInTx(db, "lastModified", nowMs, nowMs);
|
||||
await this._setMetaInTx(db, "lastMutationReason", "importSnapshot", nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"lastMutationReason",
|
||||
"importSnapshot",
|
||||
nowMs,
|
||||
);
|
||||
|
||||
await this._setMetaInTx(db, "syncDirty", shouldMarkSyncDirty, nowMs);
|
||||
await this._setMetaInTx(db, "syncDirtyReason", "importSnapshot", nowMs);
|
||||
@@ -1148,7 +1322,12 @@ export class BmeDatabase {
|
||||
|
||||
await this._setMetaInTx(db, "revision", nextRevision, nowMs);
|
||||
await this._setMetaInTx(db, "chatId", this.chatId, nowMs);
|
||||
await this._setMetaInTx(db, "schemaVersion", BME_DB_SCHEMA_VERSION, nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"schemaVersion",
|
||||
BME_DB_SCHEMA_VERSION,
|
||||
nowMs,
|
||||
);
|
||||
await this._setMetaInTx(db, "nodeCount", 0, nowMs);
|
||||
await this._setMetaInTx(db, "edgeCount", 0, nowMs);
|
||||
await this._setMetaInTx(db, "tombstoneCount", 0, nowMs);
|
||||
@@ -1192,32 +1371,32 @@ export class BmeDatabase {
|
||||
db.table("tombstones"),
|
||||
db.table("meta"),
|
||||
async () => {
|
||||
const staleIds = await db
|
||||
.table("tombstones")
|
||||
.where("deletedAt")
|
||||
.below(cutoffMs)
|
||||
.primaryKeys();
|
||||
const staleIds = await db
|
||||
.table("tombstones")
|
||||
.where("deletedAt")
|
||||
.below(cutoffMs)
|
||||
.primaryKeys();
|
||||
|
||||
if (!staleIds.length) {
|
||||
return;
|
||||
}
|
||||
if (!staleIds.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await db.table("tombstones").bulkDelete(staleIds);
|
||||
removedCount = staleIds.length;
|
||||
await db.table("tombstones").bulkDelete(staleIds);
|
||||
removedCount = staleIds.length;
|
||||
|
||||
await this._updateCountMetaInTx(db, normalizedNow);
|
||||
nextRevision = await this._bumpRevisionInTx(
|
||||
db,
|
||||
"pruneExpiredTombstones",
|
||||
normalizedNow,
|
||||
);
|
||||
await this._setMetaInTx(db, "syncDirty", true, normalizedNow);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
"pruneExpiredTombstones",
|
||||
normalizedNow,
|
||||
);
|
||||
await this._updateCountMetaInTx(db, normalizedNow);
|
||||
nextRevision = await this._bumpRevisionInTx(
|
||||
db,
|
||||
"pruneExpiredTombstones",
|
||||
normalizedNow,
|
||||
);
|
||||
await this._setMetaInTx(db, "syncDirty", true, normalizedNow);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"syncDirtyReason",
|
||||
"pruneExpiredTombstones",
|
||||
normalizedNow,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1254,12 +1433,24 @@ export class BmeDatabase {
|
||||
}
|
||||
|
||||
async _bumpRevisionInTx(db, reason = "mutation", nowMs = Date.now()) {
|
||||
const currentRevision = normalizeRevision((await db.table("meta").get("revision"))?.value);
|
||||
const currentRevision = normalizeRevision(
|
||||
(await db.table("meta").get("revision"))?.value,
|
||||
);
|
||||
const nextRevision = currentRevision + 1;
|
||||
|
||||
await this._setMetaInTx(db, "revision", nextRevision, nowMs);
|
||||
await this._setMetaInTx(db, "lastModified", normalizeTimestamp(nowMs), nowMs);
|
||||
await this._setMetaInTx(db, "lastMutationReason", String(reason || "mutation"), nowMs);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"lastModified",
|
||||
normalizeTimestamp(nowMs),
|
||||
nowMs,
|
||||
);
|
||||
await this._setMetaInTx(
|
||||
db,
|
||||
"lastMutationReason",
|
||||
String(reason || "mutation"),
|
||||
nowMs,
|
||||
);
|
||||
|
||||
return nextRevision;
|
||||
}
|
||||
@@ -1309,7 +1500,8 @@ export class BmeDatabase {
|
||||
const nowMs = normalizeTimestamp(fallbackNowMs);
|
||||
return toArray(nodes)
|
||||
.map((node) => {
|
||||
if (!node || typeof node !== "object" || Array.isArray(node)) return null;
|
||||
if (!node || typeof node !== "object" || Array.isArray(node))
|
||||
return null;
|
||||
const id = normalizeRecordId(node.id);
|
||||
if (!id) return null;
|
||||
|
||||
@@ -1326,7 +1518,8 @@ export class BmeDatabase {
|
||||
const nowMs = normalizeTimestamp(fallbackNowMs);
|
||||
return toArray(edges)
|
||||
.map((edge) => {
|
||||
if (!edge || typeof edge !== "object" || Array.isArray(edge)) return null;
|
||||
if (!edge || typeof edge !== "object" || Array.isArray(edge))
|
||||
return null;
|
||||
const id = normalizeRecordId(edge.id);
|
||||
if (!id) return null;
|
||||
|
||||
@@ -1345,7 +1538,8 @@ export class BmeDatabase {
|
||||
const nowMs = normalizeTimestamp(fallbackNowMs);
|
||||
return toArray(tombstones)
|
||||
.map((record) => {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
||||
if (!record || typeof record !== "object" || Array.isArray(record))
|
||||
return null;
|
||||
|
||||
const id = normalizeRecordId(record.id);
|
||||
if (!id) return null;
|
||||
|
||||
@@ -96,28 +96,65 @@ export function installSendIntentHooksController(runtime) {
|
||||
|
||||
export function registerCoreEventHooksController(runtime) {
|
||||
const { eventSource, eventTypes, handlers } = runtime;
|
||||
const registrationState = runtime.getCoreEventBindingState?.() || {};
|
||||
|
||||
eventSource.on(eventTypes.CHAT_CHANGED, handlers.onChatChanged);
|
||||
if (registrationState.registered) {
|
||||
runtime.console?.warn?.("[ST-BME] 核心事件已注册,跳过重复绑定");
|
||||
return registrationState;
|
||||
}
|
||||
|
||||
const cleanups = [];
|
||||
const bind = (eventName, listener) => {
|
||||
if (!eventName || typeof listener !== "function") return;
|
||||
eventSource.on(eventName, listener);
|
||||
if (typeof eventSource.off === "function") {
|
||||
cleanups.push(() => eventSource.off(eventName, listener));
|
||||
} else if (typeof eventSource.removeListener === "function") {
|
||||
cleanups.push(() => eventSource.removeListener(eventName, listener));
|
||||
}
|
||||
};
|
||||
|
||||
bind(eventTypes.CHAT_CHANGED, handlers.onChatChanged);
|
||||
if (eventTypes.CHAT_LOADED) {
|
||||
eventSource.on(eventTypes.CHAT_LOADED, handlers.onChatLoaded);
|
||||
bind(eventTypes.CHAT_LOADED, handlers.onChatLoaded);
|
||||
}
|
||||
if (eventTypes.MESSAGE_SENT) {
|
||||
eventSource.on(eventTypes.MESSAGE_SENT, handlers.onMessageSent);
|
||||
bind(eventTypes.MESSAGE_SENT, handlers.onMessageSent);
|
||||
}
|
||||
|
||||
runtime.registerGenerationAfterCommands(handlers.onGenerationAfterCommands);
|
||||
runtime.registerBeforeCombinePrompts(handlers.onBeforeCombinePrompts);
|
||||
const beforeCombineCleanup = runtime.registerBeforeCombinePrompts(
|
||||
handlers.onBeforeCombinePrompts,
|
||||
);
|
||||
if (typeof beforeCombineCleanup === "function") {
|
||||
cleanups.push(beforeCombineCleanup);
|
||||
}
|
||||
|
||||
eventSource.on(eventTypes.MESSAGE_RECEIVED, handlers.onMessageReceived);
|
||||
eventSource.on(eventTypes.MESSAGE_DELETED, handlers.onMessageDeleted);
|
||||
eventSource.on(eventTypes.MESSAGE_EDITED, handlers.onMessageEdited);
|
||||
eventSource.on(eventTypes.MESSAGE_SWIPED, handlers.onMessageSwiped);
|
||||
const afterCommandsCleanup = runtime.registerGenerationAfterCommands(
|
||||
handlers.onGenerationAfterCommands,
|
||||
);
|
||||
if (typeof afterCommandsCleanup === "function") {
|
||||
cleanups.push(afterCommandsCleanup);
|
||||
}
|
||||
|
||||
bind(eventTypes.MESSAGE_RECEIVED, handlers.onMessageReceived);
|
||||
bind(eventTypes.MESSAGE_DELETED, handlers.onMessageDeleted);
|
||||
bind(eventTypes.MESSAGE_EDITED, handlers.onMessageEdited);
|
||||
bind(eventTypes.MESSAGE_SWIPED, handlers.onMessageSwiped);
|
||||
if (eventTypes.MESSAGE_UPDATED) {
|
||||
eventSource.on(eventTypes.MESSAGE_UPDATED, handlers.onMessageEdited);
|
||||
bind(eventTypes.MESSAGE_UPDATED, handlers.onMessageEdited);
|
||||
}
|
||||
|
||||
const nextState = {
|
||||
registered: true,
|
||||
cleanups,
|
||||
registeredAt: Date.now(),
|
||||
};
|
||||
runtime.setCoreEventBindingState?.(nextState);
|
||||
return nextState;
|
||||
}
|
||||
|
||||
export function onChatChangedController(runtime) {
|
||||
runtime.clearCoreEventBindingState?.();
|
||||
runtime.clearPendingHistoryMutationChecks();
|
||||
runtime.clearTimeout(runtime.getPendingHistoryRecoveryTimer());
|
||||
runtime.setPendingHistoryRecoveryTimer(null);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// ST-BME: 图谱持久化常量与纯工具函数
|
||||
// 不依赖 index.js 模块级可变状态(currentGraph / graphPersistenceState 等)
|
||||
|
||||
import {
|
||||
deserializeGraph,
|
||||
serializeGraph,
|
||||
} from "./graph.js";
|
||||
import { deserializeGraph, serializeGraph } from "./graph.js";
|
||||
import { normalizeGraphRuntimeState } from "./runtime-state.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -167,6 +164,9 @@ export function readGraphShadowSnapshot(chatId = "") {
|
||||
serializedGraph: snapshot.serializedGraph,
|
||||
updatedAt: String(snapshot.updatedAt || ""),
|
||||
reason: String(snapshot.reason || ""),
|
||||
integrity: String(snapshot.integrity || ""),
|
||||
persistedChatId: String(snapshot.persistedChatId || ""),
|
||||
debugReason: String(snapshot.debugReason || snapshot.reason || ""),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -183,13 +183,14 @@ export function readGraphShadowSnapshot(chatId = "") {
|
||||
export function writeGraphShadowSnapshot(
|
||||
chatId,
|
||||
graph,
|
||||
{ revision = 0, reason = "" } = {},
|
||||
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
|
||||
) {
|
||||
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!storageKey || !graph) return false;
|
||||
|
||||
try {
|
||||
const serializedGraph = serializeGraph(graph);
|
||||
const persistedMeta = getGraphPersistenceMeta(graph) || {};
|
||||
globalThis.sessionStorage?.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
@@ -198,6 +199,9 @@ export function writeGraphShadowSnapshot(
|
||||
serializedGraph,
|
||||
updatedAt: new Date().toISOString(),
|
||||
reason: String(reason || ""),
|
||||
integrity: String(integrity || persistedMeta.integrity || ""),
|
||||
persistedChatId: String(persistedMeta.chatId || ""),
|
||||
debugReason: String(debugReason || reason || ""),
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
@@ -230,9 +234,89 @@ export function cloneGraphForPersistence(graph, chatId = "") {
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldPreferShadowSnapshotOverOfficial(officialGraph, shadowSnapshot) {
|
||||
if (!shadowSnapshot) return false;
|
||||
export function shouldPreferShadowSnapshotOverOfficial(
|
||||
officialGraph,
|
||||
shadowSnapshot,
|
||||
) {
|
||||
if (!shadowSnapshot) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-missing",
|
||||
};
|
||||
}
|
||||
|
||||
const shadowRevision = Number(shadowSnapshot.revision || 0);
|
||||
const officialRevision = getGraphPersistedRevision(officialGraph);
|
||||
return shadowRevision > 0 && shadowRevision > officialRevision;
|
||||
const officialMeta = getGraphPersistenceMeta(officialGraph) || {};
|
||||
const normalizedOfficialChatId = String(officialMeta.chatId || "").trim();
|
||||
const normalizedShadowChatId = String(shadowSnapshot.chatId || "").trim();
|
||||
const normalizedShadowPersistedChatId = String(
|
||||
shadowSnapshot.persistedChatId || "",
|
||||
).trim();
|
||||
const officialIntegrity = String(officialMeta.integrity || "").trim();
|
||||
const shadowIntegrity = String(shadowSnapshot.integrity || "").trim();
|
||||
|
||||
if (shadowRevision <= 0) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-revision-invalid",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedOfficialChatId &&
|
||||
normalizedShadowPersistedChatId &&
|
||||
normalizedOfficialChatId !== normalizedShadowPersistedChatId
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-persisted-chat-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
officialChatId: normalizedOfficialChatId,
|
||||
shadowPersistedChatId: normalizedShadowPersistedChatId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedOfficialChatId &&
|
||||
normalizedShadowChatId &&
|
||||
normalizedOfficialChatId !== normalizedShadowChatId
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-chat-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
officialChatId: normalizedOfficialChatId,
|
||||
shadowChatId: normalizedShadowChatId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
officialIntegrity &&
|
||||
shadowIntegrity &&
|
||||
officialIntegrity !== shadowIntegrity
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-integrity-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
officialIntegrity,
|
||||
shadowIntegrity,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prefer: shadowRevision > 0 && shadowRevision > officialRevision,
|
||||
reason:
|
||||
shadowRevision > officialRevision
|
||||
? "shadow-newer-than-official"
|
||||
: "shadow-not-newer-than-official",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
};
|
||||
}
|
||||
|
||||
40
graph.js
40
graph.js
@@ -136,8 +136,13 @@ export function updateNode(graph, nodeId, updates) {
|
||||
* @param {string} nodeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function removeNode(graph, nodeId) {
|
||||
const node = getNode(graph, nodeId);
|
||||
export function removeNode(graph, nodeId, visited = new Set()) {
|
||||
const normalizedNodeId = String(nodeId || "");
|
||||
if (!normalizedNodeId) return false;
|
||||
if (visited.has(normalizedNodeId)) return false;
|
||||
visited.add(normalizedNodeId);
|
||||
|
||||
const node = getNode(graph, normalizedNodeId);
|
||||
if (!node) return false;
|
||||
|
||||
// 修复时间链表
|
||||
@@ -150,26 +155,39 @@ export function removeNode(graph, nodeId) {
|
||||
if (next) next.prevId = node.prevId;
|
||||
}
|
||||
|
||||
// 递归删除子节点
|
||||
// 递归删除子节点(带环保护)
|
||||
for (const childId of node.childIds) {
|
||||
removeNode(graph, childId);
|
||||
removeNode(graph, childId, visited);
|
||||
}
|
||||
|
||||
// 从父节点中移除引用
|
||||
if (node.parentId) {
|
||||
const parent = getNode(graph, node.parentId);
|
||||
if (parent) {
|
||||
parent.childIds = parent.childIds.filter((id) => id !== nodeId);
|
||||
parent.childIds = parent.childIds.filter((id) => id !== normalizedNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// 同时清理其它节点上可能残留的脏 child 引用,避免导入脏图残留环
|
||||
for (const candidate of graph.nodes) {
|
||||
if (
|
||||
!Array.isArray(candidate?.childIds) ||
|
||||
candidate.id === normalizedNodeId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
candidate.childIds = candidate.childIds.filter(
|
||||
(id) => id !== normalizedNodeId,
|
||||
);
|
||||
}
|
||||
|
||||
// 删除相关边
|
||||
graph.edges = graph.edges.filter(
|
||||
(e) => e.fromId !== nodeId && e.toId !== nodeId,
|
||||
(e) => e.fromId !== normalizedNodeId && e.toId !== normalizedNodeId,
|
||||
);
|
||||
|
||||
// 删除节点本身
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== nodeId);
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== normalizedNodeId);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -545,7 +563,9 @@ export function deserializeGraph(json) {
|
||||
extractionCount: Number.isFinite(data?.historyState?.extractionCount)
|
||||
? data.historyState.extractionCount
|
||||
: 0,
|
||||
lastMutationSource: String(data?.historyState?.lastMutationSource || ""),
|
||||
lastMutationSource: String(
|
||||
data?.historyState?.lastMutationSource || "",
|
||||
),
|
||||
};
|
||||
data.batchJournal = Array.isArray(data.batchJournal)
|
||||
? data.batchJournal
|
||||
@@ -623,7 +643,9 @@ export function exportGraph(graph) {
|
||||
historyState: {
|
||||
...createDefaultHistoryState(graph?.historyState?.chatId || ""),
|
||||
lastProcessedAssistantFloor:
|
||||
graph?.historyState?.lastProcessedAssistantFloor ?? graph?.lastProcessedSeq ?? -1,
|
||||
graph?.historyState?.lastProcessedAssistantFloor ??
|
||||
graph?.lastProcessedSeq ??
|
||||
-1,
|
||||
},
|
||||
vectorIndexState: {
|
||||
...createDefaultVectorIndexState(graph?.historyState?.chatId || ""),
|
||||
|
||||
243
index.js
243
index.js
@@ -447,6 +447,11 @@ const lastStatusToastAt = {};
|
||||
let pendingRecallSendIntent = createRecallInputRecord();
|
||||
let lastRecallSentUserMessage = createRecallInputRecord();
|
||||
let pendingHostGenerationInputSnapshot = createRecallInputRecord();
|
||||
let coreEventBindingState = {
|
||||
registered: false,
|
||||
cleanups: [],
|
||||
registeredAt: 0,
|
||||
};
|
||||
let sendIntentHookCleanup = [];
|
||||
let sendIntentHookRetryTimer = null;
|
||||
let pendingHistoryRecoveryTimer = null;
|
||||
@@ -1031,6 +1036,41 @@ function clearRecallInputTracking() {
|
||||
pendingHostGenerationInputSnapshot = createRecallInputRecord();
|
||||
}
|
||||
|
||||
function getCoreEventBindingState() {
|
||||
return coreEventBindingState;
|
||||
}
|
||||
|
||||
function setCoreEventBindingState(nextState = {}) {
|
||||
coreEventBindingState = {
|
||||
registered: Boolean(nextState?.registered),
|
||||
cleanups: Array.isArray(nextState?.cleanups) ? nextState.cleanups : [],
|
||||
registeredAt: Number(nextState?.registeredAt) || 0,
|
||||
};
|
||||
return coreEventBindingState;
|
||||
}
|
||||
|
||||
function clearCoreEventBindingState() {
|
||||
const cleanups = Array.isArray(coreEventBindingState?.cleanups)
|
||||
? coreEventBindingState.cleanups.splice(
|
||||
0,
|
||||
coreEventBindingState.cleanups.length,
|
||||
)
|
||||
: [];
|
||||
for (const cleanup of cleanups) {
|
||||
try {
|
||||
cleanup?.();
|
||||
} catch (error) {
|
||||
console.warn("[ST-BME] 清理核心事件绑定失败:", error);
|
||||
}
|
||||
}
|
||||
coreEventBindingState = {
|
||||
registered: false,
|
||||
cleanups: [],
|
||||
registeredAt: 0,
|
||||
};
|
||||
return coreEventBindingState;
|
||||
}
|
||||
|
||||
function freezeHostGenerationInputSnapshot(
|
||||
text,
|
||||
source = "host-generation-lifecycle",
|
||||
@@ -1071,19 +1111,36 @@ function getPendingHostGenerationInputSnapshot() {
|
||||
|
||||
function recordRecallSendIntent(text, source = "dom-intent") {
|
||||
const normalized = normalizeRecallInputText(text);
|
||||
if (!normalized) return;
|
||||
if (!normalized) return createRecallInputRecord();
|
||||
|
||||
const hash = hashRecallInput(normalized);
|
||||
const previousRecord = isFreshRecallInputRecord(pendingRecallSendIntent)
|
||||
? pendingRecallSendIntent
|
||||
: null;
|
||||
const previousHash = String(previousRecord?.hash || "");
|
||||
const previousText = String(previousRecord?.text || "");
|
||||
|
||||
if (previousHash && previousHash === hash && previousText === normalized) {
|
||||
pendingRecallSendIntent = createRecallInputRecord({
|
||||
...previousRecord,
|
||||
at: Date.now(),
|
||||
source: String(source || previousRecord.source || "dom-intent"),
|
||||
});
|
||||
return pendingRecallSendIntent;
|
||||
}
|
||||
|
||||
pendingRecallSendIntent = createRecallInputRecord({
|
||||
text: normalized,
|
||||
hash: hashRecallInput(normalized),
|
||||
hash,
|
||||
source,
|
||||
at: Date.now(),
|
||||
});
|
||||
return pendingRecallSendIntent;
|
||||
}
|
||||
|
||||
function recordRecallSentUserMessage(messageId, text, source = "message-sent") {
|
||||
const normalized = normalizeRecallInputText(text);
|
||||
if (!normalized) return;
|
||||
if (!normalized) return createRecallInputRecord();
|
||||
|
||||
const hash = hashRecallInput(normalized);
|
||||
lastRecallSentUserMessage = createRecallInputRecord({
|
||||
@@ -1103,6 +1160,13 @@ function recordRecallSentUserMessage(messageId, text, source = "message-sent") {
|
||||
) {
|
||||
pendingHostGenerationInputSnapshot = createRecallInputRecord();
|
||||
}
|
||||
|
||||
const activeChatId = getCurrentChatId();
|
||||
if (activeChatId) {
|
||||
clearGenerationRecallTransactionsForChat(activeChatId);
|
||||
}
|
||||
|
||||
return lastRecallSentUserMessage;
|
||||
}
|
||||
|
||||
function getMessageRecallRecord(messageIndex) {
|
||||
@@ -2678,9 +2742,50 @@ function applyIndexedDbSnapshotToRuntime(
|
||||
1,
|
||||
normalizeIndexedDbRevision(snapshot?.meta?.revision),
|
||||
);
|
||||
const graphFromSnapshot = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: normalizedChatId,
|
||||
});
|
||||
let graphFromSnapshot = null;
|
||||
try {
|
||||
graphFromSnapshot = buildGraphFromSnapshot(snapshot, {
|
||||
chatId: normalizedChatId,
|
||||
});
|
||||
} catch (error) {
|
||||
const failureReason =
|
||||
error?.code === "BME_SNAPSHOT_INTEGRITY_ERROR"
|
||||
? "indexeddb-snapshot-integrity-rejected"
|
||||
: "indexeddb-snapshot-load-failed";
|
||||
updateGraphPersistenceState({
|
||||
storagePrimary: "indexeddb",
|
||||
storageMode: "indexeddb",
|
||||
dbReady: true,
|
||||
indexedDbRevision: revision,
|
||||
indexedDbLastError: error?.message || String(error),
|
||||
dualWriteLastResult: {
|
||||
action: "load",
|
||||
source: String(source || "indexeddb"),
|
||||
success: false,
|
||||
rejected: true,
|
||||
reason: failureReason,
|
||||
revision,
|
||||
at: Date.now(),
|
||||
},
|
||||
});
|
||||
console.warn("[ST-BME] IndexedDB 图谱快照已拒绝加载", {
|
||||
chatId: normalizedChatId,
|
||||
source,
|
||||
revision,
|
||||
reason: failureReason,
|
||||
detail: error?.message || String(error),
|
||||
integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [],
|
||||
});
|
||||
return {
|
||||
success: false,
|
||||
loaded: false,
|
||||
reason: failureReason,
|
||||
detail: error?.message || String(error),
|
||||
integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [],
|
||||
chatId: normalizedChatId,
|
||||
attemptIndex,
|
||||
};
|
||||
}
|
||||
currentGraph = cloneGraphForPersistence(
|
||||
normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
|
||||
normalizedChatId,
|
||||
@@ -2740,6 +2845,8 @@ function applyIndexedDbSnapshotToRuntime(
|
||||
dualWriteLastResult: {
|
||||
action: "load",
|
||||
source: String(source || "indexeddb"),
|
||||
success: true,
|
||||
reason: "indexeddb-loaded",
|
||||
revision,
|
||||
at: Date.now(),
|
||||
},
|
||||
@@ -4249,11 +4356,30 @@ function loadGraphFromChat(options = {}) {
|
||||
normalizeGraphRuntimeState(deserializeGraph(savedData), chatId),
|
||||
chatId,
|
||||
);
|
||||
const shadowSnapshot = readGraphShadowSnapshot(chatId);
|
||||
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
|
||||
officialGraph,
|
||||
shadowSnapshot,
|
||||
);
|
||||
const officialRevision = Math.max(
|
||||
1,
|
||||
getGraphPersistedRevision(officialGraph),
|
||||
);
|
||||
|
||||
if (shadowSnapshot && shadowDecision?.reason) {
|
||||
updateGraphPersistenceState({
|
||||
dualWriteLastResult: {
|
||||
action: "shadow-compare",
|
||||
source: `${source}:metadata-shadow-compare`,
|
||||
success: Boolean(shadowDecision.prefer),
|
||||
reason: shadowDecision.reason,
|
||||
shadowRevision: Number(shadowSnapshot.revision || 0),
|
||||
officialRevision,
|
||||
at: Date.now(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
clearPendingGraphLoadRetry();
|
||||
currentGraph = officialGraph;
|
||||
extractionCount = Number.isFinite(
|
||||
@@ -4295,9 +4421,11 @@ function loadGraphFromChat(options = {}) {
|
||||
queuedPersistChatId: "",
|
||||
pendingPersist: false,
|
||||
shadowSnapshotUsed: false,
|
||||
shadowSnapshotRevision: 0,
|
||||
shadowSnapshotUpdatedAt: "",
|
||||
shadowSnapshotReason: "",
|
||||
shadowSnapshotRevision: Number(shadowSnapshot?.revision || 0),
|
||||
shadowSnapshotUpdatedAt: String(shadowSnapshot?.updatedAt || ""),
|
||||
shadowSnapshotReason: String(
|
||||
shadowDecision?.reason || shadowSnapshot?.reason || "",
|
||||
),
|
||||
dbReady: false,
|
||||
writesBlocked: true,
|
||||
});
|
||||
@@ -5313,6 +5441,7 @@ function createGenerationRecallContext({
|
||||
transaction: null,
|
||||
recallOptions: null,
|
||||
shouldRun: false,
|
||||
guardReason: "missing-frozen-recall-options",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5327,6 +5456,18 @@ function createGenerationRecallContext({
|
||||
userMessage: frozenRecallOptions.overrideUserMessage,
|
||||
});
|
||||
|
||||
if (!normalizedChatId || !String(fallbackRecallKey || "").trim()) {
|
||||
return {
|
||||
hookName,
|
||||
generationType: transactionGenerationType,
|
||||
recallKey: "",
|
||||
transaction: null,
|
||||
recallOptions: null,
|
||||
shouldRun: false,
|
||||
guardReason: !normalizedChatId ? "missing-chat-id" : "missing-recall-key",
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const recentTransaction = findRecentGenerationRecallTransactionForChat(
|
||||
normalizedChatId,
|
||||
@@ -5352,11 +5493,32 @@ function createGenerationRecallContext({
|
||||
if (!transaction) {
|
||||
return {
|
||||
hookName,
|
||||
generationType,
|
||||
generationType: transactionGenerationType,
|
||||
recallKey: "",
|
||||
transaction: null,
|
||||
recallOptions: null,
|
||||
shouldRun: false,
|
||||
guardReason: "transaction-unavailable",
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedTransactionChatId = normalizeChatIdCandidate(
|
||||
transaction.chatId,
|
||||
);
|
||||
const transactionRecallKey = String(transaction.recallKey || "").trim();
|
||||
if (
|
||||
normalizedTransactionChatId !== normalizedChatId ||
|
||||
!transactionRecallKey ||
|
||||
transactionRecallKey !== String(fallbackRecallKey)
|
||||
) {
|
||||
return {
|
||||
hookName,
|
||||
generationType: transactionGenerationType,
|
||||
recallKey: String(fallbackRecallKey || ""),
|
||||
transaction,
|
||||
recallOptions: null,
|
||||
shouldRun: false,
|
||||
guardReason: "transaction-mismatch",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5368,9 +5530,6 @@ function createGenerationRecallContext({
|
||||
...frozenRecallOptions,
|
||||
};
|
||||
}
|
||||
if (!String(transaction.recallKey || "").trim()) {
|
||||
transaction.recallKey = fallbackRecallKey;
|
||||
}
|
||||
if (!String(transaction.generationType || "").trim()) {
|
||||
transaction.generationType = transactionGenerationType;
|
||||
}
|
||||
@@ -5384,7 +5543,7 @@ function createGenerationRecallContext({
|
||||
transaction.frozenRecallOptions?.generationType || generationType,
|
||||
};
|
||||
|
||||
const recallKey = String(transaction.recallKey || fallbackRecallKey || "");
|
||||
const recallKey = transactionRecallKey;
|
||||
const shouldRun = shouldRunRecallForTransaction(transaction, hookName);
|
||||
|
||||
return {
|
||||
@@ -5394,6 +5553,7 @@ function createGenerationRecallContext({
|
||||
transaction,
|
||||
recallOptions: boundRecallOptions,
|
||||
shouldRun,
|
||||
guardReason: shouldRun ? "" : "transaction-not-runnable",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5944,21 +6104,35 @@ function applyRecoveryPlanToVectorState(
|
||||
if (nodeId) replayRequiredNodeIds.add(nodeId);
|
||||
}
|
||||
|
||||
const fallbackFloor = Number.isFinite(dirtyFallbackFloor)
|
||||
? dirtyFallbackFloor
|
||||
: currentGraph.historyState?.historyDirtyFrom;
|
||||
const pendingRepairFromFloor = Number.isFinite(
|
||||
recoveryPlan?.pendingRepairFromFloor,
|
||||
)
|
||||
? recoveryPlan.pendingRepairFromFloor
|
||||
: Number.isFinite(fallbackFloor)
|
||||
? fallbackFloor
|
||||
: null;
|
||||
|
||||
vectorState.replayRequiredNodeIds = [...replayRequiredNodeIds];
|
||||
vectorState.dirty = true;
|
||||
vectorState.dirtyReason =
|
||||
recoveryPlan?.dirtyReason ||
|
||||
vectorState.dirtyReason ||
|
||||
"history-recovery-replay";
|
||||
const fallbackFloor = Number.isFinite(dirtyFallbackFloor)
|
||||
? dirtyFallbackFloor
|
||||
: currentGraph.historyState?.historyDirtyFrom;
|
||||
vectorState.pendingRepairFromFloor = Number.isFinite(
|
||||
recoveryPlan?.pendingRepairFromFloor,
|
||||
)
|
||||
? recoveryPlan.pendingRepairFromFloor
|
||||
: Number.isFinite(fallbackFloor)
|
||||
? fallbackFloor
|
||||
vectorState.pendingRepairFromFloor = pendingRepairFromFloor;
|
||||
vectorState.lastIntegrityIssue =
|
||||
recoveryPlan?.valid === false
|
||||
? {
|
||||
scope: "history-recovery-plan",
|
||||
reason: String(recoveryPlan.invalidReason || "invalid-recovery-plan"),
|
||||
dirtyFallbackFloor: Number.isFinite(fallbackFloor)
|
||||
? fallbackFloor
|
||||
: null,
|
||||
pendingRepairFromFloor,
|
||||
at: Date.now(),
|
||||
}
|
||||
: null;
|
||||
vectorState.lastWarning = recoveryPlan?.legacyGapFallback
|
||||
? "历史恢复检测到 legacy-gap,向量索引需按受影响后缀修复"
|
||||
@@ -5996,6 +6170,18 @@ async function rollbackGraphForReroll(targetFloor, context = getContext()) {
|
||||
recoveryPoint.affectedJournals,
|
||||
targetFloor,
|
||||
);
|
||||
if (recoveryPlan?.valid === false) {
|
||||
return {
|
||||
success: false,
|
||||
rollbackPerformed: false,
|
||||
extractionTriggered: false,
|
||||
requestedFloor: targetFloor,
|
||||
effectiveFromFloor: null,
|
||||
recoveryPath: "reverse-journal-rejected",
|
||||
affectedBatchCount,
|
||||
error: `回滚计划完整性校验失败: ${recoveryPlan.invalidReason || "unknown"}`,
|
||||
};
|
||||
}
|
||||
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
|
||||
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
|
||||
extractionCount = currentGraph.historyState.extractionCount || 0;
|
||||
@@ -6131,6 +6317,13 @@ async function recoverHistoryIfNeeded(trigger = "history-recovery") {
|
||||
recoveryPoint.affectedJournals,
|
||||
initialDirtyFrom,
|
||||
);
|
||||
if (recoveryPlan?.valid === false) {
|
||||
throw new Error(
|
||||
`reverse-journal recovery plan invalid: ${
|
||||
recoveryPlan.invalidReason || "unknown"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
rollbackAffectedJournals(currentGraph, recoveryPoint.affectedJournals);
|
||||
currentGraph = normalizeGraphRuntimeState(currentGraph, chatId);
|
||||
extractionCount = currentGraph.historyState.extractionCount || 0;
|
||||
@@ -6466,6 +6659,7 @@ async function runRecall(options = {}) {
|
||||
function onChatChanged() {
|
||||
const result = onChatChangedController({
|
||||
abortAllRunningStages,
|
||||
clearCoreEventBindingState,
|
||||
clearGenerationRecallTransactionsForChat,
|
||||
clearInjectionState,
|
||||
clearPendingGraphLoadRetry,
|
||||
@@ -6914,8 +7108,10 @@ async function onReembedDirect() {
|
||||
|
||||
// 注册事件钩子
|
||||
registerCoreEventHooksController({
|
||||
console,
|
||||
eventSource,
|
||||
eventTypes: event_types,
|
||||
getCoreEventBindingState,
|
||||
handlers: {
|
||||
onBeforeCombinePrompts,
|
||||
onChatChanged,
|
||||
@@ -6929,6 +7125,7 @@ async function onReembedDirect() {
|
||||
},
|
||||
registerBeforeCombinePrompts,
|
||||
registerGenerationAfterCommands,
|
||||
setCoreEventBindingState,
|
||||
});
|
||||
|
||||
// 加载当前聊天的图谱
|
||||
|
||||
@@ -41,6 +41,7 @@ export function createDefaultVectorIndexState(chatId = "") {
|
||||
pending: 0,
|
||||
},
|
||||
lastWarning: "",
|
||||
lastIntegrityIssue: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,6 +134,13 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
|
||||
if (!Number.isFinite(vectorIndexState.pendingRepairFromFloor)) {
|
||||
vectorIndexState.pendingRepairFromFloor = null;
|
||||
}
|
||||
if (
|
||||
vectorIndexState.lastIntegrityIssue != null &&
|
||||
(typeof vectorIndexState.lastIntegrityIssue !== "object" ||
|
||||
Array.isArray(vectorIndexState.lastIntegrityIssue))
|
||||
) {
|
||||
vectorIndexState.lastIntegrityIssue = null;
|
||||
}
|
||||
|
||||
const previousCollectionId = vectorIndexState.collectionId;
|
||||
vectorIndexState.collectionId = buildVectorCollectionId(
|
||||
@@ -253,7 +261,12 @@ export function detectHistoryMutation(chat, historyState) {
|
||||
}
|
||||
|
||||
for (let floor = 0; floor <= lastProcessedAssistantFloor; floor++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(processedMessageHashes, String(floor))) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
processedMessageHashes,
|
||||
String(floor),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
dirty: true,
|
||||
earliestAffectedFloor: floor,
|
||||
@@ -679,6 +692,11 @@ export function buildReverseJournalRecoveryPlan(
|
||||
let minProcessedFloor = Number.isFinite(dirtyFromFloor)
|
||||
? dirtyFromFloor
|
||||
: null;
|
||||
let invalidJournalReason = "";
|
||||
|
||||
if (!Array.isArray(affectedJournals) || affectedJournals.length === 0) {
|
||||
invalidJournalReason = "affected-journals-empty";
|
||||
}
|
||||
|
||||
for (const journal of affectedJournals) {
|
||||
const vectorDelta = journal?.vectorDelta || {};
|
||||
@@ -698,6 +716,13 @@ export function buildReverseJournalRecoveryPlan(
|
||||
? journal.processedRange
|
||||
: [-1, -1];
|
||||
|
||||
if (
|
||||
!invalidJournalReason &&
|
||||
(!Number.isFinite(range[0]) || !Number.isFinite(range[1]))
|
||||
) {
|
||||
invalidJournalReason = "processed-range-missing";
|
||||
}
|
||||
|
||||
if (Number.isFinite(range[0])) {
|
||||
minProcessedFloor = Number.isFinite(minProcessedFloor)
|
||||
? Math.min(minProcessedFloor, range[0])
|
||||
@@ -751,6 +776,17 @@ export function buildReverseJournalRecoveryPlan(
|
||||
pendingRepairFromFloor,
|
||||
legacyGapFallback: hasLegacyGap,
|
||||
dirtyReason: hasLegacyGap ? "legacy-gap" : "history-recovery-replay",
|
||||
valid:
|
||||
!invalidJournalReason &&
|
||||
Number.isFinite(pendingRepairFromFloor) &&
|
||||
pendingRepairFromFloor >= 0,
|
||||
invalidReason:
|
||||
invalidJournalReason ||
|
||||
(!Number.isFinite(pendingRepairFromFloor)
|
||||
? "pending-repair-floor-missing"
|
||||
: pendingRepairFromFloor < 0
|
||||
? "pending-repair-floor-negative"
|
||||
: ""),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -758,6 +794,12 @@ export function buildRecoveryResult(status, extra = {}) {
|
||||
return {
|
||||
status,
|
||||
at: Date.now(),
|
||||
debugReason:
|
||||
typeof extra?.debugReason === "string" && extra.debugReason.trim()
|
||||
? extra.debugReason.trim()
|
||||
: typeof extra?.reason === "string"
|
||||
? extra.reason
|
||||
: "",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,34 +4,13 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
|
||||
import {
|
||||
createEmptyGraph,
|
||||
deserializeGraph,
|
||||
getGraphStats,
|
||||
getNode,
|
||||
serializeGraph,
|
||||
} from "../graph.js";
|
||||
import { normalizeGraphRuntimeState } from "../runtime-state.js";
|
||||
import {
|
||||
createUiStatus,
|
||||
createGraphPersistenceState,
|
||||
createRecallInputRecord,
|
||||
createRecallRunResult,
|
||||
normalizeStageNoticeLevel,
|
||||
getStageNoticeTitle,
|
||||
getStageNoticeDuration,
|
||||
normalizeRecallInputText,
|
||||
hashRecallInput,
|
||||
isFreshRecallInputRecord,
|
||||
clampInt,
|
||||
clampFloat,
|
||||
formatRecallContextLine,
|
||||
} from "../ui-status.js";
|
||||
import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js";
|
||||
import { onMessageReceivedController } from "../event-binding.js";
|
||||
import {
|
||||
cloneGraphForPersistence,
|
||||
cloneRuntimeDebugValue,
|
||||
getGraphPersistenceMeta,
|
||||
getGraphPersistedRevision,
|
||||
getGraphPersistenceMeta,
|
||||
getGraphShadowSnapshotStorageKey,
|
||||
GRAPH_LOAD_PENDING_CHAT_ID,
|
||||
GRAPH_LOAD_STATES,
|
||||
@@ -48,8 +27,29 @@ import {
|
||||
writeChatMetadataPatch,
|
||||
writeGraphShadowSnapshot,
|
||||
} from "../graph-persistence.js";
|
||||
import { onMessageReceivedController } from "../event-binding.js";
|
||||
import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js";
|
||||
import {
|
||||
createEmptyGraph,
|
||||
deserializeGraph,
|
||||
getGraphStats,
|
||||
getNode,
|
||||
serializeGraph,
|
||||
} from "../graph.js";
|
||||
import { normalizeGraphRuntimeState } from "../runtime-state.js";
|
||||
import {
|
||||
clampFloat,
|
||||
clampInt,
|
||||
createGraphPersistenceState,
|
||||
createRecallInputRecord,
|
||||
createRecallRunResult,
|
||||
createUiStatus,
|
||||
formatRecallContextLine,
|
||||
getStageNoticeDuration,
|
||||
getStageNoticeTitle,
|
||||
hashRecallInput,
|
||||
isFreshRecallInputRecord,
|
||||
normalizeRecallInputText,
|
||||
normalizeStageNoticeLevel,
|
||||
} from "../ui-status.js";
|
||||
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const indexPath = path.resolve(moduleDir, "../index.js");
|
||||
@@ -258,35 +258,63 @@ async function createGraphPersistenceHarness({
|
||||
const raw = storage.getItem(key);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || String(snap.chatId || "") !== String(chatId || "") ||
|
||||
typeof snap.serializedGraph !== "string" || !snap.serializedGraph) return null;
|
||||
if (
|
||||
!snap ||
|
||||
String(snap.chatId || "") !== String(chatId || "") ||
|
||||
typeof snap.serializedGraph !== "string" ||
|
||||
!snap.serializedGraph
|
||||
)
|
||||
return null;
|
||||
return {
|
||||
chatId: String(snap.chatId || ""),
|
||||
revision: Number.isFinite(snap.revision) ? snap.revision : 0,
|
||||
serializedGraph: snap.serializedGraph,
|
||||
updatedAt: String(snap.updatedAt || ""),
|
||||
reason: String(snap.reason || ""),
|
||||
integrity: String(snap.integrity || ""),
|
||||
persistedChatId: String(snap.persistedChatId || ""),
|
||||
debugReason: String(snap.debugReason || snap.reason || ""),
|
||||
};
|
||||
} catch { return null; }
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
writeGraphShadowSnapshot(chatId = "", graph = null, { revision = 0, reason = "" } = {}) {
|
||||
writeGraphShadowSnapshot(
|
||||
chatId = "",
|
||||
graph = null,
|
||||
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
|
||||
) {
|
||||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!key || !graph) return false;
|
||||
const persistedMeta = getGraphPersistenceMeta(graph) || {};
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({
|
||||
chatId: String(chatId || ""),
|
||||
revision: Number.isFinite(revision) ? revision : 0,
|
||||
serializedGraph: serializeGraph(graph),
|
||||
updatedAt: new Date().toISOString(),
|
||||
reason: String(reason || ""),
|
||||
}));
|
||||
storage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
chatId: String(chatId || ""),
|
||||
revision: Number.isFinite(revision) ? revision : 0,
|
||||
serializedGraph: serializeGraph(graph),
|
||||
updatedAt: new Date().toISOString(),
|
||||
reason: String(reason || ""),
|
||||
integrity: String(integrity || persistedMeta.integrity || ""),
|
||||
persistedChatId: String(persistedMeta.chatId || ""),
|
||||
debugReason: String(debugReason || reason || ""),
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
removeGraphShadowSnapshot(chatId = "") {
|
||||
const key = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!key) return false;
|
||||
try { storage.removeItem(key); return true; } catch { return false; }
|
||||
try {
|
||||
storage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
createDefaultTaskProfiles() {
|
||||
return {
|
||||
@@ -398,8 +426,12 @@ async function createGraphPersistenceHarness({
|
||||
},
|
||||
async isEmpty() {
|
||||
const snapshot = runtimeContext.__indexedDbSnapshot || {};
|
||||
const nodes = Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0;
|
||||
const edges = Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0;
|
||||
const nodes = Array.isArray(snapshot?.nodes)
|
||||
? snapshot.nodes.length
|
||||
: 0;
|
||||
const edges = Array.isArray(snapshot?.edges)
|
||||
? snapshot.edges.length
|
||||
: 0;
|
||||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||||
? snapshot.tombstones.length
|
||||
: 0;
|
||||
@@ -420,7 +452,16 @@ async function createGraphPersistenceHarness({
|
||||
migrationSource: "chat_metadata",
|
||||
},
|
||||
});
|
||||
return { migrated: true, revision, imported: { nodes: runtimeContext.__indexedDbSnapshot.nodes.length, edges: runtimeContext.__indexedDbSnapshot.edges.length, tombstones: runtimeContext.__indexedDbSnapshot.tombstones.length } };
|
||||
return {
|
||||
migrated: true,
|
||||
revision,
|
||||
imported: {
|
||||
nodes: runtimeContext.__indexedDbSnapshot.nodes.length,
|
||||
edges: runtimeContext.__indexedDbSnapshot.edges.length,
|
||||
tombstones:
|
||||
runtimeContext.__indexedDbSnapshot.tombstones.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
async markSyncDirty() {},
|
||||
};
|
||||
@@ -549,10 +590,7 @@ result = {
|
||||
"chat-global",
|
||||
);
|
||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceLiveState().writesBlocked,
|
||||
true,
|
||||
);
|
||||
assert.equal(harness.api.getGraphPersistenceLiveState().writesBlocked, true);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -599,7 +637,10 @@ result = {
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(harness.api.getCurrentGraph().historyState.chatId, "chat-late");
|
||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||||
assert.equal(harness.api.getGraphPersistenceState().storagePrimary, "indexeddb");
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceState().storagePrimary,
|
||||
"indexeddb",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -638,7 +679,10 @@ result = {
|
||||
|
||||
assert.equal(result.synced, true);
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(harness.api.getGraphPersistenceState().loadState, "empty-confirmed");
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceState().loadState,
|
||||
"empty-confirmed",
|
||||
);
|
||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||||
}
|
||||
|
||||
@@ -823,14 +867,20 @@ result = {
|
||||
chatId: "chat-late-reconcile",
|
||||
chatMetadata: {
|
||||
integrity: "chat-late-reconcile-ready",
|
||||
st_bme_graph: createMeaningfulGraph("chat-late-reconcile", "late-official"),
|
||||
st_bme_graph: createMeaningfulGraph(
|
||||
"chat-late-reconcile",
|
||||
"late-official",
|
||||
),
|
||||
},
|
||||
});
|
||||
harness.api.setIndexedDbSnapshot(
|
||||
buildSnapshotFromGraph(createMeaningfulGraph("chat-late-reconcile", "late-indexeddb"), {
|
||||
chatId: "chat-late-reconcile",
|
||||
revision: 7,
|
||||
}),
|
||||
buildSnapshotFromGraph(
|
||||
createMeaningfulGraph("chat-late-reconcile", "late-indexeddb"),
|
||||
{
|
||||
chatId: "chat-late-reconcile",
|
||||
revision: 7,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
harness.api.onMessageReceived();
|
||||
@@ -854,7 +904,10 @@ result = {
|
||||
},
|
||||
});
|
||||
harness.api.setCurrentGraph(
|
||||
normalizeGraphRuntimeState(createMeaningfulGraph("chat-sync-refresh", "stale-runtime"), "chat-sync-refresh"),
|
||||
normalizeGraphRuntimeState(
|
||||
createMeaningfulGraph("chat-sync-refresh", "stale-runtime"),
|
||||
"chat-sync-refresh",
|
||||
),
|
||||
);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
@@ -866,14 +919,20 @@ result = {
|
||||
writesBlocked: false,
|
||||
});
|
||||
harness.api.setIndexedDbSnapshot(
|
||||
buildSnapshotFromGraph(createMeaningfulGraph("chat-sync-refresh", "fresh-indexeddb"), {
|
||||
chatId: "chat-sync-refresh",
|
||||
revision: 7,
|
||||
}),
|
||||
buildSnapshotFromGraph(
|
||||
createMeaningfulGraph("chat-sync-refresh", "fresh-indexeddb"),
|
||||
{
|
||||
chatId: "chat-sync-refresh",
|
||||
revision: 7,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||||
await runtimeOptions.onSyncApplied({ chatId: "chat-sync-refresh", action: "download" });
|
||||
await runtimeOptions.onSyncApplied({
|
||||
chatId: "chat-sync-refresh",
|
||||
action: "download",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
@@ -1043,11 +1102,21 @@ result = {
|
||||
chatMetadata: undefined,
|
||||
sessionStore: sharedSession,
|
||||
});
|
||||
writer.api.writeGraphShadowSnapshot(
|
||||
"chat-shadow-newer",
|
||||
const shadowGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-shadow-newer", "shadow-newer"),
|
||||
{ revision: 9, reason: "pagehide-refresh" },
|
||||
{
|
||||
revision: 9,
|
||||
integrity: "integrity-shadow-mismatch",
|
||||
chatId: "chat-shadow-newer",
|
||||
reason: "pagehide-refresh",
|
||||
},
|
||||
);
|
||||
writer.api.writeGraphShadowSnapshot("chat-shadow-newer", shadowGraph, {
|
||||
revision: 9,
|
||||
reason: "pagehide-refresh",
|
||||
integrity: "integrity-shadow-mismatch",
|
||||
debugReason: "pagehide-refresh",
|
||||
});
|
||||
|
||||
const officialGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-shadow-newer", "official-older"),
|
||||
@@ -1067,7 +1136,10 @@ result = {
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(result.reason, "official-older-than-shadow:metadata-compat-provisional");
|
||||
assert.equal(
|
||||
result.reason,
|
||||
"official-older-than-shadow:metadata-compat-provisional",
|
||||
);
|
||||
assert.equal(
|
||||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-official-older",
|
||||
@@ -1083,6 +1155,9 @@ result = {
|
||||
"pagehide-refresh",
|
||||
"metadata 兼容加载后影子快照可保留,但不作为主链路恢复来源",
|
||||
);
|
||||
const live = reader.api.getGraphPersistenceLiveState();
|
||||
assert.equal(live.shadowSnapshotRevision, 9);
|
||||
assert.equal(live.shadowSnapshotReason, "shadow-integrity-mismatch");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1212,7 +1287,10 @@ result = {
|
||||
?.length,
|
||||
undefined,
|
||||
);
|
||||
assert.equal(reader.runtimeContext.__chatContext.chatMetadata?.integrity, "meta-ready-promote");
|
||||
assert.equal(
|
||||
reader.runtimeContext.__chatContext.chatMetadata?.integrity,
|
||||
"meta-ready-promote",
|
||||
);
|
||||
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 0);
|
||||
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
|
||||
assert.equal(live.lastPersistedRevision, 0);
|
||||
@@ -1603,7 +1681,10 @@ result = {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-indexeddb");
|
||||
assert.equal(harness.api.getGraphPersistenceState().storagePrimary, "indexeddb");
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceState().storagePrimary,
|
||||
"indexeddb",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -1644,9 +1725,15 @@ result = {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.ok(harness.runtimeContext.__syncNowCalls.length >= 1);
|
||||
assert.equal(harness.runtimeContext.__syncNowCalls[0].options.reason, "post-migration");
|
||||
assert.equal(
|
||||
harness.runtimeContext.__syncNowCalls[0].options.reason,
|
||||
"post-migration",
|
||||
);
|
||||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-legacy");
|
||||
assert.equal(harness.api.getIndexedDbSnapshot().meta.migrationSource, "chat_metadata");
|
||||
assert.equal(
|
||||
harness.api.getIndexedDbSnapshot().meta.migrationSource,
|
||||
"chat_metadata",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("graph-persistence tests passed");
|
||||
|
||||
@@ -8,6 +8,7 @@ import { pruneProcessedMessageHashesFromFloor } from "../chat-history.js";
|
||||
import {
|
||||
onBeforeCombinePromptsController,
|
||||
onGenerationAfterCommandsController,
|
||||
registerCoreEventHooksController,
|
||||
} from "../event-binding.js";
|
||||
import { onRerollController } from "../extraction-controller.js";
|
||||
import {
|
||||
@@ -123,8 +124,14 @@ globalThis.__p0ExtensionSettings = {
|
||||
globalThis.__stBmeTestOverrides = {};
|
||||
globalThis.require = require;
|
||||
|
||||
const { createEmptyGraph, createNode, addNode, createEdge, addEdge } =
|
||||
await import("../graph.js");
|
||||
const {
|
||||
createEmptyGraph,
|
||||
createNode,
|
||||
addNode,
|
||||
createEdge,
|
||||
addEdge,
|
||||
removeNode,
|
||||
} = await import("../graph.js");
|
||||
const { compressType } = await import("../compressor.js");
|
||||
const { syncGraphVectorIndex } = await import("../vector-index.js");
|
||||
const { extractMemories } = await import("../extractor.js");
|
||||
@@ -1699,6 +1706,8 @@ async function testReverseJournalRecoveryPlanLegacyFallback() {
|
||||
assert.equal(recoveryPlan.legacyGapFallback, true);
|
||||
assert.equal(recoveryPlan.dirtyReason, "legacy-gap");
|
||||
assert.equal(recoveryPlan.pendingRepairFromFloor, 5);
|
||||
assert.equal(recoveryPlan.valid, true);
|
||||
assert.equal(recoveryPlan.invalidReason, "");
|
||||
assert.deepEqual(recoveryPlan.backendDeleteHashes, ["hash_1"]);
|
||||
assert.deepEqual(recoveryPlan.replayRequiredNodeIds, []);
|
||||
}
|
||||
@@ -1741,6 +1750,8 @@ async function testReverseJournalRecoveryPlanAggregatesDeletesAndReplay() {
|
||||
assert.equal(recoveryPlan.legacyGapFallback, false);
|
||||
assert.equal(recoveryPlan.dirtyReason, "history-recovery-replay");
|
||||
assert.equal(recoveryPlan.pendingRepairFromFloor, 4);
|
||||
assert.equal(recoveryPlan.valid, true);
|
||||
assert.equal(recoveryPlan.invalidReason, "");
|
||||
assert.deepEqual(recoveryPlan.backendDeleteHashes.sort(), [
|
||||
"hash_backend",
|
||||
"hash_new",
|
||||
@@ -1956,6 +1967,8 @@ async function testReverseJournalRecoveryPlanMixedLegacyAndCurrentRetainsRepairS
|
||||
assert.equal(recoveryPlan.legacyGapFallback, true);
|
||||
assert.equal(recoveryPlan.dirtyReason, "legacy-gap");
|
||||
assert.equal(recoveryPlan.pendingRepairFromFloor, 7);
|
||||
assert.equal(recoveryPlan.valid, true);
|
||||
assert.equal(recoveryPlan.invalidReason, "");
|
||||
assert.deepEqual(recoveryPlan.replayRequiredNodeIds.sort(), [
|
||||
"node-current",
|
||||
"node-extra",
|
||||
@@ -2399,6 +2412,107 @@ async function testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine() {
|
||||
assert.equal(transaction.hookStates.GENERATION_AFTER_COMMANDS, "skipped");
|
||||
}
|
||||
|
||||
async function testGenerationRecallSentMessageClearsStaleTransactionForSameKey() {
|
||||
const harness = await createGenerationRecallHarness();
|
||||
harness.chat = [{ is_user: true, mes: "同 key 发送后重开" }];
|
||||
|
||||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||||
assert.equal(harness.runRecallCalls.length, 1);
|
||||
assert.equal(harness.result.generationRecallTransactions.size, 1);
|
||||
|
||||
harness.recordRecallSentUserMessage(0, "同 key 发送后重开");
|
||||
await harness.result.onGenerationAfterCommands("normal", {}, false);
|
||||
|
||||
assert.equal(harness.runRecallCalls.length, 2);
|
||||
}
|
||||
|
||||
async function testRegisterCoreEventHooksIsIdempotent() {
|
||||
const eventRegistrations = [];
|
||||
const makeFirstRegistrations = [];
|
||||
const bindingState = { registered: false, cleanups: [], registeredAt: 0 };
|
||||
const eventSource = {
|
||||
on(eventName, listener) {
|
||||
eventRegistrations.push({ eventName, listener });
|
||||
},
|
||||
off() {},
|
||||
};
|
||||
const runtime = {
|
||||
console: { warn() {} },
|
||||
eventSource,
|
||||
eventTypes: {
|
||||
CHAT_CHANGED: "chat-changed",
|
||||
CHAT_LOADED: "chat-loaded",
|
||||
MESSAGE_SENT: "message-sent",
|
||||
MESSAGE_RECEIVED: "message-received",
|
||||
MESSAGE_DELETED: "message-deleted",
|
||||
MESSAGE_EDITED: "message-edited",
|
||||
MESSAGE_SWIPED: "message-swiped",
|
||||
MESSAGE_UPDATED: "message-updated",
|
||||
},
|
||||
handlers: {
|
||||
onChatChanged() {},
|
||||
onChatLoaded() {},
|
||||
onMessageSent() {},
|
||||
onGenerationAfterCommands() {},
|
||||
onBeforeCombinePrompts() {},
|
||||
onMessageReceived() {},
|
||||
onMessageDeleted() {},
|
||||
onMessageEdited() {},
|
||||
onMessageSwiped() {},
|
||||
},
|
||||
registerGenerationAfterCommands(listener) {
|
||||
makeFirstRegistrations.push({ hook: "after", listener });
|
||||
return () => {};
|
||||
},
|
||||
registerBeforeCombinePrompts(listener) {
|
||||
makeFirstRegistrations.push({ hook: "before", listener });
|
||||
return () => {};
|
||||
},
|
||||
getCoreEventBindingState: () => bindingState,
|
||||
setCoreEventBindingState(nextState) {
|
||||
bindingState.registered = Boolean(nextState?.registered);
|
||||
bindingState.cleanups = Array.isArray(nextState?.cleanups)
|
||||
? nextState.cleanups
|
||||
: [];
|
||||
bindingState.registeredAt = Number(nextState?.registeredAt) || 0;
|
||||
return bindingState;
|
||||
},
|
||||
};
|
||||
|
||||
registerCoreEventHooksController(runtime);
|
||||
registerCoreEventHooksController(runtime);
|
||||
|
||||
assert.equal(eventRegistrations.length, 8);
|
||||
assert.equal(makeFirstRegistrations.length, 2);
|
||||
assert.equal(bindingState.registered, true);
|
||||
}
|
||||
|
||||
async function testRemoveNodeHandlesCyclicChildGraph() {
|
||||
const graph = createEmptyGraph();
|
||||
const nodeA = addNode(
|
||||
graph,
|
||||
createNode({ type: "event", fields: { title: "A" }, seq: 0 }),
|
||||
);
|
||||
const nodeB = addNode(
|
||||
graph,
|
||||
createNode({ type: "event", fields: { title: "B" }, seq: 1 }),
|
||||
);
|
||||
nodeA.childIds = [nodeB.id];
|
||||
nodeB.parentId = nodeA.id;
|
||||
nodeB.childIds = [nodeA.id];
|
||||
nodeA.parentId = nodeB.id;
|
||||
addEdge(
|
||||
graph,
|
||||
createEdge({ fromId: nodeA.id, toId: nodeB.id, relation: "cycle" }),
|
||||
);
|
||||
|
||||
const removed = removeNode(graph, nodeA.id);
|
||||
|
||||
assert.equal(removed, true);
|
||||
assert.equal(graph.nodes.length, 0);
|
||||
assert.equal(graph.edges.length, 0);
|
||||
}
|
||||
|
||||
async function testGenerationRecallAppliesFinalInjectionOncePerTransaction() {
|
||||
const harness = await createGenerationRecallHarness();
|
||||
harness.chat = [{ is_user: true, mes: "同一轮仅一次最终注入" }];
|
||||
@@ -3059,6 +3173,9 @@ await testBeforeCombineRecallNotSkippedWhenGraphLoadingButRuntimeGraphReadable()
|
||||
await testGenerationRecallBeforeCombineRunsStandalone();
|
||||
await testGenerationRecallDifferentKeyCanRunAgain();
|
||||
await testGenerationRecallSkippedStateDoesNotLoopToBeforeCombine();
|
||||
await testGenerationRecallSentMessageClearsStaleTransactionForSameKey();
|
||||
await testRegisterCoreEventHooksIsIdempotent();
|
||||
await testRemoveNodeHandlesCyclicChildGraph();
|
||||
await testGenerationRecallAppliesFinalInjectionOncePerTransaction();
|
||||
await testPersistentRecallDataLayerLifecycleAndCompatibility();
|
||||
await testPersistentRecallSourceResolutionAndTargetRouting();
|
||||
|
||||
Reference in New Issue
Block a user