fix: align cleanup actions with opfs storage

This commit is contained in:
Youzini-afk
2026-04-14 19:55:18 +08:00
parent 78db223833
commit 1867662653
6 changed files with 246 additions and 24 deletions

View File

@@ -31,6 +31,8 @@ import {
BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB,
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW,
deleteAllOpfsStorage,
deleteOpfsChatStorage,
OpfsGraphStore,
detectOpfsSupport,
isGraphLocalStorageModeOpfs,
@@ -15280,6 +15282,7 @@ const _cleanupRuntime = () => ({
buildBmeDbName,
buildRestoreSafetyDbName: (chatId) =>
buildBmeDbName(buildRestoreSafetyChatId(chatId)),
buildRestoreSafetyChatId,
closeBmeDb: async (chatId) => {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId || !bmeChatManager) return;
@@ -15299,10 +15302,15 @@ const _cleanupRuntime = () => ({
clearCachedIndexedDbSnapshot,
clearAllCachedIndexedDbSnapshots,
clearCurrentChatCommitMarker,
deleteCurrentChatOpfsStorage: async (chatId) =>
await deleteOpfsChatStorage(chatId),
deleteAllOpfsStorage: async () =>
await deleteAllOpfsStorage(),
deleteRemoteSyncFile: (chatId) => deleteRemoteSyncFile(chatId, {
fetch: globalThis.fetch?.bind(globalThis),
getRequestHeaders: typeof getRequestHeaders === "function" ? getRequestHeaders : undefined,
}),
getGraphPersistenceState: () => graphPersistenceState,
toastr,
});

View File

@@ -340,6 +340,19 @@ async function maybeGetFileHandle(parentHandle, name) {
}
}
async function maybeGetDirectoryHandle(parentHandle, name) {
try {
return await parentHandle.getDirectoryHandle(String(name || ""), {
create: false,
});
} catch (error) {
if (isNotFoundError(error)) {
return null;
}
throw error;
}
}
async function readJsonFile(parentHandle, name, fallbackValue = null) {
const fileHandle = await maybeGetFileHandle(parentHandle, name);
if (!fileHandle) {
@@ -599,6 +612,116 @@ export async function detectOpfsSupport(options = {}) {
}
}
export async function deleteOpfsChatStorage(chatId, options = {}) {
const normalizedChatId = normalizeChatId(chatId);
if (!normalizedChatId) {
return {
deleted: false,
reason: "missing-chat-id",
chatId: "",
};
}
const rootDirectoryFactory =
typeof options.rootDirectoryFactory === "function"
? options.rootDirectoryFactory
: getDefaultOpfsRootDirectory;
try {
const rootDirectory = await rootDirectoryFactory();
if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") {
return {
deleted: false,
reason: "missing-directory-handle",
chatId: normalizedChatId,
};
}
const opfsRoot = await maybeGetDirectoryHandle(
rootDirectory,
OPFS_ROOT_DIRECTORY_NAME,
);
if (!opfsRoot) {
return {
deleted: false,
reason: "not-found",
chatId: normalizedChatId,
};
}
const chatsDirectory = await maybeGetDirectoryHandle(
opfsRoot,
OPFS_CHATS_DIRECTORY_NAME,
);
if (!chatsDirectory) {
return {
deleted: false,
reason: "not-found",
chatId: normalizedChatId,
};
}
const chatDirectoryName = buildChatDirectoryName(normalizedChatId);
const chatDirectory = await maybeGetDirectoryHandle(chatsDirectory, chatDirectoryName);
if (!chatDirectory) {
return {
deleted: false,
reason: "not-found",
chatId: normalizedChatId,
};
}
await chatsDirectory.removeEntry(chatDirectoryName, {
recursive: true,
});
return {
deleted: true,
reason: "deleted",
chatId: normalizedChatId,
};
} catch (error) {
return {
deleted: false,
reason: "delete-failed",
chatId: normalizedChatId,
error,
};
}
}
export async function deleteAllOpfsStorage(options = {}) {
const rootDirectoryFactory =
typeof options.rootDirectoryFactory === "function"
? options.rootDirectoryFactory
: getDefaultOpfsRootDirectory;
try {
const rootDirectory = await rootDirectoryFactory();
if (!rootDirectory || typeof rootDirectory.getDirectoryHandle !== "function") {
return {
deleted: false,
reason: "missing-directory-handle",
};
}
const opfsRoot = await maybeGetDirectoryHandle(
rootDirectory,
OPFS_ROOT_DIRECTORY_NAME,
);
if (!opfsRoot) {
return {
deleted: false,
reason: "not-found",
};
}
await rootDirectory.removeEntry(OPFS_ROOT_DIRECTORY_NAME, {
recursive: true,
});
return {
deleted: true,
reason: "deleted",
};
} catch (error) {
return {
deleted: false,
reason: "delete-failed",
error,
};
}
}
class LegacyOpfsGraphStore {
constructor(chatId, options = {}) {
this.chatId = normalizeChatId(chatId);

View File

@@ -8,6 +8,8 @@ import {
import {
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW,
deleteAllOpfsStorage,
deleteOpfsChatStorage,
OpfsGraphStore,
detectOpfsSupport,
} from "../sync/bme-opfs-store.js";
@@ -520,6 +522,59 @@ async function testPruneExpiredTombstonesAndClearAll() {
await store.close();
}
async function testDeleteCurrentAndAllOpfsStorage() {
const rootDirectory = createMemoryOpfsRoot();
const storeA = new OpfsGraphStore("chat-opfs-delete-a", {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
const storeB = new OpfsGraphStore("chat-opfs-delete-b", {
rootDirectoryFactory: async () => rootDirectory,
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
});
await storeA.open();
await storeB.open();
await storeA.importSnapshot(
{
meta: { revision: 1 },
state: { lastProcessedFloor: 1, extractionCount: 1 },
nodes: [{ id: "node-a", type: "event", fields: { title: "A" }, archived: false, updatedAt: 1 }],
edges: [],
tombstones: [],
},
{ mode: "replace", preserveRevision: true },
);
await storeB.importSnapshot(
{
meta: { revision: 1 },
state: { lastProcessedFloor: 1, extractionCount: 1 },
nodes: [{ id: "node-b", type: "event", fields: { title: "B" }, archived: false, updatedAt: 1 }],
edges: [],
tombstones: [],
},
{ mode: "replace", preserveRevision: true },
);
const deleteCurrentResult = await deleteOpfsChatStorage("chat-opfs-delete-a", {
rootDirectoryFactory: async () => rootDirectory,
});
assert.equal(deleteCurrentResult.deleted, true);
const chatsDirectory = getNestedDirectory(
getNestedDirectory(rootDirectory, "st-bme"),
"chats",
);
assert.equal(chatsDirectory.directories.has(encodeURIComponent("chat-opfs-delete-a")), false);
assert.equal(chatsDirectory.directories.has(encodeURIComponent("chat-opfs-delete-b")), true);
const deleteAllResult = await deleteAllOpfsStorage({
rootDirectoryFactory: async () => rootDirectory,
});
assert.equal(deleteAllResult.deleted, true);
assert.equal(rootDirectory.directories.has("st-bme"), false);
}
async function main() {
console.log(`${PREFIX} starting`);
@@ -527,6 +582,7 @@ async function main() {
await testImportExportPersistenceAndFileRotation();
await testImportLegacyGraphMigrationAndSkipPaths();
await testPruneExpiredTombstonesAndClearAll();
await testDeleteCurrentAndAllOpfsStorage();
console.log("opfs-persistence tests passed");
}

View File

@@ -2921,7 +2921,7 @@
<div>
<div class="bme-config-card-title">数据存储清理</div>
<div class="bme-config-card-subtitle">
删除本地 IndexedDB 缓存或服务端同步文件
删除本地 OPFS / IndexedDB 图谱存储,或清理服务端同步数据
</div>
</div>
</div>
@@ -2932,7 +2932,7 @@
type="button"
>
<i class="fa-solid fa-hard-drive"></i>
<span>清空当前聊天 IDB</span>
<span>清空当前聊天本地存储</span>
</button>
<button
class="bme-action-btn danger"
@@ -2940,7 +2940,7 @@
type="button"
>
<i class="fa-solid fa-explosion"></i>
<span>清空全部 BME IDB</span>
<span>清空全部 BME 本地存储</span>
</button>
<button
class="bme-action-btn danger"
@@ -2948,12 +2948,12 @@
type="button"
>
<i class="fa-solid fa-cloud-arrow-down"></i>
<span>清空服务端同步文件</span>
<span>清空服务端同步数据</span>
</button>
</div>
<div class="bme-config-help bme-cleanup-warning-text">
<i class="fa-solid fa-triangle-exclamation"></i>
「清空全部 BME IDB」和「清空服务端同步文件」需要输入 DELETE 确认。
「清空全部 BME 本地存储」和「清空服务端同步数据」需要输入 DELETE 确认。
</div>
</div>
</section>

View File

@@ -5246,9 +5246,9 @@ function _bindActions() {
clearGraph: "清空图谱",
clearVectorCache: "清空向量缓存",
clearBatchJournal: "清空提取历史",
deleteCurrentIdb: "清空当前 IDB",
deleteAllIdb: "清空全部 IDB",
deleteServerSyncFile: "清空服务端同步文件",
deleteCurrentIdb: "清空当前本地存储",
deleteAllIdb: "清空全部本地存储",
deleteServerSyncFile: "清空服务端同步数据",
backupToCloud: "\u5907\u4efd\u5230\u4e91\u7aef",
restoreFromCloud: "\u4ece\u4e91\u7aef\u83b7\u53d6\u5907\u4efd",
manageServerBackups: "\u7ba1\u7406\u670d\u52a1\u5668\u5907\u4efd",
@@ -11356,7 +11356,7 @@ function _formatPersistMismatchReason(reason = "") {
if (!normalized) return "—";
switch (normalized) {
case "persist-mismatch:indexeddb-behind-commit-marker":
return "本地 IndexedDB 缓存版本落后于当前聊天已确认版本";
return "本地图谱存储版本落后于当前聊天已确认版本";
default:
return normalized;
}
@@ -11366,7 +11366,7 @@ function _formatPersistMismatchHelp(reason = "") {
const normalized = String(reason || "").trim();
switch (normalized) {
case "persist-mismatch:indexeddb-behind-commit-marker":
return "当前聊天记录显示图谱已经确认到更高版本,但本地 IndexedDB 里还没有对应数据。常见于刚清空本地缓存,或写入确认还没完成。建议先点“重新探测图谱”;如果仍异常,再点“重试持久化”或执行重建/恢复。";
return "当前聊天记录显示图谱已经确认到更高版本,但本地 OPFS / IndexedDB 存储里还没有对应数据。常见于刚清空本地缓存,或写入确认还没完成。建议先点“重新探测图谱”;如果仍异常,再点“重试持久化”或执行重建/恢复。";
default:
return `检测到持久化一致性异常:${_formatPersistMismatchReason(normalized)}。建议先重新探测图谱;如果仍异常,再执行重建或恢复。`;
}
@@ -11750,7 +11750,7 @@ function _refreshCloudStorageModeUi(settings = _getSettings?.() || {}) {
if (helpText) {
helpText.textContent =
mode === "manual"
? "\u624b\u52a8\u50a8\u5b58\u53ea\u4fdd\u7559\u672c\u5730 IndexedDB \u5199\u5165\uff0c\u4e0d\u4f1a\u81ea\u52a8\u4e0a\u4f20\u6216\u8986\u76d6\u4e91\u7aef\u3002\u9700\u8981\u63a5\u529b\u65f6\uff0c\u8bf7\u624b\u52a8\u70b9\u51fb\u4e0b\u65b9\u6309\u94ae\u3002"
? "\u624b\u52a8\u50a8\u5b58\u53ea\u4fdd\u7559\u672c\u5730 OPFS / IndexedDB \u5199\u5165\uff0c\u4e0d\u4f1a\u81ea\u52a8\u4e0a\u4f20\u6216\u8986\u76d6\u4e91\u7aef\u3002\u9700\u8981\u63a5\u529b\u65f6\uff0c\u8bf7\u624b\u52a8\u70b9\u51fb\u4e0b\u65b9\u6309\u94ae\u3002"
: "\u81ea\u52a8\u50a8\u5b58\u4f1a\u7ee7\u7eed\u6cbf\u7528\u5f53\u524d\u955c\u50cf\u540c\u6b65\u903b\u8f91\u4e0e\u95f4\u9694\uff1b\u624b\u52a8\u50a8\u5b58\u53ea\u4fdd\u7559\u672c\u5730\u5199\u5165\uff0c\u9700\u8981\u4f60\u4e3b\u52a8\u5907\u4efd\u548c\u6062\u590d\u3002";
}
_renderCloudStorageModeStatus(settings, _getGraphPersistenceSnapshot());

View File

@@ -1168,9 +1168,19 @@ export async function onDeleteCurrentIdbController(runtime) {
const dbName = runtime.buildBmeDbName(chatId);
const restoreSafetyDbName = runtime.buildRestoreSafetyDbName?.(chatId) || "";
const restoreSafetyChatId =
typeof runtime.buildRestoreSafetyChatId === "function"
? runtime.buildRestoreSafetyChatId(chatId)
: `__restore_safety__${chatId}`;
const persistenceState = runtime.getGraphPersistenceState?.() || {};
const hostProfile = String(persistenceState.hostProfile || "generic-st");
const localStoreLabel =
hostProfile === "luker"
? "当前聊天的本地缓存IndexedDB / OPFS不影响 Luker 侧车主存储)"
: "当前聊天的本地图谱存储IndexedDB / OPFS";
if (
!runtime.confirm(
`确定要删除当前聊天的本地缓存数据库?\n\n目标: ${dbName}\n操作不可撤销。`,
`确定要删除${localStoreLabel}\n\n将尝试清理:\n- ${dbName}\n- OPFS 当前聊天目录\n- restore safety 本地副本\n\n操作不可撤销。`,
)
) {
return { cancelled: true };
@@ -1178,32 +1188,50 @@ export async function onDeleteCurrentIdbController(runtime) {
try {
await runtime.closeBmeDb?.(chatId);
let deletedIndexedDbCount = 0;
await new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onsuccess = () => {
deletedIndexedDbCount += 1;
resolve();
};
req.onerror = () => reject(req.error);
req.onblocked = () => resolve();
});
if (restoreSafetyDbName) {
await new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(restoreSafetyDbName);
req.onsuccess = () => resolve();
req.onsuccess = () => {
deletedIndexedDbCount += 1;
resolve();
};
req.onerror = () => reject(req.error);
req.onblocked = () => resolve();
});
}
const currentOpfsResult = await runtime.deleteCurrentChatOpfsStorage?.(chatId);
const restoreSafetyOpfsResult =
restoreSafetyChatId && restoreSafetyChatId !== chatId
? await runtime.deleteCurrentChatOpfsStorage?.(restoreSafetyChatId)
: null;
runtime.clearCachedIndexedDbSnapshot?.(chatId);
runtime.clearCachedIndexedDbSnapshot?.(restoreSafetyChatId);
runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-current-idb",
reason: "manual-delete-current-local-storage",
immediate: true,
resetAcceptedRevision: true,
});
runtime.syncGraphLoadFromLiveContext?.({
source: "manual-delete-current-idb",
source: "manual-delete-current-local-storage",
force: true,
});
runtime.refreshPanelLiveState?.();
runtime.toastr.success(`已删除数据库 ${dbName}`);
const deletedOpfs =
currentOpfsResult?.deleted === true ||
restoreSafetyOpfsResult?.deleted === true;
runtime.toastr.success(
`已清空当前聊天本地存储IndexedDB ${deletedIndexedDbCount > 0 ? "已处理" : "无"}OPFS ${deletedOpfs ? "已处理" : "无"}`,
);
} catch (error) {
runtime.toastr.error(`删除失败: ${error?.message || error}`);
}
@@ -1212,7 +1240,7 @@ export async function onDeleteCurrentIdbController(runtime) {
export async function onDeleteAllIdbController(runtime) {
const userInput = runtime.prompt(
"此操作会删除所有聊天的 BME 本地缓存数据库,不可恢复。\n\n请输入 DELETE 确认:",
"此操作会删除所有聊天的 BME 本地图谱存储IndexedDB / OPFS不影响 Luker 侧车主存储。\n\n请输入 DELETE 确认:",
);
if (userInput !== "DELETE") {
if (userInput != null) {
@@ -1246,22 +1274,29 @@ export async function onDeleteAllIdbController(runtime) {
// continue deleting others
}
}
const opfsResult = await runtime.deleteAllOpfsStorage?.();
runtime.clearAllCachedIndexedDbSnapshots?.();
const activeChatId = runtime.getCurrentChatId?.();
if (activeChatId) {
runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-all-idb",
reason: "manual-delete-all-local-storage",
immediate: true,
resetAcceptedRevision: true,
});
runtime.syncGraphLoadFromLiveContext?.({
source: "manual-delete-all-idb",
source: "manual-delete-all-local-storage",
force: true,
});
}
runtime.refreshPanelLiveState?.();
runtime.toastr.success(`已删除 ${deletedCount}/${bmeDbs.length} 个 BME 数据库`);
if (bmeDbs.length === 0 && opfsResult?.deleted !== true) {
runtime.toastr.info("没有找到 BME 本地图谱存储");
return { handledToast: true };
}
runtime.toastr.success(
`已清空 BME 本地图谱存储IndexedDB ${deletedCount}/${bmeDbs.length}OPFS ${opfsResult?.deleted ? "已处理" : "无"}`,
);
} catch (error) {
runtime.toastr.error(`删除失败: ${error?.message || error}`);
}
@@ -1276,7 +1311,7 @@ export async function onDeleteServerSyncFileController(runtime) {
}
const userInput = runtime.prompt(
"此操作会删除当前聊天在服务端的同步文件,不可恢复。\n\n请输入 DELETE 确认:",
"此操作会删除当前聊天在服务端的同步数据。\n\n如果该聊天已经升级到远端 v2同步 manifest 和 chunk 文件都会一起删除。\n\n请输入 DELETE 确认:",
);
if (userInput !== "DELETE") {
if (userInput != null) {
@@ -1288,11 +1323,11 @@ export async function onDeleteServerSyncFileController(runtime) {
try {
const result = await runtime.deleteRemoteSyncFile(chatId);
if (result?.deleted) {
runtime.toastr.success(`已删除服务端同步文件: ${result.filename}`);
runtime.toastr.success(`已删除服务端同步数据: ${result.filename}`);
} else {
runtime.toastr.info(
result?.reason === "not-found"
? "服务端没有找到同步文件"
? "服务端没有找到同步数据"
: `删除未成功: ${result?.reason || "未知原因"}`,
);
}