diff --git a/src/app/(main)/settings/provider/(detail)/[id]/index.tsx b/src/app/(main)/settings/provider/(detail)/[id]/index.tsx index 373d060bbe04..152fea8164d7 100644 --- a/src/app/(main)/settings/provider/(detail)/[id]/index.tsx +++ b/src/app/(main)/settings/provider/(detail)/[id]/index.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Divider } from 'antd'; import { memo } from 'react'; import { Flexbox } from 'react-layout-kit'; @@ -11,7 +10,6 @@ const ProviderDetail = memo((card) => { return ( - ); diff --git a/src/app/(main)/settings/provider/(list)/ProviderGrid/Card.tsx b/src/app/(main)/settings/provider/(list)/ProviderGrid/Card.tsx index 6f9d6aa3f3e6..a11e642fe971 100644 --- a/src/app/(main)/settings/provider/(list)/ProviderGrid/Card.tsx +++ b/src/app/(main)/settings/provider/(list)/ProviderGrid/Card.tsx @@ -1,13 +1,14 @@ import { ProviderCombine, ProviderIcon } from '@lobehub/icons'; import { Avatar } from '@lobehub/ui'; -import { Divider, Skeleton, Switch, Typography } from 'antd'; +import { Divider, Skeleton, Typography } from 'antd'; import { createStyles } from 'antd-style'; import Link from 'next/link'; -import { memo, useState } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { Flexbox } from 'react-layout-kit'; -import { aiProviderSelectors, useAiInfraStore } from '@/store/aiInfra'; +import InstantSwitch from '@/components/InstantSwitch'; +import { useAiInfraStore } from '@/store/aiInfra'; import { AiProviderListItem } from '@/types/aiProvider'; const { Paragraph } = Typography; @@ -69,9 +70,6 @@ const ProviderCard = memo( const { t } = useTranslation('providers'); const { cx, styles, theme } = useStyles(); const toggleProviderEnabled = useAiInfraStore((s) => s.toggleProviderEnabled); - const isProviderLoading = useAiInfraStore(aiProviderSelectors.isProviderLoading(id)); - - const [checked, setChecked] = useState(enabled); if (loading) return ( @@ -115,12 +113,10 @@ const ProviderCard = memo(
- { - setChecked(checked); - toggleProviderEnabled(id, checked); + { + await toggleProviderEnabled(id, checked); }} size={'small'} /> diff --git a/src/app/(main)/settings/provider/features/ModelList/ModelTitle.tsx b/src/app/(main)/settings/provider/features/ModelList/ModelTitle.tsx index ae5c16d03fc6..b034422a8640 100644 --- a/src/app/(main)/settings/provider/features/ModelList/ModelTitle.tsx +++ b/src/app/(main)/settings/provider/features/ModelList/ModelTitle.tsx @@ -1,5 +1,5 @@ import { ActionIcon, Icon } from '@lobehub/ui'; -import { Button, Space, Typography } from 'antd'; +import { Button, Skeleton, Space, Typography } from 'antd'; import { useTheme } from 'antd-style'; import { CircleX, LucideRefreshCcwDot, PlusIcon } from 'lucide-react'; import { memo, useState } from 'react'; @@ -16,10 +16,10 @@ interface ModelFetcherProps { const ModelTitle = memo(({ provider }) => { const theme = useTheme(); const { t } = useTranslation('setting'); - const [fetchRemoteModelList, clearObtainedModels] = useAiInfraStore((s) => [ - s.fetchRemoteModelList, - s.clearRemoteModels, - ]); + const [fetchRemoteModelList, clearObtainedModels, useFetchAiProviderModels] = useAiInfraStore( + (s) => [s.fetchRemoteModelList, s.clearRemoteModels, s.useFetchAiProviderModels], + ); + const { isLoading } = useFetchAiProviderModels(provider); const [totalModels, hasRemoteModels] = useAiInfraStore((s) => [ // s.modelSearchKeyword, @@ -41,54 +41,61 @@ const ModelTitle = memo(({ provider }) => { 模型列表 - -
- {t('llm.modelList.total', { count: totalModels })} - {hasRemoteModels && ( - { - setClearRemoteModelsLoading(true); - await clearObtainedModels(provider); - setClearRemoteModelsLoading(false); - }} - size={'small'} - title={t('llm.fetcher.clear')} - /> - )} -
-
-
- - - {/*{totalModels >= 30 && (*/} - {/* {*/} - {/* useAiInfraStore.setState({ modelSearchKeyword: e.target.value });*/} - {/* }}*/} - {/* placeholder={'搜索模型...'}*/} - {/* prefix={}*/} - {/* size={'small'}*/} - {/* value={searchKeyword}*/} - {/* />*/} - {/*)}*/} - - - - + {isLoading ? ( + + ) : ( + +
+ {t('llm.modelList.total', { count: totalModels })} + {hasRemoteModels && ( + { + setClearRemoteModelsLoading(true); + await clearObtainedModels(provider); + setClearRemoteModelsLoading(false); + }} + size={'small'} + title={t('llm.fetcher.clear')} + /> + )} +
+
+ )}
+ {isLoading ? ( + + ) : ( + + {/*{totalModels >= 30 && (*/} + {/* {*/} + {/* useAiInfraStore.setState({ modelSearchKeyword: e.target.value });*/} + {/* }}*/} + {/* placeholder={'搜索模型...'}*/} + {/* prefix={}*/} + {/* size={'small'}*/} + {/* value={searchKeyword}*/} + {/* />*/} + {/*)}*/} + + + + + + )}{' '} ); diff --git a/src/app/(main)/settings/provider/features/ProviderConfig/index.tsx b/src/app/(main)/settings/provider/features/ProviderConfig/index.tsx index e633f8f387b6..ecb40951270a 100644 --- a/src/app/(main)/settings/provider/features/ProviderConfig/index.tsx +++ b/src/app/(main)/settings/provider/features/ProviderConfig/index.tsx @@ -12,6 +12,7 @@ import { Trans, useTranslation } from 'react-i18next'; import { Center, Flexbox } from 'react-layout-kit'; import urlJoin from 'url-join'; +import InstantSwitch from '@/components/InstantSwitch'; import { FORM_STYLE } from '@/const/layoutTokens'; import { AES_GCM_URL, BASE_PROVIDER_DOC_URL } from '@/const/url'; import { isServerMode } from '@/const/version'; @@ -258,11 +259,11 @@ const ProviderConfig = memo( isLoading ? ( ) : ( - { - toggleProviderEnabled(id as any, enabled); + { + await toggleProviderEnabled(id as any, enabled); }} - value={enabled} /> ) ) : undefined} diff --git a/src/components/InstantSwitch/index.tsx b/src/components/InstantSwitch/index.tsx new file mode 100644 index 000000000000..3f3951dc0fce --- /dev/null +++ b/src/components/InstantSwitch/index.tsx @@ -0,0 +1,28 @@ +import { Switch, SwitchProps } from 'antd'; +import { memo, useState } from 'react'; + +interface InstantSwitchProps { + enabled: boolean; + onChange: (enabled: boolean) => Promise; + size?: SwitchProps['size']; +} + +const InstantSwitch = memo(({ enabled, onChange, size }) => { + const [value, setValue] = useState(enabled); + const [loading, setLoading] = useState(false); + return ( + { + setLoading(true); + setValue(enabled); + await onChange(enabled); + setLoading(false); + }} + size={size} + value={value} + /> + ); +}); + +export default InstantSwitch; diff --git a/src/config/aiModels/ollama.ts b/src/config/aiModels/ollama.ts index 7c2fe763d9b4..9de4455f5fd0 100644 --- a/src/config/aiModels/ollama.ts +++ b/src/config/aiModels/ollama.ts @@ -106,17 +106,6 @@ const ollamaChatModels: AIChatModelCard[] = [ id: 'qwen2.5:72b', type: 'chat', }, - { - abilities: { - functionCall: true, - }, - contextWindowTokens: 128_000, - description: 'Qwen2.5 是阿里巴巴的新一代大规模语言模型,以优异的性能支持多元化的应用需求。', - displayName: 'Qwen2.5 7B', - enabled: true, - id: 'qwen2.5', - type: 'chat', - }, { abilities: { functionCall: true, diff --git a/src/config/aiModels/openai.ts b/src/config/aiModels/openai.ts index 472a24caf298..8a5f59f44ca5 100644 --- a/src/config/aiModels/openai.ts +++ b/src/config/aiModels/openai.ts @@ -1,6 +1,13 @@ -import { AIChatModelCard } from '@/types/aiModel'; +import { + AIChatModelCard, + AIEmbeddingModelCard, + AIRealtimeModelCard, + AISTTModelCard, + AITTSModelCard, + AIText2ImageModelCard, +} from '@/types/aiModel'; -const openaiChatModels: AIChatModelCard[] = [ +export const openaiChatModels: AIChatModelCard[] = [ { contextWindowTokens: 128_000, description: @@ -118,6 +125,19 @@ const openaiChatModels: AIChatModelCard[] = [ releasedAt: '2024-05-13', type: 'chat', }, + { + contextWindowTokens: 128_000, + description: 'GPT-4o Audio 模型,支持音频输入输出', + displayName: 'GPT-4o Audio', + id: 'gpt-4o-audio-preview', + maxOutput: 16_384, + pricing: { + input: 2.5, + output: 10, + }, + releasedAt: '2024-10-01', + type: 'chat', + }, { abilities: { vision: true, @@ -132,6 +152,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 5, output: 15, }, + releasedAt: '2024-08-14', type: 'chat', }, { @@ -164,6 +185,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 10, output: 30, }, + releasedAt: '2024-04-09', type: 'chat', }, { @@ -194,6 +216,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 10, output: 30, }, + releasedAt: '2024-01-25', type: 'chat', }, { @@ -209,6 +232,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 10, output: 30, }, + releasedAt: '2023-11-06', type: 'chat', }, { @@ -239,6 +263,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 30, output: 60, }, + releasedAt: '2023-06-13', type: 'chat', }, { @@ -246,10 +271,13 @@ const openaiChatModels: AIChatModelCard[] = [ functionCall: true, }, contextWindowTokens: 32_768, + description: 'GPT-4 提供了一个更大的上下文窗口,能够处理更长的文本输入,适用于需要广泛信息整合和数据分析的场景。', displayName: 'GPT-4 32K', id: 'gpt-4-32k', + // Will be discontinued on June 6, 2025 + legacy: true, pricing: { input: 60, output: 120, @@ -269,6 +297,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 60, output: 120, }, + releasedAt: '2023-06-13', type: 'chat', }, { @@ -299,6 +328,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 0.5, output: 1.5, }, + releasedAt: '2024-01-25', type: 'chat', }, { @@ -314,6 +344,7 @@ const openaiChatModels: AIChatModelCard[] = [ input: 1, output: 2, }, + releasedAt: '2023-11-06', type: 'chat', }, { @@ -330,6 +361,175 @@ const openaiChatModels: AIChatModelCard[] = [ }, ]; -export const allModels = [...openaiChatModels]; +export const openaiEmbeddingModels: AIEmbeddingModelCard[] = [ + { + contextWindowTokens: 8192, + description: '最强大的向量化模型,适用于英文和非英文任务', + displayName: 'Text Embedding 3 Large', + id: 'text-embedding-3-large', + maxDimension: 3072, + pricing: { + currency: 'USD', + input: 0.13, + }, + releasedAt: '2024-01-25', + type: 'embedding', + }, + { + contextWindowTokens: 8192, + description: '高效且经济的新一代 Embedding 模型,适用于知识检索、RAG 应用等场景', + displayName: 'Text Embedding 3 Small', + id: 'text-embedding-3-small', + maxDimension: 1536, + pricing: { + currency: 'USD', + input: 0.02, + }, + releasedAt: '2024-01-25', + type: 'embedding', + }, +]; + +// 语音合成模型 +export const openaiTTSModels: AITTSModelCard[] = [ + { + description: '最新的文本转语音模型,针对实时场景优化速度', + displayName: 'TTS-1', + id: 'tts-1', + pricing: { + input: 15, + }, + type: 'tts', + }, + { + description: '最新的文本转语音模型,针对质量进行优化', + displayName: 'TTS-1 HD', + id: 'tts-1-hd', + pricing: { + input: 30, + }, + type: 'tts', + }, +]; + +// 语音识别模型 +export const openaiSTTModels: AISTTModelCard[] = [ + { + description: '通用语音识别模型,支持多语言语音识别、语音翻译和语言识别', + displayName: 'Whisper', + id: 'whisper-1', + pricing: { + input: 0.006, // per minute + }, + type: 'stt', + }, +]; + +// 图像生成模型 +export const openaiImageModels: AIText2ImageModelCard[] = [ + { + description: + '最新的 DALL·E 模型,于2023年11月发布。支持更真实、准确的图像生成,具有更强的细节表现力', + displayName: 'DALL·E 3', + id: 'dall-e-3', + pricing: { + hd: 0.08, + standard: 0.04, + }, + resolutions: ['1024x1024', '1024x1792', '1792x1024'], + type: 'image', + }, + { + description: '第二代 DALL·E 模型,支持更真实、准确的图像生成,分辨率是第一代的4倍', + displayName: 'DALL·E 2', + id: 'dall-e-2', + pricing: { + input: 0.02, // $0.020 per image (1024×1024) + }, + resolutions: ['256x256', '512x512', '1024x1024'], + type: 'image', + }, +]; + +// GPT-4o 和 GPT-4o-mini 实时模型 +export const openaiRealtimeModels: AIRealtimeModelCard[] = [ + { + contextWindowTokens: 128_000, + description: 'GPT-4o 实时版本,支持音频和文本实时输入输出', + displayName: 'GPT-4o Realtime', + id: 'gpt-4o-realtime-preview', + maxOutput: 4096, + pricing: { + audioInput: 100, + audioOutput: 200, + cachedAudioInput: 20, + cachedInput: 2.5, + input: 5, + output: 20, + }, + releasedAt: '2024-10-01', + type: 'realtime', + }, + { + contextWindowTokens: 128_000, + description: 'GPT-4o 实时版本,支持音频和文本实时输入输出', + displayName: 'GPT-4o Realtime 10-01', + id: 'gpt-4o-realtime-preview-2024-10-01', + maxOutput: 4096, + pricing: { + audioInput: 100, + audioOutput: 200, + cachedAudioInput: 20, + cachedInput: 2.5, + input: 5, + output: 20, + }, + releasedAt: '2024-10-01', + type: 'realtime', + }, + { + contextWindowTokens: 128_000, + description: 'GPT-4o 实时版本,支持音频和文本实时输入输出', + displayName: 'GPT-4o Realtime 12-17', + id: 'gpt-4o-realtime-preview-2024-12-17', + maxOutput: 4096, + pricing: { + audioInput: 40, + audioOutput: 80, + cachedAudioInput: 2.5, + cachedInput: 2.5, + input: 5, + output: 20, + }, + releasedAt: '2024-12-17', + type: 'realtime', + }, + { + contextWindowTokens: 128_000, + description: 'GPT-4o-mini 实时版本,支持音频和文本实时输入输出', + displayName: 'GPT-4o Mini Realtime', + id: 'gpt-4o-mini-realtime-preview', + maxOutput: 4096, + pricing: { + audioInput: 10, + audioOutput: 20, + cachedAudioInput: 0.3, + cachedInput: 0.3, + input: 0.6, + output: 2.4, + }, + releasedAt: '2024-12-17', + type: 'realtime', + }, +]; + +export const allModels = [ + ...openaiChatModels, + ...openaiEmbeddingModels, + ...openaiTTSModels, + ...openaiSTTModels, + ...openaiImageModels, + ...openaiRealtimeModels, +]; export default allModels; diff --git a/src/database/server/models/aiProvider.ts b/src/database/server/models/aiProvider.ts index 7f29f4d4d67a..5bceac9819e0 100644 --- a/src/database/server/models/aiProvider.ts +++ b/src/database/server/models/aiProvider.ts @@ -72,6 +72,7 @@ export class AiProviderModel { id: aiProviders.id, logo: aiProviders.logo, name: aiProviders.name, + sort: aiProviders.sort, source: aiProviders.source, }) .from(aiProviders) diff --git a/src/server/routers/lambda/aiProvider.ts b/src/server/routers/lambda/aiProvider.ts index 2b04bbd215e0..55556a2d48c9 100644 --- a/src/server/routers/lambda/aiProvider.ts +++ b/src/server/routers/lambda/aiProvider.ts @@ -1,4 +1,3 @@ -import { uniqBy } from 'lodash-es'; import { z } from 'zod'; import { DEFAULT_MODEL_PROVIDER_LIST } from '@/config/modelProviders'; @@ -15,7 +14,7 @@ import { UpdateAiProviderConfigSchema, } from '@/types/aiProvider'; import { ProviderConfig } from '@/types/user/settings'; -import { merge } from '@/utils/merge'; +import { merge, mergeArrayById } from '@/utils/merge'; const aiProviderProcedure = authedProcedure.use(async (opts) => { const { ctx } = opts; @@ -60,6 +59,8 @@ export const aiProviderRouter = router({ >; const userProviders = await ctx.aiProviderModel.getAiProviderList(); + // 1. 先创建一个基于 DEFAULT_MODEL_PROVIDER_LIST id 顺序的映射 + const orderMap = new Map(DEFAULT_MODEL_PROVIDER_LIST.map((item, index) => [item.id, index])); const builtinProviders = DEFAULT_MODEL_PROVIDER_LIST.map((item) => ({ description: item.description, @@ -71,7 +72,16 @@ export const aiProviderRouter = router({ source: 'builtin', })) as AiProviderListItem[]; - return uniqBy([...builtinProviders, ...userProviders], 'id'); + const mergedProviders = mergeArrayById(builtinProviders, userProviders); + + // 3. 根据 orderMap 排序 + const sortedProviders = mergedProviders.sort((a, b) => { + const orderA = orderMap.get(a.id) ?? Number.MAX_SAFE_INTEGER; + const orderB = orderMap.get(b.id) ?? Number.MAX_SAFE_INTEGER; + return orderA - orderB; + }); + + return sortedProviders; }), removeAiProvider: aiProviderProcedure diff --git a/src/store/aiInfra/slices/aiProvider/selectors.ts b/src/store/aiInfra/slices/aiProvider/selectors.ts index 884e5461502b..bca720b9d518 100644 --- a/src/store/aiInfra/slices/aiProvider/selectors.ts +++ b/src/store/aiInfra/slices/aiProvider/selectors.ts @@ -4,7 +4,7 @@ import { GlobalLLMProviderKey } from '@/types/user/settings'; // List const enabledAiProviderList = (s: AIProviderStoreState) => - s.aiProviderList.filter((item) => item.enabled); + s.aiProviderList.filter((item) => item.enabled).sort((a, b) => a.sort! - b.sort!); const disabledAiProviderList = (s: AIProviderStoreState) => s.aiProviderList.filter((item) => !item.enabled); diff --git a/src/types/aiProvider.ts b/src/types/aiProvider.ts index 5d621518ef99..5a63b71dae54 100644 --- a/src/types/aiProvider.ts +++ b/src/types/aiProvider.ts @@ -26,6 +26,7 @@ export interface AiProviderListItem { id: string; logo?: string; name?: string; + sort?: number; source: 'builtin' | 'custom'; } diff --git a/src/types/llm.ts b/src/types/llm.ts index d31637f1a286..15471746453d 100644 --- a/src/types/llm.ts +++ b/src/types/llm.ts @@ -79,7 +79,6 @@ export interface ModelProviderCard { * whether provider is enabled by default */ enabled?: boolean; - enabledChatModels: string[]; id: string; modelList?: { azureDeployName?: boolean;