/** * WebUI 专有 SQLite 配置存储 * * 数据库文件位于扩展目录 data/webui.db,与 pi-Agent 配置完全隔离。 */ import { existsSync, mkdirSync, readFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; const SCHEMA_VERSION = 1; export interface WebuiDbInfo { dataDir: string; dbPath: string; } export interface WebuiAvatarSettings { userAvatarUrl: string; agentAvatarUrl: string; } let db: DatabaseSync | null = null; function requireDb(): DatabaseSync { if (!db?.isOpen) { throw new Error("WebUI 数据库未初始化"); } return db; } function getMeta(key: string): string | null { const row = requireDb() .prepare("SELECT value FROM webui_meta WHERE key = ?") .get(key) as { value: string } | undefined; return row?.value ?? null; } function setMeta(key: string, value: string): void { requireDb() .prepare( `INSERT INTO webui_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, ) .run(key, value); } function migrateLegacyJsonIfNeeded(extensionDir: string): void { if (getMeta("legacy_json_migrated") === "1") return; const legacyFile = join(extensionDir, "webui-settings.json"); if (!existsSync(legacyFile)) { setMeta("legacy_json_migrated", "1"); return; } try { const raw = JSON.parse(readFileSync(legacyFile, "utf8")) as Partial; if (typeof raw.userAvatarUrl === "string" && raw.userAvatarUrl && !getWebuiConfig("userAvatarUrl")) { setWebuiConfig("userAvatarUrl", raw.userAvatarUrl); } if (typeof raw.agentAvatarUrl === "string" && raw.agentAvatarUrl && !getWebuiConfig("agentAvatarUrl")) { setWebuiConfig("agentAvatarUrl", raw.agentAvatarUrl); } const migratedPath = `${legacyFile}.migrated`; if (!existsSync(migratedPath)) { renameSync(legacyFile, migratedPath); } console.log("[webui] 已从 webui-settings.json 迁移配置到 SQLite"); } catch (err) { console.warn("[webui] 迁移 webui-settings.json 失败:", err); } setMeta("legacy_json_migrated", "1"); } export function initWebuiDatabase(extensionDir: string): WebuiDbInfo { const dataDir = join(extensionDir, "data"); const dbPath = join(dataDir, "webui.db"); mkdirSync(dataDir, { recursive: true }); db = new DatabaseSync(dbPath); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); db.exec(` CREATE TABLE IF NOT EXISTS webui_meta ( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS webui_config ( key TEXT PRIMARY KEY NOT NULL, value TEXT NOT NULL, updated_at TEXT NOT NULL ); `); if (!getMeta("schema_version")) { setMeta("schema_version", String(SCHEMA_VERSION)); } migrateLegacyJsonIfNeeded(extensionDir); return { dataDir, dbPath }; } export function getWebuiConfig(key: string): string | null { const row = requireDb() .prepare("SELECT value FROM webui_config WHERE key = ?") .get(key) as { value: string } | undefined; return row?.value ?? null; } export function setWebuiConfig(key: string, value: string): void { const now = new Date().toISOString(); requireDb() .prepare( `INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, ) .run(key, value, now); } export function setWebuiConfigMany(entries: Record): void { const stmt = requireDb().prepare( `INSERT INTO webui_config (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, ); const now = new Date().toISOString(); for (const [key, value] of Object.entries(entries)) { stmt.run(key, value, now); } } export function getAllWebuiConfig(): Record { const rows = requireDb() .prepare("SELECT key, value FROM webui_config ORDER BY key") .all() as Array<{ key: string; value: string }>; return Object.fromEntries(rows.map((row) => [row.key, row.value])); } export function deleteWebuiConfig(key: string): boolean { const result = requireDb().prepare("DELETE FROM webui_config WHERE key = ?").run(key); return result.changes > 0; } export function readWebuiAvatarSettings(): WebuiAvatarSettings { return { userAvatarUrl: getWebuiConfig("userAvatarUrl") ?? "", agentAvatarUrl: getWebuiConfig("agentAvatarUrl") ?? "", }; } export function writeWebuiAvatarSettings(settings: WebuiAvatarSettings): void { setWebuiConfigMany({ userAvatarUrl: settings.userAvatarUrl, agentAvatarUrl: settings.agentAvatarUrl, }); } export function closeWebuiDatabase(): void { if (db?.isOpen) { db.close(); db = null; } } export function getWebuiDatabasePath(): string | null { return db?.location() ?? null; } const PINNED_SESSIONS_CONFIG_KEY = "pinnedSessionPaths"; export function readPinnedSessionPaths(): string[] { const raw = getWebuiConfig(PINNED_SESSIONS_CONFIG_KEY); if (!raw) return []; try { const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; return parsed.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); } catch { return []; } } function writePinnedSessionPaths(paths: string[]): void { const unique: string[] = []; for (const path of paths) { if (!unique.includes(path)) unique.push(path); } setWebuiConfig(PINNED_SESSIONS_CONFIG_KEY, JSON.stringify(unique)); } export function prunePinnedSessionPaths(validPaths: Iterable): string[] { const valid = new Set(validPaths); const current = readPinnedSessionPaths(); const pruned = current.filter((path) => valid.has(path)); if (pruned.length !== current.length) { writePinnedSessionPaths(pruned); } return pruned; } export function pinSessionPath(path: string): string[] { const current = readPinnedSessionPaths(); if (current.includes(path)) return current; writePinnedSessionPaths([path, ...current]); return readPinnedSessionPaths(); } export function unpinSessionPath(path: string): string[] { const next = readPinnedSessionPaths().filter((entry) => entry !== path); writePinnedSessionPaths(next); return next; } export function removePinnedSessionPath(path: string): void { if (readPinnedSessionPaths().includes(path)) { unpinSessionPath(path); } } export function setSessionPinned(path: string, pinned: boolean): string[] { return pinned ? pinSessionPath(path) : unpinSessionPath(path); }