feat: add database support, conversation sidebar, and improve chat streaming
- Update .gitignore to exclude database files, extensions, and .cursor directory - Enhance chat streaming API with safe enqueue and close functions to prevent errors - Integrate ConversationSidebar component into ClientHome for better UI - Add dependencies like better-sqlite3 and esbuild packages for database functionality This commit introduces database persistence for conversations, improves error handling in streaming responses, and adds a sidebar for managing chats, enhancing overall app robustness and user experience.
This commit is contained in:
1 parent
a3f8226fda
commit
0cc7a81319
34 files changed
+2370
-198
No files matched your search
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)]"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let db: Database.Database | null = null;
|
||||
|
||||
export function getDatabase(): Database.Database {
|
||||
if (!db) {
|
||||
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
|
||||
|
||||
// Garantir que o diretório existe
|
||||
const dbDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = new Database(dbPath);
|
||||
|
||||
// Configurações de performance
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("synchronous = NORMAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
db.pragma("cache_size = 10000");
|
||||
db.pragma("temp_store = memory");
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export function closeDatabase(): void {
|
||||
if (db) {
|
||||
db.close();
|
||||
db = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
// Criar tabela de migrações
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Verificar migrações aplicadas
|
||||
const applied = db.prepare("SELECT name FROM _migrations").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
const appliedNames = new Set(applied.map((m) => m.name));
|
||||
|
||||
// Aplicar migração inicial se não foi aplicada
|
||||
if (!appliedNames.has("001_initial_schema")) {
|
||||
console.log("📋 Aplicando migração: 001_initial_schema");
|
||||
|
||||
const schemaPath = path.join(process.cwd(), "database", "schema.sql");
|
||||
const schema = fs.readFileSync(schemaPath, "utf-8");
|
||||
|
||||
db.exec(schema);
|
||||
|
||||
// Registrar migração
|
||||
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
|
||||
"001_initial_schema",
|
||||
Date.now()
|
||||
);
|
||||
|
||||
console.log("✅ Migração 001_initial_schema aplicada");
|
||||
}
|
||||
|
||||
// Inicializar extensão vetorial se disponível
|
||||
try {
|
||||
const extPath = path.join(
|
||||
process.cwd(),
|
||||
"database",
|
||||
"extensions",
|
||||
"vector.so"
|
||||
);
|
||||
if (fs.existsSync(extPath)) {
|
||||
db.loadExtension(extPath);
|
||||
|
||||
// Inicializar índice vetorial
|
||||
db.exec(`
|
||||
SELECT vector_init('embeddings', 'vector', 'type=FLOAT32,dimension=384');
|
||||
`);
|
||||
|
||||
// Quantizar para busca mais rápida
|
||||
db.exec(`
|
||||
SELECT vector_quantize('embeddings', 'vector');
|
||||
`);
|
||||
|
||||
console.log("🔍 Extensão vetorial carregada e inicializada");
|
||||
} else {
|
||||
console.log(
|
||||
"⚠️ Extensão vetorial não encontrada - busca vetorial desabilitada"
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("⚠️ Erro ao carregar extensão vetorial:", error);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,78 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string | null;
|
||||
model: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export class ConversationRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
create(model: string, title?: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO conversations (id, title, model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(id, title || "Nova conversa", model, now, now);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
findAll(): Conversation[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM conversations
|
||||
ORDER BY updated_at DESC
|
||||
`
|
||||
)
|
||||
.all() as Conversation[];
|
||||
}
|
||||
|
||||
findById(id: string): Conversation | null {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM conversations WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(id) as Conversation | null;
|
||||
}
|
||||
|
||||
updateTitle(id: string, title: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE conversations
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.run(title, Date.now(), id);
|
||||
}
|
||||
|
||||
updateTimestamp(id: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE conversations
|
||||
SET updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.run(Date.now(), id);
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare("DELETE FROM conversations WHERE id = ?").run(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
export interface Embedding {
|
||||
id: string;
|
||||
message_id: string;
|
||||
vector: Buffer;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string;
|
||||
content: string;
|
||||
role: string;
|
||||
distance: number;
|
||||
conversation_id: string;
|
||||
}
|
||||
|
||||
export class EmbeddingRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
async create(messageId: string, vector: Float32Array): Promise<void> {
|
||||
const id = crypto.randomUUID();
|
||||
const blob = Buffer.from(vector.buffer);
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO embeddings (id, message_id, vector)
|
||||
VALUES (?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(id, messageId, blob);
|
||||
}
|
||||
|
||||
async searchSimilar(
|
||||
queryVector: Float32Array,
|
||||
limit: number = 5
|
||||
): Promise<SearchResult[]> {
|
||||
const queryBlob = Buffer.from(queryVector.buffer);
|
||||
|
||||
try {
|
||||
// Tentar busca vetorial se extensão estiver disponível
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id, m.content, m.role, m.conversation_id,
|
||||
vector_distance(e.vector, ?) as distance
|
||||
FROM embeddings e
|
||||
JOIN messages m ON m.id = e.message_id
|
||||
ORDER BY distance ASC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(queryBlob, limit) as SearchResult[];
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Busca vetorial não disponível, usando busca simples:",
|
||||
error
|
||||
);
|
||||
|
||||
// Fallback: busca simples por conteúdo
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
m.id, m.content, m.role, m.conversation_id,
|
||||
0.0 as distance
|
||||
FROM messages m
|
||||
WHERE m.content LIKE ?
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT ?
|
||||
`
|
||||
)
|
||||
.all(`%${queryVector.toString()}%`, limit) as SearchResult[];
|
||||
}
|
||||
}
|
||||
|
||||
findByMessage(messageId: string): Embedding | null {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM embeddings WHERE message_id = ?
|
||||
`
|
||||
)
|
||||
.get(messageId) as Embedding | null;
|
||||
}
|
||||
|
||||
deleteByMessage(messageId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM embeddings WHERE message_id = ?")
|
||||
.run(messageId);
|
||||
}
|
||||
|
||||
count(): number {
|
||||
const result = this.db
|
||||
.prepare("SELECT COUNT(*) as count FROM embeddings")
|
||||
.get() as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Database from "better-sqlite3";
|
||||
import type { ChatRole } from "@/lib/chat";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class MessageRepository {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
create(conversationId: string, role: ChatRole, content: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
INSERT INTO messages (id, conversation_id, role, content, timestamp)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`
|
||||
)
|
||||
.run(id, conversationId, role, content, Date.now());
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
findByConversation(conversationId: string): Message[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM messages
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY timestamp ASC
|
||||
`
|
||||
)
|
||||
.all(conversationId) as Message[];
|
||||
}
|
||||
|
||||
findById(id: string): Message | null {
|
||||
return this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT * FROM messages WHERE id = ?
|
||||
`
|
||||
)
|
||||
.get(id) as Message | null;
|
||||
}
|
||||
|
||||
updateContent(id: string, content: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`
|
||||
UPDATE messages
|
||||
SET content = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
)
|
||||
.run(content, id);
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
||||
}
|
||||
|
||||
deleteByConversation(conversationId: string): void {
|
||||
this.db
|
||||
.prepare("DELETE FROM messages WHERE conversation_id = ?")
|
||||
.run(conversationId);
|
||||
}
|
||||
|
||||
countByConversation(conversationId: string): number {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`
|
||||
SELECT COUNT(*) as count FROM messages
|
||||
WHERE conversation_id = ?
|
||||
`
|
||||
)
|
||||
.get(conversationId) as { count: number };
|
||||
|
||||
return result.count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Schema inicial do banco de dados OllaHub
|
||||
-- Conversas
|
||||
CREATE TABLE conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
model TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Mensagens
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK(role IN ('user', 'assistant', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Embeddings vetoriais (384 dimensões do Ollama)
|
||||
CREATE TABLE embeddings (
|
||||
id TEXT PRIMARY KEY,
|
||||
message_id TEXT NOT NULL,
|
||||
vector BLOB NOT NULL,
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
|
||||
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
|
||||
CREATE INDEX idx_embeddings_message ON embeddings(message_id);
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+573
-2
@@ -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]: {}
|
||||
@@ -1,2 +1,6 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- better-sqlite3
|
||||
- sqlite3
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user