diff --git a/index.js b/index.js index 4f278e2..1dba48c 100644 --- a/index.js +++ b/index.js @@ -330,6 +330,7 @@ import { onClearBatchJournalController, onDeleteCurrentIdbController, onDeleteAllIdbController, + onExportDiagnosticsBundleController, onDeleteServerSyncFileController, } from "./ui/ui-actions-controller.js"; import { @@ -377,6 +378,11 @@ import { createAuthorityBlobAdapter, normalizeAuthorityBlobConfig, } from "./maintenance/authority-blob-adapter.js"; +import { + buildAuthorityDiagnosticsBundle, + buildAuthorityDiagnosticsBundlePath, + writeAuthorityDiagnosticsBundle as writeAuthorityDiagnosticsBundleFile, +} from "./maintenance/authority-diagnostics-bundle.js"; export { DEFAULT_TRIGGER_KEYWORDS, getSmartTriggerDecision }; @@ -1897,6 +1903,18 @@ function getGraphPersistenceLiveState() { graphPersistenceState.loadDiagnostics, null, ), + authorityDiagnosticsBundlePath: String( + graphPersistenceState.authorityDiagnosticsBundlePath || "", + ), + authorityDiagnosticsBundleReason: String( + graphPersistenceState.authorityDiagnosticsBundleReason || "", + ), + authorityDiagnosticsBundleUpdatedAt: String( + graphPersistenceState.authorityDiagnosticsBundleUpdatedAt || "", + ), + authorityDiagnosticsBundleSize: Number( + graphPersistenceState.authorityDiagnosticsBundleSize || 0, + ), }; return cloneRuntimeDebugValue(snapshot, snapshot); @@ -2018,6 +2036,17 @@ function shouldUseAuthorityBlobCheckpoint() { ); } +function shouldUseAuthorityDiagnosticsBundle() { + const settings = getSettings(); + const authoritySettings = normalizeAuthoritySettings(settings); + const { capability } = getAuthorityRuntimeSnapshot(settings); + return Boolean( + authoritySettings.enabled && + settings.authorityDiagnosticsEnabled !== false && + capability.blobReady, + ); +} + function getAuthorityBlobAdapter(options = {}) { const settings = getSettings(); const config = normalizeAuthorityBlobConfig(settings); @@ -2151,6 +2180,103 @@ async function readAuthorityLukerCheckpointBlob(chatId = "", options = {}) { } } +async function exportAuthorityDiagnosticsBundle({ + chatId = "", + reason = "diagnostics-bundle", + signal = undefined, + refreshHost = false, +} = {}) { + const normalizedChatId = + normalizeChatIdCandidate(chatId) || + normalizeChatIdCandidate(graphPersistenceState.chatId) || + normalizeChatIdCandidate(getCurrentChatId()); + if (!normalizedChatId) { + return { + ok: false, + reason: "missing-chat-id", + }; + } + if (!shouldUseAuthorityDiagnosticsBundle()) { + return { + ok: false, + reason: "authority-diagnostics-unavailable", + }; + } + const normalizedReason = String(reason || "diagnostics-bundle").trim() || "diagnostics-bundle"; + const path = buildAuthorityDiagnosticsBundlePath(normalizedChatId, normalizedReason); + try { + const bundle = buildAuthorityDiagnosticsBundle({ + chatId: normalizedChatId, + reason: normalizedReason, + settings: getSettings(), + runtimeStatus: getPanelRuntimeStatus(), + runtimeDebug: getPanelRuntimeDebugSnapshot({ refreshHost }), + graphPersistence: getGraphPersistenceLiveState(), + graph: currentGraph, + lastExtractionStatus, + lastVectorStatus, + lastRecallStatus, + lastBatchStatus: currentGraph?.historyState?.lastBatchStatus || null, + lastInjection: lastInjectionContent, + lastExtract: lastExtractedItems, + lastRecall: lastRecalledItems, + }); + const adapter = getAuthorityBlobAdapter(); + const result = await writeAuthorityDiagnosticsBundleFile(adapter, bundle, { + chatId: normalizedChatId, + reason: normalizedReason, + path, + signal, + }); + const bundleSize = Math.max( + 0, + Number(result?.result?.size || 0) || JSON.stringify(bundle).length || 0, + ); + recordAuthorityBlobSnapshot({ + action: "diagnostics-write", + ok: result?.ok !== false, + backend: "authority-blob", + path: result?.path || path, + reason: normalizedReason, + size: bundleSize, + }); + updateGraphPersistenceState({ + authorityDiagnosticsBundlePath: String(result?.path || path), + authorityDiagnosticsBundleReason: normalizedReason, + authorityDiagnosticsBundleUpdatedAt: new Date().toISOString(), + authorityDiagnosticsBundleSize: bundleSize, + }); + return { + ok: result?.ok !== false, + path: String(result?.path || path), + size: bundleSize, + result, + }; + } catch (error) { + const message = error?.message || String(error) || "Authority diagnostics bundle failed"; + recordAuthorityBlobSnapshot({ + action: "diagnostics-write", + ok: false, + backend: "authority-blob", + path, + reason: normalizedReason, + error: message, + }); + updateGraphPersistenceState({ + authorityDiagnosticsBundlePath: path, + authorityDiagnosticsBundleReason: normalizedReason, + authorityDiagnosticsBundleUpdatedAt: new Date().toISOString(), + authorityDiagnosticsBundleSize: 0, + }); + return { + ok: false, + path, + reason: "authority-diagnostics-bundle-error", + error, + }; + } +} + async function readLukerGraphSidecarV2WithAuthorityBlob(context = null, options = {}) { const sidecar = await readLukerGraphSidecarV2(context, options); if (sidecar?.checkpoint) return sidecar; @@ -20524,6 +20650,7 @@ const _cleanupRuntime = () => ({ createEmptyGraph, clearInjectionState, ensureGraphMutationReady, + exportDiagnosticsBundle: async (options = {}) => await exportAuthorityDiagnosticsBundle(options), getCurrentChatId, getCurrentGraph: () => currentGraph, markVectorStateDirty: (reason) => { @@ -20538,6 +20665,7 @@ const _cleanupRuntime = () => ({ saveGraphToChat, syncGraphLoadFromLiveContext, setCurrentGraph: (graph) => { currentGraph = graph; }, + setRuntimeStatus, setExtractionCount: (count) => { if (currentGraph?.historyState) { currentGraph.historyState.extractionCount = count; @@ -20610,6 +20738,10 @@ async function onDeleteServerSyncFile() { return await onDeleteServerSyncFileController(_cleanupRuntime()); } +async function onExportDiagnosticsBundle() { + return await onExportDiagnosticsBundleController(_cleanupRuntime()); +} + async function onBackupCurrentChatToCloud() { const chatId = getCurrentChatId(); if (!chatId) { @@ -21087,6 +21219,7 @@ async function onCompactLukerSidecar() { clearBatchJournal: onClearBatchJournal, deleteCurrentIdb: onDeleteCurrentIdb, deleteAllIdb: onDeleteAllIdb, + exportDiagnosticsBundle: onExportDiagnosticsBundle, deleteServerSyncFile: onDeleteServerSyncFile, backupToCloud: onBackupCurrentChatToCloud, restoreFromCloud: onRestoreCurrentChatFromCloud, diff --git a/maintenance/authority-diagnostics-bundle.js b/maintenance/authority-diagnostics-bundle.js new file mode 100644 index 0000000..f03d226 --- /dev/null +++ b/maintenance/authority-diagnostics-bundle.js @@ -0,0 +1,260 @@ +function safeClone(value, fallback = null) { + if (value == null) return fallback; + try { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + } catch { + } + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return fallback ?? value; + } +} + +function normalizeRecordId(value) { + return String(value ?? "").trim(); +} + +function truncateText(value, maxLength = 4000) { + const text = String(value ?? ""); + if (!text) return ""; + if (!Number.isFinite(maxLength) || maxLength < 1 || text.length <= maxLength) { + return text; + } + return `${text.slice(0, Math.max(1, maxLength - 1))}…`; +} + +function buildHash(input = "") { + let hash = 2166136261; + const text = String(input ?? ""); + for (let index = 0; index < text.length; index += 1) { + hash ^= text.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function buildSafeSlug(input = "", fallback = "unknown") { + const normalized = String(input || fallback) + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^[_.-]+|[_.-]+$/g, "") + .slice(0, 96); + return normalized || fallback; +} + +function buildCompactTimestamp(date = new Date()) { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + const day = String(date.getUTCDate()).padStart(2, "0"); + const hour = String(date.getUTCHours()).padStart(2, "0"); + const minute = String(date.getUTCMinutes()).padStart(2, "0"); + const second = String(date.getUTCSeconds()).padStart(2, "0"); + return `${year}${month}${day}-${hour}${minute}${second}`; +} + +function isSensitiveKey(key = "") { + return /(api[_-]?key|token|secret|password|authorization|auth[_-]?header|cookie)/i.test( + String(key || ""), + ); +} + +function sanitizeValue(value, key = "", depth = 0) { + if (depth > 8) { + return "[Truncated]"; + } + if (value == null) return value; + if (isSensitiveKey(key)) { + return String(value || "") ? "[REDACTED]" : ""; + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeValue(entry, "", depth + 1)); + } + if (typeof value === "object") { + const result = {}; + for (const [childKey, childValue] of Object.entries(value)) { + result[childKey] = sanitizeValue(childValue, childKey, depth + 1); + } + return result; + } + return value; +} + +function buildGraphSummary(graph = null) { + const nodes = Array.isArray(graph?.nodes) ? graph.nodes : []; + const edges = Array.isArray(graph?.edges) ? graph.edges : []; + const activeNodes = nodes.filter((node) => !node?.archived); + const archivedNodes = nodes.filter((node) => node?.archived); + const typeCounts = {}; + for (const node of activeNodes) { + const type = String(node?.type || "unknown"); + typeCounts[type] = (typeCounts[type] || 0) + 1; + } + return { + nodeCount: nodes.length, + activeNodeCount: activeNodes.length, + archivedNodeCount: archivedNodes.length, + edgeCount: edges.length, + typeCounts, + historyState: safeClone( + { + chatId: graph?.historyState?.chatId || "", + extractionCount: Number(graph?.historyState?.extractionCount || 0), + lastProcessedAssistantFloor: Number( + graph?.historyState?.lastProcessedAssistantFloor ?? -1, + ), + activeRegion: String(graph?.historyState?.activeRegion || ""), + activeStorySegmentId: String( + graph?.historyState?.activeStorySegmentId || "", + ), + activeStoryTimeLabel: String( + graph?.historyState?.activeStoryTimeLabel || "", + ), + activeRecallOwnerKey: String( + graph?.historyState?.activeRecallOwnerKey || "", + ), + activeRecallOwnerKeys: Array.isArray( + graph?.historyState?.activeRecallOwnerKeys, + ) + ? graph.historyState.activeRecallOwnerKeys.map((value) => String(value || "")) + : [], + lastRecoveryResult: graph?.historyState?.lastRecoveryResult || null, + }, + null, + ), + vectorIndexState: safeClone( + { + mode: String(graph?.vectorIndexState?.mode || ""), + source: String(graph?.vectorIndexState?.source || ""), + dirty: Boolean(graph?.vectorIndexState?.dirty), + dirtyReason: String(graph?.vectorIndexState?.dirtyReason || ""), + lastWarning: String(graph?.vectorIndexState?.lastWarning || ""), + collectionId: String(graph?.vectorIndexState?.collectionId || ""), + indexedCount: Number( + Object.keys(graph?.vectorIndexState?.hashToNodeId || {}).length || 0, + ), + }, + null, + ), + summaryState: safeClone( + { + enabled: graph?.summaryState?.enabled !== false, + lastSummarizedExtractionCount: Number( + graph?.summaryState?.lastSummarizedExtractionCount || 0, + ), + lastSummarizedAssistantFloor: Number( + graph?.summaryState?.lastSummarizedAssistantFloor ?? -1, + ), + }, + null, + ), + }; +} + +function buildStatusSnapshot(status = null) { + if (!status || typeof status !== "object" || Array.isArray(status)) { + return null; + } + return safeClone( + { + text: String(status.text || ""), + meta: truncateText(status.meta || "", 2000), + level: String(status.level || "idle"), + updatedAt: Number(status.updatedAt || 0), + }, + null, + ); +} + +export function sanitizeDiagnosticsSettings(settings = {}) { + return sanitizeValue(settings, "settings", 0); +} + +export function buildAuthorityDiagnosticsBundlePath(chatId = "", reason = "diagnostics") { + const normalizedChatId = normalizeRecordId(chatId); + const safeChatId = buildSafeSlug(normalizedChatId || "global"); + const safeReason = buildSafeSlug(reason || "diagnostics", "diagnostics"); + const hash = buildHash(`${normalizedChatId}:${safeReason}`); + const timestamp = buildCompactTimestamp(new Date()); + return `user/files/ST-BME_diagnostics_${safeChatId}-${safeReason}-${hash}-${timestamp}.json`; +} + +export function buildAuthorityDiagnosticsBundle({ + chatId = "", + reason = "diagnostics-bundle", + settings = {}, + runtimeStatus = null, + runtimeDebug = null, + graphPersistence = null, + graph = null, + lastExtractionStatus = null, + lastVectorStatus = null, + lastRecallStatus = null, + lastBatchStatus = null, + lastInjection = "", + lastExtract = [], + lastRecall = [], +} = {}) { + const createdAt = new Date().toISOString(); + return { + kind: "st-bme-authority-diagnostics", + bundleVersion: 1, + createdAt, + chatId: normalizeRecordId(chatId), + reason: String(reason || "diagnostics-bundle"), + settings: sanitizeDiagnosticsSettings(settings || {}), + runtimeStatus: buildStatusSnapshot(runtimeStatus), + runtimeDebug: safeClone(runtimeDebug, null), + graphPersistence: safeClone(graphPersistence, null), + graphSummary: buildGraphSummary(graph), + lastStatuses: { + extraction: buildStatusSnapshot(lastExtractionStatus), + vector: buildStatusSnapshot(lastVectorStatus), + recall: buildStatusSnapshot(lastRecallStatus), + batch: safeClone(lastBatchStatus, null), + }, + lastInjection: { + textPreview: truncateText(lastInjection, 4000), + textLength: String(lastInjection || "").length, + }, + recentExtractedItems: safeClone( + (Array.isArray(lastExtract) ? lastExtract : []).slice(0, 20), + [], + ), + recentRecalledItems: safeClone( + (Array.isArray(lastRecall) ? lastRecall : []).slice(0, 20), + [], + ), + }; +} + +export async function writeAuthorityDiagnosticsBundle(adapter, bundle = null, options = {}) { + if (!adapter || typeof adapter.writeJson !== "function") { + throw new Error("Authority diagnostics adapter unavailable"); + } + const chatId = normalizeRecordId(options.chatId || bundle?.chatId); + const reason = String(options.reason || bundle?.reason || "diagnostics-bundle"); + const path = + options.path || buildAuthorityDiagnosticsBundlePath(chatId, reason); + const result = await adapter.writeJson(path, safeClone(bundle, bundle), { + signal: options.signal, + metadata: safeClone( + { + chatId, + reason, + kind: "diagnostics-bundle", + bundleVersion: Number(bundle?.bundleVersion || 1), + createdAt: bundle?.createdAt || new Date().toISOString(), + }, + {}, + ), + }); + return { + ok: result?.ok !== false, + path: String(result?.path || path), + result, + }; +} diff --git a/tests/authority-diagnostics-bundle.mjs b/tests/authority-diagnostics-bundle.mjs new file mode 100644 index 0000000..d6532af --- /dev/null +++ b/tests/authority-diagnostics-bundle.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; + +import { + buildAuthorityDiagnosticsBundle, + buildAuthorityDiagnosticsBundlePath, + sanitizeDiagnosticsSettings, + writeAuthorityDiagnosticsBundle, +} from "../maintenance/authority-diagnostics-bundle.js"; + +function createMockAdapter() { + const calls = []; + return { + calls, + async writeJson(path, payload, options = {}) { + calls.push([path, payload, options]); + return { + ok: true, + path, + size: JSON.stringify(payload).length, + }; + }, + }; +} + +{ + const sanitized = sanitizeDiagnosticsSettings({ + authorityBaseUrl: "https://example.test", + authorityApiKey: "secret-1", + nested: { + embeddingApiKey: "secret-2", + label: "ok", + }, + models: [ + { name: "a", token: "secret-3" }, + { name: "b", enabled: true }, + ], + }); + assert.equal(sanitized.authorityBaseUrl, "https://example.test"); + assert.equal(sanitized.authorityApiKey, "[REDACTED]"); + assert.equal(sanitized.nested.embeddingApiKey, "[REDACTED]"); + assert.equal(sanitized.nested.label, "ok"); + assert.equal(sanitized.models[0].token, "[REDACTED]"); +} + +{ + const bundle = buildAuthorityDiagnosticsBundle({ + chatId: "chat/main", + reason: "manual-export", + settings: { + authorityApiKey: "secret-1", + authorityBlobEnabled: true, + }, + runtimeStatus: { + text: "待命", + meta: "准备就绪", + level: "idle", + updatedAt: 1, + }, + runtimeDebug: { + runtimeDebug: { + injections: { + recall: { + retrievalMeta: { + authorityCandidateUsed: true, + }, + }, + }, + }, + }, + graphPersistence: { + authorityBlobState: "active", + authorityLastBlobPath: "user/files/demo.json", + }, + graph: { + nodes: [ + { id: "n1", type: "memory", archived: false }, + { id: "n2", type: "summary", archived: true }, + ], + edges: [{ id: "e1" }], + historyState: { + chatId: "chat/main", + extractionCount: 4, + lastProcessedAssistantFloor: 12, + activeRegion: "archive", + activeRecallOwnerKeys: ["character:Alice"], + }, + vectorIndexState: { + mode: "authority", + source: "authority-trivium", + dirty: false, + collectionId: "collection-1", + hashToNodeId: { + a: "n1", + }, + }, + summaryState: { + enabled: true, + lastSummarizedExtractionCount: 3, + }, + }, + lastExtractionStatus: { + text: "提取完成", + meta: "ok", + level: "success", + updatedAt: 2, + }, + lastVectorStatus: { + text: "向量完成", + meta: "ok", + level: "success", + updatedAt: 3, + }, + lastRecallStatus: { + text: "召回完成", + meta: "ok", + level: "success", + updatedAt: 4, + }, + lastBatchStatus: { + ok: true, + stage: "finalize", + }, + lastInjection: "A".repeat(4500), + lastExtract: [{ id: "n1" }], + lastRecall: [{ id: "n2" }], + }); + + assert.equal(bundle.kind, "st-bme-authority-diagnostics"); + assert.equal(bundle.chatId, "chat/main"); + assert.equal(bundle.settings.authorityApiKey, "[REDACTED]"); + assert.equal(bundle.graphSummary.nodeCount, 2); + assert.equal(bundle.graphSummary.activeNodeCount, 1); + assert.equal(bundle.graphSummary.archivedNodeCount, 1); + assert.equal(bundle.graphSummary.edgeCount, 1); + assert.equal(bundle.lastInjection.textLength, 4500); + assert.equal(bundle.lastInjection.textPreview.length, 4000); + assert.equal(bundle.recentExtractedItems.length, 1); + assert.equal(bundle.recentRecalledItems.length, 1); +} + +{ + const path = buildAuthorityDiagnosticsBundlePath("chat/main", "manual-export"); + assert.match( + path, + /^user\/files\/ST-BME_diagnostics_chat_main-manual-export-[a-z0-9]+-\d{8}-\d{6}\.json$/, + ); +} + +{ + const adapter = createMockAdapter(); + const bundle = buildAuthorityDiagnosticsBundle({ + chatId: "chat-main", + reason: "manual-export", + settings: {}, + }); + const result = await writeAuthorityDiagnosticsBundle(adapter, bundle, { + chatId: "chat-main", + reason: "manual-export", + }); + assert.equal(result.ok, true); + assert.match( + result.path, + /^user\/files\/ST-BME_diagnostics_chat-main-manual-export-[a-z0-9]+-\d{8}-\d{6}\.json$/, + ); + assert.equal(adapter.calls.length, 1); + assert.equal(adapter.calls[0][2]?.metadata?.kind, "diagnostics-bundle"); + assert.equal(adapter.calls[0][2]?.metadata?.chatId, "chat-main"); +} + +console.log("authority-diagnostics-bundle tests passed"); diff --git a/ui/panel.html b/ui/panel.html index 4f4d9e1..e2b824d 100644 --- a/ui/panel.html +++ b/ui/panel.html @@ -490,6 +490,10 @@ 压实主 Sidecar +