diff --git a/app/api/mcp/available-for-chat/route.ts b/app/api/mcp/available-for-chat/route.ts new file mode 100644 index 0000000..837a3fd --- /dev/null +++ b/app/api/mcp/available-for-chat/route.ts @@ -0,0 +1,58 @@ +/** + * API endpoint para listar MCPs disponíveis para uso no chat + */ + +import { NextResponse } from "next/server"; +import { MCPRepository } from "@/database/repositories/mcp"; +import { MCPExecutor } from "@/lib/services/mcp-executor"; +import type { MCPTool } from "@/lib/types/mcp-chat"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + // Buscar todos os MCPs instalados com status "ready" + const allMcps = MCPRepository.getAllInstalled(); + + const availableMcps = allMcps + .filter((mcp: any) => mcp.status === "ready") + .map((mcp: any) => { + const tools = MCPExecutor.getAvailableTools(mcp.id); + + // Converter tools para formato OpenAI function calling + const toolsFormatted = tools.map((tool: MCPTool) => ({ + type: "function" as const, + function: { + name: `${mcp.id}__${tool.name}`, // Prefixar com mcpId para evitar conflitos + description: tool.description, + parameters: tool.inputSchema, + }, + })); + + return { + mcpId: mcp.id, + name: mcp.name, + owner: mcp.owner, + repo: mcp.repo, + toolCount: tools.length, + tools: toolsFormatted, + rawTools: tools, // Tools originais para referência + }; + }); + + return NextResponse.json({ + success: true, + mcps: availableMcps, + count: availableMcps.length, + }); + } catch (error: any) { + console.error("Error listing available MCPs:", error); + return NextResponse.json( + { + success: false, + error: error.message || "Failed to list available MCPs", + }, + { status: 500 } + ); + } +} diff --git a/app/api/mcp/execute-tool/route.ts b/app/api/mcp/execute-tool/route.ts new file mode 100644 index 0000000..9370c2c --- /dev/null +++ b/app/api/mcp/execute-tool/route.ts @@ -0,0 +1,89 @@ +/** + * API endpoint para executar um tool de MCP + */ + +import { NextRequest, NextResponse } from "next/server"; +import { MCPExecutor } from "@/lib/services/mcp-executor"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { mcpId, toolName, parameters } = body; + + // Validar parâmetros + if (!mcpId || !toolName) { + return NextResponse.json( + { + success: false, + error: "Missing mcpId or toolName", + }, + { status: 400 } + ); + } + + console.log(`📞 API call to execute tool: ${mcpId}.${toolName}`); + + // Obter tools disponíveis + const availableTools = MCPExecutor.getAvailableTools(mcpId); + const tool = availableTools.find((t) => t.name === toolName); + + if (!tool) { + return NextResponse.json( + { + success: false, + error: `Tool ${toolName} not found in MCP ${mcpId}`, + }, + { status: 404 } + ); + } + + // Validar parâmetros contra schema + const validation = MCPExecutor.validateParameters(parameters || {}, tool); + if (!validation.valid) { + return NextResponse.json( + { + success: false, + error: `Invalid parameters: ${validation.error}`, + }, + { status: 400 } + ); + } + + // Executar tool + const result = await MCPExecutor.executeMCPTool( + mcpId, + toolName, + parameters || {} + ); + + if (!result.success) { + return NextResponse.json( + { + success: false, + error: result.error, + executionTime: result.executionTime, + }, + { status: 500 } + ); + } + + return NextResponse.json({ + success: true, + mcpId, + toolName, + result: result.result, + executionTime: result.executionTime, + }); + } catch (error: any) { + console.error("Error executing MCP tool:", error); + return NextResponse.json( + { + success: false, + error: error.message || "Failed to execute MCP tool", + }, + { status: 500 } + ); + } +} diff --git a/app/api/ollama/chat/route.ts b/app/api/ollama/chat/route.ts index 5b3b462..b21292e 100644 --- a/app/api/ollama/chat/route.ts +++ b/app/api/ollama/chat/route.ts @@ -1,20 +1,75 @@ import { NextRequest } from "next/server"; import { chatWithStream } from "@/lib/ollama"; import { convertToOllamaMessages } from "@/lib/chat"; +import { detectModelCapabilities } from "@/lib/services/model-capabilities"; +import { MCPExecutor } from "@/lib/services/mcp-executor"; +import { + generateToolsPrompt, + extractToolCallFromResponse, + formatToolResultForContext, +} from "@/lib/prompts/mcp-tools-prompt"; export const dynamic = "force-dynamic"; export async function POST(req: NextRequest) { try { - const { model, messages, options, system } = await req.json(); + const { model, messages, options, system, activeMcps } = await req.json(); if (!model || !messages?.length) { return new Response("Missing model or messages", { status: 400 }); } + // Detectar capabilities do modelo + const capabilities = detectModelCapabilities(model); + + // Preparar tools se MCPs ativos foram fornecidos + let tools: Array = []; + let toolsPrompt = ""; + const mcpToolsMap = new Map(); + + if (activeMcps && activeMcps.length > 0) { + console.log(`🔧 Active MCPs for chat: ${activeMcps.join(", ")}`); + + // Buscar tools de todos os MCPs ativos + for (const mcpId of activeMcps) { + const mcpTools = MCPExecutor.getAvailableTools(mcpId); + + for (const tool of mcpTools) { + const toolKey = `${mcpId}__${tool.name}`; + + if (capabilities.supportsNativeTools) { + // Formato OpenAI para modelos com suporte nativo + tools.push({ + type: "function", + function: { + name: toolKey, + description: tool.description, + parameters: tool.inputSchema, + }, + }); + } + + // Mapa para lookup rápido + mcpToolsMap.set(toolKey, { mcpId, toolName: tool.name }); + } + } + + // Se modelo não suporta native tools, usar prompt engineering + if (!capabilities.supportsNativeTools) { + const allTools = Array.from(mcpToolsMap.entries()).map( + ([key, { mcpId, toolName }]) => { + const mcpTools = MCPExecutor.getAvailableTools(mcpId); + return mcpTools.find((t) => t.name === toolName)!; + } + ); + toolsPrompt = generateToolsPrompt(allTools); + } + } + const stream = new ReadableStream({ async start(controller) { let isClosed = false; + let accumulatedResponse = ""; const safeEnqueue = (data: string) => { if (!isClosed) { @@ -40,17 +95,177 @@ export async function POST(req: NextRequest) { try { const ollamaMessages = convertToOllamaMessages(messages); + + // Merge system prompt com tools prompt + const finalSystemPrompt = toolsPrompt + ? `${system || ""}\n\n${toolsPrompt}` + : system; + + // Preparar parâmetros para chat + const chatParams = capabilities.supportsNativeTools ? { tools } : {}; + const response = await chatWithStream( model, ollamaMessages, options, - system + finalSystemPrompt, + capabilities.supportsNativeTools ? tools : undefined ); for await (const chunk of response) { - const token = chunk.message?.content || ""; - if (token) { - safeEnqueue(JSON.stringify({ token }) + "\n"); + // Verificar se há tool calls (modelos com suporte nativo) + if ( + chunk.message?.tool_calls && + chunk.message.tool_calls.length > 0 + ) { + console.log("🔧 Model requested tool calls (native)"); + + for (const toolCall of chunk.message.tool_calls) { + const toolKey = toolCall.function.name; + const toolInfo = mcpToolsMap.get(toolKey); + + if (toolInfo) { + // Executar tool + const result = await MCPExecutor.executeMCPTool( + toolInfo.mcpId, + toolInfo.toolName, + toolCall.function.arguments || {} + ); + + if (result.success) { + // Enviar indicador de execução de tool + safeEnqueue( + JSON.stringify({ + toolExecution: { + mcpId: toolInfo.mcpId, + toolName: toolInfo.toolName, + status: "success", + }, + }) + "\n" + ); + + // Injetar resultado no contexto e continuar + const resultMessage = formatToolResultForContext( + toolInfo.toolName, + result.result + ); + + // Fazer nova chamada ao modelo com o resultado + const updatedMessages = [ + ...ollamaMessages, + { + role: "assistant", + content: chunk.message.content || "", + }, + { role: "system", content: resultMessage }, + ]; + + const followUpResponse = await chatWithStream( + model, + updatedMessages, + options, + finalSystemPrompt, + capabilities.supportsNativeTools ? tools : undefined + ); + + for await (const followUpChunk of followUpResponse) { + const token = followUpChunk.message?.content || ""; + if (token) { + safeEnqueue(JSON.stringify({ token }) + "\n"); + } + } + } else { + console.error("Tool execution failed:", result.error); + } + } + } + } else { + // Streaming normal de tokens + const token = chunk.message?.content || ""; + if (token) { + accumulatedResponse += token; + safeEnqueue(JSON.stringify({ token }) + "\n"); + } + } + } + + // Se não há suporte nativo, verificar se resposta contém tool call via prompt engineering + if (!capabilities.supportsNativeTools && accumulatedResponse) { + const extractedToolCall = + extractToolCallFromResponse(accumulatedResponse); + + if (extractedToolCall) { + console.log("🔧 Model requested tool call (prompt engineering)"); + + // Encontrar MCP e tool + let foundToolInfo: { mcpId: string; toolName: string } | null = + null; + + for (const [key, info] of mcpToolsMap.entries()) { + if ( + extractedToolCall.name === info.toolName || + extractedToolCall.name === key + ) { + foundToolInfo = info; + break; + } + } + + if (foundToolInfo) { + // Executar tool + const result = await MCPExecutor.executeMCPTool( + foundToolInfo.mcpId, + foundToolInfo.toolName, + extractedToolCall.parameters + ); + + if (result.success) { + // Limpar resposta anterior (que continha o JSON) + safeEnqueue(JSON.stringify({ clearPrevious: true }) + "\n"); + + // Enviar indicador de execução + safeEnqueue( + JSON.stringify({ + toolExecution: { + mcpId: foundToolInfo.mcpId, + toolName: foundToolInfo.toolName, + status: "success", + }, + }) + "\n" + ); + + // Fazer nova chamada com resultado + const resultMessage = formatToolResultForContext( + foundToolInfo.toolName, + result.result + ); + + const updatedMessages = [ + ...ollamaMessages, + { + role: "assistant", + content: JSON.stringify({ + tool_call: extractedToolCall, + }), + }, + { role: "system", content: resultMessage }, + ]; + + const followUpResponse = await chatWithStream( + model, + updatedMessages, + options, + finalSystemPrompt + ); + + for await (const followUpChunk of followUpResponse) { + const token = followUpChunk.message?.content || ""; + if (token) { + safeEnqueue(JSON.stringify({ token }) + "\n"); + } + } + } + } } } diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index 5f3c976..544d3ae 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -1,10 +1,11 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +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"; import { SettingsModal } from "@/components/chat/SettingsModal"; +import { MCPSelector, ActiveMCPBadges } from "@/components/chat/MCPSelector"; import { Send, Square } from "lucide-react"; import type { ModelInfo } from "@/lib/models"; import type { Message } from "@/lib/chat"; @@ -37,6 +38,10 @@ export function ChatContainer({ const [systemPrompt, setSystemPrompt] = useState(""); const messagesEndRef = useRef(null); + // Estado para MCPs ativos + const [activeMcps, setActiveMcps] = useState([]); + const [mcpNames, setMcpNames] = useState>(new Map()); + // Carregar modelo salvo ao iniciar useEffect(() => { if (ready && prefs.selectedModel && !selectedModel) { @@ -61,6 +66,69 @@ export function ChatContainer({ update({ selectedModel: modelName }); // Salvar no Redis }; + // 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" }); }; @@ -159,6 +227,7 @@ export function ChatContainer({ messages: messagesToSend, options, system: systemPrompt || undefined, + activeMcps: activeMcps.length > 0 ? activeMcps : undefined, }), signal: abortRef.current.signal, }); @@ -187,6 +256,21 @@ export function ChatContainer({ 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); @@ -411,7 +495,21 @@ export function ChatContainer({ {/* Input */}
+ {/* Active MCP Badges */} + +
+ {/* MCP Selector */} + +