feat: add MCP marketplace and custom scrollbar styles

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

No files matched your search

+288
View File
@@ -0,0 +1,288 @@
import {
DeepNLPSearchParams,
DeepNLPResponse,
MCPServerConfig,
MCPTool,
MCPProvider
} from "@/lib/types/mcp";
import { MCPCache } from "@/lib/cache/mcp-cache";
const DEEPNLP_API_BASE = "https://www.deepnlp.org/api/mcp_marketplace/v1";
export class DeepNLPService {
// Mapear categorias do projeto para categorias DeepNLP
private static categoryMap = {
productivity: ['office'],
development: ['code', 'browser'],
data: ['database'],
integration: ['search', 'payment'],
other: ['map', 'chart', 'finance']
};
// Mapear categorias DeepNLP para categorias internas
private static reverseCategoryMap: Record<string, MCPProvider['category']> = {
'office': 'office',
'code': 'code',
'browser': 'browser',
'database': 'database',
'search': 'search',
'payment': 'payment',
'map': 'map',
'chart': 'chart',
'finance': 'finance'
};
/**
* Buscar servidores MCP na API DeepNLP
*/
static async searchMCPs(params: DeepNLPSearchParams, retryCount = 0): Promise<DeepNLPResponse> {
const maxRetries = 3;
const baseDelay = 1000; // 1 segundo
try {
// Verificar cache primeiro
const cacheKey = MCPCache.generateKey(params);
const cachedResult = MCPCache.get(cacheKey);
if (cachedResult) {
console.log('Returning cached search results');
return cachedResult;
}
const url = new URL(DEEPNLP_API_BASE);
// Adicionar parâmetros de busca
if (params.query) url.searchParams.set('query', params.query);
if (params.category) url.searchParams.set('category', params.category);
if (params.id) url.searchParams.set('id', params.id);
if (params.page_id !== undefined) url.searchParams.set('page_id', params.page_id.toString());
if (params.count_per_page !== undefined) url.searchParams.set('count_per_page', params.count_per_page.toString());
if (params.offset !== undefined) url.searchParams.set('offset', params.offset.toString());
if (params.mode) url.searchParams.set('mode', params.mode);
console.log(`DeepNLP API Request (attempt ${retryCount + 1}):`, url.toString());
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Ollahub-MCP-Client/1.0'
},
// Timeout de 10 segundos
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
throw new Error(`DeepNLP API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('DeepNLP API Response:', data);
// Salvar no cache
MCPCache.setWithDataType(cacheKey, data, 'search');
return data;
} catch (error) {
console.error(`Error calling DeepNLP API (attempt ${retryCount + 1}):`, error);
// Se ainda temos tentativas restantes, tentar novamente com backoff exponencial
if (retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount); // Backoff exponencial
console.log(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.searchMCPs(params, retryCount + 1);
}
// Se esgotamos as tentativas, lançar erro
throw new Error(`Failed to fetch MCPs after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Obter configuração completa de um servidor específico
*/
static async getServerConfig(id: string, retryCount = 0): Promise<MCPServerConfig> {
const maxRetries = 3;
const baseDelay = 1000;
try {
// Verificar cache primeiro
const cacheKey = MCPCache.generateServerConfigKey(id);
const cachedResult = MCPCache.get(cacheKey);
if (cachedResult) {
console.log('Returning cached server config');
return cachedResult;
}
const [owner, repo] = id.split('/');
if (!owner || !repo) {
throw new Error(`Invalid MCP ID format: ${id}. Expected format: owner/repo`);
}
const url = `${DEEPNLP_API_BASE}/server/${owner}/${repo}`;
console.log(`DeepNLP Server Config Request (attempt ${retryCount + 1}):`, url);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Ollahub-MCP-Client/1.0'
},
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
throw new Error(`DeepNLP Server Config API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('DeepNLP Server Config Response:', data);
// Salvar no cache
MCPCache.setWithDataType(cacheKey, data, 'config');
return data;
} catch (error) {
console.error(`Error getting server config from DeepNLP (attempt ${retryCount + 1}):`, error);
if (retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`Retrying server config in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.getServerConfig(id, retryCount + 1);
}
throw new Error(`Failed to get server config after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Listar ferramentas de um servidor específico
*/
static async getServerTools(id: string, retryCount = 0): Promise<MCPTool[]> {
const maxRetries = 3;
const baseDelay = 1000;
try {
// Verificar cache primeiro
const cacheKey = MCPCache.generateServerToolsKey(id);
const cachedResult = MCPCache.get(cacheKey);
if (cachedResult) {
console.log('Returning cached server tools');
return cachedResult;
}
const [owner, repo] = id.split('/');
if (!owner || !repo) {
throw new Error(`Invalid MCP ID format: ${id}. Expected format: owner/repo`);
}
const url = `${DEEPNLP_API_BASE}/tools/${owner}/${repo}`;
console.log(`DeepNLP Server Tools Request (attempt ${retryCount + 1}):`, url);
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Ollahub-MCP-Client/1.0'
},
signal: AbortSignal.timeout(10000)
});
if (!response.ok) {
throw new Error(`DeepNLP Server Tools API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
console.log('DeepNLP Server Tools Response:', data);
const tools = data.tools || [];
// Salvar no cache
MCPCache.setWithDataType(cacheKey, tools, 'tools');
return tools;
} catch (error) {
console.error(`Error getting server tools from DeepNLP (attempt ${retryCount + 1}):`, error);
if (retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount);
console.log(`Retrying server tools in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return this.getServerTools(id, retryCount + 1);
}
throw new Error(`Failed to get server tools after ${maxRetries + 1} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Transformar resposta DeepNLP para formato interno MCPProvider
*/
static transformToMCPProvider(deepnlpItem: any): MCPProvider {
const category = this.mapCategory(deepnlpItem.subfield || 'other');
return {
id: deepnlpItem.id || '',
name: deepnlpItem.content_name || '',
author: deepnlpItem.publisher_id || '',
description: deepnlpItem.description || '',
version: '1.0.0', // DeepNLP não fornece versão específica
category,
tags: deepnlpItem.content_tag_list ? deepnlpItem.content_tag_list.split(',').map((tag: string) => tag.trim()) : [],
rating: parseFloat(deepnlpItem.rating || '0'),
totalRatings: parseInt(deepnlpItem.review_cnt || '0'),
downloads: parseInt(deepnlpItem.review_cnt || '0') * 10, // Estimativa baseada em reviews
iconUrl: deepnlpItem.thumbnail_picture,
screenshotUrls: [],
capabilities: deepnlpItem.ext_info?.tools || [],
repository: deepnlpItem.website,
homepage: deepnlpItem.website,
installed: false, // Será verificado localmente
subfield: deepnlpItem.subfield,
field: deepnlpItem.field,
config: deepnlpItem.config,
tools: deepnlpItem.ext_info?.tools || []
};
}
/**
* Mapear categoria DeepNLP para categoria interna
*/
static mapCategory(subfield: string): MCPProvider['category'] {
return this.reverseCategoryMap[subfield?.toLowerCase()] || 'other';
}
/**
* Obter mapeamento de categoria interna para DeepNLP
*/
static getCategoryMapping(internalCategory: string): string | undefined {
const mappings = this.categoryMap[internalCategory as keyof typeof this.categoryMap];
return mappings?.[0]; // Retorna a primeira categoria DeepNLP correspondente
}
/**
* Verificar se a API está disponível
*/
static async checkHealth(): Promise<boolean> {
try {
const response = await fetch(`${DEEPNLP_API_BASE}?count_per_page=1`, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': 'Ollahub-MCP-Client/1.0'
}
});
return response.ok;
} catch (error) {
console.error('DeepNLP API health check failed:', error);
return false;
}
}
}
+330
View File
@@ -0,0 +1,330 @@
import { DeepNLPService } from "./deepnlp";
import { MCPCacheRepository } from "@/database/repositories/mcp-cache";
export interface SyncProgress {
current: number;
total: number;
page: number;
totalPages: number;
}
export interface SyncResult {
success: boolean;
totalDownloaded: number;
errors: string[];
duration: number;
}
export class MCPSyncService {
private static isSyncingFlag = false;
private static abortController: AbortController | null = null;
private static currentProgress: SyncProgress | null = null;
/**
* Download completo com paginação incremental
*/
static async syncAll(
onProgress?: (progress: SyncProgress) => void
): Promise<SyncResult> {
const startTime = Date.now();
const errors: string[] = [];
let totalDownloaded = 0;
try {
if (this.isSyncingFlag) {
throw new Error("Sync already in progress");
}
this.isSyncingFlag = true;
this.abortController = new AbortController();
console.log("🔄 Starting MCP marketplace sync...");
// Limpar cache antigo antes de começar
MCPCacheRepository.clearCache();
const allMCPs = await this.downloadAllPages(onProgress);
totalDownloaded = allMCPs.length;
// Salvar todos os MCPs no banco
if (allMCPs.length > 0) {
MCPCacheRepository.saveBatch(allMCPs);
// Atualizar metadados
MCPCacheRepository.updateMetadata("last_sync", Date.now().toString());
MCPCacheRepository.updateMetadata(
"total_items",
allMCPs.length.toString()
);
console.log(`✅ Sync completed: ${totalDownloaded} MCPs downloaded`);
}
const duration = Date.now() - startTime;
return {
success: true,
totalDownloaded,
errors,
duration,
};
} catch (error: any) {
console.error("❌ Sync failed:", error);
errors.push(error.message);
const duration = Date.now() - startTime;
return {
success: false,
totalDownloaded,
errors,
duration,
};
} finally {
this.isSyncingFlag = false;
this.abortController = null;
this.currentProgress = null;
}
}
/**
* Sincronização em background (usado automaticamente)
*/
static async backgroundSync(): Promise<void> {
try {
if (this.isSyncingFlag) {
console.log("Sync already in progress, skipping background sync");
return;
}
console.log("🔄 Starting background MCP sync...");
await this.syncAll((progress) => {
console.log(
`📥 Downloaded ${progress.current}/${progress.total} MCPs (page ${progress.page}/${progress.totalPages})`
);
});
} catch (error) {
console.error("Background sync failed:", error);
}
}
/**
* Verificar se sincronização está em andamento
*/
static isSyncing(): boolean {
return this.isSyncingFlag;
}
/**
* Cancelar sincronização atual
*/
static cancelSync(): void {
if (this.abortController) {
this.abortController.abort();
console.log("🛑 Sync cancelled by user");
}
}
/**
* Obter progresso atual da sincronização
*/
static getCurrentProgress(): SyncProgress | null {
return this.currentProgress;
}
/**
* Download paginado de todas as páginas - SEM LIMITE ARTIFICIAL
*/
private static async downloadAllPages(
onProgress?: (progress: SyncProgress) => void
): Promise<any[]> {
const allMCPs: any[] = [];
let page = 0;
let COUNT_PER_PAGE = 100; // Começar com 100
let totalHits = 0;
let consecutiveEmptyPages = 0; // Contador para páginas vazias consecutivas
console.log("📡 Starting unlimited paginated download...");
while (!this.abortController?.signal.aborted) {
try {
const params = {
mode: "list" as const,
page_id: page,
count_per_page: COUNT_PER_PAGE,
};
console.log(
`📄 Downloading page ${
page + 1
} (${COUNT_PER_PAGE} items per page)...`
);
const response = await DeepNLPService.searchMCPs(params);
// Se não há itens na resposta, incrementar contador de páginas vazias
if (!response.items || response.items.length === 0) {
consecutiveEmptyPages++;
console.log(
`⚠️ Empty page ${
page + 1
} (${consecutiveEmptyPages} consecutive empty pages)`
);
// Se temos 3 páginas vazias consecutivas, parar o download
if (consecutiveEmptyPages >= 3) {
console.log(
"🛑 Stopping download: 3 consecutive empty pages detected"
);
break;
}
page++;
continue;
}
// Reset contador de páginas vazias se encontramos dados
consecutiveEmptyPages = 0;
allMCPs.push(...response.items);
// Atualizar total hits na primeira página
if (page === 0) {
totalHits = response.total_hits;
console.log(`📊 Total MCPs available: ${totalHits}`);
}
// Notificar progresso
this.currentProgress = {
current: allMCPs.length,
total: totalHits,
page: page + 1,
totalPages:
totalHits > 0 ? Math.ceil(totalHits / COUNT_PER_PAGE) : page + 1,
};
if (onProgress) {
onProgress(this.currentProgress);
}
// Incrementar página
page++;
// Salvar em batch a cada 500 itens para evitar bloqueio
if (allMCPs.length % 500 === 0) {
console.log(`💾 Saving batch of 500 MCPs to database...`);
MCPCacheRepository.saveBatch(allMCPs.slice(-500));
}
// Pequena pausa entre requisições para não sobrecarregar a API
await new Promise((resolve) => setTimeout(resolve, 100));
// Aumentar tamanho da página progressivamente para otimizar
if (page === 5 && COUNT_PER_PAGE === 100) {
COUNT_PER_PAGE = 500;
console.log("🚀 Increasing page size to 500 for faster download");
} else if (page === 20 && COUNT_PER_PAGE === 500) {
COUNT_PER_PAGE = 1000;
console.log("🚀 Increasing page size to 1000 for maximum efficiency");
}
// Log de progresso a cada 10 páginas
if (page % 10 === 0) {
console.log(
`📈 Progress: ${allMCPs.length} MCPs downloaded (page ${page})`
);
}
} catch (error: any) {
console.error(`❌ Error downloading page ${page + 1}:`, error);
// Se for erro de abort, parar
if (this.abortController?.signal.aborted) {
console.log("🛑 Download cancelled");
break;
}
// Se for erro de rede, tentar continuar
if (
error.message.includes("fetch") ||
error.message.includes("network")
) {
console.log("🔄 Network error, retrying in 2 seconds...");
await new Promise((resolve) => setTimeout(resolve, 2000));
continue;
}
// Para outros erros, parar
throw error;
}
}
// Salvar último batch se houver
if (allMCPs.length > 0) {
const remainingItems = allMCPs.length % 500;
if (remainingItems > 0) {
console.log(`💾 Saving final batch of ${remainingItems} MCPs...`);
MCPCacheRepository.saveBatch(allMCPs.slice(-remainingItems));
}
}
console.log(
`✅ Download completed: ${allMCPs.length} total MCPs downloaded`
);
return allMCPs;
}
/**
* Verificar se precisa sincronizar
*/
static needsSync(): boolean {
return MCPCacheRepository.needsSync();
}
/**
* Obter estatísticas do cache
*/
static getCacheStats() {
return MCPCacheRepository.getStats();
}
/**
* Forçar limpeza do cache
*/
static clearCache(): void {
MCPCacheRepository.clearCache();
console.log("🗑️ Cache cleared");
}
/**
* Verificar saúde do sistema de cache
*/
static async healthCheck(): Promise<{
cacheExists: boolean;
cacheValid: boolean;
totalItems: number;
lastSync: number | null;
needsSync: boolean;
}> {
try {
const cacheExists = MCPCacheRepository.tableExists(
"mcp_marketplace_cache"
);
const stats = MCPCacheRepository.getStats();
const needsSync = MCPCacheRepository.needsSync();
return {
cacheExists,
cacheValid: stats.isValid,
totalItems: stats.totalItems,
lastSync: stats.lastSync,
needsSync,
};
} catch (error) {
console.error("Health check failed:", error);
return {
cacheExists: false,
cacheValid: false,
totalItems: 0,
lastSync: null,
needsSync: true,
};
}
}
}