Files
remoteconn-gitea/apps/miniprogram/utils/storage.js
2026-03-21 18:57:10 +08:00

903 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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
};