import React from 'react'; import { Star, HelpCircle, Lock, X, Pencil } from 'lucide-react'; import { cn } from '../../lib/utils'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'; import type { LLMProvider } from '@dexto/core'; import { PROVIDER_LOGOS, needsDarkModeInversion, formatPricingLines, hasLogo } from './constants'; import { CapabilityIcons } from './CapabilityIcons'; import type { ModelInfo, ProviderCatalog } from './types'; interface ModelCardProps { provider: LLMProvider; model: ModelInfo; providerInfo?: ProviderCatalog; isFavorite: boolean; isActive: boolean; onClick: () => void; onToggleFavorite: () => void; onDelete?: () => void; onEdit?: () => void; size?: 'sm' | 'md' | 'lg'; isCustom?: boolean; /** Installed local model (downloaded via CLI) */ isInstalled?: boolean; } // Provider display name mapping const PROVIDER_DISPLAY_NAMES: Record = { anthropic: 'Claude', google: 'Gemini', openai: 'GPT', groq: 'Groq', xai: 'Grok', cohere: 'Cohere', openrouter: 'OpenRouter', 'openai-compatible': 'Custom', litellm: 'LiteLLM', glama: 'Glama', local: 'Local', ollama: 'Ollama', dexto: 'Dexto', }; // Parse display name into provider and model parts function parseModelName( displayName: string, provider: string ): { providerName: string; modelName: string; suffix?: string } { const providerName = PROVIDER_DISPLAY_NAMES[provider] || provider; // For multi-vendor or custom model providers, show the full display name without parsing if ( provider === 'openrouter' || provider === 'dexto' || provider === 'openai-compatible' || provider === 'litellm' || provider === 'glama' || provider === 'bedrock' || provider === 'vertex' ) { return { providerName, modelName: displayName }; } // Extract suffix like (Reasoning) if present const suffixMatch = displayName.match(/\(([^)]+)\)$/); const suffix = suffixMatch ? suffixMatch[1] : undefined; const nameWithoutSuffix = suffix ? displayName.replace(/\s*\([^)]+\)$/, '') : displayName; // Try to extract model variant (remove provider prefix if present) let modelName = nameWithoutSuffix; const lowerName = nameWithoutSuffix.toLowerCase(); const lowerProvider = providerName.toLowerCase(); if (lowerName.startsWith(lowerProvider)) { modelName = nameWithoutSuffix.slice(providerName.length).trim(); } // Clean up common patterns modelName = modelName.replace(/^[-\s]+/, '').replace(/^(claude|gemini|gpt|grok)\s*/i, ''); return { providerName, modelName: modelName || nameWithoutSuffix, suffix }; } export function ModelCard({ provider, model, providerInfo, isFavorite, isActive, onClick, onToggleFavorite, onDelete, onEdit, size = 'md', isCustom = false, isInstalled = false, }: ModelCardProps) { const displayName = model.displayName || model.name; // Local/ollama/installed models don't need API keys // Custom models are user-configured, so don't show lock (they handle their own auth) const noApiKeyNeeded = isInstalled || isCustom || provider === 'local' || provider === 'ollama'; const hasApiKey = noApiKeyNeeded || (providerInfo?.hasApiKey ?? false); const { providerName, modelName, suffix } = parseModelName(displayName, provider); // Build description lines for tooltip const priceLines = formatPricingLines(model.pricing || undefined); const descriptionLines = [ `Model: ${displayName}`, isInstalled ? 'Installed via CLI' : provider === 'local' ? 'Local Model' : provider === 'openai-compatible' ? 'Custom Model' : `Provider: ${providerInfo?.name}`, model.maxInputTokens && `Max tokens: ${model.maxInputTokens.toLocaleString()}`, Array.isArray(model.supportedFileTypes) && model.supportedFileTypes.length > 0 && `Supports: ${model.supportedFileTypes.join(', ')}`, !hasApiKey && 'API key required (click to add)', ...priceLines, ].filter(Boolean) as string[]; const sizeClasses = { sm: 'px-2 py-4 h-[200px] w-full', md: 'px-3 py-5 h-[230px] w-full', lg: 'px-4 py-6 h-[275px] w-full', }; const logoSizes = { sm: { width: 36, height: 36, container: 'w-10 h-10' }, md: { width: 48, height: 48, container: 'w-14 h-14' }, lg: { width: 60, height: 60, container: 'w-16 h-16' }, }; return (
{ const target = event.target as HTMLElement | null; if (target && target.closest('button')) return; const isEnter = event.key === 'Enter'; const isSpace = event.key === ' ' || event.key === 'Spacebar' || event.code === 'Space'; if (!isEnter && !isSpace) return; if (isSpace) event.preventDefault(); onClick(); }} className={cn( 'relative flex flex-col items-center rounded-2xl border-2 transition-all duration-200 cursor-pointer group overflow-hidden', sizeClasses[size], 'hover:bg-accent/40 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5', isActive ? 'bg-primary/10 border-primary shadow-lg shadow-primary/10' : 'border-border/50 bg-card/60 backdrop-blur-sm', !hasApiKey && 'opacity-70' )} role="button" tabIndex={0} > {/* Lock Icon - Top Left (when no API key) */} {!hasApiKey && (
Click to add API key
)} {/* Action Buttons - Top Left for custom/installed models */} {(isCustom || isInstalled) && (onEdit || onDelete) && (
{onEdit && ( )} {onDelete && ( )}
)} {/* Favorite Star - Top Right */} {/* Provider Logo */}
{hasLogo(provider) ? ( {`${provider} ) : ( )}
{/* Model Name */}
{providerName}
{modelName}
{suffix && (
({suffix})
)}
{/* Capability Icons - fixed height to ensure consistent card layout */}
{descriptionLines.map((line, idx) => (
{line}
))}
); }