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:
thzxx
2026-04-17 12:35:42 +08:00
parent 5fb36a93e6
commit 26cbac71fe
14 changed files with 2001 additions and 332 deletions
+344 -120
View File
@@ -1,14 +1,24 @@
/**
* ChatDB - IndexedDB 封装
* v2: 新增 memories 存储(Agent 记忆系统)
* ChatDB - 存储兼容
* v4.0: 桌面端走 IPC → SQLiteWeb 端仍用 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;
}
}