feat: 新增「数据清理」配置页

- 图谱清理:清空当前图谱、按楼层范围删除节点
- 缓存清理:清空向量缓存、清空提取历史
- 存储清理:清空当前/全部 IDB、清空服务端同步文件
- 高危操作全部需要 confirm 弹窗确认
- 清空全部 IDB 和清空服务端同步文件需要输入 DELETE 确认
This commit is contained in:
Youzini-afk
2026-04-08 14:28:44 +08:00
parent 20f64138b1
commit 29af3d164e
5 changed files with 526 additions and 0 deletions

View File

@@ -27,6 +27,7 @@ import {
import {
autoSyncOnChatChange,
autoSyncOnVisibility,
deleteRemoteSyncFile,
scheduleUpload,
syncNow,
} from "./sync/bme-sync.js";
@@ -212,6 +213,13 @@ import {
onTestMemoryLLMController,
onViewGraphController,
onViewLastInjectionController,
onClearGraphController,
onClearGraphRangeController,
onClearVectorCacheController,
onClearBatchJournalController,
onDeleteCurrentIdbController,
onDeleteAllIdbController,
onDeleteServerSyncFileController,
} from "./ui/ui-actions-controller.js";
import {
clampInt,
@@ -10917,6 +10925,70 @@ async function onReembedDirect() {
});
}
// ==================== 数据清理 ====================
const _cleanupRuntime = () => ({
confirm: (msg) => (typeof globalThis.confirm === "function" ? globalThis.confirm(msg) : false),
prompt: (msg) => (typeof globalThis.prompt === "function" ? globalThis.prompt(msg) : null),
createEmptyGraph,
clearInjectionState,
ensureGraphMutationReady,
getCurrentChatId,
getCurrentGraph: () => currentGraph,
markVectorStateDirty: (reason) => {
if (currentGraph?.vectorIndexState) {
currentGraph.vectorIndexState.dirty = true;
currentGraph.vectorIndexState.dirtyReason = reason;
}
},
normalizeGraphRuntimeState,
refreshPanelLiveState,
removeNode: (graph, nodeId) => removeNode(graph, nodeId),
saveGraphToChat,
setCurrentGraph: (graph) => { currentGraph = graph; },
setExtractionCount: (count) => {
if (currentGraph?.historyState) {
currentGraph.historyState.extractionCount = count;
}
},
setLastExtractedItems: () => { lastExtractedItems = []; },
buildBmeDbName,
closeBmeDb: null,
deleteRemoteSyncFile: (chatId) => deleteRemoteSyncFile(chatId, {
fetch: globalThis.fetch?.bind(globalThis),
getRequestHeaders: typeof getRequestHeaders === "function" ? getRequestHeaders : undefined,
}),
toastr,
});
async function onClearGraph() {
return await onClearGraphController(_cleanupRuntime());
}
async function onClearGraphRange(startSeq, endSeq) {
return await onClearGraphRangeController(_cleanupRuntime(), startSeq, endSeq);
}
async function onClearVectorCache() {
return await onClearVectorCacheController(_cleanupRuntime());
}
async function onClearBatchJournal() {
return await onClearBatchJournalController(_cleanupRuntime());
}
async function onDeleteCurrentIdb() {
return await onDeleteCurrentIdbController(_cleanupRuntime());
}
async function onDeleteAllIdb() {
return await onDeleteAllIdbController(_cleanupRuntime());
}
async function onDeleteServerSyncFile() {
return await onDeleteServerSyncFileController(_cleanupRuntime());
}
// ==================== 初始化 ====================
(async function init() {
@@ -10953,6 +11025,13 @@ async function onReembedDirect() {
rebuildVectorRange: (range) => onRebuildVectorIndex(range),
reembedDirect: onReembedDirect,
reroll: onReroll,
clearGraph: onClearGraph,
clearGraphRange: (startSeq, endSeq) => onClearGraphRange(startSeq, endSeq),
clearVectorCache: onClearVectorCache,
clearBatchJournal: onClearBatchJournal,
deleteCurrentIdb: onDeleteCurrentIdb,
deleteAllIdb: onDeleteAllIdb,
deleteServerSyncFile: onDeleteServerSyncFile,
},
console,
document,

View File

@@ -1350,6 +1350,21 @@
margin-bottom: 10px;
}
.bme-cleanup-warning-text {
color: #ffc54f;
border-left: 3px solid #ffc54f;
padding-left: 10px;
margin-top: 12px;
display: flex;
align-items: flex-start;
gap: 6px;
}
.bme-cleanup-warning-text i {
flex-shrink: 0;
margin-top: 1px;
}
.bme-config-subgroup + .bme-config-subgroup {
margin-top: 16px;
padding-top: 16px;

View File

@@ -144,6 +144,14 @@
<i class="fa-solid fa-palette"></i>
<span>面板外观</span>
</button>
<button
class="bme-config-nav-btn"
data-config-section="cleanup"
type="button"
>
<i class="fa-solid fa-broom"></i>
<span>数据清理</span>
</button>
</div>
</div>
@@ -542,6 +550,14 @@
<i class="fa-solid fa-palette"></i>
<span>面板外观</span>
</button>
<button
class="bme-config-nav-btn"
data-config-section="cleanup"
type="button"
>
<i class="fa-solid fa-broom"></i>
<span>数据清理</span>
</button>
</div>
<div class="bme-config-sections">
@@ -2327,6 +2343,148 @@
</div>
</div>
</section>
<section
class="bme-config-section"
data-config-section="cleanup"
>
<div class="bme-config-section-head">
<div class="bme-config-section-kicker">数据清理</div>
<h3 class="bme-config-section-title">图谱、缓存与存储清理</h3>
<p class="bme-config-section-desc">
在这里执行高危清理操作。所有操作均需二次确认,部分操作不可撤销。
</p>
</div>
<!-- 图谱清理 -->
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">图谱清理</div>
<div class="bme-config-card-subtitle">
清空整个图谱或删除指定楼层范围内的记忆节点。操作不可撤销。
</div>
</div>
</div>
<div class="bme-action-grid">
<button
class="bme-action-btn danger"
id="bme-act-clear-graph"
type="button"
>
<i class="fa-solid fa-trash-can"></i>
<span>清空当前图谱</span>
</button>
<button
class="bme-action-btn danger"
id="bme-act-clear-graph-range"
type="button"
>
<i class="fa-solid fa-scissors"></i>
<span>按楼层范围清理</span>
</button>
</div>
<div class="bme-action-group-extra">
<div class="bme-config-help">
按楼层范围清理:删除指定楼层范围内的所有节点和相关边。留空则不执行。
</div>
<div class="bme-action-range-row">
<div class="bme-config-row">
<label for="bme-cleanup-range-start">起始楼层</label>
<input
id="bme-cleanup-range-start"
class="bme-config-input"
type="number"
min="0"
max="999999"
/>
</div>
<div class="bme-config-row">
<label for="bme-cleanup-range-end">结束楼层</label>
<input
id="bme-cleanup-range-end"
class="bme-config-input"
type="number"
min="0"
max="999999"
/>
</div>
</div>
</div>
</div>
<!-- 缓存清理 -->
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">缓存清理</div>
<div class="bme-config-card-subtitle">
清空运行时向量缓存或提取历史。不影响已持久化的图谱节点。
</div>
</div>
</div>
<div class="bme-action-grid">
<button
class="bme-action-btn"
id="bme-act-clear-vector-cache"
type="button"
>
<i class="fa-solid fa-database"></i>
<span>清空向量缓存</span>
</button>
<button
class="bme-action-btn"
id="bme-act-clear-batch-journal"
type="button"
>
<i class="fa-solid fa-clock-rotate-left"></i>
<span>清空提取历史</span>
</button>
</div>
</div>
<!-- 数据存储清理 -->
<div class="bme-config-card">
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">数据存储清理</div>
<div class="bme-config-card-subtitle">
删除本地 IndexedDB 缓存或服务端同步文件。
</div>
</div>
</div>
<div class="bme-action-grid">
<button
class="bme-action-btn danger"
id="bme-act-delete-current-idb"
type="button"
>
<i class="fa-solid fa-hard-drive"></i>
<span>清空当前聊天 IDB</span>
</button>
<button
class="bme-action-btn danger"
id="bme-act-delete-all-idb"
type="button"
>
<i class="fa-solid fa-explosion"></i>
<span>清空全部 BME IDB</span>
</button>
<button
class="bme-action-btn danger"
id="bme-act-delete-server-sync"
type="button"
>
<i class="fa-solid fa-cloud-arrow-down"></i>
<span>清空服务端同步文件</span>
</button>
</div>
<div class="bme-config-help bme-cleanup-warning-text">
<i class="fa-solid fa-triangle-exclamation"></i>
「清空全部 BME IDB」和「清空服务端同步文件」需要输入 DELETE 确认。
</div>
</div>
</section>
</div>
</div>
</div>

View File

@@ -1990,6 +1990,12 @@ function _bindActions() {
"bme-act-undo-maintenance": "undoMaintenance",
"bme-act-vector-rebuild": "rebuildVectorIndex",
"bme-act-vector-reembed": "reembedDirect",
"bme-act-clear-graph": "clearGraph",
"bme-act-clear-vector-cache": "clearVectorCache",
"bme-act-clear-batch-journal": "clearBatchJournal",
"bme-act-delete-current-idb": "deleteCurrentIdb",
"bme-act-delete-all-idb": "deleteAllIdb",
"bme-act-delete-server-sync": "deleteServerSyncFile",
};
const actionLabels = {
@@ -2004,6 +2010,12 @@ function _bindActions() {
undoMaintenance: "撤销最近维护",
rebuildVectorIndex: "重建向量",
reembedDirect: "直连重嵌",
clearGraph: "清空图谱",
clearVectorCache: "清空向量缓存",
clearBatchJournal: "清空提取历史",
deleteCurrentIdb: "清空当前 IDB",
deleteAllIdb: "清空全部 IDB",
deleteServerSyncFile: "清空服务端同步文件",
};
for (const [elementId, actionKey] of Object.entries(bindings)) {
@@ -2148,6 +2160,50 @@ function _bindActions() {
_refreshGraphAvailabilityState();
}
});
// 按楼层范围清理 (cleanup)
document
.getElementById("bme-act-clear-graph-range")
?.addEventListener("click", async () => {
const btn = document.getElementById("bme-act-clear-graph-range");
if (btn?.disabled) return;
const startStr = document.getElementById("bme-cleanup-range-start")?.value;
const endStr = document.getElementById("bme-cleanup-range-end")?.value;
const startSeq = _parseOptionalInt(startStr);
const endSeq = _parseOptionalInt(endStr);
if (btn) {
btn.disabled = true;
btn.style.opacity = "0.5";
}
_showActionProgressUi("按楼层范围清理");
try {
await _actionHandlers.clearGraphRange?.(
Number.isFinite(startSeq) ? startSeq : null,
Number.isFinite(endSeq) ? endSeq : null,
);
_refreshDashboard();
_refreshGraph();
if (
document
.getElementById("bme-pane-memory")
?.classList.contains("active")
) {
_refreshMemoryBrowser();
}
} catch (error) {
console.error("[ST-BME] Action clearGraphRange failed:", error);
toastr.error(`按楼层范围清理失败: ${error?.message || error}`, "ST-BME");
} finally {
if (btn) {
btn.style.opacity = "";
}
_refreshRuntimeStatus();
_refreshGraphAvailabilityState();
}
});
}
function _refreshConfigTab() {

View File

@@ -860,3 +860,221 @@ export async function onUndoLastMaintenanceController(runtime) {
throw error;
}
}
// ==================== 数据清理 ====================
export async function onClearGraphController(runtime) {
if (!runtime.confirm("确定要清空当前图谱?\n\n所有节点和边将被删除操作不可撤销。")) {
return { cancelled: true };
}
if (!runtime.ensureGraphMutationReady("清空图谱")) return;
const nextGraph = runtime.normalizeGraphRuntimeState(
runtime.createEmptyGraph(),
runtime.getCurrentChatId(),
);
runtime.setCurrentGraph(nextGraph);
runtime.clearInjectionState();
runtime.markVectorStateDirty?.("清空图谱后需要重建向量索引");
runtime.setExtractionCount(0);
runtime.setLastExtractedItems([]);
runtime.saveGraphToChat({ reason: "manual-clear-graph" });
runtime.refreshPanelLiveState();
runtime.toastr.success("当前图谱已清空");
return { handledToast: true };
}
export async function onClearGraphRangeController(runtime, startSeq, endSeq) {
if (!Number.isFinite(startSeq) || !Number.isFinite(endSeq) || startSeq > endSeq) {
runtime.toastr.warning("请填写有效的起始和结束楼层");
return { handledToast: true };
}
if (
!runtime.confirm(
`确定要删除楼层 ${startSeq} ~ ${endSeq} 范围内的所有节点?\n\n操作不可撤销。`,
)
) {
return { cancelled: true };
}
if (!runtime.ensureGraphMutationReady("按楼层范围清理")) return;
const graph = runtime.getCurrentGraph();
if (!graph) return;
const nodesToRemove = graph.nodes.filter((node) => {
const range = Array.isArray(node.seqRange) ? node.seqRange : [node.seq, node.seq];
const nodeStart = Number(range[0]) || 0;
const nodeEnd = Number(range[1]) || 0;
return nodeEnd >= startSeq && nodeStart <= endSeq;
});
let removedCount = 0;
for (const node of nodesToRemove) {
if (runtime.removeNode(graph, node.id)) {
removedCount += 1;
}
}
if (removedCount > 0) {
runtime.markVectorStateDirty?.("按楼层范围清理后需要重建向量索引");
runtime.saveGraphToChat({ reason: "manual-clear-graph-range" });
}
runtime.refreshPanelLiveState();
runtime.toastr.success(`已删除楼层 ${startSeq}~${endSeq} 范围内 ${removedCount} 个节点`);
return { handledToast: true };
}
export async function onClearVectorCacheController(runtime) {
if (!runtime.confirm("确定要清空向量缓存?\n\n清空后需要重新构建向量索引。")) {
return { cancelled: true };
}
const graph = runtime.getCurrentGraph();
if (!graph) {
runtime.toastr.warning("当前没有加载的图谱");
return { handledToast: true };
}
if (graph.vectorIndexState) {
graph.vectorIndexState.hashToNodeId = {};
graph.vectorIndexState.nodeToHash = {};
graph.vectorIndexState.dirty = true;
graph.vectorIndexState.dirtyReason = "manual-clear-vector-cache";
graph.vectorIndexState.lastWarning = "向量缓存已手动清空,需要重建索引";
}
runtime.saveGraphToChat({ reason: "manual-clear-vector-cache" });
runtime.refreshPanelLiveState();
runtime.toastr.success("向量缓存已清空,请重建向量索引");
return { handledToast: true };
}
export async function onClearBatchJournalController(runtime) {
if (!runtime.confirm("确定要清空提取历史?\n\n提取批次记录和计数将被重置。")) {
return { cancelled: true };
}
const graph = runtime.getCurrentGraph();
if (!graph) {
runtime.toastr.warning("当前没有加载的图谱");
return { handledToast: true };
}
graph.batchJournal = [];
if (graph.historyState) {
graph.historyState.extractionCount = 0;
}
runtime.setExtractionCount(0);
runtime.saveGraphToChat({ reason: "manual-clear-batch-journal" });
runtime.refreshPanelLiveState();
runtime.toastr.success("提取历史已清空");
return { handledToast: true };
}
export async function onDeleteCurrentIdbController(runtime) {
const chatId = runtime.getCurrentChatId();
if (!chatId) {
runtime.toastr.warning("当前没有聊天上下文");
return { handledToast: true };
}
const dbName = runtime.buildBmeDbName(chatId);
if (
!runtime.confirm(
`确定要删除当前聊天的本地缓存数据库?\n\n目标: ${dbName}\n操作不可撤销。`,
)
) {
return { cancelled: true };
}
try {
await runtime.closeBmeDb?.(chatId);
await new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(dbName);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => resolve();
});
runtime.toastr.success(`已删除数据库 ${dbName}`);
} catch (error) {
runtime.toastr.error(`删除失败: ${error?.message || error}`);
}
return { handledToast: true };
}
export async function onDeleteAllIdbController(runtime) {
const userInput = runtime.prompt(
"此操作会删除所有聊天的 BME 本地缓存数据库,不可恢复。\n\n请输入 DELETE 确认:",
);
if (userInput !== "DELETE") {
if (userInput != null) {
runtime.toastr.warning("输入不匹配,操作已取消");
}
return { cancelled: true };
}
try {
const databases = await indexedDB.databases();
const bmeDbs = databases.filter((db) =>
String(db.name || "").startsWith("STBME_"),
);
if (bmeDbs.length === 0) {
runtime.toastr.info("没有找到 BME 本地缓存数据库");
return { handledToast: true };
}
let deletedCount = 0;
for (const db of bmeDbs) {
try {
await new Promise((resolve, reject) => {
const req = indexedDB.deleteDatabase(db.name);
req.onsuccess = () => resolve();
req.onerror = () => reject(req.error);
req.onblocked = () => resolve();
});
deletedCount += 1;
} catch {
// continue deleting others
}
}
runtime.toastr.success(`已删除 ${deletedCount}/${bmeDbs.length} 个 BME 数据库`);
} catch (error) {
runtime.toastr.error(`删除失败: ${error?.message || error}`);
}
return { handledToast: true };
}
export async function onDeleteServerSyncFileController(runtime) {
const chatId = runtime.getCurrentChatId();
if (!chatId) {
runtime.toastr.warning("当前没有聊天上下文");
return { handledToast: true };
}
const userInput = runtime.prompt(
"此操作会删除当前聊天在服务端的同步文件,不可恢复。\n\n请输入 DELETE 确认:",
);
if (userInput !== "DELETE") {
if (userInput != null) {
runtime.toastr.warning("输入不匹配,操作已取消");
}
return { cancelled: true };
}
try {
const result = await runtime.deleteRemoteSyncFile(chatId);
if (result?.deleted) {
runtime.toastr.success(`已删除服务端同步文件: ${result.filename}`);
} else {
runtime.toastr.info(
result?.reason === "not-found"
? "服务端没有找到同步文件"
: `删除未成功: ${result?.reason || "未知原因"}`,
);
}
} catch (error) {
runtime.toastr.error(`删除失败: ${error?.message || error}`);
}
return { handledToast: true };
}