diff --git a/app/api/mcp/categories/route.ts b/app/api/mcp/categories/route.ts new file mode 100644 index 0000000..0d51000 --- /dev/null +++ b/app/api/mcp/categories/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { MCPCacheRepository } from "@/database/repositories/mcp-cache"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/mcp/categories - Obter categorias dinâmicas do cache + */ +export async function GET(request: Request) { + try { + console.log("📊 Fetching dynamic categories from cache..."); + + // Verificar se cache existe e tem dados + const cacheExists = MCPCacheRepository.tableExists("mcp_marketplace_cache"); + if (!cacheExists) { + return NextResponse.json({ + success: false, + error: "Cache not initialized", + categories: { + primary: [], + others: [], + }, + }); + } + + // Obter categorias dinâmicas + const categories = MCPCacheRepository.getCategories(); + const stats = MCPCacheRepository.getCategoryStats(); + + // Verificar se há dados no cache + if (stats.totalMCPs === 0) { + return NextResponse.json({ + success: false, + error: "No MCPs found in cache", + categories: { + primary: [], + others: [], + }, + stats, + }); + } + + console.log( + `📊 Categories extracted: ${categories.primary.length} primary, ${categories.others.length} others` + ); + + return NextResponse.json({ + success: true, + categories, + stats, + lastSync: MCPCacheRepository.getLastSync(), + cacheValid: MCPCacheRepository.isCacheValid(), + }); + } catch (error: any) { + console.error("Error fetching categories:", error); + return NextResponse.json( + { + success: false, + error: error.message || "Failed to fetch categories", + categories: { + primary: [], + others: [], + }, + }, + { status: 500 } + ); + } +} diff --git a/app/api/mcp/install/route.ts b/app/api/mcp/install/route.ts new file mode 100644 index 0000000..cd1ffe7 --- /dev/null +++ b/app/api/mcp/install/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server"; +import { MCPInstallationStatus } from "@/lib/types/mcp"; +import { DeepNLPService } from "@/lib/services/deepnlp"; +import { MCPRepository } from "@/database/repositories/mcp"; + +export async function POST(request: Request) { + try { + const { mcpId } = await request.json(); + + if (!mcpId) { + return NextResponse.json( + { success: false, error: "MCP ID is required" }, + { status: 400 } + ); + } + + console.log(`Installing MCP: ${mcpId}`); + + // Verificar se já está instalado + if (MCPRepository.isInstalled(mcpId)) { + return NextResponse.json({ + success: false, + error: `MCP ${mcpId} is already installed`, + }, { status: 400 }); + } + + // Obter configuração completa do servidor + console.log(`Fetching server config for ${mcpId}`); + const serverConfig = await DeepNLPService.getServerConfig(mcpId); + + if (!serverConfig.items || serverConfig.items.length === 0) { + throw new Error(`No configuration found for MCP ${mcpId}`); + } + + // Obter ferramentas disponíveis + console.log(`Fetching server tools for ${mcpId}`); + const tools = await DeepNLPService.getServerTools(mcpId); + + // Salvar configuração localmente + console.log(`Saving MCP ${mcpId} to local database`); + MCPRepository.saveInstallation(mcpId, serverConfig.items[0], tools); + + const result: MCPInstallationStatus = { + success: true, + message: `MCP ${mcpId} installed successfully`, + mcpId, + }; + + return NextResponse.json({ + success: true, + installation: result, + config: serverConfig.items[0], + tools: tools, + }); + } catch (error: any) { + console.error("Error installing MCP:", error); + return NextResponse.json( + { success: false, error: error.message || "Failed to install MCP" }, + { status: 500 } + ); + } +} diff --git a/app/api/mcp/list/route.ts b/app/api/mcp/list/route.ts new file mode 100644 index 0000000..96424ee --- /dev/null +++ b/app/api/mcp/list/route.ts @@ -0,0 +1,158 @@ +import { NextResponse } from "next/server"; +import { + MCPProvider, + MCPSearchParams, + DeepNLPSearchParams, +} from "@/lib/types/mcp"; +import { DeepNLPService } from "@/lib/services/deepnlp"; +import { MCPRepository } from "@/database/repositories/mcp"; +import { MCPCacheRepository } from "@/database/repositories/mcp-cache"; +import { MCPSyncService } from "@/lib/services/mcp-sync"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get("category") || undefined; + const search = searchParams.get("search") || undefined; + const sort = + (searchParams.get("sort") as MCPSearchParams["sort"]) || "rating"; + const order = (searchParams.get("order") as "asc" | "desc") || "desc"; + const page = parseInt(searchParams.get("page") || "0"); + const limit = parseInt(searchParams.get("limit") || "2000"); // Aumentado para 2000 + + console.log("MCP List API called with params:", { + category, + search, + sort, + order, + page, + limit, + }); + + // 1. Verificar se cache do DB é válido + const cacheValid = MCPCacheRepository.isCacheValid(); + + if (!cacheValid) { + // Iniciar sincronização em background se necessário + if (!MCPSyncService.isSyncing()) { + console.log("Cache invalid, starting background sync..."); + MCPSyncService.backgroundSync().catch(console.error); + } + } + + // 2. Tentar buscar do banco de dados primeiro + const cachedMcps = MCPCacheRepository.search({ + query: search, + category: category !== "all" ? category : undefined, + limit, + offset: page * limit, + sortBy: sort, + order: order, + }); + + if (cachedMcps.length > 0 || cacheValid) { + console.log(`Found ${cachedMcps.length} MCPs in cache`); + + // Transformar para formato interno + const mcps = cachedMcps.map((item) => + DeepNLPService.transformToMCPProvider(item) + ); + + // Adicionar status de instalação + const installedMCPs = MCPRepository.listInstalled(); + const mcpsWithStatus = mcps.map((mcp) => ({ + ...mcp, + installed: installedMCPs.includes(mcp.id), + })); + + const total = MCPCacheRepository.count({ + category: category !== "all" ? category : undefined, + }); + + return NextResponse.json({ + success: true, + mcps: mcpsWithStatus, + total, + hasMore: (page + 1) * limit < total, + cached: true, + lastSync: MCPCacheRepository.getLastSync(), + }); + } + + // 3. Fallback: buscar da API se cache vazio + console.log("Cache empty, falling back to DeepNLP API"); + + const deepnlpCategory = + category && category !== "all" + ? DeepNLPService.getCategoryMapping(category) + : undefined; + + const deepnlpParams: DeepNLPSearchParams = { + query: search, + category: deepnlpCategory, + page_id: page, + count_per_page: limit, + mode: "list", + }; + + console.log("Calling DeepNLP API with params:", deepnlpParams); + + const response = await DeepNLPService.searchMCPs(deepnlpParams); + const mcps = response.items.map((item) => + DeepNLPService.transformToMCPProvider(item) + ); + + // Adicionar status de instalação + const installedMCPs = MCPRepository.listInstalled(); + const mcpsWithStatus = mcps.map((mcp) => ({ + ...mcp, + installed: installedMCPs.includes(mcp.id), + })); + + return NextResponse.json({ + success: true, + mcps: mcpsWithStatus, + total: response.total_hits, + hasMore: response.total_hits > (page + 1) * limit, + cached: false, + }); + } catch (error: any) { + console.error("Error fetching MCPs from DeepNLP:", error); + + // Fallback para dados mock em caso de erro + console.log("Falling back to mock data due to API error"); + + const mockMCPs: MCPProvider[] = [ + { + id: "google-maps/google-maps", + name: "Google Maps", + author: "Google", + description: + "Google Maps Location services, directions, and place details", + version: "1.0.0", + category: "map", + tags: ["maps", "location", "directions"], + rating: 4.5, + totalRatings: 2, + downloads: 20, + capabilities: ["geocoding", "directions", "places"], + repository: + "https://github.com/modelcontextprotocol/servers/tree/main/src/google-maps", + installed: false, + subfield: "MAP", + field: "MCP SERVER", + }, + ]; + + return NextResponse.json({ + success: true, + mcps: mockMCPs, + total: mockMCPs.length, + hasMore: false, + fallback: true, + error: error.message, + }); + } +} diff --git a/app/api/mcp/sync/route.ts b/app/api/mcp/sync/route.ts new file mode 100644 index 0000000..e8a6bcc --- /dev/null +++ b/app/api/mcp/sync/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { MCPSyncService } from "@/lib/services/mcp-sync"; +import { MCPCacheRepository } from "@/database/repositories/mcp-cache"; + +export const dynamic = "force-dynamic"; + +/** + * POST /api/mcp/sync - Iniciar sincronização manual + */ +export async function POST(request: Request) { + try { + if (MCPSyncService.isSyncing()) { + return NextResponse.json({ + success: false, + error: "Sync already in progress" + }, { status: 409 }); + } + + console.log("🔄 Starting manual MCP sync..."); + + const result = await MCPSyncService.syncAll((progress) => { + console.log(`📥 Progress: ${progress.current}/${progress.total} (${progress.page}/${progress.totalPages} pages)`); + }); + + return NextResponse.json({ + success: result.success, + totalDownloaded: result.totalDownloaded, + errors: result.errors, + duration: result.duration + }); + } catch (error: any) { + console.error("Manual sync failed:", error); + return NextResponse.json( + { success: false, error: error.message || "Failed to sync MCPs" }, + { status: 500 } + ); + } +} + +/** + * GET /api/mcp/sync - Obter status da sincronização + */ +export async function GET(request: Request) { + try { + const stats = MCPCacheRepository.getStats(); + const healthCheck = await MCPSyncService.healthCheck(); + + return NextResponse.json({ + isSyncing: MCPSyncService.isSyncing(), + lastSync: stats.lastSync, + totalCached: stats.totalItems, + needsSync: MCPCacheRepository.needsSync(), + cacheValid: stats.isValid, + categories: stats.categories, + healthCheck + }); + } catch (error: any) { + console.error("Error getting sync status:", error); + return NextResponse.json( + { success: false, error: error.message || "Failed to get sync status" }, + { status: 500 } + ); + } +} + +/** + * DELETE /api/mcp/sync - Cancelar sincronização atual + */ +export async function DELETE(request: Request) { + try { + if (!MCPSyncService.isSyncing()) { + return NextResponse.json({ + success: false, + error: "No sync in progress" + }, { status: 400 }); + } + + MCPSyncService.cancelSync(); + + return NextResponse.json({ + success: true, + message: "Sync cancelled successfully" + }); + } catch (error: any) { + console.error("Error cancelling sync:", error); + return NextResponse.json( + { success: false, error: error.message || "Failed to cancel sync" }, + { status: 500 } + ); + } +} diff --git a/app/api/mcp/uninstall/route.ts b/app/api/mcp/uninstall/route.ts new file mode 100644 index 0000000..d4d9f1b --- /dev/null +++ b/app/api/mcp/uninstall/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { MCPInstallationStatus } from "@/lib/types/mcp"; +import { MCPRepository } from "@/database/repositories/mcp"; + +export async function DELETE(request: Request) { + try { + const { mcpId } = await request.json(); + + if (!mcpId) { + return NextResponse.json( + { success: false, error: "MCP ID is required" }, + { status: 400 } + ); + } + + console.log(`Uninstalling MCP: ${mcpId}`); + + // Verificar se está instalado + if (!MCPRepository.isInstalled(mcpId)) { + return NextResponse.json({ + success: false, + error: `MCP ${mcpId} is not installed`, + }, { status: 400 }); + } + + // Remover do banco local + MCPRepository.removeInstallation(mcpId); + + const result: MCPInstallationStatus = { + success: true, + message: `MCP ${mcpId} uninstalled successfully`, + mcpId, + }; + + return NextResponse.json({ + success: true, + installation: result, + }); + } catch (error: any) { + console.error("Error uninstalling MCP:", error); + return NextResponse.json( + { success: false, error: error.message || "Failed to uninstall MCP" }, + { status: 500 } + ); + } +} diff --git a/app/api/ollama/delete/route.ts b/app/api/ollama/delete/route.ts new file mode 100644 index 0000000..dd4a3f7 --- /dev/null +++ b/app/api/ollama/delete/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { deleteModel } from "@/lib/ollama"; + +export async function DELETE(request: NextRequest) { + try { + const { modelName } = await request.json(); + + if (!modelName) { + return NextResponse.json( + { error: "Model name is required" }, + { status: 400 } + ); + } + + await deleteModel(modelName); + + return NextResponse.json({ + success: true, + message: `Model ${modelName} deleted successfully`, + }); + } catch (error) { + console.error("Error deleting model:", error); + return NextResponse.json( + { error: "Failed to delete model" }, + { status: 500 } + ); + } +} diff --git a/app/api/ollama/search/route.ts b/app/api/ollama/search/route.ts new file mode 100644 index 0000000..302e1d3 --- /dev/null +++ b/app/api/ollama/search/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { searchRemoteModels } from "@/lib/ollama"; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const query = searchParams.get("q") || undefined; + + const remoteModels = await searchRemoteModels(query); + + return NextResponse.json({ + success: true, + models: remoteModels, + }); + } catch (error) { + console.error("Error searching remote models:", error); + return NextResponse.json( + { error: "Failed to search remote models" }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css index 9b92498..10086ed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -31,3 +31,134 @@ body { color: var(--foreground); font-family: var(--font-inter), ui-sans-serif, system-ui; } + +/* Custom scrollbar para o seletor de modelos */ +.custom-scrollbar { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background: var(--foreground); + opacity: 0.3; +} + +/* MCP Marketplace Styles */ +.mcp-card { + transition: all 0.2s ease-in-out; +} + +.mcp-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); +} + +.mcp-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +@media (max-width: 768px) { + .mcp-grid { + grid-template-columns: 1fr; + } +} + +.mcp-modal { + backdrop-filter: blur(8px); +} + +.mcp-category-badge { + transition: all 0.2s ease-in-out; +} + +.mcp-category-badge:hover { + transform: scale(1.05); +} + +.mcp-rating-star { + transition: color 0.2s ease-in-out; +} + +.mcp-rating-star:hover { + color: #fbbf24; +} + +.mcp-install-button { + transition: all 0.2s ease-in-out; +} + +.mcp-install-button:hover { + transform: scale(1.02); +} + +.mcp-install-button:active { + transform: scale(0.98); +} + +/* Loading animation for MCP cards */ +.mcp-loading { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* MCP detail modal animations */ +.mcp-detail-modal { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: scale(0.95) translateY(-20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* MCP search bar focus effects */ +.mcp-search-input:focus { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); +} + +/* MCP category filter active state */ +.mcp-category-active { + background: linear-gradient(135deg, var(--primary), var(--primary)); + color: var(--primary-foreground); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); +} + +/* MCP tag hover effects */ +.mcp-tag { + transition: all 0.2s ease-in-out; +} + +.mcp-tag:hover { + background: var(--primary); + color: var(--primary-foreground); + transform: translateY(-1px); +} diff --git a/components/ClientHome.tsx b/components/ClientHome.tsx index 0e6b80c..dc88f5d 100644 --- a/components/ClientHome.tsx +++ b/components/ClientHome.tsx @@ -8,6 +8,8 @@ import { SettingsButton, SettingsModal, } from "@/components/settings/SettingsModal"; +import { MCPMarketplaceModal } from "@/components/mcp/MCPMarketplaceModal"; +import { Store } from "lucide-react"; interface ClientHomeProps { offline: boolean; @@ -20,6 +22,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) { const [currentConversationId, setCurrentConversationId] = useState< string | null >(null); + const [showMCPMarketplace, setShowMCPMarketplace] = useState(false); const handleConversationCreated = () => { // Incrementar trigger para forçar atualização da sidebar @@ -45,6 +48,13 @@ export function ClientHome({ offline, models }: ClientHomeProps) { Ollama offline )} + setOpen(true)} /> @@ -67,6 +77,10 @@ export function ClientHome({ offline, models }: ClientHomeProps) { setOpen(false)} /> + setShowMCPMarketplace(false)} + /> ); } diff --git a/components/chat/ChatContainer.tsx b/components/chat/ChatContainer.tsx index ce6a595..5f3c976 100644 --- a/components/chat/ChatContainer.tsx +++ b/components/chat/ChatContainer.tsx @@ -278,6 +278,26 @@ export function ChatContainer({ } }; + 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", { @@ -340,6 +360,8 @@ export function ChatContainer({ models={models} selectedModel={selectedModel} onSelectModel={handleSelectModel} + onDeleteModel={handleDeleteModel} + onPullModel={handlePullModel} disabled={isStreaming} /> )} diff --git a/components/mcp/MCPCard.tsx b/components/mcp/MCPCard.tsx new file mode 100644 index 0000000..3271b81 --- /dev/null +++ b/components/mcp/MCPCard.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { Package, Star, Download, Eye, ExternalLink, CheckCircle, Zap } from "lucide-react"; +import { MCPProvider } from "@/lib/types/mcp"; + +interface MCPCardProps { + mcp: MCPProvider; + onInstall: () => void; + onUninstall: () => void; + onViewDetails: () => void; +} + +export function MCPCard({ mcp, onInstall, onUninstall, onViewDetails }: 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 || mcp.capabilities?.length || 0; + + return ( +
+ {/* Header */} +
+
+ {mcp.iconUrl ? ( + {mcp.name} { + e.currentTarget.style.display = 'none'; + e.currentTarget.nextElementSibling?.classList.remove('hidden'); + }} + /> + ) : null} +
+ +
+
+
+

{mcp.name}

+ {isOfficial && ( + + )} +
+

by {mcp.author}

+
+
+ + {mcp.category} + +
+ + {/* Description */} +

+ {mcp.description} +

+ + {/* Stats */} +
+
+ + {mcp.rating} + ({mcp.totalRatings}) +
+
+ + {formatDownloads(mcp.downloads)} +
+ {toolCount > 0 && ( +
+ + {toolCount} tools +
+ )} + + v{mcp.version} + +
+ + {/* Tags */} +
+ {mcp.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {mcp.tags.length > 3 && ( + + +{mcp.tags.length - 3} + + )} +
+ + {/* Actions */} +
+ + + {mcp.installed ? ( + + ) : ( + + )} +
+ + {/* Links */} + {(mcp.repository || mcp.homepage) && ( +
+
+ {mcp.repository && ( + + + Repository + + )} + {mcp.homepage && ( + + + Homepage + + )} +
+
+ )} +
+ ); +} diff --git a/components/mcp/MCPCategoryFilter.tsx b/components/mcp/MCPCategoryFilter.tsx new file mode 100644 index 0000000..d5d01fc --- /dev/null +++ b/components/mcp/MCPCategoryFilter.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +interface MCPCategory { + id: string; + name: string; + icon: string; + count: number; +} + +interface MCPCategoryFilterProps { + primaryCategories: MCPCategory[]; + otherCategories: MCPCategory[]; + selectedCategory: string; + onSelectCategory: (categoryId: string) => void; +} + +export function MCPCategoryFilter({ + primaryCategories, + otherCategories, + selectedCategory, + onSelectCategory, +}: MCPCategoryFilterProps) { + const [showOthers, setShowOthers] = useState(false); + + const getCategoryIcon = (categoryName: string) => { + const iconMap: Record = { + MAP: "🗺️", + BROWSER: "🌐", + OFFICE: "📄", + CODE: "💻", + DATABASE: "🗄️", + SEARCH: "🔍", + PAYMENT: "💳", + CHART: "📊", + FINANCE: "💰", + OTHER: "📦", + }; + return iconMap[categoryName.toUpperCase()] || "📦"; + }; + + return ( +
+

+ Categories +

+ + {/* All Categories */} + + + {/* Primary Categories */} +
+

+ Principais +

+ {primaryCategories.map((category) => ( + + ))} +
+ + {/* Other Categories */} + {otherCategories.length > 0 && ( +
+ + + {showOthers && ( +
+ {otherCategories.map((category) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/components/mcp/MCPDetailModal.tsx b/components/mcp/MCPDetailModal.tsx new file mode 100644 index 0000000..5e10682 --- /dev/null +++ b/components/mcp/MCPDetailModal.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState } from "react"; +import { X, Package, Star, Download, 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 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 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 ( +
+
+ +
+ {/* Header */} +
+
+
+ +
+
+

{mcp.name}

+

by {mcp.author}

+
+ + {mcp.category} + + {mcp.installed && ( +
+ + Installed +
+ )} +
+ +
+ + {/* Stats */} +
+
+
+ + + ({mcp.totalRatings} reviews) + +
+
+ + {formatDownloads(mcp.downloads)} downloads +
+ Version {mcp.version} +
+
+ + {/* Tabs */} +
+ {[ + { id: "about", label: "About" }, + { id: "capabilities", label: "Capabilities" }, + { id: "configuration", label: "Configuration" }, + { id: "screenshots", label: "Screenshots" }, + ].map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {activeTab === "about" && ( +
+
+

Description

+

+ {mcp.description} +

+
+ +
+

Tags

+
+ {mcp.tags.map((tag) => ( + + {tag} + + ))} +
+
+ + {(mcp.repository || mcp.homepage) && ( +
+

Links

+
+ {mcp.repository && ( + + + Repository + + )} + {mcp.homepage && ( + + + Homepage + + )} +
+
+ )} +
+ )} + + {activeTab === "capabilities" && ( +
+

Capabilities

+
+ {(mcp.tools || mcp.capabilities || []).map((capability, index) => ( +
+ + + {typeof capability === 'string' ? capability : (capability as any).name || String(capability)} + +
+ ))} +
+
+ )} + + {activeTab === "configuration" && ( +
+
+

Installation Configuration

+ {mcp.config && mcp.config.length > 0 ? ( +
+ {mcp.config.map((configItem, index) => ( +
+

Configuration {index + 1}

+
+ {configItem.mcpServers && ( +
+
Command
+ + {configItem.mcpServers.command} {configItem.mcpServers.arguments?.join(' ')} + +
+ )} + {configItem.mcpServers?.env && Object.keys(configItem.mcpServers.env).length > 0 && ( +
+
Environment Variables
+
+ {Object.entries(configItem.mcpServers.env).map(([key, value]) => ( +
+ + {key}={String(value)} + +
+ ))} +
+
+ )} +
+
+ ))} +
+ ) : ( +
+ +

No configuration available for this MCP

+
+ )} +
+ + {mcp.tools && mcp.tools.length > 0 && ( +
+

Available Tools

+
+ {mcp.tools.map((tool, index) => ( +
+

{tool.name}

+

{tool.description}

+ {tool.input_schema && ( +
+
Parameters
+
+ {Object.entries(tool.input_schema.properties || {}).map(([param, schema]) => ( +
+ + {param} + + + {typeof schema === 'object' && schema ? (schema as any).type || 'any' : 'any'} + {tool.input_schema.required?.includes(param) && ( + * + )} + +
+ ))} +
+
+ )} +
+ ))} +
+
+ )} +
+ )} + + {activeTab === "screenshots" && ( +
+

Screenshots

+ {mcp.screenshotUrls && mcp.screenshotUrls.length > 0 ? ( +
+ {mcp.screenshotUrls.map((url, index) => ( + {`Screenshot + ))} +
+ ) : ( +
+ +

No screenshots available

+
+ )} +
+ )} +
+ + {/* Footer */} +
+
+ + {!mcp.installed && ( + + )} +
+
+
+
+ ); +} diff --git a/components/mcp/MCPMarketplaceModal.tsx b/components/mcp/MCPMarketplaceModal.tsx new file mode 100644 index 0000000..74d844e --- /dev/null +++ b/components/mcp/MCPMarketplaceModal.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { X, Search, Filter, Package, Star, Download, Eye } from "lucide-react"; +import { MCPProvider } from "@/lib/types/mcp"; +import { MCPCard } from "./MCPCard"; +import { MCPCategoryFilter } from "./MCPCategoryFilter"; +import { MCPSearchBar } from "./MCPSearchBar"; + +interface MCPMarketplaceModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function MCPMarketplaceModal({ + isOpen, + onClose, +}: MCPMarketplaceModalProps) { + const [mcps, setMcps] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const [sortBy, setSortBy] = useState< + "rating" | "downloads" | "name" | "recent" | "total_ratings" | "updated_at" + >("rating"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [selectedMCP, setSelectedMCP] = useState(null); + const [syncStatus, setSyncStatus] = useState<{ + isSyncing: boolean; + lastSync: number | null; + totalCached: number; + cacheValid: boolean; + } | null>(null); + const [categories, setCategories] = useState<{ + primary: Array<{ category: string; count: number }>; + others: Array<{ category: string; count: number }>; + }>({ primary: [], others: [] }); + + const fetchMCPs = useCallback(async () => { + console.log("Fetching MCPs with params:", { + selectedCategory, + debouncedSearchTerm, + sortBy, + sortOrder, + }); + setLoading(true); + try { + const params = new URLSearchParams(); + if (selectedCategory !== "all") + params.append("category", selectedCategory); + if (debouncedSearchTerm) params.append("search", debouncedSearchTerm); + params.append("sort", sortBy); + params.append("order", sortOrder); + params.append("limit", "2000"); // Aumentado para 2000 + + const response = await fetch(`/api/mcp/list?${params}`); + const data = await response.json(); + + if (data.success) { + setMcps(data.mcps); + } + } catch (error) { + console.error("Error fetching MCPs:", error); + } finally { + setLoading(false); + } + }, [selectedCategory, debouncedSearchTerm, sortBy, sortOrder]); + + const fetchCategories = useCallback(async () => { + try { + const response = await fetch("/api/mcp/categories"); + const data = await response.json(); + + if (data.success) { + setCategories(data.categories); + console.log("📊 Categories loaded:", data.categories); + } else { + console.error("Failed to fetch categories:", data.error); + } + } catch (error) { + console.error("Error fetching categories:", error); + } + }, []); + + const handleInstall = async (mcpId: string) => { + try { + const response = await fetch("/api/mcp/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mcpId }), + }); + + const data = await response.json(); + if (data.success) { + // Atualizar estado local + setMcps((prev) => + prev.map((mcp) => + mcp.id === mcpId ? { ...mcp, installed: true } : mcp + ) + ); + } + } catch (error) { + console.error("Error installing MCP:", error); + } + }; + + const handleUninstall = async (mcpId: string) => { + try { + const response = await fetch("/api/mcp/uninstall", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mcpId }), + }); + + const data = await response.json(); + if (data.success) { + // Atualizar estado local + setMcps((prev) => + prev.map((mcp) => + mcp.id === mcpId ? { ...mcp, installed: false } : mcp + ) + ); + } + } catch (error) { + console.error("Error uninstalling MCP:", error); + } + }; + + const handleViewDetails = (mcp: MCPProvider) => { + setSelectedMCP(mcp); + }; + + // Carregar MCPs e categorias quando o modal abrir + useEffect(() => { + if (isOpen) { + fetchMCPs(); + fetchCategories(); + } + }, [isOpen]); // Removido fetchMCPs das dependências + + // Debounce para searchTerm + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + }, 500); + + return () => clearTimeout(timer); + }, [searchTerm]); + + // Fetch sync status when modal opens + useEffect(() => { + if (isOpen) { + fetch("/api/mcp/sync") + .then((r) => r.json()) + .then((data) => { + setSyncStatus({ + isSyncing: data.isSyncing, + lastSync: data.lastSync, + totalCached: data.totalCached, + cacheValid: data.cacheValid, + }); + }) + .catch(console.error); + } + }, [isOpen]); + + // Recarregar MCPs quando filtros mudarem (só se modal estiver aberto) + useEffect(() => { + if (isOpen) { + fetchMCPs(); + } + }, [selectedCategory, debouncedSearchTerm, sortBy, sortOrder, isOpen]); + + if (!isOpen) return null; + + return ( +
+
+ +
+ {/* Header */} +
+
+ +

MCP Marketplace

+ + {mcps.length} MCPs available + +
+ +
+ + {/* Sync Status Banner */} + {syncStatus?.isSyncing && ( +
+
+
+ + Sincronizando marketplace... {syncStatus.totalCached} servidores + carregados + +
+
+ )} + + {syncStatus?.lastSync && !syncStatus.isSyncing && ( +
+
+ + Cache local ativo • {syncStatus.totalCached} servidores • Última + atualização: {new Date(syncStatus.lastSync).toLocaleString()} + + +
+
+ )} + + {/* Content */} +
+ {/* Sidebar */} +
+ ({ + id: cat.category.toLowerCase(), + name: cat.category, + icon: cat.category, + count: cat.count, + }))} + otherCategories={categories.others.map((cat) => ({ + id: cat.category.toLowerCase(), + name: cat.category, + icon: cat.category, + count: cat.count, + }))} + selectedCategory={selectedCategory} + onSelectCategory={setSelectedCategory} + /> +
+ + {/* Main Content */} +
+ {/* Search and Filters */} +
+
+ { + // O useEffect já cuida de recarregar quando searchTerm muda + }} + /> +
+ + + +
+
+
+ + {/* MCP Grid */} +
+ {loading ? ( +
+
+
+ ) : ( +
+ {mcps.map((mcp) => ( + handleInstall(mcp.id)} + onUninstall={() => handleUninstall(mcp.id)} + onViewDetails={() => handleViewDetails(mcp)} + /> + ))} +
+ )} + + {!loading && mcps.length === 0 && ( +
+ +

No MCPs found

+

+ Try adjusting your search or filters +

+
+ )} +
+
+
+
+
+ ); +} diff --git a/components/mcp/MCPRatingStars.tsx b/components/mcp/MCPRatingStars.tsx new file mode 100644 index 0000000..889228b --- /dev/null +++ b/components/mcp/MCPRatingStars.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Star } from "lucide-react"; + +interface MCPRatingStarsProps { + rating: number; + size?: "sm" | "md" | "lg"; + showNumber?: boolean; +} + +export function MCPRatingStars({ + rating, + size = "sm", + showNumber = true, +}: MCPRatingStarsProps) { + const sizeClasses = { + sm: "h-3 w-3", + md: "h-4 w-4", + lg: "h-5 w-5", + }; + + const stars = []; + const fullStars = Math.floor(rating); + const hasHalfStar = rating % 1 >= 0.5; + + // Renderizar estrelas cheias + for (let i = 0; i < fullStars; i++) { + stars.push( + + ); + } + + // Renderizar meia estrela se necessário + if (hasHalfStar) { + stars.push( +
+ +
+ +
+
+ ); + } + + // Renderizar estrelas vazias + const emptyStars = 5 - Math.ceil(rating); + for (let i = 0; i < emptyStars; i++) { + stars.push( + + ); + } + + return ( +
+
{stars}
+ {showNumber && ( + + {rating.toFixed(1)} + + )} +
+ ); +} diff --git a/components/mcp/MCPSearchBar.tsx b/components/mcp/MCPSearchBar.tsx new file mode 100644 index 0000000..820ce35 --- /dev/null +++ b/components/mcp/MCPSearchBar.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Search } from "lucide-react"; + +interface MCPSearchBarProps { + value: string; + onChange: (value: string) => void; + onSearch: () => void; +} + +export function MCPSearchBar({ value, onChange, onSearch }: MCPSearchBarProps) { + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + onSearch(); + } + }; + + return ( +
+ + onChange(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Search MCPs..." + className="w-full pl-10 pr-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent mcp-search-input" + /> +
+ ); +} diff --git a/components/model/ModelDropdown.tsx b/components/model/ModelDropdown.tsx index 184c370..3d66828 100644 --- a/components/model/ModelDropdown.tsx +++ b/components/model/ModelDropdown.tsx @@ -1,13 +1,30 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { ChevronDown, Cpu, CircuitBoard } from "lucide-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; } @@ -15,12 +32,108 @@ 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([]); + const [isSearchingRemote, setIsSearchingRemote] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState( + null + ); const dropdownRef = useRef(null); - const selectedModelData = models.find((m) => m.id === selectedModel); + // 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 ; + case "balanced": + return ; + case "powerful": + return ; + default: + return ; + } + }; useEffect(() => { function handleClickOutside(event: MouseEvent) { @@ -29,6 +142,11 @@ export function ModelDropdown({ !dropdownRef.current.contains(event.target as Node) ) { setIsOpen(false); + setSearchTerm(""); + setFilterDevice("all"); + setShowRemote(false); + setRemoteModels([]); + setShowDeleteConfirm(null); } } @@ -36,6 +154,18 @@ export function ModelDropdown({ 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 (
{isOpen && ( -
-
- {models.length === 0 ? ( -
- No models available +
+ {/* Header com busca e filtros */} +
+
+
+ + 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 && ( + + )}
- ) : ( - models.map((model) => ( -
+ + {/* Toggle para busca remota */} +
+
+ - )) +
+ {showRemote + ? `${remoteModels.length} remote` + : `${filteredModels.length} local`}{" "} + model + {(showRemote + ? remoteModels.length + : filteredModels.length) !== 1 + ? "s" + : ""}{" "} + found +
+
+
+
+ + {/* Lista de modelos com scroll customizado */} +
+ {showRemote ? ( + // Lista de modelos remotos + <> + {isSearchingRemote ? ( +
+
+ Searching remote models... +
+ ) : remoteModels.length === 0 ? ( +
+ + No remote models found +
+ ) : ( +
+ {remoteModels.map((model) => ( +
+
+
+
+ + {model.name} + + + + Remote + + {model.installed && ( + + + Installed + + )} +
+
+
+ +
+
+ Available for download +
+ +
+
+ ))} +
+ )} + + ) : ( + // Lista de modelos locais + <> + {filteredModels.length === 0 ? ( +
+ + No models found +
+ ) : ( +
+ {filteredModels.map((model) => ( +
+
+
+
+ + {model.name} + + + {getPerformanceIcon(model)} + {categorizeModel(model)} + +
+
+ + + {model.sizeGB} GB + + + {model.quantization} + + + {model.device === "GPU" ? ( + + ) : ( + + )} + {model.device} + +
+
+
+ + {/* Informações técnicas e ações */} +
+
+ {model.estCpuUsagePct && ( +
+ + {model.estCpuUsagePct}% CPU +
+ )} + {model.estVramGB && model.estVramGB > 0 && ( +
+ + {model.estVramGB} GB VRAM +
+ )} +
+ +
+ + {onDeleteModel && ( + + )} +
+
+ + {/* Confirmação de exclusão */} + {showDeleteConfirm === model.id && ( +
+
+ Are you sure you want to delete this model? +
+
+ + +
+
+ )} +
+ ))} +
+ )} + )}
diff --git a/database/database.ts b/database/database.ts new file mode 100644 index 0000000..346e99b --- /dev/null +++ b/database/database.ts @@ -0,0 +1,34 @@ +import Database from "better-sqlite3"; +import path from "path"; +import fs from "fs"; + +let db: Database.Database | null = null; + +export function getDatabase(): Database.Database { + if (!db) { + const dbPath = path.join(process.cwd(), "database", "ollahub.db"); + + // Garantir que o diretório existe + const dbDir = path.dirname(dbPath); + if (!fs.existsSync(dbDir)) { + fs.mkdirSync(dbDir, { recursive: true }); + } + + db = new Database(dbPath); + + // Configurações de performance + db.pragma("journal_mode = WAL"); + db.pragma("synchronous = NORMAL"); + db.pragma("foreign_keys = ON"); + db.pragma("cache_size = 10000"); + db.pragma("temp_store = memory"); + } + return db; +} + +export function closeDatabase(): void { + if (db) { + db.close(); + db = null; + } +} diff --git a/database/ollahub-preferences.db-shm b/database/ollahub-preferences.db-shm index 08697f4..1b5d52b 100644 Binary files a/database/ollahub-preferences.db-shm and b/database/ollahub-preferences.db-shm differ diff --git a/database/ollahub.db-shm b/database/ollahub.db-shm index b2d03fa..29ed79e 100644 Binary files a/database/ollahub.db-shm and b/database/ollahub.db-shm differ diff --git a/database/ollahub.db-wal b/database/ollahub.db-wal index 12c8693..f12de47 100644 Binary files a/database/ollahub.db-wal and b/database/ollahub.db-wal differ diff --git a/database/repositories/mcp-cache.ts b/database/repositories/mcp-cache.ts new file mode 100644 index 0000000..110431e --- /dev/null +++ b/database/repositories/mcp-cache.ts @@ -0,0 +1,464 @@ +import { getDatabase } from "@/database/database"; + +const db = getDatabase(); + +export interface MCPSearchParams { + query?: string; + category?: string; + limit?: number; + offset?: number; + sortBy?: string; + order?: "asc" | "desc"; +} + +export interface MCPCacheItem { + id: string; + owner: string; + repo: string; + content_name: string; + publisher_id: string; + description?: string; + category?: string; + subfield?: string; + field?: string; + rating: number; + review_cnt: number; + content_tag_list?: string; + thumbnail_picture?: string; + website?: string; + detail_url?: string; + ext_info?: any; + created_at: number; + updated_at: number; +} + +export class MCPCacheRepository { + private static readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 horas em millisegundos + + /** + * Verificar se cache está válido (< 24h) + */ + static isCacheValid(): boolean { + try { + const lastSync = this.getLastSync(); + if (!lastSync) return false; + + const now = Date.now(); + return now - lastSync < this.CACHE_TTL; + } catch (error) { + console.error("Error checking cache validity:", error); + return false; + } + } + + /** + * Obter timestamp da última sincronização + */ + static getLastSync(): number | null { + try { + const stmt = db.prepare( + "SELECT value FROM mcp_cache_metadata WHERE key = 'last_sync'" + ); + const result = stmt.get() as { value: string } | undefined; + return result ? parseInt(result.value) : null; + } catch (error) { + console.error("Error getting last sync:", error); + return null; + } + } + + /** + * Salvar múltiplos MCPs em batch (performance) + */ + static saveBatch(mcps: any[]): void { + try { + const stmt = db.prepare(` + INSERT INTO mcp_marketplace_cache ( + id, owner, repo, content_name, publisher_id, description, + category, subfield, field, rating, review_cnt, content_tag_list, + thumbnail_picture, website, detail_url, ext_info, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + owner = EXCLUDED.owner, + repo = EXCLUDED.repo, + content_name = EXCLUDED.content_name, + publisher_id = EXCLUDED.publisher_id, + description = EXCLUDED.description, + category = EXCLUDED.category, + subfield = EXCLUDED.subfield, + field = EXCLUDED.field, + rating = EXCLUDED.rating, + review_cnt = EXCLUDED.review_cnt, + content_tag_list = EXCLUDED.content_tag_list, + thumbnail_picture = EXCLUDED.thumbnail_picture, + website = EXCLUDED.website, + detail_url = EXCLUDED.detail_url, + ext_info = EXCLUDED.ext_info, + updated_at = EXCLUDED.updated_at + `); + + const now = Date.now(); + + for (const mcp of mcps) { + const [owner, repo] = (mcp.id || "").split("/"); + stmt.run( + mcp.id || "", + owner || "", + repo || "", + mcp.content_name || "", + mcp.publisher_id || "", + mcp.description || null, + mcp.category || null, + mcp.subfield || null, + mcp.field || null, + parseFloat(mcp.rating || "0"), + parseInt(mcp.review_cnt || "0"), + mcp.content_tag_list || null, + mcp.thumbnail_picture || null, + mcp.website || null, + mcp.detail_url || null, + mcp.ext_info ? JSON.stringify(mcp.ext_info) : null, + now, + now + ); + } + + console.log(`Saved batch of ${mcps.length} MCPs to cache`); + } catch (error) { + console.error("Error saving MCP batch:", error); + throw error; + } + } + + /** + * Limpar cache antigo + */ + static clearCache(): void { + try { + db.exec("DELETE FROM mcp_marketplace_cache"); + db.exec("DELETE FROM mcp_cache_metadata"); + console.log("MCP cache cleared"); + } catch (error) { + console.error("Error clearing cache:", error); + throw error; + } + } + + /** + * Buscar MCPs no cache com filtros + */ + static search(params: MCPSearchParams): MCPCacheItem[] { + try { + let query = "SELECT * FROM mcp_marketplace_cache WHERE 1=1"; + const queryParams: any[] = []; + + // Filtro por categoria + if (params.category) { + query += " AND (category = ? OR subfield = ?)"; + queryParams.push(params.category, params.category); + } + + // Filtro por busca textual + if (params.query) { + query += + " AND (content_name LIKE ? OR description LIKE ? OR content_tag_list LIKE ?)"; + const searchTerm = `%${params.query}%`; + queryParams.push(searchTerm, searchTerm, searchTerm); + } + + // Ordenação dinâmica + const order = params.order || "desc"; // Padrão descrescente + let orderClause = ""; + + switch (params.sortBy) { + case "rating": + orderClause = `ORDER BY rating ${order.toUpperCase()}`; + break; + case "downloads": + orderClause = `ORDER BY review_cnt ${order.toUpperCase()}`; + break; + case "name": + orderClause = `ORDER BY content_name ${order.toUpperCase()}`; + break; + case "recent": + orderClause = `ORDER BY updated_at ${order.toUpperCase()}`; + break; + case "total_ratings": + orderClause = `ORDER BY review_cnt ${order.toUpperCase()}`; + break; + case "updated_at": + orderClause = `ORDER BY updated_at ${order.toUpperCase()}`; + break; + default: + orderClause = `ORDER BY rating DESC`; // Padrão + } + + query += ` ${orderClause}`; + + // Paginação + if (params.limit) { + query += " LIMIT ?"; + queryParams.push(params.limit); + + if (params.offset) { + query += " OFFSET ?"; + queryParams.push(params.offset); + } + } + + const stmt = db.prepare(query); + const results = stmt.all(...queryParams) as MCPCacheItem[]; + + console.log(`Found ${results.length} MCPs in cache for search:`, params); + return results; + } catch (error) { + console.error("Error searching MCP cache:", error); + return []; + } + } + + /** + * Contar total de MCPs no cache + */ + static count(filters?: { category?: string }): number { + try { + let query = + "SELECT COUNT(*) as count FROM mcp_marketplace_cache WHERE 1=1"; + const queryParams: any[] = []; + + if (filters?.category) { + query += " AND (category = ? OR subfield = ?)"; + queryParams.push(filters.category, filters.category); + } + + const stmt = db.prepare(query); + const result = stmt.get(...queryParams) as { count: number }; + return result.count; + } catch (error) { + console.error("Error counting MCP cache:", error); + return 0; + } + } + + /** + * Atualizar metadados do cache + */ + static updateMetadata(key: string, value: string): void { + try { + const stmt = db.prepare(` + INSERT INTO mcp_cache_metadata (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = EXCLUDED.updated_at + `); + stmt.run(key, value, Date.now()); + } catch (error) { + console.error("Error updating cache metadata:", error); + throw error; + } + } + + /** + * Verificar se precisa sincronizar + */ + static needsSync(): boolean { + try { + // Se não há dados no cache, precisa sincronizar + const count = this.count(); + if (count === 0) return true; + + // Se cache expirou, precisa sincronizar + return !this.isCacheValid(); + } catch (error) { + console.error("Error checking if sync is needed:", error); + return true; + } + } + + /** + * Obter estatísticas do cache + */ + static getStats(): { + totalItems: number; + lastSync: number | null; + isValid: boolean; + categories: Record; + } { + try { + const totalItems = this.count(); + const lastSync = this.getLastSync(); + const isValid = this.isCacheValid(); + + // Contar por categoria + const categoryStmt = db.prepare(` + SELECT category, COUNT(*) as count + FROM mcp_marketplace_cache + WHERE category IS NOT NULL + GROUP BY category + `); + const categoryResults = categoryStmt.all() as Array<{ + category: string; + count: number; + }>; + + const categories = categoryResults.reduce((acc, row) => { + acc[row.category] = row.count; + return acc; + }, {} as Record); + + return { + totalItems, + lastSync, + isValid, + categories, + }; + } catch (error) { + console.error("Error getting cache stats:", error); + return { + totalItems: 0, + lastSync: null, + isValid: false, + categories: {}, + }; + } + } + + /** + * Verificar se uma tabela existe + */ + static tableExists(tableName: string): boolean { + try { + const stmt = db.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' AND name=? + `); + const result = stmt.get(tableName); + return !!result; + } catch (error) { + console.error("Error checking if table exists:", error); + return false; + } + } + + /** + * Extrair categorias dinamicamente do cache + * Retorna categorias principais e outras categorias menos comuns + */ + static getCategories(): { + primary: Array<{ category: string; count: number }>; + others: Array<{ category: string; count: number }>; + } { + try { + // Categorias principais definidas (baseadas nos dados reais da API) + const primaryCategories = [ + "MAP", + "BROWSER", + "OFFICE", + "CODE", + "DATABASE", + "SEARCH", + "PAYMENT", + "CHART", + "FINANCE", + "OTHER", + ]; + + // Query para obter todas as categorias com contadores + const stmt = db.prepare(` + SELECT + COALESCE(subfield, category, 'other') as category, + COUNT(*) as count + FROM mcp_marketplace_cache + WHERE COALESCE(subfield, category, 'other') IS NOT NULL + GROUP BY COALESCE(subfield, category, 'other') + ORDER BY count DESC + `); + + const allCategories = stmt.all() as Array<{ + category: string; + count: number; + }>; + + // Separar em categorias principais e outras + const primary: Array<{ category: string; count: number }> = []; + const others: Array<{ category: string; count: number }> = []; + + for (const cat of allCategories) { + const normalizedCategory = cat.category.toUpperCase(); + + if (primaryCategories.includes(normalizedCategory)) { + primary.push({ + category: normalizedCategory, + count: cat.count, + }); + } else { + others.push({ + category: cat.category, + count: cat.count, + }); + } + } + + // Ordenar categorias principais por contagem (desc) + primary.sort((a, b) => b.count - a.count); + + // Ordenar outras categorias por contagem (desc) + others.sort((a, b) => b.count - a.count); + + console.log( + `📊 Extracted categories: ${primary.length} primary, ${others.length} others` + ); + + return { primary, others }; + } catch (error) { + console.error("Error extracting categories:", error); + return { primary: [], others: [] }; + } + } + + /** + * Obter estatísticas detalhadas de categorias + */ + static getCategoryStats(): { + totalCategories: number; + primaryCategories: number; + otherCategories: number; + totalMCPs: number; + categoryBreakdown: Record; + } { + try { + const categories = this.getCategories(); + const totalMCPs = this.count(); + + const categoryBreakdown: Record = {}; + + // Adicionar categorias principais + for (const cat of categories.primary) { + categoryBreakdown[cat.category] = cat.count; + } + + // Adicionar outras categorias + for (const cat of categories.others) { + categoryBreakdown[cat.category] = cat.count; + } + + return { + totalCategories: categories.primary.length + categories.others.length, + primaryCategories: categories.primary.length, + otherCategories: categories.others.length, + totalMCPs, + categoryBreakdown, + }; + } catch (error) { + console.error("Error getting category stats:", error); + return { + totalCategories: 0, + primaryCategories: 0, + otherCategories: 0, + totalMCPs: 0, + categoryBreakdown: {}, + }; + } + } +} diff --git a/database/repositories/mcp.ts b/database/repositories/mcp.ts new file mode 100644 index 0000000..1a64407 --- /dev/null +++ b/database/repositories/mcp.ts @@ -0,0 +1,226 @@ +import Database from "better-sqlite3"; +import path from "path"; + +const dbPath = path.join(process.cwd(), "database", "ollahub.db"); +const db = new Database(dbPath); + +export class MCPRepository { + /** + * Verificar se um MCP está instalado localmente + */ + static isInstalled(mcpId: string): boolean { + try { + const stmt = db.prepare("SELECT id FROM mcp_installations WHERE id = ?"); + const result = stmt.get(mcpId); + return !!result; + } catch (error) { + console.error(`Error checking if MCP ${mcpId} is installed:`, error); + return false; + } + } + + /** + * Salvar instalação de MCP no banco local + */ + static saveInstallation(mcpId: string, config: any, tools: any[]): void { + try { + const [owner, repo] = mcpId.split('/'); + if (!owner || !repo) { + throw new Error(`Invalid MCP ID format: ${mcpId}`); + } + + const stmt = db.prepare(` + INSERT OR REPLACE INTO mcp_installations + (id, owner, repo, name, config, tools, installed_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + `); + + stmt.run( + mcpId, + owner, + repo, + config.name || repo, + JSON.stringify(config), + JSON.stringify(tools) + ); + + console.log(`MCP ${mcpId} saved to local database`); + } catch (error) { + console.error(`Error saving MCP ${mcpId} installation:`, error); + throw error; + } + } + + /** + * Remover instalação de MCP do banco local + */ + static removeInstallation(mcpId: string): void { + try { + const stmt = db.prepare("DELETE FROM mcp_installations WHERE id = ?"); + const result = stmt.run(mcpId); + + if (result.changes > 0) { + console.log(`MCP ${mcpId} removed from local database`); + } else { + console.log(`MCP ${mcpId} was not found in local database`); + } + } catch (error) { + console.error(`Error removing MCP ${mcpId} installation:`, error); + throw error; + } + } + + /** + * Listar todos os MCPs instalados localmente + */ + static listInstalled(): string[] { + try { + const stmt = db.prepare("SELECT id FROM mcp_installations ORDER BY installed_at DESC"); + const results = stmt.all() as { id: string }[]; + return results.map(row => row.id); + } catch (error) { + console.error("Error listing installed MCPs:", error); + return []; + } + } + + /** + * Obter configuração local de um MCP + */ + static getLocalConfig(mcpId: string): any { + try { + const stmt = db.prepare("SELECT config FROM mcp_installations WHERE id = ?"); + const result = stmt.get(mcpId) as { config: string } | undefined; + + if (result) { + return JSON.parse(result.config); + } + return null; + } catch (error) { + console.error(`Error getting local config for MCP ${mcpId}:`, error); + return null; + } + } + + /** + * Obter ferramentas locais de um MCP + */ + static getLocalTools(mcpId: string): any[] { + try { + const stmt = db.prepare("SELECT tools FROM mcp_installations WHERE id = ?"); + const result = stmt.get(mcpId) as { tools: string } | undefined; + + if (result) { + return JSON.parse(result.tools); + } + return []; + } catch (error) { + console.error(`Error getting local tools for MCP ${mcpId}:`, error); + return []; + } + } + + /** + * Obter informações completas de um MCP instalado + */ + static getInstalledMCP(mcpId: string): any { + try { + const stmt = db.prepare(` + SELECT id, owner, repo, name, config, tools, installed_at, updated_at + FROM mcp_installations WHERE id = ? + `); + const result = stmt.get(mcpId) as any; + + if (result) { + return { + ...result, + config: JSON.parse(result.config), + tools: JSON.parse(result.tools) + }; + } + return null; + } catch (error) { + console.error(`Error getting installed MCP ${mcpId}:`, error); + return null; + } + } + + /** + * Listar todos os MCPs instalados com informações completas + */ + static listInstalledWithDetails(): any[] { + try { + const stmt = db.prepare(` + SELECT id, owner, repo, name, config, tools, installed_at, updated_at + FROM mcp_installations ORDER BY installed_at DESC + `); + const results = stmt.all() as any[]; + + return results.map(row => ({ + ...row, + config: JSON.parse(row.config), + tools: JSON.parse(row.tools) + })); + } catch (error) { + console.error("Error listing installed MCPs with details:", error); + return []; + } + } + + /** + * Atualizar timestamp de um MCP instalado + */ + static updateTimestamp(mcpId: string): void { + try { + const stmt = db.prepare("UPDATE mcp_installations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?"); + stmt.run(mcpId); + } catch (error) { + console.error(`Error updating timestamp for MCP ${mcpId}:`, error); + } + } + + /** + * Verificar integridade do banco de dados + */ + static checkDatabaseIntegrity(): boolean { + try { + const result = db.prepare("PRAGMA integrity_check").get() as { integrity_check: string }; + return result.integrity_check === "ok"; + } catch (error) { + console.error("Error checking database integrity:", error); + return false; + } + } + + /** + * Obter estatísticas das instalações + */ + static getInstallationStats(): any { + try { + const totalStmt = db.prepare("SELECT COUNT(*) as total FROM mcp_installations"); + const totalResult = totalStmt.get() as { total: number }; + + const recentStmt = db.prepare(` + SELECT COUNT(*) as recent + FROM mcp_installations + WHERE installed_at > datetime('now', '-7 days') + `); + const recentResult = recentStmt.get() as { recent: number }; + + const categoriesStmt = db.prepare(` + SELECT COUNT(DISTINCT owner) as unique_owners + FROM mcp_installations + `); + const categoriesResult = categoriesStmt.get() as { unique_owners: number }; + + return { + total: totalResult.total, + recent: recentResult.recent, + uniqueOwners: categoriesResult.unique_owners + }; + } catch (error) { + console.error("Error getting installation stats:", error); + return { total: 0, recent: 0, uniqueOwners: 0 }; + } + } +} diff --git a/database/schema.sql b/database/schema.sql index ae3ddc0..8447872 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -26,7 +26,55 @@ CREATE TABLE embeddings ( FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE ); +-- Tabela para gerenciar MCPs instalados +CREATE TABLE IF NOT EXISTS mcp_installations ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + name TEXT NOT NULL, + config JSON NOT NULL, + tools JSON, + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Cache de todos os servidores MCP do marketplace +CREATE TABLE IF NOT EXISTS mcp_marketplace_cache ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + content_name TEXT NOT NULL, + publisher_id TEXT NOT NULL, + description TEXT, + category TEXT, + subfield TEXT, + field TEXT, + rating REAL DEFAULT 0, + review_cnt INTEGER DEFAULT 0, + content_tag_list TEXT, + thumbnail_picture TEXT, + website TEXT, + detail_url TEXT, + ext_info JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Metadados do cache (última sincronização, total de registros) +CREATE TABLE IF NOT EXISTS mcp_cache_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + -- Índices para performance CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp); CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC); CREATE INDEX idx_embeddings_message ON embeddings(message_id); +CREATE INDEX idx_mcp_owner_repo ON mcp_installations(owner, repo); +CREATE INDEX idx_mcp_installed_at ON mcp_installations(installed_at DESC); +CREATE INDEX idx_mcp_cache_category ON mcp_marketplace_cache(category); +CREATE INDEX idx_mcp_cache_subfield ON mcp_marketplace_cache(subfield); +CREATE INDEX idx_mcp_cache_rating ON mcp_marketplace_cache(rating DESC); +CREATE INDEX idx_mcp_cache_owner_repo ON mcp_marketplace_cache(owner, repo); +CREATE INDEX idx_mcp_cache_search ON mcp_marketplace_cache(content_name, description); diff --git a/hooks/useMCPMarketplace.ts b/hooks/useMCPMarketplace.ts new file mode 100644 index 0000000..2397811 --- /dev/null +++ b/hooks/useMCPMarketplace.ts @@ -0,0 +1,192 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { MCPProvider, MCPSearchParams } from "@/lib/types/mcp"; + +export function useMCPMarketplace() { + const [mcps, setMcps] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedCategory, setSelectedCategory] = useState("all"); + const [searchTerm, setSearchTerm] = useState(""); + const [sortBy, setSortBy] = useState("rating"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [selectedMCP, setSelectedMCP] = useState(null); + const [showDetailModal, setShowDetailModal] = useState(false); + + const fetchMCPs = useCallback( + async (params?: Partial) => { + setLoading(true); + setError(null); + + try { + const searchParams = new URLSearchParams(); + + const category = params?.category || selectedCategory; + const search = params?.search || searchTerm; + const sort = params?.sort || sortBy; + const order = params?.order || sortOrder; + + if (category && category !== "all") { + searchParams.append("category", category); + } + if (search) { + searchParams.append("search", search); + } + if (sort) { + searchParams.append("sort", sort); + } + if (order) { + searchParams.append("order", order); + } + searchParams.append("limit", "2000"); // Aumentado para 2000 + + const response = await fetch(`/api/mcp/list?${searchParams}`); + const data = await response.json(); + + if (data.success) { + setMcps(data.mcps); + } else { + setError(data.error || "Failed to fetch MCPs"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch MCPs"); + } finally { + setLoading(false); + } + }, + [selectedCategory, searchTerm, sortBy, sortOrder] + ); + + const installMCP = useCallback(async (mcpId: string) => { + try { + const response = await fetch("/api/mcp/install", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mcpId }), + }); + + const data = await response.json(); + if (data.success) { + // Atualizar estado local + setMcps((prev) => + prev.map((mcp) => + mcp.id === mcpId ? { ...mcp, installed: true } : mcp + ) + ); + return { success: true, message: data.installation.message }; + } else { + return { success: false, message: data.error }; + } + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : "Failed to install MCP", + }; + } + }, []); + + const uninstallMCP = useCallback(async (mcpId: string) => { + try { + const response = await fetch("/api/mcp/uninstall", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mcpId }), + }); + + const data = await response.json(); + if (data.success) { + // Atualizar estado local + setMcps((prev) => + prev.map((mcp) => + mcp.id === mcpId ? { ...mcp, installed: false } : mcp + ) + ); + return { success: true, message: data.installation.message }; + } else { + return { success: false, message: data.error }; + } + } catch (err) { + return { + success: false, + message: err instanceof Error ? err.message : "Failed to uninstall MCP", + }; + } + }, []); + + const viewMCPDetails = useCallback((mcp: MCPProvider) => { + setSelectedMCP(mcp); + setShowDetailModal(true); + }, []); + + const closeDetailModal = useCallback(() => { + setShowDetailModal(false); + setSelectedMCP(null); + }, []); + + const handleSearch = useCallback( + (search: string) => { + setSearchTerm(search); + fetchMCPs({ search }); + }, + [fetchMCPs] + ); + + const handleCategoryChange = useCallback( + (category: string) => { + setSelectedCategory(category); + fetchMCPs({ category }); + }, + [fetchMCPs] + ); + + const handleSortChange = useCallback( + (sort: MCPSearchParams["sort"]) => { + setSortBy(sort); + fetchMCPs({ sort }); + }, + [fetchMCPs] + ); + + const handleOrderChange = useCallback( + (order: "asc" | "desc") => { + setSortOrder(order); + fetchMCPs({ order }); + }, + [fetchMCPs] + ); + + // Carregar MCPs na inicialização + useEffect(() => { + fetchMCPs(); + }, []); + + return { + // Estado + mcps, + loading, + error, + selectedCategory, + searchTerm, + sortBy, + sortOrder, + selectedMCP, + showDetailModal, + + // Ações + fetchMCPs, + installMCP, + uninstallMCP, + viewMCPDetails, + closeDetailModal, + handleSearch, + handleCategoryChange, + handleSortChange, + handleOrderChange, + + // Setters diretos + setSearchTerm, + setSortBy, + setSortOrder, + }; +} diff --git a/lib/cache/mcp-cache.ts b/lib/cache/mcp-cache.ts new file mode 100644 index 0000000..497bf4f --- /dev/null +++ b/lib/cache/mcp-cache.ts @@ -0,0 +1,173 @@ +import { DeepNLPSearchParams } from "@/lib/types/mcp"; + +interface CacheEntry { + data: any; + timestamp: number; + ttl: number; +} + +export class MCPCache { + private static cache = new Map(); + private static TTL = 5 * 60 * 1000; // 5 minutos em millisegundos + + /** + * Obter dados do cache + */ + static get(key: string): any | null { + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + // Verificar se expirou + const now = Date.now(); + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return null; + } + + console.log(`Cache hit for key: ${key}`); + return entry.data; + } + + /** + * Salvar dados no cache + */ + static set(key: string, value: any, ttl?: number): void { + const entry: CacheEntry = { + data: value, + timestamp: Date.now(), + ttl: ttl || this.TTL + }; + + this.cache.set(key, entry); + console.log(`Cache set for key: ${key}, TTL: ${entry.ttl}ms`); + } + + /** + * Limpar cache + */ + static clear(): void { + this.cache.clear(); + console.log("Cache cleared"); + } + + /** + * Remover entrada específica do cache + */ + static delete(key: string): void { + this.cache.delete(key); + console.log(`Cache entry deleted for key: ${key}`); + } + + /** + * Gerar chave de cache baseada nos parâmetros de busca + */ + static generateKey(params: DeepNLPSearchParams): string { + const sortedParams = Object.keys(params) + .sort() + .reduce((result, key) => { + const value = params[key as keyof DeepNLPSearchParams]; + if (value !== undefined && value !== null) { + result[key] = value; + } + return result; + }, {} as Record); + + return `mcp_search_${JSON.stringify(sortedParams)}`; + } + + /** + * Gerar chave para configuração de servidor + */ + static generateServerConfigKey(mcpId: string): string { + return `mcp_config_${mcpId}`; + } + + /** + * Gerar chave para ferramentas de servidor + */ + static generateServerToolsKey(mcpId: string): string { + return `mcp_tools_${mcpId}`; + } + + /** + * Obter estatísticas do cache + */ + static getStats(): { + size: number; + entries: Array<{ key: string; age: number; ttl: number }>; + } { + const now = Date.now(); + const entries = Array.from(this.cache.entries()).map(([key, entry]) => ({ + key, + age: now - entry.timestamp, + ttl: entry.ttl + })); + + return { + size: this.cache.size, + entries + }; + } + + /** + * Limpar entradas expiradas + */ + static cleanup(): number { + const now = Date.now(); + let cleaned = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + cleaned++; + } + } + + if (cleaned > 0) { + console.log(`Cache cleanup: removed ${cleaned} expired entries`); + } + + return cleaned; + } + + /** + * Verificar se uma chave existe no cache (sem verificar TTL) + */ + static has(key: string): boolean { + return this.cache.has(key); + } + + /** + * Obter todas as chaves do cache + */ + static keys(): string[] { + return Array.from(this.cache.keys()); + } + + /** + * Definir TTL personalizado para diferentes tipos de dados + */ + static getTTLForDataType(dataType: 'search' | 'config' | 'tools'): number { + switch (dataType) { + case 'search': + return 5 * 60 * 1000; // 5 minutos para buscas + case 'config': + return 30 * 60 * 1000; // 30 minutos para configurações + case 'tools': + return 15 * 60 * 1000; // 15 minutos para ferramentas + default: + return this.TTL; + } + } + + /** + * Cache com TTL específico para tipo de dados + */ + static setWithDataType(key: string, value: any, dataType: 'search' | 'config' | 'tools'): void { + const ttl = this.getTTLForDataType(dataType); + this.set(key, value, ttl); + } +} diff --git a/lib/ollama.ts b/lib/ollama.ts index 965c09d..848c7ba 100644 --- a/lib/ollama.ts +++ b/lib/ollama.ts @@ -102,3 +102,72 @@ export async function ensureModelExists( console.log(`✅ Modelo ${modelName} já está disponível`); } } + +export async function deleteModel(modelName: string): Promise { + const client = new Ollama({ host: OLLAMA_HOST }); + await client.delete({ model: modelName }); +} + +export async function searchRemoteModels(query?: string): Promise { + try { + // Ollama não tem API oficial para buscar modelos remotos + // Vamos usar uma lista conhecida de modelos populares + const popularModels = [ + "llama3.2:1b", + "llama3.2:3b", + "llama3.2:11b", + "llama3.2:70b", + "llama3.1:8b", + "llama3.1:70b", + "llama3.1:405b", + "qwen2.5:1.5b", + "qwen2.5:3b", + "qwen2.5:7b", + "qwen2.5:14b", + "qwen2.5:32b", + "qwen2.5:72b", + "qwen2:0.5b", + "qwen2:1.5b", + "qwen2:3b", + "qwen2:7b", + "qwen2:14b", + "qwen2:32b", + "qwen2:72b", + "mistral:7b", + "mixtral:8x7b", + "mixtral:8x22b", + "phi3:mini", + "phi3:medium", + "phi3:large", + "gemma2:2b", + "gemma2:9b", + "gemma2:27b", + "codellama:7b", + "codellama:13b", + "codellama:34b", + "deepseek-coder:1.3b", + "deepseek-coder:6.7b", + "deepseek-coder:33b", + ]; + + // Filtrar por query se fornecida + const filteredModels = query + ? popularModels.filter((model) => + model.toLowerCase().includes(query.toLowerCase()) + ) + : popularModels; + + // Verificar quais já estão instalados + const installedModels = await listModelsViaSdk(); + const installedNames = installedModels.map((m) => m.name); + + return filteredModels.map((modelName) => ({ + name: modelName, + installed: installedNames.includes(modelName), + remote: true, + })); + } catch (error) { + console.error("Erro ao buscar modelos remotos:", error); + return []; + } +} diff --git a/lib/services/deepnlp.ts b/lib/services/deepnlp.ts new file mode 100644 index 0000000..5c8a7b6 --- /dev/null +++ b/lib/services/deepnlp.ts @@ -0,0 +1,288 @@ +import { + DeepNLPSearchParams, + DeepNLPResponse, + MCPServerConfig, + MCPTool, + MCPProvider +} from "@/lib/types/mcp"; +import { MCPCache } from "@/lib/cache/mcp-cache"; + +const DEEPNLP_API_BASE = "https://www.deepnlp.org/api/mcp_marketplace/v1"; + +export class DeepNLPService { + // Mapear categorias do projeto para categorias DeepNLP + private static categoryMap = { + productivity: ['office'], + development: ['code', 'browser'], + data: ['database'], + integration: ['search', 'payment'], + other: ['map', 'chart', 'finance'] + }; + + // Mapear categorias DeepNLP para categorias internas + private static reverseCategoryMap: Record = { + 'office': 'office', + 'code': 'code', + 'browser': 'browser', + 'database': 'database', + 'search': 'search', + 'payment': 'payment', + 'map': 'map', + 'chart': 'chart', + 'finance': 'finance' + }; + + /** + * Buscar servidores MCP na API DeepNLP + */ + static async searchMCPs(params: DeepNLPSearchParams, retryCount = 0): Promise { + const maxRetries = 3; + const baseDelay = 1000; // 1 segundo + + try { + // Verificar cache primeiro + const cacheKey = MCPCache.generateKey(params); + const cachedResult = MCPCache.get(cacheKey); + + if (cachedResult) { + console.log('Returning cached search results'); + return cachedResult; + } + + const url = new URL(DEEPNLP_API_BASE); + + // Adicionar parâmetros de busca + if (params.query) url.searchParams.set('query', params.query); + if (params.category) url.searchParams.set('category', params.category); + if (params.id) url.searchParams.set('id', params.id); + if (params.page_id !== undefined) url.searchParams.set('page_id', params.page_id.toString()); + if (params.count_per_page !== undefined) url.searchParams.set('count_per_page', params.count_per_page.toString()); + if (params.offset !== undefined) url.searchParams.set('offset', params.offset.toString()); + if (params.mode) url.searchParams.set('mode', params.mode); + + console.log(`DeepNLP API Request (attempt ${retryCount + 1}):`, url.toString()); + + const response = await fetch(url.toString(), { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ollahub-MCP-Client/1.0' + }, + // Timeout de 10 segundos + signal: AbortSignal.timeout(10000) + }); + + if (!response.ok) { + throw new Error(`DeepNLP API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('DeepNLP API Response:', data); + + // Salvar no cache + MCPCache.setWithDataType(cacheKey, data, 'search'); + + return data; + } catch (error) { + console.error(`Error calling DeepNLP API (attempt ${retryCount + 1}):`, error); + + // Se ainda temos tentativas restantes, tentar novamente com backoff exponencial + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); // Backoff exponencial + console.log(`Retrying in ${delay}ms...`); + + await new Promise(resolve => setTimeout(resolve, delay)); + return this.searchMCPs(params, retryCount + 1); + } + + // Se esgotamos as tentativas, lançar erro + throw new Error(`Failed to fetch MCPs after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Obter configuração completa de um servidor específico + */ + static async getServerConfig(id: string, retryCount = 0): Promise { + const maxRetries = 3; + const baseDelay = 1000; + + try { + // Verificar cache primeiro + const cacheKey = MCPCache.generateServerConfigKey(id); + const cachedResult = MCPCache.get(cacheKey); + + if (cachedResult) { + console.log('Returning cached server config'); + return cachedResult; + } + + const [owner, repo] = id.split('/'); + if (!owner || !repo) { + throw new Error(`Invalid MCP ID format: ${id}. Expected format: owner/repo`); + } + + const url = `${DEEPNLP_API_BASE}/server/${owner}/${repo}`; + console.log(`DeepNLP Server Config Request (attempt ${retryCount + 1}):`, url); + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ollahub-MCP-Client/1.0' + }, + signal: AbortSignal.timeout(10000) + }); + + if (!response.ok) { + throw new Error(`DeepNLP Server Config API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('DeepNLP Server Config Response:', data); + + // Salvar no cache + MCPCache.setWithDataType(cacheKey, data, 'config'); + + return data; + } catch (error) { + console.error(`Error getting server config from DeepNLP (attempt ${retryCount + 1}):`, error); + + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`Retrying server config in ${delay}ms...`); + + await new Promise(resolve => setTimeout(resolve, delay)); + return this.getServerConfig(id, retryCount + 1); + } + + throw new Error(`Failed to get server config after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Listar ferramentas de um servidor específico + */ + static async getServerTools(id: string, retryCount = 0): Promise { + const maxRetries = 3; + const baseDelay = 1000; + + try { + // Verificar cache primeiro + const cacheKey = MCPCache.generateServerToolsKey(id); + const cachedResult = MCPCache.get(cacheKey); + + if (cachedResult) { + console.log('Returning cached server tools'); + return cachedResult; + } + + const [owner, repo] = id.split('/'); + if (!owner || !repo) { + throw new Error(`Invalid MCP ID format: ${id}. Expected format: owner/repo`); + } + + const url = `${DEEPNLP_API_BASE}/tools/${owner}/${repo}`; + console.log(`DeepNLP Server Tools Request (attempt ${retryCount + 1}):`, url); + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ollahub-MCP-Client/1.0' + }, + signal: AbortSignal.timeout(10000) + }); + + if (!response.ok) { + throw new Error(`DeepNLP Server Tools API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + console.log('DeepNLP Server Tools Response:', data); + + const tools = data.tools || []; + + // Salvar no cache + MCPCache.setWithDataType(cacheKey, tools, 'tools'); + + return tools; + } catch (error) { + console.error(`Error getting server tools from DeepNLP (attempt ${retryCount + 1}):`, error); + + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + console.log(`Retrying server tools in ${delay}ms...`); + + await new Promise(resolve => setTimeout(resolve, delay)); + return this.getServerTools(id, retryCount + 1); + } + + throw new Error(`Failed to get server tools after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Transformar resposta DeepNLP para formato interno MCPProvider + */ + static transformToMCPProvider(deepnlpItem: any): MCPProvider { + const category = this.mapCategory(deepnlpItem.subfield || 'other'); + + return { + id: deepnlpItem.id || '', + name: deepnlpItem.content_name || '', + author: deepnlpItem.publisher_id || '', + description: deepnlpItem.description || '', + version: '1.0.0', // DeepNLP não fornece versão específica + category, + tags: deepnlpItem.content_tag_list ? deepnlpItem.content_tag_list.split(',').map((tag: string) => tag.trim()) : [], + rating: parseFloat(deepnlpItem.rating || '0'), + totalRatings: parseInt(deepnlpItem.review_cnt || '0'), + downloads: parseInt(deepnlpItem.review_cnt || '0') * 10, // Estimativa baseada em reviews + iconUrl: deepnlpItem.thumbnail_picture, + screenshotUrls: [], + capabilities: deepnlpItem.ext_info?.tools || [], + repository: deepnlpItem.website, + homepage: deepnlpItem.website, + installed: false, // Será verificado localmente + subfield: deepnlpItem.subfield, + field: deepnlpItem.field, + config: deepnlpItem.config, + tools: deepnlpItem.ext_info?.tools || [] + }; + } + + /** + * Mapear categoria DeepNLP para categoria interna + */ + static mapCategory(subfield: string): MCPProvider['category'] { + return this.reverseCategoryMap[subfield?.toLowerCase()] || 'other'; + } + + /** + * Obter mapeamento de categoria interna para DeepNLP + */ + static getCategoryMapping(internalCategory: string): string | undefined { + const mappings = this.categoryMap[internalCategory as keyof typeof this.categoryMap]; + return mappings?.[0]; // Retorna a primeira categoria DeepNLP correspondente + } + + /** + * Verificar se a API está disponível + */ + static async checkHealth(): Promise { + try { + const response = await fetch(`${DEEPNLP_API_BASE}?count_per_page=1`, { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'User-Agent': 'Ollahub-MCP-Client/1.0' + } + }); + return response.ok; + } catch (error) { + console.error('DeepNLP API health check failed:', error); + return false; + } + } +} diff --git a/lib/services/mcp-sync.ts b/lib/services/mcp-sync.ts new file mode 100644 index 0000000..1e942cc --- /dev/null +++ b/lib/services/mcp-sync.ts @@ -0,0 +1,330 @@ +import { DeepNLPService } from "./deepnlp"; +import { MCPCacheRepository } from "@/database/repositories/mcp-cache"; + +export interface SyncProgress { + current: number; + total: number; + page: number; + totalPages: number; +} + +export interface SyncResult { + success: boolean; + totalDownloaded: number; + errors: string[]; + duration: number; +} + +export class MCPSyncService { + private static isSyncingFlag = false; + private static abortController: AbortController | null = null; + private static currentProgress: SyncProgress | null = null; + + /** + * Download completo com paginação incremental + */ + static async syncAll( + onProgress?: (progress: SyncProgress) => void + ): Promise { + const startTime = Date.now(); + const errors: string[] = []; + let totalDownloaded = 0; + + try { + if (this.isSyncingFlag) { + throw new Error("Sync already in progress"); + } + + this.isSyncingFlag = true; + this.abortController = new AbortController(); + + console.log("🔄 Starting MCP marketplace sync..."); + + // Limpar cache antigo antes de começar + MCPCacheRepository.clearCache(); + + const allMCPs = await this.downloadAllPages(onProgress); + totalDownloaded = allMCPs.length; + + // Salvar todos os MCPs no banco + if (allMCPs.length > 0) { + MCPCacheRepository.saveBatch(allMCPs); + + // Atualizar metadados + MCPCacheRepository.updateMetadata("last_sync", Date.now().toString()); + MCPCacheRepository.updateMetadata( + "total_items", + allMCPs.length.toString() + ); + + console.log(`✅ Sync completed: ${totalDownloaded} MCPs downloaded`); + } + + const duration = Date.now() - startTime; + return { + success: true, + totalDownloaded, + errors, + duration, + }; + } catch (error: any) { + console.error("❌ Sync failed:", error); + errors.push(error.message); + + const duration = Date.now() - startTime; + return { + success: false, + totalDownloaded, + errors, + duration, + }; + } finally { + this.isSyncingFlag = false; + this.abortController = null; + this.currentProgress = null; + } + } + + /** + * Sincronização em background (usado automaticamente) + */ + static async backgroundSync(): Promise { + try { + if (this.isSyncingFlag) { + console.log("Sync already in progress, skipping background sync"); + return; + } + + console.log("🔄 Starting background MCP sync..."); + await this.syncAll((progress) => { + console.log( + `📥 Downloaded ${progress.current}/${progress.total} MCPs (page ${progress.page}/${progress.totalPages})` + ); + }); + } catch (error) { + console.error("Background sync failed:", error); + } + } + + /** + * Verificar se sincronização está em andamento + */ + static isSyncing(): boolean { + return this.isSyncingFlag; + } + + /** + * Cancelar sincronização atual + */ + static cancelSync(): void { + if (this.abortController) { + this.abortController.abort(); + console.log("🛑 Sync cancelled by user"); + } + } + + /** + * Obter progresso atual da sincronização + */ + static getCurrentProgress(): SyncProgress | null { + return this.currentProgress; + } + + /** + * Download paginado de todas as páginas - SEM LIMITE ARTIFICIAL + */ + private static async downloadAllPages( + onProgress?: (progress: SyncProgress) => void + ): Promise { + const allMCPs: any[] = []; + let page = 0; + let COUNT_PER_PAGE = 100; // Começar com 100 + let totalHits = 0; + let consecutiveEmptyPages = 0; // Contador para páginas vazias consecutivas + + console.log("📡 Starting unlimited paginated download..."); + + while (!this.abortController?.signal.aborted) { + try { + const params = { + mode: "list" as const, + page_id: page, + count_per_page: COUNT_PER_PAGE, + }; + + console.log( + `📄 Downloading page ${ + page + 1 + } (${COUNT_PER_PAGE} items per page)...` + ); + + const response = await DeepNLPService.searchMCPs(params); + + // Se não há itens na resposta, incrementar contador de páginas vazias + if (!response.items || response.items.length === 0) { + consecutiveEmptyPages++; + console.log( + `⚠️ Empty page ${ + page + 1 + } (${consecutiveEmptyPages} consecutive empty pages)` + ); + + // Se temos 3 páginas vazias consecutivas, parar o download + if (consecutiveEmptyPages >= 3) { + console.log( + "🛑 Stopping download: 3 consecutive empty pages detected" + ); + break; + } + + page++; + continue; + } + + // Reset contador de páginas vazias se encontramos dados + consecutiveEmptyPages = 0; + allMCPs.push(...response.items); + + // Atualizar total hits na primeira página + if (page === 0) { + totalHits = response.total_hits; + console.log(`📊 Total MCPs available: ${totalHits}`); + } + + // Notificar progresso + this.currentProgress = { + current: allMCPs.length, + total: totalHits, + page: page + 1, + totalPages: + totalHits > 0 ? Math.ceil(totalHits / COUNT_PER_PAGE) : page + 1, + }; + + if (onProgress) { + onProgress(this.currentProgress); + } + + // Incrementar página + page++; + + // Salvar em batch a cada 500 itens para evitar bloqueio + if (allMCPs.length % 500 === 0) { + console.log(`💾 Saving batch of 500 MCPs to database...`); + MCPCacheRepository.saveBatch(allMCPs.slice(-500)); + } + + // Pequena pausa entre requisições para não sobrecarregar a API + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Aumentar tamanho da página progressivamente para otimizar + if (page === 5 && COUNT_PER_PAGE === 100) { + COUNT_PER_PAGE = 500; + console.log("🚀 Increasing page size to 500 for faster download"); + } else if (page === 20 && COUNT_PER_PAGE === 500) { + COUNT_PER_PAGE = 1000; + console.log("🚀 Increasing page size to 1000 for maximum efficiency"); + } + + // Log de progresso a cada 10 páginas + if (page % 10 === 0) { + console.log( + `📈 Progress: ${allMCPs.length} MCPs downloaded (page ${page})` + ); + } + } catch (error: any) { + console.error(`❌ Error downloading page ${page + 1}:`, error); + + // Se for erro de abort, parar + if (this.abortController?.signal.aborted) { + console.log("🛑 Download cancelled"); + break; + } + + // Se for erro de rede, tentar continuar + if ( + error.message.includes("fetch") || + error.message.includes("network") + ) { + console.log("🔄 Network error, retrying in 2 seconds..."); + await new Promise((resolve) => setTimeout(resolve, 2000)); + continue; + } + + // Para outros erros, parar + throw error; + } + } + + // Salvar último batch se houver + if (allMCPs.length > 0) { + const remainingItems = allMCPs.length % 500; + if (remainingItems > 0) { + console.log(`💾 Saving final batch of ${remainingItems} MCPs...`); + MCPCacheRepository.saveBatch(allMCPs.slice(-remainingItems)); + } + } + + console.log( + `✅ Download completed: ${allMCPs.length} total MCPs downloaded` + ); + return allMCPs; + } + + /** + * Verificar se precisa sincronizar + */ + static needsSync(): boolean { + return MCPCacheRepository.needsSync(); + } + + /** + * Obter estatísticas do cache + */ + static getCacheStats() { + return MCPCacheRepository.getStats(); + } + + /** + * Forçar limpeza do cache + */ + static clearCache(): void { + MCPCacheRepository.clearCache(); + console.log("🗑️ Cache cleared"); + } + + /** + * Verificar saúde do sistema de cache + */ + static async healthCheck(): Promise<{ + cacheExists: boolean; + cacheValid: boolean; + totalItems: number; + lastSync: number | null; + needsSync: boolean; + }> { + try { + const cacheExists = MCPCacheRepository.tableExists( + "mcp_marketplace_cache" + ); + const stats = MCPCacheRepository.getStats(); + const needsSync = MCPCacheRepository.needsSync(); + + return { + cacheExists, + cacheValid: stats.isValid, + totalItems: stats.totalItems, + lastSync: stats.lastSync, + needsSync, + }; + } catch (error) { + console.error("Health check failed:", error); + return { + cacheExists: false, + cacheValid: false, + totalItems: 0, + lastSync: null, + needsSync: true, + }; + } + } +} diff --git a/lib/types/mcp.ts b/lib/types/mcp.ts new file mode 100644 index 0000000..397bbfe --- /dev/null +++ b/lib/types/mcp.ts @@ -0,0 +1,94 @@ +export interface MCPProvider { + id: string; // formato: "owner/repo" + name: string; // mapeado de content_name + author: string; // mapeado de publisher_id + description: string; + version: string; + category: + | "map" + | "browser" + | "office" + | "search" + | "database" + | "finance" + | "code" + | "chart" + | "payment" + | "other"; + tags: string[]; // mapeado de content_tag_list + rating: number; // já existe na API + totalRatings: number; // mapeado de review_cnt + downloads: number; // opcional, calcular baseado em reviews + iconUrl?: string; // mapeado de thumbnail_picture + screenshotUrls?: string[]; + capabilities: string[]; // mapeado de ext_info.tools + repository?: string; // mapeado de website + homepage?: string; + installed: boolean; // verificar localmente + // Novos campos da API DeepNLP + subfield?: string; // MAP, BROWSER, etc + field?: string; // sempre "MCP SERVER" + config?: any[]; // configuração JSON do servidor + tools?: MCPTool[]; // schemas das ferramentas +} + +export interface MCPCategory { + id: string; + name: string; + icon: string; + count: number; +} + +export interface MCPInstallationStatus { + success: boolean; + message: string; + mcpId: string; +} + +export interface MCPTool { + name: string; + description: string; + input_schema: { + type: string; + properties: Record; + required?: string[]; + }; +} + +export interface DeepNLPSearchParams { + query?: string; + category?: string; + id?: string; + page_id?: number; + count_per_page?: number; + offset?: number; + mode?: "list" | "dict"; +} + +export interface DeepNLPResponse { + query?: string; + items: any[]; + count: number; + total_hits: number; +} + +export interface MCPServerConfig { + total_hits: number; + id: string; + items: any[]; +} + +export interface MCPSearchParams { + category?: string; + search?: string; + sort?: + | "rating" + | "downloads" + | "name" + | "recent" + | "total_ratings" + | "updated_at"; + order?: "asc" | "desc"; + limit?: number; + offset?: number; +} diff --git a/scripts/init-db.ts b/scripts/init-db.ts index a5358bb..b75d2f6 100644 --- a/scripts/init-db.ts +++ b/scripts/init-db.ts @@ -61,6 +61,88 @@ function runMigrations(db: Database.Database): void { console.log("✅ Migração 001_initial_schema aplicada"); } + // Aplicar migração para tabela MCP se não foi aplicada + if (!appliedNames.has("002_mcp_installations")) { + console.log("📋 Aplicando migração: 002_mcp_installations"); + + db.exec(` + -- Tabela para gerenciar MCPs instalados + CREATE TABLE IF NOT EXISTS mcp_installations ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + name TEXT NOT NULL, + config JSON NOT NULL, + tools JSON, + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Índices para performance + CREATE INDEX IF NOT EXISTS idx_mcp_owner_repo ON mcp_installations(owner, repo); + CREATE INDEX IF NOT EXISTS idx_mcp_installed_at ON mcp_installations(installed_at DESC); + `); + + // Registrar migração + db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run( + "002_mcp_installations", + Date.now() + ); + + console.log("✅ Migração 002_mcp_installations aplicada"); + } + + // Aplicar migração para tabela MCP marketplace cache se não foi aplicada + if (!appliedNames.has("003_mcp_marketplace_cache")) { + console.log("📋 Aplicando migração: 003_mcp_marketplace_cache"); + + db.exec(` + -- Tabela principal de cache + CREATE TABLE IF NOT EXISTS mcp_marketplace_cache ( + id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + repo TEXT NOT NULL, + content_name TEXT NOT NULL, + publisher_id TEXT NOT NULL, + description TEXT, + category TEXT, + subfield TEXT, + field TEXT, + rating REAL DEFAULT 0, + review_cnt INTEGER DEFAULT 0, + content_tag_list TEXT, + thumbnail_picture TEXT, + website TEXT, + detail_url TEXT, + ext_info JSON, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Metadados + CREATE TABLE IF NOT EXISTS mcp_cache_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + + -- Índices + CREATE INDEX IF NOT EXISTS idx_mcp_cache_category ON mcp_marketplace_cache(category); + CREATE INDEX IF NOT EXISTS idx_mcp_cache_subfield ON mcp_marketplace_cache(subfield); + CREATE INDEX IF NOT EXISTS idx_mcp_cache_rating ON mcp_marketplace_cache(rating DESC); + CREATE INDEX IF NOT EXISTS idx_mcp_cache_owner_repo ON mcp_marketplace_cache(owner, repo); + CREATE INDEX IF NOT EXISTS idx_mcp_cache_search ON mcp_marketplace_cache(content_name, description); + `); + + // Registrar migração + db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run( + "003_mcp_marketplace_cache", + Date.now() + ); + + console.log("✅ Migração 003_mcp_marketplace_cache aplicada"); + } + // Inicializar extensão vetorial se disponível try { const extPath = path.join( @@ -147,8 +229,24 @@ if (require.main === module) { initializeTitleModel(), initializeEmbeddingModel(), ]) - .then(() => { + .then(async () => { console.log("🎉 Inicialização concluída"); + + // Iniciar sincronização do marketplace se necessário + try { + const { MCPSyncService } = await import("@/lib/services/mcp-sync"); + const { MCPCacheRepository } = await import("@/database/repositories/mcp-cache"); + + if (MCPCacheRepository.needsSync()) { + console.log("🔄 Iniciando sincronização inicial do marketplace..."); + MCPSyncService.backgroundSync(); + } else { + console.log("✅ Cache do marketplace já está atualizado"); + } + } catch (error) { + console.log("⚠️ Não foi possível inicializar sincronização do marketplace:", error); + } + process.exit(0); }) .catch((error) => {