420 lines
17 KiB
TypeScript
420 lines
17 KiB
TypeScript
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 <div>Loading providers...</div>;
|
|
}
|
|
|
|
// 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 (
|
|
<div className="space-y-6">
|
|
<div className="flex justify-between items-center">
|
|
<div>
|
|
<h3 className="text-lg font-medium">已配置的服务商</h3>
|
|
<p className="text-sm text-muted-foreground">管理连接到系统的 LLM API。</p>
|
|
</div>
|
|
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button><Plus className="mr-2 h-4 w-4" /> 添加 Provider</Button>
|
|
</DialogTrigger>
|
|
<DialogContent className="sm:max-w-[425px]">
|
|
<DialogHeader>
|
|
<DialogTitle>添加 AI Provider</DialogTitle>
|
|
<DialogDescription>
|
|
配置新的 LLM 服务商连接信息。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label htmlFor="name" className="text-right">名称</Label>
|
|
<Input
|
|
id="name"
|
|
placeholder="e.g. OpenAI"
|
|
className="col-span-3"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label htmlFor="url" className="text-right">Base URL</Label>
|
|
<Input
|
|
id="url"
|
|
placeholder="https://api.openai.com/v1"
|
|
className="col-span-3"
|
|
value={formData.url}
|
|
onChange={(e) => setFormData({...formData, url: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-4 items-center gap-4">
|
|
<Label htmlFor="key" className="text-right">API Key</Label>
|
|
<Input
|
|
id="key"
|
|
type="password"
|
|
className="col-span-3"
|
|
value={formData.key}
|
|
onChange={(e) => setFormData({...formData, key: e.target.value})}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="submit" onClick={handleSave} disabled={updateProviders.isPending}>
|
|
{updateProviders.isPending ? "保存中..." : "保存"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-1 lg:grid-cols-2">
|
|
{providers && Object.entries(providers).map(([id, provider]) => (
|
|
<ProviderCard
|
|
key={id}
|
|
id={id}
|
|
provider={provider}
|
|
onDelete={() => handleDelete(id)}
|
|
onUpdate={(p, showToast) => handleUpdateProvider(id, p, showToast)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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<LlmModel[]>([]);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [isSearchFocused, setIsSearchFocused] = useState(false);
|
|
const [isFetchingModels, setIsFetchingModels] = useState(false);
|
|
const searchRef = useRef<HTMLDivElement>(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 (
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<div className="space-y-1">
|
|
<CardTitle className="text-xl">{provider.name}</CardTitle>
|
|
<CardDescription className="font-mono text-xs truncate max-w-[250px]">
|
|
{provider.api_base_url}
|
|
</CardDescription>
|
|
</div>
|
|
<Badge variant="outline">{provider.models.length} Models</Badge>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 mt-4">
|
|
<div className="space-y-2">
|
|
<Label className="text-xs text-muted-foreground">API KEY</Label>
|
|
<div className="flex items-center space-x-2">
|
|
<Input
|
|
type={showKey ? "text" : "password"}
|
|
value={provider.api_key}
|
|
readOnly
|
|
className="h-8 font-mono text-xs bg-muted/50"
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setShowKey(!showKey)}
|
|
>
|
|
{showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs text-muted-foreground">ACTIVE MODELS</Label>
|
|
<div className="flex gap-1">
|
|
{provider.models.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 text-xs px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
onClick={handleClearModels}
|
|
>
|
|
<Trash2 className="mr-1 h-3 w-3" /> 清空配置
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search / Add Input */}
|
|
<div className="relative" ref={searchRef}>
|
|
<div className="relative">
|
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search or enter model ID (auto-completes from provider)..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
onFocus={handleInputFocus}
|
|
className="pl-8 h-9 text-sm"
|
|
/>
|
|
{isFetchingModels && (
|
|
<div className="absolute right-8 top-2.5">
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
{searchQuery && !isFetchingModels && (
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="absolute right-1 top-1 h-7 px-2 text-xs"
|
|
onClick={handleManualAdd}
|
|
>
|
|
<Plus className="h-3 w-3 mr-1" /> Add "{searchQuery}"
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Autocomplete Dropdown */}
|
|
{isSearchFocused && searchQuery && discoveredModels.length > 0 && (
|
|
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-md max-h-[200px] overflow-y-auto">
|
|
{filteredModels.length > 0 ? (
|
|
filteredModels.map((model) => (
|
|
<button
|
|
key={model.model_id}
|
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground flex items-center justify-between"
|
|
onClick={() => handleAddModel(model)}
|
|
>
|
|
<span className="truncate">{model.name || model.model_id}</span>
|
|
{provider.models.some(m => m.model_id === model.model_id) && (
|
|
<Badge variant="outline" className="text-[10px]">Added</Badge>
|
|
)}
|
|
</button>
|
|
))
|
|
) : (
|
|
<div className="px-3 py-2 text-xs text-muted-foreground">
|
|
No discovered models match "{searchQuery}". Click 'Add' to add manually.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Active Models List */}
|
|
<div className="flex flex-wrap gap-2 max-h-[150px] overflow-y-auto p-1 border rounded-md bg-muted/10 mt-2">
|
|
{provider.models.length === 0 && <span className="text-xs text-muted-foreground p-2">No active models.</span>}
|
|
{provider.models.map(model => (
|
|
<Badge key={model.model_id} variant="secondary" className="text-xs font-normal flex items-center gap-1 pr-1">
|
|
{model.name || model.model_id}
|
|
<button
|
|
onClick={() => handleRemoveModel(model.model_id)}
|
|
className="ml-1 hover:text-destructive focus:outline-none"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</button>
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="justify-end pt-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
onClick={onDelete}
|
|
>
|
|
<Trash2 className="mr-2 h-4 w-4" /> 删除
|
|
</Button>
|
|
</CardFooter>
|
|
</Card>
|
|
)
|
|
}
|