first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View 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
};