feat(api): integrate MCP tools support in Ollama chat route
Add support for Model Context Protocol (MCP) tools in the Ollama chat API. Detect model capabilities to use native tools or prompt engineering. Handle tool calls, execute them via MCPExecutor, and inject results back into the conversation for seamless tool-assisted responses. This enhances chat functionality by enabling external tool integration.
This commit is contained in:
1 parent
745746afa9
commit
4739a13458
18 files changed
+1782
-10
No files matched your search
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<any> = [];
|
||||
let toolsPrompt = "";
|
||||
const mcpToolsMap = new Map<string, { mcpId: string; toolName: string }>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 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) {
|
||||
@@ -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 */}
|
||||
<div className="flex-shrink-0 border-t border-[var(--border)] bg-[var(--background)]">
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
{/* Active MCP Badges */}
|
||||
<ActiveMCPBadges
|
||||
activeMcps={activeMcps}
|
||||
mcpNames={mcpNames}
|
||||
onRemove={handleRemoveMCP}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* MCP Selector */}
|
||||
<MCPSelector
|
||||
activeMcps={activeMcps}
|
||||
onToggleMCP={handleToggleMCP}
|
||||
onClearAll={handleClearAllMCPs}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={inputContent}
|
||||
onChange={(e) => setInputContent(e.target.value)}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"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;
|
||||
}
|
||||
|
||||
export function MCPSelector({
|
||||
activeMcps,
|
||||
onToggleMCP,
|
||||
onClearAll,
|
||||
}: MCPSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [availableMcps, setAvailableMcps] = useState<MCPForChat[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
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);
|
||||
};
|
||||
|
||||
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(--primary)] text-[var(--primary-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-80 bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-96 overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-[var(--border)] flex items-center justify-between">
|
||||
<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 text-red-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto flex-1">
|
||||
{loading ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
|
||||
Loading MCPs...
|
||||
</div>
|
||||
) : availableMcps.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-[var(--muted-foreground)]">
|
||||
No MCPs installed
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{availableMcps.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(--primary)]/10 border border-[var(--primary)]/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(--muted-foreground)] ml-6">
|
||||
<Zap className="h-3 w-3" />
|
||||
<span>{mcp.toolCount} tools available</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(--primary)]/20 text-[var(--primary)] 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 */}
|
||||
{activeMcps.length > 0 && (
|
||||
<div className="p-3 border-t border-[var(--border)] bg-[var(--surface)]/50">
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
AI can use {activeMcps.length} MCP
|
||||
{activeMcps.length > 1 ? "s" : ""} to help answer your questions
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Repositório para gerenciar MCPs ativos por conversa/usuário
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const dbPath = path.join(process.cwd(), "database", "ollahub.db");
|
||||
const db = new Database(dbPath);
|
||||
|
||||
export interface ActiveMCP {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
mcp_id: string;
|
||||
enabled: boolean;
|
||||
config?: any;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export class MCPActiveRepository {
|
||||
/**
|
||||
* Salvar MCPs ativos para uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
* @param mcpIds Array de IDs de MCPs ativos
|
||||
*/
|
||||
static saveActiveMCPs(conversationId: string, mcpIds: string[]): void {
|
||||
try {
|
||||
// Primeiro, remover todas as entradas antigas da conversa
|
||||
const deleteStmt = db.prepare(
|
||||
"DELETE FROM active_mcps WHERE conversation_id = ?"
|
||||
);
|
||||
deleteStmt.run(conversationId);
|
||||
|
||||
// Inserir novos MCPs ativos
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO active_mcps (conversation_id, mcp_id, enabled, created_at)
|
||||
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
|
||||
for (const mcpId of mcpIds) {
|
||||
insertStmt.run(conversationId, mcpId);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Saved ${mcpIds.length} active MCPs for conversation ${conversationId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error saving active MCPs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter MCPs ativos de uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
* @returns Array de IDs de MCPs ativos
|
||||
*/
|
||||
static getActiveMCPs(conversationId: string): string[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT mcp_id FROM active_mcps
|
||||
WHERE conversation_id = ? AND enabled = 1
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
const results = stmt.all(conversationId) as { mcp_id: string }[];
|
||||
return results.map((row) => row.mcp_id);
|
||||
} catch (error) {
|
||||
console.error("Error getting active MCPs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ativar/desativar um MCP específico para uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
* @param mcpId ID do MCP
|
||||
* @param enabled true para ativar, false para desativar
|
||||
*/
|
||||
static toggleMCP(
|
||||
conversationId: string,
|
||||
mcpId: string,
|
||||
enabled: boolean
|
||||
): void {
|
||||
try {
|
||||
// Verificar se já existe
|
||||
const checkStmt = db.prepare(`
|
||||
SELECT id FROM active_mcps
|
||||
WHERE conversation_id = ? AND mcp_id = ?
|
||||
`);
|
||||
const existing = checkStmt.get(conversationId, mcpId);
|
||||
|
||||
if (existing) {
|
||||
// Atualizar
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE active_mcps SET enabled = ?
|
||||
WHERE conversation_id = ? AND mcp_id = ?
|
||||
`);
|
||||
updateStmt.run(enabled ? 1 : 0, conversationId, mcpId);
|
||||
} else {
|
||||
// Inserir
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO active_mcps (conversation_id, mcp_id, enabled, created_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`);
|
||||
insertStmt.run(conversationId, mcpId, enabled ? 1 : 0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`MCP ${mcpId} ${
|
||||
enabled ? "enabled" : "disabled"
|
||||
} for conversation ${conversationId}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error toggling MCP:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpar MCPs ativos de uma conversa
|
||||
* @param conversationId ID da conversa
|
||||
*/
|
||||
static clearActiveMCPs(conversationId: string): void {
|
||||
try {
|
||||
const stmt = db.prepare(
|
||||
"DELETE FROM active_mcps WHERE conversation_id = ?"
|
||||
);
|
||||
stmt.run(conversationId);
|
||||
console.log(`Cleared active MCPs for conversation ${conversationId}`);
|
||||
} catch (error) {
|
||||
console.error("Error clearing active MCPs:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter todas as conversas que usam um MCP específico
|
||||
* @param mcpId ID do MCP
|
||||
* @returns Array de IDs de conversas
|
||||
*/
|
||||
static getConversationsUsingMCP(mcpId: string): string[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT DISTINCT conversation_id FROM active_mcps
|
||||
WHERE mcp_id = ? AND enabled = 1
|
||||
`);
|
||||
|
||||
const results = stmt.all(mcpId) as { conversation_id: string }[];
|
||||
return results.map((row) => row.conversation_id);
|
||||
} catch (error) {
|
||||
console.error("Error getting conversations using MCP:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,38 @@ export class MCPRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter todos os MCPs instalados com informações completas (incluindo status, environment, etc)
|
||||
*/
|
||||
static getAllInstalled(): any[] {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT
|
||||
id, owner, repo, name, config, tools,
|
||||
status, status_message,
|
||||
environment_path, executable_command,
|
||||
validation_result, install_logs,
|
||||
installed_at, updated_at
|
||||
FROM mcp_installations
|
||||
ORDER BY installed_at DESC
|
||||
`);
|
||||
const results = stmt.all() as any[];
|
||||
|
||||
return results.map((row) => ({
|
||||
...row,
|
||||
config: row.config ? JSON.parse(row.config) : null,
|
||||
tools: row.tools ? JSON.parse(row.tools) : [],
|
||||
validation_result: row.validation_result
|
||||
? JSON.parse(row.validation_result)
|
||||
: null,
|
||||
install_logs: row.install_logs ? JSON.parse(row.install_logs) : [],
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error getting all installed MCPs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar timestamp de um MCP instalado
|
||||
*/
|
||||
|
||||
@@ -73,6 +73,20 @@ CREATE TABLE IF NOT EXISTS mcp_cache_metadata (
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tabela para rastrear MCPs ativos por conversa
|
||||
CREATE TABLE IF NOT EXISTS active_mcps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
mcp_id TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
UNIQUE(conversation_id, mcp_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_active_mcps_conversation ON active_mcps(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_active_mcps_mcp ON active_mcps(mcp_id);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
|
||||
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
|
||||
|
||||
+13
-4
@@ -24,17 +24,26 @@ export async function chatWithStream(
|
||||
model: string,
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
options?: Record<string, unknown>,
|
||||
system?: string
|
||||
system?: string,
|
||||
tools?: Array<any>
|
||||
) {
|
||||
const client = new Ollama({ host: OLLAMA_HOST });
|
||||
return client.chat({
|
||||
|
||||
const chatOptions: any = {
|
||||
model,
|
||||
messages: system
|
||||
? [...messages, { role: "system", content: system }]
|
||||
? [{ role: "system", content: system }, ...messages]
|
||||
: messages,
|
||||
stream: true,
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
// Adicionar tools se fornecidos
|
||||
if (tools && tools.length > 0) {
|
||||
chatOptions.tools = tools;
|
||||
}
|
||||
|
||||
return client.chat(chatOptions);
|
||||
}
|
||||
|
||||
export async function pullModel(model: string) {
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Templates de prompt engineering para modelos sem suporte nativo a function calling
|
||||
*/
|
||||
|
||||
import type { MCPTool } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Gera prompt de instruções para uso de tools via prompt engineering
|
||||
* @param tools Array de tools disponíveis
|
||||
* @returns String com instruções formatadas
|
||||
*/
|
||||
export function generateToolsPrompt(tools: MCPTool[]): string {
|
||||
if (tools.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const toolsDescription = tools
|
||||
.map((tool, index) => {
|
||||
const params = Object.entries(tool.inputSchema.properties || {})
|
||||
.map(([name, schema]: [string, any]) => {
|
||||
const required = tool.inputSchema.required?.includes(name)
|
||||
? " (required)"
|
||||
: " (optional)";
|
||||
const type = schema.type || "any";
|
||||
const desc = schema.description || "";
|
||||
return ` - ${name}: ${type}${required}${desc ? ` - ${desc}` : ""}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `${index + 1}. **${tool.name}**
|
||||
Description: ${tool.description}
|
||||
Parameters:
|
||||
${params || " (no parameters)"}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
return `
|
||||
# FERRAMENTAS DISPONÍVEIS
|
||||
|
||||
Você tem acesso às seguintes ferramentas externas para auxiliar o usuário:
|
||||
|
||||
${toolsDescription}
|
||||
|
||||
## INSTRUÇÕES IMPORTANTES
|
||||
|
||||
1. **Quando usar ferramentas:**
|
||||
- Use ferramentas quando precisar de informações em tempo real
|
||||
- Use ferramentas quando precisar executar ações externas
|
||||
- Use ferramentas quando o usuário solicitar dados específicos
|
||||
|
||||
2. **Como usar ferramentas:**
|
||||
- Para chamar uma ferramenta, responda EXATAMENTE no seguinte formato JSON:
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"tool_call": {
|
||||
"name": "nome_da_ferramenta",
|
||||
"parameters": {
|
||||
"parametro1": "valor1",
|
||||
"parametro2": "valor2"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
- NÃO adicione texto antes ou depois do JSON
|
||||
- Certifique-se de que o JSON está bem formatado
|
||||
- Use apenas ferramentas da lista acima
|
||||
|
||||
3. **REGRA CRUCIAL - NUNCA MENCIONE AO USUÁRIO:**
|
||||
- NUNCA mencione que você está usando ferramentas
|
||||
- NUNCA mencione MCPs ou APIs externas
|
||||
- NUNCA diga "vou usar a ferramenta X"
|
||||
- NUNCA explique o processo técnico de como obteve informações
|
||||
- Apresente as informações naturalmente como se fossem seu conhecimento
|
||||
|
||||
4. **Após receber resultado da ferramenta:**
|
||||
- Você receberá uma mensagem do sistema com o resultado
|
||||
- Integre o resultado naturalmente na sua resposta
|
||||
- Responda ao usuário de forma fluida e natural
|
||||
- Cite informações específicas do resultado quando relevante
|
||||
|
||||
## EXEMPLO
|
||||
|
||||
Usuário: "Qual é o clima em São Paulo?"
|
||||
|
||||
Sua resposta:
|
||||
\`\`\`json
|
||||
{
|
||||
"tool_call": {
|
||||
"name": "get_weather",
|
||||
"parameters": {
|
||||
"city": "São Paulo"
|
||||
}
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
[Sistema retorna: {"temperature": 25, "condition": "sunny"}]
|
||||
|
||||
Sua resposta final ao usuário:
|
||||
"O clima em São Paulo está ensolarado com 25°C."
|
||||
|
||||
**NÃO faça:**
|
||||
"Vou consultar a ferramenta de clima para você..."
|
||||
"Usei o get_weather e obtive..."
|
||||
"De acordo com a API..."
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera prompt condensado para economia de tokens
|
||||
* @param tools Array de tools disponíveis
|
||||
* @returns String com instruções compactas
|
||||
*/
|
||||
export function generateCompactToolsPrompt(tools: MCPTool[]): string {
|
||||
if (tools.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const toolsList = tools
|
||||
.map((tool) => {
|
||||
const params = Object.keys(tool.inputSchema.properties || {}).join(", ");
|
||||
return `${tool.name}(${params}): ${tool.description}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `
|
||||
TOOLS: ${tools.length} available
|
||||
${toolsList}
|
||||
|
||||
FORMAT: {"tool_call":{"name":"tool_name","parameters":{...}}}
|
||||
RULE: Never mention tools to user. Present results naturally.
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrai tool call de uma resposta do modelo
|
||||
* @param response Resposta do modelo
|
||||
* @returns Tool call extraído ou null
|
||||
*/
|
||||
export function extractToolCallFromResponse(response: string): {
|
||||
name: string;
|
||||
parameters: Record<string, any>;
|
||||
} | null {
|
||||
try {
|
||||
// Tentar extrair JSON da resposta
|
||||
const jsonMatch = response.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
const jsonText = jsonMatch ? jsonMatch[1] : response;
|
||||
|
||||
const parsed = JSON.parse(jsonText.trim());
|
||||
|
||||
if (parsed.tool_call && parsed.tool_call.name) {
|
||||
return {
|
||||
name: parsed.tool_call.name,
|
||||
parameters: parsed.tool_call.parameters || {},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
// Tentar extrair manualmente
|
||||
const nameMatch = response.match(/"name":\s*"([^"]+)"/);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1];
|
||||
const paramsMatch = response.match(/"parameters":\s*({[^}]+})/);
|
||||
const parameters = paramsMatch ? JSON.parse(paramsMatch[1]) : {};
|
||||
|
||||
return { name, parameters };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata resultado de tool para injetar no contexto
|
||||
* @param toolName Nome do tool
|
||||
* @param result Resultado da execução
|
||||
* @returns Mensagem formatada para o sistema
|
||||
*/
|
||||
export function formatToolResultForContext(
|
||||
toolName: string,
|
||||
result: any
|
||||
): string {
|
||||
const resultJson =
|
||||
typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
||||
|
||||
return `[TOOL RESULT - ${toolName}]
|
||||
${resultJson}
|
||||
|
||||
[END TOOL RESULT]
|
||||
|
||||
Use as informações acima para responder ao usuário de forma natural. NÃO mencione que usou uma ferramenta.`;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Serviço para detectar intenções do usuário através de palavras-chave
|
||||
*/
|
||||
|
||||
import type { DetectedIntent } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Padrões de keywords para cada tipo de intent
|
||||
*/
|
||||
const INTENT_PATTERNS = {
|
||||
web_search: {
|
||||
keywords: [
|
||||
"buscar",
|
||||
"pesquisar",
|
||||
"procurar",
|
||||
"google",
|
||||
"encontrar",
|
||||
"search",
|
||||
"look up",
|
||||
"find",
|
||||
"o que é",
|
||||
"quem é",
|
||||
"onde fica",
|
||||
],
|
||||
regex:
|
||||
/\b(buscar|pesquisar|procurar|google|encontrar|search|look up|find)\b/i,
|
||||
},
|
||||
time: {
|
||||
keywords: [
|
||||
"que horas",
|
||||
"data",
|
||||
"horário",
|
||||
"hoje",
|
||||
"agora",
|
||||
"quando",
|
||||
"calendário",
|
||||
"time",
|
||||
"date",
|
||||
"current time",
|
||||
"what time",
|
||||
],
|
||||
regex:
|
||||
/\b(que horas|data|horário|hoje|agora|quando|time|date|current time)\b/i,
|
||||
},
|
||||
weather: {
|
||||
keywords: [
|
||||
"clima",
|
||||
"tempo",
|
||||
"temperatura",
|
||||
"chuva",
|
||||
"previsão",
|
||||
"weather",
|
||||
"forecast",
|
||||
"temperature",
|
||||
],
|
||||
regex:
|
||||
/\b(clima|tempo|temperatura|chuva|previsão|weather|forecast|temperature)\b/i,
|
||||
},
|
||||
file: {
|
||||
keywords: [
|
||||
"arquivo",
|
||||
"pasta",
|
||||
"diretório",
|
||||
"file",
|
||||
"folder",
|
||||
"directory",
|
||||
"ler arquivo",
|
||||
"read file",
|
||||
"listar",
|
||||
"list",
|
||||
],
|
||||
regex:
|
||||
/\b(arquivo|pasta|diretório|file|folder|directory|ler arquivo|read file)\b/i,
|
||||
},
|
||||
code: {
|
||||
keywords: [
|
||||
"código",
|
||||
"programar",
|
||||
"executar",
|
||||
"run",
|
||||
"execute",
|
||||
"compile",
|
||||
"debug",
|
||||
"git",
|
||||
"commit",
|
||||
],
|
||||
regex:
|
||||
/\b(código|programar|executar|run|execute|compile|debug|git|commit)\b/i,
|
||||
},
|
||||
database: {
|
||||
keywords: [
|
||||
"banco de dados",
|
||||
"database",
|
||||
"query",
|
||||
"sql",
|
||||
"select",
|
||||
"insert",
|
||||
"update",
|
||||
"delete",
|
||||
],
|
||||
regex:
|
||||
/\b(banco de dados|database|query|sql|select|insert|update|delete)\b/i,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Detecta a intenção do usuário na mensagem
|
||||
* @param message Mensagem do usuário
|
||||
* @returns Intent detectado ou null
|
||||
*/
|
||||
export function detectIntent(message: string): DetectedIntent | null {
|
||||
const messageLower = message.toLowerCase();
|
||||
|
||||
for (const [type, pattern] of Object.entries(INTENT_PATTERNS)) {
|
||||
const matches = pattern.regex.test(messageLower);
|
||||
|
||||
if (matches) {
|
||||
// Calcular confiança baseado em quantas keywords foram encontradas
|
||||
const foundKeywords = pattern.keywords.filter((keyword) =>
|
||||
messageLower.includes(keyword.toLowerCase())
|
||||
);
|
||||
|
||||
const confidence = Math.min(0.5 + foundKeywords.length * 0.2, 1.0);
|
||||
|
||||
return {
|
||||
type: type as DetectedIntent["type"],
|
||||
confidence,
|
||||
keywords: foundKeywords,
|
||||
suggestedTools: getSuggestedToolsForIntent(
|
||||
type as DetectedIntent["type"]
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna tools sugeridos para um tipo de intent
|
||||
* @param intentType Tipo de intent
|
||||
* @returns Array de nomes de tools sugeridos
|
||||
*/
|
||||
function getSuggestedToolsForIntent(
|
||||
intentType: DetectedIntent["type"]
|
||||
): string[] {
|
||||
const toolMapping: Record<DetectedIntent["type"], string[]> = {
|
||||
web_search: ["web_search", "duckduckgo_search", "google_search", "search"],
|
||||
time: ["get_current_time", "get_date", "current_time", "time"],
|
||||
weather: ["get_weather", "weather", "forecast"],
|
||||
file: ["read_file", "list_files", "filesystem", "file"],
|
||||
code: ["execute_code", "git", "run_command"],
|
||||
database: ["query", "sql", "database"],
|
||||
other: [],
|
||||
};
|
||||
|
||||
return toolMapping[intentType] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um tool name corresponde a algum intent detectado
|
||||
* @param toolName Nome do tool
|
||||
* @param intent Intent detectado
|
||||
* @returns true se o tool é relevante para o intent
|
||||
*/
|
||||
export function isToolRelevantForIntent(
|
||||
toolName: string,
|
||||
intent: DetectedIntent
|
||||
): boolean {
|
||||
const toolNameLower = toolName.toLowerCase();
|
||||
return intent.suggestedTools.some((suggested) =>
|
||||
toolNameLower.includes(suggested.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filtra tools com base em um intent detectado
|
||||
* @param tools Array de tools disponíveis
|
||||
* @param intent Intent detectado
|
||||
* @returns Tools filtrados e ordenados por relevância
|
||||
*/
|
||||
export function filterToolsByIntent(
|
||||
tools: Array<{ name: string; description: string }>,
|
||||
intent: DetectedIntent
|
||||
): Array<{ name: string; description: string; relevance: number }> {
|
||||
return tools
|
||||
.map((tool) => {
|
||||
let relevance = 0;
|
||||
|
||||
// Verificar se o tool name contém alguma keyword sugerida
|
||||
const toolNameLower = tool.name.toLowerCase();
|
||||
for (const suggested of intent.suggestedTools) {
|
||||
if (toolNameLower.includes(suggested.toLowerCase())) {
|
||||
relevance += 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se a descrição contém keywords do intent
|
||||
const descriptionLower = tool.description.toLowerCase();
|
||||
for (const keyword of intent.keywords) {
|
||||
if (descriptionLower.includes(keyword.toLowerCase())) {
|
||||
relevance += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...tool, relevance };
|
||||
})
|
||||
.filter((tool) => tool.relevance > 0)
|
||||
.sort((a, b) => b.relevance - a.relevance);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Serviço executor de MCPs - spawna processos e executa tools via JSON-RPC
|
||||
*/
|
||||
|
||||
import { spawn, type ChildProcess } from "child_process";
|
||||
import { MCPRepository } from "@/database/repositories/mcp";
|
||||
import type { MCPExecutionResult, MCPTool } from "@/lib/types/mcp-chat";
|
||||
|
||||
const EXECUTION_TIMEOUT = 30000; // 30 segundos
|
||||
|
||||
export class MCPExecutor {
|
||||
/**
|
||||
* Executa um tool de MCP
|
||||
* @param mcpId ID do MCP instalado
|
||||
* @param toolName Nome do tool a executar
|
||||
* @param parameters Parâmetros do tool
|
||||
* @returns Resultado da execução
|
||||
*/
|
||||
static async executeMCPTool(
|
||||
mcpId: string,
|
||||
toolName: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<MCPExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Obter dados do MCP instalado
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData) {
|
||||
throw new Error(`MCP ${mcpId} not found or not installed`);
|
||||
}
|
||||
|
||||
// Verificar se está pronto
|
||||
if (mcpData.status !== "ready") {
|
||||
throw new Error(
|
||||
`MCP ${mcpId} is not ready (status: ${mcpData.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Obter comando executável
|
||||
const config = JSON.parse(mcpData.config);
|
||||
const command = config.command || mcpData.executable_command;
|
||||
const args = config.args || [];
|
||||
|
||||
if (!command) {
|
||||
throw new Error(`No executable command found for MCP ${mcpId}`);
|
||||
}
|
||||
|
||||
console.log(`🚀 Executing MCP tool: ${mcpId}.${toolName}`);
|
||||
console.log(`Command: ${command} ${args.join(" ")}`);
|
||||
|
||||
// Spawnar processo do MCP
|
||||
const result = await this.spawnMCPProcess(
|
||||
command,
|
||||
args,
|
||||
toolName,
|
||||
parameters,
|
||||
mcpData.environment_path
|
||||
);
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
toolName,
|
||||
mcpId,
|
||||
result,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
console.error(`❌ Error executing MCP tool ${mcpId}.${toolName}:`, error);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
toolName,
|
||||
mcpId,
|
||||
error: error.message || "Unknown error",
|
||||
executionTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawna processo do MCP e executa JSON-RPC
|
||||
* @param command Comando executável
|
||||
* @param args Argumentos do comando
|
||||
* @param toolName Nome do tool
|
||||
* @param parameters Parâmetros do tool
|
||||
* @param environmentPath Caminho do ambiente (opcional)
|
||||
* @returns Resultado do tool
|
||||
*/
|
||||
private static async spawnMCPProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
toolName: string,
|
||||
parameters: Record<string, any>,
|
||||
environmentPath?: string
|
||||
): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env = {
|
||||
...process.env,
|
||||
...(environmentPath && {
|
||||
PATH: `${environmentPath}/node_modules/.bin:${process.env.PATH}`,
|
||||
}),
|
||||
};
|
||||
|
||||
const process = spawn(command, args, {
|
||||
cwd: environmentPath || process.cwd(),
|
||||
env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let responseReceived = false;
|
||||
|
||||
// Timeout
|
||||
const timeout = setTimeout(() => {
|
||||
if (!responseReceived) {
|
||||
process.kill();
|
||||
reject(new Error(`Execution timeout after ${EXECUTION_TIMEOUT}ms`));
|
||||
}
|
||||
}, EXECUTION_TIMEOUT);
|
||||
|
||||
// Capturar stdout
|
||||
process.stdout?.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
|
||||
// Tentar parsear respostas JSON-RPC
|
||||
const lines = stdout.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line);
|
||||
if (parsed.id === 2 && parsed.result) {
|
||||
// Resposta do tools/call
|
||||
clearTimeout(timeout);
|
||||
responseReceived = true;
|
||||
process.kill();
|
||||
resolve(parsed.result);
|
||||
}
|
||||
} catch (e) {
|
||||
// Linha não é JSON válido, continuar
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Capturar stderr
|
||||
process.stderr?.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
// Erro de processo
|
||||
process.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Process error: ${error.message}`));
|
||||
});
|
||||
|
||||
// Processo encerrou
|
||||
process.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (!responseReceived) {
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(`Process exited with code ${code}. stderr: ${stderr}`)
|
||||
);
|
||||
} else {
|
||||
reject(new Error("No valid response received from MCP"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Enviar requisições JSON-RPC
|
||||
try {
|
||||
// 1. Initialize
|
||||
const initRequest = {
|
||||
jsonrpc: "2.0",
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "ollahub", version: "1.0.0" },
|
||||
},
|
||||
id: 1,
|
||||
};
|
||||
process.stdin?.write(JSON.stringify(initRequest) + "\n");
|
||||
|
||||
// Aguardar um pouco antes de enviar tool call
|
||||
setTimeout(() => {
|
||||
// 2. Call tool
|
||||
const toolCallRequest = {
|
||||
jsonrpc: "2.0",
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: toolName,
|
||||
arguments: parameters,
|
||||
},
|
||||
id: 2,
|
||||
};
|
||||
process.stdin?.write(JSON.stringify(toolCallRequest) + "\n");
|
||||
process.stdin?.end();
|
||||
}, 500);
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeout);
|
||||
process.kill();
|
||||
reject(new Error(`Failed to send JSON-RPC request: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tools disponíveis de um MCP
|
||||
* @param mcpId ID do MCP
|
||||
* @returns Array de tools
|
||||
*/
|
||||
static getAvailableTools(mcpId: string): MCPTool[] {
|
||||
try {
|
||||
const mcpData = MCPRepository.getInstalledMCP(mcpId);
|
||||
if (!mcpData || !mcpData.tools) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Se já é um array (já foi parseado), retornar diretamente
|
||||
if (Array.isArray(mcpData.tools)) {
|
||||
return mcpData.tools;
|
||||
}
|
||||
|
||||
// Se é string, parsear
|
||||
if (typeof mcpData.tools === "string") {
|
||||
const tools = JSON.parse(mcpData.tools);
|
||||
return Array.isArray(tools) ? tools : [];
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`Error getting available tools for ${mcpId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar parâmetros contra schema do tool
|
||||
* @param parameters Parâmetros fornecidos
|
||||
* @param tool Definição do tool
|
||||
* @returns true se válido, false caso contrário
|
||||
*/
|
||||
static validateParameters(
|
||||
parameters: Record<string, any>,
|
||||
tool: MCPTool
|
||||
): { valid: boolean; error?: string } {
|
||||
try {
|
||||
const schema = tool.inputSchema;
|
||||
|
||||
// Verificar campos obrigatórios
|
||||
if (schema.required) {
|
||||
for (const required of schema.required) {
|
||||
if (!(required in parameters)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Missing required parameter: ${required}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validação básica de tipos
|
||||
for (const [key, value] of Object.entries(parameters)) {
|
||||
if (schema.properties && schema.properties[key]) {
|
||||
const propSchema = schema.properties[key];
|
||||
const expectedType = propSchema.type;
|
||||
|
||||
if (expectedType) {
|
||||
const actualType = typeof value;
|
||||
const valid =
|
||||
(expectedType === "string" && actualType === "string") ||
|
||||
(expectedType === "number" && actualType === "number") ||
|
||||
(expectedType === "boolean" && actualType === "boolean") ||
|
||||
(expectedType === "object" && actualType === "object") ||
|
||||
(expectedType === "array" && Array.isArray(value));
|
||||
|
||||
if (!valid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Parameter ${key} should be ${expectedType}, got ${actualType}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
} catch (error: any) {
|
||||
return { valid: false, error: error.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Serviço para detectar capabilities de function calling dos modelos
|
||||
*/
|
||||
|
||||
import type { ModelCapabilities } from "@/lib/types/mcp-chat";
|
||||
|
||||
/**
|
||||
* Lista de modelos com suporte nativo a function calling
|
||||
* Baseado na documentação do Ollama (atualizado em nov 2024)
|
||||
*/
|
||||
const MODELS_WITH_NATIVE_TOOLS = [
|
||||
// Llama 3.x
|
||||
/^llama3\.1/i,
|
||||
/^llama3\.2/i,
|
||||
/^llama3-/i,
|
||||
|
||||
// Mistral
|
||||
/^mistral/i,
|
||||
/^mistral-nemo/i,
|
||||
/^mixtral/i,
|
||||
|
||||
// Gemma
|
||||
/^gemma2/i,
|
||||
|
||||
// Qwen
|
||||
/^qwen2\.5/i,
|
||||
|
||||
// Command-R
|
||||
/^command-r/i,
|
||||
|
||||
// Firefunction
|
||||
/^firefunction/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Detecta capabilities de function calling de um modelo
|
||||
* @param modelName Nome do modelo (ex: "llama3.1:8b", "qwen2.5:7b")
|
||||
* @returns Capabilities do modelo
|
||||
*/
|
||||
export function detectModelCapabilities(modelName: string): ModelCapabilities {
|
||||
const supportsNativeTools = MODELS_WITH_NATIVE_TOOLS.some((pattern) =>
|
||||
pattern.test(modelName)
|
||||
);
|
||||
|
||||
return {
|
||||
modelName,
|
||||
supportsNativeTools,
|
||||
requiresPromptEngineering: !supportsNativeTools,
|
||||
toolCallFormat: supportsNativeTools ? "openai" : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um modelo específico suporta function calling nativo
|
||||
* @param modelName Nome do modelo
|
||||
* @returns true se suporta, false caso contrário
|
||||
*/
|
||||
export function supportsNativeTools(modelName: string): boolean {
|
||||
return detectModelCapabilities(modelName).supportsNativeTools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os modelos conhecidos com suporte a function calling
|
||||
* @returns Array de padrões regex em string
|
||||
*/
|
||||
export function getSupportedModelsPatterns(): string[] {
|
||||
return [
|
||||
"llama3.1:*",
|
||||
"llama3.2:*",
|
||||
"mistral:*",
|
||||
"mixtral:*",
|
||||
"gemma2:*",
|
||||
"qwen2.5:*",
|
||||
"command-r:*",
|
||||
"firefunction:*",
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tipos e interfaces para integração de MCPs no chat
|
||||
*/
|
||||
|
||||
/**
|
||||
* Representa um tool MCP com schema JSON
|
||||
*/
|
||||
export interface MCPTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: {
|
||||
type: "object";
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Contexto de MCPs ativos para a conversa
|
||||
*/
|
||||
export interface MCPChatContext {
|
||||
mcpId: string;
|
||||
enabled: boolean;
|
||||
tools: MCPTool[];
|
||||
config: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resultado da execução de um MCP
|
||||
*/
|
||||
export interface MCPExecutionResult {
|
||||
success: boolean;
|
||||
toolName: string;
|
||||
mcpId: string;
|
||||
result?: any;
|
||||
error?: string;
|
||||
executionTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capabilities de function calling do modelo
|
||||
*/
|
||||
export interface ModelCapabilities {
|
||||
supportsNativeTools: boolean;
|
||||
requiresPromptEngineering: boolean;
|
||||
modelName: string;
|
||||
toolCallFormat?: "openai" | "anthropic" | "custom";
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuração de MCPs para chat
|
||||
*/
|
||||
export interface MCPChatConfig {
|
||||
alwaysActive: string[]; // MCPs sempre ativos
|
||||
neverUse: string[]; // MCPs nunca usar
|
||||
activationMode: "automatic" | "keywords" | "manual";
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent detectado na mensagem do usuário
|
||||
*/
|
||||
export interface DetectedIntent {
|
||||
type:
|
||||
| "web_search"
|
||||
| "time"
|
||||
| "weather"
|
||||
| "file"
|
||||
| "code"
|
||||
| "database"
|
||||
| "other";
|
||||
confidence: number;
|
||||
keywords: string[];
|
||||
suggestedTools: string[];
|
||||
}
|
||||
Reference in new issue
Block a user