mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-10 00:20:44 +08:00
287 lines
8.8 KiB
JavaScript
287 lines
8.8 KiB
JavaScript
export function resolveVisibleGraphWorkspaceMode({
|
|
overlayActive = false,
|
|
isMobile = false,
|
|
currentTabId = "dashboard",
|
|
currentGraphView = "graph",
|
|
currentMobileGraphView = "graph",
|
|
} = {}) {
|
|
if (!overlayActive) return "hidden";
|
|
if (isMobile) {
|
|
if (currentTabId !== "graph") return "hidden";
|
|
const mobileView = String(currentMobileGraphView || "graph").trim() || "graph";
|
|
return mobileView === "cognition"
|
|
? "mobile:cognition"
|
|
: mobileView === "summary"
|
|
? "mobile:summary"
|
|
: "mobile:graph";
|
|
}
|
|
if (currentTabId === "config") return "hidden";
|
|
const desktopView = String(currentGraphView || "graph").trim() || "graph";
|
|
return desktopView === "cognition"
|
|
? "desktop:cognition"
|
|
: desktopView === "summary"
|
|
? "desktop:summary"
|
|
: "desktop:graph";
|
|
}
|
|
|
|
export function buildVisibleGraphRefreshToken({
|
|
visibleMode = "hidden",
|
|
chatId = "",
|
|
loadState = "",
|
|
revision = 0,
|
|
nodeCount = -1,
|
|
edgeCount = -1,
|
|
lastProcessedSeq = -1,
|
|
} = {}) {
|
|
const normalizedMode = String(visibleMode || "hidden").trim() || "hidden";
|
|
if (normalizedMode === "hidden") return "hidden";
|
|
const normalizedRevision = Number.isFinite(Number(revision))
|
|
? Math.trunc(Number(revision))
|
|
: 0;
|
|
const normalizedNodeCount = Number.isFinite(Number(nodeCount))
|
|
? Math.trunc(Number(nodeCount))
|
|
: -1;
|
|
const normalizedEdgeCount = Number.isFinite(Number(edgeCount))
|
|
? Math.trunc(Number(edgeCount))
|
|
: -1;
|
|
const normalizedLastProcessedSeq = Number.isFinite(Number(lastProcessedSeq))
|
|
? Math.trunc(Number(lastProcessedSeq))
|
|
: -1;
|
|
return [
|
|
normalizedMode,
|
|
String(chatId || "").trim(),
|
|
String(loadState || "").trim() || "unknown",
|
|
normalizedRevision,
|
|
normalizedNodeCount,
|
|
normalizedEdgeCount,
|
|
normalizedLastProcessedSeq,
|
|
].join("|");
|
|
}
|
|
|
|
function graphCollectionToArray(collection) {
|
|
if (!collection) return [];
|
|
if (Array.isArray(collection)) return collection;
|
|
if (collection instanceof Map) return Array.from(collection.values());
|
|
if (typeof collection === "object") return Object.values(collection);
|
|
return [];
|
|
}
|
|
|
|
function normalizeGraphIdentity(value) {
|
|
if (value === null || value === undefined) return "";
|
|
return String(value).trim();
|
|
}
|
|
|
|
function stableHash(input) {
|
|
const text = String(input || "");
|
|
let hash = 2166136261;
|
|
for (let i = 0; i < text.length; i += 1) {
|
|
hash ^= text.charCodeAt(i);
|
|
hash = Math.imul(hash, 16777619);
|
|
}
|
|
return (hash >>> 0).toString(36);
|
|
}
|
|
|
|
function hashParts(parts) {
|
|
if (!Array.isArray(parts) || parts.length === 0) return "0";
|
|
return stableHash(parts.join("\u001f"));
|
|
}
|
|
|
|
function isArchivedNode(node) {
|
|
if (!node || typeof node !== "object") return true;
|
|
return Boolean(node.archived || node.archivedAt);
|
|
}
|
|
|
|
/**
|
|
* Builds a compact, deterministic fingerprint of graph structure.
|
|
*
|
|
* The fingerprint intentionally omits large raw node/edge arrays. It includes
|
|
* only active node ids and active edge identities, sorted before hashing.
|
|
*/
|
|
export function buildGraphStructureFingerprint(graph) {
|
|
if (!graph) return "empty";
|
|
|
|
const activeNodeIds = graphCollectionToArray(graph.nodes)
|
|
.filter((node) => !isArchivedNode(node))
|
|
.map((node) => normalizeGraphIdentity(node?.id ?? node?.nodeId ?? node?.key))
|
|
.filter(Boolean)
|
|
.sort();
|
|
const activeNodeIdSet = new Set(activeNodeIds);
|
|
|
|
const activeEdgeIdentities = graphCollectionToArray(graph.edges)
|
|
.filter((edge) => edge && typeof edge === "object")
|
|
.filter((edge) => !edge.invalidAt && !edge.expiredAt)
|
|
.map((edge) => {
|
|
const from = normalizeGraphIdentity(edge.from ?? edge.fromId);
|
|
const to = normalizeGraphIdentity(edge.to ?? edge.toId);
|
|
const relation = normalizeGraphIdentity(edge.relation ?? edge.type);
|
|
if (!from || !to) return "";
|
|
if (!activeNodeIdSet.has(from) || !activeNodeIdSet.has(to)) return "";
|
|
return `${from}>${to}:${relation}`;
|
|
})
|
|
.filter(Boolean)
|
|
.sort();
|
|
|
|
return [
|
|
`nodes:${activeNodeIds.length}:${hashParts(activeNodeIds)}`,
|
|
`edges:${activeEdgeIdentities.length}:${hashParts(activeEdgeIdentities)}`,
|
|
].join("|");
|
|
}
|
|
|
|
export function classifyGraphRefresh({
|
|
previousToken,
|
|
nextToken,
|
|
previousFingerprint,
|
|
nextFingerprint,
|
|
force = false,
|
|
final = false,
|
|
hard = false,
|
|
visibleMode,
|
|
} = {}) {
|
|
const normalizedVisibleMode = String(visibleMode || "").trim();
|
|
const normalizedNextToken = String(nextToken || "").trim();
|
|
const tokenChanged = previousToken !== nextToken;
|
|
const structureChanged = previousFingerprint !== nextFingerprint;
|
|
const forced = Boolean(force);
|
|
const isFinal = Boolean(final);
|
|
const isHard = Boolean(hard);
|
|
|
|
const base = {
|
|
tokenChanged,
|
|
structureChanged,
|
|
force: forced,
|
|
final: isFinal,
|
|
hard: isHard,
|
|
};
|
|
|
|
if (normalizedVisibleMode === "hidden" || normalizedNextToken === "hidden") {
|
|
return { ...base, action: "hidden", reason: "hidden" };
|
|
}
|
|
if (isHard) return { ...base, action: "hard-refresh", reason: "hard" };
|
|
if (isFinal) return { ...base, action: "final-refresh", reason: "final" };
|
|
if (structureChanged) {
|
|
return { ...base, action: "refresh", reason: "structure-changed" };
|
|
}
|
|
if (forced) return { ...base, action: "refresh", reason: "force" };
|
|
if (tokenChanged) {
|
|
return { ...base, action: "highlight-only", reason: "token-changed" };
|
|
}
|
|
return { ...base, action: "skip", reason: "unchanged" };
|
|
}
|
|
|
|
export function createGraphRefreshGovernor(options = {}) {
|
|
const liveThrottleMs = Number.isFinite(Number(options.liveThrottleMs))
|
|
? Math.max(0, Number(options.liveThrottleMs))
|
|
: 240;
|
|
const extractionThrottleMs = Number.isFinite(Number(options.extractionThrottleMs))
|
|
? Math.max(0, Number(options.extractionThrottleMs))
|
|
: 700;
|
|
const layoutRestartWindowMs = Number.isFinite(Number(options.layoutRestartWindowMs))
|
|
? Math.max(0, Number(options.layoutRestartWindowMs))
|
|
: 5000;
|
|
const layoutRestartMax = Number.isFinite(Number(options.layoutRestartMax))
|
|
? Math.max(0, Math.trunc(Number(options.layoutRestartMax)))
|
|
: 2;
|
|
const layoutCooldownMs = Number.isFinite(Number(options.layoutCooldownMs))
|
|
? Math.max(0, Number(options.layoutCooldownMs))
|
|
: 9000;
|
|
const now = typeof options.now === "function" ? options.now : () => Date.now();
|
|
|
|
const initialState = () => ({
|
|
lastToken: undefined,
|
|
lastFingerprint: undefined,
|
|
lastRefreshAt: 0,
|
|
coalescedCount: 0,
|
|
cooldownUntil: 0,
|
|
layoutStarts: [],
|
|
});
|
|
let state = initialState();
|
|
|
|
function getCurrentTime(input = {}) {
|
|
return Number.isFinite(Number(input.now)) ? Number(input.now) : Number(now());
|
|
}
|
|
|
|
function getState() {
|
|
return {
|
|
...state,
|
|
layoutStarts: [...state.layoutStarts],
|
|
};
|
|
}
|
|
|
|
function reset() {
|
|
state = initialState();
|
|
}
|
|
|
|
function noteRefresh(input = {}) {
|
|
const timestamp = getCurrentTime(input);
|
|
const classification = classifyGraphRefresh({
|
|
previousToken: input.previousToken ?? state.lastToken,
|
|
nextToken: input.nextToken,
|
|
previousFingerprint: input.previousFingerprint ?? state.lastFingerprint,
|
|
nextFingerprint: input.nextFingerprint,
|
|
force: input.force,
|
|
final: input.final,
|
|
hard: input.hard,
|
|
visibleMode: input.visibleMode,
|
|
});
|
|
const shouldRefresh = !["hidden", "skip"].includes(classification.action);
|
|
const shouldLayout = ["refresh", "hard-refresh", "final-refresh"].includes(
|
|
classification.action,
|
|
);
|
|
const delayMs = shouldRefresh
|
|
? (input.isExtracting ? extractionThrottleMs : liveThrottleMs)
|
|
: 0;
|
|
|
|
state.lastToken = input.nextToken;
|
|
state.lastFingerprint = input.nextFingerprint;
|
|
if (shouldRefresh) {
|
|
state.lastRefreshAt = timestamp;
|
|
state.coalescedCount = delayMs > 0 ? state.coalescedCount + 1 : 0;
|
|
} else if (classification.action === "skip") {
|
|
state.coalescedCount = 0;
|
|
}
|
|
|
|
return {
|
|
shouldRefresh,
|
|
shouldLayout,
|
|
action: classification.action,
|
|
delayMs,
|
|
reason: classification.reason,
|
|
coalescedCount: state.coalescedCount,
|
|
cooldownUntil: state.cooldownUntil,
|
|
};
|
|
}
|
|
|
|
function canStartLayout(input = {}) {
|
|
const timestamp = getCurrentTime(input);
|
|
if (timestamp < state.cooldownUntil) {
|
|
return {
|
|
allowed: false,
|
|
reason: "cooldown",
|
|
cooldownUntil: state.cooldownUntil,
|
|
};
|
|
}
|
|
|
|
state.layoutStarts = state.layoutStarts.filter(
|
|
(startedAt) => timestamp - startedAt < layoutRestartWindowMs,
|
|
);
|
|
|
|
if (state.layoutStarts.length >= layoutRestartMax) {
|
|
state.cooldownUntil = timestamp + layoutCooldownMs;
|
|
return {
|
|
allowed: false,
|
|
reason: "budget-exhausted",
|
|
cooldownUntil: state.cooldownUntil,
|
|
};
|
|
}
|
|
|
|
state.layoutStarts.push(timestamp);
|
|
return {
|
|
allowed: true,
|
|
reason: "allowed",
|
|
cooldownUntil: state.cooldownUntil,
|
|
};
|
|
}
|
|
|
|
return { getState, reset, noteRefresh, canStartLayout };
|
|
}
|