first commit
This commit is contained in:
902
apps/miniprogram/utils/storage.js
Normal file
902
apps/miniprogram/utils/storage.js
Normal file
@@ -0,0 +1,902 @@
|
||||
/* 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
|
||||
};
|
||||
Reference in New Issue
Block a user