From 0cc7a81319aadabf15712da9566b55f1909d1068 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Tue, 28 Oct 2025 14:14:07 -0300 Subject: [PATCH] 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. --- .gitignore | 9 + app/api/conversations/[id]/messages/route.ts | 68 +++ app/api/conversations/[id]/route.ts | 58 ++ app/api/conversations/route.ts | 54 ++ app/api/ollama/chat/route.ts | 42 +- app/api/preferences/route.ts | 42 ++ app/api/search/route.ts | 25 + components/ClientHome.tsx | 32 +- components/chat/ChatContainer.tsx | 301 +++++++--- components/chat/ChatMessage.tsx | 25 +- components/chat/ConversationSidebar.tsx | 117 ++++ components/chat/SettingsModal.tsx | 121 ++++ components/message/MarkdownRenderer.tsx | 51 ++ components/message/MessageActions.tsx | 100 ++-- database/client.ts | 34 ++ database/migrations.ts | 69 +++ database/ollahub-preferences.db-shm | Bin 0 -> 32768 bytes database/ollahub-preferences.db-wal | Bin 0 -> 144232 bytes database/ollahub.db-shm | Bin 0 -> 32768 bytes database/ollahub.db-wal | Bin 0 -> 420272 bytes database/repositories/conversation.ts | 78 +++ database/repositories/embedding.ts | 100 ++++ database/repositories/message.ts | 86 +++ database/schema.sql | 32 ++ hooks/useCopyPrefs.ts | 28 + hooks/useUserPrefs.ts | 57 +- lib/embeddings.ts | 102 ++++ lib/ollama.ts | 64 ++- lib/preferences.ts | 99 ++++ lib/vector-search.ts | 32 ++ package.json | 5 + pnpm-lock.yaml | 575 ++++++++++++++++++- pnpm-workspace.yaml | 4 + scripts/init-db.ts | 158 +++++ 34 files changed, 2370 insertions(+), 198 deletions(-) create mode 100644 app/api/conversations/[id]/messages/route.ts create mode 100644 app/api/conversations/[id]/route.ts create mode 100644 app/api/conversations/route.ts create mode 100644 app/api/preferences/route.ts create mode 100644 app/api/search/route.ts create mode 100644 components/chat/ConversationSidebar.tsx create mode 100644 components/chat/SettingsModal.tsx create mode 100644 database/client.ts create mode 100644 database/migrations.ts create mode 100644 database/ollahub-preferences.db-shm create mode 100644 database/ollahub-preferences.db-wal create mode 100644 database/ollahub.db-shm create mode 100644 database/ollahub.db-wal create mode 100644 database/repositories/conversation.ts create mode 100644 database/repositories/embedding.ts create mode 100644 database/repositories/message.ts create mode 100644 database/schema.sql create mode 100644 hooks/useCopyPrefs.ts create mode 100644 lib/embeddings.ts create mode 100644 lib/preferences.ts create mode 100644 lib/vector-search.ts create mode 100644 scripts/init-db.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..4e35ea0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,12 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# database +database/*.db +database/extensions/*.so +database/extensions/*.dll +database/extensions/*.dylib + + +/.cursor \ No newline at end of file diff --git a/app/api/conversations/[id]/messages/route.ts b/app/api/conversations/[id]/messages/route.ts new file mode 100644 index 0000000..7c8f79d --- /dev/null +++ b/app/api/conversations/[id]/messages/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDatabase } from "@/database/client"; +import { MessageRepository } from "@/database/repositories/message"; +import { ConversationRepository } from "@/database/repositories/conversation"; +import { addMessageEmbedding } from "@/lib/vector-search"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const db = getDatabase(); + const messageRepo = new MessageRepository(db); + + const messages = messageRepo.findByConversation(id); + + return NextResponse.json(messages); + } catch (error) { + console.error("Erro ao buscar mensagens:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const { role, content } = await request.json(); + + if (!role || !content) { + return NextResponse.json( + { error: "Role e content são obrigatórios" }, + { status: 400 } + ); + } + + const db = getDatabase(); + const messageRepo = new MessageRepository(db); + const conversationRepo = new ConversationRepository(db); + + // Criar mensagem + const messageId = messageRepo.create(id, role, content); + + // Atualizar timestamp da conversa + conversationRepo.updateTimestamp(id); + + // Gerar embedding em background (apenas para mensagens do usuário e assistente) + if (role === "user" || role === "assistant") { + addMessageEmbedding(messageId, content).catch((error) => { + console.error("Erro ao gerar embedding:", error); + }); + } + + return NextResponse.json({ id: messageId }); + } catch (error) { + console.error("Erro ao criar mensagem:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} diff --git a/app/api/conversations/[id]/route.ts b/app/api/conversations/[id]/route.ts new file mode 100644 index 0000000..2c7181b --- /dev/null +++ b/app/api/conversations/[id]/route.ts @@ -0,0 +1,58 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDatabase } from "@/database/client"; +import { ConversationRepository } from "@/database/repositories/conversation"; +import { MessageRepository } from "@/database/repositories/message"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const db = getDatabase(); + const conversationRepo = new ConversationRepository(db); + const messageRepo = new MessageRepository(db); + + const conversation = conversationRepo.findById(id); + if (!conversation) { + return NextResponse.json( + { error: "Conversa não encontrada" }, + { status: 404 } + ); + } + + const messages = messageRepo.findByConversation(id); + + return NextResponse.json({ + conversation, + messages, + }); + } catch (error) { + console.error("Erro ao buscar conversa:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const db = getDatabase(); + const conversationRepo = new ConversationRepository(db); + + conversationRepo.delete(id); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erro ao deletar conversa:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} diff --git a/app/api/conversations/route.ts b/app/api/conversations/route.ts new file mode 100644 index 0000000..95d1b24 --- /dev/null +++ b/app/api/conversations/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDatabase } from "@/database/client"; +import { ConversationRepository } from "@/database/repositories/conversation"; +import { MessageRepository } from "@/database/repositories/message"; +import { generateChatTitle } from "@/lib/embeddings"; + +export async function GET() { + try { + const db = getDatabase(); + const conversationRepo = new ConversationRepository(db); + + const conversations = conversationRepo.findAll(); + + return NextResponse.json(conversations); + } catch (error) { + console.error("Erro ao buscar conversas:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const { model, firstMessage } = await request.json(); + + if (!model) { + return NextResponse.json( + { error: "Modelo é obrigatório" }, + { status: 400 } + ); + } + + const db = getDatabase(); + const conversationRepo = new ConversationRepository(db); + + // Gerar título se primeira mensagem fornecida + let title = "Nova conversa"; + if (firstMessage) { + title = await generateChatTitle(firstMessage); + } + + const conversationId = conversationRepo.create(model, title); + + return NextResponse.json({ id: conversationId, title }); + } catch (error) { + console.error("Erro ao criar conversa:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} diff --git a/app/api/ollama/chat/route.ts b/app/api/ollama/chat/route.ts index 1c94e3e..5b3b462 100644 --- a/app/api/ollama/chat/route.ts +++ b/app/api/ollama/chat/route.ts @@ -14,6 +14,30 @@ export async function POST(req: NextRequest) { const stream = new ReadableStream({ async start(controller) { + let isClosed = false; + + const safeEnqueue = (data: string) => { + if (!isClosed) { + try { + controller.enqueue(new TextEncoder().encode(data)); + } catch (error) { + console.error("Error enqueuing data:", error); + isClosed = true; + } + } + }; + + const safeClose = () => { + if (!isClosed) { + try { + controller.close(); + isClosed = true; + } catch (error) { + console.error("Error closing controller:", error); + } + } + }; + try { const ollamaMessages = convertToOllamaMessages(messages); const response = await chatWithStream( @@ -26,24 +50,18 @@ export async function POST(req: NextRequest) { for await (const chunk of response) { const token = chunk.message?.content || ""; if (token) { - controller.enqueue( - new TextEncoder().encode(JSON.stringify({ token }) + "\n") - ); + safeEnqueue(JSON.stringify({ token }) + "\n"); } } - controller.enqueue( - new TextEncoder().encode(JSON.stringify({ done: true }) + "\n") - ); - controller.close(); + safeEnqueue(JSON.stringify({ done: true }) + "\n"); + safeClose(); } catch (error) { console.error("Chat streaming error:", error); - controller.enqueue( - new TextEncoder().encode( - JSON.stringify({ error: "Failed to generate response" }) + "\n" - ) + safeEnqueue( + JSON.stringify({ error: "Failed to generate response" }) + "\n" ); - controller.close(); + safeClose(); } }, }); diff --git a/app/api/preferences/route.ts b/app/api/preferences/route.ts new file mode 100644 index 0000000..0da9a7c --- /dev/null +++ b/app/api/preferences/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPreferences, setPreferences } from "@/lib/preferences"; + +// Estrutura de preferências +type UserPreferences = { + selectedModel: string | null; + systemPrompt: string; + device: "auto" | "cpu" | "gpu"; + gpuIndex: number; + numGpu: number; +}; + +// GET - Buscar preferências +export async function GET(request: NextRequest) { + try { + const userId = "default"; // Pode ser expandido para multi-usuário + const prefs = await getPreferences(userId); + return NextResponse.json(prefs); + } catch (error) { + console.error("Erro ao buscar preferências:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} + +// POST - Salvar preferências +export async function POST(request: NextRequest) { + try { + const userId = "default"; + const prefs = await request.json(); + await setPreferences(userId, prefs); + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Erro ao salvar preferências:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} diff --git a/app/api/search/route.ts b/app/api/search/route.ts new file mode 100644 index 0000000..9a0fe57 --- /dev/null +++ b/app/api/search/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { semanticSearch } from "@/lib/vector-search"; + +export async function POST(request: NextRequest) { + try { + const { query, limit = 5 } = await request.json(); + + if (!query) { + return NextResponse.json( + { error: "Query é obrigatória" }, + { status: 400 } + ); + } + + const results = await semanticSearch(query, limit); + + return NextResponse.json(results); + } catch (error) { + console.error("Erro na busca semântica:", error); + return NextResponse.json( + { error: "Erro interno do servidor" }, + { status: 500 } + ); + } +} diff --git a/components/ClientHome.tsx b/components/ClientHome.tsx index 3e6903c..c16c185 100644 --- a/components/ClientHome.tsx +++ b/components/ClientHome.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { ModeToggle } from "@/components/mode-toggle"; import { ChatContainer } from "@/components/chat/ChatContainer"; +import { ConversationSidebar } from "@/components/chat/ConversationSidebar"; import { SettingsButton, SettingsModal, @@ -15,6 +16,23 @@ interface ClientHomeProps { export function ClientHome({ offline, models }: ClientHomeProps) { const [open, setOpen] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [currentConversationId, setCurrentConversationId] = useState< + string | null + >(null); + + const handleConversationCreated = () => { + // Incrementar trigger para forçar atualização da sidebar + setRefreshTrigger((prev) => prev + 1); + }; + + const handleSelectConversation = (id: string) => { + setCurrentConversationId(id); + }; + + const handleNewConversation = () => { + setCurrentConversationId(null); + }; return (
@@ -33,7 +51,19 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
- +
+ + +
setOpen(false)} /> diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index 88e4991..7c00e2a 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -3,8 +3,9 @@ import { useState, useEffect, useRef } from "react"; import { ModelDropdown } from "@/components/model/ModelDropdown"; import { ModelPullDialog } from "@/components/model/ModelPullDialog"; -import { ChatMessage, ChatInput } from "@/components/chat/ChatMessage"; -import { SystemPrompt } from "@/components/chat/SystemPrompt"; +import { ChatMessage } from "@/components/chat/ChatMessage"; +import { SettingsModal } from "@/components/chat/SettingsModal"; +import { Send, Square } from "lucide-react"; import type { ModelInfo } from "@/lib/models"; import type { Message } from "@/lib/chat"; import { createMessage } from "@/lib/chat"; @@ -13,20 +14,40 @@ import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs"; interface ChatContainerProps { models: ModelInfo[]; offline: boolean; + onConversationCreated?: () => void; } -export function ChatContainer({ models, offline }: ChatContainerProps) { - const { prefs, ready } = useUserPrefs(); +export function ChatContainer({ + models, + offline, + onConversationCreated, +}: ChatContainerProps) { + const { prefs, ready, update } = useUserPrefs(); const [messages, setMessages] = useState([]); const [selectedModel, setSelectedModel] = useState(null); + const [conversationId, setConversationId] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(""); + const [inputContent, setInputContent] = useState(""); const abortRef = useRef(null); const [showPullDialog, setShowPullDialog] = useState(false); const [modelToPull, setModelToPull] = useState(""); const [systemPrompt, setSystemPrompt] = useState(""); const messagesEndRef = useRef(null); + // Carregar modelo salvo ao iniciar + useEffect(() => { + if (ready && prefs.selectedModel && !selectedModel) { + setSelectedModel(prefs.selectedModel); + } + }, [ready, prefs.selectedModel]); + + // Salvar modelo quando selecionado + const handleModelChange = (modelName: string) => { + setSelectedModel(modelName); + update({ selectedModel: modelName }); // Salvar no Redis + }; + const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); }; @@ -35,9 +56,55 @@ export function ChatContainer({ models, offline }: ChatContainerProps) { scrollToBottom(); }, [messages, streamingContent]); + const loadConversation = async (id: string) => { + try { + const response = await fetch(`/api/conversations/${id}`); + const data = await response.json(); + + if (data.conversation && data.messages) { + setConversationId(id); + setMessages(data.messages); + setSelectedModel(data.conversation.model); + } + } catch (error) { + console.error("Erro ao carregar conversa:", error); + } + }; + + const startNewConversation = () => { + setConversationId(null); + setMessages([]); + }; + const handleSendMessage = async (content: string) => { if (!selectedModel || isStreaming) return; + // Criar nova conversa se não existir (COM TÍTULO) + let currentConversationId = conversationId; + if (!currentConversationId) { + try { + const response = await fetch("/api/conversations", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: selectedModel, + firstMessage: content, // Passar primeira mensagem + }), + }); + const { id, title } = await response.json(); + currentConversationId = id; + setConversationId(id); + console.log("✨ Chat criado:", title); + + // Notificar sidebar para atualizar lista de conversas + if (onConversationCreated) { + onConversationCreated(); + } + } catch (error) { + console.error("Erro ao criar conversa:", error); + } + } + const userMessage = createMessage("user", content); const newMessages = [...messages, userMessage]; @@ -51,6 +118,19 @@ export function ChatContainer({ models, offline }: ChatContainerProps) { setIsStreaming(true); setStreamingContent(""); + // Persistir mensagem do usuário + if (currentConversationId) { + try { + await fetch(`/api/conversations/${currentConversationId}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: "user", content }), + }); + } catch (error) { + console.error("Erro ao persistir mensagem:", error); + } + } + try { const options = buildOllamaOptions( prefs.device, @@ -105,6 +185,28 @@ export function ChatContainer({ models, offline }: ChatContainerProps) { ); setMessages((prev) => [...prev, assistantMessage]); setStreamingContent(""); + + // Persistir mensagem do assistente + if (currentConversationId) { + try { + await fetch( + `/api/conversations/${currentConversationId}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + role: "assistant", + content: assistantContent, + }), + } + ); + } catch (error) { + console.error( + "Erro ao persistir mensagem do assistente:", + error + ); + } + } } if (data.error) { throw new Error(data.error); @@ -128,16 +230,36 @@ export function ChatContainer({ models, offline }: ChatContainerProps) { } }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (inputContent.trim() && !isStreaming) { + handleSendMessage(inputContent.trim()); + setInputContent(""); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(e); + } + }; + const handleStop = () => { if (abortRef.current && isStreaming) { abortRef.current.abort(); } }; + const handleSystemPromptChange = (prompt: string) => { + setSystemPrompt(prompt); + update({ systemPrompt: prompt }); // Salvar no SQLite + }; + const handleSelectModel = (modelId: string) => { const model = models.find((m) => m.id === modelId); if (model) { - setSelectedModel(modelId); + handleModelChange(modelId); // Optional: clear chat when switching models // setMessages([]); } @@ -191,97 +313,98 @@ export function ChatContainer({ models, offline }: ChatContainerProps) { const selectedModelData = models.find((m) => m.id === selectedModel); return ( -
- {/* Sidebar */} -
-
-

- Models -

- -
- - {offline && ( -
-

- Ollama is offline. Using mock data. -

-
- )} - - {selectedModelData && ( -
-

Selected Model

-
-
Name: {selectedModelData.name}
-
Size: {selectedModelData.sizeGB} GB
-
Quantization: {selectedModelData.quantization}
-
Device: {selectedModelData.device}
-
-
- )} - - -
- - {/* Chat Area */} -
- {/* Chat Header */} -
+
+ {/* Chat Header */} +
+

{selectedModelData - ? `Chat with ${selectedModelData.name}` + ? "Chat with" : "Select a model to start chatting"}

-
- -
-
- - {/* Messages */} -
- {messages.length === 0 ? ( -
-
-

Welcome to Ollahub

-

Select a model and start chatting!

-
-
- ) : ( - <> - {messages.map((message) => ( - - ))} - {isStreaming && streamingContent && ( - - )} -
- + {selectedModelData && ( + )}
+
+ + +
+
- {/* Input */} - + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+

Welcome to Ollahub

+

Select a model and start chatting!

+
+
+ ) : ( + <> + {messages.map((message) => ( + + ))} + {isStreaming && streamingContent && ( + + )} +
+ + )} +
+ + {/* Input */} +
+
+
+