feat: update requesters and improve provider selection UI

- Added `litellm_provider` field to various requesters' YAML configurations.
- Removed obsolete Python requester files for OpenRouter, PPIO, QHAIGC, ShengSuanYun, SiliconFlow, Space, TokenPony, VolcArk, and Xai.
- Introduced new requesters for Tencent and Together AI with corresponding YAML configurations and SVG icons.
- Enhanced the ProviderForm component to include a searchable dropdown for selecting providers, improving user experience.
- Updated localization files to include search provider text for both English and Chinese.
This commit is contained in:
fdc310
2026-06-04 08:31:17 +08:00
parent 8dd16aac51
commit 527c8dc76a
75 changed files with 566 additions and 3309 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useRef, useCallback } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -16,19 +16,12 @@ import {
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { CustomApiError } from '@/app/infra/entities/common';
import { cn } from '@/lib/utils';
import { Check, ChevronDown, Search } from 'lucide-react';
const getFormSchema = (t: (key: string) => string) =>
z.object({
@@ -71,6 +64,10 @@ export default function ProviderForm({
description: string;
}[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
loadRequesters();
@@ -79,6 +76,54 @@ export default function ProviderForm({
}
}, [providerId]);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
searchInputRef.current.focus();
}
}, [isOpen]);
// Filter requesters based on search query
const filteredRequesters = requesterList.filter(
(r) =>
r.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.value.toLowerCase().includes(searchQuery.toLowerCase()),
);
// Group filtered requesters by category
const groupedRequesters = {
builtin: filteredRequesters.filter((r) => r.category === 'builtin'),
manufacturer: filteredRequesters.filter(
(r) => r.category === 'manufacturer',
),
maas: filteredRequesters.filter((r) => r.category === 'maas'),
'self-hosted': filteredRequesters.filter(
(r) => r.category === 'self-hosted',
),
};
const categoryLabels: Record<string, string> = {
builtin: t('models.builtin'),
manufacturer: t('models.modelManufacturer'),
maas: t('models.aggregationPlatform'),
'self-hosted': t('models.selfDeployed'),
};
async function loadRequesters() {
const resp = await httpClient.getProviderRequesters();
setRequesterList(
@@ -165,17 +210,16 @@ export default function ProviderForm({
{t('models.requester')}
<span className="text-red-500">*</span>
</FormLabel>
<Select
onValueChange={(v) => {
field.onChange(v);
const req = requesterList.find((r) => r.value === v);
if (req && (!providerId || !form.getValues('base_url'))) {
form.setValue('base_url', req.defaultUrl);
}
}}
value={field.value}
>
<SelectTrigger className="bg-background">
<div ref={dropdownRef} className="relative">
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
isOpen && 'ring-2 ring-ring ring-offset-2',
)}
>
{selectedRequester ? (
<div className="flex items-center gap-2">
<img
@@ -188,90 +232,102 @@ export default function ProviderForm({
<span>{selectedRequester.label}</span>
</div>
) : (
<SelectValue placeholder={t('models.selectRequester')} />
<span className="text-muted-foreground">
{t('models.selectRequester')}
</span>
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('models.builtin')}</SelectLabel>
{requesterList
.filter((r) => r.category === 'builtin')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.modelManufacturer')}</SelectLabel>
{requesterList
.filter((r) => r.category === 'manufacturer')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>
{t('models.aggregationPlatform')}
</SelectLabel>
{requesterList
.filter((r) => r.category === 'maas')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel>{t('models.selfDeployed')}</SelectLabel>
{requesterList
.filter((r) => r.category === 'self-hosted')
.map((r) => (
<SelectItem key={r.value} value={r.value}>
<div className="flex items-center gap-2">
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span>{r.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<ChevronDown
className={cn(
'h-4 w-4 opacity-50 transition-transform',
isOpen && 'rotate-180',
)}
/>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95">
{/* Search input */}
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
ref={searchInputRef}
type="text"
placeholder={
t('models.searchProviders') || 'Search providers...'
}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
{/* Options list */}
<div className="max-h-[300px] overflow-y-auto p-1">
{Object.entries(groupedRequesters).map(
([category, items]) => {
if (items.length === 0) return null;
return (
<div key={category}>
<div className="py-1.5 px-2 text-xs font-semibold text-muted-foreground">
{categoryLabels[category]}
</div>
{items.map((r) => (
<button
key={r.value}
type="button"
onClick={() => {
field.onChange(r.value);
const req = requesterList.find(
(req) => req.value === r.value,
);
if (
req &&
(!providerId ||
!form.getValues('base_url'))
) {
form.setValue(
'base_url',
req.defaultUrl,
);
}
setIsOpen(false);
setSearchQuery('');
}}
className={cn(
'flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground cursor-pointer',
field.value === r.value &&
'bg-accent text-accent-foreground',
)}
>
<img
src={httpClient.getProviderRequesterIconURL(
r.value,
)}
alt={r.label}
className="h-5 w-5 rounded"
/>
<span className="flex-1 text-left">
{r.label}
</span>
{field.value === r.value && (
<Check className="h-4 w-4" />
)}
</button>
))}
</div>
);
},
)}
{filteredRequesters.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
No results found.
</div>
)}
</div>
</div>
)}
</div>
<FormMessage />
{selectedRequester?.description && (
<p className="text-sm text-muted-foreground">

View File

@@ -244,6 +244,7 @@ const enUS = {
selectProvider: 'Select Provider',
requester: 'Provider Type',
selectRequester: 'Select Provider Type',
searchProviders: 'Search providers...',
langbotModelsDescription: 'Cloud models powered by LangBot Space',
credits: 'Credits',
loginWithSpace: 'Login with Space',

View File

@@ -234,6 +234,7 @@ const zhHans = {
selectProvider: '选择供应商',
requester: '供应商类型',
selectRequester: '选择供应商类型',
searchProviders: '搜索供应商...',
langbotModelsDescription: 'LangBot Space 提供的云端模型',
credits: '积分',
loginWithSpace: '通过 Space 登录',