diff --git a/electron/main/database/sessionCache.ts b/electron/main/database/sessionCache.ts index f44a2c62..24bc0bf6 100644 --- a/electron/main/database/sessionCache.ts +++ b/electron/main/database/sessionCache.ts @@ -1,187 +1,20 @@ /** - * 会话级通用 JSON 缓存模块 + * Session cache — Electron re-export layer. * - * 每个 session 对应一个 {sessionId}.cache.json,内部按 key 分区存储。 - * 与数据库 schema 完全解耦,读取失败自动重建,无需版本管理。 - * - * 文件路径: {cacheDir}/{sessionId}.cache.json + * All logic has been extracted to @openchatlab/node-runtime/cache. + * This file provides backward-compatible imports for existing Electron code. */ -import * as fs from 'fs' -import * as path from 'path' -import type Database from 'better-sqlite3' +export { + getCachePath, + getCache, + setCache, + invalidateCache, + deleteSessionCache, + computeAndSetOverviewCache, + computeAndSetMembersCache, + CACHE_KEY_OVERVIEW, + CACHE_KEY_MEMBERS, +} from '@openchatlab/node-runtime' -// ==================== 通用缓存基础设施 ==================== - -interface CacheEntry { - data: T - ts: number -} - -type CacheFile = Record - -export function getCachePath(sessionId: string, cacheDir: string): string { - return path.join(cacheDir, `${sessionId}.cache.json`) -} - -function readCacheFile(cachePath: string): CacheFile | null { - try { - if (!fs.existsSync(cachePath)) return null - return JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CacheFile - } catch { - return null - } -} - -function writeCacheFile(cachePath: string, content: CacheFile): void { - try { - const dir = path.dirname(cachePath) - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }) - } - fs.writeFileSync(cachePath, JSON.stringify(content), 'utf-8') - } catch { - // 写入失败不影响主流程 - } -} - -/** - * 读取缓存分区 - */ -export function getCache(sessionId: string, key: string, cacheDir: string): T | null { - const cachePath = getCachePath(sessionId, cacheDir) - const file = readCacheFile(cachePath) - if (!file || !file[key]) return null - return file[key].data as T -} - -/** - * 写入缓存分区 - */ -export function setCache(sessionId: string, key: string, data: T, cacheDir: string): void { - const cachePath = getCachePath(sessionId, cacheDir) - const file = readCacheFile(cachePath) ?? {} - file[key] = { data, ts: Math.floor(Date.now() / 1000) } - writeCacheFile(cachePath, file) -} - -/** - * 使缓存失效(不传 key 则清空整个文件) - */ -export function invalidateCache(sessionId: string, cacheDir: string, key?: string): void { - const cachePath = getCachePath(sessionId, cacheDir) - try { - if (!key) { - if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath) - } else { - const file = readCacheFile(cachePath) - if (file && file[key]) { - delete file[key] - writeCacheFile(cachePath, file) - } - } - } catch { - // 忽略失败 - } -} - -/** - * 删除 session 的所有缓存 - */ -export function deleteSessionCache(sessionId: string, cacheDir: string): void { - const cachePath = getCachePath(sessionId, cacheDir) - try { - if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath) - } catch { - // 忽略删除失败 - } -} - -// ==================== Overview 缓存(聚合统计) ==================== - -export const CACHE_KEY_OVERVIEW = 'overview' - -export interface OverviewCache { - totalMessages: number - totalMembers: number - firstMessageTs: number | null - lastMessageTs: number | null -} - -/** - * 从数据库计算概览统计并写入缓存 - */ -export function computeAndSetOverviewCache(db: Database.Database, sessionId: string, cacheDir: string): OverviewCache { - const msgStats = db.prepare('SELECT MIN(ts) as first_ts, MAX(ts) as last_ts FROM message').get() as { - first_ts: number | null - last_ts: number | null - } - - const totalMessages = ( - db - .prepare( - `SELECT COUNT(*) as count FROM message msg - JOIN member m ON msg.sender_id = m.id - WHERE COALESCE(m.account_name, '') != '系统消息'` - ) - .get() as { count: number } - ).count - - const totalMembers = ( - db.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != '系统消息'`).get() as { - count: number - } - ).count - - const data: OverviewCache = { - totalMessages, - totalMembers, - firstMessageTs: msgStats.first_ts, - lastMessageTs: msgStats.last_ts, - } - - setCache(sessionId, CACHE_KEY_OVERVIEW, data, cacheDir) - - // 同步生成成员缓存 - computeAndSetMembersCache(db, sessionId, cacheDir) - - return data -} - -// ==================== Members 缓存(成员维度) ==================== - -export const CACHE_KEY_MEMBERS = 'members' - -export interface MemberStat { - name: string - count: number -} - -export interface MembersCache { - /** member.id -> { name, count } */ - members: Record -} - -/** - * 从数据库计算成员统计并写入缓存 - */ -export function computeAndSetMembersCache(db: Database.Database, sessionId: string, cacheDir: string): MembersCache { - const rows = db - .prepare( - `SELECT msg.sender_id, COUNT(*) as count, - COALESCE(m.group_nickname, m.account_name, m.platform_id) as name - FROM message msg - JOIN member m ON msg.sender_id = m.id - GROUP BY msg.sender_id` - ) - .all() as Array<{ sender_id: number; count: number; name: string }> - - const members: Record = {} - for (const row of rows) { - members[row.sender_id] = { name: row.name, count: row.count } - } - - const data: MembersCache = { members } - setCache(sessionId, CACHE_KEY_MEMBERS, data, cacheDir) - return data -} +export type { OverviewCache, MembersCache, MemberStat } from '@openchatlab/node-runtime' diff --git a/electron/main/worker/import/incrementalImport.ts b/electron/main/worker/import/incrementalImport.ts index dbea25bc..e2547098 100644 --- a/electron/main/worker/import/incrementalImport.ts +++ b/electron/main/worker/import/incrementalImport.ts @@ -44,7 +44,7 @@ function buildDeps(requestId: string): IncrementalImportDeps { const { computeAndSetOverviewCache } = await import('../../database/sessionCache') const dbPath = getDbPath(sessionId) const rawDb = new Database(dbPath) - computeAndSetOverviewCache(rawDb, sessionId, getCacheDir()) + computeAndSetOverviewCache(new BetterSqliteAdapter(rawDb), sessionId, getCacheDir()) rawDb.close() } catch { /* non-fatal */ diff --git a/electron/main/worker/import/streamImport.ts b/electron/main/worker/import/streamImport.ts index 3f5dd049..93533612 100644 --- a/electron/main/worker/import/streamImport.ts +++ b/electron/main/worker/import/streamImport.ts @@ -72,7 +72,7 @@ function buildStreamImportDeps(requestId: string): StreamImportDeps { const { computeAndSetOverviewCache } = await import('../../database/sessionCache') const dbPath = getDbPath(sessionId) const rawDb = new Database(dbPath) - computeAndSetOverviewCache(rawDb, sessionId, getCacheDir()) + computeAndSetOverviewCache(new BetterSqliteAdapter(rawDb), sessionId, getCacheDir()) rawDb.close() } catch { /* non-fatal */ diff --git a/electron/main/worker/query/sessions.ts b/electron/main/worker/query/sessions.ts index ea08b59c..e6ab7814 100644 --- a/electron/main/worker/query/sessions.ts +++ b/electron/main/worker/query/sessions.ts @@ -8,6 +8,7 @@ */ import Database from 'better-sqlite3' +import { BetterSqliteAdapter } from '@openchatlab/node-runtime' import * as fs from 'fs' import * as path from 'path' import { openDatabase, getDbDir, getDbPath, getCacheDir } from '../core' @@ -89,7 +90,7 @@ function resolveOverview(db: Database.Database, sessionId: string, cacheDir: str let cached = cacheDir ? getCache(sessionId, CACHE_KEY_OVERVIEW, cacheDir) : null if (!cached && cacheDir) { try { - cached = computeAndSetOverviewCache(db, sessionId, cacheDir) + cached = computeAndSetOverviewCache(new BetterSqliteAdapter(db), sessionId, cacheDir) } catch { // cache compute failure — fall through to live query } @@ -197,7 +198,7 @@ export function getChatOverview(sessionId: string, topN: number = 10) { let membersCache = getCache(sessionId, CACHE_KEY_MEMBERS, cacheDir) if (!membersCache) { try { - membersCache = computeAndSetMembersCache(db, sessionId, cacheDir) + membersCache = computeAndSetMembersCache(new BetterSqliteAdapter(db), sessionId, cacheDir) } catch { // fallback: no member data } diff --git a/packages/node-runtime/src/cache/index.ts b/packages/node-runtime/src/cache/index.ts new file mode 100644 index 00000000..99952267 --- /dev/null +++ b/packages/node-runtime/src/cache/index.ts @@ -0,0 +1,12 @@ +export { + getCachePath, + getCache, + setCache, + invalidateCache, + deleteSessionCache, + computeAndSetOverviewCache, + computeAndSetMembersCache, + CACHE_KEY_OVERVIEW, + CACHE_KEY_MEMBERS, +} from './session-cache' +export type { OverviewCache, MembersCache, MemberStat } from './session-cache' diff --git a/packages/node-runtime/src/cache/session-cache.ts b/packages/node-runtime/src/cache/session-cache.ts new file mode 100644 index 00000000..14343793 --- /dev/null +++ b/packages/node-runtime/src/cache/session-cache.ts @@ -0,0 +1,167 @@ +/** + * Session-level JSON cache module (platform-agnostic). + * + * Extracted from electron/main/database/sessionCache.ts. + * Each session has a {sessionId}.cache.json file partitioned by key. + * Decoupled from DB schema — reads auto-rebuild on failure, no versioning needed. + * + * File path: {cacheDir}/{sessionId}.cache.json + */ + +import * as fs from 'fs' +import * as path from 'path' +import type { DatabaseAdapter } from '@openchatlab/core' + +// ==================== Generic cache infrastructure ==================== + +interface CacheEntry { + data: T + ts: number +} + +type CacheFile = Record + +export function getCachePath(sessionId: string, cacheDir: string): string { + return path.join(cacheDir, `${sessionId}.cache.json`) +} + +function readCacheFile(cachePath: string): CacheFile | null { + try { + if (!fs.existsSync(cachePath)) return null + return JSON.parse(fs.readFileSync(cachePath, 'utf-8')) as CacheFile + } catch { + return null + } +} + +function writeCacheFile(cachePath: string, content: CacheFile): void { + try { + const dir = path.dirname(cachePath) + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + fs.writeFileSync(cachePath, JSON.stringify(content), 'utf-8') + } catch { + // Write failure is non-fatal + } +} + +export function getCache(sessionId: string, key: string, cacheDir: string): T | null { + const cachePath = getCachePath(sessionId, cacheDir) + const file = readCacheFile(cachePath) + if (!file || !file[key]) return null + return file[key].data as T +} + +export function setCache(sessionId: string, key: string, data: T, cacheDir: string): void { + const cachePath = getCachePath(sessionId, cacheDir) + const file = readCacheFile(cachePath) ?? {} + file[key] = { data, ts: Math.floor(Date.now() / 1000) } + writeCacheFile(cachePath, file) +} + +export function invalidateCache(sessionId: string, cacheDir: string, key?: string): void { + const cachePath = getCachePath(sessionId, cacheDir) + try { + if (!key) { + if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath) + } else { + const file = readCacheFile(cachePath) + if (file && file[key]) { + delete file[key] + writeCacheFile(cachePath, file) + } + } + } catch { + // Ignore + } +} + +export function deleteSessionCache(sessionId: string, cacheDir: string): void { + const cachePath = getCachePath(sessionId, cacheDir) + try { + if (fs.existsSync(cachePath)) fs.unlinkSync(cachePath) + } catch { + // Ignore + } +} + +// ==================== Overview cache (aggregate stats) ==================== + +export const CACHE_KEY_OVERVIEW = 'overview' + +export interface OverviewCache { + totalMessages: number + totalMembers: number + firstMessageTs: number | null + lastMessageTs: number | null +} + +export function computeAndSetOverviewCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): OverviewCache { + const msgStats = db.prepare('SELECT MIN(ts) as first_ts, MAX(ts) as last_ts FROM message').get() as { + first_ts: number | null + last_ts: number | null + } + + const totalMessages = ( + db + .prepare( + `SELECT COUNT(*) as count FROM message msg + JOIN member m ON msg.sender_id = m.id + WHERE COALESCE(m.account_name, '') != '系统消息'` + ) + .get() as { count: number } + ).count + + const totalMembers = ( + db.prepare(`SELECT COUNT(*) as count FROM member WHERE COALESCE(account_name, '') != '系统消息'`).get() as { + count: number + } + ).count + + const data: OverviewCache = { + totalMessages, + totalMembers, + firstMessageTs: msgStats.first_ts, + lastMessageTs: msgStats.last_ts, + } + + setCache(sessionId, CACHE_KEY_OVERVIEW, data, cacheDir) + computeAndSetMembersCache(db, sessionId, cacheDir) + + return data +} + +// ==================== Members cache (per-member stats) ==================== + +export const CACHE_KEY_MEMBERS = 'members' + +export interface MemberStat { + name: string + count: number +} + +export interface MembersCache { + members: Record +} + +export function computeAndSetMembersCache(db: DatabaseAdapter, sessionId: string, cacheDir: string): MembersCache { + const rows = db + .prepare( + `SELECT msg.sender_id, COUNT(*) as count, + COALESCE(m.group_nickname, m.account_name, m.platform_id) as name + FROM message msg + JOIN member m ON msg.sender_id = m.id + GROUP BY msg.sender_id` + ) + .all() as Array<{ sender_id: number; count: number; name: string }> + + const members: Record = {} + for (const row of rows) { + members[row.sender_id] = { name: row.name, count: row.count } + } + + const data: MembersCache = { members } + setCache(sessionId, CACHE_KEY_MEMBERS, data, cacheDir) + return data +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 3263fd59..00f5153c 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -188,6 +188,20 @@ export type { PiModelConfig, BuildPiModelOptions } from './ai' export { fetchRemoteModels, validateApiKey } from './ai' export type { RemoteModel, FetchRemoteModelsResult, RemoteApiOptions } from './ai' +// Session cache (overview + members JSON file cache) +export { + getCachePath, + getCache, + setCache, + invalidateCache, + deleteSessionCache, + computeAndSetOverviewCache, + computeAndSetMembersCache, + CACHE_KEY_OVERVIEW, + CACHE_KEY_MEMBERS, +} from './cache' +export type { OverviewCache, MembersCache, MemberStat } from './cache' + // Chat DB migrations export { getChatDbMigrations } from './migrations' export type { MigrationDeps } from './migrations'