mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Reorganize modules into layered directories
This commit is contained in:
650
graph/graph-persistence.js
Normal file
650
graph/graph-persistence.js
Normal file
@@ -0,0 +1,650 @@
|
||||
// ST-BME: 图谱持久化常量与纯工具函数
|
||||
// 不依赖 index.js 模块级可变状态(currentGraph / graphPersistenceState 等)
|
||||
|
||||
import { deserializeGraph, serializeGraph } from "./graph.js";
|
||||
import { normalizeGraphRuntimeState } from "../runtime/runtime-state.js";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 常量
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export const MODULE_NAME = "st_bme";
|
||||
export const GRAPH_METADATA_KEY = "st_bme_graph";
|
||||
export const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence";
|
||||
export const GRAPH_LOAD_STATES = Object.freeze({
|
||||
NO_CHAT: "no-chat",
|
||||
LOADING: "loading",
|
||||
LOADED: "loaded",
|
||||
SHADOW_RESTORED: "shadow-restored",
|
||||
EMPTY_CONFIRMED: "empty-confirmed",
|
||||
BLOCKED: "blocked",
|
||||
});
|
||||
export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__";
|
||||
export const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`;
|
||||
export const GRAPH_IDENTITY_ALIAS_STORAGE_KEY = `${MODULE_NAME}:chat-identity-aliases`;
|
||||
export const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 纯工具
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export function cloneRuntimeDebugValue(value, fallback = null) {
|
||||
if (value == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
} catch {
|
||||
return fallback ?? value;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLocalIntegritySlug() {
|
||||
const nativeUuid = globalThis.crypto?.randomUUID?.();
|
||||
if (nativeUuid) return nativeUuid;
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (char) => {
|
||||
const random = Math.floor(Math.random() * 16);
|
||||
const value = char === "x" ? random : (random & 0x3) | 0x8;
|
||||
return value.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug();
|
||||
|
||||
function normalizeIdentityValue(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function getLocalStorageSafe() {
|
||||
const storage = globalThis.localStorage;
|
||||
if (
|
||||
!storage ||
|
||||
typeof storage.getItem !== "function" ||
|
||||
typeof storage.setItem !== "function"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
function getSessionStorageSafe() {
|
||||
const storage = globalThis.sessionStorage;
|
||||
if (!storage || typeof storage.getItem !== "function") {
|
||||
return null;
|
||||
}
|
||||
return storage;
|
||||
}
|
||||
|
||||
function listStorageKeys(storage) {
|
||||
if (!storage) return [];
|
||||
|
||||
if (typeof storage.length === "number" && typeof storage.key === "function") {
|
||||
const keys = [];
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index);
|
||||
if (typeof key === "string" && key) {
|
||||
keys.push(key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
if (storage.__store instanceof Map) {
|
||||
return Array.from(storage.__store.keys()).map((key) => String(key));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function readGraphIdentityAliasRegistryRaw() {
|
||||
const storage = getLocalStorageSafe();
|
||||
if (!storage) {
|
||||
return {
|
||||
byIntegrity: {},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = storage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
byIntegrity: {},
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
const byIntegrity =
|
||||
parsed?.byIntegrity &&
|
||||
typeof parsed.byIntegrity === "object" &&
|
||||
!Array.isArray(parsed.byIntegrity)
|
||||
? parsed.byIntegrity
|
||||
: {};
|
||||
|
||||
return {
|
||||
byIntegrity,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
byIntegrity: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function writeGraphIdentityAliasRegistryRaw(registry = null) {
|
||||
const storage = getLocalStorageSafe();
|
||||
if (!storage) return false;
|
||||
|
||||
try {
|
||||
storage.setItem(
|
||||
GRAPH_IDENTITY_ALIAS_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
byIntegrity:
|
||||
registry?.byIntegrity &&
|
||||
typeof registry.byIntegrity === "object" &&
|
||||
!Array.isArray(registry.byIntegrity)
|
||||
? registry.byIntegrity
|
||||
: {},
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGraphIdentityAliasEntry(entry = {}, integrity = "") {
|
||||
const normalizedIntegrity = normalizeIdentityValue(integrity || entry.integrity);
|
||||
const normalizedPersistenceChatId = normalizeIdentityValue(
|
||||
entry.persistenceChatId || normalizedIntegrity,
|
||||
);
|
||||
const normalizedHostChatIds = Array.from(
|
||||
new Set(
|
||||
(Array.isArray(entry.hostChatIds) ? entry.hostChatIds : [])
|
||||
.map((value) => normalizeIdentityValue(value))
|
||||
.filter(Boolean),
|
||||
),
|
||||
).slice(-16);
|
||||
|
||||
return {
|
||||
integrity: normalizedIntegrity,
|
||||
persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity,
|
||||
hostChatIds: normalizedHostChatIds,
|
||||
updatedAt: String(entry.updatedAt || ""),
|
||||
};
|
||||
}
|
||||
|
||||
export function rememberGraphIdentityAlias({
|
||||
integrity = "",
|
||||
hostChatId = "",
|
||||
persistenceChatId = "",
|
||||
} = {}) {
|
||||
const normalizedIntegrity = normalizeIdentityValue(integrity);
|
||||
if (!normalizedIntegrity) return null;
|
||||
|
||||
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
|
||||
const normalizedPersistenceChatId = normalizeIdentityValue(
|
||||
persistenceChatId || normalizedIntegrity,
|
||||
);
|
||||
const registry = readGraphIdentityAliasRegistryRaw();
|
||||
const existingEntry = normalizeGraphIdentityAliasEntry(
|
||||
registry.byIntegrity?.[normalizedIntegrity] || {},
|
||||
normalizedIntegrity,
|
||||
);
|
||||
const hostChatIds = Array.from(
|
||||
new Set(
|
||||
[normalizedHostChatId, ...existingEntry.hostChatIds].filter(Boolean),
|
||||
),
|
||||
).slice(-16);
|
||||
const nextEntry = {
|
||||
integrity: normalizedIntegrity,
|
||||
persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity,
|
||||
hostChatIds,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
registry.byIntegrity[normalizedIntegrity] = nextEntry;
|
||||
writeGraphIdentityAliasRegistryRaw(registry);
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
export function resolveGraphIdentityAliasByHostChatId(hostChatId = "") {
|
||||
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
|
||||
if (!normalizedHostChatId) return "";
|
||||
|
||||
const registry = readGraphIdentityAliasRegistryRaw();
|
||||
let bestEntry = null;
|
||||
|
||||
for (const [integrity, value] of Object.entries(registry.byIntegrity || {})) {
|
||||
const entry = normalizeGraphIdentityAliasEntry(value, integrity);
|
||||
if (!entry.hostChatIds.includes(normalizedHostChatId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!bestEntry) {
|
||||
bestEntry = entry;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (String(entry.updatedAt || "") > String(bestEntry.updatedAt || "")) {
|
||||
bestEntry = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeIdentityValue(bestEntry?.persistenceChatId || "");
|
||||
}
|
||||
|
||||
export function getGraphIdentityAliasCandidates({
|
||||
integrity = "",
|
||||
hostChatId = "",
|
||||
persistenceChatId = "",
|
||||
} = {}) {
|
||||
const normalizedIntegrity = normalizeIdentityValue(integrity);
|
||||
const normalizedHostChatId = normalizeIdentityValue(hostChatId);
|
||||
const normalizedPersistenceChatId = normalizeIdentityValue(persistenceChatId);
|
||||
const registry = readGraphIdentityAliasRegistryRaw();
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const pushCandidate = (value) => {
|
||||
const normalized = normalizeIdentityValue(value);
|
||||
if (!normalized || seen.has(normalized)) return;
|
||||
seen.add(normalized);
|
||||
candidates.push(normalized);
|
||||
};
|
||||
|
||||
if (normalizedIntegrity) {
|
||||
const entry = normalizeGraphIdentityAliasEntry(
|
||||
registry.byIntegrity?.[normalizedIntegrity] || {},
|
||||
normalizedIntegrity,
|
||||
);
|
||||
pushCandidate(entry.persistenceChatId);
|
||||
for (const value of entry.hostChatIds) {
|
||||
pushCandidate(value);
|
||||
}
|
||||
} else if (normalizedHostChatId) {
|
||||
pushCandidate(resolveGraphIdentityAliasByHostChatId(normalizedHostChatId));
|
||||
}
|
||||
|
||||
pushCandidate(normalizedHostChatId);
|
||||
pushCandidate(normalizedPersistenceChatId);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function normalizeShadowSnapshotPayload(snapshot = null) {
|
||||
if (!snapshot || typeof snapshot !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const serializedGraph = String(snapshot.serializedGraph || "");
|
||||
const chatId = normalizeIdentityValue(snapshot.chatId);
|
||||
if (!chatId || !serializedGraph) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chatId,
|
||||
revision: Number.isFinite(snapshot.revision) ? snapshot.revision : 0,
|
||||
serializedGraph,
|
||||
updatedAt: String(snapshot.updatedAt || ""),
|
||||
reason: String(snapshot.reason || ""),
|
||||
integrity: normalizeIdentityValue(snapshot.integrity),
|
||||
persistedChatId: normalizeIdentityValue(snapshot.persistedChatId),
|
||||
debugReason: String(snapshot.debugReason || snapshot.reason || ""),
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 图谱持久化元数据
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* @param {object} graph
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getGraphPersistenceMeta(graph) {
|
||||
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
|
||||
return null;
|
||||
}
|
||||
const meta = graph[GRAPH_PERSISTENCE_META_KEY];
|
||||
if (!meta || typeof meta !== "object" || Array.isArray(meta)) {
|
||||
return null;
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} graph
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getGraphPersistedRevision(graph) {
|
||||
const revision = Number(getGraphPersistenceMeta(graph)?.revision);
|
||||
return Number.isFinite(revision) && revision > 0 ? revision : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} graph
|
||||
* @param {object} opts
|
||||
* @param {number} [opts.revision]
|
||||
* @param {string} [opts.reason]
|
||||
* @param {string} [opts.chatId]
|
||||
* @param {string} [opts.integrity]
|
||||
*/
|
||||
export function stampGraphPersistenceMeta(
|
||||
graph,
|
||||
{ revision = 0, reason = "", chatId = "", integrity = "" } = {},
|
||||
) {
|
||||
if (!graph || typeof graph !== "object" || Array.isArray(graph)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const existingMeta = getGraphPersistenceMeta(graph) || {};
|
||||
const nextMeta = {
|
||||
...existingMeta,
|
||||
revision: Number.isFinite(revision) && revision > 0 ? revision : 0,
|
||||
updatedAt: new Date().toISOString(),
|
||||
sessionId: GRAPH_PERSISTENCE_SESSION_ID,
|
||||
reason: String(reason || ""),
|
||||
chatId: String(chatId || existingMeta.chatId || ""),
|
||||
integrity: String(integrity || existingMeta.integrity || ""),
|
||||
};
|
||||
graph[GRAPH_PERSISTENCE_META_KEY] = nextMeta;
|
||||
return nextMeta;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 聊天元数据
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export function writeChatMetadataPatch(context, patch = {}) {
|
||||
if (!context) return false;
|
||||
if (typeof context.updateChatMetadata === "function") {
|
||||
context.updateChatMetadata(patch);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
!context.chatMetadata ||
|
||||
typeof context.chatMetadata !== "object" ||
|
||||
Array.isArray(context.chatMetadata)
|
||||
) {
|
||||
context.chatMetadata = {};
|
||||
}
|
||||
Object.assign(context.chatMetadata, patch || {});
|
||||
return true;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Shadow Snapshot(会话存储)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export function getGraphShadowSnapshotStorageKey(chatId = "") {
|
||||
const normalizedChatId = String(chatId || "").trim();
|
||||
if (!normalizedChatId) return "";
|
||||
return `${GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX}${encodeURIComponent(normalizedChatId)}`;
|
||||
}
|
||||
|
||||
export function readGraphShadowSnapshot(chatId = "") {
|
||||
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!storageKey) return null;
|
||||
|
||||
try {
|
||||
const raw = getSessionStorageSafe()?.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
const snapshot = normalizeShadowSnapshotPayload(JSON.parse(raw));
|
||||
if (!snapshot || snapshot.chatId !== String(chatId || "")) {
|
||||
return null;
|
||||
}
|
||||
return snapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function findGraphShadowSnapshotByIntegrity(
|
||||
integrity = "",
|
||||
{ excludeChatIds = [] } = {},
|
||||
) {
|
||||
const normalizedIntegrity = normalizeIdentityValue(integrity);
|
||||
if (!normalizedIntegrity) return null;
|
||||
|
||||
const storage = getSessionStorageSafe();
|
||||
if (!storage) return null;
|
||||
|
||||
const excludedChatIds = new Set(
|
||||
(Array.isArray(excludeChatIds) ? excludeChatIds : [])
|
||||
.map((value) => normalizeIdentityValue(value))
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
let bestSnapshot = null;
|
||||
for (const key of listStorageKeys(storage)) {
|
||||
if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = normalizeShadowSnapshotPayload(
|
||||
JSON.parse(storage.getItem(key)),
|
||||
);
|
||||
if (!snapshot || snapshot.integrity !== normalizedIntegrity) {
|
||||
continue;
|
||||
}
|
||||
if (excludedChatIds.has(snapshot.chatId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bestRevision = Number(bestSnapshot?.revision || 0);
|
||||
const nextRevision = Number(snapshot.revision || 0);
|
||||
if (!bestSnapshot || nextRevision > bestRevision) {
|
||||
bestSnapshot = snapshot;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
nextRevision === bestRevision &&
|
||||
String(snapshot.updatedAt || "") > String(bestSnapshot.updatedAt || "")
|
||||
) {
|
||||
bestSnapshot = snapshot;
|
||||
}
|
||||
} catch {
|
||||
// ignore broken shadow snapshot payloads
|
||||
}
|
||||
}
|
||||
|
||||
return bestSnapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} chatId
|
||||
* @param {object} graph
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.revision]
|
||||
* @param {string} [opts.reason]
|
||||
*/
|
||||
export function writeGraphShadowSnapshot(
|
||||
chatId,
|
||||
graph,
|
||||
{ revision = 0, reason = "", integrity = "", debugReason = "" } = {},
|
||||
) {
|
||||
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!storageKey || !graph) return false;
|
||||
|
||||
try {
|
||||
const serializedGraph = serializeGraph(graph);
|
||||
const persistedMeta = getGraphPersistenceMeta(graph) || {};
|
||||
getSessionStorageSafe()?.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({
|
||||
chatId: String(chatId || ""),
|
||||
revision: Number.isFinite(revision) ? revision : 0,
|
||||
serializedGraph,
|
||||
updatedAt: new Date().toISOString(),
|
||||
reason: String(reason || ""),
|
||||
integrity: String(integrity || persistedMeta.integrity || ""),
|
||||
persistedChatId: String(persistedMeta.chatId || ""),
|
||||
debugReason: String(debugReason || reason || ""),
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn("[ST-BME] 写入会话图谱临时快照失败:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function removeGraphShadowSnapshot(chatId = "") {
|
||||
const storageKey = getGraphShadowSnapshotStorageKey(chatId);
|
||||
if (!storageKey) return false;
|
||||
|
||||
try {
|
||||
getSessionStorageSafe()?.removeItem(storageKey);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 图谱克隆 / 比较
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
export function cloneGraphForPersistence(graph, chatId = "") {
|
||||
return normalizeGraphRuntimeState(
|
||||
deserializeGraph(serializeGraph(graph)),
|
||||
chatId,
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldPreferShadowSnapshotOverOfficial(
|
||||
officialGraph,
|
||||
shadowSnapshot,
|
||||
) {
|
||||
if (!shadowSnapshot) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-missing",
|
||||
resultCode: "shadow.missing",
|
||||
};
|
||||
}
|
||||
|
||||
const shadowRevision = Number(shadowSnapshot.revision || 0);
|
||||
const officialRevision = getGraphPersistedRevision(officialGraph);
|
||||
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",
|
||||
resultCode: "shadow.reject.revision-invalid",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedOfficialChatId &&
|
||||
normalizedShadowPersistedChatId &&
|
||||
normalizedOfficialChatId !== normalizedShadowPersistedChatId
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-persisted-chat-mismatch",
|
||||
resultCode: "shadow.reject.persisted-chat-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
officialChatId: normalizedOfficialChatId,
|
||||
shadowPersistedChatId: normalizedShadowPersistedChatId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedOfficialChatId &&
|
||||
normalizedShadowChatId &&
|
||||
normalizedOfficialChatId !== normalizedShadowChatId
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-chat-mismatch",
|
||||
resultCode: "shadow.reject.chat-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
officialChatId: normalizedOfficialChatId,
|
||||
shadowChatId: normalizedShadowChatId,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
officialIntegrity &&
|
||||
shadowIntegrity &&
|
||||
officialIntegrity !== shadowIntegrity
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-integrity-mismatch",
|
||||
resultCode: "shadow.reject.integrity-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
officialIntegrity,
|
||||
shadowIntegrity,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedShadowPersistedChatId &&
|
||||
normalizedShadowChatId &&
|
||||
normalizedShadowPersistedChatId !== normalizedShadowChatId
|
||||
) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-self-chat-mismatch",
|
||||
resultCode: "shadow.reject.self-chat-mismatch",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
shadowChatId: normalizedShadowChatId,
|
||||
shadowPersistedChatId: normalizedShadowPersistedChatId,
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedShadowPersistedChatId && !normalizedOfficialChatId) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-persisted-chat-without-official-chat",
|
||||
resultCode: "shadow.reject.persisted-chat-without-official-chat",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
shadowPersistedChatId: normalizedShadowPersistedChatId,
|
||||
};
|
||||
}
|
||||
|
||||
if (shadowIntegrity && !officialIntegrity) {
|
||||
return {
|
||||
prefer: false,
|
||||
reason: "shadow-integrity-without-official-integrity",
|
||||
resultCode: "shadow.reject.integrity-without-official-integrity",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
shadowIntegrity,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prefer: shadowRevision > 0 && shadowRevision > officialRevision,
|
||||
reason:
|
||||
shadowRevision > officialRevision
|
||||
? "shadow-newer-than-official"
|
||||
: "shadow-not-newer-than-official",
|
||||
resultCode:
|
||||
shadowRevision > officialRevision
|
||||
? "shadow.accept.newer-than-official"
|
||||
: "shadow.keep.official-not-older",
|
||||
shadowRevision,
|
||||
officialRevision,
|
||||
};
|
||||
}
|
||||
731
graph/graph.js
Normal file
731
graph/graph.js
Normal file
@@ -0,0 +1,731 @@
|
||||
// ST-BME: 图数据模型
|
||||
// 管理节点、边的 CRUD 操作,以及序列化到 chat_metadata
|
||||
|
||||
import {
|
||||
createDefaultBatchJournal,
|
||||
createDefaultHistoryState,
|
||||
createDefaultVectorIndexState,
|
||||
normalizeGraphRuntimeState,
|
||||
PROCESSED_MESSAGE_HASH_VERSION,
|
||||
} from "../runtime/runtime-state.js";
|
||||
import {
|
||||
hasSameScopeIdentity,
|
||||
normalizeEdgeMemoryScope,
|
||||
normalizeMemoryScope,
|
||||
normalizeNodeMemoryScope,
|
||||
isSameLatestScopeBucket,
|
||||
} from "./memory-scope.js";
|
||||
import { debugLog } from "../runtime/debug-logging.js";
|
||||
|
||||
/**
|
||||
* 图状态版本号
|
||||
*/
|
||||
const GRAPH_VERSION = 6;
|
||||
|
||||
/**
|
||||
* 生成 UUID v4
|
||||
*/
|
||||
function uuid() {
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建空的图状态
|
||||
* @returns {GraphState}
|
||||
*/
|
||||
export function createEmptyGraph() {
|
||||
return normalizeGraphRuntimeState({
|
||||
version: GRAPH_VERSION,
|
||||
lastProcessedSeq: -1,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastRecallResult: null,
|
||||
historyState: createDefaultHistoryState(),
|
||||
vectorIndexState: createDefaultVectorIndexState(),
|
||||
batchJournal: createDefaultBatchJournal(),
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 节点操作 ====================
|
||||
|
||||
/**
|
||||
* 创建新节点
|
||||
* @param {object} params
|
||||
* @returns {object} 新节点
|
||||
*/
|
||||
export function createNode({
|
||||
type,
|
||||
fields = {},
|
||||
seq = 0,
|
||||
seqRange = null,
|
||||
importance = 5.0,
|
||||
clusters = [],
|
||||
scope = undefined,
|
||||
}) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: uuid(),
|
||||
type,
|
||||
level: 0,
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
seq,
|
||||
seqRange: seqRange || [seq, seq],
|
||||
archived: false,
|
||||
fields,
|
||||
embedding: null,
|
||||
importance: Math.max(0, Math.min(10, importance)),
|
||||
accessCount: 0,
|
||||
lastAccessTime: now,
|
||||
createdTime: now,
|
||||
prevId: null,
|
||||
nextId: null,
|
||||
clusters,
|
||||
scope: normalizeMemoryScope(scope),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图中添加节点
|
||||
* @param {GraphState} graph
|
||||
* @param {object} node
|
||||
* @returns {object} 添加的节点
|
||||
*/
|
||||
export function addNode(graph, node) {
|
||||
// 同类型节点的时间链表:连接到最后一个同类型节点
|
||||
const sameTypeNodes = graph.nodes
|
||||
.filter(
|
||||
(n) =>
|
||||
n.type === node.type &&
|
||||
!n.archived &&
|
||||
n.level === 0 &&
|
||||
hasSameScopeIdentity(n.scope, node.scope),
|
||||
)
|
||||
.sort((a, b) => a.seq - b.seq);
|
||||
|
||||
if (sameTypeNodes.length > 0) {
|
||||
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
||||
lastNode.nextId = node.id;
|
||||
node.prevId = lastNode.id;
|
||||
}
|
||||
|
||||
graph.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 ID 获取节点
|
||||
* @param {GraphState} graph
|
||||
* @param {string} nodeId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getNode(graph, nodeId) {
|
||||
return graph.nodes.find((n) => n.id === nodeId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点字段(部分更新)
|
||||
* @param {GraphState} graph
|
||||
* @param {string} nodeId
|
||||
* @param {object} updates - 要更新的字段
|
||||
* @returns {boolean} 是否找到并更新
|
||||
*/
|
||||
export function updateNode(graph, nodeId, updates) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
if (updates.fields) {
|
||||
node.fields = { ...node.fields, ...updates.fields };
|
||||
delete updates.fields;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(updates, "scope")) {
|
||||
node.scope = normalizeMemoryScope(updates.scope, node.scope || {});
|
||||
delete updates.scope;
|
||||
}
|
||||
|
||||
Object.assign(node, updates);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点及其相关边
|
||||
* @param {GraphState} graph
|
||||
* @param {string} nodeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
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;
|
||||
|
||||
// 修复时间链表
|
||||
if (node.prevId) {
|
||||
const prev = getNode(graph, node.prevId);
|
||||
if (prev) prev.nextId = node.nextId;
|
||||
}
|
||||
if (node.nextId) {
|
||||
const next = getNode(graph, node.nextId);
|
||||
if (next) next.prevId = node.prevId;
|
||||
}
|
||||
|
||||
// 递归删除子节点(带环保护)
|
||||
for (const childId of node.childIds) {
|
||||
removeNode(graph, childId, visited);
|
||||
}
|
||||
|
||||
// 从父节点中移除引用
|
||||
if (node.parentId) {
|
||||
const parent = getNode(graph, node.parentId);
|
||||
if (parent) {
|
||||
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 !== normalizedNodeId && e.toId !== normalizedNodeId,
|
||||
);
|
||||
|
||||
// 删除节点本身
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== normalizedNodeId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有未归档的节点
|
||||
* @param {GraphState} graph
|
||||
* @param {string} [typeFilter] - 可选类型过滤
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getActiveNodes(graph, typeFilter = null) {
|
||||
let nodes = graph.nodes.filter((n) => !n.archived);
|
||||
if (typeFilter) {
|
||||
nodes = nodes.filter((n) => n.type === typeFilter);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型查找最新版本的节点(用于 latestOnly 类型)
|
||||
* @param {GraphState} graph
|
||||
* @param {string} type
|
||||
* @param {string} primaryKeyValue - 主键值(如角色名)
|
||||
* @param {string} primaryKeyField - 主键字段名(默认 'name')
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function findLatestNode(
|
||||
graph,
|
||||
type,
|
||||
primaryKeyValue,
|
||||
primaryKeyField = "name",
|
||||
scope = undefined,
|
||||
) {
|
||||
const candidates = graph.nodes.filter(
|
||||
(n) =>
|
||||
n.type === type &&
|
||||
!n.archived &&
|
||||
n.fields[primaryKeyField] === primaryKeyValue &&
|
||||
(scope == null ||
|
||||
isSameLatestScopeBucket(n, {
|
||||
type,
|
||||
primaryKeyValue,
|
||||
primaryKeyField,
|
||||
scope,
|
||||
})),
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates.sort((a, b) => b.seq - a.seq)[0];
|
||||
}
|
||||
|
||||
// ==================== 边操作 ====================
|
||||
|
||||
/**
|
||||
* 创建边
|
||||
* @param {object} params
|
||||
* @returns {object} 新边
|
||||
*/
|
||||
export function createEdge({
|
||||
fromId,
|
||||
toId,
|
||||
relation = "related",
|
||||
strength = 0.8,
|
||||
edgeType = 0,
|
||||
scope = undefined,
|
||||
}) {
|
||||
return {
|
||||
id: uuid(),
|
||||
fromId,
|
||||
toId,
|
||||
relation,
|
||||
strength: Math.max(0, Math.min(1, strength)),
|
||||
edgeType,
|
||||
createdTime: Date.now(),
|
||||
// Graphiti 启发的时序字段
|
||||
validAt: Date.now(), // 关系生效时间
|
||||
invalidAt: null, // 关系失效时间(null = 当前有效)
|
||||
expiredAt: null, // 系统标记过期时间
|
||||
scope: normalizeMemoryScope(scope),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在图中添加边(检查节点存在性)
|
||||
* @param {GraphState} graph
|
||||
* @param {object} edge
|
||||
* @returns {object|null} 添加的边或 null
|
||||
*/
|
||||
export function addEdge(graph, edge) {
|
||||
const from = getNode(graph, edge.fromId);
|
||||
const to = getNode(graph, edge.toId);
|
||||
if (!from || !to) return null;
|
||||
if (edge.fromId === edge.toId) return null;
|
||||
|
||||
const isCurrentEdgeValid = (candidate) => {
|
||||
if (candidate.invalidAt) return false;
|
||||
if (candidate.expiredAt) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// 对当前有效边去重;历史边保留,避免历史污染当前检索
|
||||
const existing = graph.edges.find(
|
||||
(e) =>
|
||||
e.fromId === edge.fromId &&
|
||||
e.toId === edge.toId &&
|
||||
e.relation === edge.relation &&
|
||||
JSON.stringify(normalizeMemoryScope(e.scope)) ===
|
||||
JSON.stringify(normalizeMemoryScope(edge.scope)) &&
|
||||
isCurrentEdgeValid(e),
|
||||
);
|
||||
if (existing) {
|
||||
existing.strength = Math.max(existing.strength, edge.strength ?? 0);
|
||||
existing.validAt = Math.max(
|
||||
existing.validAt || 0,
|
||||
edge.validAt || Date.now(),
|
||||
);
|
||||
if (edge.invalidAt) {
|
||||
existing.invalidAt = edge.invalidAt;
|
||||
}
|
||||
if (edge.expiredAt) {
|
||||
existing.expiredAt = edge.expiredAt;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
graph.edges.push(edge);
|
||||
return edge;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除边
|
||||
* @param {GraphState} graph
|
||||
* @param {string} edgeId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function removeEdge(graph, edgeId) {
|
||||
const idx = graph.edges.findIndex((e) => e.id === edgeId);
|
||||
if (idx === -1) return false;
|
||||
graph.edges.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的所有出边
|
||||
* @param {GraphState} graph
|
||||
* @param {string} nodeId
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getOutEdges(graph, nodeId) {
|
||||
return graph.edges.filter((e) => e.fromId === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点的所有入边
|
||||
* @param {GraphState} graph
|
||||
* @param {string} nodeId
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getInEdges(graph, nodeId) {
|
||||
return graph.edges.filter((e) => e.toId === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接到节点的所有边(入+出)
|
||||
* @param {GraphState} graph
|
||||
* @param {string} nodeId
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getNodeEdges(graph, nodeId) {
|
||||
return graph.edges.filter((e) => e.fromId === nodeId || e.toId === nodeId);
|
||||
}
|
||||
|
||||
// ==================== 查询辅助 ====================
|
||||
|
||||
/**
|
||||
* 构建邻接表(用于扩散引擎)
|
||||
* @param {GraphState} graph
|
||||
* @returns {Map<string, Array<{targetId: string, strength: number, edgeType: number}>>}
|
||||
*/
|
||||
export function buildAdjacencyMap(graph) {
|
||||
const adj = new Map();
|
||||
const activeNodeIds = new Set(
|
||||
graph.nodes.filter((node) => !node.archived).map((node) => node.id),
|
||||
);
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (!isEdgeActive(edge)) continue;
|
||||
if (!activeNodeIds.has(edge.fromId) || !activeNodeIds.has(edge.toId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
|
||||
adj.get(edge.fromId).push({
|
||||
targetId: edge.toId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
|
||||
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
|
||||
adj.get(edge.toId).push({
|
||||
targetId: edge.fromId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
}
|
||||
|
||||
return adj;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建时序感知邻接表(过滤失效边)
|
||||
* Graphiti 启发:只纳入 "当前有效" 的边
|
||||
* @param {GraphState} graph
|
||||
* @returns {Map}
|
||||
*/
|
||||
export function buildTemporalAdjacencyMap(graph, options = {}) {
|
||||
const adj = new Map();
|
||||
adj.syntheticEdgeCount = 0;
|
||||
const activeNodeIds = new Set(
|
||||
graph.nodes.filter((node) => !node.archived).map((node) => node.id),
|
||||
);
|
||||
const includeTemporalLinks = options.includeTemporalLinks !== false;
|
||||
const temporalLinkStrength = Math.max(
|
||||
0,
|
||||
Math.min(1, Number(options.temporalLinkStrength) || 0.2),
|
||||
);
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
if (!isEdgeActive(edge)) continue;
|
||||
if (!activeNodeIds.has(edge.fromId) || !activeNodeIds.has(edge.toId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
addAdjacencyPair(adj, edge.fromId, edge.toId, edge.strength, edge.edgeType);
|
||||
}
|
||||
|
||||
if (includeTemporalLinks && temporalLinkStrength > 0) {
|
||||
const activeNodes = graph.nodes.filter(
|
||||
(node) => !node.archived && activeNodeIds.has(node.id),
|
||||
);
|
||||
const seenPairs = new Set();
|
||||
|
||||
for (const node of activeNodes) {
|
||||
for (const neighborId of [node.prevId, node.nextId]) {
|
||||
if (!neighborId || !activeNodeIds.has(neighborId)) continue;
|
||||
const key = [node.id, neighborId].sort().join("::");
|
||||
if (seenPairs.has(key)) continue;
|
||||
seenPairs.add(key);
|
||||
addAdjacencyPair(adj, node.id, neighborId, temporalLinkStrength, 0);
|
||||
adj.syntheticEdgeCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return adj;
|
||||
}
|
||||
|
||||
function addAdjacencyPair(adj, fromId, toId, strength, edgeType) {
|
||||
if (!adj.has(fromId)) adj.set(fromId, []);
|
||||
adj.get(fromId).push({
|
||||
targetId: toId,
|
||||
strength,
|
||||
edgeType,
|
||||
});
|
||||
|
||||
if (!adj.has(toId)) adj.set(toId, []);
|
||||
adj.get(toId).push({
|
||||
targetId: fromId,
|
||||
strength,
|
||||
edgeType,
|
||||
});
|
||||
}
|
||||
|
||||
function isEdgeActive(edge, now = Date.now()) {
|
||||
if (!edge) return false;
|
||||
if (edge.invalidAt && edge.invalidAt <= now) return false;
|
||||
if (edge.expiredAt && edge.expiredAt <= now) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将边标记为失效(不删除,保留历史)
|
||||
* @param {object} edge
|
||||
*/
|
||||
export function invalidateEdge(edge) {
|
||||
if (!edge) return;
|
||||
if (!edge.invalidAt) {
|
||||
edge.invalidAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取图的统计信息
|
||||
* @param {GraphState} graph
|
||||
* @returns {object}
|
||||
*/
|
||||
export function getGraphStats(graph) {
|
||||
const activeNodes = graph.nodes.filter((n) => !n.archived);
|
||||
const archivedNodes = graph.nodes.filter((n) => n.archived);
|
||||
const typeCounts = {};
|
||||
for (const node of activeNodes) {
|
||||
typeCounts[node.type] = (typeCounts[node.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalNodes: graph.nodes.length,
|
||||
activeNodes: activeNodes.length,
|
||||
archivedNodes: archivedNodes.length,
|
||||
totalEdges: graph.edges.length,
|
||||
lastProcessedSeq: graph.lastProcessedSeq,
|
||||
typeCounts,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 序列化 ====================
|
||||
|
||||
/**
|
||||
* 序列化图状态为 JSON 字符串
|
||||
* @param {GraphState} graph
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeGraph(graph) {
|
||||
return JSON.stringify(graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 JSON 反序列化图状态
|
||||
* @param {string} json
|
||||
* @returns {GraphState}
|
||||
*/
|
||||
export function deserializeGraph(json) {
|
||||
try {
|
||||
const data = typeof json === "string" ? JSON.parse(json) : json;
|
||||
|
||||
if (!data || data.version === undefined) {
|
||||
return createEmptyGraph();
|
||||
}
|
||||
|
||||
if (data.version < GRAPH_VERSION) {
|
||||
debugLog(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`);
|
||||
|
||||
if (data.version < 2 && data.edges) {
|
||||
for (const edge of data.edges) {
|
||||
if (edge.validAt === undefined)
|
||||
edge.validAt = edge.createdTime || Date.now();
|
||||
if (edge.invalidAt === undefined) edge.invalidAt = null;
|
||||
if (edge.expiredAt === undefined) edge.expiredAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.version < 3) {
|
||||
if (typeof data.lastProcessedSeq !== "number") {
|
||||
data.lastProcessedSeq = -1;
|
||||
}
|
||||
for (const node of data.nodes || []) {
|
||||
if (!Array.isArray(node.seqRange)) {
|
||||
const seq = Number.isFinite(node.seq) ? node.seq : 0;
|
||||
node.seqRange = [seq, seq];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.version < 4) {
|
||||
data.historyState = {
|
||||
...createDefaultHistoryState(),
|
||||
...(data.historyState || {}),
|
||||
lastProcessedAssistantFloor: Number.isFinite(data.lastProcessedSeq)
|
||||
? data.lastProcessedSeq
|
||||
: -1,
|
||||
};
|
||||
data.vectorIndexState = {
|
||||
...createDefaultVectorIndexState(),
|
||||
...(data.vectorIndexState || {}),
|
||||
dirty: true,
|
||||
lastWarning: "旧版本图谱已迁移,需要重建向量运行时状态",
|
||||
};
|
||||
data.batchJournal = Array.isArray(data.batchJournal)
|
||||
? data.batchJournal
|
||||
: createDefaultBatchJournal();
|
||||
}
|
||||
|
||||
if (data.version < 5) {
|
||||
data.historyState = {
|
||||
...createDefaultHistoryState(),
|
||||
...(data.historyState || {}),
|
||||
extractionCount: Number.isFinite(data?.historyState?.extractionCount)
|
||||
? data.historyState.extractionCount
|
||||
: 0,
|
||||
lastMutationSource: String(
|
||||
data?.historyState?.lastMutationSource || "",
|
||||
),
|
||||
};
|
||||
data.batchJournal = Array.isArray(data.batchJournal)
|
||||
? data.batchJournal
|
||||
: createDefaultBatchJournal();
|
||||
}
|
||||
|
||||
if (data.version < 6) {
|
||||
for (const node of data.nodes || []) {
|
||||
node.scope = normalizeMemoryScope(node?.scope);
|
||||
}
|
||||
for (const edge of data.edges || []) {
|
||||
edge.scope = normalizeMemoryScope(edge?.scope);
|
||||
}
|
||||
}
|
||||
|
||||
data.version = GRAPH_VERSION;
|
||||
}
|
||||
|
||||
data.nodes = (data.nodes || []).map((node) => {
|
||||
const seq = Number.isFinite(node.seq) ? node.seq : 0;
|
||||
return {
|
||||
level: 0,
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
accessCount: 0,
|
||||
lastAccessTime: node.createdTime || Date.now(),
|
||||
prevId: null,
|
||||
nextId: null,
|
||||
clusters: [],
|
||||
...node,
|
||||
seq,
|
||||
seqRange: Array.isArray(node.seqRange) ? node.seqRange : [seq, seq],
|
||||
scope: normalizeNodeMemoryScope(node),
|
||||
};
|
||||
});
|
||||
data.edges = (data.edges || []).map((edge) => {
|
||||
const normalizedEdge = {
|
||||
createdTime: Date.now(),
|
||||
validAt: edge?.createdTime || Date.now(),
|
||||
invalidAt: null,
|
||||
expiredAt: null,
|
||||
...edge,
|
||||
};
|
||||
normalizedEdge.scope = normalizeEdgeMemoryScope(normalizedEdge);
|
||||
return normalizedEdge;
|
||||
});
|
||||
data.lastProcessedSeq = Number.isFinite(data.lastProcessedSeq)
|
||||
? data.lastProcessedSeq
|
||||
: -1;
|
||||
data.lastRecallResult = Array.isArray(data.lastRecallResult)
|
||||
? data.lastRecallResult
|
||||
: null;
|
||||
data.historyState = {
|
||||
...createDefaultHistoryState(),
|
||||
...(data.historyState || {}),
|
||||
lastProcessedAssistantFloor: Number.isFinite(
|
||||
data?.historyState?.lastProcessedAssistantFloor,
|
||||
)
|
||||
? data.historyState.lastProcessedAssistantFloor
|
||||
: data.lastProcessedSeq,
|
||||
extractionCount: Number.isFinite(data?.historyState?.extractionCount)
|
||||
? data.historyState.extractionCount
|
||||
: 0,
|
||||
lastMutationSource: String(data?.historyState?.lastMutationSource || ""),
|
||||
};
|
||||
data.vectorIndexState = {
|
||||
...createDefaultVectorIndexState(data?.historyState?.chatId || ""),
|
||||
...(data.vectorIndexState || {}),
|
||||
};
|
||||
data.batchJournal = Array.isArray(data.batchJournal)
|
||||
? data.batchJournal
|
||||
: createDefaultBatchJournal();
|
||||
|
||||
return normalizeGraphRuntimeState(data, data?.historyState?.chatId || "");
|
||||
} catch (e) {
|
||||
console.error("[ST-BME] 图反序列化失败:", e);
|
||||
return createEmptyGraph();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出图数据(不含 embedding 以减小体积)
|
||||
* @param {GraphState} graph
|
||||
* @returns {string} JSON 字符串
|
||||
*/
|
||||
export function exportGraph(graph) {
|
||||
const exportData = {
|
||||
...graph,
|
||||
historyState: {
|
||||
...createDefaultHistoryState(graph?.historyState?.chatId || ""),
|
||||
lastProcessedAssistantFloor:
|
||||
graph?.historyState?.lastProcessedAssistantFloor ??
|
||||
graph?.lastProcessedSeq ??
|
||||
-1,
|
||||
},
|
||||
vectorIndexState: {
|
||||
...createDefaultVectorIndexState(graph?.historyState?.chatId || ""),
|
||||
dirty: true,
|
||||
lastWarning: "导出图谱不包含运行时向量索引",
|
||||
},
|
||||
batchJournal: createDefaultBatchJournal(),
|
||||
nodes: graph.nodes.map((n) => ({ ...n, embedding: null })),
|
||||
};
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入图数据
|
||||
* @param {string} json
|
||||
* @returns {GraphState}
|
||||
*/
|
||||
export function importGraph(json) {
|
||||
const graph = normalizeGraphRuntimeState(deserializeGraph(json));
|
||||
// 导入的节点需要重新生成 embedding
|
||||
for (const node of graph.nodes) {
|
||||
node.embedding = null;
|
||||
}
|
||||
graph.batchJournal = createDefaultBatchJournal();
|
||||
graph.historyState.processedMessageHashVersion =
|
||||
PROCESSED_MESSAGE_HASH_VERSION;
|
||||
graph.historyState.processedMessageHashes = {};
|
||||
graph.historyState.processedMessageHashesNeedRefresh = true;
|
||||
graph.historyState.historyDirtyFrom = null;
|
||||
graph.vectorIndexState.hashToNodeId = {};
|
||||
graph.vectorIndexState.nodeToHash = {};
|
||||
graph.vectorIndexState.dirty = true;
|
||||
graph.vectorIndexState.lastWarning = "导入图谱后需要重建向量索引";
|
||||
return graph;
|
||||
}
|
||||
352
graph/memory-scope.js
Normal file
352
graph/memory-scope.js
Normal file
@@ -0,0 +1,352 @@
|
||||
const MEMORY_SCOPE_LAYER = {
|
||||
OBJECTIVE: "objective",
|
||||
POV: "pov",
|
||||
};
|
||||
|
||||
const MEMORY_SCOPE_OWNER_TYPE = {
|
||||
NONE: "",
|
||||
CHARACTER: "character",
|
||||
USER: "user",
|
||||
};
|
||||
|
||||
export const DEFAULT_MEMORY_SCOPE = Object.freeze({
|
||||
layer: MEMORY_SCOPE_LAYER.OBJECTIVE,
|
||||
ownerType: MEMORY_SCOPE_OWNER_TYPE.NONE,
|
||||
ownerId: "",
|
||||
ownerName: "",
|
||||
regionPrimary: "",
|
||||
regionPath: [],
|
||||
regionSecondary: [],
|
||||
});
|
||||
|
||||
export const MEMORY_SCOPE_BUCKETS = Object.freeze({
|
||||
CHARACTER_POV: "characterPov",
|
||||
USER_POV: "userPov",
|
||||
OBJECTIVE_CURRENT_REGION: "objectiveCurrentRegion",
|
||||
OBJECTIVE_ADJACENT_REGION: "objectiveAdjacentRegion",
|
||||
OBJECTIVE_GLOBAL: "objectiveGlobal",
|
||||
OTHER_POV: "otherPov",
|
||||
});
|
||||
|
||||
export const DEFAULT_SCOPE_BUCKET_WEIGHTS = Object.freeze({
|
||||
[MEMORY_SCOPE_BUCKETS.CHARACTER_POV]: 1.25,
|
||||
[MEMORY_SCOPE_BUCKETS.USER_POV]: 1.05,
|
||||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION]: 1.15,
|
||||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION]: 0.9,
|
||||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL]: 0.75,
|
||||
[MEMORY_SCOPE_BUCKETS.OTHER_POV]: 0.6,
|
||||
});
|
||||
|
||||
function normalizeString(value) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeKey(value) {
|
||||
return normalizeString(value).toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeStringArray(values = []) {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
for (const value of Array.isArray(values) ? values : [values]) {
|
||||
const normalized = normalizeString(value);
|
||||
const key = normalizeKey(normalized);
|
||||
if (!normalized || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
result.push(normalized);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeOwnerType(layer, ownerType) {
|
||||
if (layer !== MEMORY_SCOPE_LAYER.POV) {
|
||||
return MEMORY_SCOPE_OWNER_TYPE.NONE;
|
||||
}
|
||||
if (
|
||||
ownerType === MEMORY_SCOPE_OWNER_TYPE.CHARACTER ||
|
||||
ownerType === MEMORY_SCOPE_OWNER_TYPE.USER
|
||||
) {
|
||||
return ownerType;
|
||||
}
|
||||
return MEMORY_SCOPE_OWNER_TYPE.NONE;
|
||||
}
|
||||
|
||||
function normalizeLayer(layer) {
|
||||
return layer === MEMORY_SCOPE_LAYER.POV
|
||||
? MEMORY_SCOPE_LAYER.POV
|
||||
: MEMORY_SCOPE_LAYER.OBJECTIVE;
|
||||
}
|
||||
|
||||
export function createDefaultMemoryScope(overrides = {}) {
|
||||
return normalizeMemoryScope(overrides);
|
||||
}
|
||||
|
||||
export function normalizeMemoryScope(scope = {}, defaults = {}) {
|
||||
const merged = {
|
||||
...DEFAULT_MEMORY_SCOPE,
|
||||
...(defaults || {}),
|
||||
...(scope || {}),
|
||||
};
|
||||
const layer = normalizeLayer(merged.layer);
|
||||
const ownerType = normalizeOwnerType(layer, normalizeString(merged.ownerType));
|
||||
const ownerId = ownerType
|
||||
? normalizeString(merged.ownerId || merged.ownerName)
|
||||
: "";
|
||||
const ownerName = ownerType ? normalizeString(merged.ownerName) : "";
|
||||
const regionPrimary = normalizeString(merged.regionPrimary);
|
||||
const regionPath = normalizeStringArray(merged.regionPath);
|
||||
const regionSecondary = normalizeStringArray(merged.regionSecondary);
|
||||
|
||||
return {
|
||||
layer,
|
||||
ownerType,
|
||||
ownerId,
|
||||
ownerName,
|
||||
regionPrimary,
|
||||
regionPath,
|
||||
regionSecondary,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeNodeMemoryScope(node, defaults = {}) {
|
||||
const scope = normalizeMemoryScope(node?.scope, defaults);
|
||||
if (node && typeof node === "object") {
|
||||
node.scope = scope;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function normalizeEdgeMemoryScope(edge, defaults = {}) {
|
||||
const scope = normalizeMemoryScope(edge?.scope, defaults);
|
||||
if (edge && typeof edge === "object") {
|
||||
edge.scope = scope;
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
export function isPovScope(scope) {
|
||||
return normalizeMemoryScope(scope).layer === MEMORY_SCOPE_LAYER.POV;
|
||||
}
|
||||
|
||||
export function isObjectiveScope(scope) {
|
||||
return normalizeMemoryScope(scope).layer === MEMORY_SCOPE_LAYER.OBJECTIVE;
|
||||
}
|
||||
|
||||
export function getScopeOwnerKey(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
const ownerType = normalizeString(normalized.ownerType);
|
||||
const ownerId = normalizeKey(normalized.ownerId || normalized.ownerName);
|
||||
return ownerType && ownerId ? `${ownerType}:${ownerId}` : "";
|
||||
}
|
||||
|
||||
export function getScopeRegionTokens(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
return normalizeStringArray([
|
||||
normalized.regionPrimary,
|
||||
...normalized.regionPath,
|
||||
...normalized.regionSecondary,
|
||||
]);
|
||||
}
|
||||
|
||||
export function getScopeRegionKey(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
return normalizeString(normalized.regionPrimary);
|
||||
}
|
||||
|
||||
export function getScopeSummary(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
const regionTokens = getScopeRegionTokens(normalized);
|
||||
return {
|
||||
layer: normalized.layer,
|
||||
ownerType: normalized.ownerType,
|
||||
ownerId: normalized.ownerId,
|
||||
ownerName: normalized.ownerName,
|
||||
ownerKey: getScopeOwnerKey(normalized),
|
||||
regionPrimary: normalized.regionPrimary,
|
||||
regionKey: getScopeRegionKey(normalized),
|
||||
regionTokens,
|
||||
};
|
||||
}
|
||||
|
||||
export function matchesScopeOwner(scope, ownerType, ownerValue = "") {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
if (normalizeString(normalized.ownerType) !== normalizeString(ownerType)) {
|
||||
return false;
|
||||
}
|
||||
const target = normalizeKey(ownerValue);
|
||||
if (!target) {
|
||||
return Boolean(normalized.ownerType);
|
||||
}
|
||||
return [normalized.ownerId, normalized.ownerName]
|
||||
.map((value) => normalizeKey(value))
|
||||
.includes(target);
|
||||
}
|
||||
|
||||
export function isSameLatestScopeBucket(node, options = {}) {
|
||||
const scope = normalizeMemoryScope(options.scope);
|
||||
const targetType = normalizeString(options.type);
|
||||
const primaryKeyField = normalizeString(options.primaryKeyField || "name") || "name";
|
||||
const primaryKeyValue = normalizeString(options.primaryKeyValue);
|
||||
if (!node || normalizeString(node.type) !== targetType) return false;
|
||||
if (normalizeString(node?.fields?.[primaryKeyField]) !== primaryKeyValue) {
|
||||
return false;
|
||||
}
|
||||
return hasSameScopeIdentity(node?.scope, scope);
|
||||
}
|
||||
|
||||
export function hasSameScopeIdentity(a, b) {
|
||||
const scopeA = normalizeMemoryScope(a);
|
||||
const scopeB = normalizeMemoryScope(b);
|
||||
if (scopeA.layer !== scopeB.layer) return false;
|
||||
if (scopeA.layer === MEMORY_SCOPE_LAYER.POV) {
|
||||
return getScopeOwnerKey(scopeA) === getScopeOwnerKey(scopeB);
|
||||
}
|
||||
return normalizeKey(getScopeRegionKey(scopeA)) === normalizeKey(getScopeRegionKey(scopeB));
|
||||
}
|
||||
|
||||
export function canMergeScopedMemories(a, b) {
|
||||
const scopeA = normalizeMemoryScope(a?.scope || a);
|
||||
const scopeB = normalizeMemoryScope(b?.scope || b);
|
||||
if (scopeA.layer !== scopeB.layer) return false;
|
||||
|
||||
if (scopeA.layer === MEMORY_SCOPE_LAYER.POV) {
|
||||
const ownerKeyA = getScopeOwnerKey(scopeA);
|
||||
const ownerKeyB = getScopeOwnerKey(scopeB);
|
||||
return Boolean(ownerKeyA) && ownerKeyA === ownerKeyB;
|
||||
}
|
||||
|
||||
const regionA = normalizeKey(getScopeRegionKey(scopeA));
|
||||
const regionB = normalizeKey(getScopeRegionKey(scopeB));
|
||||
return regionA === regionB;
|
||||
}
|
||||
|
||||
export function classifyNodeScopeBucket(
|
||||
node,
|
||||
{
|
||||
activeCharacterPovOwner = "",
|
||||
activeUserPovOwner = "",
|
||||
activeRegion = "",
|
||||
enablePovMemory = true,
|
||||
enableRegionScopedObjective = true,
|
||||
} = {},
|
||||
) {
|
||||
const scope = normalizeMemoryScope(node?.scope);
|
||||
const normalizedActiveRegion = normalizeKey(activeRegion);
|
||||
|
||||
if (scope.layer === MEMORY_SCOPE_LAYER.POV) {
|
||||
if (!enablePovMemory) {
|
||||
return MEMORY_SCOPE_BUCKETS.OTHER_POV;
|
||||
}
|
||||
if (
|
||||
scope.ownerType === MEMORY_SCOPE_OWNER_TYPE.CHARACTER &&
|
||||
matchesScopeOwner(scope, MEMORY_SCOPE_OWNER_TYPE.CHARACTER, activeCharacterPovOwner)
|
||||
) {
|
||||
return MEMORY_SCOPE_BUCKETS.CHARACTER_POV;
|
||||
}
|
||||
if (
|
||||
scope.ownerType === MEMORY_SCOPE_OWNER_TYPE.USER &&
|
||||
matchesScopeOwner(scope, MEMORY_SCOPE_OWNER_TYPE.USER, activeUserPovOwner)
|
||||
) {
|
||||
return MEMORY_SCOPE_BUCKETS.USER_POV;
|
||||
}
|
||||
if (
|
||||
!normalizeString(activeCharacterPovOwner) &&
|
||||
scope.ownerType === MEMORY_SCOPE_OWNER_TYPE.CHARACTER
|
||||
) {
|
||||
return MEMORY_SCOPE_BUCKETS.CHARACTER_POV;
|
||||
}
|
||||
if (
|
||||
!normalizeString(activeUserPovOwner) &&
|
||||
scope.ownerType === MEMORY_SCOPE_OWNER_TYPE.USER
|
||||
) {
|
||||
return MEMORY_SCOPE_BUCKETS.USER_POV;
|
||||
}
|
||||
return MEMORY_SCOPE_BUCKETS.OTHER_POV;
|
||||
}
|
||||
|
||||
if (!enableRegionScopedObjective || !normalizedActiveRegion) {
|
||||
return MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL;
|
||||
}
|
||||
|
||||
const regionPrimary = normalizeKey(scope.regionPrimary);
|
||||
if (regionPrimary && regionPrimary === normalizedActiveRegion) {
|
||||
return MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION;
|
||||
}
|
||||
|
||||
const tokens = getScopeRegionTokens(scope).map((value) => normalizeKey(value));
|
||||
if (tokens.includes(normalizedActiveRegion)) {
|
||||
return MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION;
|
||||
}
|
||||
|
||||
return MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL;
|
||||
}
|
||||
|
||||
export function resolveScopeBucketWeight(bucket, overrides = {}) {
|
||||
return Number(
|
||||
overrides?.[bucket] ?? DEFAULT_SCOPE_BUCKET_WEIGHTS[bucket] ?? 1,
|
||||
) || 1;
|
||||
}
|
||||
|
||||
export function describeScopeBucket(bucket) {
|
||||
switch (bucket) {
|
||||
case MEMORY_SCOPE_BUCKETS.CHARACTER_POV:
|
||||
return "角色 POV";
|
||||
case MEMORY_SCOPE_BUCKETS.USER_POV:
|
||||
return "用户 POV";
|
||||
case MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION:
|
||||
return "当前地区客观";
|
||||
case MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION:
|
||||
return "邻近地区客观";
|
||||
case MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL:
|
||||
return "全局客观";
|
||||
case MEMORY_SCOPE_BUCKETS.OTHER_POV:
|
||||
return "其他 POV";
|
||||
default:
|
||||
return normalizeString(bucket) || "未知作用域";
|
||||
}
|
||||
}
|
||||
|
||||
export function describeMemoryScope(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
const parts = [];
|
||||
parts.push(
|
||||
normalized.layer === MEMORY_SCOPE_LAYER.POV ? "POV" : "客观",
|
||||
);
|
||||
|
||||
if (normalized.ownerType) {
|
||||
const ownerLabel = normalized.ownerName || normalized.ownerId;
|
||||
parts.push(`${normalized.ownerType}:${ownerLabel || "未命名"}`);
|
||||
}
|
||||
|
||||
if (normalized.regionPrimary) {
|
||||
parts.push(`地区:${normalized.regionPrimary}`);
|
||||
}
|
||||
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
export function buildScopeBadgeText(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
if (normalized.layer === MEMORY_SCOPE_LAYER.POV) {
|
||||
const ownerLabel = normalized.ownerName || normalized.ownerId || "POV";
|
||||
return normalized.ownerType === MEMORY_SCOPE_OWNER_TYPE.USER
|
||||
? `用户 POV · ${ownerLabel}`
|
||||
: `角色 POV · ${ownerLabel}`;
|
||||
}
|
||||
return normalized.regionPrimary ? `客观 · ${normalized.regionPrimary}` : "客观 · 全局";
|
||||
}
|
||||
|
||||
export function buildRegionLine(scope) {
|
||||
const normalized = normalizeMemoryScope(scope);
|
||||
const parts = [];
|
||||
if (normalized.regionPrimary) {
|
||||
parts.push(`主地区: ${normalized.regionPrimary}`);
|
||||
}
|
||||
if (normalized.regionPath.length > 0) {
|
||||
parts.push(`地区路径: ${normalized.regionPath.join(" / ")}`);
|
||||
}
|
||||
if (normalized.regionSecondary.length > 0) {
|
||||
parts.push(`次级地区: ${normalized.regionSecondary.join(", ")}`);
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}
|
||||
64
graph/node-labels.js
Normal file
64
graph/node-labels.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const DEFAULT_GRAPH_LABEL_LENGTH = 14;
|
||||
|
||||
const GRAPH_LABEL_LENGTH_BY_TYPE = {
|
||||
character: 12,
|
||||
event: 14,
|
||||
location: 12,
|
||||
thread: 14,
|
||||
rule: 14,
|
||||
synopsis: 16,
|
||||
reflection: 14,
|
||||
pov_memory: 16,
|
||||
};
|
||||
|
||||
function normalizeLabelText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function truncateNodeLabel(text, maxLength = DEFAULT_GRAPH_LABEL_LENGTH) {
|
||||
const normalized = normalizeLabelText(text);
|
||||
if (!normalized) return "—";
|
||||
if (!Number.isFinite(maxLength) || maxLength < 2) return normalized;
|
||||
if (normalized.length <= maxLength) return normalized;
|
||||
return `${normalized.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
export function deriveEventTitleFromSummary(summary, maxLength = 18) {
|
||||
const normalized = normalizeLabelText(summary).replace(/^事件[::]\s*/, "");
|
||||
if (!normalized) return "";
|
||||
|
||||
const clause =
|
||||
normalized.split(/[\r\n]+/, 1)[0]?.split(/[。!?!?]/, 1)[0]?.split(/[;;]/, 1)[0]?.split(/[,,]/, 1)[0] ||
|
||||
normalized;
|
||||
|
||||
return truncateNodeLabel(clause || normalized, maxLength);
|
||||
}
|
||||
|
||||
export function ensureEventTitle(fields = {}) {
|
||||
const nextFields = { ...(fields || {}) };
|
||||
if (!nextFields.title && nextFields.summary) {
|
||||
nextFields.title = deriveEventTitleFromSummary(nextFields.summary);
|
||||
}
|
||||
return nextFields;
|
||||
}
|
||||
|
||||
export function getNodeDisplayName(node) {
|
||||
const label = normalizeLabelText(
|
||||
node?.fields?.name ||
|
||||
node?.fields?.title ||
|
||||
node?.fields?.summary ||
|
||||
node?.fields?.insight ||
|
||||
node?.name ||
|
||||
node?.id?.slice(0, 8) ||
|
||||
"—",
|
||||
);
|
||||
return label || "—";
|
||||
}
|
||||
|
||||
export function getGraphNodeLabel(node) {
|
||||
const maxLength =
|
||||
GRAPH_LABEL_LENGTH_BY_TYPE[node?.type] || DEFAULT_GRAPH_LABEL_LENGTH;
|
||||
return truncateNodeLabel(getNodeDisplayName(node), maxLength);
|
||||
}
|
||||
330
graph/schema.js
Normal file
330
graph/schema.js
Normal file
@@ -0,0 +1,330 @@
|
||||
// ST-BME: 节点类型 Schema 定义
|
||||
// 定义图谱中支持的节点类型、字段、注入策略和压缩配置
|
||||
|
||||
/**
|
||||
* 压缩模式
|
||||
*/
|
||||
export const COMPRESSION_MODE = {
|
||||
NONE: "none",
|
||||
HIERARCHICAL: "hierarchical",
|
||||
};
|
||||
|
||||
/**
|
||||
* 默认节点类型 Schema
|
||||
* 每种类型定义了:
|
||||
* - id: 唯一标识
|
||||
* - label: 显示名称
|
||||
* - tableName: 注入时的表名
|
||||
* - columns: 字段列表 [{name, hint, required}]
|
||||
* - alwaysInject: 是否常驻注入(true=Core, false=需要召回)
|
||||
* - latestOnly: 是否只保留最新版本(用于角色/地点等随时间更新的实体)
|
||||
* - forceUpdate: 每次提取是否必须产出此类型节点
|
||||
* - compression: 压缩配置
|
||||
*/
|
||||
export const DEFAULT_NODE_SCHEMA = [
|
||||
{
|
||||
id: "event",
|
||||
label: "事件",
|
||||
tableName: "event_table",
|
||||
columns: [
|
||||
{ name: "title", hint: "简短事件名(建议 6-18 字,用于图谱显示)", required: false },
|
||||
{ name: "summary", hint: "事件摘要,包含因果关系和结果", required: true },
|
||||
{ name: "participants", hint: "参与角色名,逗号分隔", required: false },
|
||||
{
|
||||
name: "status",
|
||||
hint: "事件状态:ongoing/resolved/blocked",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: true,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 9,
|
||||
fanIn: 3,
|
||||
maxDepth: 10,
|
||||
keepRecentLeaves: 6,
|
||||
instruction:
|
||||
"将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "character",
|
||||
label: "角色",
|
||||
tableName: "character_table",
|
||||
columns: [
|
||||
{ name: "name", hint: "角色名(仅规范名称)", required: true },
|
||||
{ name: "traits", hint: "稳定的性格特征和外貌标记", required: false },
|
||||
{ name: "state", hint: "当前状态或处境", required: false },
|
||||
{ name: "goal", hint: "当前目标或动机", required: false },
|
||||
{ name: "inventory", hint: "携带或拥有的关键物品", required: false },
|
||||
{ name: "core_note", hint: "值得长期记住的关键备注", required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: true,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
label: "地点",
|
||||
tableName: "location_table",
|
||||
columns: [
|
||||
{ name: "name", hint: "地点名称(仅规范名称)", required: true },
|
||||
{ name: "state", hint: "当前状态或环境条件", required: false },
|
||||
{ name: "features", hint: "重要特征、资源或服务", required: false },
|
||||
{ name: "danger", hint: "危险等级或威胁", required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: true,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rule",
|
||||
label: "规则",
|
||||
tableName: "rule_table",
|
||||
columns: [
|
||||
{ name: "title", hint: "简短规则名", required: true },
|
||||
{ name: "constraint", hint: "不可违反的规则内容", required: true },
|
||||
{ name: "scope", hint: "适用范围/场景", required: false },
|
||||
{
|
||||
name: "status",
|
||||
hint: "当前有效性:active/suspended/revoked",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "thread",
|
||||
label: "主线",
|
||||
tableName: "thread_table",
|
||||
columns: [
|
||||
{ name: "title", hint: "主线名称", required: true },
|
||||
{ name: "summary", hint: "当前进展摘要", required: false },
|
||||
{
|
||||
name: "status",
|
||||
hint: "状态:active/completed/abandoned",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 6,
|
||||
fanIn: 3,
|
||||
maxDepth: 5,
|
||||
keepRecentLeaves: 3,
|
||||
instruction: "将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。",
|
||||
},
|
||||
},
|
||||
// ====== v2 新增节点类型 ======
|
||||
{
|
||||
id: "synopsis",
|
||||
label: "全局概要",
|
||||
tableName: "synopsis_table",
|
||||
columns: [
|
||||
{
|
||||
name: "summary",
|
||||
hint: "当前故事的全局概要(前情提要)",
|
||||
required: true,
|
||||
},
|
||||
{ name: "scope", hint: "概要覆盖的楼层范围", required: false },
|
||||
],
|
||||
alwaysInject: true, // 常驻注入(MemoRAG 启发)
|
||||
latestOnly: true, // 只保留最新版本
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reflection",
|
||||
label: "反思",
|
||||
tableName: "reflection_table",
|
||||
columns: [
|
||||
{ name: "insight", hint: "对角色行为或情节的元认知反思", required: true },
|
||||
{ name: "trigger", hint: "触发反思的事件/矛盾", required: false },
|
||||
{ name: "suggestion", hint: "对后续叙事的建议", required: false },
|
||||
],
|
||||
alwaysInject: false, // 需要被召回
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 6,
|
||||
fanIn: 3,
|
||||
maxDepth: 3,
|
||||
keepRecentLeaves: 3,
|
||||
instruction: "将反思条目合并为高层次的叙事指导原则。",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pov_memory",
|
||||
label: "主观记忆",
|
||||
tableName: "pov_memory_table",
|
||||
columns: [
|
||||
{ name: "summary", hint: "这个视角如何记住这件事", required: true },
|
||||
{ name: "belief", hint: "她/他认为发生了什么", required: false },
|
||||
{ name: "emotion", hint: "主观情绪反应", required: false },
|
||||
{ name: "attitude", hint: "对人物或事件的态度", required: false },
|
||||
{
|
||||
name: "certainty",
|
||||
hint: "确定度:certain/unsure/mistaken",
|
||||
required: false,
|
||||
},
|
||||
{ name: "about", hint: "关联对象或引用标签", required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 8,
|
||||
fanIn: 3,
|
||||
maxDepth: 4,
|
||||
keepRecentLeaves: 4,
|
||||
instruction:
|
||||
"将同一视角、同一角色归属下的主观记忆压缩成更稳定的第一视角记忆摘要,保留误解、情绪和态度变化。",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 规范化的关系类型
|
||||
*/
|
||||
export const RELATION_TYPES = [
|
||||
"related", // 一般关联
|
||||
"involved_in", // 参与事件
|
||||
"occurred_at", // 发生于地点
|
||||
"advances", // 推进主线
|
||||
"updates", // 更新实体状态
|
||||
"contradicts", // 矛盾/冲突(用于抑制边)
|
||||
"evolves", // A-MEM 进化链接(新→旧)
|
||||
"temporal_update", // 时序更新(Graphiti:新状态替代旧状态)
|
||||
];
|
||||
|
||||
/**
|
||||
* 验证 Schema 配置的合法性
|
||||
* @param {Array} schema
|
||||
* @returns {{valid: boolean, errors: string[]}}
|
||||
*/
|
||||
export function validateSchema(schema) {
|
||||
const errors = [];
|
||||
|
||||
if (!Array.isArray(schema) || schema.length === 0) {
|
||||
errors.push("Schema 必须是非空数组");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const tableNames = new Set();
|
||||
|
||||
for (const type of schema) {
|
||||
if (!type || typeof type !== "object") {
|
||||
errors.push("Schema 类型定义必须是对象");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!type.id || typeof type.id !== "string") {
|
||||
errors.push("每种类型必须有 id");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ids.has(type.id)) {
|
||||
errors.push(`类型 ID 重复:${type.id}`);
|
||||
}
|
||||
ids.add(type.id);
|
||||
|
||||
if (!type.label || typeof type.label !== "string") {
|
||||
errors.push(`类型 ${type.id}:缺少 label`);
|
||||
}
|
||||
|
||||
if (!type.tableName || typeof type.tableName !== "string") {
|
||||
errors.push(`类型 ${type.id}:缺少 tableName`);
|
||||
} else if (tableNames.has(type.tableName)) {
|
||||
errors.push(`表名重复:${type.tableName}`);
|
||||
} else {
|
||||
tableNames.add(type.tableName);
|
||||
}
|
||||
|
||||
if (!Array.isArray(type.columns) || type.columns.length === 0) {
|
||||
errors.push(`类型 ${type.id}:至少需要一个列`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnNames = new Set();
|
||||
for (const column of type.columns) {
|
||||
if (!column?.name || typeof column.name !== "string") {
|
||||
errors.push(`类型 ${type.id}:存在缺少 name 的列定义`);
|
||||
continue;
|
||||
}
|
||||
if (columnNames.has(column.name)) {
|
||||
errors.push(`类型 ${type.id}:列名重复 ${column.name}`);
|
||||
}
|
||||
columnNames.add(column.name);
|
||||
}
|
||||
|
||||
const hasRequired = type.columns.some((c) => c?.required);
|
||||
if (!hasRequired) {
|
||||
errors.push(`类型 ${type.id}:至少需要一个 required 列`);
|
||||
}
|
||||
|
||||
if (type.latestOnly) {
|
||||
const hasPrimaryLikeField = ["name", "title", "summary"].some(
|
||||
(fieldName) =>
|
||||
type.columns.some((column) => column?.name === fieldName),
|
||||
);
|
||||
if (!hasPrimaryLikeField) {
|
||||
errors.push(
|
||||
`类型 ${type.id}:latestOnly 类型至少需要 name/title/summary 之一作为主标识字段`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Schema 中某个类型的定义
|
||||
* @param {Array} schema
|
||||
* @param {string} typeId
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getSchemaType(schema, typeId) {
|
||||
return schema.find((t) => t.id === typeId) || null;
|
||||
}
|
||||
Reference in New Issue
Block a user