Harden graph persistence writes

This commit is contained in:
Youzini-afk
2026-03-29 10:53:44 +08:00
parent 4e26849c6c
commit 562c226b1d
2 changed files with 445 additions and 43 deletions

331
index.js
View File

@@ -7,6 +7,7 @@ import {
extension_prompt_types,
extension_prompt_roles,
getRequestHeaders,
saveMetadata,
saveSettingsDebounced,
} from "../../../../script.js";
import {
@@ -82,6 +83,7 @@ let _themesModule = null;
const MODULE_NAME = "st_bme";
const GRAPH_METADATA_KEY = "st_bme_graph";
const GRAPH_PERSISTENCE_META_KEY = "__stBmePersistence";
const SERVER_SETTINGS_FILENAME = "st-bme-settings.json";
const SERVER_SETTINGS_URL = `/user/files/${SERVER_SETTINGS_FILENAME}`;
const GRAPH_LOAD_STATES = Object.freeze({
@@ -108,6 +110,120 @@ function cloneRuntimeDebugValue(value, fallback = null) {
}
}
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);
});
}
const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug();
function getGraphPersistenceMeta(graph = currentGraph) {
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;
}
function getGraphPersistedRevision(graph = currentGraph) {
const revision = Number(getGraphPersistenceMeta(graph)?.revision);
return Number.isFinite(revision) && revision > 0 ? revision : 0;
}
function stampGraphPersistenceMeta(
graph = currentGraph,
{
revision = graphPersistenceState.revision,
reason = "",
chatId = getCurrentChatId(),
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;
}
function getChatMetadataIntegrity(context = getContext()) {
return normalizeChatIdCandidate(context?.chatMetadata?.integrity);
}
function writeChatMetadataPatch(context = getContext(), 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;
}
function triggerChatMetadataSave(
context = getContext(),
{ immediate = false } = {},
) {
if (immediate) {
const immediateSave =
typeof context?.saveMetadata === "function" ? context.saveMetadata : saveMetadata;
if (typeof immediateSave === "function") {
try {
const result = immediateSave.call(context);
if (result && typeof result.catch === "function") {
result.catch((error) => {
console.error("[ST-BME] 立即保存聊天元数据失败:", error);
});
}
return "immediate";
} catch (error) {
console.error("[ST-BME] 触发立即保存聊天元数据失败:", error);
}
}
}
if (typeof context?.saveMetadataDebounced === "function") {
context.saveMetadataDebounced();
return "debounced";
}
saveMetadataDebounced();
return "debounced";
}
function shouldPreferShadowSnapshotOverOfficial(officialGraph, shadowSnapshot) {
if (!shadowSnapshot) return false;
const shadowRevision = Number(shadowSnapshot.revision || 0);
const officialRevision = getGraphPersistedRevision(officialGraph);
return shadowRevision > 0 && shadowRevision > officialRevision;
}
function getRuntimeDebugState() {
const stateKey = "__stBmeRuntimeDebugState";
if (
@@ -360,11 +476,16 @@ function createGraphPersistenceState() {
revision: 0,
lastPersistedRevision: 0,
queuedPersistRevision: 0,
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
shadowSnapshotUsed: false,
shadowSnapshotRevision: 0,
shadowSnapshotUpdatedAt: "",
shadowSnapshotReason: "",
lastPersistReason: "",
lastPersistMode: "",
metadataIntegrity: "",
writesBlocked: false,
pendingPersist: false,
updatedAt: new Date().toISOString(),
@@ -461,8 +582,13 @@ function getGraphPersistenceLiveState() {
shadowSnapshotUpdatedAt: graphPersistenceState.shadowSnapshotUpdatedAt,
shadowSnapshotReason: graphPersistenceState.shadowSnapshotReason,
lastPersistReason: graphPersistenceState.lastPersistReason,
lastPersistMode: graphPersistenceState.lastPersistMode,
metadataIntegrity: graphPersistenceState.metadataIntegrity,
writesBlocked: graphPersistenceState.writesBlocked,
pendingPersist: graphPersistenceState.pendingPersist,
queuedPersistMode: graphPersistenceState.queuedPersistMode,
queuedPersistRotateIntegrity: graphPersistenceState.queuedPersistRotateIntegrity,
queuedPersistReason: graphPersistenceState.queuedPersistReason,
canWriteToMetadata: isGraphMetadataWriteAllowed(
graphPersistenceState.loadState,
),
@@ -1437,6 +1563,7 @@ function buildGraphPersistResult({
reason = "",
loadState = graphPersistenceState.loadState,
revision = graphPersistenceState.revision,
saveMode = graphPersistenceState.lastPersistMode,
} = {}) {
return {
saved,
@@ -1445,6 +1572,7 @@ function buildGraphPersistResult({
reason: String(reason || ""),
loadState,
revision: Number.isFinite(revision) ? revision : 0,
saveMode: String(saveMode || ""),
};
}
@@ -1464,7 +1592,12 @@ function maybeCaptureGraphShadowSnapshot(reason = "runtime-shadow") {
function persistGraphToChatMetadata(
context = getContext(),
{ reason = "graph-persist", revision = graphPersistenceState.revision } = {},
{
reason = "graph-persist",
revision = graphPersistenceState.revision,
immediate = false,
rotateIntegrity = false,
} = {},
) {
if (!context || !currentGraph) {
return buildGraphPersistResult({
@@ -1485,41 +1618,43 @@ function persistGraphToChatMetadata(
});
}
if (typeof context.updateChatMetadata === "function") {
context.updateChatMetadata({ [GRAPH_METADATA_KEY]: currentGraph });
} else {
if (
!context.chatMetadata ||
typeof context.chatMetadata !== "object" ||
Array.isArray(context.chatMetadata)
) {
context.chatMetadata = {};
}
context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph;
}
if (typeof context.saveMetadataDebounced === "function") {
context.saveMetadataDebounced();
} else {
saveMetadataDebounced();
}
const nextIntegrity = rotateIntegrity
? createLocalIntegritySlug()
: getChatMetadataIntegrity(context);
stampGraphPersistenceMeta(currentGraph, {
revision,
reason,
chatId,
integrity: nextIntegrity,
});
writeChatMetadataPatch(context, {
[GRAPH_METADATA_KEY]: currentGraph,
...(nextIntegrity ? { integrity: nextIntegrity } : {}),
});
const saveMode = triggerChatMetadataSave(context, { immediate });
applyGraphLoadState(graphPersistenceState.loadState, {
chatId,
reason: graphPersistenceState.reason,
attemptIndex: graphPersistenceState.attemptIndex,
shadowSnapshotUsed: graphPersistenceState.shadowSnapshotUsed,
shadowSnapshotRevision: graphPersistenceState.shadowSnapshotRevision,
shadowSnapshotUpdatedAt: graphPersistenceState.shadowSnapshotUpdatedAt,
shadowSnapshotReason: graphPersistenceState.shadowSnapshotReason,
shadowSnapshotUsed: false,
shadowSnapshotRevision: 0,
shadowSnapshotUpdatedAt: "",
shadowSnapshotReason: "",
revision,
lastPersistedRevision: revision,
queuedPersistRevision: 0,
pendingPersist: false,
writesBlocked: false,
});
removeGraphShadowSnapshot(chatId);
updateGraphPersistenceState({
lastPersistReason: String(reason || ""),
lastPersistMode: saveMode,
metadataIntegrity: String(nextIntegrity || ""),
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
});
return buildGraphPersistResult({
@@ -1527,12 +1662,14 @@ function persistGraphToChatMetadata(
reason,
loadState: graphPersistenceState.loadState,
revision,
saveMode,
});
}
function queueGraphPersist(
reason = "graph-persist-blocked",
revision = graphPersistenceState.revision,
{ immediate = true, rotateIntegrity = true } = {},
) {
maybeCaptureGraphShadowSnapshot(reason);
updateGraphPersistenceState({
@@ -1540,6 +1677,9 @@ function queueGraphPersist(
graphPersistenceState.queuedPersistRevision || 0,
revision || 0,
),
queuedPersistMode: immediate ? "immediate" : "debounced",
queuedPersistRotateIntegrity: Boolean(rotateIntegrity),
queuedPersistReason: String(reason || ""),
pendingPersist: true,
writesBlocked: true,
lastPersistReason: String(reason || ""),
@@ -1551,6 +1691,7 @@ function queueGraphPersist(
reason,
loadState: graphPersistenceState.loadState,
revision,
saveMode: immediate ? "immediate" : "debounced",
});
}
@@ -1589,6 +1730,8 @@ function maybeFlushQueuedGraphPersist(reason = "queued-graph-persist") {
return persistGraphToChatMetadata(getContext(), {
reason,
revision: targetRevision,
immediate: graphPersistenceState.queuedPersistMode !== "debounced",
rotateIntegrity: graphPersistenceState.queuedPersistRotateIntegrity,
});
}
@@ -2479,15 +2622,86 @@ function loadGraphFromChat(options = {}) {
? context.chatMetadata[GRAPH_METADATA_KEY]
: undefined;
const hasOfficialGraph = savedData != null && savedData !== "";
const shadowSnapshot = hasOfficialGraph ? null : readGraphShadowSnapshot(chatId);
const shadowSnapshot = readGraphShadowSnapshot(chatId);
const shouldRetry = attemptIndex < GRAPH_LOAD_RETRY_DELAYS_MS.length;
if (hasOfficialGraph) {
clearPendingGraphLoadRetry();
currentGraph = normalizeGraphRuntimeState(
const officialGraph = normalizeGraphRuntimeState(
deserializeGraph(savedData),
chatId,
);
const officialRevision = Math.max(1, getGraphPersistedRevision(officialGraph));
const metadataIntegrity = getChatMetadataIntegrity(context);
if (shouldPreferShadowSnapshotOverOfficial(officialGraph, shadowSnapshot)) {
clearPendingGraphLoadRetry();
currentGraph = normalizeGraphRuntimeState(
deserializeGraph(shadowSnapshot.serializedGraph),
chatId,
);
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
? currentGraph.historyState.extractionCount
: 0;
lastExtractedItems = [];
updateLastRecalledItems(currentGraph.lastRecallResult || []);
lastInjectionContent = "";
runtimeStatus = createUiStatus(
"待命",
"已恢复本次会话里较新的图谱,正在补写正式聊天元数据",
"warning",
);
lastExtractionStatus = createUiStatus(
"待命",
"检测到本次会话里有更新的图谱快照,正在补写正式元数据",
"warning",
);
lastVectorStatus = createUiStatus(
"待命",
"检测到本次会话里有更新的图谱快照,正在补写正式元数据",
"warning",
);
lastRecallStatus = createUiStatus(
"待命",
"检测到本次会话里有更新的图谱快照,正在补写正式元数据",
"warning",
);
applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, {
chatId,
reason: "shadow-snapshot-newer-than-official",
attemptIndex,
revision: Math.max(shadowSnapshot.revision || 0, 1),
lastPersistedRevision: officialRevision,
queuedPersistRevision: Math.max(shadowSnapshot.revision || 0, 1),
pendingPersist: true,
shadowSnapshotUsed: true,
shadowSnapshotRevision: Math.max(shadowSnapshot.revision || 0, 1),
shadowSnapshotUpdatedAt: shadowSnapshot.updatedAt,
shadowSnapshotReason: shadowSnapshot.reason,
writesBlocked: false,
});
updateGraphPersistenceState({
metadataIntegrity,
queuedPersistMode: "immediate",
queuedPersistRotateIntegrity: true,
queuedPersistReason: "shadow-snapshot-newer-than-official",
});
const persistResult = maybeFlushQueuedGraphPersist(
"shadow-snapshot-newer-than-official",
);
refreshPanelLiveState();
return {
success: Boolean(persistResult.saved),
loaded: true,
loadState: GRAPH_LOAD_STATES.LOADED,
reason: "shadow-snapshot-newer-than-official",
chatId,
attemptIndex,
shadowSnapshotUsed: true,
};
}
clearPendingGraphLoadRetry();
currentGraph = officialGraph;
extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount)
? currentGraph.historyState.extractionCount
: 0;
@@ -2515,12 +2729,6 @@ function loadGraphFromChat(options = {}) {
"已加载聊天图谱,等待下一次召回",
"idle",
);
const officialRevision = Math.max(
1,
shadowSnapshot?.revision || 0,
graphPersistenceState.lastPersistedRevision || 0,
graphPersistenceState.revision || 0,
);
applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, {
chatId,
reason: source,
@@ -2535,6 +2743,13 @@ function loadGraphFromChat(options = {}) {
shadowSnapshotReason: "",
writesBlocked: false,
});
updateGraphPersistenceState({
metadataIntegrity,
lastPersistMode: "",
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
});
removeGraphShadowSnapshot(chatId);
console.log("[ST-BME] 从聊天数据加载图谱:", {
@@ -2598,6 +2813,12 @@ function loadGraphFromChat(options = {}) {
shadowSnapshotReason: shadowSnapshot.reason,
writesBlocked: false,
});
updateGraphPersistenceState({
metadataIntegrity: getChatMetadataIntegrity(context),
queuedPersistMode: "immediate",
queuedPersistRotateIntegrity: true,
queuedPersistReason: "shadow-snapshot-promoted",
});
const persistResult = maybeFlushQueuedGraphPersist(
"shadow-snapshot-promoted",
);
@@ -2632,6 +2853,13 @@ function loadGraphFromChat(options = {}) {
shadowSnapshotReason: shadowSnapshot.reason,
writesBlocked: true,
});
updateGraphPersistenceState({
queuedPersistMode: "immediate",
queuedPersistRotateIntegrity: true,
queuedPersistReason: shouldRetry
? "shadow-snapshot-restored"
: "shadow-snapshot-blocked",
});
if (shouldRetry) {
scheduleGraphLoadRetry(
chatId,
@@ -2764,6 +2992,12 @@ function loadGraphFromChat(options = {}) {
shadowSnapshotReason: "",
writesBlocked: false,
});
updateGraphPersistenceState({
metadataIntegrity: getChatMetadataIntegrity(context),
queuedPersistMode: "",
queuedPersistRotateIntegrity: false,
queuedPersistReason: "",
});
removeGraphShadowSnapshot(chatId);
refreshPanelLiveState();
return {
@@ -2791,6 +3025,8 @@ function saveGraphToChat(options = {}) {
reason = "graph-save",
markMutation = true,
captureShadow = true,
immediate = markMutation,
rotateIntegrity = markMutation,
} = options;
ensureCurrentGraphRuntimeState();
@@ -2811,16 +3047,35 @@ function saveGraphToChat(options = {}) {
maybeCaptureGraphShadowSnapshot(reason);
}
if (!markMutation) {
const hasMeaningfulGraphData = !isGraphEffectivelyEmpty(currentGraph);
if (
!hasMeaningfulGraphData ||
graphPersistenceState.loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED
) {
return buildGraphPersistResult({
saved: false,
blocked: false,
reason: hasMeaningfulGraphData
? "passive-empty-confirmed-skipped"
: "passive-empty-graph-skipped",
revision,
});
}
}
if (!isGraphMetadataWriteAllowed()) {
console.warn(
`[ST-BME] 图谱写回已被安全保护拦截chat=${chatId}state=${graphPersistenceState.loadState}reason=${reason}`,
);
return queueGraphPersist(reason, revision);
return queueGraphPersist(reason, revision, { immediate, rotateIntegrity });
}
return persistGraphToChatMetadata(context, {
reason,
revision,
immediate,
rotateIntegrity,
});
}
@@ -5160,14 +5415,10 @@ async function onBeforeCombinePrompts() {
function onMessageReceived() {
// 新消息到达,图状态可能需要更新
if (currentGraph) {
if (isGraphMetadataWriteAllowed()) {
saveGraphToChat({
reason: "message-received-passive-sync",
markMutation: false,
});
} else {
maybeCaptureGraphShadowSnapshot("message-received-passive-sync");
if (graphPersistenceState.pendingPersist && isGraphMetadataWriteAllowed()) {
maybeFlushQueuedGraphPersist("message-received-pending-flush");
}
maybeCaptureGraphShadowSnapshot("message-received-passive-sync");
}
if (

View File

@@ -87,6 +87,26 @@ function createMeaningfulGraph(chatId = "chat-test", suffix = "base") {
return normalizeGraphRuntimeState(graph, chatId);
}
function stampPersistedGraph(
graph,
{
revision = 1,
integrity = "",
chatId = graph?.historyState?.chatId || "",
reason = "test",
} = {},
) {
graph.__stBmePersistence = {
revision,
integrity,
chatId,
reason,
updatedAt: new Date().toISOString(),
sessionId: "test-session",
};
return graph;
}
async function createGraphPersistenceHarness({
chatId = "chat-test",
chatMetadata = undefined,
@@ -168,10 +188,14 @@ async function createGraphPersistenceHarness({
getContext() {
return runtimeContext.__chatContext;
},
async saveMetadata() {
runtimeContext.__globalImmediateSaveCalls += 1;
},
saveMetadataDebounced() {
runtimeContext.__globalSaveCalls += 1;
},
__globalSaveCalls: 0,
__globalImmediateSaveCalls: 0,
isAssistantChatMessage() {
return false;
},
@@ -201,8 +225,12 @@ async function createGraphPersistenceHarness({
saveMetadataDebounced() {
runtimeContext.__contextSaveCalls += 1;
},
async saveMetadata() {
runtimeContext.__contextImmediateSaveCalls += 1;
},
},
__contextSaveCalls: 0,
__contextImmediateSaveCalls: 0,
};
runtimeContext.globalThis = runtimeContext;
@@ -455,7 +483,9 @@ result = {
reason: "loading-empty-save",
markMutation: false,
});
assert.equal(result.blocked, true);
assert.equal(result.blocked, false);
assert.equal(result.queued, false);
assert.equal(result.reason, "passive-empty-graph-skipped");
assert.equal(
harness.api.readGraphShadowSnapshot("chat-empty"),
null,
@@ -536,10 +566,14 @@ result = {
{ revision: 3, reason: "stale-shadow" },
);
const officialGraph = createMeaningfulGraph("chat-official", "official");
const officialGraph = stampPersistedGraph(
createMeaningfulGraph("chat-official", "official"),
{ revision: 6, integrity: "official-integrity" },
);
const reader = await createGraphPersistenceHarness({
chatId: "chat-official",
chatMetadata: {
integrity: "official-integrity",
st_bme_graph: officialGraph,
},
sessionStore: sharedSession,
@@ -561,6 +595,55 @@ result = {
);
}
{
const sharedSession = new Map();
const writer = await createGraphPersistenceHarness({
chatId: "chat-shadow-newer",
chatMetadata: undefined,
sessionStore: sharedSession,
});
writer.api.writeGraphShadowSnapshot(
"chat-shadow-newer",
createMeaningfulGraph("chat-shadow-newer", "shadow-newer"),
{ revision: 9, reason: "pagehide-refresh" },
);
const officialGraph = stampPersistedGraph(
createMeaningfulGraph("chat-shadow-newer", "official-older"),
{ revision: 3, integrity: "integrity-official-older" },
);
const reader = await createGraphPersistenceHarness({
chatId: "chat-shadow-newer",
chatMetadata: {
integrity: "integrity-official-older",
st_bme_graph: officialGraph,
},
sessionStore: sharedSession,
});
const result = reader.api.loadGraphFromChat({
attemptIndex: 0,
source: "official-older-than-shadow",
});
assert.equal(result.loadState, "loaded");
assert.equal(result.reason, "shadow-snapshot-newer-than-official");
assert.equal(
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
"事件-shadow-newer",
);
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 1);
assert.equal(
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes?.[0]?.fields
?.title,
"事件-shadow-newer",
);
assert.equal(
reader.api.readGraphShadowSnapshot("chat-shadow-newer"),
null,
"影子快照补写成功后应被清理",
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-empty-confirmed",
@@ -584,6 +667,73 @@ result = {
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-empty-confirmed-passive",
chatMetadata: {
integrity: "meta-ready-empty-passive",
},
});
harness.api.loadGraphFromChat({
attemptIndex: 0,
source: "ready-empty-passive",
});
harness.api.onMessageReceived();
assert.equal(
harness.runtimeContext.__contextImmediateSaveCalls,
0,
"空聊天的被动同步不应触发立即保存",
);
assert.equal(
harness.runtimeContext.__contextSaveCalls,
0,
"空聊天的被动同步不应触发防抖保存",
);
assert.equal(
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
undefined,
"empty-confirmed 状态下不能把空图被动写回 metadata",
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-create-first-graph",
chatMetadata: {
integrity: "integrity-before-first-save",
},
});
harness.api.loadGraphFromChat({
attemptIndex: 0,
source: "ready-for-first-save",
});
harness.api.setCurrentGraph(
createMeaningfulGraph("chat-create-first-graph", "first-save"),
);
const result = harness.api.saveGraphToChat({
reason: "first-meaningful-graph",
});
assert.equal(result.saved, true);
assert.equal(result.saveMode, "immediate");
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 1);
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
assert.equal(
harness.runtimeContext.__chatContext.chatMetadata?.integrity ===
"integrity-before-first-save",
false,
"真正改图后应轮换 metadata.integrity阻止旧页面覆盖",
);
assert.equal(
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.__stBmePersistence
?.revision > 0,
true,
);
}
{
const sharedSession = new Map();
const writer = await createGraphPersistenceHarness({
@@ -615,7 +765,8 @@ result = {
reader.runtimeContext.__chatContext.chatMetadata?.st_bme_graph?.nodes?.length,
1,
);
assert.equal(reader.runtimeContext.__contextSaveCalls, 1);
assert.equal(reader.runtimeContext.__contextImmediateSaveCalls, 1);
assert.equal(reader.runtimeContext.__contextSaveCalls, 0);
assert.equal(live.lastPersistedRevision, 9);
assert.equal(live.pendingPersist, false);
}