feat: add MCP marketplace and custom scrollbar styles

Add comprehensive CSS styles for the MCP Marketplace interface, including card hover effects, responsive grid layout, modal backdrop blur, category badges, rating stars, install buttons, and loading animations. Also include custom scrollbar styling for the model selector to enhance user experience and visual consistency.
This commit is contained in:
Evandro Fonseca Junior committed 2025-10-28 19:40:45 -03:00
1 parent d5dd3a9f09
commit ce010d39ee
31 files changed
+4127 -45

No files matched your search

+68
View File
@@ -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 }
);
}
}
+62
View File
@@ -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 }
);
}
}
+158
View File
@@ -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,
});
}
}
+91
View File
@@ -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 }
);
}
}
+46
View File
@@ -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 }
);
}
}
+28
View File
@@ -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 }
);
}
}
+22
View File
@@ -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 }
);
}
}
+131
View File
@@ -31,3 +31,134 @@ body {
color: var(--foreground); color: var(--foreground);
font-family: var(--font-inter), ui-sans-serif, system-ui; 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);
}
+14
View File
@@ -8,6 +8,8 @@ import {
SettingsButton, SettingsButton,
SettingsModal, SettingsModal,
} from "@/components/settings/SettingsModal"; } from "@/components/settings/SettingsModal";
import { MCPMarketplaceModal } from "@/components/mcp/MCPMarketplaceModal";
import { Store } from "lucide-react";
interface ClientHomeProps { interface ClientHomeProps {
offline: boolean; offline: boolean;
@@ -20,6 +22,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
const [currentConversationId, setCurrentConversationId] = useState< const [currentConversationId, setCurrentConversationId] = useState<
string | null string | null
>(null); >(null);
const [showMCPMarketplace, setShowMCPMarketplace] = useState(false);
const handleConversationCreated = () => { const handleConversationCreated = () => {
// Incrementar trigger para forçar atualização da sidebar // Incrementar trigger para forçar atualização da sidebar
@@ -45,6 +48,13 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
Ollama offline Ollama offline
</span> </span>
)} )}
<button
onClick={() => setShowMCPMarketplace(true)}
className="flex items-center gap-2 px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors text-sm"
>
<Store className="h-4 w-4" />
MCP Marketplace
</button>
<SettingsButton onOpen={() => setOpen(true)} /> <SettingsButton onOpen={() => setOpen(true)} />
<ModeToggle /> <ModeToggle />
</div> </div>
@@ -67,6 +77,10 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
</div> </div>
</main> </main>
<SettingsModal isOpen={open} onClose={() => setOpen(false)} /> <SettingsModal isOpen={open} onClose={() => setOpen(false)} />
<MCPMarketplaceModal
isOpen={showMCPMarketplace}
onClose={() => setShowMCPMarketplace(false)}
/>
</div> </div>
); );
} }
+22
View File
@@ -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) => { const handlePullModel = async (modelName: string) => {
try { try {
const response = await fetch("/api/ollama/pull", { const response = await fetch("/api/ollama/pull", {
@@ -340,6 +360,8 @@ export function ChatContainer({
models={models} models={models}
selectedModel={selectedModel} selectedModel={selectedModel}
onSelectModel={handleSelectModel} onSelectModel={handleSelectModel}
onDeleteModel={handleDeleteModel}
onPullModel={handlePullModel}
disabled={isStreaming} disabled={isStreaming}
/> />
)} )}
+174
View File
@@ -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 (
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
{mcp.iconUrl ? (
<img
src={mcp.iconUrl}
alt={mcp.name}
className="h-8 w-8 rounded-md object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div className={`p-2 bg-[var(--primary)]/10 rounded-md ${mcp.iconUrl ? 'hidden' : ''}`}>
<Package className="h-4 w-4 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">{mcp.name}</h3>
{isOfficial && (
<CheckCircle className="h-3 w-3 text-green-500" />
)}
</div>
<p className="text-xs text-[var(--muted-foreground)]">by {mcp.author}</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(mcp.category)}`}>
{mcp.category}
</span>
</div>
{/* Description */}
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
{mcp.description}
</p>
{/* Stats */}
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
<span>{mcp.rating}</span>
<span>({mcp.totalRatings})</span>
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3" />
<span>{formatDownloads(mcp.downloads)}</span>
</div>
{toolCount > 0 && (
<div className="flex items-center gap-1">
<Zap className="h-3 w-3" />
<span>{toolCount} tools</span>
</div>
)}
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
v{mcp.version}
</span>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-4">
{mcp.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
>
{tag}
</span>
))}
{mcp.tags.length > 3 && (
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
+{mcp.tags.length - 3}
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onViewDetails}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors text-sm"
>
<Eye className="h-4 w-4" />
Details
</button>
{mcp.installed ? (
<button
onClick={onUninstall}
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
>
Uninstall
</button>
) : (
<button
onClick={onInstall}
className="px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button"
>
Install
</button>
)}
</div>
{/* Links */}
{(mcp.repository || mcp.homepage) && (
<div className="mt-3 pt-3 border-t border-[var(--border)]">
<div className="flex gap-2">
{mcp.repository && (
<a
href={mcp.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Repository
</a>
)}
{mcp.homepage && (
<a
href={mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Homepage
</a>
)}
</div>
</div>
)}
</div>
);
}
+136
View File
@@ -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<string, string> = {
MAP: "🗺️",
BROWSER: "🌐",
OFFICE: "📄",
CODE: "💻",
DATABASE: "🗄️",
SEARCH: "🔍",
PAYMENT: "💳",
CHART: "📊",
FINANCE: "💰",
OTHER: "📦",
};
return iconMap[categoryName.toUpperCase()] || "📦";
};
return (
<div className="space-y-2">
<h3 className="font-semibold text-sm text-[var(--foreground)] mb-3">
Categories
</h3>
{/* All Categories */}
<button
onClick={() => onSelectCategory("all")}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
selectedCategory === "all"
? "mcp-category-active"
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<span className="flex items-center gap-2">📦 All Categories</span>
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
{primaryCategories.reduce((sum, cat) => sum + cat.count, 0) +
otherCategories.reduce((sum, cat) => sum + cat.count, 0)}
</span>
</button>
{/* Primary Categories */}
<div className="space-y-1">
<h4 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
Principais
</h4>
{primaryCategories.map((category) => (
<button
key={category.id}
onClick={() => onSelectCategory(category.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
selectedCategory === category.id
? "mcp-category-active"
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<span className="flex items-center gap-2">
{getCategoryIcon(category.name)} {category.name}
</span>
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
{category.count}
</span>
</button>
))}
</div>
{/* Other Categories */}
{otherCategories.length > 0 && (
<div className="space-y-1">
<button
onClick={() => setShowOthers(!showOthers)}
className="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="flex items-center gap-2">
{showOthers ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="text-xs font-medium uppercase tracking-wide">
Outras ({otherCategories.length})
</span>
</span>
</button>
{showOthers && (
<div className="ml-4 space-y-1">
{otherCategories.map((category) => (
<button
key={category.id}
onClick={() => onSelectCategory(category.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
selectedCategory === category.id
? "mcp-category-active"
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<span className="flex items-center gap-2">
📦 {category.name}
</span>
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
{category.count}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+315
View File
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl mcp-detail-modal">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
<Package className="h-8 w-8 text-[var(--primary)]" />
</div>
<div>
<h2 className="text-2xl font-bold">{mcp.name}</h2>
<p className="text-[var(--muted-foreground)]">by {mcp.author}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium mcp-category-badge ${getCategoryColor(mcp.category)}`}>
{mcp.category}
</span>
{mcp.installed && (
<div className="flex items-center gap-1 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium">Installed</span>
</div>
)}
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Stats */}
<div className="p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<MCPRatingStars rating={mcp.rating} size="md" />
<span className="text-sm text-[var(--muted-foreground)]">
({mcp.totalRatings} reviews)
</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-[var(--muted-foreground)]" />
<span className="text-sm">{formatDownloads(mcp.downloads)} downloads</span>
</div>
<span className="text-sm text-[var(--muted-foreground)]">Version {mcp.version}</span>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-[var(--border)]">
{[
{ id: "about", label: "About" },
{ id: "capabilities", label: "Capabilities" },
{ id: "configuration", label: "Configuration" },
{ id: "screenshots", label: "Screenshots" },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 p-6 overflow-auto">
{activeTab === "about" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">Description</h3>
<p className="text-[var(--muted-foreground)] leading-relaxed">
{mcp.description}
</p>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{mcp.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 bg-[var(--surface)] text-sm rounded-md border border-[var(--border)] mcp-tag"
>
{tag}
</span>
))}
</div>
</div>
{(mcp.repository || mcp.homepage) && (
<div>
<h3 className="text-lg font-semibold mb-3">Links</h3>
<div className="flex gap-4">
{mcp.repository && (
<a
href={mcp.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
>
<ExternalLink className="h-4 w-4" />
Repository
</a>
)}
{mcp.homepage && (
<a
href={mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
>
<ExternalLink className="h-4 w-4" />
Homepage
</a>
)}
</div>
</div>
)}
</div>
)}
{activeTab === "capabilities" && (
<div>
<h3 className="text-lg font-semibold mb-3">Capabilities</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(mcp.tools || mcp.capabilities || []).map((capability, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-[var(--surface)] rounded-md border border-[var(--border)]"
>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm">
{typeof capability === 'string' ? capability : (capability as any).name || String(capability)}
</span>
</div>
))}
</div>
</div>
)}
{activeTab === "configuration" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">Installation Configuration</h3>
{mcp.config && mcp.config.length > 0 ? (
<div className="space-y-4">
{mcp.config.map((configItem, index) => (
<div key={index} className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4">
<h4 className="font-medium mb-3">Configuration {index + 1}</h4>
<div className="space-y-3">
{configItem.mcpServers && (
<div>
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Command</h5>
<code className="block bg-[var(--background)] p-2 rounded text-sm">
{configItem.mcpServers.command} {configItem.mcpServers.arguments?.join(' ')}
</code>
</div>
)}
{configItem.mcpServers?.env && Object.keys(configItem.mcpServers.env).length > 0 && (
<div>
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Environment Variables</h5>
<div className="space-y-2">
{Object.entries(configItem.mcpServers.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
{key}={String(value)}
</code>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No configuration available for this MCP</p>
</div>
)}
</div>
{mcp.tools && mcp.tools.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Available Tools</h3>
<div className="space-y-3">
{mcp.tools.map((tool, index) => (
<div key={index} className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4">
<h4 className="font-medium mb-2">{tool.name}</h4>
<p className="text-sm text-[var(--muted-foreground)] mb-3">{tool.description}</p>
{tool.input_schema && (
<div>
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Parameters</h5>
<div className="space-y-2">
{Object.entries(tool.input_schema.properties || {}).map(([param, schema]) => (
<div key={param} className="flex items-center gap-2">
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
{param}
</code>
<span className="text-xs text-[var(--muted-foreground)]">
{typeof schema === 'object' && schema ? (schema as any).type || 'any' : 'any'}
{tool.input_schema.required?.includes(param) && (
<span className="text-red-500 ml-1">*</span>
)}
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === "screenshots" && (
<div>
<h3 className="text-lg font-semibold mb-3">Screenshots</h3>
{mcp.screenshotUrls && mcp.screenshotUrls.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{mcp.screenshotUrls.map((url, index) => (
<img
key={index}
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-md border border-[var(--border)]"
/>
))}
</div>
) : (
<div className="text-center py-12 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No screenshots available</p>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-[var(--border)]">
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
>
Close
</button>
{!mcp.installed && (
<button
onClick={onInstall}
className="px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors mcp-install-button"
>
Install MCP
</button>
)}
</div>
</div>
</div>
</div>
);
}
+345
View File
@@ -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<MCPProvider[]>([]);
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<MCPProvider | null>(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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-7xl h-[95vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border)] flex-shrink-0">
<div className="flex items-center gap-3">
<Package className="h-6 w-6 text-[var(--primary)]" />
<h2 className="text-xl font-semibold">MCP Marketplace</h2>
<span className="text-sm text-[var(--muted-foreground)]">
{mcps.length} MCPs available
</span>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Sync Status Banner */}
{syncStatus?.isSyncing && (
<div className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm text-blue-700 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span>
Sincronizando marketplace... {syncStatus.totalCached} servidores
carregados
</span>
</div>
</div>
)}
{syncStatus?.lastSync && !syncStatus.isSyncing && (
<div className="bg-green-50 border-b border-green-200 px-4 py-2 text-sm text-green-700 flex-shrink-0">
<div className="flex items-center justify-between">
<span>
Cache local ativo • {syncStatus.totalCached} servidores • Última
atualização: {new Date(syncStatus.lastSync).toLocaleString()}
</span>
<button
onClick={() => {
fetch("/api/mcp/sync", { method: "POST" })
.then(() => {
// Recarregar status após iniciar sync
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);
}}
className="px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors"
>
Atualizar
</button>
</div>
</div>
)}
{/* Content */}
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
<MCPCategoryFilter
primaryCategories={categories.primary.map((cat) => ({
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}
/>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0">
{/* Search and Filters */}
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
<div className="flex gap-4 items-center">
<MCPSearchBar
value={searchTerm}
onChange={setSearchTerm}
onSearch={() => {
// O useEffect já cuida de recarregar quando searchTerm muda
}}
/>
<div className="flex gap-2 items-center">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
>
<option value="rating">Por Avaliação</option>
<option value="downloads">Por Downloads</option>
<option value="name">Por Nome</option>
<option value="recent">Mais Recentes</option>
<option value="total_ratings">Por Nº de Reviews</option>
<option value="updated_at">Atualizados Recentemente</option>
</select>
<button
onClick={() =>
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))
}
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
title={
sortOrder === "asc"
? "Ordem Crescente"
: "Ordem Decrescente"
}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
</div>
{/* MCP Grid */}
<div className="flex-1 p-4 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mcps.map((mcp) => (
<MCPCard
key={mcp.id}
mcp={mcp}
onInstall={() => handleInstall(mcp.id)}
onUninstall={() => handleUninstall(mcp.id)}
onViewDetails={() => handleViewDetails(mcp)}
/>
))}
</div>
)}
{!loading && mcps.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>No MCPs found</p>
<p className="text-sm">
Try adjusting your search or filters
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+74
View File
@@ -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(
<Star
key={i}
className={`${sizeClasses[size]} fill-yellow-400 text-yellow-400`}
/>
);
}
// Renderizar meia estrela se necessário
if (hasHalfStar) {
stars.push(
<div key="half" className="relative">
<Star className={`${sizeClasses[size]} text-gray-300`} />
<div
className="absolute inset-0 overflow-hidden"
style={{ width: "50%" }}
>
<Star
className={`${sizeClasses[size]} fill-yellow-400 text-yellow-400`}
/>
</div>
</div>
);
}
// Renderizar estrelas vazias
const emptyStars = 5 - Math.ceil(rating);
for (let i = 0; i < emptyStars; i++) {
stars.push(
<Star
key={`empty-${i}`}
className={`${sizeClasses[size]} text-gray-300`}
/>
);
}
return (
<div className="flex items-center gap-1">
<div className="flex">{stars}</div>
{showNumber && (
<span className="text-sm text-[var(--muted-foreground)] ml-1">
{rating.toFixed(1)}
</span>
)}
</div>
);
}
+31
View File
@@ -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 (
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--muted-foreground)]" />
<input
type="text"
value={value}
onChange={(e) => 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"
/>
</div>
);
}
+393 -44
View File
@@ -1,13 +1,30 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; 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"; import type { ModelInfo } from "@/lib/models";
interface ModelDropdownProps { interface ModelDropdownProps {
models: ModelInfo[]; models: ModelInfo[];
selectedModel: string | null; selectedModel: string | null;
onSelectModel: (modelId: string) => void; onSelectModel: (modelId: string) => void;
onDeleteModel?: (modelId: string) => void;
onPullModel?: (modelName: string) => void;
disabled?: boolean; disabled?: boolean;
} }
@@ -15,12 +32,108 @@ export function ModelDropdown({
models, models,
selectedModel, selectedModel,
onSelectModel, onSelectModel,
onDeleteModel,
onPullModel,
disabled = false, disabled = false,
}: ModelDropdownProps) { }: ModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [filterDevice, setFilterDevice] = useState<"all" | "CPU" | "GPU">(
"all"
);
const [showRemote, setShowRemote] = useState(false);
const [remoteModels, setRemoteModels] = useState<any[]>([]);
const [isSearchingRemote, setIsSearchingRemote] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
null
);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(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 <Zap className="h-3 w-3" />;
case "balanced":
return <TrendingUp className="h-3 w-3" />;
case "powerful":
return <CircuitBoard className="h-3 w-3" />;
default:
return <Cpu className="h-3 w-3" />;
}
};
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
@@ -29,6 +142,11 @@ export function ModelDropdown({
!dropdownRef.current.contains(event.target as Node) !dropdownRef.current.contains(event.target as Node)
) { ) {
setIsOpen(false); setIsOpen(false);
setSearchTerm("");
setFilterDevice("all");
setShowRemote(false);
setRemoteModels([]);
setShowDeleteConfirm(null);
} }
} }
@@ -36,6 +154,18 @@ export function ModelDropdown({
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, []); }, []);
// Buscar modelos remotos quando necessário
useEffect(() => {
if (showRemote && searchTerm.length > 2) {
const timeoutId = setTimeout(() => {
searchRemoteModels(searchTerm);
}, 500);
return () => clearTimeout(timeoutId);
}
}, [showRemote, searchTerm]);
const selectedModelData = models.find((m) => m.id === selectedModel);
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
@@ -46,15 +176,27 @@ export function ModelDropdown({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedModelData ? ( {selectedModelData ? (
<> <>
<span className="truncate">{selectedModelData.name}</span> <span className="truncate font-medium">
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs"> {selectedModelData.name}
{selectedModelData.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{selectedModelData.device}
</span> </span>
<div className="flex items-center gap-1">
<span
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
selectedModelData
)}`}
>
{getPerformanceIcon(selectedModelData)}
{categorizeModel(selectedModelData)}
</span>
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
{selectedModelData.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{selectedModelData.device}
</span>
</div>
</> </>
) : ( ) : (
<span className="text-[var(--foreground)]/60"> <span className="text-[var(--foreground)]/60">
@@ -70,43 +212,250 @@ export function ModelDropdown({
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute top-full z-50 mt-1 w-full rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-lg"> <div className="absolute top-full z-50 mt-1 w-full min-w-[400px] rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-xl">
<div className="max-h-60 overflow-y-auto"> {/* Header com busca e filtros */}
{models.length === 0 ? ( <div className="border-b border-[var(--border)] p-3">
<div className="px-3 py-2 text-sm text-[var(--foreground)]/60"> <div className="flex items-center gap-2 mb-2">
No models available <div className="relative flex-1">
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground)]/40" />
<input
type="text"
placeholder="Search models..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--foreground)]/40 hover:text-[var(--foreground)]/60"
>
<X className="h-4 w-4" />
</button>
)}
</div> </div>
) : ( <div className="flex items-center gap-1">
models.map((model) => ( <Filter className="h-4 w-4 text-[var(--foreground)]/40" />
<button <select
key={model.id} value={filterDevice}
onClick={() => { onChange={(e) =>
onSelectModel(model.id); setFilterDevice(e.target.value as "all" | "CPU" | "GPU")
setIsOpen(false); }
}} className="rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--accent)]"
className="flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--surface)]"
> >
<div className="flex flex-col items-start gap-1"> <option value="all">All Devices</option>
<span className="font-medium">{model.name}</span> <option value="CPU">CPU Only</option>
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60"> <option value="GPU">GPU Only</option>
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5"> </select>
{model.quantization} </div>
</span> </div>
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.sizeGB} GB {/* Toggle para busca remota */}
</span> <div className="flex items-center justify-between">
<span className="flex items-center gap-1"> <div className="flex items-center gap-2">
{model.device === "GPU" ? ( <button
<CircuitBoard className="h-3 w-3" /> onClick={() => setShowRemote(!showRemote)}
) : ( className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
<Cpu className="h-3 w-3" /> showRemote
)} ? "bg-[var(--accent)] text-white"
{model.device} : "bg-[var(--surface)] text-[var(--foreground)]/60 hover:bg-[var(--surface)]/80"
</span> }`}
</div> >
</div> <Cloud className="h-3 w-3" />
Remote Search
</button> </button>
)) <div className="text-xs text-[var(--foreground)]/60">
{showRemote
? `${remoteModels.length} remote`
: `${filteredModels.length} local`}{" "}
model
{(showRemote
? remoteModels.length
: filteredModels.length) !== 1
? "s"
: ""}{" "}
found
</div>
</div>
</div>
</div>
{/* Lista de modelos com scroll customizado */}
<div className="max-h-80 overflow-y-auto custom-scrollbar">
{showRemote ? (
// Lista de modelos remotos
<>
{isSearchingRemote ? (
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--accent)] mx-auto mb-2"></div>
Searching remote models...
</div>
) : remoteModels.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
<Cloud className="h-8 w-8 mx-auto mb-2 opacity-40" />
No remote models found
</div>
) : (
<div className="p-2">
{remoteModels.map((model) => (
<div
key={model.name}
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 mb-2"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{model.name}
</span>
<span className="flex items-center gap-1 rounded-md bg-blue-50 text-blue-600 border border-blue-200 px-2 py-0.5 text-xs">
<Cloud className="h-3 w-3" />
Remote
</span>
{model.installed && (
<span className="flex items-center gap-1 rounded-md bg-green-50 text-green-600 border border-green-200 px-2 py-0.5 text-xs">
<CheckCircle className="h-3 w-3" />
Installed
</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-xs text-[var(--foreground)]/50">
Available for download
</div>
<button
onClick={() => handlePullModel(model.name)}
disabled={model.installed}
className="flex items-center gap-1 rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="h-3 w-3" />
{model.installed ? "Installed" : "Download"}
</button>
</div>
</div>
))}
</div>
)}
</>
) : (
// Lista de modelos locais
<>
{filteredModels.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
<Search className="h-8 w-8 mx-auto mb-2 opacity-40" />
No models found
</div>
) : (
<div className="p-2">
{filteredModels.map((model) => (
<div
key={model.id}
className={`w-full rounded-lg border p-3 mb-2 transition-all hover:shadow-md ${
selectedModel === model.id
? "border-[var(--accent)] bg-[var(--accent)]/5"
: "border-[var(--border)] bg-[var(--background)] hover:bg-[var(--surface)]"
}`}
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">
{model.name}
</span>
<span
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
model
)}`}
>
{getPerformanceIcon(model)}
{categorizeModel(model)}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
<span className="flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{model.sizeGB} GB
</span>
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
{model.quantization}
</span>
<span className="flex items-center gap-1">
{model.device === "GPU" ? (
<CircuitBoard className="h-3 w-3" />
) : (
<Cpu className="h-3 w-3" />
)}
{model.device}
</span>
</div>
</div>
</div>
{/* Informações técnicas e ações */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-xs text-[var(--foreground)]/50">
{model.estCpuUsagePct && (
<div className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
<span>{model.estCpuUsagePct}% CPU</span>
</div>
)}
{model.estVramGB && model.estVramGB > 0 && (
<div className="flex items-center gap-1">
<CircuitBoard className="h-3 w-3" />
<span>{model.estVramGB} GB VRAM</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onSelectModel(model.id)}
className="rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
>
Select
</button>
{onDeleteModel && (
<button
onClick={() => setShowDeleteConfirm(model.id)}
className="rounded-md bg-red-50 text-red-600 border border-red-200 px-3 py-1 text-xs hover:bg-red-100"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
</div>
{/* Confirmação de exclusão */}
{showDeleteConfirm === model.id && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-md">
<div className="text-xs text-red-700 mb-2">
Are you sure you want to delete this model?
</div>
<div className="flex gap-2">
<button
onClick={() => handleDeleteModel(model.id)}
className="rounded-md bg-red-600 text-white px-2 py-1 text-xs hover:bg-red-700"
>
Delete
</button>
<button
onClick={() => setShowDeleteConfirm(null)}
className="rounded-md bg-gray-200 text-gray-700 px-2 py-1 text-xs hover:bg-gray-300"
>
Cancel
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</>
)} )}
</div> </div>
</div> </div>
+34
View File
@@ -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;
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+464
View File
@@ -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<string, number>;
} {
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<string, number>);
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<string, number>;
} {
try {
const categories = this.getCategories();
const totalMCPs = this.count();
const categoryBreakdown: Record<string, number> = {};
// 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: {},
};
}
}
}
+226
View File
@@ -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 };
}
}
}
+48
View File
@@ -26,7 +26,55 @@ CREATE TABLE embeddings (
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE 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 -- Índices para performance
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp); CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC); CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
CREATE INDEX idx_embeddings_message ON embeddings(message_id); 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);
+192
View File
@@ -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<MCPProvider[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<MCPSearchParams["sort"]>("rating");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [selectedMCP, setSelectedMCP] = useState<MCPProvider | null>(null);
const [showDetailModal, setShowDetailModal] = useState(false);
const fetchMCPs = useCallback(
async (params?: Partial<MCPSearchParams>) => {
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,
};
}
+173
View File
@@ -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<string, CacheEntry>();
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<string, any>);
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);
}
}
+69
View File
@@ -102,3 +102,72 @@ export async function ensureModelExists(
console.log(`✅ Modelo ${modelName} já está disponível`); console.log(`✅ Modelo ${modelName} já está disponível`);
} }
} }
export async function deleteModel(modelName: string): Promise<void> {
const client = new Ollama({ host: OLLAMA_HOST });
await client.delete({ model: modelName });
}
export async function searchRemoteModels(query?: string): Promise<any[]> {
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 [];
}
}
+288
View File
@@ -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<string, MCPProvider['category']> = {
'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<DeepNLPResponse> {
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<MCPServerConfig> {
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<MCPTool[]> {
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<boolean> {
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;
}
}
}
+330
View File
@@ -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<SyncResult> {
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<void> {
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<any[]> {
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,
};
}
}
}
+94
View File
@@ -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<string, any>;
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;
}
+99 -1
View File
@@ -61,6 +61,88 @@ function runMigrations(db: Database.Database): void {
console.log("✅ Migração 001_initial_schema aplicada"); 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 // Inicializar extensão vetorial se disponível
try { try {
const extPath = path.join( const extPath = path.join(
@@ -147,8 +229,24 @@ if (require.main === module) {
initializeTitleModel(), initializeTitleModel(),
initializeEmbeddingModel(), initializeEmbeddingModel(),
]) ])
.then(() => { .then(async () => {
console.log("🎉 Inicialização concluída"); 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); process.exit(0);
}) })
.catch((error) => { .catch((error) => {