Fundamental_Analysis/frontend/src/pages/config/AIProviderTab.tsx

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>
)
}