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

+14
View File
@@ -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>
);
}
+22
View File
@@ -278,6 +278,26 @@ export function ChatContainer({
}
};
const handleDeleteModel = async (modelId: string) => {
try {
const response = await fetch("/api/ollama/delete", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ modelName: modelId }),
});
if (!response.ok) {
throw new Error("Failed to delete model");
}
// Atualizar lista de modelos após exclusão
// Aqui você pode recarregar a lista ou remover o modelo da lista local
console.log(`Model ${modelId} deleted successfully`);
} catch (error) {
console.error("Error deleting model:", error);
}
};
const handlePullModel = async (modelName: string) => {
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}
/>
)}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { Package, Star, Download, Eye, ExternalLink, CheckCircle, Zap } from "lucide-react";
import { MCPProvider } from "@/lib/types/mcp";
interface MCPCardProps {
mcp: MCPProvider;
onInstall: () => void;
onUninstall: () => void;
onViewDetails: () => void;
}
export function MCPCard({ mcp, onInstall, onUninstall, onViewDetails }: MCPCardProps) {
const getCategoryColor = (category: string) => {
switch (category) {
case "map": return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
case "browser": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
case "office": return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
case "search": return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
case "database": return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
case "finance": return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
case "code": return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
case "chart": return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
case "payment": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
default: return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
}
};
const formatDownloads = (downloads: number) => {
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
return downloads.toString();
};
const isOfficial = mcp.tags?.includes('official') || false;
const toolCount = mcp.tools?.length || mcp.capabilities?.length || 0;
return (
<div className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4 hover:shadow-md transition-all duration-200 hover:border-[var(--primary)]/50 mcp-card">
{/* Header */}
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
{mcp.iconUrl ? (
<img
src={mcp.iconUrl}
alt={mcp.name}
className="h-8 w-8 rounded-md object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : null}
<div className={`p-2 bg-[var(--primary)]/10 rounded-md ${mcp.iconUrl ? 'hidden' : ''}`}>
<Package className="h-4 w-4 text-[var(--primary)]" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm">{mcp.name}</h3>
{isOfficial && (
<CheckCircle className="h-3 w-3 text-green-500" />
)}
</div>
<p className="text-xs text-[var(--muted-foreground)]">by {mcp.author}</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium mcp-category-badge ${getCategoryColor(mcp.category)}`}>
{mcp.category}
</span>
</div>
{/* Description */}
<p className="text-sm text-[var(--muted-foreground)] mb-3 line-clamp-2">
{mcp.description}
</p>
{/* Stats */}
<div className="flex items-center gap-4 mb-3 text-xs text-[var(--muted-foreground)]">
<div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-yellow-400 text-yellow-400" />
<span>{mcp.rating}</span>
<span>({mcp.totalRatings})</span>
</div>
<div className="flex items-center gap-1">
<Download className="h-3 w-3" />
<span>{formatDownloads(mcp.downloads)}</span>
</div>
{toolCount > 0 && (
<div className="flex items-center gap-1">
<Zap className="h-3 w-3" />
<span>{toolCount} tools</span>
</div>
)}
<span className="text-xs bg-[var(--surface)] px-2 py-1 rounded">
v{mcp.version}
</span>
</div>
{/* Tags */}
<div className="flex flex-wrap gap-1 mb-4">
{mcp.tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag"
>
{tag}
</span>
))}
{mcp.tags.length > 3 && (
<span className="px-2 py-1 bg-[var(--background)] text-xs rounded-md border border-[var(--border)] mcp-tag">
+{mcp.tags.length - 3}
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2">
<button
onClick={onViewDetails}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors text-sm"
>
<Eye className="h-4 w-4" />
Details
</button>
{mcp.installed ? (
<button
onClick={onUninstall}
className="px-3 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 transition-colors text-sm mcp-install-button"
>
Uninstall
</button>
) : (
<button
onClick={onInstall}
className="px-3 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors text-sm mcp-install-button"
>
Install
</button>
)}
</div>
{/* Links */}
{(mcp.repository || mcp.homepage) && (
<div className="mt-3 pt-3 border-t border-[var(--border)]">
<div className="flex gap-2">
{mcp.repository && (
<a
href={mcp.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Repository
</a>
)}
{mcp.homepage && (
<a
href={mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<ExternalLink className="h-3 w-3" />
Homepage
</a>
)}
</div>
</div>
)}
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
interface MCPCategory {
id: string;
name: string;
icon: string;
count: number;
}
interface MCPCategoryFilterProps {
primaryCategories: MCPCategory[];
otherCategories: MCPCategory[];
selectedCategory: string;
onSelectCategory: (categoryId: string) => void;
}
export function MCPCategoryFilter({
primaryCategories,
otherCategories,
selectedCategory,
onSelectCategory,
}: MCPCategoryFilterProps) {
const [showOthers, setShowOthers] = useState(false);
const getCategoryIcon = (categoryName: string) => {
const iconMap: Record<string, string> = {
MAP: "🗺️",
BROWSER: "🌐",
OFFICE: "📄",
CODE: "💻",
DATABASE: "🗄️",
SEARCH: "🔍",
PAYMENT: "💳",
CHART: "📊",
FINANCE: "💰",
OTHER: "📦",
};
return iconMap[categoryName.toUpperCase()] || "📦";
};
return (
<div className="space-y-2">
<h3 className="font-semibold text-sm text-[var(--foreground)] mb-3">
Categories
</h3>
{/* All Categories */}
<button
onClick={() => onSelectCategory("all")}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
selectedCategory === "all"
? "mcp-category-active"
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<span className="flex items-center gap-2">📦 All Categories</span>
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
{primaryCategories.reduce((sum, cat) => sum + cat.count, 0) +
otherCategories.reduce((sum, cat) => sum + cat.count, 0)}
</span>
</button>
{/* Primary Categories */}
<div className="space-y-1">
<h4 className="text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wide">
Principais
</h4>
{primaryCategories.map((category) => (
<button
key={category.id}
onClick={() => onSelectCategory(category.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
selectedCategory === category.id
? "mcp-category-active"
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<span className="flex items-center gap-2">
{getCategoryIcon(category.name)} {category.name}
</span>
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
{category.count}
</span>
</button>
))}
</div>
{/* Other Categories */}
{otherCategories.length > 0 && (
<div className="space-y-1">
<button
onClick={() => setShowOthers(!showOthers)}
className="w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<span className="flex items-center gap-2">
{showOthers ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
<span className="text-xs font-medium uppercase tracking-wide">
Outras ({otherCategories.length})
</span>
</span>
</button>
{showOthers && (
<div className="ml-4 space-y-1">
{otherCategories.map((category) => (
<button
key={category.id}
onClick={() => onSelectCategory(category.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-md text-sm transition-colors ${
selectedCategory === category.id
? "mcp-category-active"
: "hover:bg-[var(--surface)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
<span className="flex items-center gap-2">
📦 {category.name}
</span>
<span className="text-xs bg-[var(--background)] px-2 py-1 rounded-full">
{category.count}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
"use client";
import { useState } from "react";
import { X, Package, Star, Download, ExternalLink, CheckCircle } from "lucide-react";
import { MCPProvider } from "@/lib/types/mcp";
import { MCPRatingStars } from "./MCPRatingStars";
interface MCPDetailModalProps {
mcp: MCPProvider;
isOpen: boolean;
onClose: () => void;
onInstall: () => void;
}
export function MCPDetailModal({ mcp, isOpen, onClose, onInstall }: MCPDetailModalProps) {
const [activeTab, setActiveTab] = useState<"about" | "capabilities" | "screenshots" | "configuration">("about");
if (!isOpen) return null;
const formatDownloads = (downloads: number) => {
if (downloads >= 1000000) return `${(downloads / 1000000).toFixed(1)}M`;
if (downloads >= 1000) return `${(downloads / 1000).toFixed(1)}K`;
return downloads.toString();
};
const getCategoryColor = (category: string) => {
switch (category) {
case "map": return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
case "browser": return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
case "office": return "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200";
case "search": return "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200";
case "database": return "bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200";
case "finance": return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200";
case "code": return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
case "chart": return "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-200";
case "payment": return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
default: return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-4xl h-[90vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl mcp-detail-modal">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-4">
<div className="p-3 bg-[var(--primary)]/10 rounded-lg">
<Package className="h-8 w-8 text-[var(--primary)]" />
</div>
<div>
<h2 className="text-2xl font-bold">{mcp.name}</h2>
<p className="text-[var(--muted-foreground)]">by {mcp.author}</p>
</div>
<span className={`px-3 py-1 rounded-full text-sm font-medium mcp-category-badge ${getCategoryColor(mcp.category)}`}>
{mcp.category}
</span>
{mcp.installed && (
<div className="flex items-center gap-1 text-green-600">
<CheckCircle className="h-4 w-4" />
<span className="text-sm font-medium">Installed</span>
</div>
)}
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Stats */}
<div className="p-6 border-b border-[var(--border)]">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<MCPRatingStars rating={mcp.rating} size="md" />
<span className="text-sm text-[var(--muted-foreground)]">
({mcp.totalRatings} reviews)
</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-[var(--muted-foreground)]" />
<span className="text-sm">{formatDownloads(mcp.downloads)} downloads</span>
</div>
<span className="text-sm text-[var(--muted-foreground)]">Version {mcp.version}</span>
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-[var(--border)]">
{[
{ id: "about", label: "About" },
{ id: "capabilities", label: "Capabilities" },
{ id: "configuration", label: "Configuration" },
{ id: "screenshots", label: "Screenshots" },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`px-6 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 p-6 overflow-auto">
{activeTab === "about" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">Description</h3>
<p className="text-[var(--muted-foreground)] leading-relaxed">
{mcp.description}
</p>
</div>
<div>
<h3 className="text-lg font-semibold mb-3">Tags</h3>
<div className="flex flex-wrap gap-2">
{mcp.tags.map((tag) => (
<span
key={tag}
className="px-3 py-1 bg-[var(--surface)] text-sm rounded-md border border-[var(--border)] mcp-tag"
>
{tag}
</span>
))}
</div>
</div>
{(mcp.repository || mcp.homepage) && (
<div>
<h3 className="text-lg font-semibold mb-3">Links</h3>
<div className="flex gap-4">
{mcp.repository && (
<a
href={mcp.repository}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
>
<ExternalLink className="h-4 w-4" />
Repository
</a>
)}
{mcp.homepage && (
<a
href={mcp.homepage}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-[var(--primary)] hover:underline"
>
<ExternalLink className="h-4 w-4" />
Homepage
</a>
)}
</div>
</div>
)}
</div>
)}
{activeTab === "capabilities" && (
<div>
<h3 className="text-lg font-semibold mb-3">Capabilities</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{(mcp.tools || mcp.capabilities || []).map((capability, index) => (
<div
key={index}
className="flex items-center gap-2 p-3 bg-[var(--surface)] rounded-md border border-[var(--border)]"
>
<CheckCircle className="h-4 w-4 text-green-500" />
<span className="text-sm">
{typeof capability === 'string' ? capability : (capability as any).name || String(capability)}
</span>
</div>
))}
</div>
</div>
)}
{activeTab === "configuration" && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold mb-3">Installation Configuration</h3>
{mcp.config && mcp.config.length > 0 ? (
<div className="space-y-4">
{mcp.config.map((configItem, index) => (
<div key={index} className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4">
<h4 className="font-medium mb-3">Configuration {index + 1}</h4>
<div className="space-y-3">
{configItem.mcpServers && (
<div>
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Command</h5>
<code className="block bg-[var(--background)] p-2 rounded text-sm">
{configItem.mcpServers.command} {configItem.mcpServers.arguments?.join(' ')}
</code>
</div>
)}
{configItem.mcpServers?.env && Object.keys(configItem.mcpServers.env).length > 0 && (
<div>
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Environment Variables</h5>
<div className="space-y-2">
{Object.entries(configItem.mcpServers.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2">
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
{key}={String(value)}
</code>
</div>
))}
</div>
</div>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No configuration available for this MCP</p>
</div>
)}
</div>
{mcp.tools && mcp.tools.length > 0 && (
<div>
<h3 className="text-lg font-semibold mb-3">Available Tools</h3>
<div className="space-y-3">
{mcp.tools.map((tool, index) => (
<div key={index} className="bg-[var(--surface)] border border-[var(--border)] rounded-lg p-4">
<h4 className="font-medium mb-2">{tool.name}</h4>
<p className="text-sm text-[var(--muted-foreground)] mb-3">{tool.description}</p>
{tool.input_schema && (
<div>
<h5 className="text-sm font-medium text-[var(--muted-foreground)] mb-2">Parameters</h5>
<div className="space-y-2">
{Object.entries(tool.input_schema.properties || {}).map(([param, schema]) => (
<div key={param} className="flex items-center gap-2">
<code className="bg-[var(--background)] px-2 py-1 rounded text-sm font-mono">
{param}
</code>
<span className="text-xs text-[var(--muted-foreground)]">
{typeof schema === 'object' && schema ? (schema as any).type || 'any' : 'any'}
{tool.input_schema.required?.includes(param) && (
<span className="text-red-500 ml-1">*</span>
)}
</span>
</div>
))}
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{activeTab === "screenshots" && (
<div>
<h3 className="text-lg font-semibold mb-3">Screenshots</h3>
{mcp.screenshotUrls && mcp.screenshotUrls.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{mcp.screenshotUrls.map((url, index) => (
<img
key={index}
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-md border border-[var(--border)]"
/>
))}
</div>
) : (
<div className="text-center py-12 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>No screenshots available</p>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-[var(--border)]">
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md hover:bg-[var(--background)] transition-colors"
>
Close
</button>
{!mcp.installed && (
<button
onClick={onInstall}
className="px-4 py-2 bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors mcp-install-button"
>
Install MCP
</button>
)}
</div>
</div>
</div>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import { X, Search, Filter, Package, Star, Download, Eye } from "lucide-react";
import { MCPProvider } from "@/lib/types/mcp";
import { MCPCard } from "./MCPCard";
import { MCPCategoryFilter } from "./MCPCategoryFilter";
import { MCPSearchBar } from "./MCPSearchBar";
interface MCPMarketplaceModalProps {
isOpen: boolean;
onClose: () => void;
}
export function MCPMarketplaceModal({
isOpen,
onClose,
}: MCPMarketplaceModalProps) {
const [mcps, setMcps] = useState<MCPProvider[]>([]);
const [loading, setLoading] = useState(false);
const [selectedCategory, setSelectedCategory] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [sortBy, setSortBy] = useState<
"rating" | "downloads" | "name" | "recent" | "total_ratings" | "updated_at"
>("rating");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [selectedMCP, setSelectedMCP] = useState<MCPProvider | null>(null);
const [syncStatus, setSyncStatus] = useState<{
isSyncing: boolean;
lastSync: number | null;
totalCached: number;
cacheValid: boolean;
} | null>(null);
const [categories, setCategories] = useState<{
primary: Array<{ category: string; count: number }>;
others: Array<{ category: string; count: number }>;
}>({ primary: [], others: [] });
const fetchMCPs = useCallback(async () => {
console.log("Fetching MCPs with params:", {
selectedCategory,
debouncedSearchTerm,
sortBy,
sortOrder,
});
setLoading(true);
try {
const params = new URLSearchParams();
if (selectedCategory !== "all")
params.append("category", selectedCategory);
if (debouncedSearchTerm) params.append("search", debouncedSearchTerm);
params.append("sort", sortBy);
params.append("order", sortOrder);
params.append("limit", "2000"); // Aumentado para 2000
const response = await fetch(`/api/mcp/list?${params}`);
const data = await response.json();
if (data.success) {
setMcps(data.mcps);
}
} catch (error) {
console.error("Error fetching MCPs:", error);
} finally {
setLoading(false);
}
}, [selectedCategory, debouncedSearchTerm, sortBy, sortOrder]);
const fetchCategories = useCallback(async () => {
try {
const response = await fetch("/api/mcp/categories");
const data = await response.json();
if (data.success) {
setCategories(data.categories);
console.log("📊 Categories loaded:", data.categories);
} else {
console.error("Failed to fetch categories:", data.error);
}
} catch (error) {
console.error("Error fetching categories:", error);
}
}, []);
const handleInstall = async (mcpId: string) => {
try {
const response = await fetch("/api/mcp/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpId }),
});
const data = await response.json();
if (data.success) {
// Atualizar estado local
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === mcpId ? { ...mcp, installed: true } : mcp
)
);
}
} catch (error) {
console.error("Error installing MCP:", error);
}
};
const handleUninstall = async (mcpId: string) => {
try {
const response = await fetch("/api/mcp/uninstall", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mcpId }),
});
const data = await response.json();
if (data.success) {
// Atualizar estado local
setMcps((prev) =>
prev.map((mcp) =>
mcp.id === mcpId ? { ...mcp, installed: false } : mcp
)
);
}
} catch (error) {
console.error("Error uninstalling MCP:", error);
}
};
const handleViewDetails = (mcp: MCPProvider) => {
setSelectedMCP(mcp);
};
// Carregar MCPs e categorias quando o modal abrir
useEffect(() => {
if (isOpen) {
fetchMCPs();
fetchCategories();
}
}, [isOpen]); // Removido fetchMCPs das dependências
// Debounce para searchTerm
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
// Fetch sync status when modal opens
useEffect(() => {
if (isOpen) {
fetch("/api/mcp/sync")
.then((r) => r.json())
.then((data) => {
setSyncStatus({
isSyncing: data.isSyncing,
lastSync: data.lastSync,
totalCached: data.totalCached,
cacheValid: data.cacheValid,
});
})
.catch(console.error);
}
}, [isOpen]);
// Recarregar MCPs quando filtros mudarem (só se modal estiver aberto)
useEffect(() => {
if (isOpen) {
fetchMCPs();
}
}, [selectedCategory, debouncedSearchTerm, sortBy, sortOrder, isOpen]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-7xl h-[95vh] bg-[var(--background)] border border-[var(--border)] rounded-lg shadow-xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-[var(--border)] flex-shrink-0">
<div className="flex items-center gap-3">
<Package className="h-6 w-6 text-[var(--primary)]" />
<h2 className="text-xl font-semibold">MCP Marketplace</h2>
<span className="text-sm text-[var(--muted-foreground)]">
{mcps.length} MCPs available
</span>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Sync Status Banner */}
{syncStatus?.isSyncing && (
<div className="bg-blue-50 border-b border-blue-200 px-4 py-2 text-sm text-blue-700 flex-shrink-0">
<div className="flex items-center gap-2">
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
<span>
Sincronizando marketplace... {syncStatus.totalCached} servidores
carregados
</span>
</div>
</div>
)}
{syncStatus?.lastSync && !syncStatus.isSyncing && (
<div className="bg-green-50 border-b border-green-200 px-4 py-2 text-sm text-green-700 flex-shrink-0">
<div className="flex items-center justify-between">
<span>
Cache local ativo • {syncStatus.totalCached} servidores • Última
atualização: {new Date(syncStatus.lastSync).toLocaleString()}
</span>
<button
onClick={() => {
fetch("/api/mcp/sync", { method: "POST" })
.then(() => {
// Recarregar status após iniciar sync
fetch("/api/mcp/sync")
.then((r) => r.json())
.then((data) =>
setSyncStatus({
isSyncing: data.isSyncing,
lastSync: data.lastSync,
totalCached: data.totalCached,
cacheValid: data.cacheValid,
})
);
})
.catch(console.error);
}}
className="px-2 py-1 bg-green-600 text-white text-xs rounded hover:bg-green-700 transition-colors"
>
Atualizar
</button>
</div>
</div>
)}
{/* Content */}
<div className="flex flex-1 min-h-0">
{/* Sidebar */}
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
<MCPCategoryFilter
primaryCategories={categories.primary.map((cat) => ({
id: cat.category.toLowerCase(),
name: cat.category,
icon: cat.category,
count: cat.count,
}))}
otherCategories={categories.others.map((cat) => ({
id: cat.category.toLowerCase(),
name: cat.category,
icon: cat.category,
count: cat.count,
}))}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-h-0">
{/* Search and Filters */}
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
<div className="flex gap-4 items-center">
<MCPSearchBar
value={searchTerm}
onChange={setSearchTerm}
onSearch={() => {
// O useEffect já cuida de recarregar quando searchTerm muda
}}
/>
<div className="flex gap-2 items-center">
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)}
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
>
<option value="rating">Por Avaliação</option>
<option value="downloads">Por Downloads</option>
<option value="name">Por Nome</option>
<option value="recent">Mais Recentes</option>
<option value="total_ratings">Por Nº de Reviews</option>
<option value="updated_at">Atualizados Recentemente</option>
</select>
<button
onClick={() =>
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))
}
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
title={
sortOrder === "asc"
? "Ordem Crescente"
: "Ordem Decrescente"
}
>
{sortOrder === "asc" ? "↑" : "↓"}
</button>
</div>
</div>
</div>
{/* MCP Grid */}
<div className="flex-1 p-4 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{mcps.map((mcp) => (
<MCPCard
key={mcp.id}
mcp={mcp}
onInstall={() => handleInstall(mcp.id)}
onUninstall={() => handleUninstall(mcp.id)}
onViewDetails={() => handleViewDetails(mcp)}
/>
))}
</div>
)}
{!loading && mcps.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
<Package className="h-12 w-12 mb-4 opacity-50" />
<p>No MCPs found</p>
<p className="text-sm">
Try adjusting your search or filters
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
"use client";
import { Star } from "lucide-react";
interface MCPRatingStarsProps {
rating: number;
size?: "sm" | "md" | "lg";
showNumber?: boolean;
}
export function MCPRatingStars({
rating,
size = "sm",
showNumber = true,
}: MCPRatingStarsProps) {
const sizeClasses = {
sm: "h-3 w-3",
md: "h-4 w-4",
lg: "h-5 w-5",
};
const stars = [];
const fullStars = Math.floor(rating);
const hasHalfStar = rating % 1 >= 0.5;
// Renderizar estrelas cheias
for (let i = 0; i < fullStars; i++) {
stars.push(
<Star
key={i}
className={`${sizeClasses[size]} fill-yellow-400 text-yellow-400`}
/>
);
}
// Renderizar meia estrela se necessário
if (hasHalfStar) {
stars.push(
<div key="half" className="relative">
<Star className={`${sizeClasses[size]} text-gray-300`} />
<div
className="absolute inset-0 overflow-hidden"
style={{ width: "50%" }}
>
<Star
className={`${sizeClasses[size]} fill-yellow-400 text-yellow-400`}
/>
</div>
</div>
);
}
// Renderizar estrelas vazias
const emptyStars = 5 - Math.ceil(rating);
for (let i = 0; i < emptyStars; i++) {
stars.push(
<Star
key={`empty-${i}`}
className={`${sizeClasses[size]} text-gray-300`}
/>
);
}
return (
<div className="flex items-center gap-1">
<div className="flex">{stars}</div>
{showNumber && (
<span className="text-sm text-[var(--muted-foreground)] ml-1">
{rating.toFixed(1)}
</span>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { Search } from "lucide-react";
interface MCPSearchBarProps {
value: string;
onChange: (value: string) => void;
onSearch: () => void;
}
export function MCPSearchBar({ value, onChange, onSearch }: MCPSearchBarProps) {
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
onSearch();
}
};
return (
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-[var(--muted-foreground)]" />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Search MCPs..."
className="w-full pl-10 pr-4 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:border-transparent mcp-search-input"
/>
</div>
);
}
+393 -44
View File
@@ -1,13 +1,30 @@
"use client";
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>