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:
1 parent
a3f8226fda
commit
0cc7a81319
34 files changed
+2370
-198
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user