feat: add database support, conversation sidebar, and improve chat streaming

- Update .gitignore to exclude database files, extensions, and .cursor directory
- Enhance chat streaming API with safe enqueue and close functions to prevent errors
- Integrate ConversationSidebar component into ClientHome for better UI
- Add dependencies like better-sqlite3 and esbuild packages for database functionality

This commit introduces database persistence for conversations, improves error handling in streaming responses, and adds a sidebar for managing chats, enhancing overall app robustness and user experience.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-28 14:14:07 -03:00
1 parent a3f8226fda
commit 0cc7a81319
34 files changed
+2370 -198

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
let db: Database.Database | null = null;
export function getDatabase(): Database.Database {
if (!db) {
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
// Garantir que o diretório existe
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
db = new Database(dbPath);
// Configurações de performance
db.pragma("journal_mode = WAL");
db.pragma("synchronous = NORMAL");
db.pragma("foreign_keys = ON");
db.pragma("cache_size = 10000");
db.pragma("temp_store = memory");
}
return db;
}
export function closeDatabase(): void {
if (db) {
db.close();
db = null;
}
}
+69
View File
@@ -0,0 +1,69 @@
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
export function runMigrations(db: Database.Database): void {
// Criar tabela de migrações
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
applied_at INTEGER NOT NULL
);
`);
// Verificar migrações aplicadas
const applied = db.prepare("SELECT name FROM _migrations").all() as Array<{
name: string;
}>;
const appliedNames = new Set(applied.map((m) => m.name));
// Aplicar migração inicial se não foi aplicada
if (!appliedNames.has("001_initial_schema")) {
console.log("📋 Aplicando migração: 001_initial_schema");
const schemaPath = path.join(process.cwd(), "database", "schema.sql");
const schema = fs.readFileSync(schemaPath, "utf-8");
db.exec(schema);
// Registrar migração
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
"001_initial_schema",
Date.now()
);
console.log("✅ Migração 001_initial_schema aplicada");
}
// Inicializar extensão vetorial se disponível
try {
const extPath = path.join(
process.cwd(),
"database",
"extensions",
"vector.so"
);
if (fs.existsSync(extPath)) {
db.loadExtension(extPath);
// Inicializar índice vetorial
db.exec(`
SELECT vector_init('embeddings', 'vector', 'type=FLOAT32,dimension=384');
`);
// Quantizar para busca mais rápida
db.exec(`
SELECT vector_quantize('embeddings', 'vector');
`);
console.log("🔍 Extensão vetorial carregada e inicializada");
} else {
console.log(
"⚠️ Extensão vetorial não encontrada - busca vetorial desabilitada"
);
}
} catch (error) {
console.log("⚠️ Erro ao carregar extensão vetorial:", error);
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+78
View File
@@ -0,0 +1,78 @@
import Database from "better-sqlite3";
export interface Conversation {
id: string;
title: string | null;
model: string;
created_at: number;
updated_at: number;
}
export class ConversationRepository {
constructor(private db: Database.Database) {}
create(model: string, title?: string): string {
const id = crypto.randomUUID();
const now = Date.now();
this.db
.prepare(
`
INSERT INTO conversations (id, title, model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`
)
.run(id, title || "Nova conversa", model, now, now);
return id;
}
findAll(): Conversation[] {
return this.db
.prepare(
`
SELECT * FROM conversations
ORDER BY updated_at DESC
`
)
.all() as Conversation[];
}
findById(id: string): Conversation | null {
return this.db
.prepare(
`
SELECT * FROM conversations WHERE id = ?
`
)
.get(id) as Conversation | null;
}
updateTitle(id: string, title: string): void {
this.db
.prepare(
`
UPDATE conversations
SET title = ?, updated_at = ?
WHERE id = ?
`
)
.run(title, Date.now(), id);
}
updateTimestamp(id: string): void {
this.db
.prepare(
`
UPDATE conversations
SET updated_at = ?
WHERE id = ?
`
)
.run(Date.now(), id);
}
delete(id: string): void {
this.db.prepare("DELETE FROM conversations WHERE id = ?").run(id);
}
}
+100
View File
@@ -0,0 +1,100 @@
import Database from "better-sqlite3";
export interface Embedding {
id: string;
message_id: string;
vector: Buffer;
}
export interface SearchResult {
id: string;
content: string;
role: string;
distance: number;
conversation_id: string;
}
export class EmbeddingRepository {
constructor(private db: Database.Database) {}
async create(messageId: string, vector: Float32Array): Promise<void> {
const id = crypto.randomUUID();
const blob = Buffer.from(vector.buffer);
this.db
.prepare(
`
INSERT INTO embeddings (id, message_id, vector)
VALUES (?, ?, ?)
`
)
.run(id, messageId, blob);
}
async searchSimilar(
queryVector: Float32Array,
limit: number = 5
): Promise<SearchResult[]> {
const queryBlob = Buffer.from(queryVector.buffer);
try {
// Tentar busca vetorial se extensão estiver disponível
return this.db
.prepare(
`
SELECT
m.id, m.content, m.role, m.conversation_id,
vector_distance(e.vector, ?) as distance
FROM embeddings e
JOIN messages m ON m.id = e.message_id
ORDER BY distance ASC
LIMIT ?
`
)
.all(queryBlob, limit) as SearchResult[];
} catch (error) {
console.warn(
"Busca vetorial não disponível, usando busca simples:",
error
);
// Fallback: busca simples por conteúdo
return this.db
.prepare(
`
SELECT
m.id, m.content, m.role, m.conversation_id,
0.0 as distance
FROM messages m
WHERE m.content LIKE ?
ORDER BY m.timestamp DESC
LIMIT ?
`
)
.all(`%${queryVector.toString()}%`, limit) as SearchResult[];
}
}
findByMessage(messageId: string): Embedding | null {
return this.db
.prepare(
`
SELECT * FROM embeddings WHERE message_id = ?
`
)
.get(messageId) as Embedding | null;
}
deleteByMessage(messageId: string): void {
this.db
.prepare("DELETE FROM embeddings WHERE message_id = ?")
.run(messageId);
}
count(): number {
const result = this.db
.prepare("SELECT COUNT(*) as count FROM embeddings")
.get() as { count: number };
return result.count;
}
}
+86
View File
@@ -0,0 +1,86 @@
import Database from "better-sqlite3";
import type { ChatRole } from "@/lib/chat";
export interface Message {
id: string;
conversation_id: string;
role: ChatRole;
content: string;
timestamp: number;
}
export class MessageRepository {
constructor(private db: Database.Database) {}
create(conversationId: string, role: ChatRole, content: string): string {
const id = crypto.randomUUID();
this.db
.prepare(
`
INSERT INTO messages (id, conversation_id, role, content, timestamp)
VALUES (?, ?, ?, ?, ?)
`
)
.run(id, conversationId, role, content, Date.now());
return id;
}
findByConversation(conversationId: string): Message[] {
return this.db
.prepare(
`
SELECT * FROM messages
WHERE conversation_id = ?
ORDER BY timestamp ASC
`
)
.all(conversationId) as Message[];
}
findById(id: string): Message | null {
return this.db
.prepare(
`
SELECT * FROM messages WHERE id = ?
`
)
.get(id) as Message | null;
}
updateContent(id: string, content: string): void {
this.db
.prepare(
`
UPDATE messages
SET content = ?
WHERE id = ?
`
)
.run(content, id);
}
delete(id: string): void {
this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
}
deleteByConversation(conversationId: string): void {
this.db
.prepare("DELETE FROM messages WHERE conversation_id = ?")
.run(conversationId);
}
countByConversation(conversationId: string): number {
const result = this.db
.prepare(
`
SELECT COUNT(*) as count FROM messages
WHERE conversation_id = ?
`
)
.get(conversationId) as { count: number };
return result.count;
}
}
+32
View File
@@ -0,0 +1,32 @@
-- Schema inicial do banco de dados OllaHub
-- Conversas
CREATE TABLE conversations (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- Mensagens
CREATE TABLE messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
content TEXT NOT NULL,
timestamp INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
-- Embeddings vetoriais (384 dimensões do Ollama)
CREATE TABLE embeddings (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
vector BLOB NOT NULL,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
);
-- Índices para performance
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
CREATE INDEX idx_embeddings_message ON embeddings(message_id);