perf: optimize persist delta gating and diagnostics

This commit is contained in:
Youzini-afk
2026-04-13 16:11:22 +08:00
parent 8f7572b615
commit b16785e56f
30 changed files with 4495 additions and 47 deletions

303
ui/graph-layout-solver.js Normal file
View File

@@ -0,0 +1,303 @@
const DEFAULT_LAYOUT_CONFIG = Object.freeze({
iterations: 80,
repulsion: 2800,
springK: 0.048,
damping: 0.88,
centerGravity: 0.014,
minGap: 12,
speedCap: 3.8,
});
function clampFinite(value, fallback = 0, min = -Infinity, max = Infinity) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.max(min, Math.min(max, numeric));
}
function normalizeConfig(raw = {}) {
return {
iterations: Math.max(
8,
Math.min(220, Math.floor(clampFinite(raw.iterations, DEFAULT_LAYOUT_CONFIG.iterations, 1, 220))),
),
repulsion: clampFinite(raw.repulsion, DEFAULT_LAYOUT_CONFIG.repulsion, 100, 120000),
springK: clampFinite(raw.springK, DEFAULT_LAYOUT_CONFIG.springK, 0.001, 1.0),
damping: clampFinite(raw.damping, DEFAULT_LAYOUT_CONFIG.damping, 0.1, 0.999),
centerGravity: clampFinite(
raw.centerGravity,
DEFAULT_LAYOUT_CONFIG.centerGravity,
0.0001,
1,
),
minGap: clampFinite(raw.minGap, DEFAULT_LAYOUT_CONFIG.minGap, 0, 120),
speedCap: clampFinite(raw.speedCap, DEFAULT_LAYOUT_CONFIG.speedCap, 0.5, 20),
};
}
function normalizePanel(raw = null) {
if (!raw || typeof raw !== "object") {
return { x: 0, y: 0, w: 0, h: 0 };
}
return {
x: clampFinite(raw.x, 0),
y: clampFinite(raw.y, 0),
w: Math.max(0, clampFinite(raw.w, 0)),
h: Math.max(0, clampFinite(raw.h, 0)),
};
}
function normalizeNode(raw = {}) {
const rect = normalizePanel(raw.regionRect);
return {
x: clampFinite(raw.x, 0),
y: clampFinite(raw.y, 0),
vx: clampFinite(raw.vx, 0),
vy: clampFinite(raw.vy, 0),
pinned: raw.pinned === true,
radius: Math.max(1, clampFinite(raw.radius, 8, 1, 96)),
regionKey: String(raw.regionKey || "objective"),
regionRect: rect,
};
}
function normalizeEdge(raw = {}, nodeCount = 0) {
const from = Math.floor(clampFinite(raw.from, -1));
const to = Math.floor(clampFinite(raw.to, -1));
if (from < 0 || to < 0 || from >= nodeCount || to >= nodeCount || from === to) {
return null;
}
return {
from,
to,
strength: clampFinite(raw.strength, 0.5, 0, 1),
};
}
function clampNodeToRegion(state, index) {
const rect = state.rects[index];
const radius = state.radius[index] + 6;
state.x[index] = Math.max(rect.x + radius, Math.min(rect.x + rect.w - radius, state.x[index]));
state.y[index] = Math.max(rect.y + radius, Math.min(rect.y + rect.h - radius, state.y[index]));
}
function computeSpringIdealByRegion(nodes = []) {
const countByRegion = new Map();
for (const node of nodes) {
countByRegion.set(node.regionKey, (countByRegion.get(node.regionKey) || 0) + 1);
}
const idealByRegion = new Map();
for (const node of nodes) {
if (idealByRegion.has(node.regionKey)) continue;
const rect = node.regionRect;
const count = Math.max(1, countByRegion.get(node.regionKey) || 1);
const area = Math.max(1, (rect?.w || 1) * (rect?.h || 1));
const ideal = Math.max(36, Math.min(92, 0.78 * Math.sqrt(area / count)));
idealByRegion.set(node.regionKey, ideal);
}
return idealByRegion;
}
function buildRegionBuckets(nodes = []) {
const bucketByRegion = new Map();
for (let index = 0; index < nodes.length; index++) {
const key = nodes[index].regionKey;
if (!bucketByRegion.has(key)) {
bucketByRegion.set(key, []);
}
bucketByRegion.get(key).push(index);
}
return bucketByRegion;
}
function buildInRegionEdges(nodes = [], edges = []) {
const result = [];
for (const edge of edges) {
const fromRegion = nodes[edge.from]?.regionKey;
const toRegion = nodes[edge.to]?.regionKey;
if (!fromRegion || fromRegion !== toRegion) continue;
result.push({
from: edge.from,
to: edge.to,
strength: edge.strength,
});
}
return result;
}
function buildRegionCenters(nodes = []) {
const centerX = new Float32Array(nodes.length);
const centerY = new Float32Array(nodes.length);
for (let index = 0; index < nodes.length; index++) {
const rect = nodes[index]?.regionRect || { x: 0, y: 0, w: 0, h: 0 };
centerX[index] = rect.x + rect.w / 2;
centerY[index] = rect.y + rect.h / 2;
}
return { centerX, centerY };
}
function buildSimulationState(nodes = []) {
const length = nodes.length;
const x = new Float32Array(length);
const y = new Float32Array(length);
const vx = new Float32Array(length);
const vy = new Float32Array(length);
const fx = new Float32Array(length);
const fy = new Float32Array(length);
const radius = new Float32Array(length);
const pinned = new Uint8Array(length);
const rects = new Array(length);
for (let i = 0; i < length; i++) {
const node = nodes[i];
x[i] = node.x;
y[i] = node.y;
vx[i] = node.vx;
vy[i] = node.vy;
radius[i] = node.radius;
pinned[i] = node.pinned ? 1 : 0;
rects[i] = node.regionRect;
}
return {
x,
y,
vx,
vy,
fx,
fy,
radius,
pinned,
rects,
};
}
export function solveLayoutWithJs(payload = {}) {
const startedAt = performance.now();
const nodes = Array.isArray(payload.nodes) ? payload.nodes.map(normalizeNode) : [];
const config = normalizeConfig(payload.config || {});
const edgesRaw = Array.isArray(payload.edges) ? payload.edges : [];
const edges = edgesRaw
.map((edge) => normalizeEdge(edge, nodes.length))
.filter(Boolean);
if (nodes.length === 0) {
return {
ok: true,
positions: new Float32Array(0),
diagnostics: {
nodeCount: 0,
edgeCount: 0,
elapsedMs: 0,
solver: "js-worker",
},
};
}
const springIdealByRegion = computeSpringIdealByRegion(nodes);
const regionBuckets = buildRegionBuckets(nodes);
const state = buildSimulationState(nodes);
const inRegionEdges = buildInRegionEdges(nodes, edges);
const { centerX, centerY } = buildRegionCenters(nodes);
let actualIterations = 0;
let stableRounds = 0;
for (let iter = 0; iter < config.iterations; iter++) {
actualIterations += 1;
state.fx.fill(0);
state.fy.fill(0);
for (const indexes of regionBuckets.values()) {
for (let i = 0; i < indexes.length; i++) {
const a = indexes[i];
for (let j = i + 1; j < indexes.length; j++) {
const b = indexes[j];
const dx = state.x[b] - state.x[a];
const dy = state.y[b] - state.y[a];
let distSq = dx * dx + dy * dy;
if (distSq < 0.25) distSq = 0.25;
const dist = Math.sqrt(distSq);
const minSep = state.radius[a] + state.radius[b] + config.minGap;
let force = config.repulsion / distSq;
if (dist < minSep) {
force += (minSep - dist) * 0.22;
}
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
state.fx[a] -= fx;
state.fy[a] -= fy;
state.fx[b] += fx;
state.fy[b] += fy;
}
}
}
for (const edge of inRegionEdges) {
const from = edge.from;
const to = edge.to;
const ideal = springIdealByRegion.get(nodes[from].regionKey) ?? 68;
const dx = state.x[to] - state.x[from];
const dy = state.y[to] - state.y[from];
const dist = Math.sqrt(dx * dx + dy * dy) || 0.001;
const displacement = dist - ideal * (0.82 + 0.18 * edge.strength);
const force = config.springK * displacement * (0.45 + 0.55 * edge.strength);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
state.fx[from] += fx;
state.fy[from] += fy;
state.fx[to] -= fx;
state.fy[to] -= fy;
}
for (let i = 0; i < nodes.length; i++) {
state.fx[i] += (centerX[i] - state.x[i]) * config.centerGravity;
state.fy[i] += (centerY[i] - state.y[i]) * config.centerGravity;
}
let maxSpeed = 0;
for (let i = 0; i < nodes.length; i++) {
if (state.pinned[i]) {
continue;
}
state.vx[i] = (state.vx[i] + state.fx[i]) * config.damping;
state.vy[i] = (state.vy[i] + state.fy[i]) * config.damping;
const speed = Math.hypot(state.vx[i], state.vy[i]);
if (speed > maxSpeed) {
maxSpeed = speed;
}
if (speed > config.speedCap) {
state.vx[i] = (state.vx[i] / speed) * config.speedCap;
state.vy[i] = (state.vy[i] / speed) * config.speedCap;
}
state.x[i] += state.vx[i];
state.y[i] += state.vy[i];
clampNodeToRegion(state, i);
}
if (maxSpeed < 0.015) {
stableRounds += 1;
if (stableRounds >= 6) {
break;
}
} else {
stableRounds = 0;
}
}
const positions = new Float32Array(nodes.length * 2);
for (let i = 0; i < nodes.length; i++) {
positions[i * 2] = state.x[i];
positions[i * 2 + 1] = state.y[i];
}
return {
ok: true,
positions,
diagnostics: {
nodeCount: nodes.length,
edgeCount: edges.length,
elapsedMs: Math.max(0, performance.now() - startedAt),
solver: "js-worker",
iterations: actualIterations,
},
};
}

141
ui/graph-layout-worker.js Normal file
View File

@@ -0,0 +1,141 @@
import { solveLayoutWithJs } from "./graph-layout-solver.js";
let nativeSolver = null;
let nativeLoadAttempted = false;
let nativeLoadError = "";
let readNativeModuleStatus = null;
const canceledJobIds = new Set();
const activeJobIds = new Set();
async function ensureNativeSolver() {
if (nativeLoadAttempted) return nativeSolver;
nativeLoadAttempted = true;
try {
const nativeModule = await import("../vendor/wasm/stbme_core.js");
const solveLayout =
nativeModule?.solveLayout || nativeModule?.default?.solveLayout || null;
nativeSolver = typeof solveLayout === "function" ? solveLayout : null;
readNativeModuleStatus =
typeof nativeModule?.getNativeModuleStatus === "function"
? nativeModule.getNativeModuleStatus
: null;
} catch (error) {
nativeLoadError = error?.message || String(error);
nativeSolver = null;
}
return nativeSolver;
}
async function solveLayout(payload = {}) {
const nativeRequested = payload?.nativeRequested === true;
if (nativeRequested) {
const solver = await ensureNativeSolver();
if (solver) {
try {
const nativeResult = await solver(payload);
const nativeStatus =
typeof readNativeModuleStatus === "function"
? readNativeModuleStatus()
: null;
if (
nativeResult &&
nativeResult.ok === true &&
nativeResult.positions instanceof Float32Array
) {
return {
...nativeResult,
usedNative: true,
diagnostics: {
...(nativeResult.diagnostics || {}),
solver: "rust-wasm",
moduleSource: String(nativeStatus?.source || ""),
nativeLoadError: String(nativeStatus?.error || nativeLoadError || ""),
},
};
}
} catch (error) {
return {
ok: false,
skipped: true,
reason: "native-solver-failed",
error: error?.message || String(error),
nativeLoadError,
};
}
}
}
const jsResult = solveLayoutWithJs(payload);
return {
...jsResult,
usedNative: false,
nativeLoadError,
};
}
self.addEventListener("message", async (event) => {
const message = event?.data || {};
if (message.type === "cancel-layout") {
const canceledJobId = Number(message.jobId);
if (Number.isFinite(canceledJobId)) {
canceledJobIds.add(canceledJobId);
}
return;
}
if (message.type !== "solve-layout") return;
const jobId = Number(message.jobId);
if (!Number.isFinite(jobId)) return;
if (canceledJobIds.has(jobId)) {
canceledJobIds.delete(jobId);
return;
}
activeJobIds.add(jobId);
const fallbackErrorResult = (errorMessage = "unknown-worker-error") => ({
ok: false,
skipped: true,
reason: "worker-exception",
error: String(errorMessage || "unknown-worker-error"),
});
try {
const result = await solveLayout(message.payload || {});
if (!activeJobIds.has(jobId) || canceledJobIds.has(jobId)) {
activeJobIds.delete(jobId);
canceledJobIds.delete(jobId);
return;
}
activeJobIds.delete(jobId);
if (result?.positions instanceof Float32Array) {
self.postMessage(
{
type: "layout-result",
jobId,
result,
},
[result.positions.buffer],
);
return;
}
self.postMessage({
type: "layout-result",
jobId,
result,
});
} catch (error) {
if (!activeJobIds.has(jobId) || canceledJobIds.has(jobId)) {
activeJobIds.delete(jobId);
canceledJobIds.delete(jobId);
return;
}
activeJobIds.delete(jobId);
self.postMessage({
type: "layout-result",
jobId,
result: fallbackErrorResult(error?.message || String(error)),
});
}
});

246
ui/graph-native-bridge.js Normal file
View File

@@ -0,0 +1,246 @@
const DEFAULT_NATIVE_RUNTIME_OPTIONS = Object.freeze({
graphUseNativeLayout: false,
graphNativeLayoutThresholdNodes: 280,
graphNativeLayoutThresholdEdges: 1600,
graphNativeLayoutWorkerTimeoutMs: 260,
nativeEngineFailOpen: true,
graphNativeForceDisable: false,
});
function clampPositiveInt(value, fallback, { min = 1, max = 120000 } = {}) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.max(min, Math.min(max, Math.floor(numeric)));
}
function normalizeBoolean(value, fallback = false) {
if (typeof value === "boolean") return value;
if (value == null) return fallback;
if (typeof value === "number") return Number.isFinite(value) && value !== 0;
const normalized = String(value).trim().toLowerCase();
if (!normalized) return fallback;
if (["1", "true", "yes", "on"].includes(normalized)) return true;
if (["0", "false", "no", "off"].includes(normalized)) return false;
return fallback;
}
export function normalizeGraphNativeRuntimeOptions(options = {}) {
const source =
options && typeof options === "object" && !Array.isArray(options)
? options
: {};
return {
graphUseNativeLayout: normalizeBoolean(
source.graphUseNativeLayout,
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphUseNativeLayout,
),
graphNativeLayoutThresholdNodes: clampPositiveInt(
source.graphNativeLayoutThresholdNodes,
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutThresholdNodes,
{ min: 1, max: 20000 },
),
graphNativeLayoutThresholdEdges: clampPositiveInt(
source.graphNativeLayoutThresholdEdges,
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutThresholdEdges,
{ min: 1, max: 50000 },
),
graphNativeLayoutWorkerTimeoutMs: clampPositiveInt(
source.graphNativeLayoutWorkerTimeoutMs,
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeLayoutWorkerTimeoutMs,
{ min: 40, max: 15000 },
),
nativeEngineFailOpen: normalizeBoolean(
source.nativeEngineFailOpen,
DEFAULT_NATIVE_RUNTIME_OPTIONS.nativeEngineFailOpen,
),
graphNativeForceDisable: normalizeBoolean(
source.graphNativeForceDisable,
DEFAULT_NATIVE_RUNTIME_OPTIONS.graphNativeForceDisable,
),
};
}
export class GraphNativeLayoutBridge {
constructor(runtimeOptions = {}) {
this.runtimeOptions = normalizeGraphNativeRuntimeOptions(runtimeOptions);
this._worker = null;
this._workerBootError = "";
this._nextJobId = 1;
this._pendingJobs = new Map();
this._isDisposed = false;
}
updateRuntimeOptions(runtimeOptions = {}) {
this.runtimeOptions = normalizeGraphNativeRuntimeOptions(runtimeOptions);
}
shouldRunForGraph(nodeCount = 0, edgeCount = 0) {
if (this.runtimeOptions.graphNativeForceDisable) return false;
if (!this.runtimeOptions.graphUseNativeLayout) return false;
const normalizedNodes = Math.max(0, Number(nodeCount) || 0);
const normalizedEdges = Math.max(0, Number(edgeCount) || 0);
return (
normalizedNodes >= this.runtimeOptions.graphNativeLayoutThresholdNodes ||
normalizedEdges >= this.runtimeOptions.graphNativeLayoutThresholdEdges
);
}
async solveLayout(payload = {}, { timeoutMs = null } = {}) {
if (this._isDisposed) {
return {
ok: false,
skipped: true,
reason: "bridge-disposed",
};
}
const worker = this._ensureWorker();
if (!worker) {
const result = {
ok: false,
skipped: true,
reason: "worker-unavailable",
error: this._workerBootError || "Graph worker unavailable",
};
if (this.runtimeOptions.nativeEngineFailOpen) {
return result;
}
throw new Error(result.error);
}
const normalizedTimeoutMs = clampPositiveInt(
timeoutMs,
this.runtimeOptions.graphNativeLayoutWorkerTimeoutMs,
{ min: 40, max: 15000 },
);
const jobId = this._nextJobId++;
return await new Promise((resolve) => {
const timer = setTimeout(() => {
if (this._worker) {
this._worker.postMessage({
type: "cancel-layout",
jobId,
reason: "native-layout-timeout",
});
}
this._pendingJobs.delete(jobId);
resolve({
ok: false,
skipped: true,
reason: "native-layout-timeout",
timeoutMs: normalizedTimeoutMs,
});
}, normalizedTimeoutMs);
this._pendingJobs.set(jobId, {
resolve,
timer,
});
worker.postMessage({
type: "solve-layout",
jobId,
payload: {
...payload,
nativeRequested: true,
timeoutMs: normalizedTimeoutMs,
},
});
});
}
cancelPending(reason = "native-layout-canceled") {
if (this._pendingJobs.size <= 0) return;
const cancelReason = String(reason || "native-layout-canceled").trim() ||
"native-layout-canceled";
for (const [jobId, pending] of this._pendingJobs.entries()) {
clearTimeout(pending.timer);
pending.resolve({
ok: false,
skipped: true,
reason: cancelReason,
});
if (this._worker) {
this._worker.postMessage({
type: "cancel-layout",
jobId,
reason: cancelReason,
});
}
}
this._pendingJobs.clear();
}
dispose() {
if (this._isDisposed) return;
this._isDisposed = true;
this.cancelPending("bridge-disposed");
if (this._worker) {
this._worker.terminate();
this._worker = null;
}
}
_ensureWorker() {
if (this._worker) return this._worker;
if (this._isDisposed) return null;
if (typeof Worker !== "function") {
this._workerBootError = "Worker API unavailable";
return null;
}
try {
const worker = new Worker(new URL("./graph-layout-worker.js", import.meta.url), {
type: "module",
});
worker.addEventListener("message", (event) => {
this._handleWorkerMessage(event?.data || {});
});
worker.addEventListener("error", (event) => {
const message =
String(event?.message || "").trim() || "Graph layout worker error";
this._workerBootError = message;
this._resolveAllPending({
ok: false,
skipped: true,
reason: "native-worker-error",
error: message,
});
});
this._worker = worker;
return worker;
} catch (error) {
this._workerBootError = error?.message || String(error);
return null;
}
}
_handleWorkerMessage(data = {}) {
if (!data || data.type !== "layout-result") return;
const jobId = Number(data.jobId);
if (!Number.isFinite(jobId)) return;
const pending = this._pendingJobs.get(jobId);
if (!pending) return;
this._pendingJobs.delete(jobId);
clearTimeout(pending.timer);
const result = data.result || {};
if (result?.positions && !(result.positions instanceof Float32Array)) {
try {
result.positions = Float32Array.from(result.positions);
} catch {
result.positions = null;
}
}
pending.resolve(result);
}
_resolveAllPending(result = {}) {
for (const pending of this._pendingJobs.values()) {
clearTimeout(pending.timer);
pending.resolve(result);
}
this._pendingJobs.clear();
}
}

View File

@@ -12,6 +12,10 @@ import {
aliasSetMatchesValue,
buildUserPovAliasNormalizedSet,
} from '../runtime/user-alias-utils.js';
import {
GraphNativeLayoutBridge,
normalizeGraphNativeRuntimeOptions,
} from './graph-native-bridge.js';
/**
* @typedef {Object} GraphNode
@@ -56,6 +60,39 @@ const ADAPTIVE_NEURAL_LAYOUT_POLICY = Object.freeze({
});
const MIN_USABLE_CANVAS_DIMENSION = 48;
const RUNTIME_DEBUG_STATE_KEY = '__stBmeRuntimeDebugState';
function cloneGraphLayoutDebugValue(value, fallback = null) {
if (value == null) return fallback;
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(value);
} catch {}
}
try {
return JSON.parse(JSON.stringify(value));
} catch {
return fallback;
}
}
function recordGraphLayoutDebugSnapshot(snapshot = null) {
if (!globalThis || typeof globalThis !== 'object') return;
if (!globalThis[RUNTIME_DEBUG_STATE_KEY] || typeof globalThis[RUNTIME_DEBUG_STATE_KEY] !== 'object') {
globalThis[RUNTIME_DEBUG_STATE_KEY] = {
updatedAt: '',
graphLayout: null,
};
}
const state = globalThis[RUNTIME_DEBUG_STATE_KEY];
state.graphLayout = snapshot && typeof snapshot === 'object'
? {
updatedAt: new Date().toISOString(),
...cloneGraphLayoutDebugValue(snapshot, {}),
}
: null;
state.updatedAt = new Date().toISOString();
}
/** 兼容旧版 forceConfig召回卡片等 */
function layoutKeysFromForceConfig(fc) {
@@ -194,6 +231,7 @@ export class GraphRenderer {
const themeName = isLegacy ? options : (options?.theme || 'crimson');
const layoutOverride = isLegacy ? {} : (options?.layoutConfig || {});
const fromForce = isLegacy ? {} : layoutKeysFromForceConfig(options?.forceConfig);
const runtimeConfig = isLegacy ? {} : (options?.runtimeConfig || {});
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
@@ -203,9 +241,13 @@ export class GraphRenderer {
this.colors = getNodeColors(themeName);
this.themeName = themeName;
this.config = { ...DEFAULT_LAYOUT_CONFIG, ...fromForce, ...layoutOverride };
this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
this._userPovAliasSet = buildUserPovAliasNormalizedSet(
isLegacy ? null : options?.userPovAliases,
);
this._nativeLayoutBridge = null;
this._layoutSolveRevision = 0;
this._lastLayoutDiagnostics = null;
this._regionPanels = [];
this._lastGraph = null;
@@ -253,7 +295,10 @@ export class GraphRenderer {
* @param {{ userPovAliases?: string|string[]|object }} [layoutHints]
*/
loadGraph(graph, layoutHints = {}) {
const loadStartedAt = performance.now();
const prevSelectedId = this.selectedNode?.id || null;
const solveRevision = this._nextLayoutSolveRevision();
this._nativeLayoutBridge?.cancelPending?.('graph-load-replaced');
this._lastGraph = graph;
this._lastLayoutHints = layoutHints && typeof layoutHints === 'object'
? { ...layoutHints }
@@ -303,13 +348,39 @@ export class GraphRenderer {
strength: e.strength || 0.5,
relation: e.relation || 'related',
}));
const prepareFinishedAt = performance.now();
const parts = partitionNodesByScope(this.nodes, this._userPovAliasSet);
this._regionPanels = this._computeRegionPanels(W, H, parts);
this._layoutAllPartitions(parts);
const layoutFinishedAt = performance.now();
const neuralPlan = this._resolveNeuralSimulationPlan();
const shouldTryNativeLayout = this._shouldTryNativeLayout(
this.nodes.length,
this.edges.length,
);
let solvePath = neuralPlan.skip ? 'skipped' : 'js-main';
let solveMs = 0;
let nativeSolvePromise = null;
if (!neuralPlan.skip && neuralPlan.iterations > 0) {
this._simulateNeuralWithinRegions(neuralPlan.iterations);
if (shouldTryNativeLayout) {
solvePath = 'native-worker-pending';
nativeSolvePromise = this._simulateNeuralWithNativeBridge(
neuralPlan.iterations,
solveRevision,
{
loadStartedAt,
prepareFinishedAt,
layoutFinishedAt,
},
);
} else {
const solveStartedAt = performance.now();
this._simulateNeuralWithinRegions(neuralPlan.iterations);
solveMs = Math.max(0, performance.now() - solveStartedAt);
}
}
if (prevSelectedId) {
@@ -318,6 +389,35 @@ export class GraphRenderer {
this._cancelAnim();
this._render();
if (!nativeSolvePromise) {
this._setLastLayoutDiagnostics({
mode: solvePath,
nodeCount: this.nodes.length,
edgeCount: this.edges.length,
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
solveMs,
totalMs: Math.max(0, performance.now() - loadStartedAt),
at: Date.now(),
});
return;
}
nativeSolvePromise
.then((result) => {
if (!result) return;
this._setLastLayoutDiagnostics({
...result.diagnostics,
at: Date.now(),
});
if (result.applied && this.enabled) {
this._scheduleRender();
}
})
.catch(() => {
// fail-open 路径由 bridge 内部控制
});
}
/**
@@ -329,6 +429,33 @@ export class GraphRenderer {
if (this.enabled) this._render();
}
setRuntimeConfig(runtimeConfig = {}) {
this.runtimeConfig = normalizeGraphNativeRuntimeOptions(runtimeConfig);
if (this._nativeLayoutBridge) {
this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig);
}
}
getLastLayoutDiagnostics() {
return this._lastLayoutDiagnostics
? { ...this._lastLayoutDiagnostics }
: null;
}
_setLastLayoutDiagnostics(diagnostics = null) {
this._lastLayoutDiagnostics = diagnostics && typeof diagnostics === 'object'
? { ...diagnostics }
: null;
recordGraphLayoutDebugSnapshot(
this._lastLayoutDiagnostics
? {
...this._lastLayoutDiagnostics,
enabled: this.enabled !== false,
}
: null,
);
}
/**
* 高亮指定节点
*/
@@ -351,7 +478,12 @@ export class GraphRenderer {
if (!nextEnabled) this._clearCanvas();
return;
}
this._nextLayoutSolveRevision();
this._nativeLayoutBridge?.cancelPending?.('graph-renderer-state-changed');
this.enabled = nextEnabled;
if (this._lastLayoutDiagnostics) {
this._setLastLayoutDiagnostics(this._lastLayoutDiagnostics);
}
this._cancelAnim();
this.dragNode = null;
this.isDragging = false;
@@ -630,6 +762,190 @@ export class GraphRenderer {
};
}
_nextLayoutSolveRevision() {
this._layoutSolveRevision = Math.max(1, Number(this._layoutSolveRevision || 0) + 1);
return this._layoutSolveRevision;
}
_ensureNativeLayoutBridge() {
if (this._nativeLayoutBridge) {
this._nativeLayoutBridge.updateRuntimeOptions(this.runtimeConfig);
return this._nativeLayoutBridge;
}
this._nativeLayoutBridge = new GraphNativeLayoutBridge(this.runtimeConfig);
return this._nativeLayoutBridge;
}
_shouldTryNativeLayout(nodeCount = 0, edgeCount = 0) {
if (this.runtimeConfig.graphNativeForceDisable) return false;
if (!this.runtimeConfig.graphUseNativeLayout) return false;
const bridge = this._ensureNativeLayoutBridge();
if (!bridge) return false;
return bridge.shouldRunForGraph(nodeCount, edgeCount);
}
_buildNativeLayoutPayload(iterations) {
const nodeIndexById = new Map();
const nodes = this.nodes.map((node, index) => {
nodeIndexById.set(node.id, index);
return {
x: node.x,
y: node.y,
vx: node.vx,
vy: node.vy,
pinned: node.pinned === true,
radius: this._nodeRadius(node),
regionKey: node.regionKey,
regionRect: node.regionRect
? {
x: node.regionRect.x,
y: node.regionRect.y,
w: node.regionRect.w,
h: node.regionRect.h,
}
: null,
};
});
const edges = this.edges
.map((edge) => {
const from = nodeIndexById.get(edge.from?.id);
const to = nodeIndexById.get(edge.to?.id);
if (!Number.isFinite(from) || !Number.isFinite(to) || from === to) {
return null;
}
return {
from,
to,
strength: edge.strength || 0.5,
};
})
.filter(Boolean);
return {
nodes,
edges,
config: {
iterations,
repulsion: this.config.neuralRepulsion ?? 2800,
springK: this.config.neuralSpringK ?? 0.048,
damping: this.config.neuralDamping ?? 0.88,
centerGravity: this.config.neuralCenterGravity ?? 0.014,
minGap: this.config.neuralMinGap ?? 12,
speedCap: 3.8,
},
};
}
_applyLayoutPositions(positions) {
if (!(positions instanceof Float32Array)) return false;
if (positions.length < this.nodes.length * 2) return false;
for (let i = 0; i < this.nodes.length; i++) {
const node = this.nodes[i];
if (!node || node.pinned) continue;
node.x = positions[i * 2];
node.y = positions[i * 2 + 1];
node.vx = 0;
node.vy = 0;
this._clampNodeToRegion(node);
}
return true;
}
async _simulateNeuralWithNativeBridge(iterations, solveRevision, timings = {}) {
const loadStartedAt = Number(timings.loadStartedAt) || performance.now();
const prepareFinishedAt = Number(timings.prepareFinishedAt) || loadStartedAt;
const layoutFinishedAt = Number(timings.layoutFinishedAt) || prepareFinishedAt;
const bridge = this._ensureNativeLayoutBridge();
const solveStartedAt = performance.now();
let nativeResult = null;
try {
nativeResult = await bridge.solveLayout(this._buildNativeLayoutPayload(iterations), {
timeoutMs: this.runtimeConfig.graphNativeLayoutWorkerTimeoutMs,
});
} catch (error) {
nativeResult = {
ok: false,
skipped: true,
reason: 'native-layout-bridge-error',
error: error?.message || String(error),
};
}
if (solveRevision !== this._layoutSolveRevision) {
return {
applied: false,
diagnostics: {
mode: 'native-stale',
nodeCount: this.nodes.length,
edgeCount: this.edges.length,
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
solveMs: Math.max(0, performance.now() - solveStartedAt),
totalMs: Math.max(0, performance.now() - loadStartedAt),
reason: 'stale-layout-result',
},
};
}
if (nativeResult?.ok && this._applyLayoutPositions(nativeResult.positions)) {
const workerElapsedMs = Number(nativeResult?.diagnostics?.elapsedMs);
return {
applied: true,
diagnostics: {
mode: nativeResult.usedNative ? 'rust-wasm-worker' : 'js-worker',
nodeCount: this.nodes.length,
edgeCount: this.edges.length,
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
solveMs: Math.max(0, performance.now() - solveStartedAt),
workerSolveMs: Number.isFinite(workerElapsedMs)
? Math.max(0, workerElapsedMs)
: 0,
totalMs: Math.max(0, performance.now() - loadStartedAt),
reason: '',
},
};
}
if (!this.runtimeConfig.nativeEngineFailOpen) {
return {
applied: false,
diagnostics: {
mode: 'native-failed-hard',
nodeCount: this.nodes.length,
edgeCount: this.edges.length,
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
solveMs: Math.max(0, performance.now() - solveStartedAt),
totalMs: Math.max(0, performance.now() - loadStartedAt),
reason: nativeResult?.reason || 'native-layout-failed',
},
};
}
const fallbackStartedAt = performance.now();
this._simulateNeuralWithinRegions(iterations);
const fallbackSolveMs = Math.max(0, performance.now() - fallbackStartedAt);
return {
applied: true,
diagnostics: {
mode: 'js-fallback',
nodeCount: this.nodes.length,
edgeCount: this.edges.length,
prepareMs: Math.max(0, prepareFinishedAt - loadStartedAt),
layoutSeedMs: Math.max(0, layoutFinishedAt - prepareFinishedAt),
solveMs: Math.max(0, performance.now() - solveStartedAt) + fallbackSolveMs,
fallbackSolveMs,
totalMs: Math.max(0, performance.now() - loadStartedAt),
reason: nativeResult?.reason || 'native-layout-failed',
},
};
}
/**
* 分区内一次性力导向:斥力 + 同区边弹簧 + 弱向心,稳定后停止(无帧循环)
*/
@@ -1171,6 +1487,8 @@ export class GraphRenderer {
}
if (this.nodes.length > 0 && this._regionPanels.length > 0) {
this._nextLayoutSolveRevision();
this._nativeLayoutBridge?.cancelPending?.('viewport-resize-layout-reset');
this._rebuildLayoutForCurrentViewport(w, h);
this._render();
} else if (this._lastGraph) {
@@ -1181,7 +1499,22 @@ export class GraphRenderer {
}
destroy() {
this._nextLayoutSolveRevision();
this._cancelAnim();
this._nativeLayoutBridge?.dispose?.();
this._nativeLayoutBridge = null;
recordGraphLayoutDebugSnapshot(
this._lastLayoutDiagnostics
? {
...this._lastLayoutDiagnostics,
enabled: false,
destroyed: true,
}
: {
enabled: false,
destroyed: true,
},
);
this._resizeObserver?.disconnect();
}
}

View File

@@ -534,6 +534,7 @@
<div class="bme-graph-statusbar">
<span><span class="bme-status-dot"></span><span id="bme-mobile-status-text">READY</span></span>
<span id="bme-mobile-status-meta">NODES: 0 | EDGES: 0</span>
<span id="bme-mobile-graph-layout-meta" class="bme-graph-layout-meta">LAYOUT: --</span>
</div>
</div>
@@ -643,6 +644,7 @@
><span id="bme-status-text">READY</span></span
>
<span id="bme-status-meta">NODES: 0 | EDGES: 0</span>
<span id="bme-graph-layout-meta" class="bme-graph-layout-meta">LAYOUT: --</span>
</div>

View File

@@ -737,6 +737,36 @@ function _isGraphRenderingEnabled() {
return graphRenderingEnabled !== false;
}
function _buildGraphRuntimeConfig(settings = _getSettings?.() || {}) {
return {
graphUseNativeLayout: settings.graphUseNativeLayout === true,
graphNativeLayoutThresholdNodes: Number.isFinite(
Number(settings.graphNativeLayoutThresholdNodes),
)
? Math.max(1, Math.floor(Number(settings.graphNativeLayoutThresholdNodes)))
: 280,
graphNativeLayoutThresholdEdges: Number.isFinite(
Number(settings.graphNativeLayoutThresholdEdges),
)
? Math.max(1, Math.floor(Number(settings.graphNativeLayoutThresholdEdges)))
: 1600,
graphNativeLayoutWorkerTimeoutMs: Number.isFinite(
Number(settings.graphNativeLayoutWorkerTimeoutMs),
)
? Math.max(40, Math.floor(Number(settings.graphNativeLayoutWorkerTimeoutMs)))
: 260,
nativeEngineFailOpen: settings.nativeEngineFailOpen !== false,
graphNativeForceDisable: settings.graphNativeForceDisable === true,
};
}
function _applyGraphRuntimeConfig(settings = _getSettings?.() || {}) {
const runtimeConfig = _buildGraphRuntimeConfig(settings);
graphRenderer?.setRuntimeConfig?.(runtimeConfig);
mobileGraphRenderer?.setRuntimeConfig?.(runtimeConfig);
return runtimeConfig;
}
function _refreshGraphRenderToggleUi() {
const enabled = _isGraphRenderingEnabled();
const syncButton = (button) => {
@@ -779,6 +809,7 @@ function _toggleGraphRenderingEnabled() {
function _refreshVisibleGraphWorkspace({ force = false } = {}) {
const visibleMode = _getVisibleGraphWorkspaceMode();
if (visibleMode === "hidden") {
_refreshGraphLayoutDiagnosticsUi();
return { refreshed: false, reason: "hidden" };
}
@@ -808,6 +839,8 @@ function _refreshVisibleGraphWorkspace({ force = false } = {}) {
_refreshMobileSummaryFull();
}
_refreshGraphLayoutDiagnosticsUi();
lastVisibleGraphRefreshToken = nextToken;
lastVisibleGraphRefreshAt = Date.now();
return {
@@ -1159,6 +1192,7 @@ export function openPanel() {
const graphOpts = {
theme: themeName,
userPovAliases: _hostUserPovAliasHintsForGraph(),
runtimeConfig: _buildGraphRuntimeConfig(settings),
};
const canvas = document.getElementById("bme-graph-canvas");
if (canvas && !graphRenderer && !isMobile) {
@@ -1172,6 +1206,8 @@ export function openPanel() {
mobileGraphRenderer.onNodeSelect = (node) => _showNodeDetail(node);
}
_applyGraphRuntimeConfig(settings);
_applyGraphRenderEnabledState();
const activeTabId =
@@ -1204,6 +1240,7 @@ export function updatePanelTheme(themeName) {
export function refreshLiveState() {
if (!overlayEl?.classList.contains("active")) return;
_applyGraphRuntimeConfig(_getSettings?.() || {});
_refreshRuntimeStatus();
switch (currentTabId) {
@@ -3982,6 +4019,89 @@ function _getActiveGraphRenderer() {
return mobileGraphRenderer || graphRenderer;
}
function _resolveVisibleGraphRenderer() {
const visibleMode = _getVisibleGraphWorkspaceMode();
if (visibleMode.startsWith("mobile:")) {
return mobileGraphRenderer || graphRenderer;
}
if (visibleMode.startsWith("desktop:")) {
return graphRenderer || mobileGraphRenderer;
}
return _getActiveGraphRenderer();
}
function _formatGraphLayoutDiagnosticsText(diagnostics = null) {
if (!diagnostics || typeof diagnostics !== "object") {
return "LAYOUT: --";
}
const modeRaw = String(
diagnostics.mode || diagnostics.solver || "",
).trim();
const modeMap = {
"js-main": "JS-main",
"js-worker": "JS-worker",
"rust-wasm-worker": "Rust-WASM",
"js-fallback": "JS-fallback",
skipped: "skipped",
"native-stale": "stale",
"native-failed-hard": "native-failed",
};
const modeLabel = modeMap[modeRaw] || modeRaw || "unknown";
const totalMs = Number(
diagnostics.totalMs ?? diagnostics.solveMs ?? diagnostics.workerSolveMs,
);
const nodeCount = Number(diagnostics.nodeCount);
const edgeCount = Number(diagnostics.edgeCount);
const parts = [`LAYOUT: ${modeLabel}`];
if (Number.isFinite(totalMs)) {
parts.push(`${Math.max(0, Math.round(totalMs))}ms`);
}
if (Number.isFinite(nodeCount) && Number.isFinite(edgeCount)) {
parts.push(
`${Math.max(0, Math.floor(nodeCount))}/${Math.max(
0,
Math.floor(edgeCount),
)}`,
);
}
return parts.join(" · ");
}
function _refreshGraphLayoutDiagnosticsUi() {
const desktopMeta = document.getElementById("bme-graph-layout-meta");
const mobileMeta = document.getElementById("bme-mobile-graph-layout-meta");
if (!desktopMeta && !mobileMeta) return;
const renderer = _resolveVisibleGraphRenderer();
const diagnostics = renderer?.getLastLayoutDiagnostics?.() || null;
const text = _formatGraphLayoutDiagnosticsText(diagnostics);
const title = diagnostics?.reason
? `layout reason: ${String(diagnostics.reason).trim()}`
: "";
if (desktopMeta) {
desktopMeta.textContent = text;
if (title) {
desktopMeta.title = title;
} else {
desktopMeta.removeAttribute("title");
}
}
if (mobileMeta) {
mobileMeta.textContent = text;
if (title) {
mobileMeta.title = title;
} else {
mobileMeta.removeAttribute("title");
}
}
}
function _bindGraphControls() {
document
.getElementById("bme-graph-render-toggle")
@@ -7290,6 +7410,8 @@ function _getMessageTraceWorkspaceState(settings = _getSettings?.() || {}) {
panelDebug,
runtimeDebug,
recallInjection: runtimeDebug?.injections?.recall || null,
graphLayout: runtimeDebug?.graphLayout || null,
persistDelta: runtimeDebug?.graphPersistence?.persistDelta || null,
messageTrace: runtimeDebug?.messageTrace || null,
recallLlmRequest: runtimeDebug?.taskLlmRequests?.recall || null,
recallPromptBuild: runtimeDebug?.taskPromptBuilds?.recall || null,
@@ -7313,6 +7435,8 @@ function _refreshMessageTraceWorkspace(settings = _getSettings?.() || {}) {
function _renderMessageTraceWorkspace(state) {
const updatedCandidates = [
state.recallInjection?.updatedAt,
state.graphLayout?.updatedAt,
state.persistDelta?.updatedAt,
state.recallLlmRequest?.updatedAt,
state.extractLlmRequest?.updatedAt,
state.extractPromptBuild?.updatedAt,
@@ -7345,6 +7469,12 @@ function _renderMessageTraceWorkspace(state) {
<div class="bme-config-card">
${_renderAiMonitorCognitionCard(state)}
</div>
<div class="bme-config-card">
${_renderGraphLayoutTraceCard(state)}
</div>
<div class="bme-config-card">
${_renderPersistDeltaTraceCard(state)}
</div>
</div>
</div>
`;
@@ -7808,6 +7938,180 @@ function _renderAiMonitorCognitionCard(state) {
`;
}
function _renderGraphLayoutTraceCard(state) {
const layout = state.graphLayout || null;
if (!layout) {
return `
<div class="bme-config-card-title">图布局 / Native 诊断</div>
<div class="bme-config-help">
还没有图布局诊断快照。打开图谱页并触发一次布局后,这里会显示实际执行路径、耗时和 native 模块来源。
</div>
`;
}
const mode = String(layout.mode || layout.solver || 'unknown').trim() || 'unknown';
const moduleSource = String(layout.moduleSource || '').trim() || '—';
const reason = String(layout.reason || '').trim() || '—';
const nativeLoadError = String(layout.nativeLoadError || '').trim();
return `
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">图布局 / Native 诊断</div>
<div class="bme-config-card-subtitle">
记录最近一次图布局走了哪条路径,以及 native 模块是 wasm-pack 产物还是 fallback loader。
</div>
</div>
<span class="bme-task-pill">${_escHtml(_formatTaskProfileTime(layout.updatedAt || layout.at))}</span>
</div>
<div class="bme-ai-monitor-kv">
<div class="bme-ai-monitor-kv__row">
<span>布局路径</span>
<strong>${_escHtml(mode)}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>节点 / 边</span>
<strong>${_escHtml(`${Number(layout.nodeCount || 0)} / ${Number(layout.edgeCount || 0)}`)}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>总耗时</span>
<strong>${_escHtml(_formatDurationMs(layout.totalMs))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>求解耗时</span>
<strong>${_escHtml(_formatDurationMs(layout.solveMs || layout.workerSolveMs))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>迭代次数</span>
<strong>${_escHtml(String(layout.iterations || '—'))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>Native 来源</span>
<strong>${_escHtml(moduleSource)}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>状态原因</span>
<strong>${_escHtml(reason)}</strong>
</div>
</div>
${_renderMessageTraceTextBlock(
'Native load error',
nativeLoadError,
'当前没有 native load error。',
)}
`;
}
function _formatPersistDeltaGateReasonText(reasons = []) {
const labels = {
"below-record-threshold": "记录数不足",
"below-structural-delta-threshold": "结构变化不足",
"below-serialized-chars-threshold": "序列化体积不足",
};
const normalized = Array.isArray(reasons)
? reasons
.map((item) => String(item || "").trim())
.filter(Boolean)
: [];
if (!normalized.length) return "—";
return normalized.map((item) => labels[item] || item).join(" · ");
}
function _formatPersistDeltaGateText(diagnostics = null) {
if (!diagnostics || typeof diagnostics !== "object") return "—";
if (diagnostics.requestedNative !== true) return "未请求 native";
if (diagnostics.nativeForceDisabled === true) return "已强制关闭";
if (diagnostics.gateAllowed === true) return "通过";
return `已拦截 · ${_formatPersistDeltaGateReasonText(diagnostics.gateReasons)}`;
}
function _renderPersistDeltaTraceCard(state) {
const diagnostics = state.persistDelta || null;
if (!diagnostics) {
return `
<div class="bme-config-card-title">Persist Delta / Native 诊断</div>
<div class="bme-config-help">
还没有 persist delta 诊断快照。等图谱完成一次 IndexedDB 写回后,这里会显示 gate、执行路径、耗时和 fallback 原因。
</div>
`;
}
const moduleSource = String(diagnostics.moduleSource || "").trim() || "—";
const fallbackReason = String(diagnostics.fallbackReason || "").trim();
const errorText = String(
diagnostics.moduleError || diagnostics.preloadError || diagnostics.nativeError || "",
).trim();
const payloadCharsText = diagnostics.combinedSerializedChars
? `${Number(diagnostics.combinedSerializedChars || 0)} / ${Number(diagnostics.minCombinedSerializedChars || 0)}`
: "—";
return `
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">Persist Delta / Native 诊断</div>
<div class="bme-config-card-subtitle">
记录最近一次图谱增量写回的 gate 判定、真实执行路径,以及 native preload / fallback 情况。
</div>
</div>
<span class="bme-task-pill">${_escHtml(_formatTaskProfileTime(diagnostics.updatedAt))}</span>
</div>
<div class="bme-ai-monitor-kv">
<div class="bme-ai-monitor-kv__row">
<span>执行路径</span>
<strong>${_escHtml(String(diagnostics.path || "—"))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>Native Gate</span>
<strong>${_escHtml(_formatPersistDeltaGateText(diagnostics))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>快照记录数</span>
<strong>${_escHtml(`${Number(diagnostics.beforeRecordCount || 0)}${Number(diagnostics.afterRecordCount || 0)}`)}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>结构变化量</span>
<strong>${_escHtml(String(diagnostics.structuralDelta ?? "—"))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>Payload chars</span>
<strong>${_escHtml(payloadCharsText)}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>总耗时</span>
<strong>${_escHtml(_formatDurationMs(diagnostics.totalMs || diagnostics.buildMs))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>构建耗时</span>
<strong>${_escHtml(_formatDurationMs(diagnostics.buildMs))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>Preload</span>
<strong>${_escHtml(String(diagnostics.preloadStatus || "—"))}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>Native 来源</span>
<strong>${_escHtml(moduleSource)}</strong>
</div>
<div class="bme-ai-monitor-kv__row">
<span>增量规模</span>
<strong>${_escHtml(
`${Number(diagnostics.upsertNodeCount || 0)}N / ${Number(diagnostics.upsertEdgeCount || 0)}E / ${Number(diagnostics.deleteNodeCount || 0)}DN / ${Number(diagnostics.deleteEdgeCount || 0)}DE`,
)}</strong>
</div>
</div>
${_renderMessageTraceTextBlock(
"Fallback reason",
fallbackReason,
"这次没有发生 native fallback。",
)}
${_renderMessageTraceTextBlock(
"Preload / native error",
errorText,
"当前没有 preload / native error。",
)}
`;
}
function _renderMessageTraceTextBlock(title, text, emptyText = "暂无内容") {
const normalized = String(text || "").trim();
return `
@@ -9016,6 +9320,8 @@ function _renderTaskDebugGraphPersistenceCard(graphPersistence) {
`;
}
const persistDelta = graphPersistence.persistDelta || null;
return `
<div class="bme-config-card-head">
<div>
@@ -9085,6 +9391,22 @@ function _renderTaskDebugGraphPersistenceCard(graphPersistence) {
: "—",
)}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">Persist Delta 路径</span>
<span class="bme-debug-kv-value">${_escHtml(String(persistDelta?.path || "—"))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">Persist Native Gate</span>
<span class="bme-debug-kv-value">${_escHtml(_formatPersistDeltaGateText(persistDelta))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">Persist Delta 耗时</span>
<span class="bme-debug-kv-value">${_escHtml(_formatDurationMs(persistDelta?.totalMs || persistDelta?.buildMs))}</span>
</div>
<div class="bme-debug-kv-item">
<span class="bme-debug-kv-key">Persist Native 来源</span>
<span class="bme-debug-kv-value">${_escHtml(String(persistDelta?.moduleSource || "—"))}</span>
</div>
</div>
${_renderDebugDetails("图谱持久化详情", graphPersistence)}
`;
@@ -10887,6 +11209,7 @@ function _getGraphPersistenceSnapshot() {
lastBackupRollbackAt: 0,
lastBackupFilename: "",
lastSyncError: "",
persistDelta: null,
};
}
@@ -11182,6 +11505,8 @@ function _refreshGraphAvailabilityState() {
if (mobileOverlayText) {
mobileOverlayText.textContent = overlayLabel;
}
_refreshGraphLayoutDiagnosticsUi();
}
function _formatCloudTimeLabel(timestamp) {

View File

@@ -78,6 +78,7 @@ export function createGraphPersistenceState() {
syncDirtyReason: "",
lastSyncError: "",
dualWriteLastResult: null,
persistDelta: null,
updatedAt: new Date().toISOString(),
};
}