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:
1 parent
d5dd3a9f09
commit
ce010d39ee
31 files changed
+4127
-45
No files matched your search
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -31,3 +31,134 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
/* Custom scrollbar para o seletor de modelos */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--foreground);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* MCP Marketplace Styles */
|
||||
.mcp-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mcp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mcp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.mcp-modal {
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.mcp-category-badge {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-category-badge:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.mcp-rating-star {
|
||||
transition: color 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-rating-star:hover {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.mcp-install-button {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-install-button:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.mcp-install-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Loading animation for MCP cards */
|
||||
.mcp-loading {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* MCP detail modal animations */
|
||||
.mcp-detail-modal {
|
||||
animation: slideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* MCP search bar focus effects */
|
||||
.mcp-search-input:focus {
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
/* MCP category filter active state */
|
||||
.mcp-category-active {
|
||||
background: linear-gradient(135deg, var(--primary), var(--primary));
|
||||
color: var(--primary-foreground);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* MCP tag hover effects */
|
||||
.mcp-tag {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.mcp-tag:hover {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
SettingsButton,
|
||||
SettingsModal,
|
||||
} from "@/components/settings/SettingsModal";
|
||||
import { MCPMarketplaceModal } from "@/components/mcp/MCPMarketplaceModal";
|
||||
import { Store } from "lucide-react";
|
||||
|
||||
interface ClientHomeProps {
|
||||
offline: boolean;
|
||||
@@ -20,6 +22,7 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
const [currentConversationId, setCurrentConversationId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [showMCPMarketplace, setShowMCPMarketplace] = useState(false);
|
||||
|
||||
const handleConversationCreated = () => {
|
||||
// Incrementar trigger para forçar atualização da sidebar
|
||||
@@ -45,6 +48,13 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
Ollama offline
|
||||
</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)} />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
@@ -67,6 +77,10 @@ export function ClientHome({ offline, models }: ClientHomeProps) {
|
||||
</div>
|
||||
</main>
|
||||
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
|
||||
<MCPMarketplaceModal
|
||||
isOpen={showMCPMarketplace}
|
||||
onClose={() => setShowMCPMarketplace(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -278,6 +278,26 @@ export function ChatContainer({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/delete", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ modelName: modelId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to delete model");
|
||||
}
|
||||
|
||||
// Atualizar lista de modelos após exclusão
|
||||
// Aqui você pode recarregar a lista ou remover o modelo da lista local
|
||||
console.log(`Model ${modelId} deleted successfully`);
|
||||
} catch (error) {
|
||||
console.error("Error deleting model:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePullModel = async (modelName: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/ollama/pull", {
|
||||
@@ -340,6 +360,8 @@ export function ChatContainer({
|
||||
models={models}
|
||||
selectedModel={selectedModel}
|
||||
onSelectModel={handleSelectModel}
|
||||
onDeleteModel={handleDeleteModel}
|
||||
onPullModel={handlePullModel}
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { ChevronDown, Cpu, CircuitBoard } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Cpu,
|
||||
CircuitBoard,
|
||||
Search,
|
||||
Filter,
|
||||
Zap,
|
||||
HardDrive,
|
||||
Clock,
|
||||
TrendingUp,
|
||||
X,
|
||||
Download,
|
||||
Trash2,
|
||||
Cloud,
|
||||
CheckCircle,
|
||||
} from "lucide-react";
|
||||
import type { ModelInfo } from "@/lib/models";
|
||||
|
||||
interface ModelDropdownProps {
|
||||
models: ModelInfo[];
|
||||
selectedModel: string | null;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
onDeleteModel?: (modelId: string) => void;
|
||||
onPullModel?: (modelName: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -15,12 +32,108 @@ export function ModelDropdown({
|
||||
models,
|
||||
selectedModel,
|
||||
onSelectModel,
|
||||
onDeleteModel,
|
||||
onPullModel,
|
||||
disabled = false,
|
||||
}: ModelDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterDevice, setFilterDevice] = useState<"all" | "CPU" | "GPU">(
|
||||
"all"
|
||||
);
|
||||
const [showRemote, setShowRemote] = useState(false);
|
||||
const [remoteModels, setRemoteModels] = useState<any[]>([]);
|
||||
const [isSearchingRemote, setIsSearchingRemote] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(
|
||||
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(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -29,6 +142,11 @@ export function ModelDropdown({
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
setSearchTerm("");
|
||||
setFilterDevice("all");
|
||||
setShowRemote(false);
|
||||
setRemoteModels([]);
|
||||
setShowDeleteConfirm(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +154,18 @@ export function ModelDropdown({
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Buscar modelos remotos quando necessário
|
||||
useEffect(() => {
|
||||
if (showRemote && searchTerm.length > 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
searchRemoteModels(searchTerm);
|
||||
}, 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [showRemote, searchTerm]);
|
||||
|
||||
const selectedModelData = models.find((m) => m.id === selectedModel);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
@@ -46,15 +176,27 @@ export function ModelDropdown({
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedModelData ? (
|
||||
<>
|
||||
<span className="truncate">{selectedModelData.name}</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 className="truncate font-medium">
|
||||
{selectedModelData.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
|
||||
selectedModelData
|
||||
)}`}
|
||||
>
|
||||
{getPerformanceIcon(selectedModelData)}
|
||||
{categorizeModel(selectedModelData)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-[var(--surface)] px-2 py-0.5 text-xs">
|
||||
{selectedModelData.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{selectedModelData.device}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[var(--foreground)]/60">
|
||||
@@ -70,43 +212,250 @@ export function ModelDropdown({
|
||||
</button>
|
||||
|
||||
{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="max-h-60 overflow-y-auto">
|
||||
{models.length === 0 ? (
|
||||
<div className="px-3 py-2 text-sm text-[var(--foreground)]/60">
|
||||
No models available
|
||||
<div className="absolute top-full z-50 mt-1 w-full min-w-[400px] rounded-lg border border-[var(--border)] bg-[var(--background)] shadow-xl">
|
||||
{/* Header com busca e filtros */}
|
||||
<div className="border-b border-[var(--border)] p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--foreground)]/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search models..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full rounded-md border border-[var(--border)] bg-[var(--background)] pl-8 pr-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--accent)]"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
onClick={() => setSearchTerm("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--foreground)]/40 hover:text-[var(--foreground)]/60"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
models.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => {
|
||||
onSelectModel(model.id);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
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 items-center gap-1">
|
||||
<Filter className="h-4 w-4 text-[var(--foreground)]/40" />
|
||||
<select
|
||||
value={filterDevice}
|
||||
onChange={(e) =>
|
||||
setFilterDevice(e.target.value as "all" | "CPU" | "GPU")
|
||||
}
|
||||
className="rounded-md border border-[var(--border)] bg-[var(--background)] px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--accent)]"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<span className="font-medium">{model.name}</span>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
<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>
|
||||
<option value="all">All Devices</option>
|
||||
<option value="CPU">CPU Only</option>
|
||||
<option value="GPU">GPU Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle para busca remota */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowRemote(!showRemote)}
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-1 text-xs transition-colors ${
|
||||
showRemote
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--surface)] text-[var(--foreground)]/60 hover:bg-[var(--surface)]/80"
|
||||
}`}
|
||||
>
|
||||
<Cloud className="h-3 w-3" />
|
||||
Remote Search
|
||||
</button>
|
||||
))
|
||||
<div className="text-xs text-[var(--foreground)]/60">
|
||||
{showRemote
|
||||
? `${remoteModels.length} remote`
|
||||
: `${filteredModels.length} local`}{" "}
|
||||
model
|
||||
{(showRemote
|
||||
? remoteModels.length
|
||||
: filteredModels.length) !== 1
|
||||
? "s"
|
||||
: ""}{" "}
|
||||
found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lista de modelos com scroll customizado */}
|
||||
<div className="max-h-80 overflow-y-auto custom-scrollbar">
|
||||
{showRemote ? (
|
||||
// Lista de modelos remotos
|
||||
<>
|
||||
{isSearchingRemote ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--accent)] mx-auto mb-2"></div>
|
||||
Searching remote models...
|
||||
</div>
|
||||
) : remoteModels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<Cloud className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No remote models found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{remoteModels.map((model) => (
|
||||
<div
|
||||
key={model.name}
|
||||
className="w-full rounded-lg border border-[var(--border)] bg-[var(--background)] p-3 mb-2"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{model.name}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 rounded-md bg-blue-50 text-blue-600 border border-blue-200 px-2 py-0.5 text-xs">
|
||||
<Cloud className="h-3 w-3" />
|
||||
Remote
|
||||
</span>
|
||||
{model.installed && (
|
||||
<span className="flex items-center gap-1 rounded-md bg-green-50 text-green-600 border border-green-200 px-2 py-0.5 text-xs">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs text-[var(--foreground)]/50">
|
||||
Available for download
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handlePullModel(model.name)}
|
||||
disabled={model.installed}
|
||||
className="flex items-center gap-1 rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
{model.installed ? "Installed" : "Download"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// Lista de modelos locais
|
||||
<>
|
||||
{filteredModels.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-[var(--foreground)]/60">
|
||||
<Search className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
||||
No models found
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-2">
|
||||
{filteredModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className={`w-full rounded-lg border p-3 mb-2 transition-all hover:shadow-md ${
|
||||
selectedModel === model.id
|
||||
? "border-[var(--accent)] bg-[var(--accent)]/5"
|
||||
: "border-[var(--border)] bg-[var(--background)] hover:bg-[var(--surface)]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">
|
||||
{model.name}
|
||||
</span>
|
||||
<span
|
||||
className={`flex items-center gap-1 rounded-md px-2 py-0.5 text-xs border ${getPerformanceColor(
|
||||
model
|
||||
)}`}
|
||||
>
|
||||
{getPerformanceIcon(model)}
|
||||
{categorizeModel(model)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--foreground)]/60">
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="h-3 w-3" />
|
||||
{model.sizeGB} GB
|
||||
</span>
|
||||
<span className="rounded-md bg-[var(--surface)] px-2 py-0.5">
|
||||
{model.quantization}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{model.device === "GPU" ? (
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
) : (
|
||||
<Cpu className="h-3 w-3" />
|
||||
)}
|
||||
{model.device}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Informações técnicas e ações */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4 text-xs text-[var(--foreground)]/50">
|
||||
{model.estCpuUsagePct && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
<span>{model.estCpuUsagePct}% CPU</span>
|
||||
</div>
|
||||
)}
|
||||
{model.estVramGB && model.estVramGB > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<CircuitBoard className="h-3 w-3" />
|
||||
<span>{model.estVramGB} GB VRAM</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onSelectModel(model.id)}
|
||||
className="rounded-md bg-[var(--accent)] text-white px-3 py-1 text-xs hover:bg-[color-mix(in_oklab,var(--accent),black_10%)]"
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
{onDeleteModel && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(model.id)}
|
||||
className="rounded-md bg-red-50 text-red-600 border border-red-200 px-3 py-1 text-xs hover:bg-red-100"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmação de exclusão */}
|
||||
{showDeleteConfirm === model.id && (
|
||||
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded-md">
|
||||
<div className="text-xs text-red-700 mb-2">
|
||||
Are you sure you want to delete this model?
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleDeleteModel(model.id)}
|
||||
className="rounded-md bg-red-600 text-white px-2 py-1 text-xs hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(null)}
|
||||
className="rounded-md bg-gray-200 text-gray-700 px-2 py-1 text-xs hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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.
@@ -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: {},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,55 @@ CREATE TABLE embeddings (
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Tabela para gerenciar MCPs instalados
|
||||
CREATE TABLE IF NOT EXISTS mcp_installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config JSON NOT NULL,
|
||||
tools JSON,
|
||||
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Cache de todos os servidores MCP do marketplace
|
||||
CREATE TABLE IF NOT EXISTS mcp_marketplace_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
content_name TEXT NOT NULL,
|
||||
publisher_id TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
subfield TEXT,
|
||||
field TEXT,
|
||||
rating REAL DEFAULT 0,
|
||||
review_cnt INTEGER DEFAULT 0,
|
||||
content_tag_list TEXT,
|
||||
thumbnail_picture TEXT,
|
||||
website TEXT,
|
||||
detail_url TEXT,
|
||||
ext_info JSON,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Metadados do cache (última sincronização, total de registros)
|
||||
CREATE TABLE IF NOT EXISTS mcp_cache_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX idx_messages_conversation ON messages(conversation_id, timestamp);
|
||||
CREATE INDEX idx_conversations_updated ON conversations(updated_at DESC);
|
||||
CREATE INDEX idx_embeddings_message ON embeddings(message_id);
|
||||
CREATE INDEX idx_mcp_owner_repo ON mcp_installations(owner, repo);
|
||||
CREATE INDEX idx_mcp_installed_at ON mcp_installations(installed_at DESC);
|
||||
CREATE INDEX idx_mcp_cache_category ON mcp_marketplace_cache(category);
|
||||
CREATE INDEX idx_mcp_cache_subfield ON mcp_marketplace_cache(subfield);
|
||||
CREATE INDEX idx_mcp_cache_rating ON mcp_marketplace_cache(rating DESC);
|
||||
CREATE INDEX idx_mcp_cache_owner_repo ON mcp_marketplace_cache(owner, repo);
|
||||
CREATE INDEX idx_mcp_cache_search ON mcp_marketplace_cache(content_name, description);
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Vendored
+173
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -102,3 +102,72 @@ export async function ensureModelExists(
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -61,6 +61,88 @@ function runMigrations(db: Database.Database): void {
|
||||
console.log("✅ Migração 001_initial_schema aplicada");
|
||||
}
|
||||
|
||||
// Aplicar migração para tabela MCP se não foi aplicada
|
||||
if (!appliedNames.has("002_mcp_installations")) {
|
||||
console.log("📋 Aplicando migração: 002_mcp_installations");
|
||||
|
||||
db.exec(`
|
||||
-- Tabela para gerenciar MCPs instalados
|
||||
CREATE TABLE IF NOT EXISTS mcp_installations (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
config JSON NOT NULL,
|
||||
tools JSON,
|
||||
installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices para performance
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_owner_repo ON mcp_installations(owner, repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_installed_at ON mcp_installations(installed_at DESC);
|
||||
`);
|
||||
|
||||
// Registrar migração
|
||||
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
|
||||
"002_mcp_installations",
|
||||
Date.now()
|
||||
);
|
||||
|
||||
console.log("✅ Migração 002_mcp_installations aplicada");
|
||||
}
|
||||
|
||||
// Aplicar migração para tabela MCP marketplace cache se não foi aplicada
|
||||
if (!appliedNames.has("003_mcp_marketplace_cache")) {
|
||||
console.log("📋 Aplicando migração: 003_mcp_marketplace_cache");
|
||||
|
||||
db.exec(`
|
||||
-- Tabela principal de cache
|
||||
CREATE TABLE IF NOT EXISTS mcp_marketplace_cache (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
content_name TEXT NOT NULL,
|
||||
publisher_id TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
subfield TEXT,
|
||||
field TEXT,
|
||||
rating REAL DEFAULT 0,
|
||||
review_cnt INTEGER DEFAULT 0,
|
||||
content_tag_list TEXT,
|
||||
thumbnail_picture TEXT,
|
||||
website TEXT,
|
||||
detail_url TEXT,
|
||||
ext_info JSON,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Metadados
|
||||
CREATE TABLE IF NOT EXISTS mcp_cache_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Índices
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_cache_category ON mcp_marketplace_cache(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_cache_subfield ON mcp_marketplace_cache(subfield);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_cache_rating ON mcp_marketplace_cache(rating DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_cache_owner_repo ON mcp_marketplace_cache(owner, repo);
|
||||
CREATE INDEX IF NOT EXISTS idx_mcp_cache_search ON mcp_marketplace_cache(content_name, description);
|
||||
`);
|
||||
|
||||
// Registrar migração
|
||||
db.prepare("INSERT INTO _migrations (name, applied_at) VALUES (?, ?)").run(
|
||||
"003_mcp_marketplace_cache",
|
||||
Date.now()
|
||||
);
|
||||
|
||||
console.log("✅ Migração 003_mcp_marketplace_cache aplicada");
|
||||
}
|
||||
|
||||
// Inicializar extensão vetorial se disponível
|
||||
try {
|
||||
const extPath = path.join(
|
||||
@@ -147,8 +229,24 @@ if (require.main === module) {
|
||||
initializeTitleModel(),
|
||||
initializeEmbeddingModel(),
|
||||
])
|
||||
.then(() => {
|
||||
.then(async () => {
|
||||
console.log("🎉 Inicialização concluída");
|
||||
|
||||
// Iniciar sincronização do marketplace se necessário
|
||||
try {
|
||||
const { MCPSyncService } = await import("@/lib/services/mcp-sync");
|
||||
const { MCPCacheRepository } = await import("@/database/repositories/mcp-cache");
|
||||
|
||||
if (MCPCacheRepository.needsSync()) {
|
||||
console.log("🔄 Iniciando sincronização inicial do marketplace...");
|
||||
MCPSyncService.backgroundSync();
|
||||
} else {
|
||||
console.log("✅ Cache do marketplace já está atualizado");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("⚠️ Não foi possível inicializar sincronização do marketplace:", error);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
Reference in new issue
Block a user