/* global wx, console, module, require */ const { DEFAULT_AI_PROVIDER, DEFAULT_CODEX_SANDBOX_MODE, DEFAULT_COPILOT_PERMISSION_MODE, normalizeAiProvider, normalizeCodexSandboxMode, normalizeCopilotPermissionMode } = require("./aiLaunch"); const { normalizeTerminalFontFamily, pickBtnColor, pickShellAccentColor } = require("./themeStyle"); const { normalizeUiLanguage } = require("./i18n"); const { DEFAULT_TERMINAL_RESUME_MINUTES, MIN_TERMINAL_RESUME_MINUTES, MAX_TERMINAL_RESUME_MINUTES } = require("./terminalSessionState"); const { DEFAULT_TTS_SPEAKABLE_MAX_CHARS, MIN_TTS_SPEAKABLE_MAX_CHARS, MAX_TTS_SPEAKABLE_MAX_CHARS, DEFAULT_TTS_SEGMENT_MAX_CHARS, MIN_TTS_SEGMENT_MAX_CHARS, MAX_TTS_SEGMENT_MAX_CHARS } = require("./ttsSettings"); /** * 小程序本地存储封装。 * 约束: * 1. 全部走同步 API,保证页面进入时读取行为确定; * 2. 结构保持与 Web 端语义一致,便于后续对齐。 */ const KEYS = { servers: "remoteconn.servers.v2", settings: "remoteconn.settings.v2", logs: "remoteconn.logs.v2", records: "remoteconn.records.v2", pluginPackages: "remoteconn.plugins.packages.v2", pluginRecords: "remoteconn.plugins.records.v2", pluginRuntimeLogs: "remoteconn.plugins.runtimeLogs.v2" }; /** * 稳定备份 key 不随业务版本号变化: * 1. 正式 key 升级时,可直接从备份恢复; * 2. 即使未来继续升 v3/v4,也不需要把备份链条无限拉长。 */ const BACKUP_KEYS = { servers: "remoteconn.backup.servers", settings: "remoteconn.backup.settings", logs: "remoteconn.backup.logs", records: "remoteconn.backup.records", pluginPackages: "remoteconn.backup.plugins.packages", pluginRecords: "remoteconn.backup.plugins.records", pluginRuntimeLogs: "remoteconn.backup.plugins.runtimeLogs" }; /** * 历史 key 候选表: * 1. 只在当前正式 key 缺失时尝试; * 2. 明确列出已知/合理的旧命名,避免运行时“猜 key”。 */ const LEGACY_KEYS = { servers: ["remoteconn.servers.v1", "remoteconn.servers"], settings: ["remoteconn.settings.v1", "remoteconn.settings"], logs: ["remoteconn.logs.v1", "remoteconn.logs"], records: ["remoteconn.records.v1", "remoteconn.records"], pluginPackages: ["remoteconn.plugins.packages.v1", "remoteconn.plugins.packages"], pluginRecords: ["remoteconn.plugins.records.v1", "remoteconn.plugins.records"], pluginRuntimeLogs: ["remoteconn.plugins.runtimeLogs.v1", "remoteconn.plugins.runtimeLogs"] }; let storageBootstrapDone = false; let storageBootstrapInProgress = false; let syncServiceModule = null; const DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK = "未分类"; const DEFAULT_VOICE_RECORD_CATEGORIES = ["未分类", "优化", "新需求", "问题", "灵感"]; const DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY = "优化"; const DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES = 200; const MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES = 20; const MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES = 5000; const OPS_SETTING_KEYS = new Set([ "gatewayUrl", "gatewayToken", "hostKeyPolicy", "credentialMemoryPolicy", "gatewayConnectTimeoutMs", "waitForConnectedTimeoutMs", "terminalBufferMaxEntries", "terminalBufferMaxBytes", "maskSecrets" ]); const DEFAULT_SETTINGS = { // ── 界面主题(用户可调)──────────────────────────────────────────────────── uiLanguage: "zh-Hans", uiThemeMode: "dark", uiThemePreset: "tide", uiAccentColor: "#5bd2ff", uiBgColor: "#192b4d", uiTextColor: "#e6f0ff", uiBtnColor: pickBtnColor("#192b4d", "#e6f0ff"), // ── 连接与服务器默认参数(用户可调)──────────────────────────────────────── autoReconnect: true, syncConfigEnabled: true, reconnectLimit: 3, backgroundSessionKeepAliveMinutes: DEFAULT_TERMINAL_RESUME_MINUTES, defaultAuthType: "password", aiDefaultProvider: DEFAULT_AI_PROVIDER, aiCodexSandboxMode: DEFAULT_CODEX_SANDBOX_MODE, aiCopilotPermissionMode: DEFAULT_COPILOT_PERMISSION_MODE, defaultPort: 22, defaultProjectPath: "~/workspace", defaultTimeoutSeconds: 20, defaultHeartbeatSeconds: 15, defaultTransportMode: "gateway", // ── 终端显示与缓冲 ───────────────────────────────────────────────────────── shellThemeMode: "dark", shellThemePreset: "tide", shellBgColor: "#192b4d", shellTextColor: "#e6f0ff", shellAccentColor: pickShellAccentColor("#192b4d", "#e6f0ff"), shellFontFamily: "JetBrains Mono", shellFontSize: 15, shellLineHeight: 1.4, shellActivationDebugOutline: true, showVoiceInputButton: true, ttsSpeakableMaxChars: DEFAULT_TTS_SPEAKABLE_MAX_CHARS, ttsSegmentMaxChars: DEFAULT_TTS_SEGMENT_MAX_CHARS, shellBufferMaxEntries: 5000, shellBufferMaxBytes: 4 * 1024 * 1024, shellBufferSnapshotMaxLines: DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, unicode11: true, // ── 日志 ─────────────────────────────────────────────────────────────────── logRetentionDays: 30, voiceRecordCategories: DEFAULT_VOICE_RECORD_CATEGORIES.slice(), voiceRecordDefaultCategory: DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY }; const NUMBER_RULES = { reconnectLimit: { fallback: 3, min: 0, max: 10, integer: true }, backgroundSessionKeepAliveMinutes: { fallback: DEFAULT_TERMINAL_RESUME_MINUTES, min: MIN_TERMINAL_RESUME_MINUTES, max: MAX_TERMINAL_RESUME_MINUTES, integer: true }, defaultPort: { fallback: 22, min: 1, max: 65535, integer: true }, defaultTimeoutSeconds: { fallback: 20, min: 5, max: 600, integer: true }, defaultHeartbeatSeconds: { fallback: 15, min: 5, max: 600, integer: true }, shellFontSize: { fallback: 15, min: 12, max: 22, integer: true }, shellLineHeight: { fallback: 1.4, min: 1, max: 2, integer: false }, ttsSpeakableMaxChars: { fallback: DEFAULT_TTS_SPEAKABLE_MAX_CHARS, min: MIN_TTS_SPEAKABLE_MAX_CHARS, max: MAX_TTS_SPEAKABLE_MAX_CHARS, integer: true }, ttsSegmentMaxChars: { fallback: DEFAULT_TTS_SEGMENT_MAX_CHARS, min: MIN_TTS_SEGMENT_MAX_CHARS, max: MAX_TTS_SEGMENT_MAX_CHARS, integer: true }, shellBufferMaxEntries: { fallback: 5000, min: 100, max: 200000, integer: true }, shellBufferMaxBytes: { fallback: 4 * 1024 * 1024, min: 1024, max: 50 * 1024 * 1024, integer: true }, shellBufferSnapshotMaxLines: { fallback: DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, min: MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, max: MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, integer: true }, logRetentionDays: { fallback: 30, min: 1, max: 365, integer: true } }; const THEME_MODE_VALUES = new Set(["dark", "light"]); const AUTH_TYPE_VALUES = new Set(["password", "key"]); const HEX_COLOR_REGEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; function normalizeServerTags(value) { const source = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; const seen = new Set(); const next = []; source.forEach((entry) => { const normalized = String(entry || "").trim(); if (!normalized || seen.has(normalized)) return; seen.add(normalized); next.push(normalized); }); return next; } function normalizeVoiceRecordCategories(value) { const source = Array.isArray(value) ? value : []; const seen = new Set(); const next = []; source.forEach((entry) => { const normalized = String(entry || "").trim(); if (!normalized || seen.has(normalized)) return; seen.add(normalized); next.push(normalized); }); if (!seen.has(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK)) { next.unshift(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK); } return next.slice(0, 10); } function normalizeVoiceRecordDefaultCategory(value, categories) { const normalized = String(value || "").trim(); if (normalized && categories.includes(normalized)) { return normalized; } if (categories.includes(DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY)) { return DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY; } return categories[0] || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK; } function normalizeNumber(value, rule) { const parsed = Number(value); if (!Number.isFinite(parsed)) return rule.fallback; const normalized = rule.integer ? Math.round(parsed) : parsed; if (normalized < rule.min) return rule.min; if (normalized > rule.max) return rule.max; return normalized; } function normalizeSettings(input) { const source = input && typeof input === "object" ? input : {}; const next = { ...source }; Object.keys(NUMBER_RULES).forEach((key) => { next[key] = normalizeNumber(next[key], NUMBER_RULES[key]); }); next.uiLanguage = normalizeUiLanguage(next.uiLanguage); next.uiThemeMode = THEME_MODE_VALUES.has(next.uiThemeMode) ? next.uiThemeMode : DEFAULT_SETTINGS.uiThemeMode; next.shellThemeMode = THEME_MODE_VALUES.has(next.shellThemeMode) ? next.shellThemeMode : DEFAULT_SETTINGS.shellThemeMode; next.shellThemePreset = String(next.shellThemePreset || DEFAULT_SETTINGS.shellThemePreset); next.defaultAuthType = AUTH_TYPE_VALUES.has(next.defaultAuthType) ? next.defaultAuthType : DEFAULT_SETTINGS.defaultAuthType; next.aiDefaultProvider = normalizeAiProvider(next.aiDefaultProvider); next.aiCodexSandboxMode = normalizeCodexSandboxMode(next.aiCodexSandboxMode); next.aiCopilotPermissionMode = normalizeCopilotPermissionMode(next.aiCopilotPermissionMode); next.defaultTransportMode = String(next.defaultTransportMode || DEFAULT_SETTINGS.defaultTransportMode); next.defaultProjectPath = String(next.defaultProjectPath || DEFAULT_SETTINGS.defaultProjectPath); next.uiThemePreset = String(next.uiThemePreset || DEFAULT_SETTINGS.uiThemePreset); next.uiAccentColor = HEX_COLOR_REGEX.test(String(next.uiAccentColor || "")) ? String(next.uiAccentColor) : DEFAULT_SETTINGS.uiAccentColor; next.uiBgColor = HEX_COLOR_REGEX.test(String(next.uiBgColor || "")) ? String(next.uiBgColor) : DEFAULT_SETTINGS.uiBgColor; next.uiTextColor = HEX_COLOR_REGEX.test(String(next.uiTextColor || "")) ? String(next.uiTextColor) : DEFAULT_SETTINGS.uiTextColor; next.uiBtnColor = HEX_COLOR_REGEX.test(String(next.uiBtnColor || "")) ? String(next.uiBtnColor) : pickBtnColor(next.uiBgColor, next.uiTextColor); /** * 终端字体必须先经过“安全字体”归一: * 1. 避免把比例字体直接写入终端运行态; * 2. 老版本已保存的不安全字体在这里自动迁回安全等宽字体。 */ next.shellFontFamily = normalizeTerminalFontFamily( String(next.shellFontFamily || DEFAULT_SETTINGS.shellFontFamily) ); next.shellBgColor = HEX_COLOR_REGEX.test(String(next.shellBgColor || "")) ? String(next.shellBgColor) : DEFAULT_SETTINGS.shellBgColor; next.shellTextColor = HEX_COLOR_REGEX.test(String(next.shellTextColor || "")) ? String(next.shellTextColor) : DEFAULT_SETTINGS.shellTextColor; next.shellAccentColor = HEX_COLOR_REGEX.test(String(next.shellAccentColor || "")) ? String(next.shellAccentColor) : pickShellAccentColor(next.shellBgColor, next.shellTextColor); next.shellActivationDebugOutline = next.shellActivationDebugOutline === undefined ? DEFAULT_SETTINGS.shellActivationDebugOutline : !!next.shellActivationDebugOutline; /** * 语音输入按钮默认显示: * 1. 老版本没有该字段时自动补 true; * 2. 统一收敛为布尔值,避免 storage 里残留字符串/数字脏值。 */ next.showVoiceInputButton = next.showVoiceInputButton === undefined ? DEFAULT_SETTINGS.showVoiceInputButton : !!next.showVoiceInputButton; next.unicode11 = !!next.unicode11; next.autoReconnect = !!next.autoReconnect; // 云端同步开关默认开启,兼容现有“本地 + Gateway”双写行为。 next.syncConfigEnabled = next.syncConfigEnabled === undefined ? DEFAULT_SETTINGS.syncConfigEnabled : !!next.syncConfigEnabled; next.voiceRecordCategories = normalizeVoiceRecordCategories(next.voiceRecordCategories); next.voiceRecordDefaultCategory = normalizeVoiceRecordDefaultCategory( next.voiceRecordDefaultCategory, next.voiceRecordCategories ); return next; } function sanitizeUserSettings(input) { if (!input || typeof input !== "object") return {}; const next = {}; Object.keys(input).forEach((key) => { if (OPS_SETTING_KEYS.has(key)) return; next[key] = input[key]; }); return next; } function listStorageKeys() { try { const info = wx.getStorageInfoSync(); return Array.isArray(info && info.keys) ? info.keys : []; } catch { return []; } } function readRaw(key) { try { const value = wx.getStorageSync(key); const keys = listStorageKeys(); const found = keys.includes(key) || !(value === undefined || value === null || value === ""); return { found, value }; } catch (error) { console.warn("[storage.read.raw]", key, error); return { found: false, value: undefined }; } } function writeRaw(key, value) { try { wx.setStorageSync(key, value); return true; } catch (error) { console.error("[storage.write.raw]", key, error); return false; } } function hydrateStorageKey(logicalKey) { const primaryKey = KEYS[logicalKey]; const backupKey = BACKUP_KEYS[logicalKey]; const primary = readRaw(primaryKey); if (primary.found) { if (backupKey) { const backup = readRaw(backupKey); if (!backup.found) { writeRaw(backupKey, primary.value); } } return; } if (backupKey) { const backup = readRaw(backupKey); if (backup.found) { writeRaw(primaryKey, backup.value); console.info("[storage.restore.backup]", logicalKey, backupKey, "->", primaryKey); return; } } const legacyKeys = Array.isArray(LEGACY_KEYS[logicalKey]) ? LEGACY_KEYS[logicalKey] : []; for (let i = 0; i < legacyKeys.length; i += 1) { const legacyKey = legacyKeys[i]; const legacy = readRaw(legacyKey); if (!legacy.found) continue; writeRaw(primaryKey, legacy.value); if (backupKey) { writeRaw(backupKey, legacy.value); } console.info("[storage.restore.legacy]", logicalKey, legacyKey, "->", primaryKey); return; } } function ensureStorageBootstrapped() { if (storageBootstrapDone || storageBootstrapInProgress) return; storageBootstrapInProgress = true; try { Object.keys(KEYS).forEach((logicalKey) => { hydrateStorageKey(logicalKey); }); storageBootstrapDone = true; } finally { storageBootstrapInProgress = false; } } function read(key, fallback) { ensureStorageBootstrapped(); try { const { found, value } = readRaw(key); if (!found) return fallback; return value === undefined || value === null || value === "" ? fallback : value; } catch (error) { console.warn("[storage.read]", key, error); return fallback; } } function write(key, value) { ensureStorageBootstrapped(); writeRaw(key, value); const logicalKey = Object.keys(KEYS).find((name) => KEYS[name] === key); if (!logicalKey) return; const backupKey = BACKUP_KEYS[logicalKey]; if (!backupKey) return; if (!writeRaw(backupKey, value)) { console.error("[storage.write.backup]", key, backupKey); } } function nowIso() { return new Date().toISOString(); } function loadSyncService() { if (syncServiceModule) return syncServiceModule; try { syncServiceModule = require("./syncService"); } catch { syncServiceModule = null; } return syncServiceModule; } function notifySync(method, ...args) { const service = loadSyncService(); if (!service || typeof service[method] !== "function") return; try { service[method](...args); } catch (error) { console.warn(`[storage.sync.${method}]`, error); } } function normalizeJumpHost(input) { const source = input && typeof input === "object" ? input : {}; const authType = source.authType === "privateKey" || source.authType === "certificate" ? source.authType : "password"; return { enabled: source.enabled === true, host: String(source.host || "").trim(), port: Number(source.port) || 22, username: String(source.username || "").trim(), authType }; } function normalizeServerAuthType(value) { return value === "privateKey" || value === "certificate" ? value : "password"; } function normalizeServerRecord(server) { const source = server && typeof server === "object" ? server : {}; // 服务器对象要在本地先归一化成“同步可直接上传”的形状。 // 历史数据里可能残留 authType="key"、null 字段或字符串数字,若原样上传会被网关 schema 拒绝。 return { ...source, id: String(source.id || ""), name: String(source.name || ""), tags: normalizeServerTags(source.tags), host: String(source.host || "").trim(), port: Number(source.port) || 22, username: String(source.username || "").trim(), authType: normalizeServerAuthType(source.authType), password: String(source.password || ""), privateKey: String(source.privateKey || ""), passphrase: String(source.passphrase || ""), certificate: String(source.certificate || ""), projectPath: String(source.projectPath || ""), timeoutSeconds: Number(source.timeoutSeconds) || 15, heartbeatSeconds: Number(source.heartbeatSeconds) || 10, transportMode: String(source.transportMode || "gateway"), jumpHost: normalizeJumpHost(source.jumpHost), jumpPassword: String(source.jumpPassword || ""), jumpPrivateKey: String(source.jumpPrivateKey || ""), jumpPassphrase: String(source.jumpPassphrase || ""), jumpCertificate: String(source.jumpCertificate || ""), sortOrder: Number(source.sortOrder) || 0, lastConnectedAt: String(source.lastConnectedAt || ""), updatedAt: String(source.updatedAt || source.lastConnectedAt || nowIso()) }; } function createServerSeed() { const ts = Date.now(); const rand = Math.random().toString(36).slice(2, 8); const settings = getSettings(); const defaultAuthType = settings.defaultAuthType === "key" ? "privateKey" : "password"; return { id: `srv-${ts}-${rand}`, name: "", tags: [], host: "", port: Number(settings.defaultPort) || 22, username: "", authType: defaultAuthType, password: "", privateKey: "", passphrase: "", certificate: "", projectPath: settings.defaultProjectPath || "", timeoutSeconds: Number(settings.defaultTimeoutSeconds) || 15, heartbeatSeconds: Number(settings.defaultHeartbeatSeconds) || 10, transportMode: settings.defaultTransportMode || "gateway", jumpHost: normalizeJumpHost(), sortOrder: ts, lastConnectedAt: "", updatedAt: nowIso() }; } function listServers() { const rows = read(KEYS.servers, []); return Array.isArray(rows) ? rows .map((item) => normalizeServerRecord({ ...createServerSeed(), ...item })) .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) : []; } function saveServers(next, options) { write(KEYS.servers, Array.isArray(next) ? next : []); const extra = options && typeof options === "object" ? options : {}; if (!extra.silentSync) { notifySync("scheduleServersSync"); } } function upsertServer(server, options) { const extra = options && typeof options === "object" ? options : {}; const rows = listServers(); const index = rows.findIndex((item) => item.id === server.id); const updatedAt = extra.preserveUpdatedAt ? String(server.updatedAt || nowIso()) : nowIso(); if (index >= 0) { rows[index] = normalizeServerRecord({ ...rows[index], ...server, updatedAt }); } else { rows.push(normalizeServerRecord({ ...createServerSeed(), ...server, updatedAt })); } saveServers(rows, extra); return rows; } function removeServer(serverId, options) { const extra = options && typeof options === "object" ? options : {}; const rows = listServers().filter((item) => item.id !== serverId); saveServers(rows, extra); if (!extra.silentSync) { notifySync("markServerDeleted", serverId, nowIso()); } return rows; } function markServerConnected(serverId) { const rows = listServers(); const index = rows.findIndex((item) => item.id === serverId); if (index >= 0) { rows[index].lastConnectedAt = nowIso(); rows[index].updatedAt = nowIso(); saveServers(rows); } } function getSettings() { return normalizeSettings({ ...DEFAULT_SETTINGS, ...sanitizeUserSettings(read(KEYS.settings, {})) }); } function saveSettings(next, options) { const extra = options && typeof options === "object" ? options : {}; const previous = getSettings(); const updatedAt = extra.preserveUpdatedAt ? String(next && next.updatedAt ? next.updatedAt : nowIso()) : nowIso(); const normalized = normalizeSettings({ ...previous, ...sanitizeUserSettings(next || {}), updatedAt }); write(KEYS.settings, normalized); if (!extra.silentSync) { /** * 重新打开云端同步时,先强制跑一次 bootstrap: * 1. 先把云端最新视图合并回本地; * 2. 再补推当前合并结果,避免直接用“关闭期间的本地快照”覆盖远端。 */ if (!previous.syncConfigEnabled && normalized.syncConfigEnabled) { notifySync("resumeSyncConfig"); } else { notifySync("scheduleSettingsSync"); } } } function normalizeRecord(item) { const source = item && typeof item === "object" ? item : {}; const createdAt = String(source.createdAt || nowIso()); const discarded = source.discarded === true; const processed = !discarded && source.processed === true; return { id: String(source.id || `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`), content: String(source.content || "").trim(), serverId: String(source.serverId || ""), createdAt, updatedAt: String(source.updatedAt || createdAt), category: String(source.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK, contextLabel: String(source.contextLabel || ""), processed, discarded }; } function dropExpiredLogs(rows) { const list = Array.isArray(rows) ? rows : []; const retentionDays = getSettings().logRetentionDays; const ttlMs = Number(retentionDays) * 24 * 60 * 60 * 1000; if (!Number.isFinite(ttlMs) || ttlMs <= 0) return list; const now = Date.now(); return list.filter((item) => { const startAt = +new Date(item.startAt || 0); if (!Number.isFinite(startAt) || startAt <= 0) return true; return now - startAt <= ttlMs; }); } function listLogs() { const rows = read(KEYS.logs, []); const cleaned = dropExpiredLogs(rows); if (Array.isArray(rows) && cleaned.length !== rows.length) { write(KEYS.logs, cleaned); } return Array.isArray(cleaned) ? cleaned.slice().sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)) : []; } function appendLog(entry) { const rows = listLogs(); rows.unshift({ id: entry.id || `log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, startAt: entry.startAt || nowIso(), endAt: entry.endAt || "", serverId: entry.serverId || "", status: entry.status || "connected", summary: entry.summary || "" }); write(KEYS.logs, rows); } function listRecords() { const rows = read(KEYS.records, []); if (!Array.isArray(rows)) return []; let dirty = false; const normalized = rows .map((item) => { const next = normalizeRecord(item); if ( !item || item.updatedAt !== next.updatedAt || item.category !== next.category || item.contextLabel !== next.contextLabel || item.processed !== next.processed || item.discarded !== next.discarded || item.content !== next.content || item.serverId !== next.serverId ) { dirty = true; } return next; }) .sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)); if (dirty) { write(KEYS.records, normalized); } return normalized; } function saveRecords(next, options) { write(KEYS.records, Array.isArray(next) ? next.map((item) => normalizeRecord(item)) : []); const extra = options && typeof options === "object" ? options : {}; if (!extra.silentSync) { notifySync("scheduleRecordsSync"); } } function addRecord(content, serverId, options) { const text = String(content || "").trim(); if (!text) return null; const rows = listRecords(); const extra = options && typeof options === "object" ? options : {}; const timestamp = nowIso(); const discarded = extra.discarded === true; const processed = !discarded && extra.processed === true; const next = { id: `rec-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, content: text, serverId: serverId || "", createdAt: timestamp, updatedAt: timestamp, category: String(extra.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK, contextLabel: String(extra.contextLabel || ""), processed, discarded }; rows.unshift(next); saveRecords(rows); return next; } function updateRecord(payload) { const source = payload && typeof payload === "object" ? payload : {}; const recordId = String(source.id || "").trim(); const content = String(source.content || "").trim(); if (!recordId || !content) return null; const rows = listRecords(); const index = rows.findIndex((item) => item.id === recordId); if (index < 0) return null; const current = rows[index]; let nextProcessed = Object.prototype.hasOwnProperty.call(source, "processed") ? source.processed === true : current.processed === true; let nextDiscarded = Object.prototype.hasOwnProperty.call(source, "discarded") ? source.discarded === true : current.discarded === true; /** * 闪念终态互斥规则与 normalizeRecord 保持一致: * 1. 只要本次结果里 discarded 为 true,就强制关掉 processed; * 2. 否则若 processed 为 true,再回头关掉 discarded; * 3. 这样“已废弃”不会被历史 processed 残值反向吞掉。 */ if (nextDiscarded) { nextProcessed = false; } else if (nextProcessed) { nextDiscarded = false; } const next = { ...current, content, category: String(source.category || current.category || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK).trim() || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK, processed: nextProcessed, discarded: nextDiscarded, updatedAt: nowIso() }; rows[index] = next; saveRecords(rows); return next; } function searchRecords(input) { const source = input && typeof input === "object" ? input : {}; const keyword = String(source.keyword || "") .trim() .toLowerCase(); const category = String(source.category || "").trim(); return listRecords().filter((item) => { if (category && item.category !== category) return false; if (!keyword) return true; return [item.content, item.category, item.contextLabel, item.createdAt] .join(" ") .toLowerCase() .includes(keyword); }); } function removeRecord(recordId, options) { const extra = options && typeof options === "object" ? options : {}; const rows = listRecords().filter((item) => item.id !== recordId); saveRecords(rows, extra); if (!extra.silentSync) { notifySync("markRecordDeleted", recordId, nowIso()); } return rows; } function listPluginPackages() { const rows = read(KEYS.pluginPackages, []); return Array.isArray(rows) ? rows : []; } function getPluginPackage(pluginId) { const id = String(pluginId || ""); return listPluginPackages().find((item) => item && item.manifest && item.manifest.id === id) || null; } function savePluginPackages(next) { write(KEYS.pluginPackages, Array.isArray(next) ? next : []); } function upsertPluginPackage(pluginPackage) { if (!pluginPackage || !pluginPackage.manifest || !pluginPackage.manifest.id) { throw new Error("插件包格式非法"); } const rows = listPluginPackages(); const index = rows.findIndex( (item) => item && item.manifest && item.manifest.id === pluginPackage.manifest.id ); if (index >= 0) { rows[index] = pluginPackage; } else { rows.push(pluginPackage); } savePluginPackages(rows); } function removePluginPackage(pluginId) { const id = String(pluginId || ""); const rows = listPluginPackages().filter((item) => item && item.manifest && item.manifest.id !== id); savePluginPackages(rows); } function listPluginRecords() { const rows = read(KEYS.pluginRecords, []); return Array.isArray(rows) ? rows : []; } function savePluginRecords(next) { write(KEYS.pluginRecords, Array.isArray(next) ? next : []); } function listPluginRuntimeLogs() { const rows = read(KEYS.pluginRuntimeLogs, []); return Array.isArray(rows) ? rows : []; } function savePluginRuntimeLogs(next) { write(KEYS.pluginRuntimeLogs, Array.isArray(next) ? next : []); } function readPluginData(pluginId) { const id = String(pluginId || "").trim(); if (!id) return {}; return read(`remoteconn.plugins.data.${id}.v2`, {}); } function writePluginData(pluginId, value) { const id = String(pluginId || "").trim(); if (!id) return; write(`remoteconn.plugins.data.${id}.v2`, value && typeof value === "object" ? value : {}); } module.exports = { createServerSeed, listServers, saveServers, upsertServer, removeServer, markServerConnected, getSettings, saveSettings, listLogs, appendLog, listRecords, addRecord, updateRecord, saveRecords, searchRecords, removeRecord, listPluginPackages, getPluginPackage, savePluginPackages, upsertPluginPackage, removePluginPackage, listPluginRecords, savePluginRecords, listPluginRuntimeLogs, savePluginRuntimeLogs, readPluginData, writePluginData, DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK, DEFAULT_VOICE_RECORD_CATEGORIES, DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY, DEFAULT_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, MIN_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, MAX_TERMINAL_BUFFER_SNAPSHOT_MAX_LINES, normalizeVoiceRecordCategories, normalizeVoiceRecordDefaultCategory };