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。
添加 Provider
添加 AI Provider
配置新的 LLM 服务商连接信息。
{updateProviders.isPending ? "保存中..." : "保存"}
{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
ACTIVE MODELS
{provider.models.length > 0 && (
清空配置
)}
{/* Search / Add Input */}
setSearchQuery(e.target.value)}
onFocus={handleInputFocus}
className="pl-8 h-9 text-sm"
/>
{isFetchingModels && (
)}
{searchQuery && !isFetchingModels && (
Add "{searchQuery}"
)}
{/* Autocomplete Dropdown */}
{isSearchFocused && searchQuery && discoveredModels.length > 0 && (
{filteredModels.length > 0 ? (
filteredModels.map((model) => (
handleAddModel(model)}
>
{model.name || model.model_id}
{provider.models.some(m => m.model_id === model.model_id) && (
Added
)}
))
) : (
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}
handleRemoveModel(model.model_id)}
className="ml-1 hover:text-destructive focus:outline-none"
>
))}
删除
)
}