Some checks failed
CI / build-check-test (push) Has been cancelled
Document main/upstream-sync/feature branch strategy, add sync/push scripts, track .pi/agent extensions and webui in git, and disable startup changelog via showChangelogOnStartup. Co-authored-by: Cursor <cursoragent@cursor.com>
225 lines
6.4 KiB
TypeScript
225 lines
6.4 KiB
TypeScript
/**
|
||
* 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<WebuiAvatarSettings>;
|
||
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<string, string>): 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<string, string> {
|
||
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>): 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);
|
||
}
|