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 */}
+
{/* Pull Dialog */}
diff --git a/components/chat/ChatMessage.tsx b/components/chat/ChatMessage.tsx
index 10aa5e6..0027a95 100644
--- a/components/chat/ChatMessage.tsx
+++ b/components/chat/ChatMessage.tsx
@@ -1,10 +1,9 @@
"use client";
import { useState, useEffect, useRef } from "react";
-import { User, Bot, Send, Loader2 } from "lucide-react";
+import { User, Bot, Send, Loader2, Square } from "lucide-react";
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
import { MessageActions } from "@/components/message/MessageActions";
-import { extractCodeBlocks } from "@/hooks/useMarkdown";
import type { Message } from "@/lib/chat";
import { formatTimestamp } from "@/lib/chat";
@@ -29,7 +28,7 @@ export function ChatMessage({
)}
{isStreaming &&
▊}
{!isUser && !isStreaming && (
-
-
+
+
)}
void;
+ onStop?: () => void;
disabled?: boolean;
+ isStreaming?: boolean;
placeholder?: string;
}
export function ChatInput({
onSendMessage,
+ onStop,
disabled = false,
+ isStreaming = false,
placeholder = "Type a message...",
}: ChatInputProps) {
const [content, setContent] = useState("");
@@ -125,11 +125,14 @@ export function ChatInput({
maxLength={2000}
/>