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

+9
View File
@@ -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
@@ -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 }
);
}
}
+58
View File
@@ -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 }
);
}
}
+54
View File
@@ -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 }
);
}
}
+30 -12
View File
@@ -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();
}
},
});
+42
View File
@@ -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 }
);
}
}
+25
View File
@@ -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 }
);
}
}
+31 -1
View File
@@ -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 (
<div className="h-screen flex flex-col bg-[var(--background)]">
@@ -33,7 +51,19 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
</div>
</header>
<main className="flex-1 overflow-hidden">
<ChatContainer models={models} offline={offline} />
<div className="flex h-full">
<ConversationSidebar
currentConversationId={currentConversationId}
onSelectConversation={handleSelectConversation}
onNewConversation={handleNewConversation}
refreshTrigger={refreshTrigger}
/>
<ChatContainer
models={models}
offline={offline}
onConversationCreated={handleConversationCreated}
/>
</div>
</main>
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
</div>
+212 -89
View File
@@ -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<Message[]>([]);
const [selectedModel, setSelectedModel] = useState<string | null>(null);
const [conversationId, setConversationId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [inputContent, setInputContent] = useState("");
const abortRef = useRef<AbortController | null>(null);
const [showPullDialog, setShowPullDialog] = useState(false);
const [modelToPull, setModelToPull] = useState<string>("");
const [systemPrompt, setSystemPrompt] = useState<string>("");
const messagesEndRef = useRef<HTMLDivElement>(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 (
<div className="flex h-full bg-[var(--background)]">
{/* Sidebar */}
<div className="w-80 border-r border-[var(--border)] bg-[var(--surface)] p-4 overflow-y-auto">
<div className="mb-4">
<h2 className="text-sm font-medium text-[var(--foreground)]/80 mb-2">
Models
</h2>
<ModelDropdown
models={models}
selectedModel={selectedModel}
onSelectModel={handleSelectModel}
disabled={isStreaming}
/>
</div>
{offline && (
<div className="rounded-lg border border-orange-200 bg-orange-50 p-3">
<p className="text-sm text-orange-800">
Ollama is offline. Using mock data.
</p>
</div>
)}
{selectedModelData && (
<div className="rounded-lg border border-[var(--border)] bg-[var(--background)] p-3">
<h3 className="font-medium text-sm mb-2">Selected Model</h3>
<div className="space-y-1 text-xs text-[var(--foreground)]/80">
<div>Name: {selectedModelData.name}</div>
<div>Size: {selectedModelData.sizeGB} GB</div>
<div>Quantization: {selectedModelData.quantization}</div>
<div>Device: {selectedModelData.device}</div>
</div>
</div>
)}
<SystemPrompt onSystemPromptChange={setSystemPrompt} />
</div>
{/* Chat Area */}
<div className="flex-1 flex flex-col min-h-0">
{/* Chat Header */}
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3">
<div className="flex-1 flex flex-col bg-[var(--background)] min-h-0">
{/* Chat Header */}
<div className="border-b border-[var(--border)] p-4 flex items-center justify-between gap-3 flex-shrink-0">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">
{selectedModelData
? `Chat with ${selectedModelData.name}`
? "Chat with"
: "Select a model to start chatting"}
</h1>
<div className="flex items-center gap-2">
<button
onClick={handleStop}
disabled={!isStreaming}
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
>
Stop
</button>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-[var(--foreground)]/60">
<p className="text-lg mb-2">Welcome to Ollahub</p>
<p className="text-sm">Select a model and start chatting!</p>
</div>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{isStreaming && streamingContent && (
<ChatMessage
message={createMessage("assistant", streamingContent)}
isStreaming={true}
/>
)}
<div ref={messagesEndRef} />
</>
{selectedModelData && (
<ModelDropdown
models={models}
selectedModel={selectedModel}
onSelectModel={handleSelectModel}
disabled={isStreaming}
/>
)}
</div>
<div className="flex items-center gap-2">
<SettingsModal
systemPrompt={systemPrompt}
onSystemPromptChange={handleSystemPromptChange}
selectedModel={selectedModel}
models={models}
/>
<button
onClick={handleStop}
disabled={!isStreaming}
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
>
Stop
</button>
</div>
</div>
{/* Input */}
<ChatInput
onSendMessage={handleSendMessage}
disabled={!selectedModel || isStreaming}
placeholder={
selectedModel ? "Type a message..." : "Select a model first..."
}
/>
{/* Messages */}
<div className="flex-1 overflow-y-auto min-h-0">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center text-[var(--foreground)]/60">
<p className="text-lg mb-2">Welcome to Ollahub</p>
<p className="text-sm">Select a model and start chatting!</p>
</div>
</div>
) : (
<>
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{isStreaming && streamingContent && (
<ChatMessage
message={createMessage("assistant", streamingContent)}
isStreaming={true}
/>
)}
<div ref={messagesEndRef} />
</>
)}
</div>
{/* Input */}
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--background)]">
<form onSubmit={handleSubmit} className="p-4">
<div className="flex gap-2">
<textarea
value={inputContent}
onChange={(e) => setInputContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selectedModel ? "Type a message..." : "Select a model first..."
}
disabled={!selectedModel || isStreaming}
className="flex-1 resize-none rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)] disabled:opacity-50"
rows={1}
maxLength={2000}
/>
<button
type={isStreaming ? "button" : "submit"}
onClick={isStreaming ? handleStop : undefined}
disabled={
isStreaming ? false : !inputContent.trim() || !selectedModel
}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
>
{isStreaming ? (
<Square className="h-4 w-4" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
</form>
</div>
{/* Pull Dialog */}
+14 -11
View File
@@ -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({
</div>
)}
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
className={`max-w-[80%] relative rounded-lg px-4 py-2 ${
isUser
? "bg-[var(--accent)] text-white"
: "bg-[var(--surface)] text-[var(--foreground)]"
@@ -44,11 +43,8 @@ export function ChatMessage({
</div>
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
{!isUser && !isStreaming && (
<div className="mt-2">
<MessageActions
content={message.content}
extractCodeBlocks={extractCodeBlocks}
/>
<div className="absolute bottom-2 right-2">
<MessageActions content={message.content} />
</div>
)}
<div
@@ -71,13 +67,17 @@ export function ChatMessage({
interface ChatInputProps {
onSendMessage: (content: string) => 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}
/>
<button
type="submit"
disabled={!content.trim() || disabled}
type={isStreaming ? "button" : "submit"}
onClick={isStreaming ? onStop : undefined}
disabled={isStreaming ? false : !content.trim() || disabled}
className="flex h-10 w-10 items-center justify-center rounded-lg bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
>
{disabled ? (
{isStreaming ? (
<Square className="h-4 w-4" />
) : disabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
+117
View File
@@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { MessageSquare, Plus, Trash2 } from "lucide-react";
type Conversation = {
id: string;
title: string;
model: string;
updated_at: number;
};
type ConversationSidebarProps = {
currentConversationId: string | null;
onSelectConversation: (id: string) => void;
onNewConversation: () => void;
refreshTrigger?: number; // Para forçar atualização
};
export function ConversationSidebar({
currentConversationId,
onSelectConversation,
onNewConversation,
refreshTrigger,
}: ConversationSidebarProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadConversations();
}, []);
// Recarregar conversas quando refreshTrigger mudar
useEffect(() => {
if (refreshTrigger !== undefined) {
loadConversations();
}
}, [refreshTrigger]);
const loadConversations = async () => {
try {
const response = await fetch("/api/conversations");
const data = await response.json();
setConversations(data);
} catch (error) {
console.error("Erro ao carregar conversas:", error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Deletar esta conversa?")) return;
try {
await fetch(`/api/conversations/${id}`, { method: "DELETE" });
setConversations((prev) => prev.filter((c) => c.id !== id));
if (currentConversationId === id) {
onNewConversation();
}
} catch (error) {
console.error("Erro ao deletar conversa:", error);
}
};
return (
<div className="w-64 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
{/* Header */}
<div className="p-4 border-b border-[var(--border)]">
<button
onClick={onNewConversation}
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:opacity-90 transition-opacity"
>
<Plus className="h-4 w-4" />
Nova Conversa
</button>
</div>
{/* Lista de Conversas */}
<div className="flex-1 overflow-y-auto p-2">
{loading ? (
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
Carregando...
</div>
) : conversations.length === 0 ? (
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
Nenhuma conversa ainda
</div>
) : (
conversations.map((conv) => (
<div
key={conv.id}
onClick={() => onSelectConversation(conv.id)}
className={`w-full flex items-center gap-2 p-3 rounded-lg mb-1 text-left transition-colors group cursor-pointer ${
currentConversationId === conv.id
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-[var(--background)]"
}`}
>
<MessageSquare className="h-4 w-4 flex-shrink-0" />
<span className="flex-1 text-sm truncate">{conv.title}</span>
<button
onClick={(e) => handleDelete(conv.id, e)}
className="p-1 hover:bg-[var(--background)] rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))
)}
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useState } from "react";
import { Settings, X } from "lucide-react";
type SettingsModalProps = {
systemPrompt: string;
onSystemPromptChange: (prompt: string) => void;
selectedModel: string | null;
models: Array<{ id: string; name: string; device: string }>;
};
export function SettingsModal({
systemPrompt,
onSystemPromptChange,
selectedModel,
models,
}: SettingsModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
const selectedModelData = models.find((m) => m.id === selectedModel);
const handleSave = () => {
onSystemPromptChange(tempPrompt);
setIsOpen(false);
};
const handleCancel = () => {
setTempPrompt(systemPrompt);
setIsOpen(false);
};
return (
<>
{/* Botão de Configurações */}
<button
onClick={() => setIsOpen(true)}
className="p-2 rounded-lg hover:bg-[var(--surface)] transition-colors"
aria-label="Configurações"
>
<Settings className="h-5 w-5" />
</button>
{/* Modal */}
{isOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-[var(--background)] rounded-lg border border-[var(--border)] w-full max-w-2xl mx-4 max-h-[80vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold">Configurações</h2>
<button
onClick={handleCancel}
className="p-1 rounded hover:bg-[var(--surface)] transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Model Info */}
{selectedModelData && (
<div className="space-y-3">
<h3 className="font-medium">Modelo Selecionado</h3>
<div className="rounded-lg border border-[var(--border)] bg-[var(--surface)] p-3">
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-[var(--foreground)]/60">Nome:</span>
<span>{selectedModelData.name}</span>
</div>
<div className="flex justify-between">
<span className="text-[var(--foreground)]/60">Dispositivo:</span>
<span className="capitalize">{selectedModelData.device}</span>
</div>
</div>
</div>
</div>
)}
{/* System Prompt */}
<div className="space-y-3">
<h3 className="font-medium">System Prompt</h3>
<div className="space-y-2">
<label className="text-sm text-[var(--foreground)]/80">
Instruções para o modelo (opcional)
</label>
<textarea
value={tempPrompt}
onChange={(e) => setTempPrompt(e.target.value)}
placeholder="Ex: Você é um assistente útil e prestativo..."
className="w-full h-32 p-3 rounded-lg border border-[var(--border)] bg-[var(--background)] text-[var(--foreground)] resize-none focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
<p className="text-xs text-[var(--foreground)]/60">
Este prompt será usado como contexto para todas as mensagens desta conversa.
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-[var(--border)]">
<button
onClick={handleCancel}
className="px-4 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface)] transition-colors"
>
Cancelar
</button>
<button
onClick={handleSave}
className="px-4 py-2 rounded-lg bg-[var(--accent)] text-white hover:opacity-90 transition-opacity"
>
Salvar
</button>
</div>
</div>
</div>
)}
</>
);
}
+51
View File
@@ -5,6 +5,7 @@ import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeHighlight from "rehype-highlight";
import { Copy } from "lucide-react";
type MarkdownRendererProps = {
content: string;
@@ -15,6 +16,56 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) {
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeHighlight]}
components={{
code({ inline, className, children, ...props }: any) {
const text = String(children || "");
if (inline) {
return (
<code className="rounded bg-[var(--surface)] px-1 py-0.5 text-[0.95em]">
{children}
</code>
);
}
return (
<div className="relative my-3 rounded-lg border border-[var(--border)] bg-[var(--background)]">
<button
type="button"
className="absolute right-2 top-2 rounded px-2 py-1 text-xs bg-[var(--surface)]/70 hover:bg-[var(--surface)] transition-colors"
onClick={() => navigator.clipboard.writeText(text)}
aria-label="Copiar código"
>
<Copy className="h-3 w-3" />
</button>
<pre className="overflow-auto p-3">
<code className={className} {...props}>
{children}
</code>
</pre>
</div>
);
},
h1: ({ children }: any) => (
<h1 className="text-xl font-bold mt-3 mb-1">{children}</h1>
),
h2: ({ children }: any) => (
<h2 className="text-lg font-semibold mt-2 mb-1">{children}</h2>
),
h3: ({ children }: any) => (
<h3 className="text-base font-semibold mt-2 mb-1">{children}</h3>
),
h4: ({ children }: any) => (
<h4 className="text-sm font-semibold mt-1 mb-1">{children}</h4>
),
h5: ({ children }: any) => (
<h5 className="text-sm font-medium mt-1 mb-1">{children}</h5>
),
h6: ({ children }: any) => (
<h6 className="text-xs font-medium mt-1 mb-1">{children}</h6>
),
p: ({ children }: any) => <p className="mb-1">{children}</p>,
}}
>
{content}
</ReactMarkdown>
+49 -51
View File
@@ -1,16 +1,15 @@
"use client";
import { useState } from "react";
import { Copy, Edit3, RefreshCw, Check } from "lucide-react";
import { Copy, Edit3, RefreshCw, Check, ChevronDown } from "lucide-react";
import { useCopyPrefs } from "@/hooks/useCopyPrefs";
import { stripMarkdown } from "@/hooks/useMarkdown";
type MessageActionsProps = {
content: string;
disabled?: boolean;
onEdit?: () => void;
onRegenerate?: () => void;
extractCodeBlocks?: (
text: string
) => Array<{ language: string; code: string }>;
};
export function MessageActions({
@@ -18,70 +17,69 @@ export function MessageActions({
disabled,
onEdit,
onRegenerate,
extractCodeBlocks,
}: MessageActionsProps) {
const [copied, setCopied] = useState<"none" | "markdown" | "text" | "code">(
"none"
);
const [copied, setCopied] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const { copyMode, setCopyMode } = useCopyPrefs();
const copy = async (text: string, flag: MessageActionsProps["content"]) => {
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(flag as any);
setTimeout(() => setCopied("none"), 1500);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
const stripMarkdown = (md: string) =>
md
.replace(/```[\s\S]*?```/g, (block) =>
block.replace(/```[\s\S]*?\n/, "").replace(/```$/, "")
)
.replace(/[#*_>`~-]/g, "");
const codeBlocks = extractCodeBlocks ? extractCodeBlocks(content) : [];
const handleCopyDefault = () => {
const textToCopy =
copyMode === "markdown" ? content : stripMarkdown(content);
copy(textToCopy);
};
const handleModeSelect = (mode: "markdown" | "text") => {
setCopyMode(mode);
const textToCopy = mode === "markdown" ? content : stripMarkdown(content);
copy(textToCopy);
setDropdownOpen(false);
};
return (
<div className="flex items-center gap-1 opacity-70">
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => copy(content, "markdown")}
onClick={handleCopyDefault}
disabled={disabled}
aria-label="Copy as markdown"
aria-label="Copiar"
>
{copied === "markdown" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}{" "}
MD
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => copy(stripMarkdown(content), "text")}
disabled={disabled}
aria-label="Copy as plain text"
>
{copied === "text" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}{" "}
TXT
</button>
{codeBlocks.map((b, i) => (
<div className="relative">
<button
key={`${b.language}-${i}`}
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => copy(b.code, "code")}
className="rounded px-1 py-1 text-xs hover:bg-[var(--surface)]"
onClick={() => setDropdownOpen(!dropdownOpen)}
disabled={disabled}
aria-label={`Copy ${b.language} code`}
aria-label="Selecionar formato"
>
{copied === "code" ? (
<Check className="h-3 w-3" />
) : (
<Copy className="h-3 w-3" />
)}{" "}
{b.language}
<ChevronDown className="h-3 w-3" />
</button>
))}
{dropdownOpen && (
<div className="absolute right-0 z-10 mt-1 rounded border border-[var(--border)] bg-[var(--background)] shadow-lg">
<button
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
onClick={() => handleModeSelect("markdown")}
>
Markdown
</button>
<button
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
onClick={() => handleModeSelect("text")}
>
Texto
</button>
</div>
)}
</div>
{onEdit && (
<button
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
+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);
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useCallback, useEffect, useState } from "react";
export type CopyMode = "markdown" | "text";
const KEY = "ollahub-copy-mode";
export function useCopyPrefs() {
const [copyMode, setCopyModeState] = useState<CopyMode>("markdown");
useEffect(() => {
if (typeof window === "undefined") return;
const saved = localStorage.getItem(KEY) as CopyMode | null;
if (saved === "markdown" || saved === "text") {
setCopyModeState(saved);
}
}, []);
const setCopyMode = useCallback((mode: CopyMode) => {
setCopyModeState(mode);
if (typeof window !== "undefined") {
localStorage.setItem(KEY, mode);
}
}, []);
return { copyMode, setCopyMode };
}
+27 -30
View File
@@ -5,20 +5,15 @@ import { useCallback, useEffect, useState } from "react";
export type DevicePref = "auto" | "cpu" | "gpu";
type Prefs = {
selectedModel: string | null; // NOVO
systemPrompt: string;
device: DevicePref;
gpuIndex: number; // main_gpu
numGpu: number; // num_gpu
gpuIndex: number;
numGpu: number;
};
const KEYS = {
systemPrompt: "ollahub-system-prompt",
device: "ollahub-device-pref",
gpuIndex: "ollahub-gpu-index",
numGpu: "ollahub-num-gpu",
} as const;
const defaultPrefs: Prefs = {
selectedModel: null, // NOVO
systemPrompt: "",
device: "auto",
gpuIndex: 0,
@@ -29,32 +24,34 @@ export function useUserPrefs() {
const [prefs, setPrefs] = useState<Prefs>(defaultPrefs);
const [ready, setReady] = useState(false);
// Carregar do Redis
useEffect(() => {
if (typeof window === "undefined") return;
const p: Prefs = {
systemPrompt:
localStorage.getItem(KEYS.systemPrompt) || defaultPrefs.systemPrompt,
device:
(localStorage.getItem(KEYS.device) as DevicePref) ||
defaultPrefs.device,
gpuIndex: Number(
localStorage.getItem(KEYS.gpuIndex) || defaultPrefs.gpuIndex
),
numGpu: Number(localStorage.getItem(KEYS.numGpu) || defaultPrefs.numGpu),
};
setPrefs(p);
setReady(true);
fetch("/api/preferences")
.then((res) => res.json())
.then((data) => {
setPrefs(data);
setReady(true);
})
.catch((err) => {
console.error("Erro ao carregar preferências:", err);
setReady(true);
});
}, []);
// Salvar no Redis
const update = useCallback((next: Partial<Prefs>) => {
setPrefs((prev) => {
const merged: Prefs = { ...prev, ...next };
if (typeof window !== "undefined") {
localStorage.setItem(KEYS.systemPrompt, merged.systemPrompt);
localStorage.setItem(KEYS.device, merged.device);
localStorage.setItem(KEYS.gpuIndex, String(merged.gpuIndex));
localStorage.setItem(KEYS.numGpu, String(merged.numGpu));
}
// Salvar no Redis
fetch("/api/preferences", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(merged),
}).catch((err) =>
console.error("Erro ao salvar preferências:", err)
);
return merged;
});
}, []);
@@ -74,4 +71,4 @@ export function buildOllamaOptions(
num_gpu: Math.max(1, numGpu),
main_gpu: Math.max(0, gpuIndex),
} as const;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Ollama } from "ollama";
const ollama = new Ollama({
host: process.env.OLLAMA_HOST || "http://localhost:11434",
});
export async function generateEmbedding(
text: string,
model: string = "nomic-embed-text"
): Promise<Float32Array> {
try {
const response = await ollama.embeddings({
model,
prompt: text,
});
return new Float32Array(response.embedding);
} catch (error) {
console.error("Erro ao gerar embedding:", error);
// Fallback: retornar vetor zero se modelo não estiver disponível
console.warn(
`Modelo ${model} não disponível, usando vetor zero como fallback`
);
return new Float32Array(384); // 384 dimensões padrão
}
}
const EMOJI_CATEGORIES = {
code: ["💻", "🖥️", "⌨️", "🔧", "🛠️"],
idea: ["💡", "🧠", "💭", "🤔", "✨"],
question: ["❓", "🤷", "🔍", "🧐", "📚"],
creative: ["🎨", "🎭", "🎪", "🌈", "🎬"],
business: ["💼", "📊", "💰", "📈", "🏢"],
science: ["🔬", "🧪", "🔭", "⚗️", "🧬"],
education: ["📖", "📝", "🎓", "✏️", "📚"],
communication: ["💬", "📱", "📧", "🗣️", "💌"],
general: ["🌟", "🎯", "🚀", "⚡", "🔥"],
};
export async function generateChatTitle(firstMessage: string): Promise<string> {
try {
const response = await ollama.generate({
model: "qwen2.5:0.5b",
prompt: `Analise esta mensagem e crie um título curto e descritivo (MAX 30 caracteres, sem emoji):
Mensagem: "${firstMessage}"
Título (direto, sem explicação):`,
options: {
temperature: 0.7,
num_predict: 15,
},
});
let title = response.response
.trim()
.replace(/['"]/g, "")
.replace(/^Título:\s*/i, "")
.substring(0, 30);
// Selecionar emoji baseado no conteúdo
const emoji = selectEmoji(firstMessage);
return `${emoji} ${title}`;
} catch (error) {
console.error("Erro ao gerar título:", error);
// Fallback: usar título simples baseado no conteúdo
const emoji = selectEmoji(firstMessage);
const fallbackTitle = firstMessage.substring(0, 20).trim();
return `${emoji} ${fallbackTitle}`;
}
}
function selectEmoji(content: string): string {
const lower = content.toLowerCase();
if (/code|program|debug|function|class/.test(lower))
return EMOJI_CATEGORIES.code[
Math.floor(Math.random() * EMOJI_CATEGORIES.code.length)
];
if (/why|how|what|when|where/.test(lower))
return EMOJI_CATEGORIES.question[
Math.floor(Math.random() * EMOJI_CATEGORIES.question.length)
];
if (/idea|think|create|design/.test(lower))
return EMOJI_CATEGORIES.idea[
Math.floor(Math.random() * EMOJI_CATEGORIES.idea.length)
];
if (/business|money|sale|market/.test(lower))
return EMOJI_CATEGORIES.business[
Math.floor(Math.random() * EMOJI_CATEGORIES.business.length)
];
if (/science|research|experiment/.test(lower))
return EMOJI_CATEGORIES.science[
Math.floor(Math.random() * EMOJI_CATEGORIES.science.length)
];
return EMOJI_CATEGORIES.general[
Math.floor(Math.random() * EMOJI_CATEGORIES.general.length)
];
}
+62 -2
View File
@@ -29,10 +29,11 @@ export async function chatWithStream(
const client = new Ollama({ host: OLLAMA_HOST });
return client.chat({
model,
messages,
messages: system
? [...messages, { role: "system", content: system }]
: messages,
stream: true,
options,
system,
});
}
@@ -40,3 +41,62 @@ export async function pullModel(model: string) {
const client = new Ollama({ host: OLLAMA_HOST });
return client.pull({ model, stream: true });
}
export async function checkModelExists(modelName: string): Promise<boolean> {
try {
const models = await listModelsViaSdk();
return models.some((model) => model.name === modelName);
} catch (error) {
console.error("Erro ao verificar modelos:", error);
return false;
}
}
export async function ensureModelExists(
modelName: string,
description?: string
): Promise<void> {
const exists = await checkModelExists(modelName);
if (!exists) {
console.log(`📥 Baixando modelo ${modelName}...`);
if (description) {
console.log(`ℹ️ ${description}`);
}
const stream = await pullModel(modelName);
let downloadedBytes = 0;
let totalBytes = 0;
let progress = 0;
// Processar stream para mostrar progresso
for await (const chunk of stream) {
if (chunk.status === "downloading" && chunk.completed && chunk.total) {
downloadedBytes = chunk.completed;
totalBytes = chunk.total;
progress = Math.round((downloadedBytes / totalBytes) * 100);
// Criar barra de progresso visual
const barLength = 20;
const filledLength = Math.round((progress / 100) * barLength);
const bar =
"█".repeat(filledLength) + "░".repeat(barLength - filledLength);
// Mostrar progresso com informações
const downloadedMB = (downloadedBytes / 1024 / 1024).toFixed(1);
const totalMB = (totalBytes / 1024 / 1024).toFixed(1);
process.stdout.write(
`\r📥 ${modelName}: [${bar}] ${progress}% (${downloadedMB}MB/${totalMB}MB)`
);
} else if (chunk.status === "success") {
console.log(`\n✅ Modelo ${modelName} baixado com sucesso!`);
if (description) {
console.log(`🎯 ${description.replace("será usado", "agora funciona")}`);
}
break;
}
}
} else {
console.log(`✅ Modelo ${modelName} já está disponível`);
}
}
+99
View File
@@ -0,0 +1,99 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
let db: Database.Database | null = null;
export async function getPreferencesDb(): Promise<Database.Database> {
if (!db) {
try {
const dbPath = path.join(
process.cwd(),
"database",
"ollahub-preferences.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");
// Criar tabela de preferências se não existir
db.exec(`
CREATE TABLE IF NOT EXISTS user_preferences (
user_id TEXT PRIMARY KEY,
preferences TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
console.log("✅ Banco de preferências SQLite conectado com sucesso");
} catch (error) {
console.warn("⚠️ Erro ao conectar banco de preferências:", error);
throw error;
}
}
return db;
}
export async function getPreferences(userId: string = "default"): Promise<any> {
try {
const database = await getPreferencesDb();
const stmt = database.prepare(
"SELECT preferences FROM user_preferences WHERE user_id = ?"
);
const result = stmt.get(userId) as { preferences: string } | undefined;
if (result) {
return JSON.parse(result.preferences);
}
// Retornar preferências padrão
return {
selectedModel: null,
systemPrompt: "",
device: "auto",
gpuIndex: 0,
numGpu: 1,
};
} catch (error) {
console.error("Erro ao buscar preferências:", error);
throw error;
}
}
export async function setPreferences(
userId: string = "default",
preferences: any
): Promise<void> {
try {
const database = await getPreferencesDb();
const stmt = database.prepare(`
INSERT OR REPLACE INTO user_preferences (user_id, preferences, updated_at)
VALUES (?, ?, ?)
`);
stmt.run(userId, JSON.stringify(preferences), Date.now());
console.log("✅ Preferências salvas no SQLite");
} catch (error) {
console.error("Erro ao salvar preferências:", error);
throw error;
}
}
export function closePreferencesDb(): void {
if (db) {
db.close();
db = null;
}
}
+32
View File
@@ -0,0 +1,32 @@
import { generateEmbedding } from "./embeddings";
import { getDatabase } from "@/database/client";
import { EmbeddingRepository } from "@/database/repositories/embedding";
export async function semanticSearch(query: string, limit: number = 5) {
try {
const queryVector = await generateEmbedding(query);
const db = getDatabase();
const embeddingRepo = new EmbeddingRepository(db);
return await embeddingRepo.searchSimilar(queryVector, limit);
} catch (error) {
console.error("Erro na busca semântica:", error);
return [];
}
}
export async function addMessageEmbedding(
messageId: string,
content: string
): Promise<void> {
try {
const vector = await generateEmbedding(content);
const db = getDatabase();
const embeddingRepo = new EmbeddingRepository(db);
await embeddingRepo.create(messageId, vector);
} catch (error) {
console.error("Erro ao adicionar embedding:", error);
// Não falhar silenciosamente - embedding é opcional
}
}
+5
View File
@@ -3,6 +3,8 @@
"version": "0.1.0",
"private": true,
"scripts": {
"predev": "tsx scripts/init-db.ts",
"prebuild": "tsx scripts/init-db.ts",
"dev": "next dev",
"build": "next build",
"start": "next start",
@@ -10,6 +12,7 @@
"format": "biome format --write"
},
"dependencies": {
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.548.0",
@@ -29,10 +32,12 @@
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tsx": "^4.20.6",
"typescript": "^5"
}
}
+573 -2
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
better-sqlite3:
specifier: ^12.4.1
version: 12.4.1
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -60,6 +63,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.1.16
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/node':
specifier: ^20
version: 20.19.23
@@ -72,6 +78,9 @@ importers:
tailwindcss:
specifier: ^4
version: 4.1.16
tsx:
specifier: ^4.20.6
version: 4.20.6
typescript:
specifier: ^5
version: 5.9.3
@@ -142,6 +151,162 @@ packages:
'@emnapi/[email protected]':
resolution: {integrity: sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA==}
'@esbuild/[email protected]':
resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/[email protected]':
resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/[email protected]':
resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/[email protected]':
resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/[email protected]':
resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/[email protected]':
resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/[email protected]':
resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/[email protected]':
resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/[email protected]':
resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@img/[email protected]':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
@@ -426,6 +591,9 @@ packages:
'@tailwindcss/[email protected]':
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==}
'@types/[email protected]':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/[email protected]':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@@ -473,6 +641,22 @@ packages:
[email protected]:
resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==}
[email protected]:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
[email protected]:
resolution: {integrity: sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x}
[email protected]:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
[email protected]:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
[email protected]:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
[email protected]:
resolution: {integrity: sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==}
@@ -500,6 +684,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==}
[email protected]:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
[email protected]:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
@@ -531,6 +718,14 @@ packages:
[email protected]:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
[email protected]:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
[email protected]:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -542,6 +737,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
[email protected]:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
[email protected]:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
@@ -550,6 +748,11 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
[email protected]:
resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==}
engines: {node: '>=18'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -557,16 +760,37 @@ packages:
[email protected]:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
[email protected]:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
[email protected]:
resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==}
[email protected]:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
[email protected]:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
[email protected]:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
[email protected]:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
[email protected]:
resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==}
[email protected]:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
[email protected]:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -619,6 +843,15 @@ packages:
[email protected]:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
[email protected]:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
[email protected]:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
[email protected]:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
[email protected]:
resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==}
@@ -873,6 +1106,16 @@ packages:
[email protected]:
resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==}
[email protected]:
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
[email protected]:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
[email protected]:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -881,6 +1124,9 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
[email protected]:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
[email protected]:
resolution: {integrity: sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==}
peerDependencies:
@@ -908,9 +1154,16 @@ packages:
sass:
optional: true
[email protected]:
resolution: {integrity: sha512-E2wEyrgX/CqvicaQYU3Ze1PFGjc4QYPGsjUrlYkqAE0WjHEZwgOsGMPMzkMse4LjJbDmaEuDX3CM036j5K2DSQ==}
engines: {node: '>=10'}
[email protected]:
resolution: {integrity: sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg==}
[email protected]:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
[email protected]:
resolution: {integrity: sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==}
@@ -931,6 +1184,11 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
[email protected]:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-t13BGPUlFDR7wRB5kQDG4jjl7XeuH6jbJGt11JHPL96qwsEHNX2+68tFXqc1/k+/jALsbSWJKUOT/hcYAZ5LkA==}
engines: {node: '>=6'}
@@ -948,6 +1206,13 @@ packages:
[email protected]:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
[email protected]:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
[email protected]:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
peerDependencies:
@@ -968,6 +1233,10 @@ packages:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
[email protected]:
resolution: {integrity: sha512-MY9W41IOWxxk31o+YvFCNyNzdkc9M20NoZK5vq6jkv4I/uh2zkWcfudj0Q1fovjUQJrNewS9NMzeTtqPf+n5EA==}
@@ -992,6 +1261,12 @@ packages:
[email protected]:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
[email protected]:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
[email protected]:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
[email protected]:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -1004,6 +1279,12 @@ packages:
resolution: {integrity: sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
[email protected]:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
[email protected]:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
[email protected]:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1014,9 +1295,16 @@ packages:
[email protected]:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
[email protected]:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
[email protected]:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
[email protected]:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
[email protected]:
resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==}
@@ -1046,6 +1334,13 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
[email protected]:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
[email protected]:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -1055,6 +1350,14 @@ packages:
[email protected]:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
[email protected]:
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
[email protected]:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1084,6 +1387,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
[email protected]:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -1099,6 +1405,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==}
[email protected]:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
[email protected]:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -1152,6 +1461,84 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@esbuild/[email protected]':
optional: true
'@img/[email protected]':
optional: true
@@ -1359,6 +1746,10 @@ snapshots:
postcss: 8.5.6
tailwindcss: 4.1.16
'@types/[email protected]':
dependencies:
'@types/node': 20.19.23
'@types/[email protected]':
dependencies:
'@types/ms': 2.1.0
@@ -1405,6 +1796,28 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
[email protected]:
dependencies:
file-uri-to-path: 1.0.0
[email protected]:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
[email protected]:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
[email protected]: {}
[email protected]: {}
@@ -1423,6 +1836,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
clsx: 2.1.1
@@ -1445,6 +1860,12 @@ snapshots:
dependencies:
character-entities: 2.0.2
[email protected]:
dependencies:
mimic-response: 3.1.0
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -1453,6 +1874,10 @@ snapshots:
dependencies:
dequal: 2.0.3
[email protected]:
dependencies:
once: 1.4.0
[email protected]:
dependencies:
graceful-fs: 4.2.11
@@ -1460,18 +1885,62 @@ snapshots:
[email protected]: {}
[email protected]:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.11
'@esbuild/android-arm': 0.25.11
'@esbuild/android-arm64': 0.25.11
'@esbuild/android-x64': 0.25.11
'@esbuild/darwin-arm64': 0.25.11
'@esbuild/darwin-x64': 0.25.11
'@esbuild/freebsd-arm64': 0.25.11
'@esbuild/freebsd-x64': 0.25.11
'@esbuild/linux-arm': 0.25.11
'@esbuild/linux-arm64': 0.25.11
'@esbuild/linux-ia32': 0.25.11
'@esbuild/linux-loong64': 0.25.11
'@esbuild/linux-mips64el': 0.25.11
'@esbuild/linux-ppc64': 0.25.11
'@esbuild/linux-riscv64': 0.25.11
'@esbuild/linux-s390x': 0.25.11
'@esbuild/linux-x64': 0.25.11
'@esbuild/netbsd-arm64': 0.25.11
'@esbuild/netbsd-x64': 0.25.11
'@esbuild/openbsd-arm64': 0.25.11
'@esbuild/openbsd-x64': 0.25.11
'@esbuild/openharmony-arm64': 0.25.11
'@esbuild/sunos-x64': 0.25.11
'@esbuild/win32-arm64': 0.25.11
'@esbuild/win32-ia32': 0.25.11
'@esbuild/win32-x64': 0.25.11
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
format: 0.2.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
[email protected]:
dependencies:
resolve-pkg-maps: 1.0.0
[email protected]: {}
[email protected]: {}
[email protected]:
@@ -1578,6 +2047,12 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2022,10 +2497,18 @@ snapshots:
transitivePeerDependencies:
- supports-color
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]([email protected]([email protected]))([email protected]):
dependencies:
react: 19.2.0
@@ -2054,10 +2537,18 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
[email protected]:
dependencies:
semver: 7.7.3
[email protected]:
dependencies:
whatwg-fetch: 3.6.20
[email protected]:
dependencies:
wrappy: 1.0.2
[email protected]:
dependencies:
character-entities: 1.2.4
@@ -2095,6 +2586,21 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
[email protected]:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.78.0
pump: 3.0.3
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
[email protected]: {}
[email protected]: {}
@@ -2107,6 +2613,18 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
[email protected]:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
[email protected]([email protected]):
dependencies:
react: 19.2.0
@@ -2142,6 +2660,12 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
[email protected]:
dependencies:
hastscript: 6.0.0
@@ -2203,10 +2727,13 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
optional: true
[email protected]: {}
[email protected]:
dependencies:
@@ -2238,17 +2765,31 @@ snapshots:
'@img/sharp-win32-x64': 0.34.4
optional: true
[email protected]: {}
[email protected]:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
safe-buffer: 5.2.1
[email protected]:
dependencies:
character-entities-html4: 2.1.0
character-entities-legacy: 3.0.0
[email protected]: {}
[email protected]:
dependencies:
style-to-object: 1.0.11
@@ -2268,12 +2809,38 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
[email protected]:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
esbuild: 0.25.11
get-tsconfig: 4.13.0
optionalDependencies:
fsevents: 2.3.3
[email protected]:
dependencies:
safe-buffer: 5.2.1
[email protected]: {}
[email protected]: {}
@@ -2316,6 +2883,8 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
[email protected]: {}
[email protected]:
dependencies:
'@types/unist': 3.0.3
@@ -2335,6 +2904,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}
+4
View File
@@ -1,2 +1,6 @@
ignoredBuiltDependencies:
- sharp
onlyBuiltDependencies:
- better-sqlite3
- sqlite3
+158
View File
@@ -0,0 +1,158 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import { ensureModelExists } from "@/lib/ollama";
let db: Database.Database | null = null;
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;
}
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);
}
}
export async function initializeDatabase(): Promise<void> {
console.log("🔧 Inicializando banco de dados OllaHub...");
try {
// Conectar ao banco
const db = getDatabase();
// Executar migrações
runMigrations(db);
console.log("✅ Banco de dados inicializado com sucesso!");
} catch (error) {
console.error("❌ Erro ao inicializar banco de dados:", error);
throw error;
}
}
export async function initializeTitleModel(): Promise<void> {
console.log("🤖 Verificando modelo para geração de títulos...");
try {
await ensureModelExists(
"qwen2.5:0.5b",
"Este modelo será usado para gerar títulos automáticos das conversas"
);
console.log("✅ Modelo de título pronto!");
} catch (error) {
console.error("❌ Erro ao inicializar modelo de título:", error);
console.log("⚠️ Títulos de conversa podem não funcionar corretamente");
}
}
export async function initializeEmbeddingModel(): Promise<void> {
console.log("🔍 Verificando modelo para embeddings...");
try {
await ensureModelExists(
"nomic-embed-text",
"Este modelo será usado para busca vetorial das mensagens"
);
console.log("✅ Modelo de embedding pronto!");
} catch (error) {
console.error("❌ Erro ao inicializar modelo de embedding:", error);
console.log("⚠️ Busca vetorial desabilitada - usando fallback");
}
}
// Executar se chamado diretamente
if (require.main === module) {
Promise.all([
initializeDatabase(),
initializeTitleModel(),
initializeEmbeddingModel(),
])
.then(() => {
console.log("🎉 Inicialização concluída");
process.exit(0);
})
.catch((error) => {
console.error("💥 Falha na inicialização:", error);
process.exit(1);
});
}