diff --git a/index.js b/index.js index 7665cd7..186dd7c 100644 --- a/index.js +++ b/index.js @@ -27,7 +27,14 @@ import { import { autoSyncOnChatChange, autoSyncOnVisibility, + backupToServer, + buildRestoreSafetyChatId, deleteRemoteSyncFile, + deleteServerBackup, + getRestoreSafetySnapshotStatus, + listServerBackups, + rollbackFromRestoreSafetySnapshot, + restoreFromServer, scheduleUpload, syncNow, } from "./sync/bme-sync.js"; @@ -116,6 +123,7 @@ import { rememberGraphIdentityAlias, readGraphCommitMarker, resolveGraphIdentityAliasByHostChatId, + shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, writeGraphShadowSnapshot, @@ -757,10 +765,19 @@ function getGraphPersistenceLiveState() { indexedDbRevision: graphPersistenceState.indexedDbRevision || 0, indexedDbLastError: graphPersistenceState.indexedDbLastError || "", syncState: normalizeGraphSyncState(graphPersistenceState.syncState), + syncDirty: Boolean(graphPersistenceState.syncDirty), + syncDirtyReason: String(graphPersistenceState.syncDirtyReason || ""), lastSyncUploadedAt: Number(graphPersistenceState.lastSyncUploadedAt) || 0, lastSyncDownloadedAt: Number(graphPersistenceState.lastSyncDownloadedAt) || 0, lastSyncedRevision: Number(graphPersistenceState.lastSyncedRevision) || 0, + lastBackupUploadedAt: + Number(graphPersistenceState.lastBackupUploadedAt) || 0, + lastBackupRestoredAt: + Number(graphPersistenceState.lastBackupRestoredAt) || 0, + lastBackupRollbackAt: + Number(graphPersistenceState.lastBackupRollbackAt) || 0, + lastBackupFilename: String(graphPersistenceState.lastBackupFilename || ""), lastSyncError: String(graphPersistenceState.lastSyncError || ""), dualWriteLastResult: cloneRuntimeDebugValue( graphPersistenceState.dualWriteLastResult, @@ -4012,7 +4029,11 @@ async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { const action = String(syncPayload?.action || "") .trim() .toLowerCase(); - if (action !== "download" && action !== "merge") { + if ( + action !== "download" + && action !== "merge" + && action !== "restore-backup" + ) { return { refreshed: false, reason: "action-not-supported", @@ -4074,6 +4095,7 @@ function buildBmeSyncRuntimeOptions(extra = {}) { return await manager.getCurrentDb(chatId); }, getCurrentChatId: () => getCurrentChatId(), + getCloudStorageMode: () => getSettings().cloudStorageMode || "automatic", getRequestHeaders, onSyncApplied: async (payload = {}) => { await refreshRuntimeGraphAfterSyncApplied(payload); @@ -4110,14 +4132,26 @@ async function syncIndexedDbMetaToPersistenceState( const db = await manager.getCurrentDb(normalizedChatId); const [ revision, + syncDirty, + syncDirtyReason, lastSyncUploadedAt, lastSyncDownloadedAt, lastSyncedRevision, + lastBackupUploadedAt, + lastBackupRestoredAt, + lastBackupRollbackAt, + lastBackupFilename, ] = await Promise.all([ db.getRevision(), + db.getMeta("syncDirty", false), + db.getMeta("syncDirtyReason", ""), db.getMeta("lastSyncUploadedAt", 0), db.getMeta("lastSyncDownloadedAt", 0), db.getMeta("lastSyncedRevision", 0), + db.getMeta("lastBackupUploadedAt", 0), + db.getMeta("lastBackupRestoredAt", 0), + db.getMeta("lastBackupRollbackAt", 0), + db.getMeta("lastBackupFilename", ""), ]); const patch = { @@ -4125,9 +4159,15 @@ async function syncIndexedDbMetaToPersistenceState( storageMode: "indexeddb", indexedDbRevision: normalizeIndexedDbRevision(revision), syncState: normalizeGraphSyncState(syncState), + syncDirty: Boolean(syncDirty), + syncDirtyReason: String(syncDirtyReason || ""), lastSyncUploadedAt: Number(lastSyncUploadedAt) || 0, lastSyncDownloadedAt: Number(lastSyncDownloadedAt) || 0, lastSyncedRevision: Number(lastSyncedRevision) || 0, + lastBackupUploadedAt: Number(lastBackupUploadedAt) || 0, + lastBackupRestoredAt: Number(lastBackupRestoredAt) || 0, + lastBackupRollbackAt: Number(lastBackupRollbackAt) || 0, + lastBackupFilename: String(lastBackupFilename || ""), lastSyncError: String(lastSyncError || ""), }; @@ -4162,7 +4202,12 @@ async function runBmeAutoSyncForChat(source = "unknown", chatId = "") { ); await syncIndexedDbMetaToPersistenceState(normalizedChatId, { - syncState: syncResult?.synced ? "idle" : "warning", + syncState: + syncResult?.action === "manual-probe" + ? "idle" + : syncResult?.synced + ? "idle" + : "warning", lastSyncError: syncResult?.error || "", }); @@ -7602,6 +7647,9 @@ function updateModuleSettings(patch = {}) { const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]); const noticeUiKeys = new Set(["noticeDisplayMode"]); const settings = getSettings(); + const previousCloudStorageMode = String( + settings.cloudStorageMode || "automatic", + ); Object.assign(settings, patch); extension_settings[MODULE_NAME] = settings; globalThis.__stBmeDebugLoggingEnabled = Boolean( @@ -7668,6 +7716,38 @@ function updateModuleSettings(patch = {}) { refreshVisibleStageNotices(); } + const currentCloudStorageMode = String( + settings.cloudStorageMode || "automatic", + ); + if ( + previousCloudStorageMode !== "automatic" + && currentCloudStorageMode === "automatic" + ) { + const chatId = getCurrentChatId(); + if (chatId) { + scheduleBmeIndexedDbTask(async () => { + try { + await syncNow( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "mode-switch-bootstrap", + trigger: "settings:cloud-storage-mode-bootstrap", + }), + ); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + } catch (error) { + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "error", + lastSyncError: error?.message || String(error), + }); + } + }); + } + } + scheduleServerSettingsSave(); return settings; } @@ -12191,6 +12271,8 @@ const _cleanupRuntime = () => ({ }, setLastExtractedItems: () => { lastExtractedItems = []; }, buildBmeDbName, + buildRestoreSafetyDbName: (chatId) => + buildBmeDbName(buildRestoreSafetyChatId(chatId)), closeBmeDb: null, deleteRemoteSyncFile: (chatId) => deleteRemoteSyncFile(chatId, { fetch: globalThis.fetch?.bind(globalThis), @@ -12227,8 +12309,194 @@ async function onDeleteServerSyncFile() { return await onDeleteServerSyncFileController(_cleanupRuntime()); } +async function onBackupCurrentChatToCloud() { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下文"); + return { handledToast: true }; + } + + const result = await backupToServer( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-backup", + trigger: "panel:manual-backup", + }), + ); + + if (!result?.backedUp) { + const backupFailureMessage = + result?.reason === "backup-manifest-error" + ? result?.backupUploaded + ? "备份文件已上传,但服务器备份清单更新失败,请稍后重试" + : "服务器备份清单更新失败,请稍后重试" + : `备份失败: ${result?.error?.message || result?.reason || "未知原因"}`; + toastr.error(backupFailureMessage); + return { handledToast: true, result }; + } + + toastr.success("当前聊天已备份到云端"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; +} + +async function onRestoreCurrentChatFromCloud() { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下文"); + return { handledToast: true }; + } + + const confirmed = globalThis.confirm?.( + "这会用云端备份完整覆盖当前聊天的本地记忆,并先保留一份本地安全快照。确定继续吗?", + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await restoreFromServer( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore", + trigger: "panel:manual-restore", + }), + ); + + if (!result?.restored) { + const reasonMap = { + "not-found": "服务器上没有找到当前聊天的备份", + "backup-missing": "服务器上没有找到当前聊天的备份", + "backup-version-mismatch": "备份版本与当前运行时不兼容", + "backup-chat-id-mismatch": "备份聊天 ID 与当前聊天不匹配", + "snapshot-chat-id-mismatch": "备份内部快照与当前聊天不匹配", + }; + toastr.error( + reasonMap[result?.reason] || + `恢复失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已从云端恢复当前聊天备份"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; +} + +async function onManageServerBackups() { + const chatId = getCurrentChatId(); + const { entries } = await listServerBackups( + buildBmeSyncRuntimeOptions({ + reason: "manage-backups", + trigger: "panel:manage-backups", + }), + ); + return { + entries: Array.isArray(entries) ? entries : [], + currentChatId: chatId, + handledToast: true, + skipDashboardRefresh: true, + }; +} + +async function onDeleteServerBackupEntry(payload = {}) { + const chatId = String(payload?.chatId || "").trim(); + const filename = String(payload?.filename || "").trim(); + if (!chatId) { + return { + deleted: false, + reason: "missing-chat-id", + filename, + handledToast: true, + skipDashboardRefresh: true, + }; + } + + const deleteResult = await deleteServerBackup( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "delete-backup", + trigger: "panel:delete-backup", + }), + ); + + return { + ...deleteResult, + filename, + handledToast: true, + skipDashboardRefresh: true, + }; +} + // ==================== 初始化 ==================== +async function onGetRestoreSafetySnapshotStatus() { + const chatId = getCurrentChatId(); + if (!chatId) { + return { + exists: false, + chatId: "", + createdAt: 0, + reason: "missing-chat-id", + }; + } + + return await getRestoreSafetySnapshotStatus( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore-safety-status", + trigger: "panel:restore-safety-status", + }), + ); +} + +async function onRollbackLastRestore() { + const chatId = getCurrentChatId(); + if (!chatId) { + toastr.warning("当前没有聊天上下文"); + return { handledToast: true }; + } + + const safetyStatus = await onGetRestoreSafetySnapshotStatus(); + if (!safetyStatus?.exists) { + toastr.info("当前聊天还没有可用的上次恢复回滚点"); + return { handledToast: true, result: safetyStatus }; + } + + const confirmed = globalThis.confirm?.( + "这会回滚到上次从云端恢复之前的本地状态。确定继续吗?", + ); + if (!confirmed) { + return { cancelled: true }; + } + + const result = await rollbackFromRestoreSafetySnapshot( + chatId, + buildBmeSyncRuntimeOptions({ + reason: "manual-restore-safety-rollback", + trigger: "panel:rollback-last-restore", + }), + ); + + if (!result?.restored) { + toastr.error( + `回滚失败: ${result?.error?.message || result?.reason || "未知原因"}`, + ); + return { handledToast: true, result }; + } + + toastr.success("已回滚到上次恢复前的本地状态"); + await syncIndexedDbMetaToPersistenceState(chatId, { + syncState: "idle", + lastSyncError: "", + }); + return { handledToast: true, result }; +} (async function init() { await loadServerSettings(); syncGraphPersistenceDebugState(); @@ -12279,6 +12547,12 @@ async function onDeleteServerSyncFile() { deleteCurrentIdb: onDeleteCurrentIdb, deleteAllIdb: onDeleteAllIdb, deleteServerSyncFile: onDeleteServerSyncFile, + backupToCloud: onBackupCurrentChatToCloud, + restoreFromCloud: onRestoreCurrentChatFromCloud, + rollbackLastRestore: onRollbackLastRestore, + manageServerBackups: onManageServerBackups, + deleteServerBackupEntry: onDeleteServerBackupEntry, + getRestoreSafetyStatus: onGetRestoreSafetySnapshotStatus, }, console, document, @@ -12381,3 +12655,5 @@ async function onDeleteServerSyncFile() { } debugLog("[ST-BME] 初始化完成"); })(); + + diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index fe8f267..e5d2c62 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -144,6 +144,7 @@ export const defaultSettings = { // UI 面板 noticeDisplayMode: "normal", panelTheme: "crimson", + cloudStorageMode: "automatic", }; const DEFAULT_SETTING_KEYS = Object.freeze(Object.keys(defaultSettings)); diff --git a/sync/bme-db.js b/sync/bme-db.js index 6541c1a..37d7576 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -20,6 +20,10 @@ export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; export const BME_RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; export const BME_RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult"; export const BME_RUNTIME_SUMMARY_STATE_META_KEY = "runtimeSummaryState"; +export const BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY = "maintenanceJournal"; +export const BME_RUNTIME_KNOWLEDGE_STATE_META_KEY = "knowledgeState"; +export const BME_RUNTIME_REGION_STATE_META_KEY = "regionState"; +export const BME_RUNTIME_TIMELINE_STATE_META_KEY = "timelineState"; export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; @@ -45,6 +49,11 @@ function createDefaultMetaValues(chatId = "", nowMs = Date.now()) { lastSyncUploadedAt: 0, lastSyncDownloadedAt: 0, lastSyncedRevision: 0, + lastBackupUploadedAt: 0, + lastBackupRestoredAt: 0, + lastBackupRollbackAt: 0, + lastBackupFilename: "", + syncDirtyReason: "", deviceId: "", nodeCount: 0, edgeCount: 0, @@ -250,6 +259,15 @@ export function buildSnapshotFromGraph(graph, options = {}) { if (chatId) { graphInput.historyState.chatId = chatId; } + const legacyActiveOwnerKey = String( + graphInput?.knowledgeState?.activeOwnerKey || "", + ).trim(); + const legacyActiveRegion = String( + graphInput?.regionState?.activeRegion || "", + ).trim(); + const legacyActiveSegmentId = String( + graphInput?.timelineState?.activeSegmentId || "", + ).trim(); graphInput.vectorIndexState.collectionId = buildVectorCollectionId( chatId || graphInput.historyState.chatId || "", ); @@ -352,6 +370,45 @@ export function buildSnapshotFromGraph(graph, options = {}) { runtimeGraph?.summaryState || {}, {}, ), + [BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: toPlainData( + runtimeGraph?.maintenanceJournal || [], + [], + ), + [BME_RUNTIME_KNOWLEDGE_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.knowledgeState || {}), + activeOwnerKey: String( + legacyActiveOwnerKey || + runtimeGraph?.historyState?.activeRecallOwnerKey || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_REGION_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.regionState || {}), + activeRegion: String( + legacyActiveRegion || + runtimeGraph?.historyState?.activeRegion || + runtimeGraph?.regionState?.manualActiveRegion || + "", + ).trim(), + }, + {}, + ), + [BME_RUNTIME_TIMELINE_STATE_META_KEY]: toPlainData( + { + ...(runtimeGraph?.timelineState || {}), + activeSegmentId: String( + legacyActiveSegmentId || + runtimeGraph?.historyState?.activeStorySegmentId || + runtimeGraph?.timelineState?.manualActiveSegmentId || + "", + ).trim(), + }, + {}, + ), [BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: Number.isFinite( Number(runtimeGraph?.lastProcessedSeq), ) @@ -399,10 +456,43 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { normalizedSnapshot.meta?.[BME_RUNTIME_LAST_RECALL_META_KEY], null, ); + runtimeGraph.maintenanceJournal = toArray( + normalizedSnapshot.meta?.[BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY], + ); + runtimeGraph.knowledgeState = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_KNOWLEDGE_STATE_META_KEY], + runtimeGraph.knowledgeState || {}, + ); + runtimeGraph.regionState = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_REGION_STATE_META_KEY], + runtimeGraph.regionState || {}, + ); + runtimeGraph.timelineState = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_TIMELINE_STATE_META_KEY], + runtimeGraph.timelineState || {}, + ); runtimeGraph.summaryState = toPlainData( normalizedSnapshot.meta?.[BME_RUNTIME_SUMMARY_STATE_META_KEY], runtimeGraph.summaryState || {}, ); + const rawKnowledgeState = + runtimeGraph.knowledgeState && + typeof runtimeGraph.knowledgeState === "object" && + !Array.isArray(runtimeGraph.knowledgeState) + ? runtimeGraph.knowledgeState + : {}; + const rawRegionState = + runtimeGraph.regionState && + typeof runtimeGraph.regionState === "object" && + !Array.isArray(runtimeGraph.regionState) + ? runtimeGraph.regionState + : {}; + const rawTimelineState = + runtimeGraph.timelineState && + typeof runtimeGraph.timelineState === "object" && + !Array.isArray(runtimeGraph.timelineState) + ? runtimeGraph.timelineState + : {}; runtimeGraph.historyState = { ...(runtimeGraph.historyState || {}), @@ -424,6 +514,59 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { ?.extractionCount ?? META_DEFAULT_EXTRACTION_COUNT, ), }; + if ( + typeof runtimeGraph.historyState.activeRecallOwnerKey !== "string" || + !runtimeGraph.historyState.activeRecallOwnerKey + ) { + const legacyActiveOwnerKey = String(rawKnowledgeState.activeOwnerKey || "").trim(); + if (legacyActiveOwnerKey) { + runtimeGraph.historyState.activeRecallOwnerKey = legacyActiveOwnerKey; + } + } + if ( + typeof runtimeGraph.historyState.activeRegion !== "string" || + !runtimeGraph.historyState.activeRegion + ) { + const legacyActiveRegion = String(rawRegionState.activeRegion || "").trim(); + if (legacyActiveRegion) { + runtimeGraph.historyState.activeRegion = legacyActiveRegion; + if ( + typeof runtimeGraph.historyState.activeRegionSource !== "string" || + !runtimeGraph.historyState.activeRegionSource + ) { + runtimeGraph.historyState.activeRegionSource = "snapshot"; + } + } + } + if ( + typeof runtimeGraph.historyState.activeStorySegmentId !== "string" || + !runtimeGraph.historyState.activeStorySegmentId + ) { + const legacyActiveSegmentId = String(rawTimelineState.activeSegmentId || "").trim(); + if (legacyActiveSegmentId) { + runtimeGraph.historyState.activeStorySegmentId = legacyActiveSegmentId; + const activeSegment = Array.isArray(rawTimelineState.segments) + ? rawTimelineState.segments.find( + (segment) => String(segment?.id || "").trim() === legacyActiveSegmentId, + ) + : null; + if ( + (typeof runtimeGraph.historyState.activeStoryTimeLabel !== "string" || + !runtimeGraph.historyState.activeStoryTimeLabel) && + activeSegment + ) { + runtimeGraph.historyState.activeStoryTimeLabel = String( + activeSegment.label || "", + ).trim(); + } + if ( + typeof runtimeGraph.historyState.activeStoryTimeSource !== "string" || + !runtimeGraph.historyState.activeStoryTimeSource + ) { + runtimeGraph.historyState.activeStoryTimeSource = "snapshot"; + } + } + } runtimeGraph.vectorIndexState = { ...(runtimeGraph.vectorIndexState || {}), ...(normalizedSnapshot.meta?.[BME_RUNTIME_VECTOR_META_KEY] || {}), @@ -442,6 +585,41 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { : Number(runtimeGraph.historyState.lastProcessedAssistantFloor); const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId); + if ( + normalizedGraph.knowledgeState && + typeof normalizedGraph.knowledgeState === "object" && + !Array.isArray(normalizedGraph.knowledgeState) + ) { + normalizedGraph.knowledgeState.activeOwnerKey = String( + normalizedGraph.historyState?.activeRecallOwnerKey || + rawKnowledgeState.activeOwnerKey || + "", + ).trim(); + } + if ( + normalizedGraph.regionState && + typeof normalizedGraph.regionState === "object" && + !Array.isArray(normalizedGraph.regionState) + ) { + normalizedGraph.regionState.activeRegion = String( + normalizedGraph.historyState?.activeRegion || + normalizedGraph.regionState.manualActiveRegion || + rawRegionState.activeRegion || + "", + ).trim(); + } + if ( + normalizedGraph.timelineState && + typeof normalizedGraph.timelineState === "object" && + !Array.isArray(normalizedGraph.timelineState) + ) { + normalizedGraph.timelineState.activeSegmentId = String( + normalizedGraph.historyState?.activeStorySegmentId || + normalizedGraph.timelineState.manualActiveSegmentId || + rawTimelineState.activeSegmentId || + "", + ).trim(); + } const historyState = normalizedGraph.historyState || {}; const vectorState = normalizedGraph.vectorIndexState || {}; const resolvedLastProcessedFloor = Number.isFinite( diff --git a/sync/bme-sync.js b/sync/bme-sync.js index 75ff3b7..b0f31b3 100644 --- a/sync/bme-sync.js +++ b/sync/bme-sync.js @@ -1,8 +1,12 @@ +import { BmeDatabase } from "./bme-db.js"; import { PROCESSED_MESSAGE_HASH_VERSION } from "../runtime/runtime-state.js"; const BME_SYNC_FILE_PREFIX = "ST-BME_sync_"; const BME_SYNC_FILE_SUFFIX = ".json"; const BME_SYNC_FILENAME_MAX_LENGTH = 180; +const BME_BACKUP_FILE_PREFIX = "ST-BME_backup_"; +const BME_BACKUP_MANIFEST_FILENAME = "ST-BME_BackupManifest.json"; +const BME_BACKUP_SCHEMA_VERSION = 1; export const BME_SYNC_DEVICE_ID_KEY = "st_bme_sync_device_id_v1"; export const BME_SYNC_UPLOAD_DEBOUNCE_MS = 2500; @@ -18,6 +22,11 @@ const RUNTIME_HISTORY_META_KEY = "runtimeHistoryState"; const RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; const RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; const RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult"; +const RUNTIME_SUMMARY_STATE_META_KEY = "runtimeSummaryState"; +const RUNTIME_MAINTENANCE_JOURNAL_META_KEY = "maintenanceJournal"; +const RUNTIME_KNOWLEDGE_STATE_META_KEY = "knowledgeState"; +const RUNTIME_REGION_STATE_META_KEY = "regionState"; +const RUNTIME_TIMELINE_STATE_META_KEY = "timelineState"; const RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; const RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; const RUNTIME_BATCH_JOURNAL_LIMIT = 96; @@ -26,6 +35,24 @@ function normalizeChatId(chatId) { return String(chatId ?? "").trim(); } +export function buildRestoreSafetyChatId(chatId) { + return `__restore_safety__${normalizeChatId(chatId)}`; +} + +function resolveCloudStorageMode(options = {}) { + const mode = + typeof options.getCloudStorageMode === "function" + ? options.getCloudStorageMode() + : options.cloudStorageMode; + return String(mode || "automatic").trim().toLowerCase() === "manual" + ? "manual" + : "automatic"; +} + +function isAutomaticCloudMode(options = {}) { + return resolveCloudStorageMode(options) === "automatic"; +} + function createStableFilenameHash(input = "") { let hash = 2166136261; const normalized = String(input ?? ""); @@ -49,6 +76,26 @@ function normalizeRemoteFilenameCandidate(fileName, fallbackValue = "ST-BME_sync return sanitized || fallbackValue; } +function buildBackupFilename(chatId) { + const normalizedChatId = normalizeChatId(chatId); + const hash = createStableFilenameHash(normalizedChatId || "unknown"); + const rawSlug = normalizeRemoteFilenameCandidate(normalizedChatId, ""); + const suffixPart = `-${hash}${BME_SYNC_FILE_SUFFIX}`; + const maxSlugLength = Math.max( + 0, + BME_SYNC_FILENAME_MAX_LENGTH - + BME_BACKUP_FILE_PREFIX.length - + suffixPart.length, + ); + const safeSlug = rawSlug + .slice(0, maxSlugLength) + .replace(/^[_.-]+|[_.-]+$/g, ""); + const core = safeSlug + ? `${BME_BACKUP_FILE_PREFIX}${safeSlug}-${hash}` + : `${BME_BACKUP_FILE_PREFIX}${hash}`; + return `${core}${BME_SYNC_FILE_SUFFIX}`; +} + function normalizeLegacyRemoteFilenameCandidate( fileName, fallbackValue = "ST-BME_sync_unknown.json", @@ -141,6 +188,48 @@ function toSerializableData(value, fallback = null) { } } +function normalizeBackupManifestEntry(rawEntry = {}) { + if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) { + return null; + } + + const filename = String(rawEntry.filename || "").trim(); + if ( + !filename + || !filename.startsWith(BME_BACKUP_FILE_PREFIX) + || !filename.endsWith(BME_SYNC_FILE_SUFFIX) + ) { + return null; + } + + return { + filename, + serverPath: String(rawEntry.serverPath || "").trim(), + chatId: normalizeChatId(rawEntry.chatId), + revision: normalizeRevision(rawEntry.revision), + lastModified: normalizeTimestamp(rawEntry.lastModified, 0), + backupTime: normalizeTimestamp(rawEntry.backupTime, 0), + size: normalizeNonNegativeInteger(rawEntry.size, 0), + schemaVersion: normalizeNonNegativeInteger(rawEntry.schemaVersion, 0), + }; +} + +function normalizeBackupEnvelope(payload = {}, chatId = "") { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + return null; + } + + const normalizedSnapshot = normalizeSyncSnapshot(payload.snapshot, chatId); + return { + kind: String(payload.kind || "st-bme-backup").trim().toLowerCase(), + version: normalizeNonNegativeInteger(payload.version, 0), + chatId: normalizeChatId(payload.chatId || normalizedSnapshot.meta?.chatId), + createdAt: normalizeTimestamp(payload.createdAt, 0), + sourceDeviceId: String(payload.sourceDeviceId || "").trim(), + snapshot: normalizedSnapshot, + }; +} + function getStorage() { const storage = globalThis.localStorage; if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") { @@ -215,6 +304,283 @@ function getFetch(options = {}) { return fetchImpl; } +async function getSafetyDb(chatId, options = {}) { + if (typeof options.getSafetyDb === "function") { + return await options.getSafetyDb(chatId); + } + + const db = new BmeDatabase(buildRestoreSafetyChatId(chatId)); + await db.open(); + return db; +} + +async function fetchBackupManifest(options = {}) { + const fetchImpl = getFetch(options); + const response = await fetchImpl( + `/user/files/${BME_BACKUP_MANIFEST_FILENAME}?t=${Date.now()}`, + { + method: "GET", + cache: "no-store", + }, + ); + if (response.status === 404) { + return []; + } + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `manifest read failed: HTTP ${response.status}`); + } + const rawPayload = await response.json(); + if (!Array.isArray(rawPayload)) { + throw new Error("backup manifest payload is not an array"); + } + return rawPayload.map(normalizeBackupManifestEntry).filter(Boolean); +} + +async function writeBackupManifest(entries = [], options = {}) { + const fetchImpl = getFetch(options); + const payload = JSON.stringify(entries, null, 2); + const response = await fetchImpl("/api/files/upload", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: BME_BACKUP_MANIFEST_FILENAME, + data: encodeBase64Utf8(payload), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } +} + +async function upsertBackupManifestEntry(entry, options = {}) { + const existingEntries = await fetchBackupManifest(options); + const filteredEntries = existingEntries.filter( + (candidate) => candidate.filename !== entry.filename, + ); + filteredEntries.push(normalizeBackupManifestEntry(entry)); + filteredEntries.sort((left, right) => right.backupTime - left.backupTime); + await writeBackupManifest(filteredEntries, options); +} + +async function readBackupEnvelope(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + const backupFilename = buildBackupFilename(normalizedChatId); + const fetchImpl = getFetch(options); + + try { + const response = await fetchImpl( + `/user/files/${encodeURIComponent(backupFilename)}?t=${Date.now()}`, + { + method: "GET", + cache: "no-store", + }, + ); + if (response.status === 404) { + return { + exists: false, + filename: backupFilename, + envelope: null, + reason: "not-found", + }; + } + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + const payload = await response.json(); + const envelope = normalizeBackupEnvelope(payload, normalizedChatId); + if (!envelope) { + return { + exists: false, + filename: backupFilename, + envelope: null, + reason: "invalid-backup", + }; + } + return { + exists: true, + filename: backupFilename, + envelope, + reason: "ok", + }; + } catch (error) { + return { + exists: false, + filename: backupFilename, + envelope: null, + reason: "backup-read-error", + error, + }; + } +} + +async function writeBackupEnvelope(envelope, chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + const filename = buildBackupFilename(normalizedChatId); + const fetchImpl = getFetch(options); + const payload = JSON.stringify(envelope, null, 2); + const response = await fetchImpl("/api/files/upload", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: filename, + data: encodeBase64Utf8(payload), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + const uploadResult = await response.json().catch(() => ({})); + return { + filename, + path: String(uploadResult?.path || `/user/files/${filename}`), + }; +} + +async function createRestoreSafetySnapshot(chatId, snapshot, options = {}) { + const safetyDb = await getSafetyDb(chatId, options); + const revision = normalizeRevision(snapshot?.meta?.revision); + try { + await safetyDb.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision, + markSyncDirty: false, + }); + await patchDbMeta(safetyDb, { + restoreSafetySnapshotExists: true, + restoreSafetySnapshotCreatedAt: Date.now(), + restoreSafetySnapshotChatId: normalizeChatId(chatId), + }); + } finally { + if (typeof options.getSafetyDb !== "function") { + await safetyDb.close().catch(() => {}); + } + } +} + +export async function getRestoreSafetySnapshotStatus(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + exists: false, + chatId: "", + createdAt: 0, + reason: "missing-chat-id", + }; + } + + try { + const safetyDb = await getSafetyDb(normalizedChatId, options); + try { + const exists = Boolean( + await readDbMeta(safetyDb, "restoreSafetySnapshotExists", false), + ); + const createdAt = normalizeTimestamp( + await readDbMeta(safetyDb, "restoreSafetySnapshotCreatedAt", 0), + 0, + ); + return { + exists, + chatId: normalizedChatId, + createdAt: exists ? createdAt : 0, + reason: exists ? "ok" : "not-found", + }; + } finally { + if (typeof options.getSafetyDb !== "function") { + await safetyDb.close().catch(() => {}); + } + } + } catch (error) { + return { + exists: false, + chatId: normalizedChatId, + createdAt: 0, + reason: "safety-status-error", + error, + }; + } +} + +export async function rollbackFromRestoreSafetySnapshot(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + restored: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const status = await getRestoreSafetySnapshotStatus(normalizedChatId, options); + if (!status.exists) { + return { + restored: false, + chatId: normalizedChatId, + reason: status.reason || "safety-not-found", + }; + } + + const safetyDb = await getSafetyDb(normalizedChatId, options); + try { + const snapshot = normalizeSyncSnapshot( + await safetyDb.exportSnapshot(), + normalizedChatId, + ); + const db = await getDb(normalizedChatId, options); + await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizeRevision(snapshot.meta?.revision), + markSyncDirty: false, + }); + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + syncDirty: true, + syncDirtyReason: "restore-safety-rollback", + lastBackupRollbackAt: Date.now(), + }); + await invokeSyncAppliedHook(options, { + chatId: normalizedChatId, + action: "restore-backup", + revision: normalizeRevision(snapshot.meta?.revision), + }); + return { + restored: true, + chatId: normalizedChatId, + revision: normalizeRevision(snapshot.meta?.revision), + createdAt: normalizeTimestamp(status.createdAt, 0), + }; + } finally { + if (typeof options.getSafetyDb !== "function") { + await safetyDb.close().catch(() => {}); + } + } + } catch (error) { + console.warn("[ST-BME] 回滚本地安全快照失败:", error); + return { + restored: false, + chatId: normalizedChatId, + reason: "restore-safety-rollback-error", + error, + }; + } +} + function getRequestHeadersSafe(options = {}) { if (typeof options.getRequestHeaders === "function") { try { @@ -919,6 +1285,22 @@ async function getDb(chatId, options = {}) { return db; } +async function readDbMeta(db, key, fallbackValue = null) { + if (!db || typeof key !== "string" || !key) return fallbackValue; + if (typeof db.getMeta === "function") { + return db.getMeta(key, fallbackValue); + } + if (db.meta instanceof Map) { + return db.meta.has(key) ? db.meta.get(key) : fallbackValue; + } + if (db.meta && typeof db.meta === "object" && !Array.isArray(db.meta)) { + return Object.prototype.hasOwnProperty.call(db.meta, key) + ? db.meta[key] + : fallbackValue; + } + return fallbackValue; +} + async function patchDbMeta(db, patch = {}) { if (!db || !patch || typeof patch !== "object") return; if (typeof db.patchMeta === "function") { @@ -1289,6 +1671,280 @@ export async function getRemoteStatus(chatId, options = {}) { }; } +export async function listServerBackups(options = {}) { + const entries = await fetchBackupManifest(options); + return { + entries, + filename: BME_BACKUP_MANIFEST_FILENAME, + }; +} + +export async function backupToServer(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + backedUp: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const db = await getDb(normalizedChatId, options); + const snapshot = normalizeSyncSnapshot( + await db.exportSnapshot(), + normalizedChatId, + ); + const nowMs = Date.now(); + const deviceId = getOrCreateDeviceId(); + + snapshot.meta.chatId = normalizedChatId; + snapshot.meta.deviceId = snapshot.meta.deviceId || deviceId; + snapshot.meta.lastModified = normalizeTimestamp( + snapshot.meta.lastModified, + nowMs, + ); + + const envelope = { + kind: "st-bme-backup", + version: BME_BACKUP_SCHEMA_VERSION, + chatId: normalizedChatId, + createdAt: nowMs, + sourceDeviceId: deviceId, + snapshot: { + meta: toSerializableData(snapshot.meta, {}), + nodes: toSerializableData(snapshot.nodes, []), + edges: toSerializableData(snapshot.edges, []), + tombstones: toSerializableData(snapshot.tombstones, []), + state: toSerializableData(snapshot.state, {}), + }, + }; + + const uploadResult = await writeBackupEnvelope( + envelope, + normalizedChatId, + options, + ); + const serializedEnvelope = JSON.stringify(envelope); + + try { + await upsertBackupManifestEntry( + { + filename: uploadResult.filename, + serverPath: String(uploadResult.path || "").replace(/^\/+/, ""), + chatId: normalizedChatId, + revision: normalizeRevision(snapshot.meta.revision), + lastModified: normalizeTimestamp(snapshot.meta.lastModified, nowMs), + backupTime: nowMs, + size: serializedEnvelope.length, + schemaVersion: BME_BACKUP_SCHEMA_VERSION, + }, + options, + ); + } catch (manifestError) { + return { + backedUp: false, + chatId: normalizedChatId, + filename: uploadResult.filename, + remotePath: uploadResult.path, + reason: "backup-manifest-error", + backupUploaded: true, + error: manifestError, + }; + } + + await patchDbMeta(db, { + deviceId, + syncDirty: false, + syncDirtyReason: "", + lastBackupUploadedAt: nowMs, + lastBackupFilename: uploadResult.filename, + }); + + return { + backedUp: true, + chatId: normalizedChatId, + filename: uploadResult.filename, + remotePath: uploadResult.path, + revision: normalizeRevision(snapshot.meta.revision), + backupTime: nowMs, + }; + } catch (error) { + console.warn("[ST-BME] 手动备份到云端失败:", error); + return { + backedUp: false, + chatId: normalizedChatId, + reason: "backup-error", + error, + }; + } +} + +export async function restoreFromServer(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + restored: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const db = await getDb(normalizedChatId, options); + const remoteResult = await readBackupEnvelope(normalizedChatId, options); + if (!remoteResult.exists || !remoteResult.envelope) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename || "", + reason: remoteResult.reason || "backup-missing", + }; + } + + const envelope = remoteResult.envelope; + if (envelope.version !== BME_BACKUP_SCHEMA_VERSION) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename, + reason: "backup-version-mismatch", + }; + } + + if (envelope.chatId !== normalizedChatId) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename, + reason: "backup-chat-id-mismatch", + }; + } + + const snapshot = normalizeSyncSnapshot(envelope.snapshot, normalizedChatId); + if (normalizeChatId(snapshot.meta?.chatId) !== normalizedChatId) { + return { + restored: false, + chatId: normalizedChatId, + filename: remoteResult.filename, + reason: "snapshot-chat-id-mismatch", + }; + } + + const localSnapshot = normalizeSyncSnapshot( + await db.exportSnapshot(), + normalizedChatId, + ); + await createRestoreSafetySnapshot( + normalizedChatId, + localSnapshot, + options, + ); + + await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizeRevision(snapshot.meta.revision), + markSyncDirty: false, + }); + + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + syncDirty: false, + syncDirtyReason: "", + lastBackupRestoredAt: Date.now(), + lastBackupFilename: + remoteResult.filename || buildBackupFilename(normalizedChatId), + }); + + await invokeSyncAppliedHook(options, { + chatId: normalizedChatId, + action: "restore-backup", + revision: normalizeRevision(snapshot.meta.revision), + }); + + return { + restored: true, + chatId: normalizedChatId, + filename: remoteResult.filename, + revision: normalizeRevision(snapshot.meta.revision), + backupTime: normalizeTimestamp(envelope.createdAt, 0), + }; + } catch (error) { + console.warn("[ST-BME] 从云端恢复备份失败:", error); + return { + restored: false, + chatId: normalizedChatId, + reason: "restore-error", + error, + }; + } +} + +export async function deleteServerBackup(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + deleted: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + const filename = buildBackupFilename(normalizedChatId); + const fetchImpl = getFetch(options); + + try { + const response = await fetchImpl("/api/files/delete", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + path: `/user/files/${filename}`, + }), + }); + + if (!response.ok && response.status !== 404) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + try { + const existingEntries = await fetchBackupManifest(options); + const filteredEntries = existingEntries.filter( + (entry) => entry.filename !== filename, + ); + await writeBackupManifest(filteredEntries, options); + } catch (manifestError) { + return { + deleted: false, + chatId: normalizedChatId, + filename, + reason: "delete-backup-manifest-error", + backupDeleted: true, + error: manifestError, + }; + } + + return { + deleted: true, + chatId: normalizedChatId, + filename, + }; + } catch (error) { + console.warn("[ST-BME] 删除服务端备份失败:", error); + return { + deleted: false, + chatId: normalizedChatId, + filename, + reason: "delete-backup-error", + error, + }; + } +} + export async function upload(chatId, options = {}) { const normalizedChatId = normalizeChatId(chatId); if (!normalizedChatId) { @@ -1503,6 +2159,61 @@ export function mergeSnapshots(localSnapshot, remoteSnapshot, options = {}) { ); const mergedLastRecallResult = mergeRuntimeLastRecallResult(local, remote); + const mergedSummaryState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_SUMMARY_STATE_META_KEY], + remote.meta?.[RUNTIME_SUMMARY_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_SUMMARY_STATE_META_KEY] ?? + local.meta?.[RUNTIME_SUMMARY_STATE_META_KEY] ?? + {}, + {}, + ); + const mergedMaintenanceJournal = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY], + remote.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] ?? + local.meta?.[RUNTIME_MAINTENANCE_JOURNAL_META_KEY] ?? + [], + [], + ); + const mergedKnowledgeState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY], + remote.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY] ?? + local.meta?.[RUNTIME_KNOWLEDGE_STATE_META_KEY] ?? + {}, + {}, + ); + const mergedRegionState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_REGION_STATE_META_KEY], + remote.meta?.[RUNTIME_REGION_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_REGION_STATE_META_KEY] ?? + local.meta?.[RUNTIME_REGION_STATE_META_KEY] ?? + {}, + {}, + ); + const mergedTimelineState = + chooseNewerRuntimePayload( + local.meta?.[RUNTIME_TIMELINE_STATE_META_KEY], + remote.meta?.[RUNTIME_TIMELINE_STATE_META_KEY], + ) ?? + toSerializableData( + remote.meta?.[RUNTIME_TIMELINE_STATE_META_KEY] ?? + local.meta?.[RUNTIME_TIMELINE_STATE_META_KEY] ?? + {}, + {}, + ); const mergedLastProcessedSeq = Math.max( normalizeNonNegativeInteger(local.meta?.[RUNTIME_LAST_PROCESSED_SEQ_META_KEY], 0), @@ -1523,6 +2234,11 @@ export function mergeSnapshots(localSnapshot, remoteSnapshot, options = {}) { [RUNTIME_VECTOR_META_KEY]: mergedVectorState, [RUNTIME_BATCH_JOURNAL_META_KEY]: mergedBatchJournal, [RUNTIME_LAST_RECALL_META_KEY]: mergedLastRecallResult, + [RUNTIME_SUMMARY_STATE_META_KEY]: mergedSummaryState, + [RUNTIME_MAINTENANCE_JOURNAL_META_KEY]: mergedMaintenanceJournal, + [RUNTIME_KNOWLEDGE_STATE_META_KEY]: mergedKnowledgeState, + [RUNTIME_REGION_STATE_META_KEY]: mergedRegionState, + [RUNTIME_TIMELINE_STATE_META_KEY]: mergedTimelineState, [RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: mergedLastProcessedSeq, [RUNTIME_GRAPH_VERSION_META_KEY]: mergedRuntimeGraphVersion, schemaVersion: Math.max( @@ -1565,6 +2281,16 @@ export async function syncNow(chatId, options = {}) { }; } + if (!isAutomaticCloudMode(options)) { + return { + synced: false, + chatId: normalizedChatId, + action: "manual-probe", + reason: "manual-cloud-mode", + remoteStatus: null, + }; + } + return await withChatSyncLock(normalizedChatId, async () => { const db = await getDb(normalizedChatId, options); const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId); @@ -1680,6 +2406,14 @@ export function scheduleUpload(chatId, options = {}) { }; } + if (!isAutomaticCloudMode(options)) { + return { + scheduled: false, + chatId: normalizedChatId, + reason: "manual-cloud-mode", + }; + } + const debounceMs = Number.isFinite(Number(options.debounceMs)) ? Math.max(0, Math.floor(Number(options.debounceMs))) : BME_SYNC_UPLOAD_DEBOUNCE_MS; @@ -1713,6 +2447,16 @@ export function autoSyncOnChatChange(chatId, options = {}) { }); } + if (!isAutomaticCloudMode(options)) { + return Promise.resolve({ + synced: false, + chatId: normalizedChatId, + action: "manual-probe", + reason: "manual-cloud-mode", + remoteStatus: null, + }); + } + return syncNow(normalizedChatId, { ...options, trigger: options.trigger || "chat-change", @@ -1745,6 +2489,7 @@ export function autoSyncOnVisibility(options = {}) { const chatId = normalizeChatId(chatIdResolver()); if (!chatId) return; + if (!isAutomaticCloudMode(options)) return; autoSyncOnChatChange(chatId, { ...options, diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index e78c88b..efc451f 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -60,6 +60,7 @@ assert.equal(defaultSettings.enableReflection, true); assert.equal(defaultSettings.consolidationAutoMinNewNodes, 2); assert.equal(defaultSettings.enableAutoCompression, true); assert.equal(defaultSettings.compressionEveryN, 10); +assert.equal(defaultSettings.cloudStorageMode, "automatic"); assert.equal(defaultSettings.worldInfoFilterMode, "default"); assert.equal(defaultSettings.worldInfoFilterCustomKeywords, ""); assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false); @@ -77,6 +78,7 @@ assert.equal(migratedSettings.consolidationAutoMinNewNodes, 7); assert.equal(migratedSettings.extractAutoDelayLatestAssistant, true); assert.equal(migratedSettings.enableAutoCompression, true); assert.equal(migratedSettings.compressionEveryN, 10); +assert.equal(migratedSettings.cloudStorageMode, "automatic"); assert.equal("maintenanceAutoMinNewNodes" in migratedSettings, false); const migratedLegacyCompressionDisabled = mergePersistedSettings({ diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs index 2346a60..b25b9c0 100644 --- a/tests/indexeddb-persistence.mjs +++ b/tests/indexeddb-persistence.mjs @@ -436,6 +436,49 @@ async function testGraphSnapshotConverters() { processedRange: [8, 9], }, ]; + graph.maintenanceJournal = [ + { + id: "maintenance-1", + action: "compress", + updatedAt: 123, + }, + ]; + graph.knowledgeState = { + activeOwnerKey: "owner:hero", + owners: { + "owner:hero": { + ownerKey: "owner:hero", + displayName: "Hero", + }, + }, + }; + graph.regionState = { + activeRegion: "camp", + knownRegions: { + camp: { + regionId: "camp", + displayName: "Camp", + }, + }, + }; + graph.timelineState = { + activeSegmentId: "segment-1", + segments: [ + { + id: "segment-1", + label: "Night 1", + }, + ], + }; + graph.summaryState = { + updatedAt: 456, + entries: [ + { + id: "summary-1", + text: "Summary text", + }, + ], + }; graph.nodes.push({ id: "node-converter", type: "event", @@ -461,6 +504,11 @@ async function testGraphSnapshotConverters() { assert.equal(rebuilt.nodes.length, 1); assert.equal(rebuilt.nodes[0].id, "node-converter"); assert.equal(rebuilt.vectorIndexState.hashToNodeId["vec-hash"], "node-converter"); + assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1"); + assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero"); + assert.equal(rebuilt.regionState.activeRegion, "camp"); + assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1"); + assert.equal(rebuilt.summaryState.entries[0].id, "summary-1"); } async function main() { diff --git a/tests/indexeddb-sync.mjs b/tests/indexeddb-sync.mjs index b03a6d5..5fcc96f 100644 --- a/tests/indexeddb-sync.mjs +++ b/tests/indexeddb-sync.mjs @@ -6,11 +6,18 @@ import { __testOnlyDecodeBase64Utf8, autoSyncOnChatChange, autoSyncOnVisibility, + backupToServer, + buildRestoreSafetyChatId, deleteRemoteSyncFile, + deleteServerBackup, + getRestoreSafetySnapshotStatus, getOrCreateDeviceId, getRemoteStatus, download, + listServerBackups, mergeSnapshots, + rollbackFromRestoreSafetySnapshot, + restoreFromServer, scheduleUpload, syncNow, upload, @@ -470,6 +477,11 @@ async function testMergeRuntimeMetaPolicies() { { id: "journal-drop-local", processedRange: [4, 5], createdAt: 110 }, ], runtimeLastRecallResult: { nodes: ["local-only"] }, + runtimeSummaryState: { updatedAt: 500, frontier: ["local-summary"] }, + maintenanceJournal: [{ id: "maintenance-local", updatedAt: 600 }], + knowledgeState: { updatedAt: 700, activeOwnerKey: "local-owner" }, + regionState: { updatedAt: 800, activeRegion: "local-region" }, + timelineState: { updatedAt: 900, activeSegmentId: "local-segment" }, runtimeLastProcessedSeq: 2, runtimeGraphVersion: 10, }, @@ -519,6 +531,11 @@ async function testMergeRuntimeMetaPolicies() { { id: "journal-drop-remote", processedRange: [3, 4], createdAt: 220 }, ], runtimeLastRecallResult: { nodes: ["remote-only"] }, + runtimeSummaryState: { updatedAt: 1500, frontier: ["remote-summary"] }, + maintenanceJournal: [{ id: "maintenance-remote", updatedAt: 1600 }], + knowledgeState: { updatedAt: 1700, activeOwnerKey: "remote-owner" }, + regionState: { updatedAt: 1800, activeRegion: "remote-region" }, + timelineState: { updatedAt: 1900, activeSegmentId: "remote-segment" }, runtimeLastProcessedSeq: 9, runtimeGraphVersion: 7, }, @@ -553,10 +570,242 @@ async function testMergeRuntimeMetaPolicies() { assert.equal(merged.meta.runtimeBatchJournal[0].id, "journal-shared"); assert.deepEqual(merged.meta.runtimeBatchJournal[0].processedRange, [0, 3]); assert.equal(merged.meta.runtimeLastRecallResult, null); + assert.equal(merged.meta.runtimeSummaryState.frontier[0], "remote-summary"); + assert.equal(merged.meta.maintenanceJournal[0].id, "maintenance-remote"); + assert.equal(merged.meta.knowledgeState.activeOwnerKey, "remote-owner"); + assert.equal(merged.meta.regionState.activeRegion, "remote-region"); + assert.equal(merged.meta.timelineState.activeSegmentId, "remote-segment"); assert.equal(merged.meta.runtimeLastProcessedSeq, 9); assert.equal(merged.meta.runtimeGraphVersion, 11); } +async function testManualCloudModeGuards() { + const { fetch, logs } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + dbByChatId.set("chat-manual", new FakeDb("chat-manual")); + + const runtime = { + ...buildRuntimeOptions({ dbByChatId, fetch }), + cloudStorageMode: "manual", + }; + + const scheduleResult = scheduleUpload("chat-manual", runtime); + assert.equal(scheduleResult.scheduled, false); + assert.equal(scheduleResult.reason, "manual-cloud-mode"); + + const syncResult = await syncNow("chat-manual", runtime); + assert.equal(syncResult.action, "manual-probe"); + assert.equal(logs.uploadCalls, 0); + + const chatChangeResult = await autoSyncOnChatChange("chat-manual", runtime); + assert.equal(chatChangeResult.action, "manual-probe"); + assert.equal(chatChangeResult.remoteStatus, null); + assert.equal(logs.getCalls, 0); + assert.equal(logs.uploadCalls, 0); +} + +async function testManualBackupAndRestoreFlow() { + const { fetch, remoteFiles } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const db = new FakeDb("chat-backup-flow", { + meta: { + schemaVersion: 1, + chatId: "chat-backup-flow", + revision: 8, + lastModified: 80, + deviceId: "", + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "local-node", updatedAt: 80 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 4, + extractionCount: 2, + }, + }); + db.meta.set("syncDirty", true); + dbByChatId.set("chat-backup-flow", db); + + const safetyDb = new FakeDb("__restore_safety__chat-backup-flow"); + const hookCalls = []; + const runtime = { + ...buildRuntimeOptions({ dbByChatId, fetch }), + getSafetyDb: async () => safetyDb, + onSyncApplied: async (payload) => hookCalls.push({ ...payload }), + }; + + const backupResult = await backupToServer("chat-backup-flow", runtime); + assert.equal(backupResult.backedUp, true); + assert.equal(db.meta.get("syncDirty"), false); + assert.ok(Number(db.meta.get("lastBackupUploadedAt")) > 0); + assert.ok(String(db.meta.get("lastBackupFilename") || "").startsWith("ST-BME_backup_")); + + const manifestResult = await listServerBackups(runtime); + assert.equal(manifestResult.entries.length, 1); + assert.equal(manifestResult.entries[0].chatId, "chat-backup-flow"); + + db.snapshot = { + meta: { + schemaVersion: 1, + chatId: "chat-backup-flow", + revision: 1, + lastModified: 10, + deviceId: "", + nodeCount: 0, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: -1, + extractionCount: 0, + }, + }; + + const restoreResult = await restoreFromServer("chat-backup-flow", runtime); + assert.equal(restoreResult.restored, true); + assert.equal(db.snapshot.nodes[0].id, "local-node"); + assert.ok(Number(db.meta.get("lastBackupRestoredAt")) > 0); + const safetyStatus = await getRestoreSafetySnapshotStatus( + "chat-backup-flow", + runtime, + ); + assert.equal(safetyStatus.exists, true); + assert.equal(safetyDb.lastImportPayload.meta.revision, 1); + assert.deepEqual( + hookCalls.map((item) => item.action), + ["restore-backup"], + ); + + db.snapshot = { + meta: { + schemaVersion: 1, + chatId: "chat-backup-flow", + revision: 99, + lastModified: 999, + deviceId: "", + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "broken-node", updatedAt: 999 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 88, + extractionCount: 9, + }, + }; + + const rollbackResult = await rollbackFromRestoreSafetySnapshot( + "chat-backup-flow", + runtime, + ); + assert.equal(rollbackResult.restored, true); + assert.equal(db.snapshot.meta.revision, 1); + assert.equal(db.snapshot.nodes.length, 0); + assert.equal(db.meta.get("syncDirty"), true); + assert.ok(Number(db.meta.get("lastBackupRollbackAt")) > 0); + + const deleteResult = await deleteServerBackup("chat-backup-flow", runtime); + assert.equal(deleteResult.deleted, true); + const manifestAfterDelete = await listServerBackups(runtime); + assert.equal(manifestAfterDelete.entries.length, 0); + assert.equal( + Array.from(remoteFiles.keys()).some((key) => key.startsWith("ST-BME_backup_")), + false, + ); +} + +async function testBackupManifestReadFailureDoesNotOverwriteManifest() { + const { fetch, remoteFiles } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const db = new FakeDb("chat-manifest-guard", { + meta: { + schemaVersion: 1, + chatId: "chat-manifest-guard", + revision: 3, + lastModified: 30, + deviceId: "", + nodeCount: 1, + edgeCount: 0, + tombstoneCount: 0, + }, + nodes: [{ id: "node-manifest", updatedAt: 30 }], + edges: [], + tombstones: [], + state: { + lastProcessedFloor: 2, + extractionCount: 1, + }, + }); + dbByChatId.set("chat-manifest-guard", db); + + remoteFiles.set("ST-BME_BackupManifest.json", [ + { + filename: "ST-BME_backup_existing-a.json", + serverPath: "user/files/ST-BME_backup_existing-a.json", + chatId: "chat-a", + revision: 1, + lastModified: 10, + backupTime: 10, + size: 100, + schemaVersion: 1, + }, + ]); + + let failManifestRead = true; + const guardedFetch = async (url, options = {}) => { + if ( + failManifestRead + && String(options?.method || "GET").toUpperCase() === "GET" + && String(url).startsWith("/user/files/ST-BME_BackupManifest.json") + ) { + return createJsonResponse(500, "manifest read failed"); + } + return await fetch(url, options); + }; + + const runtime = buildRuntimeOptions({ dbByChatId, fetch: guardedFetch }); + const backupResult = await backupToServer("chat-manifest-guard", runtime); + assert.equal(backupResult.backedUp, false); + assert.equal(backupResult.reason, "backup-manifest-error"); + assert.equal(backupResult.backupUploaded, true); + + failManifestRead = false; + const manifestResult = await listServerBackups(runtime); + assert.equal(manifestResult.entries.length, 1); + assert.equal(manifestResult.entries[0].chatId, "chat-a"); +} + +async function testRestoreValidationDoesNotCreateSafetySnapshot() { + const { fetch } = createMockFetchEnvironment(); + const dbByChatId = new Map(); + const db = new FakeDb("chat-no-backup"); + const safetyDb = new FakeDb(buildRestoreSafetyChatId("chat-no-backup")); + dbByChatId.set("chat-no-backup", db); + + const runtime = { + ...buildRuntimeOptions({ dbByChatId, fetch }), + getSafetyDb: async () => safetyDb, + }; + + const restoreResult = await restoreFromServer("chat-no-backup", runtime); + assert.equal(restoreResult.restored, false); + assert.equal(restoreResult.reason, "not-found"); + + const safetyStatus = await getRestoreSafetySnapshotStatus( + "chat-no-backup", + runtime, + ); + assert.equal(safetyStatus.exists, false); +} + async function testSyncNowLockAndAutoSync() { const { fetch, remoteFiles, logs } = createMockFetchEnvironment(); const dbByChatId = new Map(); @@ -826,6 +1075,10 @@ async function main() { await testLegacyRemoteFilenameFallbackAndReuse(); await testMergeRules(); await testMergeRuntimeMetaPolicies(); + await testManualCloudModeGuards(); + await testManualBackupAndRestoreFlow(); + await testBackupManifestReadFailureDoesNotOverwriteManifest(); + await testRestoreValidationDoesNotCreateSafetySnapshot(); await testSyncNowLockAndAutoSync(); await testDeleteRemoteSyncFile(); await testDeleteRemoteSyncFileFallsBackToLegacyFilename(); diff --git a/ui/panel.html b/ui/panel.html index da12d4f..5c764d7 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -1072,6 +1072,74 @@ +
+
+
+
云端存储模式
+
+ 自动模式沿用当前镜像同步;手动模式停止自动云端写入,改为按当前聊天手动备份与恢复。 +
+
+
+
+ + +
+
+ 自动储存会继续按当前镜像逻辑同步;手动储存只保留本地写入,需要你主动备份和恢复。 +
+ + +
+
diff --git a/ui/panel.js b/ui/panel.js index 499c470..7151f3d 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -1,4 +1,4 @@ -// ST-BME: 操控面板交互逻辑 +// ST-BME: 闂傚倸鍊烽懗鍫曞箠閹剧粯鍊舵繝闈涚墢閻捇鏌i姀鐘典粵闁哄棙绮撻弻娑㈩敃閿濆棛顦ㄥ銈冨劚閻楁捇寮婚弴锛勭杸濠电姴鍊搁埛澶岀磽娴h棄鐓愮憸鏉垮暣濠€浣糕攽閻樻瑥鍟版禒銏ゆ煃瑜滈崜姘跺箰妤e啯鍤嶉弶鍫涘妼椤曢亶鎮楀☉娆樼劷闁告梻鏁诲娲传閸曨偒浠肩紓浣虹帛鐢偤寮? import { GraphRenderer } from "./graph-renderer.js"; import { getNodeDisplayName } from "../graph/node-labels.js"; @@ -74,9 +74,9 @@ function getDefaultPromptText(taskType = "") { } const TASK_PROFILE_TABS = [ - { id: "generation", label: "生成参数" }, - { id: "prompt", label: "Prompt 编排" }, - { id: "debug", label: "调试预览" }, + { id: "generation", label: "闂傚倸鍊烽悞锕傛儑瑜版帒鍨傚┑鐘宠壘缁愭鏌熼悧鍫熺凡闁搞劌鍊归幈銊ノ熼幐搴c€愰梺娲诲幗椤ㄥ﹪寮诲☉姘勃闁告挆鈧慨鍥р攽? }, + { id: "prompt", label: "Prompt 缂傚倸鍊搁崐鎼佸磹閹间礁纾圭憸鐗堝笚閸嬪淇婇妶鍛殲闁? }, + { id: "debug", label: "闂傚倷娴囧畷鍨叏閹绢噮鏁勯柛娑欐綑閻ゎ噣鏌熼幆鏉啃撻柛搴★攻閵囧嫰寮介妸銉ユ灆缂備焦鍞荤紞渚€寮诲☉銏犲嵆闁靛鍎查悵顔尖攽? }, ]; const TASK_PROFILE_ROLE_OPTIONS = [ @@ -86,15 +86,15 @@ const TASK_PROFILE_ROLE_OPTIONS = [ ]; const TASK_PROFILE_INJECTION_OPTIONS = [ - { value: "append", label: "追加" }, - { value: "prepend", label: "前置" }, - { value: "relative", label: "相对" }, + { value: "append", label: "闂傚倷绀侀幖顐λ囬锕€鐤鹃柣鎰棘濞戙垹绀嬫い鎺嶇瀵? }, + { value: "prepend", label: "闂傚倸鍊风粈渚€骞夐敓鐘茬闁告縿鍎抽惌鎾绘煕椤愶絾绀冮柛? }, + { value: "relative", label: "闂傚倸鍊烽懗鍫曞磿閻㈢鐤炬繝闈涱儌閳ь剨绠撳畷濂稿Ψ椤旇姤娅? }, ]; const TASK_PROFILE_BOOLEAN_OPTIONS = [ - { value: "", label: "跟随默认" }, - { value: "true", label: "开启" }, - { value: "false", label: "关闭" }, + { value: "", label: "闂傚倷娴囧畷鍨叏閹€鏋嶉柨婵嗩槸缁愭鏌″畵顔瑰亾闁哄妫冮弻鏇$疀婵犲喚娼戝┑鐐存崄閸╂牗绌辨繝鍥舵晬婵炴垵宕崝宀勬⒑? }, + { value: "true", label: "闂備浇顕х€涒晠顢欓弽顓炵獥闁圭儤顨呯壕濠氭煙閸撗呭笡闁? }, + { value: "false", label: "闂傚倸鍊烽懗鍫曗€﹂崼銏″床闁瑰鍋熺粻鎯р攽閻樿弓杩? }, ]; const GRAPH_WRITE_ACTION_IDS = [ @@ -124,62 +124,62 @@ const GRAPH_WRITE_ACTION_IDS = [ const TASK_PROFILE_GENERATION_GROUPS = [ { - title: "API 配置", + title: "API 闂傚倸鍊搁崐鐑芥倿閿曗偓椤灝螣閼测晝鐓嬮梺鍓插亝濞叉﹢宕?, fields: [ { key: "llm_preset", - label: "API 配置模板", + label: "API 闂傚倸鍊搁崐鐑芥倿閿曗偓椤灝螣閼测晝鐓嬮梺鍓插亝濞叉﹢宕戦鍫熺厱闁斥晛鍟ㄦ禍妤呮煟閺冨倸甯堕柣鎺撴そ閺屾盯骞囬妸锔界彇闂?, type: "llm_preset", defaultValue: "", - help: "留空表示跟随当前 API;选中已保存模板后,这个任务会独立使用那套 URL / Key / Model。", + help: "闂傚倸鍊峰鎺旀椤旀儳绶ゅΔ锝呭暞閸嬶紕鎲搁弮鍫濇槬闁绘劕鎼崘鈧銈嗗姧缁茶姤绂掗幒妤佲拺闂傚牊涓瑰☉娆愬濡炲閰i埞蹇涙⒒娴h棄鍚瑰┑顔藉劤閳诲秹鏁愭径濠勭崶闂佸搫璇為埀顒勫几閺冨牊鐓忛煫鍥ュ劤缁佸嘲霉濠婂啰绉洪柡宀€鍠栭獮鍡氼槻闁哄棜椴搁妵?API闂傚倸鍊烽悞锔锯偓绗涘懐鐭欓柟鐑橆殕閸庡孩銇勯弽顐粶闁绘帒鐏氶妵鍕箳閸℃ぞ澹曟俊鐐€х€靛矂宕抽敐澶婄疇婵炲棙鎸哥粻锝嗙節閸偄濮囬柡鍛█濮婄粯绻濇惔鈥茶埅闂佸憡锚婢х晫鍒掔紒妯碱浄閻庯綆鍋勯埀顒€鐏氱换娑㈠箣閻戝棔绱楅梺纭呮彧缁蹭粙寮抽悙鐑樷拻濞达絽鎲¢幆鍫熴亜閿旇鐏﹂柟顔矫埞鎴犫偓锝庝簽閻e搫鈹戞幊閸婃洟骞婅箛娑樼柧妞ゆ帒鍊甸崑鎾诲礂婢跺﹣澹曢梻浣告啞閸旓附绂嶉悙渚晩闁归偊鍘剧粻楣冩煙鐎电浠﹂柟顖氬闇夋繝濠傚閹冲洦顨ラ悙鏉戠伌濠殿喒鍋撻梺闈涚墕閹虫劙藝椤曗偓濮婃椽鎮欓挊澶婂Г闁诲繐绻戦悷褏鍒掗敐鍛傛棃宕ㄩ鎯у箺婵犵數鍋涘Λ娆撴晪婵犫拃鍌氬祮闁哄矉绲介埞鎴﹀炊閳规儳顫岄梻浣烘嚀閸㈡煡骞婇幇鏉跨闁告洦鍨版儫闂佹寧妫佹慨銈夘敇瑜版帗鈷掗柛灞剧懅鐠愪即鏌涘▎蹇曠鐎垫澘锕ョ粋鎺斺偓锝冨妷閸?URL / Key / Model闂?, }, ], }, { - title: "基础生成参数", + title: "闂傚倸鍊烽懗鍓佹兜閸洖鐤鹃柣鎰ゴ閺嬪秹鏌ㄥ┑鍡╂Ф闁逞屽厸缁舵艾鐣烽妸褉鍋撳☉娅亝绂掑ú顏呪拺缂備焦蓱閳锋帗銇勯鐐靛ⅵ闁诡喗锕㈠畷鍫曨敆娴e搫骞堟繝鐢靛仜濡鎹㈤幇鏉跨劦妞ゆ巻鍋撶紓宥咃工椤?, fields: [ - { key: "max_context_tokens", label: "最大上下文 Tokens", type: "number", defaultValue: "" }, - { key: "max_completion_tokens", label: "最大补全 Tokens", type: "number", defaultValue: "" }, - { key: "reply_count", label: "回复次数", type: "number", defaultValue: 1 }, - { key: "stream", label: "流式输出", type: "tri_bool", defaultValue: false }, - { key: "temperature", label: "温度 (Temperature)", type: "range", min: 0, max: 2, step: 0.01, defaultValue: 1 }, + { key: "max_context_tokens", label: "闂傚倸鍊风粈渚€骞栭锔藉亱闁告劦鍠栫壕濠氭煙閻愵剙澧柣鏂挎閺屾盯顢曢姀鈽嗘闂佺锕ら…鐑藉箖閻戣棄绠涙い鎴eГ椤秹姊洪崷顓炰壕闁告挻宀稿畷鏇㈠箛閻楀牏鍘?Tokens", type: "number", defaultValue: "" }, + { key: "max_completion_tokens", label: "闂傚倸鍊风粈渚€骞栭锔藉亱闁告劦鍠栫壕濠氭煙閻愵剙澧柣鏂挎閺屾盯顢曢姀鈽嗘闂佸搫鑻幊蹇擃嚗閸曨垰绠涙い鎺戝亞濡?Tokens", type: "number", defaultValue: "" }, + { key: "reply_count", label: "闂傚倸鍊烽悞锕傚箖閸洖纾块柟鎯版绾剧粯绻涢幋鏃€鍤嶉柛銉墮缁犵敻鏌熼崫鍕棡闁诲骸顭峰娲棘閵夛附鐝旈梺鍝ュ櫏閸ㄤ即鍩?, type: "number", defaultValue: 1 }, + { key: "stream", label: "婵犵數濮烽弫鎼佸磻閻旂儤宕叉繝闈涚墢閻棗霉閿濆洤鍔嬫い顐f礋閺屽秹鍩℃担鍛婃缂傚倸绉村ú顓㈠蓟閻旂厧绠氱憸宥夊汲鏉堛劍鍙?, type: "tri_bool", defaultValue: false }, + { key: "temperature", label: "婵犵數濮烽弫鎼佸磻閻愬搫绠伴柟缁㈠枛閻ょ偓绻涢幋鐐茬劰闁?(Temperature)", type: "range", min: 0, max: 2, step: 0.01, defaultValue: 1 }, { key: "top_p", label: "Top P", type: "range", min: 0, max: 1, step: 0.01, defaultValue: 1 }, { key: "top_k", label: "Top K", type: "number", defaultValue: 0 }, { key: "top_a", label: "Top A", type: "range", min: 0, max: 1, step: 0.01, defaultValue: 0 }, { key: "min_p", label: "Min P", type: "range", min: 0, max: 1, step: 0.01, defaultValue: 0 }, - { key: "seed", label: "随机种子 (Seed)", type: "number", defaultValue: "" }, + { key: "seed", label: "闂傚倸鍊搁崐鎼佸磹閹间礁绠犻幖杈剧稻瀹曟煡鏌熺€涙濡囬柡鈧敃鍌涚厓鐟滄粓宕滃☉姘潟闁圭儤鎸哥欢鐐测攽閻樻彃鐝旈柕蹇嬪€栭悡?(Seed)", type: "number", defaultValue: "" }, ], }, { - title: "惩罚参数", + title: "闂傚倸鍊峰ù鍥敋閺嶎厼鍨傛い蹇撶墕閻ょ偓绻濋棃娑氬ⅱ缂佲偓婵犲洦鐓欓柣鎴烇供濞堟棃鏌i鐕佹疁闁哄本绋撴禒锕傚礈瑜夋慨鍥р攽?, fields: [ - { key: "frequency_penalty", label: "频率惩罚", type: "range", min: -2, max: 2, step: 0.01, defaultValue: 0 }, - { key: "presence_penalty", label: "存在惩罚", type: "range", min: -2, max: 2, step: 0.01, defaultValue: 0 }, - { key: "repetition_penalty", label: "重复惩罚", type: "range", min: 0, max: 3, step: 0.01, defaultValue: 1 }, + { key: "frequency_penalty", label: "濠电姷顣藉Σ鍛村磻閸℃ɑ娅犳俊銈呮噹閸ㄥ倿鏌i悢鐓庝喊闁哥喎鎳庨埞鎴﹀磼濠婂海鍔哥紓浣哄У瀹€鎼佸蓟瀹ュ浼犻柛鏇ㄥ亝濞堝墎绱?, type: "range", min: -2, max: 2, step: 0.01, defaultValue: 0 }, + { key: "presence_penalty", label: "闂傚倷娴囬褏鈧稈鏅濈划娆撳箳濡や焦娅斿┑鐘垫暩婵參宕戦幘娣簻闊洦鎸炬晶鏇犵磼閻樺啿鍝洪柡灞界Ч瀹曨偊宕熼锝嗩啀缂?, type: "range", min: -2, max: 2, step: 0.01, defaultValue: 0 }, + { key: "repetition_penalty", label: "闂傚倸鍊搁崐鐑芥倿閿曚降浜归柛鎰典簽閻捇鏌熺紒銏犳殙闁搞儺鍓欓拑鐔兼煏婢跺牆鍔ら柣锝夌畺濮婃椽宕橀崣澶嬪創闂佸摜鍠愭竟鍡欏垝?, type: "range", min: 0, max: 3, step: 0.01, defaultValue: 1 }, ], }, { - title: "行为参数", + title: "闂傚倷娴囧畷鐢稿磻閻愮數鐭欓柟瀵稿仧闂勫嫰鏌¢崘銊モ偓鍦偓姘煼閺岋綁寮崒姘粯闂佹椿鍘介〃濠囧蓟濞戞矮娌柛鎾椻偓婵洤鈹?, fields: [ - { key: "squash_system_messages", label: "合并系统消息", type: "tri_bool", defaultValue: false }, + { key: "squash_system_messages", label: "闂傚倸鍊风粈渚€骞夐敓鐘冲殞濡わ絽鍟崑瀣煕閳╁啰鈯曢柛銈嗗姍閺岋綁寮幐搴㈠創闁诲骸鐏氶悡锟犲蓟閵堝棙鍙忛柟閭﹀厴閸嬫挸螖閸涱厽妲梺缁樏壕顓犵不妤e啯鐓欓悗娑欘焽缁犳ê顭胯缁嬫垿婀?, type: "tri_bool", defaultValue: false }, { key: "reasoning_effort", - label: "推理强度", + label: "闂傚倸鍊峰ù鍥綖婢跺顩插ù鐘差儏缁€澶愬箹濞n剙濡肩紒鐘崇墪閳规垿鎮╅幓鎺嶇敖闂佹悶鍊栧ú鐔煎蓟濞戞ǚ鏀介柛鈩冾殢娴犵厧顪?, type: "enum", options: [ - { value: "", label: "跟随默认" }, - { value: "minimal", label: "最低" }, - { value: "low", label: "低" }, - { value: "medium", label: "中" }, - { value: "high", label: "高" }, + { value: "", label: "闂傚倷娴囧畷鍨叏閹€鏋嶉柨婵嗩槸缁愭鏌″畵顔瑰亾闁哄妫冮弻鏇$疀婵犲喚娼戝┑鐐存崄閸╂牗绌辨繝鍥舵晬婵炴垵宕崝宀勬⒑? }, + { value: "minimal", label: "闂傚倸鍊风粈渚€骞栭锔藉亱闁告劦鍠栫壕濠氭煙閹规劗袦? }, + { value: "low", label: "濠? }, + { value: "medium", label: "濠? }, + { value: "high", label: "濠? }, ], defaultValue: "", }, - { key: "request_thoughts", label: "请求思考过程", type: "tri_bool", defaultValue: false }, - { key: "enable_function_calling", label: "函数调用", type: "tri_bool", defaultValue: false }, - { key: "enable_web_search", label: "网页搜索", type: "tri_bool", defaultValue: false }, - { key: "character_name_prefix", label: "角色名前缀", type: "text", defaultValue: "" }, - { key: "wrap_user_messages_in_quotes", label: "用户消息加引号", type: "tri_bool", defaultValue: false }, + { key: "request_thoughts", label: "闂傚倷娴囧畷鍨叏閺夋嚚娲敇閵忕姷鍝楅梻渚囧墮缁夌敻宕曢幋婢濆綊宕楅崗鑲╃▏缂備胶濮甸崹鍓佹崲濠靛顥堟繛娣劚閻楁挸鐣烽幋锕€绠婚悹鍥皺椤︺劌顪冮妶鍡樺暗濠殿喚鍏橀幃楣冩惞閸︻厾锛?, type: "tri_bool", defaultValue: false }, + { key: "enable_function_calling", label: "闂傚倸鍊风粈渚€骞夐敓鐘插瀭闁稿繐鍚嬮崣蹇涙煏閸繍妲告慨瑙勭叀閺岋綁寮崒姘闁诲孩鍑归崜鐔煎蓟濞戙垹绠涢柛蹇撴憸閸戝綊姊?, type: "tri_bool", defaultValue: false }, + { key: "enable_web_search", label: "缂傚倸鍊搁崐鎼佸磹閹间礁鐤い鏍仜閸ㄥ倿鏌涜椤ㄥ懓绻氬┑鐘灱閸╂牠宕濋弴鐘典笉閻熸瑥瀚弧鈧繝鐢靛Т閸婂綊宕宠ぐ鎺撶厱?, type: "tri_bool", defaultValue: false }, + { key: "character_name_prefix", label: "闂傚倷娴囧畷鐢稿窗閹扮増鍋¢柨鏃傚亾閺嗘粓鏌i弬鎸庢喐闁绘繆娉涢埞鎴︽偐閸欏鎮欓柣鐔哥懕婵″洭鍩€椤掆偓缁犲秹宕曢柆宥呯疇閹兼惌鐓€閻戣棄宸濇い鏍ㄧ矌閿涙繈姊虹粙鎸庢拱妞ゃ劌鎳忕粋?, type: "text", defaultValue: "" }, + { key: "wrap_user_messages_in_quotes", label: "闂傚倸鍊烽悞锕€顪冮崹顕呯劷闁秆勵殔缁€澶屸偓骞垮劚椤︻垶寮伴妷锔剧闁瑰鍊戝顑╋綁宕奸妷锔惧幈濡炪倖鍔戦崐鏇㈠几瀹ュ洨纾奸弶鍫涘妿婢х敻鏌$仦鍓ф创鐎殿噮鍣e畷鎺懶掔憗銈呯伄缂佽鲸甯¢獮宥嗘媴鐟欏嫮褰嬮柣?, type: "tri_bool", defaultValue: false }, ], }, ]; @@ -187,41 +187,41 @@ const TASK_PROFILE_GENERATION_GROUPS = [ const TASK_PROFILE_INPUT_GROUPS = { synopsis: [ { - title: "总结输入", + title: "闂傚倸鍊峰ù鍥敋閺嶎厼绀堟繝闈涙閺嗭箓鏌i姀銈嗘锭闁搞劍绻堥弻鏇熺節韫囨稒顎嶇紓鍌氱Т濞差參寮婚悢鐓庣畾鐟滃秹寮虫潏鈹惧亾?, fields: [ { key: "rawChatContextFloors", - label: "额外原文上下文楼层", + label: "濠电姷顣藉Σ鍛村磻閸℃ɑ娅犳俊銈呭暞瀹曟煡鏌涘畝鈧崐娑㈠炊閵娧呯槇濠殿喗锕╅崜娆撳磻瀹ュ鈷戠紓浣股戦悡銉╂煙鐠囇呯瘈闁诡喗顨婇、娆戜焊閺嶎煈娼旈梻浣烘嚀婢т粙顢楅幓鎺濇綎婵°倕鍟扮壕鍏间繆閵堝倸浜鹃梺纭呮珪閿氭い鏇秮椤㈡宕熼銈呪偓鐐烘⒑闂堟侗鐓紒鑼跺Г缁傛帟銇愰幒鎾跺幗?, type: "number", defaultValue: 0, - help: "在主消息范围之外额外补多少楼原文上下文,只影响小总结任务。", + help: "闂傚倸鍊风欢姘焽閼姐倖瀚婚柣鏃傚帶缁€澶愭倵閿濆骸鍘撮柛瀣崌濡啫鈽夊鍐句純闂備礁鐤囧Λ鍕囬棃娑氭殾濠靛倸鎲¢崑鍕煕閹捐尪鍏屾い锔哄劦濮婄粯鎷呴挊澶夋睏闂佸憡顭嗛崶褏鏌堝銈嗗姀閹稿苯煤椤忓嫮顔囬柟鑹版彧缁叉椽寮稿▎鎾寸厽閹兼惌鍨崇粔闈浢瑰鍡樼【閾伙綁鏌涢埄鍐︿簵婵炴垯鍨洪崑瀣煕椤愮姴鐏柣锝夋涧椤啴濡惰箛鏇犳殺閻庤娲滈弫濠氱嵁閸愨晛顕遍柡澶嬪殾閵娾晜鐓忓鑸得弸鐔兼煃鐠囧眰鍋㈤柡宀嬬秬缁犳盯骞橀崜渚囧敼闂備焦鎮堕崝宀勬偉閸忛棿绻嗛柤鍝ユ暩闂勫嫮绱掔€n亞浠㈤柛瀣Ч濮婅櫣绱掑Ο鑽ゅ弳闂佺顕滅换婵嬪箖濡ゅ拋鏁婇悘蹇旂墬椤秹姊洪悷鏉库挃妞ゆ帗褰冮~蹇撐旈崘顏嗭紲濠德板€愰崑鎾绘煙閾忣個顏堫敋閿濆鏁嗛柛鏇ㄥ亞閸婄偞淇婇悙宸剰婵炲鍏樿棟妞ゆ洍鍋撴慨濠冩そ瀹曘劍绻濋崟顒€娅у┑鐐茬摠缁秶鍒掑鍥╃处濞寸姴顑呴崘鈧銈嗘尵閸嬫妲愰崼鏇熷€垫鐐茬仢閸旀岸鏌熼搹顐㈠鐞氭瑩鏌涢鐘插姕闁绘挻鐟﹂妵鍕箳閹存繃娈梺鍛婅壘濠€杈╂閹烘鏁婇柛婵嗗椤绱撴担绋库偓鍦暜閻愬搫绠柣妯款嚙缁犵敻鏌熼悜妯肩畺闁告牜濞€濮?, }, { key: "rawChatSourceMode", - label: "原文来源模式", + label: "闂傚倸鍊风粈渚€骞夐敓鐘偓锕傚炊椤掆偓缁愭鏌熼悧鍫熺凡闁告垹濞€閺屾盯骞囬棃娑欑亪缂備胶瀚忛崶銊у帾婵犮垼鍩栫粙鎴︺€呴鍕厽闁哄稁鍓熼崫鍝勄庨崶褝韬い銏$☉椤啰鎷犻煫顓烆棜闁诲氦顫夊ú鏍洪妸鈹库偓?, type: "enum", options: [ - { value: "ignore_bme_hide", label: "忽略 BME 隐藏助手" }, + { value: "ignore_bme_hide", label: "闂傚倸鍊搁…顒勫磻閸曨個娲Ω閳轰胶鏌у銈嗗姧缁犳垵效?BME 闂傚倸鍊搁崐鎼佸磹閹间礁绠犻煫鍥ㄧ☉缁€澶嬩繆椤栨瑧绉挎繛鎴烆焽閺嗗棝鏌涢弴銊ヤ簽妞わ富鍣e娲礃閸欏鍎撻梺绋匡攻閹倽鐭? }, ], defaultValue: "ignore_bme_hide", - help: "固定绕过 BME 自己的隐藏助手裁剪,只用于小总结原文读取。", + help: "闂傚倸鍊烽悞锕傚箖閸洖纾块梺顒€绉寸粻瑙勩亜閹板爼妾柛瀣ф櫊閺屾盯骞樺Δ鈧幊鎰版晬濠婂牊鈷戦柛婵嗗缁侇偆绱掓潏銊︾缂?BME 闂傚倸鍊烽懗鍫曞储瑜旈妴鍐╂償閵忋埄娲稿┑鐘诧工閹冲繐鐣烽幓鎺嬧偓鎺戭潩閿濆懍澹曢梻浣筋嚃閸ㄤ即宕弶鎴犳殾闁绘梻鈷堥弫宥嗙箾閹存繂鑸归柟顔肩墦濮婄粯鎷呴挊澹捇鏌ㄥ杈╃<缂備焦锚婵牓鏌熸笟鍨妞ゃ垺妫冨畷鐓庘攽閸偄鎮戦梻浣筋嚙閸戠晫绱為崱娑樼煑閹肩补妲呭鏍煕瑜庨〃鍡涙偂閺囥垺鐓欓柛顭戝枛閺嗘洟鏌熼鑲┬ょ紒杈ㄥ笚濞煎繘濡歌閻ゅ嫰鎮楃憴鍕┛缂傚秳绶氶妴渚€寮崼婵嗚€垮┑鈽嗗灣缁垶顢橀悷鎵虫斀闁绘劘灏欓幗鐘电磼椤斿吋婀版い銊e劦楠炴牗鎷呴悷鏉垮婵$偑鍊栫敮鎺斺偓姘煎墰缁骞嬮敂鐣屽幘婵犳鍠楅崝鏇㈠焵椤掍緡娈旈柍缁樻婵偓闁靛牆妫涢崢鍗炩攽閳藉棗鐏i柛妯犲洤鍑犻柟杈鹃檮閻撴盯鎮楅敐搴濈盎闁哄棛鍠栭弻鐔割槹鎼粹寬銉╂煙妞嬪骸鈻堟鐐存崌楠炴帡寮埀顒勫磻瑜斿?, }, ], }, ], summary_rollup: [ { - title: "折叠输入", + title: "闂傚倸鍊烽懗鍫曘€佹繝鍥ф槬闁哄稁鍘介弲顏堟煟閻斿摜鐭屽褎顨呴~蹇涙偡闁妇鍔烽棅顐㈡处缁嬫垹绮绘繝姘厱闁归偊鍘肩徊缁樻叏?, fields: [ { key: "rawChatSourceMode", - label: "原文来源模式", + label: "闂傚倸鍊风粈渚€骞夐敓鐘偓锕傚炊椤掆偓缁愭鏌熼悧鍫熺凡闁告垹濞€閺屾盯骞囬棃娑欑亪缂備胶瀚忛崶銊у帾婵犮垼鍩栫粙鎴︺€呴鍕厽闁哄稁鍓熼崫鍝勄庨崶褝韬い銏$☉椤啰鎷犻煫顓烆棜闁诲氦顫夊ú鏍洪妸鈹库偓?, type: "enum", options: [ - { value: "ignore_bme_hide", label: "忽略 BME 隐藏助手(仅保留兼容位)" }, + { value: "ignore_bme_hide", label: "闂傚倸鍊搁…顒勫磻閸曨個娲Ω閳轰胶鏌у銈嗗姧缁犳垵效?BME 闂傚倸鍊搁崐鎼佸磹閹间礁绠犻煫鍥ㄧ☉缁€澶嬩繆椤栨瑧绉挎繛鎴烆焽閺嗗棝鏌涢弴銊ヤ簽妞わ富鍣e娲礃閸欏鍎撻梺绋匡攻閹倽鐭炬繛鎾村焹閸嬫捇鏌$仦璇插闁宠棄顦灒闁绘挸瀵掗弳顏嗙磽閸屾瑧璐伴柛鐘崇墱閳ь剚绋堥弲婵堝垝濮樿泛纭€闁绘劏鏅滈弬鈧梻浣稿閸嬪棝宕伴幇鐗堝仼妞ゆ帒瀚埛鎴犵磼鐎n厽纭剁紒鐘冲▕閺屾稑螣閻樺弶绁╅柡浣革躬閺岀喖鏌囬敃鈧獮妤€鈹戦姘煎殶闁逞屽墮缁犲秹宕曟潏鈺傚床闁圭儤姊婚惌? }, ], defaultValue: "ignore_bme_hide", - help: "折叠总结默认不直接读取原文聊天;这里保留输入配置兼容位。", + help: "闂傚倸鍊烽懗鍫曘€佹繝鍥ф槬闁哄稁鍘介弲顏堟煟閻斿摜鐭屽褎顨呰灋闁告劦鍠氬畵渚€鏌嶈閸撶喖寮婚妶鍡樺弿闁归偊鍏橀崑鎾澄旈埀顒勫煝閺冨牆顫呴柣娆屽亾婵炲皷鍓濈换娑㈠箣閻愬灚鍣介梺缁樺笧閸嬫捇濡甸崟顖氱闁告挷绀佺粭锛勭磽娴e壊鍎忔い锔诲灦椤㈡ɑ绺界粙璺ㄥ€為梺闈浤涢崘銊︾槪闂傚倸鍊峰ù鍥綖婢跺顩插ù鐘差儏绾惧潡鏌熺紒銏犳珮闁轰礁顑呴湁闁稿繐鍚嬬紞鎴︽煟椤撶噥娈滈柡灞糕偓宕囨殼妞ゆ梻鍘ч弳鐐烘煛鐎n喚鐣烘慨濠冩そ瀹曨偊宕熼鐔蜂壕缂佸锛曞ú顏勭厸闁稿本绮犲ú鎼佹⒑缂佹﹩娈旈柣妤€妫涘▎銏ゆ倷閻戞鍘撻梺鍛婄箓鐎氱兘宕曢幋鐑嗘闁绘劦浜滈悘鑼偓娈垮枛閻栧ジ宕洪敓鐘茬<婵犲﹤瀚▍姘舵⒒娓氣偓濞艰崵绱炴笟鈧銊︽綇閳哄啰鐓旈梺鍛婎殘閸嬫劙寮ㄦ禒瀣厱妞ゆ劑鍊曢弸鎴︽煟閿濆骸澧撮柡灞稿墲瀵板嫭绻濋崟顐澑闂備胶鎳撻幉锟犲箖閸屾氨鏆﹂柨婵嗩槸楠炪垺绻涢幋鐐垫噮闁告ɑ鍔欓幃妤呯嵁閸喖濮庡┑鐐茬湴閸旀垵顕i幖浣哥<闁绘劕顕崢閬嶆椤愩垺澶勬繛鍙夌墱閺侇噣鎳滈悙閫涚盎闂佸搫鍟犻崑鎾绘煕閺冣偓閻楃娀鐛箛娑樺窛妞ゆ挆鍐╁€梻浣告啞閸斞呭緤閼恒儳顩?, }, ], }, @@ -231,43 +231,43 @@ const TASK_PROFILE_INPUT_GROUPS = { const TASK_PROFILE_REGEX_STAGES = [ { key: "input", - label: "输入总开关", - desc: "控制全部输入阶段;未单独覆写的细分阶段会跟随它。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柣顓燁殜閺屾盯鍩勯崘顏佹缂備胶濮甸崹鍧楀箖瑜版帒鐐婃い蹇撳濮c垻绱掗悙顒€鍔ゆい顓犲厴瀵?, + desc: "闂傚倸鍊烽懗鍫曘€佹繝鍥ㄥ剹闁搞儺鍓欑粈鍐煏婵炑冨暙缁犳垿姊婚崒娆掑厡妞ゎ厼鐗忛幑銏ゅ醇閵夈儳顦梺纭呮彧缁犳垹澹曢崸妤佸€垫繛鎴烆伆閹寸姷绀婇柛銉墯閻撴瑩鏌熼鍡楀暞濮f劙鎮楀▓鍨灍濠电偛锕ら~蹇旂節濮橆剛顦ㄥ銈嗘尵閸嬬喖宕㈤銏╂富闁靛牆妫欓悡銉╂煟椤撶偛鈧灝顕i銏╁悑闁告侗浜濋~宥呪攽閻愬弶顥為柛鏃€娲滃Σ鎰版晝閸屾稈鎷洪梺鍛婄☉椤剙鈻撻弴鐔虹闁告瑥顦遍惌宀勬煃瑜滈崜姘跺礄瑜版帒鍌ㄥΔ锝呭暙缁犵喎霉閸忓吋缍戞鐐灪娣囧﹪濡堕崒姘闂備礁鎼幊蹇涘箖閸岀偛钃熼柨婵嗩槸鎯熼梺闈涱槶閸庣儤瀵奸埀顒傜磽閸屾瑧璐伴柛鐘崇墱閹广垹螣閾忚娈惧┑顔筋焾濞夋盯鐛姀锛勭闁瑰鍋熼幉鍧楁煛鐎n亝顥滈柍瑙勫灴閹瑩鎳犻鈧。娲⒑閸涘﹤鐒归柛瀣尵缁辨挻鎷呮禒瀣懙婵犮垻鎳撳Λ婵嬬嵁閹达箑绀嬫い鏍ㄧ☉娴滄粓姊虹粙璺ㄧ闁冲嘲鐗撳畷銉╁磼閻愬鍘介柟鑹版彧缁查箖宕甸埀顒勬⒑閸濄儱鏋傞柛鏃€鍨垮畷娲焵?, }, { key: "input.userMessage", - label: "输入: 用户消息", - desc: "处理当前 userMessage。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柣? 闂傚倸鍊烽悞锕€顪冮崹顕呯劷闁秆勵殔缁€澶屸偓骞垮劚椤︻垶寮伴妷锔剧闁瑰鍊戝顑╋綁宕奸妷锔惧幈濡炪倖鍔戦崐鏇㈠几瀹ュ洨纾?, + desc: "濠电姷鏁告慨浼村垂閻撳簶鏋栨繛鎴炲焹閸嬫挸顫濋悡搴㈢彎濡ょ姷鍋涢崯顖滄崲濠靛宸濇い鎰ㄥ墲閻繘姊绘担绛嬫綈闁稿孩濞婂畷顖炲锤濡?userMessage闂?, }, { key: "input.recentMessages", - label: "输入: 最近上下文", - desc: "处理 recentMessages、chatMessages、dialogueText。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柣? 闂傚倸鍊风粈渚€骞栭锔藉亱闁告劦鍠栫壕濠氭煙閹规劦鍤欑紒鐙欏洦鐓冮柛婵嗗閳ь剚鎮傞幃姗€鏁愰崶鈺冿紲濠德板€愰崑鎾绘煕婵犲倹璐$紒顔界懄瀵板嫮浠︾粙澶稿闂佹寧绻傛鍛婄濠靛鐓?, + desc: "濠电姷鏁告慨浼村垂閻撳簶鏋栨繛鎴炲焹閸嬫挸顫濋悡搴㈢彎濡?recentMessages闂傚倸鍊风欢姘焽瑜嶈灋闁哄啫鐗嗙粻鎺楁煟閻樺灚鐝畉Messages闂傚倸鍊风欢姘焽瑜嶈灋闁哄啠鍋撻摶鐐存叏濠靛嫬鈧ogueText闂?, }, { key: "input.candidateText", - label: "输入: 候选与摘要", - desc: "处理 candidateText、candidateNodes、nodeContent 和各类摘要。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柣? 闂傚倸鍊烽懗鍫曗€﹂崼銉︽櫇闁靛鏅滈崑锟犳煃閸濆嫭鍣归柣鎺戠仛閵囧嫰骞掗崱妞惧婵$偑鍊х€靛矂宕板鍗炲灊妞ゆ挾鍋熼弳鍡涙煕閺囥劌浜炴い鏃€鍨甸埞鎴﹀煡閸℃浠銈嗗灦閻熴儳鍙?, + desc: "濠电姷鏁告慨浼村垂閻撳簶鏋栨繛鎴炲焹閸嬫挸顫濋悡搴㈢彎濡?candidateText闂傚倸鍊风欢姘焽瑜嶈灋闁哄啫鐗嗙粻鎺楁煟閻欌偓濞插潐idateNodes闂傚倸鍊风欢姘焽瑜嶈灋闁哄啫鐗婇崵鎰版煟閹烘繃鎲縠Content 闂傚倸鍊风粈渚€骞夐敍鍕灊鐎光偓閸曨剙娈e銈嗙墱閸嬫稓绮婚弽顓熺厱闁靛鍨哄▍鍥倶韫囨洘鏆╃紒杈ㄦ尰閹峰懘鎳栭埄鍐ㄧ伌鐎规洑鍗冲畷銊р偓娑櫭禒顓㈡⒑閸濆嫭澶勭€光偓缁嬫鐎堕柣鎴eГ閻?, }, { key: "input.finalPrompt", - label: "输入: 发送前最终消息", - desc: "在最终 messages 全部组装完成、真正发送给 LLM 前统一清洗。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柣? 闂傚倸鍊风粈渚€骞夐敓鐘冲仭闁挎洖鍊搁崹鍌炴煕瑜庨〃鍛存倿閸偁浜滈柟杈剧稻绾埖銇勯敂鑲╃暠妞ゎ叀鍎婚ˇ鏉戔攽閻愨晛浜鹃柣搴㈩問閸犳岸寮繝姘槬闁逞屽墯閵囧嫰骞掗幋婵冨亾閼姐倕顥氬┑鍌氭啞閻撴洘銇勯幇鈺佲偓鏇㈠几閺冨牆鐤柟闂寸劍閳?, + desc: "闂傚倸鍊风欢姘焽閼姐倖瀚婚柣鏃傚帶缁€澶屸偓骞垮劚閹冲矂鍩€椤掍礁娴€规洘绮嶉幏鍛存惞閻у摜搴?messages 闂傚倸鍊烽懗鍫曗€﹂崼銏″床闁割偁鍎辩粈澶愭煙鏉堝墽鐣辩痪鎯ф健閹妫冨☉娆愬枑缂佺偓鍎抽妶鎼佸蓟濞戙垹鍗抽柕濞垮劜閻濐喖鈹戦埥鍡椾簻闁硅櫕锕㈤獮鍐ㄎ旈崨顔间画闂佺粯顨呴悧濠囧箯闁秵鈷戦柟鑲╁仜婵$晫鈧厜鍋撻柛娑橈梗缁诲棝鎮楀☉娅虫垶鍒婇幘顔界厽闁瑰浼濋鍫熷€跨紒瀣氨閺€鑺ャ亜閺冨倹娅曠紒鐘冲缁辨帡骞撻幒鍡椾壕闁绘梻绻濆Ч妤呮⒑缁嬭法鐏遍柛瀣仱閹偟鎷犵憗浣哥秺閺佹劙宕奸悤浣峰摋闂?LLM 闂傚倸鍊风粈渚€骞夐敓鐘茬闁告縿鍎抽惌鎾绘煕閹捐尙顦﹂柛銊︾箞閺岋綁骞嬮悜鍡欏姺闂佸磭绮Λ鍐蓟瀹ュ牜妾ㄩ梺鍛婃尰閻熲晜淇婇幘顔肩婵°倐鍋撶€瑰憡绻冮妵鍕籍閸屾繃顎楀┑鈥虫▕閸ㄨ泛顫?, }, { key: "output", - label: "输出总开关", - desc: "控制全部输出阶段;未单独覆写的细分阶段会跟随它。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柛姘愁潐閵囧嫰骞橀崡鐐典患缂備胶濮甸崹鍧楀箖瑜版帒鐐婃い蹇撳濮c垻绱掗悙顒€鍔ゆい顓犲厴瀵?, + desc: "闂傚倸鍊烽懗鍫曘€佹繝鍥ㄥ剹闁搞儺鍓欑粈鍐煏婵炑冨暙缁犳垿姊婚崒娆掑厡妞ゎ厼鐗忛幑銏ゅ醇閵夈儳顦梺纭呮彧缁犳垹澹曢崸妤佸€垫繛鎴烆伆閹寸姷绀婇柛銉墯閻撴瑩鏌熼鍡楀暞濮f劕鈹戦鍡欏埌妞わ箓娼ч~蹇旂節濮橆剛顦ㄥ銈嗘尵閸嬬喖宕㈤銏╂富闁靛牆妫欓悡銉╂煟椤撶偛鈧灝顕i銏╁悑闁告侗浜濋~宥呪攽閻愬弶顥為柛鏃€娲滃Σ鎰版晝閸屾稈鎷洪梺鍛婄☉椤剙鈻撻弴鐔虹闁告瑥顦遍惌宀勬煃瑜滈崜姘跺礄瑜版帒鍌ㄥΔ锝呭暙缁犵喎霉閸忓吋缍戞鐐灪娣囧﹪濡堕崒姘闂備礁鎼幊蹇涘箖閸岀偛钃熼柨婵嗩槸鎯熼梺闈涱槶閸庣儤瀵奸埀顒傜磽閸屾瑧璐伴柛鐘崇墱閹广垹螣閾忚娈惧┑顔筋焾濞夋盯鐛姀锛勭闁瑰鍋熼幉鍧楁煛鐎n亝顥滈柍瑙勫灴閹瑩鎳犻鈧。娲⒑閸涘﹤鐒归柛瀣尵缁辨挻鎷呮禒瀣懙婵犮垻鎳撳Λ婵嬬嵁閹达箑绀嬫い鏍ㄧ☉娴滄粓姊虹粙璺ㄧ闁冲嘲鐗撳畷銉╁磼閻愬鍘介柟鑹版彧缁查箖宕甸埀顒勬⒑閸濄儱鏋傞柛鏃€鍨垮畷娲焵?, }, { key: "output.rawResponse", - label: "输出: 原始响应", - desc: "LLM 原始文本到手后先清洗一次。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柛? 闂傚倸鍊风粈渚€骞夐敓鐘偓锕傚炊椤掆偓缁愭骞栭幖顓犲帨缂傚秵鐗犻弻鐔兼偋閸喓鍑℃繛纾嬪亹婵兘鍩€椤掆偓缁犲秹宕曢柆宥呯疇闁归偊鍠掗崑?, + desc: "LLM 闂傚倸鍊风粈渚€骞夐敓鐘偓锕傚炊椤掆偓缁愭骞栭幖顓犲帨缂傚秵鐗犻弻鐔兼偋閸喓鍑″┑鈽嗗亝閿曘垽寮诲☉銏犖ㄩ柨婵嗘噹椤姊洪崷顓炲幋濞存粌鐖煎璇测槈閵忕姷顔婇梺鍝勫€归娆撳汲椤撶喓绡€闁冲皝鍋撻柛鏇ㄥ幘閻撳鎮楃憴鍕8闁稿孩鐓¢獮鍡涘籍閸繄浼嬮梺鍛婂姈瑜板啰妲愰浣虹瘈闁汇垽娼ф禒锕傛煙閸涘﹥鍊愭鐐诧龚缁犳盯寮撮悩鐢靛姸闂備胶顭堥張顒傜矙閹捐鐓濋柡鍐ㄧ墛閻撳啰鎲稿鍫濈婵炲棙鎸搁悡婵嬪箹濞n剙鐏╃紒鐘靛█濮?, }, { key: "output.beforeParse", - label: "输出: 解析前", - desc: "在 JSON 提取/解析前再清洗一次。", + label: "闂傚倷绀侀幖顐λ囬鐐村亱濠电姴娲ょ粻浼存煙闂傚顦﹂柛? 闂傚倷娴囧畷鐢稿窗閹扮増鍋¢弶鍫氭櫅缁躲倕螖閿濆懎鏆為柛濠囨涧闇夐柣妯烘▕閸庡繒绱掗埀?, + desc: "闂?JSON 闂傚倸鍊风粈浣革耿鏉堚晛鍨濇い鏍仜缁€澶愭煛閸ゅ爼顣﹀Ч?闂傚倷娴囧畷鐢稿窗閹扮増鍋¢弶鍫氭櫅缁躲倕螖閿濆懎鏆為柛濠囨涧闇夐柣妯烘▕閸庡繒绱掗埀顒勫醇閳垛晛浜炬鐐茬仢閸旀岸鏌熼崘鏌ュ弰鐎殿喗褰冮埢搴ㄥ箛椤旂虎鍟庨梻浣筋潐瀹曟ê鈻嶉弴銏犻棷闁惧繐婀辩壕濂告煃瑜滈崜鐔风暦閹烘垟妲堟慨姗堢稻閻忓啴姊绘笟鈧埀顒傚仜閼活垱鏅堕鈧弻宥囨嫚閹绘帩鍔夊Δ鐘靛仜閿曨亪寮?, }, ]; @@ -294,7 +294,7 @@ let fetchedDirectEmbeddingModels = []; let viewportSyncBound = false; let popupRuntimePromise = null; -// 由 index.js 注入的引用 +// 闂?index.js 婵犵數濮烽弫鎼佸磻濞戔懞鍥敇閵忕姷顦悗鍏夊亾闁告洦鍋嗛悡鎴︽⒑缁洖澧茬紒瀣浮瀹曟洖顓兼径瀣幈闂佸搫娲㈤崝灞剧閻愮數纾奸悘鐐跺Г閸嬨儵鏌? let _getGraph = null; let _getSettings = null; let _getLastExtract = null; @@ -334,6 +334,131 @@ async function getPopupRuntime() { return await popupRuntimePromise; } +function _ensureCloudBackupManagerStyles() { + if (document.getElementById("bme-cloud-backup-manager-styles")) return; + const style = document.createElement("style"); + style.id = "bme-cloud-backup-manager-styles"; + style.textContent = ` + .bme-cloud-backup-modal { + width: min(920px, 88vw); + max-width: 100%; + color: var(--SmartThemeBodyColor, #f2efe8); + } + .bme-cloud-backup-modal__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + margin-bottom: 14px; + } + .bme-cloud-backup-modal__title { + font-size: 22px; + font-weight: 700; + margin: 0; + } + .bme-cloud-backup-modal__subtitle { + opacity: 0.78; + line-height: 1.5; + margin-top: 6px; + } + .bme-cloud-backup-modal__tools { + display: inline-flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + .bme-cloud-backup-modal__btn { + border: 1px solid var(--SmartThemeBorderColor, rgba(255,255,255,0.18)); + background: var(--SmartThemeBlurTintColor, rgba(255,255,255,0.06)); + color: inherit; + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; + } + .bme-cloud-backup-modal__btn:hover:not(:disabled) { + border-color: rgba(255, 181, 71, 0.65); + } + .bme-cloud-backup-modal__btn:disabled { + opacity: 0.55; + cursor: wait; + } + .bme-cloud-backup-modal__list { + display: grid; + gap: 12px; + max-height: 62vh; + overflow: auto; + padding-right: 4px; + } + .bme-cloud-backup-modal__empty, + .bme-cloud-backup-modal__loading { + border: 1px dashed var(--SmartThemeBorderColor, rgba(255,255,255,0.18)); + border-radius: 14px; + padding: 18px; + opacity: 0.85; + text-align: center; + } + .bme-cloud-backup-card { + border: 1px solid var(--SmartThemeBorderColor, rgba(255,255,255,0.18)); + border-radius: 14px; + padding: 14px; + background: rgba(255,255,255,0.03); + } + .bme-cloud-backup-card.is-current-chat { + border-color: rgba(255, 181, 71, 0.78); + box-shadow: 0 0 0 1px rgba(255, 181, 71, 0.22) inset; + } + .bme-cloud-backup-card__top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; + margin-bottom: 8px; + } + .bme-cloud-backup-card__title { + font-size: 16px; + font-weight: 700; + word-break: break-word; + } + .bme-cloud-backup-card__badge { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 4px 10px; + background: rgba(255, 181, 71, 0.15); + color: #ffcf7a; + font-size: 12px; + white-space: nowrap; + } + .bme-cloud-backup-card__meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px 12px; + margin-bottom: 10px; + font-size: 13px; + opacity: 0.88; + } + .bme-cloud-backup-card__filename { + font-family: Consolas, Monaco, monospace; + font-size: 12px; + word-break: break-all; + opacity: 0.72; + margin-bottom: 12px; + } + .bme-cloud-backup-card__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + flex-wrap: wrap; + } + .bme-cloud-backup-card__danger { + border-color: rgba(255, 107, 107, 0.45); + color: #ffd4d4; + } + `; + document.head.appendChild(style); +} + function mountPanelHtml(html) { const markup = String(html || "").trim(); if (!markup) { @@ -503,7 +628,7 @@ function bindViewportSync() { } /** - * 初始化面板(由 index.js 调用一次) + * 闂傚倸鍊风粈渚€骞夐敍鍕殰婵°倕鍟畷鏌ユ煕瀹€鈧崕鎴犵礊閺嶎厽鐓欓柣妤€鐗婄欢鑼磼閳ь剙鐣濋崟顒傚幐閻庡箍鍎辨鎼佺嵁濡や椒绻嗘俊鐐靛帶婵倿鏌$仦绋垮⒉鐎垫澘瀚换婵嬪磼濠婂嫷鍟囩紓鍌氬€峰ù鍥ㄣ仈閹间焦鍋¢柍鍝勬噹缁?index.js 闂傚倷娴囧畷鍨叏閹绢噮鏁勯柛娑欐綑閻ゎ喖霉閸忓吋缍戦柡瀣╃窔閺屾洟宕煎┑鍥舵¥闂佸磭绮Λ鍐蓟瀹ュ牜妾ㄩ梺鍛婃尰閻熲晠宕洪姀鈩冨劅闁靛鍎抽崣鈧梻浣告啞娓氭宕归幎鍓? */ export async function initPanel({ getGraph, @@ -574,7 +699,7 @@ export async function initPanel({ _bindFabToggle(); } -// ==================== 悬浮球 ==================== +// ==================== 闂傚倸鍊峰ù鍥敋閺嶎厼闂い鏇楀亾鐎规洘绮岄~婵囨綇閵娿儳褰撮梻浣虹帛閸旀宕曢妶澶婄;?==================== const FAB_STORAGE_KEY = "bme-fab-position"; const FAB_VISIBLE_KEY = "bme-fab-visible"; @@ -624,15 +749,15 @@ function _initFloatingBall() { fab.setAttribute("data-status", "idle"); fab.innerHTML = ` - BME 记忆图谱 + BME 闂傚倷娴囧畷鍨叏閹惰姤鍊块柨鏇楀亾妞ゎ厼鐏濊灒闁稿繒鍘ф惔濠囨⒑缁嬭法鐏遍柛瀣洴閹瑦绻濋崶銊у幍闁荤喐鐟ョ€氼剚鎱ㄩ崼銉︾厽?/span> `; _fabEl = fab; ensureFabMountedAtRoot(); - // 应用可见性 + // 闂傚倷绀佸﹢閬嶅储瑜旈幃娲Ω閵夘喗缍庢繝鐢靛У閼归箖寮告笟鈧弻鏇㈠醇濠垫劖笑闂佹椿鍘介〃鍡涘Φ閸曨垰鍐€闁靛ě鍜佸悑婵犵數鍋熼崢褔鎯岄崒鐐茶摕? if (!_getFabVisible()) fab.style.display = "none"; - // 恢复位置 + // 闂傚倸鍊峰ù鍥敋閺嶎厼鍌ㄧ憸鐗堝笒閸ㄥ倻鎲搁悧鍫濆惞闁搞儺鍓欓惌妤€顭跨捄渚剰妞ゅ孩鎹囬幃妤呯嵁閸喖濮庡┑鐐茬湴閸旀垵顕? const saved = _loadFabPosition(); if (saved) { fab.dataset.positionMode = "saved"; @@ -642,7 +767,7 @@ function _initFloatingBall() { syncFabPosition(); } - // 拖拽 + 点击逻辑 + // 闂傚倸鍊风粈浣虹礊婵犲洤缁╅梺顒€绉甸崑瀣繆閵堝懎鏆婇柛?+ 闂傚倸鍊烽懗鍓佸垝椤栫偛绀夋俊銈呮噹缁犵娀鏌熼幑鎰靛殭闁告俺顫夐妵鍕即濡も偓娴滈箖姊洪崫鍕闁硅櫕锚椤曪綁骞忓畝鈧悿鈧梺鍝勬川閸嬬喖顢? let isDragging = false; let hasMoved = false; let startX = 0, startY = 0; @@ -686,7 +811,7 @@ function _initFloatingBall() { fab.releasePointerCapture(e.pointerId); if (hasMoved) { - // 拖拽结束 → 保存位置 + // 闂傚倸鍊风粈浣虹礊婵犲洤缁╅梺顒€绉甸崑瀣繆閵堝懎鏆婇柛瀣尭椤繈鎼归銈冣偓濠勭磽娴d粙鍝洪悽顖ょ節閻涱喖螣鐏忔牕浜炬繛鎴烆仾椤忓牊鍎?闂?濠电姷鏁搁崕鎴犲緤閽樺娲晜閻愵剙搴婇梺绋跨灱閸嬬偤宕戦妶澶嬬厪濠电姴绻樺顔尖攽椤栨凹鍤熼柍褜鍓欑粻宥夊磿閸楃伝娲Ω閳轰礁鍤? fab.dataset.positionMode = "saved"; _saveFabPosition( Number.parseInt(fab.style.left, 10), @@ -695,14 +820,14 @@ function _initFloatingBall() { return; } - // 非拖拽 → 处理单击/双击 + // 闂傚倸鍊搁崐鎼佸磹閹间焦鍋嬮煫鍥ㄧ☉绾惧鏌i幇顕呮毌闁稿鎸搁~婵嬵敆婢跺﹤澹庢俊?闂?濠电姷鏁告慨浼村垂閻撳簶鏋栨繛鎴炲焹閸嬫挸顫濋悡搴㈢彎濡ょ姷鍋涢崯顖滄崲濠靛鐐婄憸蹇涖€侀崨瀛樷拺闁告繂瀚婵嬫煕鐎n偆鈽夌悮?闂傚倸鍊风粈渚€骞夐敓鐘冲仭闁靛鏅滈崵鎰亜閺嶎偄浠滈柛? if (clickTimer) { - // 第二次点击 → 双击 → 重 Roll + // 缂傚倸鍊搁崐鐑芥倿閿曞倶鈧啳绠涘☉妯碱槯濠电偞鍨舵穱鐑樻叏閹惰姤鐓冮弶鐐村椤斿鏌$€n亪鍙勯柡灞诲妼閳藉螣娓氼垯鎮e┑鐐差嚟婵參宕归崼鏇炶摕?闂?闂傚倸鍊风粈渚€骞夐敓鐘冲仭闁靛鏅滈崵鎰亜閺嶎偄浠滈柛?闂?闂?Roll clearTimeout(clickTimer); clickTimer = null; _onFabDoubleClick(); } else { - // 第一次点击 → 等待双击 + // 缂傚倸鍊搁崐鐑芥倿閿曞倶鈧啳绠涘☉妯碱槯濠电偞鍨跺銊╁础濮樿埖鐓涘璺侯儏閻忓秹鏌$€n亪鍙勯柡灞诲妼閳藉螣娓氼垯鎮e┑鐐差嚟婵參宕归崼鏇炶摕?闂?缂傚倸鍊搁崐鐑芥倿閿斿墽鐭欓柟娆¤娲、娑橆煥閸曢潧浠洪梻浣虹帛濮婂宕㈣閹苯螖閸涱喖鈧爼鏌i幇顖氱厫妞ゃ儱顦伴幈? clickTimer = setTimeout(() => { clickTimer = null; _onFabSingleClick(); @@ -764,7 +889,7 @@ export function updateFloatingBallStatus(status = "idle", tooltipText = "") { } /** - * 打开面板 + * 闂傚倸鍊烽懗鍫曞箠閹剧粯鍊舵慨妯挎硾缁犱即鏌涘┑鍕姕妞ゎ偅娲熼弻鐔告綇妤e啯顎嶅銈冨劚閻楁捇寮婚弴锛勭杸濠电姴鍊搁埛澶岀磽? */ export function openPanel() { if (!overlayEl) return; @@ -804,7 +929,7 @@ export function openPanel() { } /** - * 关闭面板 + * 闂傚倸鍊烽懗鍫曗€﹂崼銏″床闁瑰鍋熺粻鎯р攽閻樿弓杩规繛鎴欏灩缁犵粯銇勯弮鍌滄憘婵☆偄鍟村娲濞戞氨鐣鹃梺鍛婃尰缁诲嫬鈻? */ export function closePanel() { if (!overlayEl) return; @@ -812,7 +937,7 @@ export function closePanel() { } /** - * 更新主题 + * 闂傚倸鍊风粈渚€骞栭鈷氭椽濡舵径瀣槐闂侀潧艌閺呮盯鎷戦悢灏佹斀闁绘ɑ褰冮弳鐔兼煃缂佹ɑ鐓ラ柍瑙勫灴閹晠宕橀幓鎺撶槗闂? */ export function updatePanelTheme(themeName) { graphRenderer?.setTheme(themeName); @@ -853,7 +978,7 @@ export function refreshLiveState() { _refreshGraph(); } -// ==================== Tab 切换 ==================== +// ==================== Tab 闂傚倸鍊风粈渚€骞夐敍鍕殰闁圭儤鍤氬ú顏呮櫇闁逞屽墴閹?==================== function _bindTabs() { panelEl?.querySelectorAll(".bme-tab-btn").forEach((btn) => { @@ -874,7 +999,7 @@ function _switchTab(tabId) { pane.classList.toggle("active", pane.id === `bme-pane-${currentTabId}`); }); - // ⑥ 移动端图谱 tab 全屏覆盖 + // 闂?缂傚倸鍊搁崐椋庣矆娓氣偓钘濋柟鍓佺摂閺佸鎲告惔銊ョ疄闁靛ň鏅涢悡娑㈡煕閹板吀绨荤€规洏鍎遍—鍐Χ閸℃瑥顫х紒鐐緲缁夊墎鍒掔€n喖閱囬柕澶涚畱娴?tab 闂傚倸鍊烽懗鍫曗€﹂崼銏″床闁割偁鍎辩粈澶屸偓鍏夊亾闁告洦鍓欓崜鐢告⒑缁洖澧查柣鐕傜畵瀹曨垰煤椤忓懐鍘遍梺鏂ユ櫅閸熶即骞婇崟顓犳/? const mainEl = panelEl?.querySelector(".bme-panel-main"); if (mainEl) { mainEl.classList.toggle("mobile-visible", currentTabId === "graph"); @@ -915,8 +1040,8 @@ function _refreshPlannerLauncher() { button.disabled = !ready; button.classList.toggle("is-runtime-disabled", !ready); hint.textContent = ready - ? "已加载,可打开独立的 Ena Planner 设置页。" - : "未检测到 Ena Planner 模块,请重载 ST-BME 后再试。"; + ? "闂備浇顕уù鐑藉箠閹捐绠熼梽鍥Φ閹版澘绀冮柍鍝勫€稿鍧楁⒑缂佹ê濮囬柣掳鍔嶉幈銊ヮ煥閸喓鍘搁梺绋挎湰閿氶柍褜鍓氶〃鍫㈠垝閸喎绶為柟閭﹀幘閸樺崬鈹戦悙鍙夘棡闁挎岸鏌h箛濞惧亾閹颁胶鍞甸悷婊勭箘缁骞嬮敂缁樻櫔闂佹寧绻傞ˇ顖炴倿閸偁浜滈柟鐑樺灥椤忣亪鏌涚€n偄鐏﹂柕鍥у楠炴帡骞嬪┑鍐ㄤ壕闁哄嫬绻堟禍鍦偓鍏夊亾闁告洦鍓涢崢?Ena Planner 闂傚倷娴囧畷鍨叏瀹曞洨鐭嗗ù锝堫潐濞呯姴霉閻樺樊鍎愰柛瀣典邯閺屾盯鍩勯崗锔界矋缁傚秴顭ㄩ崼鐔哄幗闂佸綊鍋婇崜娆戞暜閵娾晜鐓? + : "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鍙夌節闂堟稓鎳佸鑸靛姇瀹告繃銇勯幒鏂垮付婵犫偓闁秴鐒垫い鎺戯功缁夐潧霉濠婂嫮鐭掗柟?Ena Planner 婵犵數濮烽。钘壩i崨鏉戝瀭妞ゅ繐鐗嗛悞鍨亜閹烘垵鏆為柣婵愪邯閺屾稓鈧絻鍔岄崝锕傛煛鐏炶濡奸柍钘夘槸铻i柤娴嬫櫅婵増淇婇悙顏勨偓鏍洪敃鍌氱闁绘梻鍘ч拑鐔兼倶閻愮數鎽傞柛姘儔閺屾盯顢曢妶鍛亖闂?ST-BME 闂傚倸鍊风粈渚€骞夐敓鐘冲殞闁绘劦鍓﹀▓浠嬫煙闂傚顦﹂柣銈庡櫍閺屽秷顧侀柛鎾跺枛楠炲啳銇愰幒鎴犲€為梺闈涱煭婵″洭鏁嶅鈧?; } function _bindPlannerLauncher() { @@ -944,7 +1069,7 @@ function _applyWorkspaceMode() { panelEl.classList.toggle("config-mode", isConfig); } -// ==================== 图谱视图切换 ==================== +// ==================== 闂傚倸鍊烽悞锕傚箖閸洖纾块柟缁樺笧閺嗭附淇婇娆掝劅婵炲皷鏅犻弻鏇熺箾瑜嶇€氼剟寮搁崒鐐粹拺缂侇垱娲栨晶鍙夈亜閵娿儲顥犵紒顔碱儔瀹曞ジ濡烽敂鎯у箞闂備礁婀遍崕銈夊垂閼搁潧绶為柛鏇ㄥ幐閸?==================== function _switchGraphView(view) { currentGraphView = view || "graph"; @@ -1003,8 +1128,8 @@ function _inferOwnerTypeFromKey(ownerKey = "") { function _getOwnerTypeDisplayLabel(ownerType = "") { const normalizedType = _normalizeOwnerUiType(ownerType); - if (normalizedType === "user") return "用户"; - if (normalizedType === "character") return "角色"; + if (normalizedType === "user") return "闂傚倸鍊烽悞锕€顪冮崹顕呯劷闁秆勵殔缁€澶屸偓骞垮劚椤︻垶寮?; + if (normalizedType === "character") return "闂傚倷娴囧畷鐢稿窗閹扮増鍋¢柨鏃傚亾閺嗘粓鏌i弬鎸庢喐闁?; return "Owner"; } @@ -1012,8 +1137,8 @@ function _buildOwnerCollisionIndex(owners = []) { const collisionIndex = new Map(); for (const owner of Array.isArray(owners) ? owners : []) { const baseName = - String(owner?.ownerName || owner?.ownerKey || "未命名角色").trim() || - "未命名角色"; + String(owner?.ownerName || owner?.ownerKey || "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鍙夌節婵犲倻澧曠紒鐘靛█閺屻劑鎮㈤崫鍕戙垽鎮峰▎娆忣洭闁逞屽墮缁犲秹宕曢柆宥嗗亱婵犲﹤鍠氶悞浠嬫煥閻斿搫校闁?).trim() || + "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鍙夌節婵犲倻澧曠紒鐘靛█閺屻劑鎮㈤崫鍕戙垽鎮峰▎娆忣洭闁逞屽墮缁犲秹宕曢柆宥嗗亱婵犲﹤鍠氶悞浠嬫煥閻斿搫校闁?; const nameKey = baseName.toLocaleLowerCase("zh-Hans-CN"); const ownerType = _normalizeOwnerUiType(owner?.ownerType) || "unknown"; const entry = collisionIndex.get(nameKey) || { @@ -1035,8 +1160,8 @@ function _shortOwnerNodeId(owner = {}) { function _getOwnerDisplayInfo(owner = {}, collisionIndex = null) { const baseName = - String(owner?.ownerName || owner?.ownerKey || "未命名角色").trim() || - "未命名角色"; + String(owner?.ownerName || owner?.ownerKey || "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鍙夌節婵犲倻澧曠紒鐘靛█閺屻劑鎮㈤崫鍕戙垽鎮峰▎娆忣洭闁逞屽墮缁犲秹宕曢柆宥嗗亱婵犲﹤鍠氶悞浠嬫煥閻斿搫校闁?).trim() || + "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鍙夌節婵犲倻澧曠紒鐘靛█閺屻劑鎮㈤崫鍕戙垽鎮峰▎娆忣洭闁逞屽墮缁犲秹宕曢柆宥嗗亱婵犲﹤鍠氶悞浠嬫煥閻斿搫校闁?; const ownerKey = String(owner?.ownerKey || "").trim(); const ownerType = _normalizeOwnerUiType(owner?.ownerType) || _inferOwnerTypeFromKey(ownerKey); @@ -1054,12 +1179,12 @@ function _getOwnerDisplayInfo(owner = {}, collisionIndex = null) { let title = baseName; if (hasCrossTypeCollision) { - title = `${baseName}(${typeLabel})`; + title = `${baseName}闂?{typeLabel}闂傚倸鍊烽悞锔锯偓绗涘懐鐭欓柟杈剧稻椤? } else if (sameTypeCount > 1) { title = ownerType === "character" && shortNodeId - ? `${baseName}(${typeLabel} ${shortNodeId})` - : `${baseName}(${typeLabel})`; + ? `${baseName}闂?{typeLabel} ${shortNodeId}闂傚倸鍊烽悞锔锯偓绗涘懐鐭欓柟杈剧稻椤? + : `${baseName}闂?{typeLabel}闂傚倸鍊烽悞锔锯偓绗涘懐鐭欓柟杈剧稻椤? } const subtitleParts = [typeLabel]; @@ -1070,16 +1195,16 @@ function _getOwnerDisplayInfo(owner = {}, collisionIndex = null) { return { title, typeLabel, - subtitle: subtitleParts.join(" · "), + subtitle: subtitleParts.join(" 闂?"), avatarText: baseName.charAt(0) || "?", avatarSeed: ownerKey || `${ownerType}:${baseName}`, tooltip: [title, ownerKey && ownerKey !== title ? ownerKey : ""] .filter(Boolean) - .join(" · "), + .join(" 闂?"), }; } -// ==================== 认知视图工作区 ==================== +// ==================== 闂傚倷娴囧畷鍨叏閹惰姤鈷旂€广儱顦壕瑙勪繆閵堝懏鍣洪柛瀣€块弻銊モ槈濡警浠鹃梺鍝ュТ濡繈寮诲☉銏犵労闁告劗鍋撻悾椋庣磽娴g鈧悂鎮ц箛娑樼劦妞ゆ帒鍠氬鎰版煙椤旇偐鍩g€规洘娲熼獮宥夘敊閸撗屾Ц闂備礁鎼崯顐︽偋閸℃瑧鐭?==================== function _refreshCognitionWorkspace() { const graph = _getGraph?.(); @@ -1116,8 +1241,8 @@ function _renderCogStatusStrip(graph, loadInfo, canRender) { historyState.activeRegion || historyState.lastExtractedRegion || regionState.manualActiveRegion || "", ).trim(); const activeRegionLabel = activeRegion - ? `${activeRegion}${historyState.activeRegionSource ? ` · ${historyState.activeRegionSource}` : ""}` - : "—"; + ? `${activeRegion}${historyState.activeRegionSource ? ` 闂?${historyState.activeRegionSource}` : ""}` + : "闂?; const adjacentRegions = Array.isArray(regionState?.adjacencyMap?.[activeRegion]?.adjacent) ? regionState.adjacencyMap[activeRegion].adjacent : []; @@ -1125,8 +1250,8 @@ function _renderCogStatusStrip(graph, loadInfo, canRender) { historyState.activeStoryTimeLabel || "", ).trim(); const activeStoryTimeMeta = activeStoryTimeLabel - ? `${activeStoryTimeLabel}${historyState.activeStoryTimeSource ? ` · ${historyState.activeStoryTimeSource}` : ""}` - : "—"; + ? `${activeStoryTimeLabel}${historyState.activeStoryTimeSource ? ` 闂?${historyState.activeStoryTimeSource}` : ""}` + : "闂?; const recentStorySegments = Array.isArray(timelineState?.recentSegmentIds) ? timelineState.recentSegmentIds .map((segmentId) => @@ -1138,34 +1263,34 @@ function _renderCogStatusStrip(graph, loadInfo, canRender) { el.innerHTML = `
-
当前场景锚点
+
闂備浇宕甸崰鎰垝鎼淬垺娅犳俊銈呮噹缁犱即鏌涘☉姗堟敾婵炲懐濞€閺岋絽螣濞嗘儳娈梺鍦嚀閻栧ジ寮婚敐澶婄闁绘劕妫欓崹鍧楀箖閻㈠壊鏁嶉柣鎰ˉ閹锋椽姊洪崷顓х劸閻庢稈鏅犻幆鍐箣閿旂晫鍘?/div>
${_escHtml( activeOwnerLabels.length > 0 ? activeOwnerLabels.join(" / ") : activeOwner ? _getOwnerDisplayInfo(activeOwner, collisionIndex).title - : activeOwnerKey || "—", + : activeOwnerKey || "闂?, )}
-
当前地区
+
闂備浇宕甸崰鎰垝鎼淬垺娅犳俊銈呮噹缁犱即鏌涘☉姗堟敾婵炲懐濞€閺岋絽螣濞嗘儳娈梺鍦嚀閻栧ジ寮婚埄鍐ㄧ窞濠电姴瀚搹搴ㄦ⒒?/div>
${_escHtml(activeRegionLabel)}
-
邻接地区
-
${_escHtml(adjacentRegions.length > 0 ? adjacentRegions.join(" / ") : "—")}
+
闂傚倸鍊搁崐椋庢閿熺姴鍌ㄩ柛鎾楀啫鐏婂銈嗙墬缁秹寮冲鍫熺厵缂備降鍨归弸娑㈡煙閻熸壆鍩i柡灞稿墲瀵板嫭绻濋崟顏囨闂?/div> +
${_escHtml(adjacentRegions.length > 0 ? adjacentRegions.join(" / ") : "闂?)}
-
认知角色数
+
闂傚倷娴囧畷鍨叏閹惰姤鈷旂€广儱顦壕瑙勪繆閵堝懏鍣洪柛瀣€块弻銊モ槈濡警浠鹃梺鍝ュТ濡繈寮婚悢鍏煎€绘俊顖濆吹椤︺儱顪冮妶鍐ㄥ姎缂佺粯锕㈠?/div>
${owners.length}
-
当前剧情时间
+
闂備浇宕甸崰鎰垝鎼淬垺娅犳俊銈呮噹缁犱即鏌涘☉姗堟敾婵炲懐濞€閺岋絽螣濞嗘儳娈紓浣插亾闁割偁鍨洪崰鎰板箹濞n剙濡肩痪鎯ф健閻擃偊宕堕妸褉妲堢紓渚囧亜缁夊綊寮诲☉銏╂晝闁挎繂妫涢ˇ銊╂⒑?/div>
${_escHtml(activeStoryTimeMeta)}
-
最近时间段
-
${_escHtml(recentStorySegments.length ? recentStorySegments.join(" / ") : "—")}
+
闂傚倸鍊风粈渚€骞栭锔藉亱闁告劦鍠栫壕濠氭煙閹规劦鍤欑紒鐙欏洦鐓冮柛婵嗗閳ь剚鎮傞幃姗€鏁傞幋鎺旂畾闂佺粯鍔栬ぐ鍐焵椤掆偓閻忔繈锝炶箛娑欏殥闁靛牆鍊告禍鐐箾閹寸偟鎳曞〒姘洴閺?/div> +
${_escHtml(recentStorySegments.length ? recentStorySegments.join(" / ") : "闂?)}
`; } @@ -1184,7 +1309,7 @@ function _renderCogOwnerList(graph, canRender) { const collisionIndex = _buildOwnerCollisionIndex(owners); if (!owners.length) { - el.innerHTML = `
暂无认知角色
`; + el.innerHTML = `
闂傚倸鍊风粈渚€骞栭鈶芥稑螖閸涱厾锛欓梺鑽ゅ枑鐎氬牆鈽夐姀鐘栄囨煕閵夛絽濡兼い搴㈢洴濮婃椽妫冨☉姘暫濠电偛鐪伴崝鎴濈暦閿濆鐒垫い鎺戝閻撶喖鏌i弬鎸庢喐闁瑰啿瀚伴幃浠嬵敍濠婂啯鐎剧紓?/div>`; return; } @@ -1209,7 +1334,7 @@ function _renderCogOwnerList(graph, canRender) {
${_escHtml(displayInfo.title)}
${_escHtml(displayInfo.typeLabel)}
-
已知 ${Number(owner.knownCount || 0)} · 误解 ${Number(owner.mistakenCount || 0)} · 隐藏 ${Number(owner.manualHiddenCount || 0)}
+
闂備浇顕у锕傦綖婢舵劖鍋ら柡鍥╁С閻掑﹥绻涢崱妯诲鞍闁?${Number(owner.knownCount || 0)} 闂?闂傚倷娴囧畷鍨叏閺夋嚚褰掑磼閻愭彃鐎繛杈剧到閸犳岸寮?${Number(owner.mistakenCount || 0)} 闂?闂傚倸鍊搁崐鎼佸磹閹间礁绠犻煫鍥ㄧ☉缁€澶嬩繆椤栨瑧绉?${Number(owner.manualHiddenCount || 0)}
`; }) @@ -1232,7 +1357,7 @@ function _renderCogOwnerDetail(graph, loadInfo, canRender) { ); if (!selectedOwner) { - el.innerHTML = `
选择上方角色查看详情,或等待提取产生认知数据。
`; + el.innerHTML = `
闂傚倸鍊搁崐椋庢閿熺姴纾婚柛鏇ㄥ瀬閸ヮ剙绠ユい鏃傛嚀娴滅偓鎱ㄥΟ绋垮姎濠碉紕鏅槐鎺斺偓锝庝憾濡插湱绱掔紒妯肩疄鐎规洘锕㈤崺锟犲礃閵娿儳顓奸梻鍌欐祰瀹曠敻宕伴幇鐗堝仭闁挎梻鍋撻弳婊堟煟閺傛寧鎲搁柣婵婃硾閳规垿鎮╅崣澶嬫倷闂佽棄鍟伴崰鏍蓟閺囩喓鐝舵い鏍ㄨ壘椤忣偊鏌i敐鍕煓闁哄矉缍侀幃鈺呭矗婢跺被鍋掗梻鍌氬€哥€氼剛鈧碍婢橀悾鐑藉即閿涘嫮鏉稿┑鐐村灦閻燂箑鈻嶉弽顓熺厽闊洦娲栨禒锕傛煕鎼淬垹鈻曢柟顔矫灃闁告劦浜為敍婊冣攽閻愭潙鐏﹂柨鏇楁櫅鍗遍柛婵勫劤绾惧ジ鏌涢幘鑼槮闁哄绋掗〃銉╂倷鐎涙ê纾冲Δ鐘靛仜濞差參銆佸Δ鍛劦妞ゆ帒鍊哥欢銈夋煕椤垵鏅归柣鐔稿閺€锕傛煟濡搫绾ч柛锝嗘そ閺岋繝宕ㄩ钘夆偓鎰版煙椤旀娼愰柟宄版嚇濡啫鈽夐幒鎴滃濠德板€曢幊蹇涘磻閸岀偞鐓ラ柣鏂挎惈瀛濋梺鍝勵儎缁舵岸寮婚敐鍛傛棃鍩€椤掑嫭鍋嬮柛鈩冪懅閻牓鏌ㄩ弴鐐测偓褰掓偂?/div>`; return; } @@ -1270,8 +1395,8 @@ function _renderCogOwnerDetail(graph, loadInfo, canRender) { ? "mistaken" : "none" : ""; - const stateLabels = { known: "强制已知", hidden: "强制隐藏", mistaken: "误解", none: "未覆盖" }; - const selectedNodeStateLabel = stateLabels[selectedNodeState] || "未选中节点"; + const stateLabels = { known: "闂備浇顕х€涒晠顢欓弽顓炵獥闁哄稁鍘肩粻瑙勩亜閹板墎鐣遍柡鍕╁劜娣囧﹪濡堕崒姘婵$偑鍊栭弻銊ф崲濮椻偓楠炲﹪鎮╁ú缁樻櫌闂佺鏈懝鍓х磼?, hidden: "闂備浇顕х€涒晠顢欓弽顓炵獥闁哄稁鍘肩粻瑙勩亜閹板墎鐣遍柡鍕╁劜娣囧﹪濡堕崨顔兼闂佹娊鏀卞ú鐔煎蓟閻斿吋鐒介柨鏇楀亾闁诲繆鍓濋妵?, mistaken: "闂傚倷娴囧畷鍨叏閺夋嚚褰掑磼閻愭彃鐎繛杈剧到閸犳岸寮?, none: "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鏌ユ煥濠靛棭妲堕柍褜鍓欓幊姗€銆侀弴銏℃櫆闁芥ê顦竟? }; + const selectedNodeStateLabel = stateLabels[selectedNodeState] || "闂傚倸鍊风粈渚€骞栭锔藉亱婵犲﹤瀚々鍙夈亜韫囨挾澧曢柣鎺戠仛閵囧嫰骞掗崱妞惧婵$偑鍊х€靛矂宕抽敐澶婄疇婵炲棙鎸哥粻锝夋煥閺冨洦顥夋繛鍫㈠枛濮婅櫣绮欓幐搴㈡嫳缂備緡鍠栭張顒傜矉?; const writeBlocked = _isGraphWriteBlocked(loadInfo); const suppressedCount = new Set([...(ownerState.manualHiddenNodeIds || []), ...(ownerState.mistakenNodeIds || [])]).size; const disabledAttr = !selectedNode || writeBlocked ? "disabled" : ""; @@ -1279,67 +1404,67 @@ function _renderCogOwnerDetail(graph, loadInfo, canRender) { const visChips = strongVisibleNames.length ? strongVisibleNames.map((n) => `${_escHtml(n)}`).join("") - : '暂无'; + : '闂傚倸鍊风粈渚€骞栭鈶芥稑螖閸涱厾锛欓梺鑽ゅ枑鐎?/span>'; const supChips = suppressedNames.length ? suppressedNames.map((n) => `${_escHtml(n)}`).join("") - : '暂无'; + : '闂傚倸鍊风粈渚€骞栭鈶芥稑螖閸涱厾锛欓梺鑽ゅ枑鐎?/span>'; el.innerHTML = `
${_escHtml(displayInfo.title)}
${_escHtml( - [displayInfo.subtitle, selectedOwner.ownerKey || ""].filter(Boolean).join(" · "), + [displayInfo.subtitle, selectedOwner.ownerKey || ""].filter(Boolean).join(" 闂?"), )}
${ selectedOwner.ownerKey === activeOwnerKey || activeOwnerKeys.includes(selectedOwner.ownerKey) - ? '当前场景锚点' + ? '闂備浇宕甸崰鎰垝鎼淬垺娅犳俊銈呮噹缁犱即鏌涘☉姗堟敾婵炲懐濞€閺岋絽螣濞嗘儳娈梺鍦嚀閻栧ジ寮婚敐澶婄闁绘劕妫欓崹鍧楀箖閻㈠壊鏁嶉柣鎰ˉ閹锋椽姊洪崷顓х劸閻庢稈鏅犻幆鍐箣閿旂晫鍘?/span>' : "" }
-
已知锚点
+
闂備浇顕у锕傦綖婢舵劖鍋ら柡鍥╁С閻掑﹥绻涢崱妯诲鞍闁稿鍊块弻銊╂偄閸濆嫅锝夋煟閹惧崬鍔滈柟渚垮妼铻i柛婵嗗閸╃偞绻?/div>
${Number(selectedOwner.knownCount || 0)}
-
误解节点
+
闂傚倷娴囧畷鍨叏閺夋嚚褰掑磼閻愭彃鐎繛杈剧到閸犳岸寮虫导瀛樷拻濞达絽鎼敮鍫曟煙绾板崬浜扮€规洘鍔栫换婵嗩潩椤掑偊绱?/div>
${Number(selectedOwner.mistakenCount || 0)}
-
强可见
+
闂備浇顕х€涒晠顢欓弽顓炵獥闁哄稁鍘肩粻瑙勩亜閹扳晛鍔樺ù婊冪秺閺屻倗鍠婇崡鐐差潽闂?/div>
${strongVisibleNames.length}
-
被压制
+
闂傚倷娴囧畷鐢稿磻閻愬搫绀勭憸鐗堝笒绾惧鏌涢弴銊ュ箺闁哄棙绮撻弻娑㈠Ψ椤旂厧顫╃紓浣插亾?/div>
${suppressedCount}
-
强可见节点 · ACTIVE VISIBILITY
+
闂備浇顕х€涒晠顢欓弽顓炵獥闁哄稁鍘肩粻瑙勩亜閹扳晛鍔樺ù婊冪秺閺屻倗鍠婇崡鐐差潽闂佸摜濮村Λ妤呮箒闂佹寧绻傞悧濠囁夋径鎰嚉闁跨喓濮甸埛?闂?ACTIVE VISIBILITY
${visChips}
-
被压制节点 · SUPPRESSED
+
闂傚倷娴囧畷鐢稿磻閻愬搫绀勭憸鐗堝笒绾惧鏌涢弴銊ュ箺闁哄棙绮撻弻娑㈠Ψ椤旂厧顫╃紓浣插亾闁糕剝绋掗悡娆愩亜閺嶃劎鈯曠紒鑸电叀閹藉爼鏁撻悩鏂ユ嫼?闂?SUPPRESSED
${supChips}
-
对当前选中节点做手动覆盖
+
闂傚倷娴囬褍霉閻戣棄鏋侀柟闂寸缁犵娀鏌熼幆褍顣崇紒鈧繝鍥ㄥ€甸柨婵嗛閺嬫稓绱掗埀顒勫醇閳垛晛浜炬鐐茬仢閸旀碍銇勯敂璇蹭喊鐎规洘鍨块獮姗€寮堕幋鐘电嵁濠电姰鍨煎▔娑欘殽閹间胶宓侀柍褜鍓熷缁樻媴閸濆嫬浠橀梺鍦拡閸嬪﹤鐣烽幇顓犵瘈婵﹩鍓涢敍娑㈡⒑閹稿海绠撴い锔诲灣婢规洜绱掑Ο璇插伎濠殿喗顨呭Λ妤呯嵁閺嶃劊浜滈柕鍫濆缁愭棃鏌$仦鍓ф创鐎殿噮鍓涢幏鐘诲箵閹烘繃缍傚┑锛勫亼閸婃劙寮查埡鍛;濠电姴娲ょ粻?/div>
${ selectedNode - ? `当前节点:${_escHtml(selectedNodeLabel)} · ${_escHtml(selectedNodeStateLabel)}` - : "先在实时图谱或记忆列表中选中一个节点。" + ? `闂備浇宕甸崰鎰垝鎼淬垺娅犳俊銈呮噹缁犱即鏌涘☉姗堟敾婵炲懐濞€閺岋絽螣閼测晛绗¢梺鎼炲€曠粔褰掑蓟濞戞矮娌柛鎾楀懐鍘愬┑鐐差嚟婵參宕归崼鏇炶摕?{_escHtml(selectedNodeLabel)} 闂?${_escHtml(selectedNodeStateLabel)}` + : "闂傚倸鍊烽懗鍫曗€﹂崼銏″床闁规壆澧楅崑瀣煕閳╁喚娈i柤鐗堝閵囧嫯绠涢幘鎼闂佺顑嗛幑鍥х暦閻戠瓔鏁囬柣鎰閸曞啯绻濈喊澶岀?闁稿鐩畷鎰板垂椤旂偓娈鹃梺鍓插亝濞叉牜绮婚弻銉︾厵闂侇叏绠戦獮妯荤箾閸稑鈧繂顫忓ú顏呭仭闁规鍠楅幉濂告⒑缂佹﹩娈橀柛瀣ㄥ€濋妴浣糕枎閹惧磭鍊炲銈呯箰濡盯寮堕幖浣光拺缂侇垱娲栨晶鍙夈亜閵娿儲鍤囬柟顔矫埢搴ㄥ箻閺夋垳鍝楁繝鐢靛仦閸ㄥ爼鎮ч弴鐔剁箚婵炲樊浜濋悡鐘绘煕椤垵浜滈柣蹇旀尦閺岋紕浠﹂懞銉ユ閻庡灚婢樼€氼厼顕ラ崟顒佸劅闁炽儱纾悾杈ㄧ節閻㈤潧浠﹂柛銊ョ埣楠炴劙骞橀鑲╋紱闂佸湱鍋撻弸鐓幬i崼銉︾厵闁割煈鍠栭弳鏇熺箾閺夋垵妲婚柍瑙勫灴閸┿儵宕卞Δ鈧猾宥夋⒑鐠団€虫灍闁荤啿鏅犻幃浼搭敊閼恒儱鍔呴梺瑙勫劤閸樻牗绔? }
- - - - +
`; @@ -1368,38 +1493,38 @@ function _renderCogSpaceTools(graph, loadInfo, canRender) { el.innerHTML = `
- +
- +