import { Plus, Trash2, Eye, EyeOff, X, Search, Loader2 } from "lucide-react" import { useState, useRef, useEffect } from "react" import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { useLlmProviders, useUpdateLlmProviders } from "@/hooks/useConfig" import { LlmProvider, LlmModel } from "@/types/config" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { useToast } from "@/hooks/use-toast" import axios from "axios" export function AIProviderTab() { const { data: providers, isLoading } = useLlmProviders(); const updateProviders = useUpdateLlmProviders(); const { toast } = useToast(); const [isAddOpen, setIsAddOpen] = useState(false); const [formData, setFormData] = useState({ name: '', url: '', key: '' }); const handleSave = () => { if (!formData.name || !formData.url || !formData.key) { toast({ title: "Validation Error", description: "All fields are required", type: "error" }); return; } const id = formData.name.toLowerCase().replace(/\s+/g, '_'); if (providers && providers[id]) { toast({ title: "Validation Error", description: "Provider with this name already exists", type: "error" }); return; } const newProvider: LlmProvider = { name: formData.name, api_base_url: formData.url, api_key: formData.key, models: [] }; const newProviders = { ...providers, [id]: newProvider }; updateProviders.mutate(newProviders, { onSuccess: () => { toast({ title: "Success", description: "Provider added successfully" }); setIsAddOpen(false); setFormData({ name: '', url: '', key: '' }); }, onError: (err) => { toast({ title: "Error", description: "Failed to add provider: " + err, type: "error" }); } }); } const handleDelete = (id: string) => { if (!providers) return; const { [id]: removed, ...rest } = providers; updateProviders.mutate(rest, { onSuccess: () => toast({ title: "Success", description: "Provider removed" }), onError: () => toast({ title: "Error", description: "Failed to remove provider", type: "error" }) }); } const handleUpdateProvider = (id: string, updatedProvider: LlmProvider, showToast: boolean = true) => { if (!providers) return; const newProviders = { ...providers, [id]: updatedProvider }; updateProviders.mutate(newProviders, { onSuccess: () => { if (showToast) { toast({ title: "Success", description: "Provider updated" }); } }, onError: () => toast({ title: "Error", description: "Failed to update provider", type: "error" }) }); } if (isLoading) { return
Loading providers...
; } // Handle empty state explicitly if data is loaded but empty if (providers && Object.keys(providers).length === 0) { // Fallthrough to render normally, showing empty list } return (

已配置的服务商

管理连接到系统的 LLM API。

添加 AI Provider 配置新的 LLM 服务商连接信息。
setFormData({...formData, name: e.target.value})} />
setFormData({...formData, url: e.target.value})} />
setFormData({...formData, key: e.target.value})} />
{providers && Object.entries(providers).map(([id, provider]) => ( handleDelete(id)} onUpdate={(p, showToast) => handleUpdateProvider(id, p, showToast)} /> ))}
) } function ProviderCard({ id, provider, onDelete, onUpdate }: { id: string, provider: LlmProvider, onDelete: () => void, onUpdate: (p: LlmProvider, showToast?: boolean) => void }) { const [showKey, setShowKey] = useState(false); // const discoverModels = useDiscoverModels(); // Removed as we now fetch manually const { toast } = useToast(); // Discovered models cache for search (not saved to config) const [discoveredModels, setDiscoveredModels] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [isSearchFocused, setIsSearchFocused] = useState(false); const [isFetchingModels, setIsFetchingModels] = useState(false); const searchRef = useRef(null); useEffect(() => { // Close search results on click outside const handleClickOutside = (event: MouseEvent) => { if (searchRef.current && !searchRef.current.contains(event.target as Node)) { setIsSearchFocused(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, []); // Auto-fetch models on interaction if cache is empty const fetchModels = async (force = false) => { // If we already have models and not forcing, don't fetch if (discoveredModels.length > 0 && !force) return; if (isFetchingModels) return; setIsFetchingModels(true); try { const response = await axios.get(`/api/v1/discover-models/${id}`); const data = response.data; let models: LlmModel[] = []; if (data && Array.isArray(data.data)) { models = data.data.map((m: any) => ({ model_id: m.id, name: m.name || m.id, is_active: true })); } else if (Array.isArray(data)) { models = data.map((m: any) => ({ model_id: m.id, name: m.name || m.id, is_active: true })); } setDiscoveredModels(models); if (models.length === 0) { // Silent fail or minimal toast? // toast({ title: "Info", description: "No models found from provider" }); } else { toast({ title: "Models Discovered", description: `Found ${models.length} available models for autocomplete.` }); } } catch (err) { console.error(err); toast({ title: "Discovery Error", description: "Failed to fetch model list for autocomplete", type: "error" }); } finally { setIsFetchingModels(false); } } const handleInputFocus = () => { setIsSearchFocused(true); if (discoveredModels.length === 0) { fetchModels(); } } const handleAddModel = (model: LlmModel) => { // Check if already exists if (provider.models.some(m => m.model_id === model.model_id)) { toast({ title: "Info", description: "Model already added" }); setSearchQuery(""); setIsSearchFocused(false); return; } const updatedProvider = { ...provider, models: [...provider.models, model] }; // Pass false to suppress generic toast since we show a specific one here onUpdate(updatedProvider, false); setSearchQuery(""); setIsSearchFocused(false); toast({ title: "Success", description: `Added ${model.name || model.model_id}` }); } const handleManualAdd = () => { if (!searchQuery.trim()) return; const newModel = { model_id: searchQuery, name: searchQuery, is_active: true }; handleAddModel(newModel); } const handleRemoveModel = (modelId: string) => { const updatedProvider = { ...provider, models: provider.models.filter(m => m.model_id !== modelId) }; onUpdate(updatedProvider); } const handleClearModels = () => { if (provider.models.length === 0) return; if (confirm("确定要清空所有已添加的模型吗?")) { const updatedProvider = { ...provider, models: [] }; onUpdate(updatedProvider, false); toast({ title: "Success", description: "已清空所有模型" }); } } const filteredModels = searchQuery ? discoveredModels.filter(m => (m.name?.toLowerCase().includes(searchQuery.toLowerCase()) || m.model_id.toLowerCase().includes(searchQuery.toLowerCase())) ) : []; return (
{provider.name} {provider.api_base_url}
{provider.models.length} Models
{provider.models.length > 0 && ( )}
{/* Search / Add Input */}
setSearchQuery(e.target.value)} onFocus={handleInputFocus} className="pl-8 h-9 text-sm" /> {isFetchingModels && (
)} {searchQuery && !isFetchingModels && ( )}
{/* Autocomplete Dropdown */} {isSearchFocused && searchQuery && discoveredModels.length > 0 && (
{filteredModels.length > 0 ? ( filteredModels.map((model) => ( )) ) : (
No discovered models match "{searchQuery}". Click 'Add' to add manually.
)}
)}
{/* Active Models List */}
{provider.models.length === 0 && No active models.} {provider.models.map(model => ( {model.name || model.model_id} ))}
) }