diff --git a/internal/domain/model.go b/internal/domain/model.go index 4c43da93..65ef3903 100644 --- a/internal/domain/model.go +++ b/internal/domain/model.go @@ -88,6 +88,9 @@ type Provider struct { // 展示的名称 Name string `json:"name"` + // Logo URL 或 data URI + Logo string `json:"logo,omitempty"` + // 配置 Config *ProviderConfig `json:"config"` diff --git a/web/src/assets/icons/88code.svg b/web/src/assets/icons/88code.svg new file mode 100644 index 00000000..e0a10593 --- /dev/null +++ b/web/src/assets/icons/88code.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/icons/aicodemirror.png b/web/src/assets/icons/aicodemirror.png new file mode 100644 index 00000000..dbbd7548 Binary files /dev/null and b/web/src/assets/icons/aicodemirror.png differ diff --git a/web/src/assets/icons/nvidia.svg b/web/src/assets/icons/nvidia.svg new file mode 100644 index 00000000..e427e2ce --- /dev/null +++ b/web/src/assets/icons/nvidia.svg @@ -0,0 +1 @@ +NVIDIA \ No newline at end of file diff --git a/web/src/components/routes/ClientTypeRoutesContent.tsx b/web/src/components/routes/ClientTypeRoutesContent.tsx index 6966017b..4758a3a7 100644 --- a/web/src/components/routes/ClientTypeRoutesContent.tsx +++ b/web/src/components/routes/ClientTypeRoutesContent.tsx @@ -48,11 +48,13 @@ import { AntigravityQuotasProvider } from '@/contexts/antigravity-quotas-context interface ClientTypeRoutesContentProps { clientType: ClientType; projectID: number; // 0 for global routes + searchQuery?: string; // Optional search query from parent } export function ClientTypeRoutesContent({ clientType, projectID, + searchQuery = '', }: ClientTypeRoutesContentProps) { const [activeId, setActiveId] = useState(null) const { data: providerStats = {} } = useProviderStats(clientType, projectID || undefined) @@ -100,7 +102,16 @@ export function ClientTypeRoutesContent({ }); // Only show providers that have routes - const filteredItems = allItems.filter((item) => item.route); + let filteredItems = allItems.filter((item) => item.route); + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + filteredItems = filteredItems.filter((item) => + item.provider.name.toLowerCase().includes(query) || + item.provider.type.toLowerCase().includes(query) + ); + } return filteredItems.sort((a, b) => { if (a.route && b.route) return a.route.position - b.route.position; @@ -110,15 +121,26 @@ export function ClientTypeRoutesContent({ if (!a.isNative && b.isNative) return 1; return a.provider.name.localeCompare(b.provider.name); }); - }, [providers, clientRoutes, clientType]); + }, [providers, clientRoutes, clientType, searchQuery]); // Get available providers (without routes yet) const availableProviders = useMemo((): Provider[] => { - return providers.filter((p) => { + let available = providers.filter((p) => { const hasRoute = clientRoutes.some((r) => Number(r.providerID) === Number(p.id)); return !hasRoute; }); - }, [providers, clientRoutes]); + + // Apply search filter + if (searchQuery.trim()) { + const query = searchQuery.toLowerCase(); + available = available.filter((p) => + p.name.toLowerCase().includes(query) || + p.type.toLowerCase().includes(query) + ); + } + + return available; + }, [providers, clientRoutes, searchQuery]); const activeItem = activeId ? items.find((item) => item.id === activeId) : null; diff --git a/web/src/components/ui/model-input.tsx b/web/src/components/ui/model-input.tsx index 36f33478..d1c581ba 100644 --- a/web/src/components/ui/model-input.tsx +++ b/web/src/components/ui/model-input.tsx @@ -48,6 +48,9 @@ const COMMON_MODELS = [ { id: '*gpt*', name: 'All GPT models', provider: 'OpenAI' }, { id: '*o1*', name: 'All o1 models', provider: 'OpenAI' }, { id: '*o3*', name: 'All o3 models', provider: 'OpenAI' }, + // NVIDIA/Meta wildcards + { id: '*llama*', name: 'All Llama models', provider: 'NVIDIA' }, + { id: 'meta/*', name: 'All Meta models', provider: 'NVIDIA' }, // OpenAI models { id: 'gpt-4o', name: 'GPT-4o', provider: 'OpenAI' }, { id: 'gpt-4o-mini', name: 'GPT-4o Mini', provider: 'OpenAI' }, @@ -58,6 +61,15 @@ const COMMON_MODELS = [ { id: 'o1-mini', name: 'o1 Mini', provider: 'OpenAI' }, { id: 'o1-pro', name: 'o1 Pro', provider: 'OpenAI' }, { id: 'o3-mini', name: 'o3 Mini', provider: 'OpenAI' }, + // NVIDIA models + { id: 'minimaxai/minimax-m2.1', name: 'MiniMax M2.1', provider: 'NVIDIA' }, + { id: 'z-ai/glm4.7', name: 'GLM 4.7', provider: 'NVIDIA' }, + { id: 'deepseek-ai/deepseek-rl', name: 'DeepSeek RL', provider: 'NVIDIA' }, + { id: 'qwen/qwen2.5-coder-32b-instruct', name: 'Qwen 2.5 Coder 32B', provider: 'NVIDIA' }, + { id: 'openai/gpt-oss-120b', name: 'GPT OSS 120B', provider: 'NVIDIA' }, + { id: 'google/gemma-3-27b-it', name: 'Gemma 3 27B', provider: 'NVIDIA' }, + { id: 'meta/llama-4-maverick-17b-128e-instruct', name: 'Llama 4 Maverick 17B', provider: 'NVIDIA' }, + { id: 'mistralai/devstral-2-123b-instruct-2512', name: 'Devstral 2 123B', provider: 'NVIDIA' }, // Antigravity supported target models (use these as mapping targets) { id: 'claude-opus-4-5-thinking', diff --git a/web/src/lib/transport/types.ts b/web/src/lib/transport/types.ts index 2b71488a..7aee4ea2 100644 --- a/web/src/lib/transport/types.ts +++ b/web/src/lib/transport/types.ts @@ -46,6 +46,7 @@ export interface Provider { updatedAt: string; type: string; name: string; + logo?: string; // Logo URL or data URI config: ProviderConfig | null; supportedClientTypes: ClientType[]; } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index ef17ab04..d9aa6c76 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -36,7 +36,9 @@ "days": "days", "hours": "hours", "global": "Global", - "initFailed": "Failed to Initialize" + "initFailed": "Failed to Initialize", + "search": "Search", + "searchProviders": "Search providers..." }, "nav": { "dashboard": "Dashboard", @@ -568,6 +570,10 @@ "freeduck": { "name": "Free Duck", "description": "Free site · Claude Code only" + }, + "nvidia": { + "name": "NVIDIA NIM", + "description": "NVIDIA NIM · OpenAI Compatible" } } } diff --git a/web/src/locales/zh.json b/web/src/locales/zh.json index eeb41827..91a8f2ba 100644 --- a/web/src/locales/zh.json +++ b/web/src/locales/zh.json @@ -36,7 +36,9 @@ "days": "天", "hours": "小时", "global": "全局", - "initFailed": "初始化失败" + "initFailed": "初始化失败", + "search": "搜索", + "searchProviders": "搜索 Provider..." }, "nav": { "dashboard": "仪表板", @@ -567,6 +569,10 @@ "freeduck": { "name": "Free Duck", "description": "免费站点 · 只有 Claude Code" + }, + "nvidia": { + "name": "NVIDIA NIM", + "description": "NVIDIA NIM · OpenAI 兼容" } } } diff --git a/web/src/pages/client-routes/index.tsx b/web/src/pages/client-routes/index.tsx index ad069198..6700f982 100644 --- a/web/src/pages/client-routes/index.tsx +++ b/web/src/pages/client-routes/index.tsx @@ -3,14 +3,20 @@ * 全局路由配置页面 - 显示当前 ClientType 的路由 */ +import { useState } from 'react'; import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Search } from 'lucide-react'; import { ClientIcon, getClientName } from '@/components/icons/client-icons'; import type { ClientType } from '@/lib/transport'; import { ClientTypeRoutesContent } from '@/components/routes/ClientTypeRoutesContent'; +import { Input } from '@/components/ui/input'; export function ClientRoutesPage() { + const { t } = useTranslation(); const { clientType } = useParams<{ clientType: string }>(); const activeClientType = (clientType as ClientType) || 'claude'; + const [searchQuery, setSearchQuery] = useState(''); return (
@@ -29,11 +35,27 @@ export function ClientRoutesPage() {

+
+ + setSearchQuery(e.target.value)} + className="pl-9 w-48" + /> +
{/* Content */}
- +
); diff --git a/web/src/pages/providers/components/custom-config-step.tsx b/web/src/pages/providers/components/custom-config-step.tsx index 9de020c6..b24c56fc 100644 --- a/web/src/pages/providers/components/custom-config-step.tsx +++ b/web/src/pages/providers/components/custom-config-step.tsx @@ -1,10 +1,11 @@ -import { Globe, ChevronLeft, Key, Check } from 'lucide-react'; +import { Globe, ChevronLeft, Key, Check, Plus, Trash2, ArrowRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useCreateProvider } from '@/hooks/queries'; +import { useCreateProvider, useCreateModelMapping } from '@/hooks/queries'; import type { ClientType, CreateProviderData } from '@/lib/transport'; import { ClientsConfigSection } from './clients-config-section'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { ModelInput } from '@/components/ui/model-input'; import { useProviderForm } from '../context/provider-form-context'; import { useProviderNavigation } from '../hooks/use-provider-navigation'; @@ -13,6 +14,7 @@ export function CustomConfigStep() { const { formData, updateFormData, updateClient, isValid, isSaving, setSaving, saveStatus, setSaveStatus } = useProviderForm(); const { goToSelectType, goToProviders } = useProviderNavigation(); const createProvider = useCreateProvider(); + const createModelMapping = useCreateModelMapping(); const handleSave = async () => { if (!isValid()) return; @@ -32,6 +34,7 @@ export function CustomConfigStep() { const data: CreateProviderData = { type: 'custom', name: formData.name, + logo: formData.logo, config: { custom: { baseURL: formData.baseURL, @@ -42,7 +45,20 @@ export function CustomConfigStep() { supportedClientTypes, }; - await createProvider.mutateAsync(data); + const provider = await createProvider.mutateAsync(data); + + // Create model mappings if template has any + if (formData.modelMappings && formData.modelMappings.length > 0) { + for (const mapping of formData.modelMappings) { + await createModelMapping.mutateAsync({ + scope: 'provider', + providerID: provider.id, + pattern: mapping.pattern, + target: mapping.target, + }); + } + } + setSaveStatus('success'); setTimeout(() => goToProviders(), 500); } catch (error) { @@ -153,6 +169,81 @@ export function CustomConfigStep() { + {/* Model Mapping Section */} +
+
+

+ {t('modelMappings.title')} +

+ +
+ + {formData.modelMappings && formData.modelMappings.length > 0 ? ( +
+ {formData.modelMappings.map((mapping, index) => ( +
+
+ + { + const newMappings = [...(formData.modelMappings || [])]; + newMappings[index] = { ...newMappings[index], pattern: e.target.value }; + updateFormData({ modelMappings: newMappings }); + }} + placeholder="*claude*, *sonnet*, *" + className="font-mono text-sm" + /> +
+ +
+ + { + const newMappings = [...(formData.modelMappings || [])]; + newMappings[index] = { ...newMappings[index], target: value }; + updateFormData({ modelMappings: newMappings }); + }} + placeholder={t('modelInput.selectOrEnter')} + /> +
+ +
+ ))} +
+ ) : ( +
+ {t('modelMappings.noMappings')} +
+ )} +
+ {saveStatus === 'error' && (
diff --git a/web/src/pages/providers/components/select-type-step.tsx b/web/src/pages/providers/components/select-type-step.tsx index e07129a1..c8575399 100644 --- a/web/src/pages/providers/components/select-type-step.tsx +++ b/web/src/pages/providers/components/select-type-step.tsx @@ -41,6 +41,8 @@ export function SelectTypeStep() { selectedTemplate: templateId, name: template.name, clients: updatedClients, + modelMappings: template.modelMappings, + logo: template.logoUrl, }); goToCustomConfig(); diff --git a/web/src/pages/providers/index.tsx b/web/src/pages/providers/index.tsx index b9328648..46aa5a82 100644 --- a/web/src/pages/providers/index.tsx +++ b/web/src/pages/providers/index.tsx @@ -1,5 +1,5 @@ -import { useMemo, useRef } from 'react'; -import { Plus, Layers, Download, Upload } from 'lucide-react'; +import { useMemo, useRef, useState } from 'react'; +import { Plus, Layers, Download, Upload, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useProviders, useAllProviderStats } from '@/hooks/queries'; @@ -9,9 +9,9 @@ import { getTransport } from '@/lib/transport'; import { ProviderRow } from './components/provider-row'; import { useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { PageHeader } from '@/components/layout/page-header'; import { PROVIDER_TYPE_CONFIGS, type ProviderTypeKey } from './types'; -import { useState } from 'react'; import { AntigravityQuotasProvider } from '@/contexts/antigravity-quotas-context'; export function ProvidersPage() { @@ -21,6 +21,7 @@ export function ProvidersPage() { const { data: providerStats = {} } = useAllProviderStats(); const { countsByProvider } = useStreamingRequests(); const [importStatus, setImportStatus] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const fileInputRef = useRef(null); const queryClient = useQueryClient(); @@ -32,7 +33,19 @@ export function ProvidersPage() { custom: [], }; - providers?.forEach((p) => { + // Filter providers by search query + const filteredProviders = providers?.filter((p) => { + if (!searchQuery.trim()) return true; + const query = searchQuery.toLowerCase(); + const config = PROVIDER_TYPE_CONFIGS[p.type as ProviderTypeKey]; + const displayInfo = config?.getDisplayInfo(p) || ''; + return ( + p.name.toLowerCase().includes(query) || + displayInfo.toLowerCase().includes(query) + ); + }); + + filteredProviders?.forEach((p) => { const type = p.type as ProviderTypeKey; if (groups[type]) { groups[type].push(p); @@ -43,7 +56,7 @@ export function ProvidersPage() { }); return groups; - }, [providers]); + }, [providers, searchQuery]); // Export providers as JSON file const handleExport = async () => { @@ -107,6 +120,18 @@ export function ProvidersPage() { count: providers?.length || 0, })} > +
+ + setSearchQuery(e.target.value)} + className="pl-9 w-48" + /> +
>; + modelMappings?: TemplateModelMapping[]; // 可选的模型映射 }; export const quickTemplates: QuickTemplate[] = [ @@ -95,6 +105,7 @@ export const quickTemplates: QuickTemplate[] = [ nameKey: 'addProvider.templates.88code.name', descriptionKey: 'addProvider.templates.88code.description', icon: 'grid', + logoUrl: logo88code, supportedClients: ['claude', 'codex', 'gemini'], clientBaseURLs: { claude: 'https://www.88code.ai/api', @@ -109,6 +120,7 @@ export const quickTemplates: QuickTemplate[] = [ nameKey: 'addProvider.templates.aicodemirror.name', descriptionKey: 'addProvider.templates.aicodemirror.description', icon: 'layers', + logoUrl: aicodemirrorLogo, supportedClients: ['claude', 'codex', 'gemini'], clientBaseURLs: { claude: 'https://api.aicodemirror.com/api/claudecode', @@ -144,6 +156,20 @@ export const quickTemplates: QuickTemplate[] = [ claude: 'https://free.duckcoding.com', }, }, + { + id: 'nvidia', + name: 'NVIDIA', + description: 'NVIDIA NIM · OpenAI 兼容', + nameKey: 'addProvider.templates.nvidia.name', + descriptionKey: 'addProvider.templates.nvidia.description', + icon: 'layers', + logoUrl: nvidiaLogo, + supportedClients: ['openai'], + clientBaseURLs: { + openai: 'https://integrate.api.nvidia.com', + }, + modelMappings: [{ pattern: '*', target: 'minimaxai/minimax-m2.1' }], + }, ]; // Client config @@ -169,6 +195,8 @@ export type ProviderFormData = { baseURL: string; apiKey: string; clients: ClientConfig[]; + modelMappings?: TemplateModelMapping[]; // 模型映射 + logo?: string; // Logo URL }; // Create step type