From 1867662653cfa22a52cbb7dcff05bbcd488b3d4d Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 14 Apr 2026 19:55:18 +0800 Subject: [PATCH] fix: align cleanup actions with opfs storage --- index.js | 8 +++ sync/bme-opfs-store.js | 123 ++++++++++++++++++++++++++++++++++++ tests/opfs-persistence.mjs | 56 ++++++++++++++++ ui/panel.html | 10 +-- ui/panel.js | 12 ++-- ui/ui-actions-controller.js | 61 ++++++++++++++---- 6 files changed, 246 insertions(+), 24 deletions(-) diff --git a/index.js b/index.js index 5d3dd58..657558c 100644 --- a/index.js +++ b/index.js @@ -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, }); diff --git a/sync/bme-opfs-store.js b/sync/bme-opfs-store.js index de50d04..a527122 100644 --- a/sync/bme-opfs-store.js +++ b/sync/bme-opfs-store.js @@ -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); diff --git a/tests/opfs-persistence.mjs b/tests/opfs-persistence.mjs index 3be76b7..10f7095 100644 --- a/tests/opfs-persistence.mjs +++ b/tests/opfs-persistence.mjs @@ -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"); } diff --git a/ui/panel.html b/ui/panel.html index 018a8e7..1f6ac72 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -2921,7 +2921,7 @@
数据存储清理
- 删除本地 IndexedDB 缓存或服务端同步文件。 + 删除本地 OPFS / IndexedDB 图谱存储,或清理服务端同步数据。
@@ -2932,7 +2932,7 @@ type="button" > - 清空当前聊天 IDB + 清空当前聊天本地存储
- 「清空全部 BME IDB」和「清空服务端同步文件」需要输入 DELETE 确认。 + 「清空全部 BME 本地存储」和「清空服务端同步数据」需要输入 DELETE 确认。
diff --git a/ui/panel.js b/ui/panel.js index 34352db..a463ad4 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -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()); diff --git a/ui/ui-actions-controller.js b/ui/ui-actions-controller.js index 7021da4..2995b8c 100644 --- a/ui/ui-actions-controller.js +++ b/ui/ui-actions-controller.js @@ -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 || "未知原因"}`, ); }