feat: v4.0.0 大版本升级 - SQLite3 + ReAct Agent
- P0: 存储层 IndexedDB → SQLite3 (better-sqlite3),7张表 + FTS5全文搜索 - P1: Agent Engine 升级为 ReAct 模式(思考→行动→观察→反思) - P2: 新增 4 个工具(memory_search, memory_add, session_list, session_read) - P3: 上下文管理器(滑动窗口 + 摘要压缩 + 记忆注入) - P4: UI 改版(ReAct 执行面板样式 + 思考过程卡片) - P5: IndexedDB → SQLite 数据迁移支持 - MAX_LOOPS 10→15, MAX_LOOP_TIME 5min→10min, 错误自动重试 2 次
This commit is contained in:
+344
-120
@@ -1,14 +1,24 @@
|
||||
/**
|
||||
* ChatDB - IndexedDB 封装层
|
||||
* v2: 新增 memories 存储(Agent 记忆系统)
|
||||
* ChatDB - 存储兼容层
|
||||
* v4.0: 桌面端走 IPC → SQLite,Web 端仍用 IndexedDB
|
||||
*/
|
||||
|
||||
import type { ChatSession, MemoryEntry } from '../types.js';
|
||||
|
||||
/** 检查是否在桌面环境 */
|
||||
function isDesktop(): boolean {
|
||||
return !!(window as any).metonaDesktop?.isDesktop;
|
||||
}
|
||||
|
||||
/** 桌面端 DB 桥接 */
|
||||
function dbBridge() {
|
||||
return (window as any).metonaDesktop?.db;
|
||||
}
|
||||
|
||||
export class ChatDB {
|
||||
private dbName: string;
|
||||
private version: number;
|
||||
private db: IDBDatabase | null = null;
|
||||
private idb: IDBDatabase | null = null;
|
||||
|
||||
constructor(dbName = 'metona-ollama', version = 2) {
|
||||
this.dbName = dbName;
|
||||
@@ -16,15 +26,22 @@ export class ChatDB {
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
// 桌面端:SQLite 已在主进程初始化,这里不需要 IndexedDB
|
||||
// 但为了数据迁移,仍打开 IndexedDB(只读)
|
||||
await this._openIDB();
|
||||
return;
|
||||
}
|
||||
// Web 端:使用 IndexedDB
|
||||
await this._openIDB();
|
||||
}
|
||||
|
||||
/** 打开 IndexedDB(用于迁移或 Web 端 fallback) */
|
||||
private async _openIDB(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(this.dbName, this.version);
|
||||
request.onerror = () => {
|
||||
reject(request.error);
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result;
|
||||
resolve();
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onsuccess = () => { this.idb = request.result; resolve(); };
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
if (!db.objectStoreNames.contains('sessions')) {
|
||||
@@ -46,172 +63,379 @@ export class ChatDB {
|
||||
});
|
||||
}
|
||||
|
||||
private _ensureDB(): void {
|
||||
if (!this.db) throw new Error('数据库未初始化,请先调用 init()');
|
||||
}
|
||||
|
||||
private _tx(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
|
||||
this._ensureDB();
|
||||
const tx = this.db!.transaction(storeName, mode);
|
||||
return tx.objectStore(storeName);
|
||||
}
|
||||
// ── Sessions ──
|
||||
|
||||
async saveSession(session: ChatSession): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('sessions', 'readwrite');
|
||||
const request = store.put(session);
|
||||
request.onsuccess = () => resolve(session.id);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (isDesktop()) {
|
||||
const row = {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
model: session.model,
|
||||
system_prompt: null,
|
||||
parent_id: null,
|
||||
status: 'active',
|
||||
created_at: session.createdAt,
|
||||
updated_at: session.updatedAt
|
||||
};
|
||||
await dbBridge().saveSession(row);
|
||||
// 同步保存消息
|
||||
for (const msg of session.messages) {
|
||||
const msgRow = {
|
||||
id: `${session.id}_${msg.timestamp}_${msg.role}`,
|
||||
session_id: session.id,
|
||||
role: msg.role,
|
||||
content: msg.content || null,
|
||||
thinking: msg.think || null,
|
||||
images: msg.images?.length ? JSON.stringify(msg.images) : null,
|
||||
tool_calls: msg.toolCalls?.length ? JSON.stringify(msg.toolCalls) : null,
|
||||
tool_name: null,
|
||||
eval_count: msg.eval_count || null,
|
||||
total_duration: msg.total_duration || null,
|
||||
created_at: msg.timestamp
|
||||
};
|
||||
await dbBridge().saveMessage(msgRow);
|
||||
}
|
||||
return session.id;
|
||||
}
|
||||
return this._idbSaveSession(session);
|
||||
}
|
||||
|
||||
async getSession(id: string): Promise<ChatSession | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('sessions');
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (isDesktop()) {
|
||||
const row = await dbBridge().getSession(id);
|
||||
if (!row) return null;
|
||||
const msgRows = await dbBridge().getMessages(id);
|
||||
const messages = msgRows.map((r: any) => ({
|
||||
role: r.role,
|
||||
content: r.content || '',
|
||||
timestamp: r.created_at,
|
||||
think: r.thinking || undefined,
|
||||
images: r.images ? JSON.parse(r.images) : undefined,
|
||||
eval_count: r.eval_count || undefined,
|
||||
total_duration: r.total_duration || undefined,
|
||||
toolCalls: r.tool_calls ? JSON.parse(r.tool_calls) : undefined
|
||||
}));
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
model: row.model,
|
||||
messages,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
return this._idbGetSession(id);
|
||||
}
|
||||
|
||||
async getAllSessions(): Promise<ChatSession[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('sessions');
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (isDesktop()) {
|
||||
const rows = await dbBridge().getAllSessions();
|
||||
const sessions: ChatSession[] = [];
|
||||
for (const row of rows) {
|
||||
const session = await this.getSession(row.id);
|
||||
if (session) sessions.push(session);
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
return this._idbGetAllSessions();
|
||||
}
|
||||
|
||||
async deleteSession(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('sessions', 'readwrite');
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (isDesktop()) {
|
||||
await dbBridge().deleteSession(id);
|
||||
return;
|
||||
}
|
||||
return this._idbDeleteSession(id);
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('sessions', 'readwrite');
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
if (isDesktop()) {
|
||||
await dbBridge().clearAllSessions();
|
||||
return;
|
||||
}
|
||||
return this._idbClearAll();
|
||||
}
|
||||
|
||||
async importSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.db!.transaction('sessions', 'readwrite');
|
||||
const store = tx.objectStore('sessions');
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
if (isDesktop()) {
|
||||
const data = {
|
||||
sessions: sessions.map(s => ({
|
||||
id: s.id, title: s.title, model: s.model, system_prompt: null,
|
||||
parent_id: null, status: 'active', created_at: s.createdAt, updated_at: s.updatedAt
|
||||
})),
|
||||
messages: sessions.flatMap(s => s.messages.map(m => ({
|
||||
id: `${s.id}_${m.timestamp}_${m.role}`,
|
||||
session_id: s.id, role: m.role, content: m.content || null,
|
||||
thinking: m.think || null,
|
||||
images: m.images?.length ? JSON.stringify(m.images) : null,
|
||||
tool_calls: m.toolCalls?.length ? JSON.stringify(m.toolCalls) : null,
|
||||
tool_name: null, eval_count: m.eval_count || null,
|
||||
total_duration: m.total_duration || null, created_at: m.timestamp
|
||||
}))),
|
||||
memories: [],
|
||||
settings: [],
|
||||
exportedAt: Date.now()
|
||||
};
|
||||
return dbBridge().importSessions(data);
|
||||
}
|
||||
return this._idbImportSessions(sessions);
|
||||
}
|
||||
|
||||
async getSessionsByTimeRange(startTime: number, endTime: number): Promise<ChatSession[]> {
|
||||
const all = await this.getAllSessions();
|
||||
return all.filter(s => s.updatedAt >= startTime && s.updatedAt <= endTime);
|
||||
}
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
async saveSetting(key: string, value: unknown): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
await dbBridge().saveSetting(key, value);
|
||||
return;
|
||||
}
|
||||
return this._idbSaveSetting(key, value);
|
||||
}
|
||||
|
||||
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
|
||||
if (isDesktop()) {
|
||||
return dbBridge().getSetting(key, defaultValue);
|
||||
}
|
||||
return this._idbGetSetting(key, defaultValue);
|
||||
}
|
||||
|
||||
// ── Memories ──
|
||||
|
||||
async saveMemory(entry: MemoryEntry): Promise<string> {
|
||||
if (isDesktop()) {
|
||||
const row = {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
content: entry.content,
|
||||
importance: entry.importance,
|
||||
tags: entry.tags?.length ? JSON.stringify(entry.tags) : null,
|
||||
source: entry.source || null,
|
||||
session_id: entry.sessionId || null,
|
||||
use_count: entry.useCount,
|
||||
embedding: entry.embedding ? JSON.stringify(entry.embedding) : null,
|
||||
created_at: entry.createdAt,
|
||||
updated_at: entry.updatedAt,
|
||||
last_used_at: entry.lastUsedAt
|
||||
};
|
||||
await dbBridge().saveMemory(row);
|
||||
return entry.id;
|
||||
}
|
||||
return this._idbSaveMemory(entry);
|
||||
}
|
||||
|
||||
async getMemory(id: string): Promise<MemoryEntry | null> {
|
||||
if (isDesktop()) {
|
||||
const row = await dbBridge().getMemory(id);
|
||||
return row ? this._rowToMemory(row) : null;
|
||||
}
|
||||
return this._idbGetMemory(id);
|
||||
}
|
||||
|
||||
async getAllMemories(): Promise<MemoryEntry[]> {
|
||||
if (isDesktop()) {
|
||||
const rows = await dbBridge().getAllMemories();
|
||||
return rows.map((r: any) => this._rowToMemory(r));
|
||||
}
|
||||
return this._idbGetAllMemories();
|
||||
}
|
||||
|
||||
async getMemoriesByType(type: string): Promise<MemoryEntry[]> {
|
||||
if (isDesktop()) {
|
||||
const rows = await dbBridge().getMemoriesByType(type);
|
||||
return rows.map((r: any) => this._rowToMemory(r));
|
||||
}
|
||||
return this._idbGetMemoriesByType(type);
|
||||
}
|
||||
|
||||
async deleteMemory(id: string): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
await dbBridge().deleteMemory(id);
|
||||
return;
|
||||
}
|
||||
return this._idbDeleteMemory(id);
|
||||
}
|
||||
|
||||
async clearAllMemories(): Promise<void> {
|
||||
if (isDesktop()) {
|
||||
await dbBridge().clearAllMemories();
|
||||
return;
|
||||
}
|
||||
return this._idbClearAllMemories();
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private _rowToMemory(row: any): MemoryEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
content: row.content,
|
||||
importance: row.importance,
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
source: row.source || undefined,
|
||||
sessionId: row.session_id || undefined,
|
||||
useCount: row.use_count,
|
||||
embedding: row.embedding ? JSON.parse(row.embedding) : undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastUsedAt: row.last_used_at
|
||||
};
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// IndexedDB fallback methods (Web 端兼容)
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
private _ensureIDB(): void {
|
||||
if (!this.idb) throw new Error('IndexedDB 未初始化');
|
||||
}
|
||||
|
||||
private _idbTx(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
|
||||
this._ensureIDB();
|
||||
return this.idb!.transaction(storeName, mode).objectStore(storeName);
|
||||
}
|
||||
|
||||
private async _idbSaveSession(session: ChatSession): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._idbTx('sessions', 'readwrite');
|
||||
const req = store.put(session);
|
||||
req.onsuccess = () => resolve(session.id);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async _idbGetSession(id: string): Promise<ChatSession | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._idbTx('sessions');
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async _idbGetAllSessions(): Promise<ChatSession[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._idbTx('sessions');
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async _idbDeleteSession(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._idbTx('sessions', 'readwrite');
|
||||
const req = store.delete(id);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async _idbClearAll(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._idbTx('sessions', 'readwrite');
|
||||
const req = store.clear();
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
private async _idbImportSessions(sessions: ChatSession[]): Promise<{ imported: number; skipped: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = this.idb!.transaction('sessions', 'readwrite');
|
||||
const store = tx.objectStore('sessions');
|
||||
let imported = 0; let skipped = 0;
|
||||
tx.oncomplete = () => resolve({ imported, skipped });
|
||||
tx.onerror = () => reject(tx.error);
|
||||
|
||||
for (const session of sessions) {
|
||||
if (!session.id || !Array.isArray(session.messages)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
const getRequest = store.get(session.id);
|
||||
getRequest.onsuccess = () => {
|
||||
if (getRequest.result) {
|
||||
skipped++;
|
||||
} else {
|
||||
store.put(session);
|
||||
imported++;
|
||||
}
|
||||
if (!session.id || !Array.isArray(session.messages)) { skipped++; continue; }
|
||||
const getReq = store.get(session.id);
|
||||
getReq.onsuccess = () => {
|
||||
if (getReq.result) { skipped++; } else { store.put(session); imported++; }
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getSessionsByTimeRange(startTime: number, endTime: number): Promise<ChatSession[]> {
|
||||
private async _idbSaveSetting(key: string, value: unknown): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('sessions');
|
||||
const index = store.index('updatedAt');
|
||||
const range = IDBKeyRange.bound(startTime, endTime);
|
||||
const request = index.getAll(range);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('settings', 'readwrite');
|
||||
const req = store.put({ key, value });
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async saveSetting(key: string, value: unknown): Promise<void> {
|
||||
private async _idbGetSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('settings', 'readwrite');
|
||||
const request = store.put({ key, value });
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('settings');
|
||||
const req = store.get(key);
|
||||
req.onsuccess = () => resolve(req.result ? req.result.value : defaultValue);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getSetting<T = unknown>(key: string, defaultValue: T | null = null): Promise<T> {
|
||||
private async _idbSaveMemory(entry: MemoryEntry): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('settings');
|
||||
const request = store.get(key);
|
||||
request.onsuccess = () => resolve(request.result ? request.result.value : defaultValue);
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('memories', 'readwrite');
|
||||
const req = store.put(entry);
|
||||
req.onsuccess = () => resolve(entry.id);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent 记忆系统 CRUD ──
|
||||
|
||||
async saveMemory(entry: MemoryEntry): Promise<string> {
|
||||
private async _idbGetMemory(id: string): Promise<MemoryEntry | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('memories', 'readwrite');
|
||||
const request = store.put(entry);
|
||||
request.onsuccess = () => resolve(entry.id);
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('memories');
|
||||
const req = store.get(id);
|
||||
req.onsuccess = () => resolve(req.result || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getMemory(id: string): Promise<MemoryEntry | null> {
|
||||
private async _idbGetAllMemories(): Promise<MemoryEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('memories');
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result || null);
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('memories');
|
||||
const req = store.getAll();
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getAllMemories(): Promise<MemoryEntry[]> {
|
||||
private async _idbGetMemoriesByType(type: string): Promise<MemoryEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('memories');
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
|
||||
async getMemoriesByType(type: string): Promise<MemoryEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('memories');
|
||||
const store = this._idbTx('memories');
|
||||
const index = store.index('type');
|
||||
const request = index.getAll(type);
|
||||
request.onsuccess = () => resolve(request.result || []);
|
||||
request.onerror = () => reject(request.error);
|
||||
const req = index.getAll(type);
|
||||
req.onsuccess = () => resolve(req.result || []);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMemory(id: string): Promise<void> {
|
||||
private async _idbDeleteMemory(id: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('memories', 'readwrite');
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('memories', 'readwrite');
|
||||
const req = store.delete(id);
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async clearAllMemories(): Promise<void> {
|
||||
private async _idbClearAllMemories(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const store = this._tx('memories', 'readwrite');
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
const store = this._idbTx('memories', 'readwrite');
|
||||
const req = store.clear();
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取原始 IndexedDB 实例(用于数据迁移) */
|
||||
getRawIDB(): IDBDatabase | null {
|
||||
return this.idb;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user