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 || "未知原因"}`,
);
}