mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 00:06:04 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -234,6 +234,7 @@ const zhHans = {
|
||||
selectProvider: '选择供应商',
|
||||
requester: '供应商类型',
|
||||
selectRequester: '选择供应商类型',
|
||||
searchProviders: '搜索供应商...',
|
||||
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
||||
credits: '积分',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
|
||||
Reference in New Issue
Block a user