Spaces:
Running
Running
| tsx | |
| import React, { useState, useRef, useEffect } from 'react' | |
| import { useAppStore } from '@/store/useAppStore' | |
| import { AI_MODELS, CATEGORY_NAMES, CATEGORY_DESCRIPTIONS, getModelsByCategory } from '@/utils/constants' | |
| import { AIModel, AICategory } from '@/types' | |
| import { ChevronDown, Search, Zap } from 'lucide-react' | |
| /** | |
| * Component for selecting AI models with categorized dropdown | |
| */ | |
| export function ModelSelector() { | |
| const { selectedModel, setSelectedModel } = useAppStore() | |
| const [isOpen, setIsOpen] = useState(false) | |
| const [searchTerm, setSearchTerm] = useState('') | |
| const [selectedCategory, setSelectedCategory] = useState<AICategory | null>(null) | |
| const dropdownRef = useRef<HTMLDivElement>(null) | |
| const searchRef = useRef<HTMLInputElement>(null) | |
| /** | |
| * Filter models based on search term and category | |
| */ | |
| const filteredModels = AI_MODELS.filter(model => { | |
| const matchesSearch = model.name.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| model.description.toLowerCase().includes(searchTerm.toLowerCase()) | |
| const matchesCategory = !selectedCategory || model.category === selectedCategory | |
| return matchesSearch && matchesCategory | |
| }) | |
| /** | |
| * Close dropdown when clicking outside | |
| */ | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setIsOpen(false) | |
| } | |
| } | |
| document.addEventListener('mousedown', handleClickOutside) | |
| return () => document.removeEventListener('mousedown', handleClickOutside) | |
| }, []) | |
| /** | |
| * Focus search input when dropdown opens | |
| */ | |
| useEffect(() => { | |
| if (isOpen && searchRef.current) { | |
| searchRef.current.focus() | |
| } | |
| }, [isOpen]) | |
| /** | |
| * Handle keyboard navigation | |
| */ | |
| const handleKeyDown = (event: React.KeyboardEvent) => { | |
| if (!isOpen && (event.key === 'Enter' || event.key === ' ')) { | |
| event.preventDefault() | |
| setIsOpen(true) | |
| } else if (isOpen && event.key === 'Escape') { | |
| setIsOpen(false) | |
| } | |
| } | |
| /** | |
| * Handle model selection | |
| */ | |
| const handleModelSelect = (model: AIModel) => { | |
| setSelectedModel(model) | |
| setIsOpen(false) | |
| setSearchTerm('') | |
| setSelectedCategory(null) | |
| } | |
| /** | |
| * Handle category selection | |
| */ | |
| const handleCategorySelect = (category: AICategory) => { | |
| setSelectedCategory(selectedCategory === category ? null : category) | |
| } | |
| /** | |
| * Get selected model info | |
| */ | |
| const selectedModelInfo = selectedModel ? AI_MODELS.find(m => m.id === selectedModel) : null | |
| /** | |
| * Get category icon | |
| */ | |
| const getCategoryIcon = (category: AICategory) => { | |
| switch (category) { | |
| case 'llm': | |
| return <Zap className="w-4 h-4" /> | |
| case 'code': | |
| return <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /> | |
| </svg> | |
| case 'image': | |
| return <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> | |
| </svg> | |
| case 'special': | |
| return <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" /> | |
| </svg> | |
| } | |
| } | |
| return ( | |
| <div className="space-y-4"> | |
| {/* Selected Model Display */} | |
| <div className="relative" ref={dropdownRef}> | |
| <button | |
| type="button" | |
| onClick={() => setIsOpen(!isOpen)} | |
| onKeyDown={handleKeyDown} | |
| className="w-full input-field flex items-center justify-between text-left" | |
| aria-label="KI-Modell auswählen" | |
| aria-expanded={isOpen} | |
| aria-haspopup="listbox" | |
| > | |
| {selectedModelInfo ? ( | |
| <div className="flex items-center space-x-3"> | |
| <div className="w-8 h-8 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-lg flex items-center justify-center flex-shrink-0"> | |
| {getCategoryIcon(selectedModelInfo.category)} | |
| </div> | |
| <div> | |
| <div className="font-medium text-gray-900 dark:text-gray-100"> | |
| {selectedModelInfo.name} | |
| </div> | |
| <div className="text-sm text-gray-600 dark:text-gray-400 truncate max-w-[200px]"> | |
| {selectedModelInfo.description} | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <span className="text-gray-500 dark:text-gray-400"> | |
| Wähle eine KI-Plattform aus... | |
| </span> | |
| )} | |
| <ChevronDown className={`w-5 h-5 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} /> | |
| </button> | |
| {/* Dropdown Menu */} | |
| {isOpen && ( | |
| <div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-96 overflow-hidden"> | |
| {/* Search Input */} | |
| <div className="p-3 border-b border-gray-200 dark:border-gray-700"> | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> | |
| <input | |
| ref={searchRef} | |
| type="text" | |
| placeholder="KI suchen..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="w-full pl-9 pr-3 py-2 text-sm input-field" | |
| /> | |
| </div> | |
| </div> | |
| {/* Categories */} | |
| <div className="p-2 space-y-2 max-h-80 overflow-y-auto"> | |
| {Object.entries(CATEGORY_NAMES).map(([categoryKey, categoryName]) => { | |
| const category = categoryKey as AICategory | |
| const categoryModels = getModelsByCategory(category) | |
| const filteredCategoryModels = categoryModels.filter(model => | |
| filteredModels.includes(model) | |
| ) | |
| if (filteredCategoryModels.length === 0) return null | |
| return ( | |
| <div key={category} className="space-y-1"> | |
| {/* Category Header */} | |
| <button | |
| type="button" | |
| onClick={() => handleCategorySelect(category)} | |
| className="w-full flex items-center justify-between p-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors" | |
| > | |
| <div className="flex items-center space-x-2"> | |
| {getCategoryIcon(category)} | |
| <span>{categoryName}</span> | |
| <span className="text-xs text-gray-500 dark:text-gray-400"> | |
| ({filteredCategoryModels.length}) | |
| </span> | |
| </div> | |
| <div className={`transform transition-transform ${selectedCategory === category ? 'rotate-180' : ''}`}> | |
| <ChevronDown className="w-3 h-3" /> | |
| </div> | |
| </button> | |
| {/* Models in Category */} | |
| {(selectedCategory === category || selectedCategory === null) && ( | |
| <div className="ml-6 space-y-1"> | |
| {filteredCategoryModels.map((model) => ( | |
| <button | |
| key={model.id} | |
| type="button" | |
| onClick={() => handleModelSelect(model.id)} | |
| className={`w-full text-left p-2 rounded-md transition-colors ${ | |
| selectedModel === model.id | |
| ? 'bg-primary-100 dark:bg-primary-900 text-primary-900 dark:text-primary-100' | |
| : 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' | |
| }`} | |
| > | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <div className="font-medium text-sm">{model.name}</div> | |
| <div className="text-xs text-gray-500 dark:text-gray-400 truncate"> | |
| {model.description} | |
| </div> | |
| </div> | |
| {model.supportsStreaming && ( | |
| <div className="w-2 h-2 bg-green-400 rounded-full flex-shrink-0 ml-2" /> | |
| )} | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Selected Model Info */} | |
| {selectedModelInfo && ( | |
| <div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg"> | |
| <div className="flex items-start space-x-3"> | |
| <div className="w-10 h-10 bg-gradient-to-br from-primary-500 to-secondary-500 rounded-lg flex items-center justify-center flex-shrink-0"> | |
| {getCategoryIcon(selectedModelInfo.category)} | |
| </div> | |
| <div className="flex-1"> | |
| <h4 className="font-medium text-gray-900 dark:text-gray-100"> | |
| {selectedModelInfo.name} | |
| </h4> | |
| <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> | |
| {selectedModelInfo.description} | |
| </p> | |
| <div className="flex items-center space-x-4 mt-2 text-xs text-gray-500 dark:text-gray-400"> | |
| <span className="flex items-center"> | |
| <span className="capitalize">{CATEGORY_NAMES[selectedModelInfo.category]}</span> | |
| </span> | |
| {selectedModelInfo.maxTokens && ( | |
| <span>Max: {(selectedModelInfo.maxTokens / 1000).toFixed(0)}k Tokens</span> | |
| )} | |
| {selectedModelInfo.supportsStreaming && ( | |
| <span className="flex items-center"> | |
| <span className="w-2 h-2 bg-green-400 rounded-full mr-1" /> | |
| Streaming | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| </html> |