chore: remove deprecated files and configurations
- Deleted .gitignore, biome.json, next.config.ts, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, postcss.config.mjs, README.md, and various API, component, and utility files to clean up the codebase. - Removed unused database files and migration scripts to streamline the project structure. - Eliminated obsolete hooks and services to enhance maintainability and reduce clutter.
This commit is contained in:
1 parent
a8bb7cc22d
commit
ad9d5d32d2
119 files changed
-17633
No files matched your search
@@ -1,75 +0,0 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
} from "@/components/settings/SettingsModal";
|
||||
import { Store } from "lucide-react";
|
||||
|
||||
interface ClientHomeProps {
|
||||
offline: boolean;
|
||||
models: any[];
|
||||
}
|
||||
|
||||
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)]">
|
||||
<header className="sticky top-0 z-20 border-b border-[var(--border)] bg-[color-mix(in_oklab,var(--background),black_3%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_3%)]/60">
|
||||
<div className="mx-auto flex max-w-full items-center justify-between px-4 py-3">
|
||||
<h1 className="text-lg font-semibold tracking-tight">Ollahub</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
{offline && (
|
||||
<span className="rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 py-1 text-xs">
|
||||
Ollama offline
|
||||
</span>
|
||||
)}
|
||||
<SettingsButton onOpen={() => setOpen(true)} />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<div className="flex h-full">
|
||||
<ConversationSidebar
|
||||
currentConversationId={currentConversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onNewConversation={handleNewConversation}
|
||||
refreshTrigger={refreshTrigger}
|
||||
/>
|
||||
<ChatContainer
|
||||
models={models}
|
||||
offline={offline}
|
||||
onConversationCreated={handleConversationCreated}
|
||||
currentConversationId={currentConversationId}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,808 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { ModelDropdown } from "@/components/model/ModelDropdown";
|
||||
import { ModelPullDialog } from "@/components/model/ModelPullDialog";
|
||||
import { ChatMessage } from "@/components/chat/ChatMessage";
|
||||
// Settings are controlled globally in header
|
||||
import { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector";
|
||||
import { MCPManualInstallModal } from "@/components/mcp/MCPManualInstallModal";
|
||||
import { Send, Square } from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { createMessage } from "@/lib/chat";
|
||||
import { useUserPrefs, buildOllamaOptions } from "@/hooks/useUserPrefs";
|
||||
|
||||
interface ChatContainerProps {
|
||||
models: ModelInfo[];
|
||||
offline: boolean;
|
||||
onConversationCreated?: () => void;
|
||||
currentConversationId?: string | null;
|
||||
}
|
||||
|
||||
export function ChatContainer({
|
||||
models,
|
||||
offline,
|
||||
onConversationCreated,
|
||||
currentConversationId: propCurrentConversationId,
|
||||
}: 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);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [showManualInstall, setShowManualInstall] = useState(false);
|
||||
const [showCommandMenu, setShowCommandMenu] = useState(false);
|
||||
const [commandQuery, setCommandQuery] = useState("");
|
||||
const [highlightedCmd, setHighlightedCmd] = useState(0);
|
||||
|
||||
// Estado para MCPs ativos
|
||||
const [activeMcps, setActiveMcps] = useState<string[]>([]);
|
||||
const [mcpNames, setMcpNames] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Carregar modelo salvo ao iniciar
|
||||
useEffect(() => {
|
||||
if (ready && prefs.selectedModel && !selectedModel) {
|
||||
setSelectedModel(prefs.selectedModel);
|
||||
// Warmup do modelo em background
|
||||
fetch(`/api/ollama/warmup?model=${encodeURIComponent(prefs.selectedModel)}`).catch(() => {});
|
||||
}
|
||||
}, [ready, prefs.selectedModel]);
|
||||
|
||||
// Carregar mensagens quando uma conversa é selecionada
|
||||
useEffect(() => {
|
||||
if (propCurrentConversationId) {
|
||||
loadConversation(propCurrentConversationId);
|
||||
} else {
|
||||
// Limpar mensagens quando nenhuma conversa está selecionada
|
||||
setMessages([]);
|
||||
setConversationId(null);
|
||||
}
|
||||
}, [propCurrentConversationId]);
|
||||
|
||||
// Salvar modelo quando selecionado
|
||||
const handleModelChange = (modelName: string) => {
|
||||
setSelectedModel(modelName);
|
||||
update({ selectedModel: modelName }); // Salvar no Redis
|
||||
// Disparar warmup em background
|
||||
fetch(`/api/ollama/warmup?model=${encodeURIComponent(modelName)}`).catch(() => {});
|
||||
};
|
||||
|
||||
// Carregar MCPs ativos do localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("ollahub-active-mcps");
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
setActiveMcps(parsed.mcps || []);
|
||||
setMcpNames(new Map(parsed.names || []));
|
||||
} catch (e) {
|
||||
console.error("Error loading active MCPs:", e);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Salvar MCPs ativos no localStorage quando mudarem
|
||||
useEffect(() => {
|
||||
if (activeMcps.length > 0 || mcpNames.size > 0) {
|
||||
localStorage.setItem(
|
||||
"ollahub-active-mcps",
|
||||
JSON.stringify({
|
||||
mcps: activeMcps,
|
||||
names: Array.from(mcpNames.entries()),
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [activeMcps, mcpNames]);
|
||||
|
||||
// Handlers para MCP Selector
|
||||
const handleToggleMCP = useCallback(
|
||||
async (mcpId: string) => {
|
||||
setActiveMcps((prev) => {
|
||||
if (prev.includes(mcpId)) {
|
||||
return prev.filter((id) => id !== mcpId);
|
||||
} else {
|
||||
// Buscar nome do MCP se ainda não temos
|
||||
if (!mcpNames.has(mcpId)) {
|
||||
fetch("/api/mcp/available-for-chat")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data.success) {
|
||||
const mcp = data.mcps.find((m: any) => m.mcpId === mcpId);
|
||||
if (mcp) {
|
||||
setMcpNames((prev) => new Map(prev).set(mcpId, mcp.name));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
return [...prev, mcpId];
|
||||
}
|
||||
});
|
||||
},
|
||||
[mcpNames]
|
||||
);
|
||||
|
||||
const handleClearAllMCPs = useCallback(() => {
|
||||
setActiveMcps([]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveMCP = useCallback((mcpId: string) => {
|
||||
setActiveMcps((prev) => prev.filter((id) => id !== mcpId));
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
// Auto-expansão do textarea conforme o usuário digita
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
const el = inputRef.current;
|
||||
el.style.height = "auto";
|
||||
const maxPx = 160; // ~10rem
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxPx)}px`;
|
||||
}
|
||||
}, [inputContent]);
|
||||
|
||||
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];
|
||||
|
||||
// Add system prompt if it exists and this is the first user message
|
||||
const messagesToSend =
|
||||
systemPrompt && messages.length === 0
|
||||
? [createMessage("system", systemPrompt), ...newMessages]
|
||||
: newMessages;
|
||||
|
||||
setMessages(newMessages);
|
||||
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,
|
||||
prefs.numGpu,
|
||||
prefs.gpuIndex
|
||||
);
|
||||
abortRef.current = new AbortController();
|
||||
const response = await fetch("/api/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages: messagesToSend,
|
||||
options,
|
||||
system: systemPrompt || undefined,
|
||||
activeMcps: activeMcps.length > 0 ? activeMcps : undefined,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to send message");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let assistantContent = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
|
||||
// Tool execution indicator
|
||||
if (data.toolExecution) {
|
||||
console.log(
|
||||
`🔧 MCP Tool executed: ${data.toolExecution.mcpId}.${data.toolExecution.toolName}`
|
||||
);
|
||||
// Opcional: Mostrar indicador visual
|
||||
}
|
||||
|
||||
// Clear previous response (para prompt engineering)
|
||||
if (data.clearPrevious) {
|
||||
assistantContent = "";
|
||||
setStreamingContent("");
|
||||
}
|
||||
|
||||
if (data.token) {
|
||||
assistantContent += data.token;
|
||||
setStreamingContent(assistantContent);
|
||||
}
|
||||
if (data.done) {
|
||||
const assistantMessage = createMessage(
|
||||
"assistant",
|
||||
assistantContent
|
||||
);
|
||||
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);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Chat error:", error);
|
||||
const errorMessage = createMessage(
|
||||
"assistant",
|
||||
"Sorry, I encountered an error. Please try again."
|
||||
);
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent("");
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse de comando no submit
|
||||
const tryHandleSlashCommand = (text: string): boolean => {
|
||||
if (!text.startsWith("/")) return false;
|
||||
const [cmd, ...rest] = text.trim().split(/\s+/);
|
||||
if (cmd === "/clear") {
|
||||
startNewConversation();
|
||||
setInputContent("");
|
||||
return true;
|
||||
}
|
||||
if (cmd === "/model") {
|
||||
const newModel = rest.join(" ");
|
||||
if (newModel) {
|
||||
handleSelectModel(newModel);
|
||||
setInputContent("");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputContent.trim() && !isStreaming) {
|
||||
if (tryHandleSlashCommand(inputContent.trim())) return;
|
||||
handleSendMessage(inputContent.trim());
|
||||
setInputContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (showCommandMenu) {
|
||||
const commands = filteredCommands;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlightedCmd((h) => (commands.length ? (h + 1) % commands.length : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlightedCmd((h) => (commands.length ? (h - 1 + commands.length) % commands.length : 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const cmd = commands[highlightedCmd] || commands[0];
|
||||
if (cmd) executeCommand(cmd);
|
||||
return;
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
|
||||
// Regenerar última resposta do assistente
|
||||
const handleRegenerate = async () => {
|
||||
if (isStreaming || messages.length === 0 || !selectedModel) return;
|
||||
const lastUserIndex = [...messages].map((m) => m.role).lastIndexOf("user");
|
||||
if (lastUserIndex === -1) return;
|
||||
const baseMessages = messages.slice(0, lastUserIndex + 1);
|
||||
setMessages(baseMessages);
|
||||
setIsStreaming(true);
|
||||
setStreamingContent("");
|
||||
try {
|
||||
const options = buildOllamaOptions(
|
||||
prefs.device,
|
||||
prefs.numGpu,
|
||||
prefs.gpuIndex
|
||||
);
|
||||
abortRef.current = new AbortController();
|
||||
const response = await fetch("/api/ollama/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages: baseMessages,
|
||||
options,
|
||||
system: systemPrompt || undefined,
|
||||
activeMcps: activeMcps.length > 0 ? activeMcps : undefined,
|
||||
}),
|
||||
signal: abortRef.current.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to regenerate");
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let assistantContent = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.clearPrevious) {
|
||||
assistantContent = "";
|
||||
setStreamingContent("");
|
||||
}
|
||||
if (data.token) {
|
||||
assistantContent += data.token;
|
||||
setStreamingContent(assistantContent);
|
||||
}
|
||||
if (data.done) {
|
||||
const assistantMessage = createMessage("assistant", assistantContent);
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
setStreamingContent("");
|
||||
if (conversationId) {
|
||||
try {
|
||||
await fetch(`/api/conversations/${conversationId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role: "assistant", content: assistantContent }),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
if (data.error) throw new Error(data.error);
|
||||
} catch (e) {
|
||||
console.error("Error parsing stream:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Regenerate error:", error);
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent("");
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Comandos do chat
|
||||
type ChatCommand = { id: string; label: string; hint?: string };
|
||||
const availableCommands: ChatCommand[] = [
|
||||
{ id: "clear", label: "/clear", hint: "Limpar o histórico atual" },
|
||||
{ id: "model", label: "/model <id>", hint: "Trocar de modelo ativo" },
|
||||
];
|
||||
const filteredCommands = availableCommands.filter((c) =>
|
||||
c.label.toLowerCase().includes(commandQuery.toLowerCase())
|
||||
);
|
||||
const executeCommand = (cmd: ChatCommand) => {
|
||||
if (cmd.id === "clear") {
|
||||
startNewConversation();
|
||||
setInputContent("");
|
||||
setShowCommandMenu(false);
|
||||
return;
|
||||
}
|
||||
if (cmd.id === "model") {
|
||||
setInputContent("/model ");
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectModel = (modelId: string) => {
|
||||
const model = models.find((m) => m.id === modelId);
|
||||
if (model) {
|
||||
handleModelChange(modelId);
|
||||
// Optional: clear chat when switching models
|
||||
// setMessages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/delete", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelName: modelId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete model");
|
||||
}
|
||||
|
||||
// Atualizar lista de modelos após exclusão
|
||||
// Aqui você pode recarregar a lista ou remover o modelo da lista local
|
||||
console.log(`Model ${modelId} deleted successfully`);
|
||||
} catch (error) {
|
||||
console.error("Error deleting model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePullModel = async (modelName: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/pull", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: modelName }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to pull model");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
if (data.status === "success") {
|
||||
// Model pulled successfully, refresh models list
|
||||
window.location.reload();
|
||||
} else if (data.error) {
|
||||
// Mostrar erro amigável quando backend identifica problema com tag/manifest
|
||||
const hint = data.hint ? `\nHint: ${data.hint}` : "";
|
||||
const url = data.url ? `\nPágina do modelo: ${data.url}` : "";
|
||||
alert(`Falha ao baixar o modelo: ${data.detail || data.error}${hint}${url}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing pull stream:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Abrir diálogo de instalação (seleção/edição de tag)
|
||||
const openPullDialog = (name: string) => {
|
||||
setModelToPull(name);
|
||||
setShowPullDialog(true);
|
||||
};
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<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" : "Select a model to start chatting"}
|
||||
</h1>
|
||||
<ModelDropdown
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
onDeleteModel={handleDeleteModel}
|
||||
onPullModel={openPullDialog}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
disabled={isStreaming || messages.length === 0}
|
||||
className="rounded-md border border-[var(--border)] px-3 py-1 text-sm hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
Regenerar
|
||||
</button>
|
||||
<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">
|
||||
<div className={`mx-auto w-full ${
|
||||
(prefs.chatLayout ?? "compact") === "edge" ? "max-w-full px-2" : "max-w-3xl px-4"
|
||||
}`}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16">
|
||||
<div className="text-center text-[var(--foreground)]/70">
|
||||
<p className="text-xl font-medium mb-1">Bem-vindo ao Ollahub</p>
|
||||
<p className="text-sm">Selecione um modelo e comece a conversar.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessage key={message.id} message={message} bubbleSize={prefs.bubbleSize ?? "md"} />
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<ChatMessage
|
||||
message={createMessage("assistant", streamingContent)}
|
||||
isStreaming={true}
|
||||
bubbleSize={prefs.bubbleSize ?? "md"}
|
||||
/>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] sticky bottom-0 z-10 bg-[color-mix(in_oklab,var(--background),black_2%)]/80 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/60">
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{/* Active MCP Badges */}
|
||||
<ActiveMCPBadges
|
||||
activeMcps={activeMcps}
|
||||
mcpNames={mcpNames}
|
||||
onRemove={handleRemoveMCP}
|
||||
/>
|
||||
|
||||
<div className="rounded-xl border border-[var(--border)] bg-[var(--surface)]/60 shadow-sm transition-all focus-within:ring-2 focus-within:ring-[var(--accent)] relative">
|
||||
<div className="flex gap-2 p-2">
|
||||
{/* MCP Selector */}
|
||||
<MCPSelector
|
||||
activeMcps={activeMcps}
|
||||
onToggleMCP={handleToggleMCP}
|
||||
onClearAll={handleClearAllMCPs}
|
||||
onOpenInstallModal={() => setShowManualInstall(true)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputContent}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setInputContent(val);
|
||||
if (val.startsWith("/")) {
|
||||
setShowCommandMenu(true);
|
||||
const q = val.slice(1).trim();
|
||||
setCommandQuery(q);
|
||||
setHighlightedCmd(0);
|
||||
} else {
|
||||
setShowCommandMenu(false);
|
||||
}
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selectedModel ? "Digite uma mensagem..." : "Selecione um modelo primeiro..."
|
||||
}
|
||||
disabled={!selectedModel || isStreaming}
|
||||
className="flex-1 resize-none rounded-lg border-none bg-transparent px-3 py-3 text-sm focus:outline-none disabled:opacity-50 max-h-40 overflow-y-auto"
|
||||
rows={1}
|
||||
|
||||
/>
|
||||
<button
|
||||
type={isStreaming ? "button" : "submit"}
|
||||
onClick={isStreaming ? handleStop : undefined}
|
||||
disabled={
|
||||
isStreaming ? false : !inputContent.trim() || !selectedModel
|
||||
}
|
||||
className="flex h-10 w-10 my-auto 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>
|
||||
<div className="flex items-center gap-2 px-3 pb-2 pt-0 text-xs text-[var(--foreground)]/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigator.clipboard.writeText(inputContent).catch(() => {})}
|
||||
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
Copiar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInputContent("")}
|
||||
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
Limpar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputContent("/");
|
||||
setShowCommandMenu(true);
|
||||
setCommandQuery("");
|
||||
setHighlightedCmd(0);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}}
|
||||
className="px-2 py-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
Comandos
|
||||
</button>
|
||||
<span className="ml-auto">{selectedModel ? "" : "Selecione um modelo para começar"}</span>
|
||||
</div>
|
||||
|
||||
{showCommandMenu && filteredCommands.length > 0 && (
|
||||
<div className="absolute left-2 right-2 bottom-14 rounded-md border border-[var(--border)] bg-[var(--background)] shadow-md overflow-hidden">
|
||||
{filteredCommands.map((cmd, idx) => (
|
||||
<button
|
||||
key={cmd.id + idx}
|
||||
type="button"
|
||||
onClick={() => executeCommand(cmd)}
|
||||
className={`w-full text-left px-3 py-2 text-sm hover:bg-[var(--surface)] ${
|
||||
idx === highlightedCmd ? "bg-[var(--surface)]" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">{cmd.label}</div>
|
||||
{cmd.hint && (
|
||||
<div className="text-[10px] opacity-70">{cmd.hint}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Pull Dialog */}
|
||||
<ModelPullDialog
|
||||
isOpen={showPullDialog}
|
||||
onClose={() => setShowPullDialog(false)}
|
||||
modelName={modelToPull}
|
||||
onConfirmPull={handlePullModel}
|
||||
/>
|
||||
<MCPManualInstallModal
|
||||
isOpen={showManualInstall}
|
||||
onClose={() => setShowManualInstall(false)}
|
||||
onSuccess={() => {
|
||||
// Fechar e opcionalmente poderíamos sinalizar o MCPSelector a recarregar.
|
||||
setShowManualInstall(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { User, Bot, Send, Loader2, Square } from "lucide-react";
|
||||
import { MarkdownRenderer } from "@/components/message/MarkdownRenderer";
|
||||
import { MessageActions } from "@/components/message/MessageActions";
|
||||
import type { Message } from "@/lib/chat";
|
||||
import { formatTimestamp } from "@/lib/chat";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
isStreaming?: boolean;
|
||||
bubbleSize?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
export function ChatMessage({
|
||||
message,
|
||||
isStreaming = false,
|
||||
bubbleSize = "md",
|
||||
}: ChatMessageProps) {
|
||||
const isUser = message.role === "user";
|
||||
const sizeClass =
|
||||
bubbleSize === "sm"
|
||||
? "px-3 py-1.5 text-sm"
|
||||
: bubbleSize === "lg"
|
||||
? "px-5 py-3 text-base"
|
||||
: "px-4 py-2 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex gap-3 p-4 ${isUser ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--accent)] text-white">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[85%] md:max-w-[80%] relative rounded-lg ${sizeClass} shadow-sm ${
|
||||
isUser
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<div className="break-words">
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<MarkdownRenderer content={message.content} />
|
||||
)}
|
||||
</div>
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
{!isUser && !isStreaming && (
|
||||
<div className="absolute bottom-2 right-2 z-10 pointer-events-auto">
|
||||
<MessageActions content={message.content} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mt-1 text-xs opacity-70 ${
|
||||
isUser ? "text-right" : "text-left"
|
||||
}`}
|
||||
>
|
||||
{formatTimestamp(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUser && (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[var(--surface)]">
|
||||
<User className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim() && !disabled) {
|
||||
onSendMessage(content.trim());
|
||||
setContent("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${Math.min(
|
||||
textareaRef.current.scrollHeight,
|
||||
120
|
||||
)}px`;
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex gap-2 p-4 border-t border-[var(--border)]"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
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}
|
||||
|
||||
/>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square className="h-4 w-4" />
|
||||
) : disabled ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
interface StreamingTextProps {
|
||||
text: string;
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
export function StreamingText({ text, isStreaming }: StreamingTextProps) {
|
||||
return (
|
||||
<span>
|
||||
{text}
|
||||
{isStreaming && <span className="ml-1 animate-pulse">▊</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
"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);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredConversations = conversations.filter((c) =>
|
||||
c.title.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-64 lg:w-72 h-full bg-[var(--surface)] border-r border-[var(--border)] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-[var(--border)] sticky top-0 z-10 bg-[color-mix(in_oklab,var(--surface),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--surface),black_2%)]/70">
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
try {
|
||||
// Buscar modelo selecionado nas preferências
|
||||
const prefsRes = await fetch("/api/preferences");
|
||||
const prefs = await prefsRes.json();
|
||||
const model = prefs?.selectedModel;
|
||||
if (!model) {
|
||||
alert("Selecione um modelo antes de criar uma conversa.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Criar conversa vazia (título padrão gerado no backend)
|
||||
const res = await fetch("/api/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Falha ao criar conversa");
|
||||
const { id, title } = await res.json();
|
||||
|
||||
// Inserir no topo da lista e selecionar
|
||||
const newConv: Conversation = {
|
||||
id,
|
||||
title,
|
||||
model,
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
setConversations((prev) => [newConv, ...prev]);
|
||||
onSelectConversation(id);
|
||||
|
||||
// Notificar container para possíveis efeitos colaterais
|
||||
onNewConversation();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Não foi possível criar a conversa.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}}
|
||||
disabled={creating}
|
||||
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 disabled:opacity-60"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{creating ? "Criando..." : "Nova Conversa"}
|
||||
</button>
|
||||
<div className="mt-3">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar conversa..."
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-[var(--background)] border border-[var(--border)] outline-none focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de Conversas */}
|
||||
<div className="flex-1 overflow-y-auto p-2 custom-scrollbar">
|
||||
{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>
|
||||
) : filteredConversations.length === 0 ? (
|
||||
<div className="text-center p-4 text-sm text-[var(--foreground)]/60">
|
||||
Nenhum resultado para "{query}"
|
||||
</div>
|
||||
) : (
|
||||
filteredConversations.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)]/12 text-[var(--accent)] ring-1 ring-[var(--accent)]/30"
|
||||
: "hover:bg-[color-mix(in_oklab,var(--surface),black_6%)]"
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Plug, ChevronDown, X, Zap } from "lucide-react";
|
||||
|
||||
interface MCPForChat {
|
||||
mcpId: string;
|
||||
name: string;
|
||||
toolCount: number;
|
||||
tools: Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
||||
interface MCPSelectorProps {
|
||||
activeMcps: string[];
|
||||
onToggleMCP: (mcpId: string) => void;
|
||||
onClearAll: () => void;
|
||||
onOpenInstallModal?: () => void;
|
||||
}
|
||||
|
||||
export function MCPSelector({
|
||||
activeMcps,
|
||||
onToggleMCP,
|
||||
onClearAll,
|
||||
onOpenInstallModal,
|
||||
}: MCPSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Carregar MCPs disponíveis
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadAvailableMCPs();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Fechar dropdown ao clicar fora
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadAvailableMCPs = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/mcp/available-for-chat");
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setAvailableMcps(
|
||||
data.mcps.map((mcp: any) => ({
|
||||
mcpId: mcp.mcpId,
|
||||
name: mcp.name,
|
||||
toolCount: mcp.toolCount,
|
||||
tools: mcp.rawTools || [],
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading available MCPs:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (mcpId: string) => {
|
||||
onToggleMCP(mcpId);
|
||||
};
|
||||
|
||||
const filtered = availableMcps.filter((mcp) => {
|
||||
if (!query.trim()) return true;
|
||||
const q = query.toLowerCase();
|
||||
return (
|
||||
mcp.name.toLowerCase().includes(q) ||
|
||||
mcp.tools.some(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
(t.description || "").toLowerCase().includes(q)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] hover:bg-[var(--surface)] transition-colors"
|
||||
title="Manage MCP Tools"
|
||||
>
|
||||
<Plug className="h-4 w-4" />
|
||||
{activeMcps.length > 0 && (
|
||||
<span className="flex items-center justify-center h-5 min-w-[20px] px-1.5 bg-[var(--accent)] text-[var(--accent-foreground)] text-xs font-medium rounded-full">
|
||||
{activeMcps.length}
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-96 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-[28rem] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-[var(--border)] sticky top-0 bg-[color-mix(in_oklab,var(--background),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/70">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plug className="h-4 w-4" />
|
||||
<span className="font-semibold text-sm">MCP Tools</span>
|
||||
</div>
|
||||
{activeMcps.length > 0 && (
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="text-xs px-2 py-1 rounded-md border border-[var(--border)] hover:bg-[var(--surface)]"
|
||||
>
|
||||
Limpar tudo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar por servidor ou ferramenta..."
|
||||
className="w-full px-3 py-2 text-sm rounded-md bg-[var(--background)] border border-[var(--border)] outline-none focus:border-[var(--accent)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||
Carregando MCPs...
|
||||
</div>
|
||||
) : availableMcps.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||
Nenhum MCP instalado
|
||||
{onOpenInstallModal && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Instalar manualmente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--foreground)]/60">
|
||||
Nenhum resultado para "{query}"
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filtered.map((mcp) => {
|
||||
const isActive = activeMcps.includes(mcp.mcpId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={mcp.mcpId}
|
||||
className={`p-3 rounded-md mb-2 cursor-pointer transition-colors ${
|
||||
isActive
|
||||
? "bg-[var(--accent)]/10 border border-[var(--accent)]/30"
|
||||
: "hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
onClick={() => handleToggle(mcp.mcpId)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={() => {}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="font-medium text-sm">
|
||||
{mcp.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60 ml-6">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{mcp.toolCount} ferramentas disponíveis</span>
|
||||
</div>
|
||||
{isActive && mcp.tools.length > 0 && (
|
||||
<div className="mt-2 ml-6">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{mcp.tools.slice(0, 3).map((tool, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-[var(--accent)]/20 text-[var(--accent)] rounded"
|
||||
title={tool.description}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tools.length > 3 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-[var(--surface)] rounded">
|
||||
+{mcp.tools.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer info */}
|
||||
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50 flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
{activeMcps.length > 0
|
||||
? `${activeMcps.length} MCPs ativos para enriquecer as respostas`
|
||||
: "Nenhum MCP ativo"}
|
||||
</p>
|
||||
{onOpenInstallModal && (
|
||||
<button
|
||||
onClick={onOpenInstallModal}
|
||||
className="text-xs px-2 py-1 rounded-md border border-[var(--border)] hover:bg-[var(--surface)]"
|
||||
>
|
||||
Instalar MCP manualmente
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pills/badges component to show active MCPs in the chat input area
|
||||
*/
|
||||
interface ActiveMCPBadgesProps {
|
||||
activeMcps: string[];
|
||||
mcpNames: Map<string, string>;
|
||||
onRemove: (mcpId: string) => void;
|
||||
}
|
||||
|
||||
export function ActiveMCPBadges({
|
||||
activeMcps,
|
||||
mcpNames,
|
||||
onRemove,
|
||||
}: ActiveMCPBadgesProps) {
|
||||
if (activeMcps.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
{activeMcps.map((mcpId) => (
|
||||
<div
|
||||
key={mcpId}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-[var(--primary)]/10 text-[var(--primary)] text-xs rounded-md border border-[var(--primary)]/30"
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
<span className="font-medium">{mcpNames.get(mcpId) || mcpId}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(mcpId);
|
||||
}}
|
||||
className="ml-1 hover:bg-[var(--primary)]/20 rounded"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
"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 }>;
|
||||
design?: {
|
||||
chatLayout: "compact" | "edge";
|
||||
bubbleSize: "sm" | "md" | "lg";
|
||||
onChange: (next: Partial<{ chatLayout: "compact" | "edge"; bubbleSize: "sm" | "md" | "lg" }>) => void;
|
||||
};
|
||||
};
|
||||
|
||||
export function SettingsModal({
|
||||
systemPrompt,
|
||||
onSystemPromptChange,
|
||||
selectedModel,
|
||||
models,
|
||||
design,
|
||||
}: SettingsModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [tempPrompt, setTempPrompt] = useState(systemPrompt);
|
||||
const [tempChatLayout, setTempChatLayout] = useState<"compact" | "edge">(design?.chatLayout ?? "compact");
|
||||
const [tempBubbleSize, setTempBubbleSize] = useState<"sm" | "md" | "lg">(design?.bubbleSize ?? "md");
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
const handleSave = () => {
|
||||
onSystemPromptChange(tempPrompt);
|
||||
if (design?.onChange) {
|
||||
design.onChange({ chatLayout: tempChatLayout, bubbleSize: tempBubbleSize });
|
||||
}
|
||||
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>
|
||||
|
||||
{/* Preferências de Design */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="font-medium">Preferências de Design</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--foreground)]/80">Layout do Chat</label>
|
||||
<select
|
||||
value={tempChatLayout}
|
||||
onChange={(e) => setTempChatLayout(e.target.value as any)}
|
||||
className="w-full p-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
|
||||
>
|
||||
<option value="compact">Compacto (centralizado)</option>
|
||||
<option value="edge">Até as bordas (edge-to-edge)</option>
|
||||
</select>
|
||||
<p className="text-xs text-[var(--foreground)]/60">Controla se o conteúdo do chat é centralizado com largura máxima ou ocupa toda a largura.</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm text-[var(--foreground)]/80">Tamanho da Bolha</label>
|
||||
<select
|
||||
value={tempBubbleSize}
|
||||
onChange={(e) => setTempBubbleSize(e.target.value as any)}
|
||||
className="w-full p-2 rounded-lg border border-[var(--border)] bg-[var(--background)]"
|
||||
>
|
||||
<option value="sm">Pequena</option>
|
||||
<option value="md">Média</option>
|
||||
<option value="lg">Grande</option>
|
||||
</select>
|
||||
<p className="text-xs text-[var(--foreground)]/60">Ajusta paddings e tipografia das bolhas de mensagem.</p>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Settings, Save, RotateCcw } from "lucide-react";
|
||||
import { SystemPromptInfo } from "./SystemPromptInfo";
|
||||
|
||||
interface SystemPromptProps {
|
||||
onSystemPromptChange: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function SystemPrompt({ onSystemPromptChange }: SystemPromptProps) {
|
||||
const [systemPrompt, setSystemPrompt] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("ollahub-system-prompt");
|
||||
if (saved) {
|
||||
setSystemPrompt(saved);
|
||||
onSystemPromptChange(saved);
|
||||
}
|
||||
}, [onSystemPromptChange]);
|
||||
|
||||
const handleSave = () => {
|
||||
localStorage.setItem("ollahub-system-prompt", systemPrompt);
|
||||
onSystemPromptChange(systemPrompt);
|
||||
setIsEditing(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
const defaultPrompt = "";
|
||||
setSystemPrompt(defaultPrompt);
|
||||
localStorage.removeItem("ollahub-system-prompt");
|
||||
onSystemPromptChange(defaultPrompt);
|
||||
setIsEditing(false);
|
||||
setHasChanges(false);
|
||||
};
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setSystemPrompt(value);
|
||||
setHasChanges(true);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-sm font-medium text-[var(--foreground)]/80">
|
||||
System Prompt
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<SystemPromptInfo />
|
||||
{isEditing && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges}
|
||||
className="p-1 rounded hover:bg-[var(--background)] disabled:opacity-50"
|
||||
title="Save prompt"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="Reset to default"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="Edit system prompt"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={systemPrompt}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="Enter system prompt (optional)..."
|
||||
className="w-full h-24 resize-none rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-xs focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
System prompts help define the AI's behavior and personality.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--background)] p-3">
|
||||
{systemPrompt ? (
|
||||
<p className="text-xs text-[var(--foreground)]/80 whitespace-pre-wrap">
|
||||
{systemPrompt}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--foreground)]/60 italic">
|
||||
No system prompt set
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SystemPromptInfo() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-1 rounded hover:bg-[var(--background)]"
|
||||
title="About system prompts"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-6 left-0 z-50 w-80 rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 shadow-lg">
|
||||
<div className="text-xs space-y-2">
|
||||
<h4 className="font-medium">System Prompts</h4>
|
||||
<p>
|
||||
System prompts define the AI's behavior, personality, and response
|
||||
style. They are sent once at the beginning of each conversation.
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Examples:</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-[var(--foreground)]/80">
|
||||
<li>"You are a helpful coding assistant"</li>
|
||||
<li>"Respond in Portuguese, be concise"</li>
|
||||
<li>"You are a creative writing coach"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-[var(--foreground)]/60">
|
||||
Based on Ollama documentation - system messages help guide model
|
||||
behavior.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="absolute top-2 right-2 p-1 rounded hover:bg-[var(--surface)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
@@ -1,297 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import {
|
||||
Package,
|
||||
Star,
|
||||
Eye,
|
||||
ExternalLink,
|
||||
CheckCircle,
|
||||
Zap,
|
||||
Settings,
|
||||
PlayCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
|
||||
interface MCPCardProps {
|
||||
mcp: MCPProvider;
|
||||
onInstall: () => void;
|
||||
onUninstall: () => void;
|
||||
mode?: "marketplace" | "installed";
|
||||
onEditConfig?: () => void;
|
||||
onTestServer?: () => void;
|
||||
isTesting?: boolean;
|
||||
installingStatus?: {
|
||||
status: string;
|
||||
message: string;
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const MCPCard = memo(
|
||||
function MCPCard({
|
||||
mcp,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
mode = "marketplace",
|
||||
onEditConfig,
|
||||
onTestServer,
|
||||
isTesting = false,
|
||||
installingStatus,
|
||||
}: MCPCardProps) {
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "map":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "browser":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
case "office":
|
||||
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
|
||||
case "search":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
|
||||
case "database":
|
||||
return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
|
||||
case "finance":
|
||||
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
|
||||
case "code":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "chart":
|
||||
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
|
||||
case "payment":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const formatDownloads = (downloads: number) => {
|
||||
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
|
||||
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
|
||||
return downloads.toString();
|
||||
};
|
||||
|
||||
const isOfficial = mcp.tags?.includes("official") || false;
|
||||
const toolCount = mcp.tools?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-[var(--primary)]/10 rounded-md">
|
||||
<Package className="h-4 w-4 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-sm">{mcp.name}</h3>
|
||||
{isOfficial && (
|
||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
by {mcp.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(
|
||||
mcp.category
|
||||
)}`}
|
||||
>
|
||||
{mcp.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
|
||||
{mcp.description}
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
|
||||
<div className="flex items-center gap-1">
|
||||
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
|
||||
<span>{mcp.rating}</span>
|
||||
<span>({mcp.totalRatings})</span>
|
||||
</div>
|
||||
{toolCount > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{toolCount} tools</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
|
||||
v{mcp.version}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-1 mb-4">
|
||||
{mcp.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tags.length > 3 && (
|
||||
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
|
||||
+{mcp.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tools Badges */}
|
||||
{mcp.tools && Array.isArray(mcp.tools) && mcp.tools.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Zap className="h-4 w-4 text-purple-500 dark:text-purple-400" />
|
||||
<span className="text-xs font-semibold text-purple-600 dark:text-purple-400">
|
||||
{mcp.tools.length} {mcp.tools.length === 1 ? "Tool" : "Tools"}
|
||||
</span>
|
||||
</div>
|
||||
{mcp.tools.length > 6 && (
|
||||
<span className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
Showing 6 of {mcp.tools.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-[60px] overflow-hidden">
|
||||
{mcp.tools.slice(0, 6).map((tool: any, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-2.5 py-1 bg-gradient-to-r from-purple-50 to-purple-100 dark:from-purple-950/40 dark:to-purple-900/30 text-purple-700 dark:text-purple-300 text-[11px] font-medium rounded-md border border-purple-200/60 dark:border-purple-800/60 hover:border-purple-300 dark:hover:border-purple-700 transition-colors cursor-default shadow-sm"
|
||||
title={tool.description || tool.name}
|
||||
>
|
||||
{tool.name}
|
||||
</span>
|
||||
))}
|
||||
{mcp.tools.length > 6 && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 bg-gradient-to-r from-gray-50 to-gray-100 dark:from-gray-900/40 dark:to-gray-800/30 text-gray-600 dark:text-gray-400 text-[11px] font-medium rounded-md border border-gray-200/60 dark:border-gray-700/60 shadow-sm">
|
||||
+{mcp.tools.length - 6}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Installation Progress */}
|
||||
{installingStatus && (
|
||||
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
||||
Installing...
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-blue-600 dark:bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${installingStatus.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
{installingStatus.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
{mode === "installed" && onEditConfig ? (
|
||||
<>
|
||||
<button
|
||||
onClick={onEditConfig}
|
||||
className="px-3 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors text-sm mcp-install-button"
|
||||
title="Editar configuração"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</button>
|
||||
{onTestServer && (
|
||||
<button
|
||||
onClick={onTestServer}
|
||||
disabled={isTesting}
|
||||
className="px-3 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors text-sm mcp-install-button disabled:opacity-50"
|
||||
title="Testar servidor MCP"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
|
||||
title="Desinstalar MCP"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
</>
|
||||
) : mcp.installed ? (
|
||||
<button
|
||||
onClick={onUninstall}
|
||||
className="w-full px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button font-medium"
|
||||
>
|
||||
Uninstall
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="w-full px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button font-medium"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--border)]">
|
||||
<div className="flex gap-2">
|
||||
{mcp.repository && (
|
||||
<a
|
||||
href={mcp.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{mcp.homepage && (
|
||||
<a
|
||||
href={mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison para evitar re-renders desnecessários
|
||||
return (
|
||||
prevProps.mcp.id === nextProps.mcp.id &&
|
||||
prevProps.mcp.installed === nextProps.mcp.installed &&
|
||||
prevProps.mcp.rating === nextProps.mcp.rating &&
|
||||
prevProps.mcp.totalRatings === nextProps.mcp.totalRatings &&
|
||||
prevProps.mcp.name === nextProps.mcp.name &&
|
||||
prevProps.mcp.description === nextProps.mcp.description &&
|
||||
prevProps.mode === nextProps.mode &&
|
||||
prevProps.isTesting === nextProps.isTesting
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,356 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Package, Star, ExternalLink, CheckCircle } from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
import { MCPRatingStars } from "./MCPRatingStars";
|
||||
|
||||
interface MCPDetailModalProps {
|
||||
mcp: MCPProvider;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInstall: () => void;
|
||||
}
|
||||
|
||||
export function MCPDetailModal({
|
||||
mcp,
|
||||
isOpen,
|
||||
onClose,
|
||||
onInstall,
|
||||
}: MCPDetailModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"about" | "capabilities" | "screenshots" | "configuration"
|
||||
>("about");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case "map":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
|
||||
case "browser":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
|
||||
case "office":
|
||||
return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
|
||||
case "search":
|
||||
return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
|
||||
case "database":
|
||||
return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
|
||||
case "finance":
|
||||
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
|
||||
case "code":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
|
||||
case "chart":
|
||||
return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
|
||||
case "payment":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl mcp-detail-modal">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
|
||||
<Package className="h-8 w-8 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{mcp.name}</h2>
|
||||
<p className="text-[var(--muted-foreground)]">by {mcp.author}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-3 py-1 rounded-full text-sm font-medium mcp-category-badge ${getCategoryColor(
|
||||
mcp.category
|
||||
)}`}
|
||||
>
|
||||
{mcp.category}
|
||||
</span>
|
||||
{mcp.installed && (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">Installed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MCPRatingStars rating={mcp.rating} size="md" />
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
({mcp.totalRatings} reviews)
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
Version {mcp.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-[var(--border)]">
|
||||
{[
|
||||
{ id: "about", label: "About" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "configuration", label: "Configuration" },
|
||||
{ id: "screenshots", label: "Screenshots" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? "border-[var(--primary)] text-[var(--primary)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 p-6 overflow-auto">
|
||||
{activeTab === "about" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Description</h3>
|
||||
<p className="text-[var(--muted-foreground)] leading-relaxed">
|
||||
{mcp.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{mcp.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-3 py-1 bg-[var(--surface)] text-sm rounded-md border border-[var(--border)] mcp-tag"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Links</h3>
|
||||
<div className="flex gap-4">
|
||||
{mcp.repository && (
|
||||
<a
|
||||
href={mcp.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Repository
|
||||
</a>
|
||||
)}
|
||||
{mcp.homepage && (
|
||||
<a
|
||||
href={mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Homepage
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "capabilities" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Capabilities</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{(mcp.tools || []).map((capability, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 p-3 bg-[var(--surface)] rounded-md border border-[var(--border)]"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm">
|
||||
{typeof capability === "string"
|
||||
? capability
|
||||
: (capability as any).name || String(capability)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "configuration" && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Installation Configuration
|
||||
</h3>
|
||||
{mcp.config && mcp.config.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{mcp.config.map((configItem, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4"
|
||||
>
|
||||
<h4 className="font-medium mb-3">
|
||||
Configuration {index + 1}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{configItem.mcpServers && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Command
|
||||
</h5>
|
||||
<code className="block bg-[var(--background)] p-2 rounded text-sm">
|
||||
{configItem.mcpServers.command}{" "}
|
||||
{configItem.mcpServers.arguments?.join(" ")}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{configItem.mcpServers?.env &&
|
||||
Object.keys(configItem.mcpServers.env).length >
|
||||
0 && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Environment Variables
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(
|
||||
configItem.mcpServers.env
|
||||
).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
|
||||
{key}={String(value)}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No configuration available for this MCP</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mcp.tools && mcp.tools.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">
|
||||
Available Tools
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{mcp.tools.map((tool, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4"
|
||||
>
|
||||
<h4 className="font-medium mb-2">{tool.name}</h4>
|
||||
<p className="text-sm text-[var(--muted-foreground)] mb-3">
|
||||
{tool.description}
|
||||
</p>
|
||||
{tool.input_schema && (
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">
|
||||
Parameters
|
||||
</h5>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(
|
||||
tool.input_schema.properties || {}
|
||||
).map(([param, schema]) => (
|
||||
<div
|
||||
key={param}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
|
||||
{param}
|
||||
</code>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">
|
||||
{typeof schema === "object" && schema
|
||||
? (schema as any).type || "any"
|
||||
: "any"}
|
||||
{tool.input_schema.required?.includes(
|
||||
param
|
||||
) && (
|
||||
<span className="text-red-500 ml-1">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "screenshots" && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-3">Screenshots</h3>
|
||||
<div className="text-center py-12 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>No screenshots available</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-[var(--border)]">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{!mcp.installed && (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
className="px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors mcp-install-button"
|
||||
>
|
||||
Install MCP
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
X,
|
||||
Package,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Info,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
|
||||
interface MCPInstallConfigModalProps {
|
||||
mcp: MCPProvider;
|
||||
config: any; // Configuração inicial do servidor
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (editedConfig: any) => void;
|
||||
isEditMode?: boolean; // Novo prop para indicar modo de edição
|
||||
}
|
||||
|
||||
export function MCPInstallConfigModal({
|
||||
mcp,
|
||||
config,
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isEditMode = false,
|
||||
}: MCPInstallConfigModalProps) {
|
||||
const [jsonString, setJsonString] = useState("");
|
||||
const [isValidJson, setIsValidJson] = useState(true);
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [hasEnvVars, setHasEnvVars] = useState(false);
|
||||
|
||||
// Inicializar JSON quando config mudar
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
try {
|
||||
const formattedJson = JSON.stringify(config, null, 2);
|
||||
setJsonString(formattedJson);
|
||||
setIsValidJson(true);
|
||||
setJsonError("");
|
||||
|
||||
// Verificar se tem variáveis de ambiente
|
||||
const hasEnv =
|
||||
JSON.stringify(config).includes('"env"') ||
|
||||
JSON.stringify(config).includes("env:");
|
||||
setHasEnvVars(hasEnv);
|
||||
} catch (error) {
|
||||
console.error("Error formatting config:", error);
|
||||
setJsonString("{}");
|
||||
setIsValidJson(false);
|
||||
setJsonError("Erro ao formatar configuração");
|
||||
}
|
||||
} else {
|
||||
// Config vazio - criar template baseado no MCP
|
||||
// Usar npm ou nome do repositório se disponível
|
||||
const serverName = mcp.id || mcp.name.toLowerCase().replace(/\s+/g, "-");
|
||||
const command = mcp.repository?.includes("npm")
|
||||
? "npx"
|
||||
: mcp.repository
|
||||
? "node"
|
||||
: "npm";
|
||||
|
||||
const template = {
|
||||
mcpServers: {
|
||||
[serverName]: {
|
||||
command: command,
|
||||
args: command === "npx" ? ["-y", serverName] : [],
|
||||
env: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setJsonString(JSON.stringify(template, null, 2));
|
||||
setIsValidJson(true);
|
||||
setJsonError("");
|
||||
setHasEnvVars(false);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Validar JSON em tempo real
|
||||
const validateJson = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
setIsValidJson(true);
|
||||
setJsonError("");
|
||||
return true;
|
||||
} catch (error) {
|
||||
setIsValidJson(false);
|
||||
setJsonError(error instanceof Error ? error.message : "JSON inválido");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonChange = (value: string) => {
|
||||
setJsonString(value);
|
||||
validateJson(value);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!isValidJson) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedConfig = JSON.parse(jsonString);
|
||||
onConfirm(parsedConfig);
|
||||
} catch (error) {
|
||||
console.error("Error parsing config:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
|
||||
<Package className="h-8 w-8 text-[var(--primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{isEditMode ? "Editar Configuração" : "Configurar Instalação"}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
{mcp.name} por {mcp.author}
|
||||
</p>
|
||||
{(mcp.repository || mcp.homepage) && (
|
||||
<a
|
||||
href={mcp.repository || mcp.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-blue-600 hover:text-blue-700 transition-colors"
|
||||
title="Ver no GitHub"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="text-sm">GitHub</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="p-6 border-b border-[var(--border)] bg-blue-50 dark:bg-blue-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-blue-800 dark:text-blue-200 mb-2">
|
||||
Instruções de Configuração
|
||||
</p>
|
||||
<ul className="text-blue-700 dark:text-blue-300 space-y-1">
|
||||
<li>
|
||||
• <strong>Edite apenas os campos necessários</strong>,
|
||||
especialmente variáveis de ambiente (env)
|
||||
</li>
|
||||
<li>
|
||||
• Adicione suas <strong>API keys</strong>,{" "}
|
||||
<strong>tokens</strong> e <strong>credenciais</strong> nos
|
||||
campos env
|
||||
</li>
|
||||
<li>
|
||||
• Mantenha a estrutura JSON válida - use aspas duplas e
|
||||
vírgulas corretamente
|
||||
</li>
|
||||
<li>
|
||||
• Campos como "command" geralmente não precisam ser alterados
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* JSON Editor */}
|
||||
<div className="flex-1 p-6 overflow-hidden flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Configuração do Servidor</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isValidJson ? (
|
||||
<div className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">JSON válido</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">JSON inválido</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{!isValidJson && jsonError && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-red-700 dark:text-red-300">
|
||||
<p className="font-medium">Erro no JSON:</p>
|
||||
<p className="font-mono text-xs mt-1">{jsonError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Textarea - Área maior */}
|
||||
<div className="flex-1 min-h-0 mb-4">
|
||||
<textarea
|
||||
value={jsonString}
|
||||
onChange={(e) => handleJsonChange(e.target.value)}
|
||||
className="w-full h-full p-4 bg-[var(--surface)] border border-[var(--border)] rounded-md font-mono text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent"
|
||||
placeholder="Digite a configuração JSON aqui..."
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Env Variables Warning */}
|
||||
{hasEnvVars && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p className="font-medium">
|
||||
Variáveis de Ambiente Detectadas
|
||||
</p>
|
||||
<p>
|
||||
Certifique-se de preencher os valores necessários nos campos
|
||||
"env" antes de instalar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-[var(--border)] flex-shrink-0">
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!isValidJson}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isEditMode ? "Salvar Alterações" : "Confirmar Instalação"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { X, Upload, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
|
||||
|
||||
interface MCPManualInstallModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: (mcpId: string) => void;
|
||||
}
|
||||
|
||||
export function MCPManualInstallModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: MCPManualInstallModalProps) {
|
||||
const [configJson, setConfigJson] = useState("");
|
||||
const [mcpId, setMcpId] = useState("");
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [parsedConfig, setParsedConfig] = useState<any>(null);
|
||||
|
||||
// Validar JSON enquanto digita
|
||||
const handleConfigChange = (value: string) => {
|
||||
setConfigJson(value);
|
||||
setValidationError(null);
|
||||
setParsedConfig(null);
|
||||
|
||||
if (!value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
setParsedConfig(parsed);
|
||||
|
||||
// Validar estrutura básica
|
||||
if (parsed.mcpServers) {
|
||||
const serverNames = Object.keys(parsed.mcpServers);
|
||||
if (serverNames.length === 0) {
|
||||
setValidationError("No MCP server found in configuration");
|
||||
return;
|
||||
}
|
||||
|
||||
const firstServer = parsed.mcpServers[serverNames[0]];
|
||||
if (!firstServer.command) {
|
||||
setValidationError("Missing required field: command");
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-preencher ID se estiver vazio
|
||||
if (!mcpId) {
|
||||
setMcpId(serverNames[0]);
|
||||
}
|
||||
} else {
|
||||
// Formato direto
|
||||
if (!parsed.command) {
|
||||
setValidationError("Missing required field: command");
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
setValidationError(`Invalid JSON: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!configJson.trim()) {
|
||||
setError("Configuration is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (validationError) {
|
||||
setError("Please fix validation errors before installing");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsInstalling(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/mcp/install-manual", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
config: parsedConfig,
|
||||
mcpId: mcpId || undefined,
|
||||
enableLogs: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`✅ Manual installation started for ${data.mcpId}`);
|
||||
onSuccess?.(data.mcpId);
|
||||
onClose();
|
||||
|
||||
// Reset form
|
||||
setConfigJson("");
|
||||
setMcpId("");
|
||||
setParsedConfig(null);
|
||||
} else {
|
||||
setError(data.error || "Failed to start installation");
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error installing MCP:", err);
|
||||
setError(err.message || "Failed to start installation");
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasteExample = () => {
|
||||
const example = `{
|
||||
"mcpServers": {
|
||||
"time-server": {
|
||||
"command": "npx",
|
||||
"args": ["@mcpcentral/mcp-time"]
|
||||
}
|
||||
}
|
||||
}`;
|
||||
setConfigJson(example);
|
||||
handleConfigChange(example);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-[var(--background)] rounded-lg shadow-xl max-w-3xl w-full max-h-[90vh] overflow-y-auto border border-[var(--border)]">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 border-b border-[var(--border)] p-4 flex items-center justify-between bg-[color-mix(in_oklab,var(--background),black_2%)]/90 backdrop-blur supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--background),black_2%)]/70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5 text-[var(--accent)]" />
|
||||
<h2 className="text-xl font-semibold">
|
||||
Instalar MCP Manualmente
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-lg transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Description */}
|
||||
<div className="rounded-lg p-4 border border-[var(--border)] bg-[var(--surface)]/60">
|
||||
<h3 className="font-medium mb-2">
|
||||
Como usar
|
||||
</h3>
|
||||
<p className="text-sm mb-3 text-[var(--foreground)]/80">
|
||||
Cole a configuração do servidor MCP no formato do Claude Desktop (claude_desktop_config.json).
|
||||
Apenas instalação manual é suportada neste projeto.
|
||||
</p>
|
||||
<button
|
||||
onClick={handlePasteExample}
|
||||
className="text-sm text-[var(--accent)] hover:underline"
|
||||
>
|
||||
Clique aqui para carregar um exemplo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MCP ID (optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
MCP ID (opcional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mcpId}
|
||||
onChange={(e) => setMcpId(e.target.value)}
|
||||
placeholder="my-custom-mcp (detectado automaticamente se vazio)"
|
||||
className="w-full px-4 py-2 border border-[var(--border)] rounded-lg bg-[var(--background)] focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Configuration JSON */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
JSON de Configuração *
|
||||
</label>
|
||||
<textarea
|
||||
value={configJson}
|
||||
onChange={(e) => handleConfigChange(e.target.value)}
|
||||
placeholder={`Cole a configuração aqui, ex.:
|
||||
{
|
||||
"mcpServers": {
|
||||
"time-server": {
|
||||
"command": "npx",
|
||||
"args": ["@mcpcentral/mcp-time"]
|
||||
}
|
||||
}
|
||||
}`}
|
||||
rows={15}
|
||||
className="w-full px-4 py-3 border border-[var(--border)] rounded-lg bg-[var(--background)] font-mono text-sm focus:ring-2 focus:ring-[var(--accent)] focus:border-transparent resize-none"
|
||||
/>
|
||||
|
||||
{/* Validation Status */}
|
||||
{configJson.trim() && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
{validationError ? (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-red-500" />
|
||||
<span className="text-sm text-red-600">{validationError}</span>
|
||||
</>
|
||||
) : parsedConfig ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm text-green-600">Configuração válida</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{parsedConfig && !validationError && (
|
||||
<div className="border border-[var(--border)] rounded-lg p-4 bg-[var(--surface)]/50">
|
||||
<h3 className="font-medium mb-3">Pré-visualização</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{parsedConfig.mcpServers ? (
|
||||
Object.entries(parsedConfig.mcpServers).map(
|
||||
([name, config]: [string, any]) => (
|
||||
<div key={name} className="space-y-1">
|
||||
<div className="">
|
||||
<span className="font-medium">Server:</span> {name}
|
||||
</div>
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Command:</span>{" "}
|
||||
{config.command} {(config.args || []).join(" ")}
|
||||
</div>
|
||||
{config.env && (
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Environment:</span>{" "}
|
||||
{Object.keys(config.env).length} variables
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Command:</span>{" "}
|
||||
{parsedConfig.command}{" "}
|
||||
{(parsedConfig.args || []).join(" ")}
|
||||
</div>
|
||||
{parsedConfig.env && (
|
||||
<div className="text-[var(--foreground)]/80">
|
||||
<span className="font-medium">Environment:</span>{" "}
|
||||
{Object.keys(parsedConfig.env).length} variables
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-red-900">Erro na instalação</h4>
|
||||
<p className="text-sm text-red-700 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 bg-[var(--surface)]/60 border-t border-[var(--border)] p-4 flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isInstalling}
|
||||
className="px-4 py-2 hover:bg-[var(--surface)] rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
disabled={isInstalling || !parsedConfig || !!validationError}
|
||||
className="px-6 py-2 bg-[var(--accent)] text-[var(--accent-foreground)] rounded-lg hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Instalando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4" />
|
||||
Instalar MCP
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
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;
|
||||
};
|
||||
|
||||
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
|
||||
// Normalizar quebras de linha e espaços
|
||||
const normalizedContent = content
|
||||
.replace(/\n{3,}/g, "\n\n") // Máximo 2 quebras consecutivas
|
||||
.replace(/[ \t]+$/gm, "") // Remover espaços no final das linhas
|
||||
.trim();
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeHighlight]}
|
||||
components={{
|
||||
code({ inline, className, children, ...props }: any) {
|
||||
// Garantir extração correta do texto do bloco, mesmo com quebras/indentação
|
||||
const node: any = (props as any).node;
|
||||
const rawChild = node?.children?.[0]?.value ?? children;
|
||||
const text = String(rawChild ?? "");
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<code className="rounded bg-[var(--surface)] px-1 py-0.5 text-[0.95em]">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const cleaned = text.replace(/^\n+|\n+$/g, "");
|
||||
return (
|
||||
<div className="relative my-4 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(cleaned)}
|
||||
aria-label="Copiar código"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
<pre className="overflow-auto p-3">
|
||||
<code className={`${className || ""} font-mono text-[var(--foreground)] whitespace-pre`} {...props}>
|
||||
{cleaned}
|
||||
</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
h1: ({ children }: any) => (
|
||||
<h1 className="text-xl font-bold mt-4 mb-2 first:mt-0">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: any) => (
|
||||
<h2 className="text-lg font-semibold mt-3 mb-2">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: any) => (
|
||||
<h3 className="text-base font-semibold mt-3 mb-1">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: any) => (
|
||||
<h4 className="text-sm font-semibold mt-2 mb-1">{children}</h4>
|
||||
),
|
||||
h5: ({ children }: any) => (
|
||||
<h5 className="text-sm font-medium mt-2 mb-1">{children}</h5>
|
||||
),
|
||||
h6: ({ children }: any) => (
|
||||
<h6 className="text-xs font-medium mt-2 mb-1">{children}</h6>
|
||||
),
|
||||
p: ({ children }: any) => (
|
||||
<p className="mb-3 leading-relaxed">{children}</p>
|
||||
),
|
||||
ul: ({ children }: any) => (
|
||||
<ul className="mb-3 ml-4 list-disc space-y-1">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: any) => (
|
||||
<ol className="mb-3 ml-4 list-decimal space-y-1">{children}</ol>
|
||||
),
|
||||
li: ({ children }: any) => (
|
||||
<li className="leading-relaxed">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }: any) => (
|
||||
<blockquote className="border-l-4 border-[var(--border)] pl-4 my-3 italic text-[var(--foreground)]/80">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="my-4 border-[var(--border)]" />,
|
||||
br: () => <br className="mb-1" />,
|
||||
}}
|
||||
>
|
||||
{normalizedContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "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;
|
||||
};
|
||||
|
||||
export function MessageActions({
|
||||
content,
|
||||
disabled,
|
||||
onEdit,
|
||||
onRegenerate,
|
||||
}: MessageActionsProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { copyMode, setCopyMode } = useCopyPrefs();
|
||||
|
||||
const copy = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
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 pointer-events-auto">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={handleCopyDefault}
|
||||
disabled={disabled}
|
||||
aria-label="Copiar"
|
||||
>
|
||||
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-1 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
disabled={disabled}
|
||||
aria-label="Selecionar formato"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
className="block w-full px-3 py-2 text-left text-xs hover:bg-[var(--surface)]"
|
||||
onClick={() => handleModeSelect("markdown")}
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<button
|
||||
type="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
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
aria-label="Edit message"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{onRegenerate && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded px-2 py-1 text-xs hover:bg-[var(--surface)]"
|
||||
onClick={onRegenerate}
|
||||
disabled={disabled}
|
||||
aria-label="Regenerate"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" /> Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
if (!mounted) return null;
|
||||
const isDark =
|
||||
theme === "dark" ||
|
||||
(!theme &&
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
return (
|
||||
<button
|
||||
aria-label="Alternar tema"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="hidden sm:inline">{isDark ? "Light" : "Dark"}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Cpu, CircuitBoard, Info } from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
import { useState } from "react";
|
||||
|
||||
export function ModelCard({ model }: { model: ModelInfo }) {
|
||||
const [hover, setHover] = useState(false);
|
||||
const isGpu = model.device === "GPU";
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
onFocus={() => setHover(true)}
|
||||
onBlur={() => setHover(false)}
|
||||
className="group relative rounded-lg border border-[var(--border)] bg-[var(--background)] p-4 shadow-sm transition-all hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-semibold tracking-tight">
|
||||
{model.name}
|
||||
</h3>
|
||||
<span className="inline-flex items-center gap-1 rounded-md border border-[var(--border)] px-2 py-0.5 text-xs text-[var(--foreground)]/80">
|
||||
{isGpu ? (
|
||||
<CircuitBoard className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Cpu className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--foreground)]/80">
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute right-3 top-3 opacity-60">
|
||||
<Info className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{hover && (
|
||||
<div className="absolute left-0 top-full z-10 mt-2 w-72 rounded-md border border-[var(--border)] bg-[var(--background)] p-3 text-sm shadow-lg">
|
||||
<div className="mb-2 font-medium">Detalhes (estimativa)</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Uso de CPU</span>
|
||||
<span className="font-medium">{model.estCpuUsagePct ?? "—"}%</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<span>Uso de VRAM</span>
|
||||
<span className="font-medium">{model.estVramGB ?? 0} GB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Cpu,
|
||||
CircuitBoard,
|
||||
Search,
|
||||
Filter,
|
||||
Zap,
|
||||
HardDrive,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
X,
|
||||
Download,
|
||||
Trash2,
|
||||
Cloud,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
interface ModelDropdownProps {
|
||||
models: ModelInfo[];
|
||||
selectedModel: string | null;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
onDeleteModel?: (modelId: string) => void;
|
||||
onPullModel?: (modelName: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ModelDropdown({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
onDeleteModel,
|
||||
onPullModel,
|
||||
disabled = false,
|
||||
}: ModelDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterDevice, setFilterDevice] = useState<"all" | "CPU" | "GPU">(
|
||||
"all"
|
||||
);
|
||||
const [showRemote, setShowRemote] = useState(false);
|
||||
const [remoteModels, setRemoteModels] = useState<any[]>([]);
|
||||
const [isSearchingRemote, setIsSearchingRemote] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Buscar modelos remotos
|
||||
const searchRemoteModels = async (query: string) => {
|
||||
setIsSearchingRemote(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/ollama/search?q=${encodeURIComponent(query)}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setRemoteModels(data.models);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao buscar modelos remotos:", error);
|
||||
} finally {
|
||||
setIsSearchingRemote(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Deletar modelo
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/delete", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelName: modelId }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
onDeleteModel?.(modelId);
|
||||
setShowDeleteConfirm(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar modelo:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Baixar modelo remoto
|
||||
const handlePullModel = (modelName: string) => {
|
||||
onPullModel?.(modelName);
|
||||
};
|
||||
|
||||
// Filtrar modelos
|
||||
const filteredModels = models.filter((model) => {
|
||||
const matchesSearch = model.name
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
const matchesDevice =
|
||||
filterDevice === "all" || model.device === filterDevice;
|
||||
return matchesSearch && matchesDevice;
|
||||
});
|
||||
|
||||
// Categorizar modelos por performance
|
||||
const categorizeModel = (model: ModelInfo) => {
|
||||
if (model.sizeGB <= 3) return "compact";
|
||||
if (model.sizeGB <= 7) return "balanced";
|
||||
return "powerful";
|
||||
};
|
||||
|
||||
const getPerformanceColor = (model: ModelInfo) => {
|
||||
const category = categorizeModel(model);
|
||||
switch (category) {
|
||||
case "compact":
|
||||
return "text-green-600 bg-green-50 border-green-200";
|
||||
case "balanced":
|
||||
return "text-blue-600 bg-blue-50 border-blue-200";
|
||||
case "powerful":
|
||||
return "text-purple-600 bg-purple-50 border-purple-200";
|
||||
default:
|
||||
return "text-gray-600 bg-gray-50 border-gray-200";
|
||||
}
|
||||
};
|
||||
|
||||
const getPerformanceIcon = (model: ModelInfo) => {
|
||||
const category = categorizeModel(model);
|
||||
switch (category) {
|
||||
case "compact":
|
||||
return <Zap className="h-3 w-3" />;
|
||||
case "balanced":
|
||||
return <TrendingUp className="h-3 w-3" />;
|
||||
case "powerful":
|
||||
return <CircuitBoard className="h-3 w-3" />;
|
||||
default:
|
||||
return <Cpu className="h-3 w-3" />;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setFilterDevice("all");
|
||||
setShowRemote(false);
|
||||
setRemoteModels([]);
|
||||
setShowDeleteConfirm(null);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Buscar modelos remotos quando necessário
|
||||
useEffect(() => {
|
||||
if (showRemote && searchTerm.length > 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
searchRemoteModels(searchTerm);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [showRemote, searchTerm]);
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center justify-between rounded-lg border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedModelData ? (
|
||||
<>
|
||||
<span className="truncate font-medium">
|
||||
{selectedModelData.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
|
||||
selectedModelData
|
||||
)}`}
|
||||
>
|
||||
{getPerformanceIcon(selectedModelData)}
|
||||
{categorizeModel(selectedModelData)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
|
||||
{selectedModelData.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{selectedModelData.device}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--foreground)]/60">
|
||||
Select a model...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full z-50 mt-1 w-full min-w-[400px] rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-xl">
|
||||
{/* Header com busca e filtros */}
|
||||
<div className="border-b border-[var(--border)] p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground)]/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--foreground)]/40 hover:text-[var(--foreground)]/60"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Filter className="h-4 w-4 text-[var(--foreground)]/40" />
|
||||
<select
|
||||
value={filterDevice}
|
||||
onChange={(e) =>
|
||||
setFilterDevice(e.target.value as "all" | "CPU" | "GPU")
|
||||
}
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<option value="all">All Devices</option>
|
||||
<option value="CPU">CPU Only</option>
|
||||
<option value="GPU">GPU Only</option>
|
||||
</select>
|
||||
{/* Instalar por referência */}
|
||||
{onPullModel && (
|
||||
<button
|
||||
onClick={() => handlePullModel("")}
|
||||
className="rounded-md bg-[var(--accent)] text-white px-2 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
title="Instalar por referência (ex.: owner/model:tag)"
|
||||
>
|
||||
Install by ref
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle para busca remota */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRemote(!showRemote)}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
showRemote
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]/60 hover:bg-[var(--surface)]/80"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-3 w-3" />
|
||||
Remote Search
|
||||
</button>
|
||||
<div className="text-xs text-[var(--foreground)]/60">
|
||||
{showRemote
|
||||
? `${remoteModels.length} remote`
|
||||
: `${filteredModels.length} local`}{" "}
|
||||
model
|
||||
{(showRemote
|
||||
? remoteModels.length
|
||||
: filteredModels.length) !== 1
|
||||
? "s"
|
||||
: ""}{" "}
|
||||
found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de modelos com scroll customizado */}
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{showRemote ? (
|
||||
// Lista de modelos remotos
|
||||
<>
|
||||
{isSearchingRemote ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--accent)] mx-auto mb-2"></div>
|
||||
Searching remote models...
|
||||
</div>
|
||||
) : remoteModels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<Cloud className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No remote models found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{remoteModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 mb-2"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{model.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-blue-50 text-blue-600 border border-blue-200 px-2 py-0.5 text-xs">
|
||||
<Cloud className="h-3 w-3" />
|
||||
Remote
|
||||
</span>
|
||||
{model.installed && (
|
||||
<span className="flex items-center gap-1 rounded-md bg-green-50 text-green-600 border border-green-200 px-2 py-0.5 text-xs">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-[var(--foreground)]/50">
|
||||
Available for download
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePullModel(model.name)}
|
||||
disabled={model.installed}
|
||||
className="flex items-center gap-1 rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{model.installed ? "Installed" : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Lista de modelos locais
|
||||
<>
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<Search className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No models found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`w-full rounded-lg border p-3 mb-2 transition-all hover:shadow-md ${
|
||||
selectedModel === model.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "border-[var(--border)] bg-[var(--background)] hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{model.name}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
|
||||
model
|
||||
)}`}
|
||||
>
|
||||
{getPerformanceIcon(model)}
|
||||
{categorizeModel(model)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{model.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações técnicas e ações */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--foreground)]/50">
|
||||
{model.estCpuUsagePct && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
<span>{model.estCpuUsagePct}% CPU</span>
|
||||
</div>
|
||||
)}
|
||||
{model.estVramGB && model.estVramGB > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
<span>{model.estVramGB} GB VRAM</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
className="rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
{onDeleteModel && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(model.id)}
|
||||
className="rounded-md bg-red-50 text-red-600 border border-red-200 px-3 py-1 text-xs hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmação de exclusão */}
|
||||
{showDeleteConfirm === model.id && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="text-xs text-red-700 mb-2">
|
||||
Are you sure you want to delete this model?
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDeleteModel(model.id)}
|
||||
className="rounded-md bg-red-600 text-white px-2 py-1 text-xs hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className="rounded-md bg-gray-200 text-gray-700 px-2 py-1 text-xs hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ModelCard } from "./ModelCard";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
export function ModelList({ models }: { models: ModelInfo[] }) {
|
||||
if (!models?.length) {
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--border)] p-6 text-sm text-[var(--foreground)]/70">
|
||||
Nenhum modelo encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{models.map((m) => (
|
||||
<ModelCard key={m.id} model={m} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Download, Loader2 } from "lucide-react";
|
||||
|
||||
interface ModelPullDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
modelName: string;
|
||||
onConfirmPull: (modelName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
interface PullProgress {
|
||||
status: string;
|
||||
digest?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ModelPullDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
modelName,
|
||||
onConfirmPull,
|
||||
}: ModelPullDialogProps) {
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [progress, setProgress] = useState<PullProgress | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSuccess, setIsSuccess] = useState<boolean>(false);
|
||||
const [editableName, setEditableName] = useState<string>(modelName);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [isLoadingTags, setIsLoadingTags] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setIsPulling(false);
|
||||
setProgress(null);
|
||||
setError(null);
|
||||
setIsSuccess(false);
|
||||
setEditableName(modelName);
|
||||
setTags([]);
|
||||
setIsLoadingTags(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ao abrir, tentar buscar tags conhecidas
|
||||
const base = String(modelName).split(":")[0];
|
||||
setEditableName(modelName);
|
||||
if (!base) {
|
||||
setTags([]);
|
||||
setIsLoadingTags(false);
|
||||
return;
|
||||
}
|
||||
setIsLoadingTags(true);
|
||||
fetch(`/api/ollama/tags?name=${encodeURIComponent(base)}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data?.success && Array.isArray(data.tags)) {
|
||||
setTags(data.tags);
|
||||
} else {
|
||||
setTags([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setTags([]))
|
||||
.finally(() => setIsLoadingTags(false));
|
||||
}, [isOpen, modelName]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setIsPulling(true);
|
||||
setError(null);
|
||||
setProgress(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/ollama/pull", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: editableName.trim() }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Falha ao iniciar download do modelo");
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Resposta inválida do servidor");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
|
||||
// Processar diferentes estados de progresso
|
||||
if (data.status === "downloading" || data.status === "pulling") {
|
||||
setProgress({
|
||||
status: data.status,
|
||||
total: data.total,
|
||||
completed: data.completed,
|
||||
digest: data.digest,
|
||||
});
|
||||
} else if (data.status === "success") {
|
||||
setProgress((prev) => ({
|
||||
status: "success",
|
||||
total: prev?.total,
|
||||
completed: prev?.total
|
||||
}));
|
||||
setIsSuccess(true);
|
||||
} else if (data.error) {
|
||||
setError(data.detail || data.error);
|
||||
} else if (data.total || data.completed) {
|
||||
// Caso o status não seja "downloading" mas tenha dados de progresso
|
||||
setProgress({
|
||||
status: data.status || "downloading",
|
||||
total: data.total,
|
||||
completed: data.completed,
|
||||
digest: data.digest,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// ignorar linhas inválidas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Não fechar automaticamente; permitir usuário fechar ao concluir
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to pull model");
|
||||
} finally {
|
||||
setIsPulling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatBytes = (n?: number) => {
|
||||
if (!n || n <= 0) return "0 MB";
|
||||
const mb = n / 1024 / 1024;
|
||||
if (mb < 1024) return `${mb.toFixed(1)} MB`;
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(2)} GB`;
|
||||
};
|
||||
|
||||
const percent = progress?.total && progress?.completed
|
||||
? Math.max(0, Math.min(100, Math.round((progress.completed / progress.total) * 100)))
|
||||
: 0;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-md rounded-lg border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">Install Model</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPulling}
|
||||
className="rounded-md p-1 transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-2">
|
||||
<div>
|
||||
<p className="text-sm text-[var(--foreground)]/80 mb-2">
|
||||
Confirme o identificador do modelo (nome[:tag]):
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={editableName}
|
||||
onChange={(e) => setEditableName(e.target.value)}
|
||||
disabled={isPulling}
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
placeholder="ex.: llama3:8b-instruct"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs text-[var(--foreground)]/60">Tags disponíveis</p>
|
||||
{isLoadingTags && (
|
||||
<span className="text-xs text-[var(--foreground)]/50">Carregando…</span>
|
||||
)}
|
||||
</div>
|
||||
{tags.length > 0 ? (
|
||||
<select
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] px-3 py-2 text-sm"
|
||||
onChange={(e) => {
|
||||
const base = String(modelName).split(":")[0];
|
||||
const tag = e.target.value;
|
||||
setEditableName(`${base}:${tag}`);
|
||||
}}
|
||||
defaultValue=""
|
||||
disabled={isPulling}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Selecione uma tag (opcional)
|
||||
</option>
|
||||
{tags.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--foreground)]/50">
|
||||
Nenhuma tag detectada automaticamente. Você pode digitar manualmente.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Isso fará o download do modelo na sua instalação local do Ollama.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{progress && (
|
||||
<div className="mb-4 rounded-lg bg-[var(--surface)] p-3">
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className="flex items-center gap-2">
|
||||
{!isSuccess && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{isSuccess ? "Download concluído" : "Baixando modelo..."}
|
||||
</span>
|
||||
{progress.total && progress.completed && <span>{percent}%</span>}
|
||||
</div>
|
||||
<div className="w-full bg-[var(--border)] rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-2 transition-all duration-300 ${
|
||||
isSuccess ? "bg-green-500" : "bg-[var(--accent)]"
|
||||
}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress.total && progress.completed ? (
|
||||
<div className="mt-2 flex items-center justify-between text-xs text-[var(--foreground)]/60">
|
||||
<span>
|
||||
{formatBytes(progress.completed)} / {formatBytes(progress.total)}
|
||||
</span>
|
||||
{progress?.digest && (
|
||||
<span>sha256: {progress.digest.slice(0, 12)}...</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 text-xs text-[var(--foreground)]/60">
|
||||
Aguardando informações de progresso...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPulling}
|
||||
className="px-4 py-2 text-sm rounded-md border border-[var(--border)] transition-colors hover:bg-[var(--surface)] disabled:opacity-50"
|
||||
>
|
||||
{isSuccess ? "Fechar" : "Cancelar"}
|
||||
</button>
|
||||
{!isSuccess && (
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={isPulling}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm rounded-md bg-[var(--accent)] text-white transition-colors hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50"
|
||||
>
|
||||
{isPulling ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4" />
|
||||
Install
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Settings } from "lucide-react";
|
||||
import { useUserPrefs, type DevicePref } from "@/hooks/useUserPrefs";
|
||||
|
||||
export function SettingsButton({ onOpen }: { onOpen: () => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onOpen}
|
||||
aria-label="Open settings"
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-border bg-[var(--surface)] px-3 text-sm transition-colors hover:bg-[color-mix(in_oklab,var(--surface),black_6%)] focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Settings</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { prefs, ready, update } = useUserPrefs();
|
||||
const [tab, setTab] = useState<"general" | "execution" | "device">("general");
|
||||
const [localPrompt, setLocalPrompt] = useState("");
|
||||
const [device, setDevice] = useState<DevicePref>("auto");
|
||||
const [gpuIndex, setGpuIndex] = useState(0);
|
||||
const [numGpu, setNumGpu] = useState(1);
|
||||
const [chatLayout, setChatLayout] = useState<"compact" | "edge">("compact");
|
||||
const [bubbleSize, setBubbleSize] = useState<"sm" | "md" | "lg">("md");
|
||||
const [sysInfo, setSysInfo] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
setLocalPrompt(prefs.systemPrompt);
|
||||
setDevice(prefs.device);
|
||||
setGpuIndex(prefs.gpuIndex);
|
||||
setNumGpu(prefs.numGpu);
|
||||
setChatLayout((prefs as any).chatLayout ?? "compact");
|
||||
setBubbleSize((prefs as any).bubbleSize ?? "md");
|
||||
}, [ready, prefs]);
|
||||
|
||||
const handleSave = () => {
|
||||
update({ systemPrompt: localPrompt, device, gpuIndex, numGpu, chatLayout, bubbleSize });
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
|
||||
<div className="relative w-full max-w-xl rounded-lg border border-border bg-background p-4 shadow-lg">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded p-1 hover:bg-[var(--surface)]"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-2 border-b border-border">
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "general"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("general")}
|
||||
>
|
||||
General
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "execution"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("execution")}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 text-sm ${
|
||||
tab === "device"
|
||||
? "border-b-2 border-accent font-medium"
|
||||
: "text-[var(--foreground)]/60"
|
||||
}`}
|
||||
onClick={() => setTab("device")}
|
||||
>
|
||||
Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "general" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">System Prompt</label>
|
||||
<textarea
|
||||
value={localPrompt}
|
||||
onChange={(e) => setLocalPrompt(e.target.value)}
|
||||
placeholder="Define how the AI should behave..."
|
||||
className="h-32 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
/>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Sent at the start of a conversation to guide model behavior.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Chat layout</label>
|
||||
<select
|
||||
value={chatLayout}
|
||||
onChange={(e) => setChatLayout(e.target.value as any)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="compact">Compact (centered)</option>
|
||||
<option value="edge">Edge-to-edge</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Bubble size</label>
|
||||
<select
|
||||
value={bubbleSize}
|
||||
onChange={(e) => setBubbleSize(e.target.value as any)}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
<option value="sm">Small</option>
|
||||
<option value="md">Medium</option>
|
||||
<option value="lg">Large</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "execution" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">Device</label>
|
||||
<div className="flex gap-2">
|
||||
{(["auto", "cpu", "gpu"] as DevicePref[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDevice(d)}
|
||||
className={`rounded-md border border-border px-3 py-1 text-sm ${
|
||||
device === d
|
||||
? "bg-[var(--surface)]"
|
||||
: "hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
{d.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
CPU best-effort via num_gpu:0. For hard CPU-only, set
|
||||
CUDA_VISIBLE_DEVICES=-1 before starting Ollama.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
GPU Index (main_gpu)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={gpuIndex}
|
||||
onChange={(e) => setGpuIndex(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
Num GPUs (num_gpu)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={numGpu}
|
||||
onChange={(e) => setNumGpu(Number(e.target.value))}
|
||||
className="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--foreground)]/60">
|
||||
Auto: no options sent. GPU: sends num_gpu/main_gpu. CPU: sends
|
||||
num_gpu:0.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "device" && (
|
||||
<DeviceStatus
|
||||
sysInfo={sysInfo}
|
||||
setSysInfo={setSysInfo}
|
||||
device={device}
|
||||
gpuIndex={gpuIndex}
|
||||
setDevice={setDevice}
|
||||
setGpuIndex={setGpuIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md border border-border px-4 py-2 text-sm hover:bg-[var(--surface)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-md bg-accent px-4 py-2 text-sm text-white hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceStatus({ sysInfo, setSysInfo, device, gpuIndex, setDevice, setGpuIndex }: { sysInfo: any; setSysInfo: (v: any) => void; device: DevicePref; gpuIndex: number; setDevice: (d: DevicePref) => void; setGpuIndex: (i: number) => void; }) {
|
||||
// polling a cada 2s
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const load = () => {
|
||||
fetch("/api/system/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => mounted && d.success && setSysInfo(d.info))
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 2000);
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [setSysInfo]);
|
||||
|
||||
// Defaultar GPU 0 quando houver GPU e usuário escolher GPU
|
||||
useEffect(() => {
|
||||
const gpus = Array.isArray(sysInfo?.gpus) ? sysInfo.gpus : [];
|
||||
if (device === "gpu" && gpus.length > 0) {
|
||||
if (typeof gpuIndex !== "number" || gpuIndex < 0 || gpuIndex >= gpus.length) {
|
||||
setGpuIndex(0);
|
||||
}
|
||||
}
|
||||
}, [device, gpuIndex, sysInfo, setGpuIndex]);
|
||||
|
||||
if (!sysInfo) {
|
||||
return <div className="text-sm text-[var(--foreground)]/60">Loading device info...</div>;
|
||||
}
|
||||
|
||||
const memPct = sysInfo.memory.total > 0 ? (sysInfo.memory.used / sysInfo.memory.total) * 100 : 0;
|
||||
const memModules = Array.isArray(sysInfo.memory.modules) ? sysInfo.memory.modules : [];
|
||||
const gpus = Array.isArray(sysInfo.gpus) ? sysInfo.gpus : [];
|
||||
const hasGpu = gpus.length > 0;
|
||||
const currentGpu = hasGpu ? Math.min(Math.max(0, gpuIndex || 0), gpus.length - 1) : 0;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<div className="font-medium mb-1">Sistema</div>
|
||||
<div>OS: {sysInfo.os?.platform || sysInfo.platform} {sysInfo.os?.release || sysInfo.release}</div>
|
||||
<div>Arch: {sysInfo.os?.arch || sysInfo.arch}</div>
|
||||
<div>CPU: {sysInfo.cpu?.model} ({sysInfo.cpu?.cores} cores)</div>
|
||||
<div>Uptime: {Math.round(sysInfo.uptimeSec / 60)} min</div>
|
||||
</div>
|
||||
<div className="rounded-md border border-border p-3">
|
||||
<div className="font-medium mb-1">Carga</div>
|
||||
<div>Load (1m/5m/15m): {Number(sysInfo.metrics?.loadAvg?.["1m"] ?? 0).toFixed(2)} / {Number(sysInfo.metrics?.loadAvg?.["5m"] ?? 0).toFixed(2)} / {Number(sysInfo.metrics?.loadAvg?.["15m"] ?? 0).toFixed(2)}</div>
|
||||
<div>Memória: {(sysInfo.memory.used/1024/1024).toFixed(0)}MB / {(sysInfo.memory.total/1024/1024).toFixed(0)}MB ({memPct.toFixed(0)}%)</div>
|
||||
<div>Módulos de RAM: {memModules.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium mb-1">Aplicativo</div>
|
||||
<div>RSS: {(sysInfo.metrics?.process?.rss/1024/1024).toFixed(0)}MB | Heap: {(sysInfo.metrics?.process?.heapUsed/1024/1024).toFixed(0)}MB / {(sysInfo.metrics?.process?.heapTotal/1024/1024).toFixed(0)}MB</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium mb-2">Placas de vídeo</div>
|
||||
{hasGpu ? (
|
||||
<div className="space-y-2">
|
||||
{gpus.map((g: any) => (
|
||||
<div key={g.index} className="flex items-center justify-between rounded-md border border-border p-2">
|
||||
<div>
|
||||
<div className="font-medium">{g.name}</div>
|
||||
<div className="text-[var(--foreground)]/70">{g.vendor || ""} {g.vramMB ? `• ${g.vramMB}MB VRAM` : ""} {g.bus ? `• ${g.bus}` : ""}</div>
|
||||
</div>
|
||||
<div className="text-xs">index: {g.index}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[var(--foreground)]/60">Nenhuma GPU detectada</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 text-sm">
|
||||
<div className="font-medium mb-2">Dispositivo principal</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center">
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input type="radio" name="dev" checked={device === "cpu"} onChange={() => setDevice("cpu")}/> CPU
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-2">
|
||||
<input type="radio" name="dev" checked={device === "gpu"} onChange={() => setDevice("gpu")} disabled={!hasGpu}/> GPU
|
||||
</label>
|
||||
<select
|
||||
disabled={device !== "gpu" || !hasGpu}
|
||||
value={currentGpu}
|
||||
onChange={(e) => setGpuIndex(Number(e.target.value))}
|
||||
className="mt-1 w-full md:w-auto rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
{gpus.map((g: any) => (
|
||||
<option key={g.index} value={g.index}>{g.index}: {g.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-[var(--foreground)]/60">Se houver GPU, ela será o padrão quando selecionar GPU.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--foreground)]/60">Para métricas detalhadas por modelo/GPU, integrações com o servidor de modelos serão adicionadas depois.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return (
|
||||
<NextThemesProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Reference in new issue
Block a user