| Upphovsman | SHA1 | Meddelande | Datum |
|---|---|---|---|
|
|
bf779f3068 | 'upgrade' | 1 vecka sedan |
|
|
1b08d03a2f | update icons | 1 vecka sedan |
|
|
e1f10fec95 | add localiazed | 1 vecka sedan |
| @@ -33,7 +33,8 @@ func GetAllEnableAbilityWithChannels() ([]AbilityWithChannel, error) { | |||
| err := DB.Table("abilities"). | |||
| Select("abilities.*, channels.type as channel_type"). | |||
| Joins("left join channels on abilities.channel_id = channels.id"). | |||
| Where("abilities.enabled = ?", true). | |||
| Joins("left join models on abilities.model = models.model_name"). | |||
| Where("abilities.enabled = ? and models.model_name is NOT null", true). | |||
| Scan(&abilities).Error | |||
| return abilities, err | |||
| } | |||
| @@ -15,6 +15,17 @@ const ( | |||
| NameRuleSuffix | |||
| ) | |||
| const ( | |||
| ModelTypeChat = 1 + iota | |||
| ModelTypeImage | |||
| ModelTypeAudio | |||
| ModelTypeVideo | |||
| ModelTypeEmbedding | |||
| ModelTypeRerank | |||
| ModelTypeVision | |||
| ModelTypeOther | |||
| ) | |||
| type BoundChannel struct { | |||
| Name string `json:"name"` | |||
| Type int `json:"type"` | |||
| @@ -26,6 +37,7 @@ type Model struct { | |||
| Description string `json:"description,omitempty" gorm:"type:text"` | |||
| Icon string `json:"icon,omitempty" gorm:"type:varchar(128)"` | |||
| Tags string `json:"tags,omitempty" gorm:"type:varchar(255)"` | |||
| Type int `json:"type" gorm:"default:0"` | |||
| VendorID int `json:"vendor_id,omitempty" gorm:"index"` | |||
| Endpoints string `json:"endpoints,omitempty" gorm:"type:text"` | |||
| Status int `json:"status" gorm:"default:1"` | |||
| @@ -28,6 +28,7 @@ type Pricing struct { | |||
| EnableGroup []string `json:"enable_groups"` | |||
| SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | |||
| PricingVersion string `json:"pricing_version,omitempty"` | |||
| Type int `json:"type"` | |||
| } | |||
| type PricingVendor struct { | |||
| @@ -279,13 +280,14 @@ func updatePricing() { | |||
| // 补充模型元数据(描述、标签、供应商、状态) | |||
| if meta, ok := metaMap[model]; ok { | |||
| // 若模型被禁用(status!=1),则直接跳过,不返回给前端 | |||
| if meta.Status != 1 { | |||
| if meta.Status != 1 || meta.Type == 0 { | |||
| continue | |||
| } | |||
| pricing.Description = meta.Description | |||
| pricing.Icon = meta.Icon | |||
| pricing.Tags = meta.Tags | |||
| pricing.VendorID = meta.VendorID | |||
| pricing.Type = meta.Type | |||
| } | |||
| modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) | |||
| if findPrice { | |||
| @@ -6,13 +6,15 @@ | |||
| "dependencies": { | |||
| "@douyinfe/semi-icons": "^2.63.1", | |||
| "@douyinfe/semi-ui": "^2.69.1", | |||
| "@lobehub/icons": "^2.0.0", | |||
| "@lobehub/icons": "^5.0.1", | |||
| "@lobehub/ui": "^4.38.0", | |||
| "@visactor/react-vchart": "~1.8.8", | |||
| "@visactor/vchart": "~1.8.8", | |||
| "@visactor/vchart-semi-theme": "~1.8.8", | |||
| "axios": "1.13.5", | |||
| "clsx": "^2.1.1", | |||
| "dayjs": "^1.11.11", | |||
| "es-toolkit": "^1.21.0", | |||
| "history": "^5.3.0", | |||
| "i18next": "^23.16.8", | |||
| "i18next-browser-languagedetector": "^7.2.0", | |||
| @@ -0,0 +1,140 @@ | |||
| /* | |||
| Copyright (C) 2025 QuantumNous | |||
| This program is free software: you can redistribute it and/or modify | |||
| it under the terms of the GNU Affero General Public License as | |||
| published by the Free Software Foundation, either version 3 of the | |||
| License, or (at your option) any later version. | |||
| This program is distributed in the hope that it will be useful, | |||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
| GNU Affero General Public License for more details. | |||
| You should have received a copy of the GNU Affero General Public License | |||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React, { useRef } from 'react'; | |||
| import { Button, Tag, Skeleton } from '@douyinfe/semi-ui'; | |||
| import { IconChevronLeft, IconChevronRight } from '@douyinfe/semi-icons'; | |||
| import { useMinimumLoadingTime } from '../../../hooks/common/useMinimumLoadingTime'; | |||
| /** | |||
| * 横向筛选项行,通过左右箭头翻页 | |||
| * @param {string} title 标题 | |||
| * @param {Array<{value:any,label:string,icon?:React.ReactNode,tagCount?:number}>} items 选项列表 | |||
| * @param {*} activeValue 当前选中值 | |||
| * @param {(value:any)=>void} onChange 选择回调 | |||
| * @param {boolean} loading 加载中 | |||
| * @param {Function} t i18n | |||
| */ | |||
| const HorizontalFilterRow = ({ | |||
| title, | |||
| items = [], | |||
| activeValue, | |||
| onChange, | |||
| loading = false, | |||
| t = (v) => v, | |||
| }) => { | |||
| const scrollRef = useRef(null); | |||
| const showSkeleton = useMinimumLoadingTime(loading); | |||
| const handleScroll = (dir) => { | |||
| const el = scrollRef.current; | |||
| if (!el) return; | |||
| const step = el.clientWidth * 0.8; | |||
| el.scrollBy({ | |||
| left: dir === 'left' ? -step : step, | |||
| behavior: 'smooth', | |||
| }); | |||
| }; | |||
| if (showSkeleton) { | |||
| return ( | |||
| <div className='mb-6'> | |||
| <Skeleton.Title active style={{ width: 60, marginBottom: 8 }} /> | |||
| <div className='flex gap-2'> | |||
| {[1, 2, 3, 4, 5].map((i) => ( | |||
| <Skeleton.Button | |||
| key={i} | |||
| active | |||
| style={{ width: 80, height: 32 }} | |||
| /> | |||
| ))} | |||
| </div> | |||
| </div> | |||
| ); | |||
| } | |||
| return ( | |||
| <div className='mb-6 last:mb-0'> | |||
| <div className='text-sm font-medium text-semi-color-text-1 mb-3'> | |||
| {title} | |||
| </div> | |||
| <div className='flex items-center gap-2'> | |||
| <Button | |||
| type='tertiary' | |||
| theme='borderless' | |||
| icon={<IconChevronLeft />} | |||
| onClick={() => handleScroll('left')} | |||
| className='flex-shrink-0' | |||
| aria-label={t('向左')} | |||
| /> | |||
| <div | |||
| ref={scrollRef} | |||
| className='flex-1 min-w-0 flex gap-2 overflow-x-auto overflow-y-hidden scroll-smooth scrollbar-hide' | |||
| style={{ | |||
| scrollbarWidth: 'none', | |||
| msOverflowStyle: 'none', | |||
| WebkitOverflowScrolling: 'touch', | |||
| }} | |||
| > | |||
| {items.map((item) => { | |||
| const isDisabled = | |||
| item.disabled || | |||
| (typeof item.tagCount === 'number' && item.tagCount === 0); | |||
| const isActive = activeValue === item.value; | |||
| return ( | |||
| <Button | |||
| key={item.value} | |||
| onClick={() => !isDisabled && onChange(item.value)} | |||
| theme={isActive ? 'solid' : 'borderless'} | |||
| type={isActive ? 'primary' : 'tertiary'} | |||
| disabled={isDisabled} | |||
| className='flex-shrink-0 !rounded-full' | |||
| > | |||
| <span className='flex items-center gap-2'> | |||
| {item.icon} | |||
| <span>{item.label}</span> | |||
| {item.tagCount !== undefined && ( | |||
| <Tag | |||
| color={isActive ? 'white' : 'grey'} | |||
| shape='circle' | |||
| size='small' | |||
| > | |||
| {item.tagCount} | |||
| </Tag> | |||
| )} | |||
| </span> | |||
| </Button> | |||
| ); | |||
| })} | |||
| </div> | |||
| <Button | |||
| type='tertiary' | |||
| theme='borderless' | |||
| icon={<IconChevronRight />} | |||
| onClick={() => handleScroll('right')} | |||
| className='flex-shrink-0' | |||
| aria-label={t('向右')} | |||
| /> | |||
| </div> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default HorizontalFilterRow; | |||
| @@ -26,6 +26,7 @@ import ModelHeader from './components/ModelHeader'; | |||
| import ModelBasicInfo from './components/ModelBasicInfo'; | |||
| import ModelEndpoints from './components/ModelEndpoints'; | |||
| import ModelPricingTable from './components/ModelPricingTable'; | |||
| import ModelCodeSnippet from './components/ModelCodeSnippet'; | |||
| const { Text } = Typography; | |||
| @@ -99,6 +100,7 @@ const ModelDetailSideSheet = ({ | |||
| autoGroups={autoGroups} | |||
| t={t} | |||
| /> | |||
| <ModelCodeSnippet modelData={modelData} t={t} /> | |||
| </> | |||
| )} | |||
| </div> | |||
| @@ -0,0 +1,232 @@ | |||
| /* | |||
| Copyright (C) 2025 QuantumNous | |||
| This program is free software: you can redistribute it and/or modify | |||
| it under the terms of the GNU Affero General Public License as | |||
| published by the Free Software Foundation, either version 3 of the | |||
| License, or (at your option) any later version. | |||
| This program is distributed in the hope that it will be useful, | |||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
| GNU Affero General Public License for more details. | |||
| You should have received a copy of the GNU Affero General Public License | |||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React, { useState, useMemo, useContext } from 'react'; | |||
| import { | |||
| Card, | |||
| Avatar, | |||
| Typography, | |||
| RadioGroup, | |||
| Radio, | |||
| TextArea, | |||
| Button, | |||
| } from '@douyinfe/semi-ui'; | |||
| import { IconCode } from '@douyinfe/semi-icons'; | |||
| import { copy, showSuccess, showInfo } from '../../../../../helpers'; | |||
| import { StatusContext } from '../../../../../context/Status'; | |||
| const { Text } = Typography; | |||
| const LANG_OPTIONS = [ | |||
| { value: 'python', label: 'Python' }, | |||
| { value: 'java', label: 'Java' }, | |||
| { value: 'go', label: 'Go' }, | |||
| { value: 'shell', label: 'Shell' }, | |||
| ]; | |||
| // 四种语言的调用示例代码模板,使用 __MODEL_NAME__ 与 __BASE_URL__ 作为占位符 | |||
| const getDefaultCodeByLang = () => ({ | |||
| python: ` | |||
| from openai import OpenAI | |||
| client = OpenAI( | |||
| api_key="<Your API Key>", | |||
| base_url="__BASE_URL__" | |||
| ) | |||
| response = client.chat.completions.create( | |||
| model="__MODEL_NAME__", | |||
| messages=[ | |||
| {"role": "system", "content": "You are a helpful assistant."}, | |||
| {"role": "user", "content": "Hello, how are you?"} | |||
| ], | |||
| max_tokens=128000, | |||
| temperature=0.7 | |||
| ) | |||
| print(response.choices[0].message.content) | |||
| `, | |||
| java: ` | |||
| import com.openai.OpenAI; | |||
| import com.openai.models.*; | |||
| OpenAI client = new OpenAI("<Your API Key>", "__BASE_URL__"); | |||
| ChatCompletionRequest request = ChatCompletionRequest.builder() | |||
| .model("__MODEL_NAME__") | |||
| .messages(Arrays.asList( | |||
| new ChatMessage("system", "You are a helpful assistant."), | |||
| new ChatMessage("user", "Hello, how are you?") | |||
| )) | |||
| .maxTokens(1000) | |||
| .temperature(0.7) | |||
| .build(); | |||
| ChatCompletion response = client.chatCompletions().create(request); | |||
| System.out.println(response.getChoices().get(0).getMessage().getContent()); | |||
| `, | |||
| go: ` | |||
| package main | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "github.com/openai/openai-go" | |||
| ) | |||
| func main() { | |||
| client := openai.NewClient("<Your API Key>", "__BASE_URL__") | |||
| messages := []openai.ChatMessage{ | |||
| {Role: "system", Content: "You are a helpful assistant."}, | |||
| {Role: "user", Content: "Hello, how are you?"}, | |||
| } | |||
| response, err := client.ChatCompletions.Create(context.Background(), openai.ChatCompletionRequest{ | |||
| Model: "__MODEL_NAME__", | |||
| Messages: messages, | |||
| MaxTokens: 128000, | |||
| Temperature: 0.7, | |||
| }) | |||
| if err != nil { | |||
| panic(err) | |||
| } | |||
| fmt.Println(response.Choices[0].Message.Content) | |||
| } | |||
| `, | |||
| shell: ` | |||
| #!/bin/bash | |||
| API_KEY="<Your API Key>" | |||
| MODEL_ID="__MODEL_NAME__" | |||
| BASE_URL="__BASE_URL__" | |||
| curl -X POST "$BASE_URL/v1/chat/completions" \ | |||
| -H "Content-Type: application/json" \ | |||
| -H "Authorization: Bearer $API_KEY" \ | |||
| -d '{ | |||
| "model": "'$MODEL_ID'", | |||
| "messages": [ | |||
| { | |||
| "role": "system", | |||
| "content": "You are a helpful assistant." | |||
| }, | |||
| { | |||
| "role": "user", | |||
| "content": "Hello, how are you?" | |||
| } | |||
| ], | |||
| "max_tokens": 128000, | |||
| "temperature": 0.7 | |||
| }' | |||
| `, | |||
| }); | |||
| const PLACEHOLDER_MODEL = '__MODEL_NAME__'; | |||
| const PLACEHOLDER_BASE_URL = '__BASE_URL__'; | |||
| const ModelCodeSnippet = ({ modelData, t }) => { | |||
| const [statusState] = useContext(StatusContext); | |||
| const serverAddress = | |||
| statusState?.status?.server_address || (typeof window !== 'undefined' ? window.location.origin : ''); | |||
| const [lang, setLang] = useState('python'); | |||
| const codeByLang = useMemo(() => { | |||
| const templates = getDefaultCodeByLang(); | |||
| const modelName = modelData?.model_name || ''; | |||
| const baseUrl = serverAddress || ''; | |||
| return Object.fromEntries( | |||
| Object.entries(templates).map(([key, code]) => { | |||
| let result = code.replaceAll(PLACEHOLDER_BASE_URL, baseUrl); | |||
| result = result.replaceAll(PLACEHOLDER_MODEL, modelName); | |||
| return [key, result]; | |||
| }), | |||
| ); | |||
| }, [modelData?.model_name, serverAddress]); | |||
| const currentCode = codeByLang[lang] || ''; | |||
| const handleCopy = async () => { | |||
| if (!currentCode || currentCode.trim() === '') { | |||
| showInfo(t('当前语言暂无示例代码')); | |||
| return; | |||
| } | |||
| const ok = await copy(currentCode); | |||
| if (ok) { | |||
| showSuccess(t('已复制到剪切板')); | |||
| } | |||
| }; | |||
| return ( | |||
| <Card className='!rounded-2xl shadow-sm border-0 mb-6'> | |||
| <div className='flex items-center mb-4'> | |||
| <Avatar size='small' color='green' className='mr-2 shadow-md'> | |||
| <IconCode size={16} /> | |||
| </Avatar> | |||
| <div> | |||
| <Text className='text-lg font-medium'>{t('调用示例')}</Text> | |||
| <div className='text-xs text-gray-600'> | |||
| {t('使用 OpenAI 兼容接口调用该模型的示例代码')} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div className='mb-3'> | |||
| <Text className='text-sm text-semi-color-text-1 mb-2 block'> | |||
| {t('选择语言')} | |||
| </Text> | |||
| <RadioGroup | |||
| type='button' | |||
| value={lang} | |||
| onChange={(e) => setLang(e.target.value)} | |||
| direction='horizontal' | |||
| > | |||
| {LANG_OPTIONS.map((opt) => ( | |||
| <Radio key={opt.value} value={opt.value}> | |||
| {opt.label} | |||
| </Radio> | |||
| ))} | |||
| </RadioGroup> | |||
| </div> | |||
| <div className='mb-3'> | |||
| <TextArea | |||
| value={currentCode} | |||
| readOnly | |||
| placeholder={t('暂无该语言示例代码')} | |||
| autosize={{ minRows: 8, maxRows: 16 }} | |||
| className='font-mono text-sm' | |||
| style={{ backgroundColor: 'var(--semi-color-fill-0)' }} | |||
| /> | |||
| </div> | |||
| <Button | |||
| theme='solid' | |||
| type='primary' | |||
| onClick={handleCopy} | |||
| disabled={!currentCode || currentCode.trim() === ''} | |||
| > | |||
| {t('一键复制代码')} | |||
| </Button> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default ModelCodeSnippet; | |||
| @@ -74,13 +74,14 @@ const PricingCardView = ({ | |||
| selectedRowKeys = [], | |||
| setSelectedRowKeys, | |||
| openModelDetail, | |||
| gridColsClass = 'grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3', | |||
| showPagination = true, | |||
| }) => { | |||
| const showSkeleton = useMinimumLoadingTime(loading); | |||
| const startIndex = (currentPage - 1) * pageSize; | |||
| const paginatedModels = filteredModels.slice( | |||
| startIndex, | |||
| startIndex + pageSize, | |||
| ); | |||
| const paginatedModels = showPagination | |||
| ? filteredModels.slice(startIndex, startIndex + pageSize) | |||
| : filteredModels; | |||
| const getModelKey = (model) => model.key ?? model.model_name ?? model.id; | |||
| const isMobile = useIsMobile(); | |||
| @@ -234,7 +235,7 @@ const PricingCardView = ({ | |||
| return ( | |||
| <div className='px-2 pt-2'> | |||
| <div className='grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4'> | |||
| <div className={`grid ${gridColsClass} gap-4`}> | |||
| {paginatedModels.map((model, index) => { | |||
| const modelKey = getModelKey(model); | |||
| const isSelected = selectedRowKeys.includes(modelKey); | |||
| @@ -357,7 +358,7 @@ const PricingCardView = ({ | |||
| </div> | |||
| {/* 分页 */} | |||
| {filteredModels.length > 0 && ( | |||
| {showPagination && filteredModels.length > 0 && ( | |||
| <div className='flex justify-center mt-6 py-4 border-t pricing-pagination-divider'> | |||
| <Pagination | |||
| currentPage={currentPage} | |||
| @@ -39,6 +39,7 @@ export const useModelPricingData = () => { | |||
| const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string | |||
| const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string | |||
| const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string | |||
| const [filterType, setFilterType] = useState('all'); // 模型类型筛选: 'all' | number (model_meta.ModelType) | |||
| const [pageSize, setPageSize] = useState(20); | |||
| const [currentPage, setCurrentPage] = useState(1); | |||
| const [currency, setCurrency] = useState('USD'); | |||
| @@ -135,6 +136,12 @@ export const useModelPricingData = () => { | |||
| }); | |||
| } | |||
| // 模型类型筛选(pricing.Type / model.Type) | |||
| if (filterType !== 'all') { | |||
| const typeNum = Number(filterType); | |||
| result = result.filter((model) => model.type === typeNum); | |||
| } | |||
| // 搜索筛选 | |||
| if (searchValue.length > 0) { | |||
| const searchTerm = searchValue.toLowerCase(); | |||
| @@ -159,6 +166,7 @@ export const useModelPricingData = () => { | |||
| filterEndpointType, | |||
| filterVendor, | |||
| filterTag, | |||
| filterType, | |||
| ]); | |||
| const rowSelection = useMemo( | |||
| @@ -321,6 +329,7 @@ export const useModelPricingData = () => { | |||
| filterEndpointType, | |||
| filterVendor, | |||
| filterTag, | |||
| filterType, | |||
| searchValue, | |||
| ]); | |||
| @@ -350,6 +359,8 @@ export const useModelPricingData = () => { | |||
| setFilterVendor, | |||
| filterTag, | |||
| setFilterTag, | |||
| filterType, | |||
| setFilterType, | |||
| pageSize, | |||
| setPageSize, | |||
| currentPage, | |||
| @@ -37,6 +37,7 @@ export const usePricingFilterCounts = ({ | |||
| filterEndpointType = 'all', | |||
| filterVendor = 'all', | |||
| filterTag = 'all', | |||
| filterType = 'all', | |||
| searchValue = '', | |||
| }) => { | |||
| // 均使用同一份模型列表,避免创建新引用 | |||
| @@ -84,6 +85,12 @@ export const usePricingFilterCounts = ({ | |||
| if (!tagsArr.includes(filterTag.toLowerCase())) return false; | |||
| } | |||
| // 模型类型(pricing.Type / model.Type) | |||
| if (!ignore.includes('type') && filterType !== 'all') { | |||
| const typeNum = Number(filterType); | |||
| if (model.type !== typeNum) return false; | |||
| } | |||
| // 搜索 | |||
| if (!ignore.includes('search') && searchValue) { | |||
| const term = searchValue.toLowerCase(); | |||
| @@ -112,6 +119,7 @@ export const usePricingFilterCounts = ({ | |||
| filterEndpointType, | |||
| filterVendor, | |||
| filterTag, | |||
| filterType, | |||
| searchValue, | |||
| ], | |||
| ); | |||
| @@ -124,6 +132,7 @@ export const usePricingFilterCounts = ({ | |||
| filterQuotaType, | |||
| filterVendor, | |||
| filterTag, | |||
| filterType, | |||
| searchValue, | |||
| ], | |||
| ); | |||
| @@ -136,6 +145,7 @@ export const usePricingFilterCounts = ({ | |||
| filterQuotaType, | |||
| filterEndpointType, | |||
| filterTag, | |||
| filterType, | |||
| searchValue, | |||
| ], | |||
| ); | |||
| @@ -148,6 +158,20 @@ export const usePricingFilterCounts = ({ | |||
| filterQuotaType, | |||
| filterEndpointType, | |||
| filterVendor, | |||
| filterType, | |||
| searchValue, | |||
| ], | |||
| ); | |||
| const typeModels = useMemo( | |||
| () => allModels.filter((m) => matchesFilters(m, ['type'])), | |||
| [ | |||
| allModels, | |||
| filterGroup, | |||
| filterQuotaType, | |||
| filterEndpointType, | |||
| filterVendor, | |||
| filterTag, | |||
| searchValue, | |||
| ], | |||
| ); | |||
| @@ -160,6 +184,7 @@ export const usePricingFilterCounts = ({ | |||
| filterEndpointType, | |||
| filterVendor, | |||
| filterTag, | |||
| filterType, | |||
| searchValue, | |||
| ], | |||
| ); | |||
| @@ -170,5 +195,6 @@ export const usePricingFilterCounts = ({ | |||
| vendorModels, | |||
| groupCountModels, | |||
| tagModels, | |||
| typeModels, | |||
| }; | |||
| }; | |||
| @@ -0,0 +1,197 @@ | |||
| /* | |||
| Copyright (C) 2025 QuantumNous | |||
| This program is free software: you can redistribute it and/or modify | |||
| it under the terms of the GNU Affero General Public License as | |||
| published by the Free Software Foundation, either version 3 of the | |||
| License, or (at your option) any later version. | |||
| This program is distributed in the hope that it will be useful, | |||
| but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
| MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |||
| GNU Affero General Public License for more details. | |||
| You should have received a copy of the GNU Affero General Public License | |||
| along with this program. If not, see <https://www.gnu.org/licenses/>. | |||
| For commercial licensing, please contact support@quantumnous.com | |||
| */ | |||
| import React, { useMemo, useState } from 'react'; | |||
| import { ImagePreview } from '@douyinfe/semi-ui'; | |||
| import HorizontalFilterRow from '../../components/common/ui/HorizontalFilterRow'; | |||
| import PricingCardView from '../../components/table/model-pricing/view/card/PricingCardView'; | |||
| import ModelDetailSideSheet from '../../components/table/model-pricing/modal/ModelDetailSideSheet'; | |||
| import { useModelPricingData } from '../../hooks/model-pricing/useModelPricingData'; | |||
| import { usePricingFilterCounts } from '../../hooks/model-pricing/usePricingFilterCounts'; | |||
| import { getLobeHubIcon } from '../../helpers'; | |||
| // 与 model_meta.go ModelType 常量一致:1=Chat, 2=Image, 3=Audio, 4=Video, 5=Embedding, 6=Rerank, 7=Vision, 8=Other | |||
| const MODEL_TYPE_LIST = [ | |||
| { value: 1, labelKey: '对话' }, | |||
| { value: 2, labelKey: '图像' }, | |||
| { value: 3, labelKey: '音频' }, | |||
| { value: 4, labelKey: '视频' }, | |||
| { value: 5, labelKey: '嵌入' }, | |||
| { value: 6, labelKey: '重排序' }, | |||
| { value: 7, labelKey: '视觉' }, | |||
| { value: 8, labelKey: '其他' }, | |||
| ]; | |||
| const HomePricingFilters = ({ t }) => { | |||
| const [showRatio, setShowRatio] = useState(false); | |||
| const pricingData = useModelPricingData(); | |||
| const { | |||
| vendorModels, | |||
| typeModels, | |||
| } = usePricingFilterCounts({ | |||
| models: pricingData.models, | |||
| filterGroup: pricingData.filterGroup, | |||
| filterQuotaType: pricingData.filterQuotaType, | |||
| filterEndpointType: pricingData.filterEndpointType, | |||
| filterVendor: pricingData.filterVendor, | |||
| filterTag: pricingData.filterTag, | |||
| filterType: pricingData.filterType, | |||
| searchValue: pricingData.searchValue, | |||
| }); | |||
| const vendorItems = useMemo(() => { | |||
| const models = pricingData.models; | |||
| const vendorSet = new Set(); | |||
| const vendorIcons = new Map(); | |||
| let hasUnknown = false; | |||
| (models || []).forEach((m) => { | |||
| if (m.vendor_name) { | |||
| vendorSet.add(m.vendor_name); | |||
| if (m.vendor_icon && !vendorIcons.has(m.vendor_name)) { | |||
| vendorIcons.set(m.vendor_name, m.vendor_icon); | |||
| } | |||
| } else { | |||
| hasUnknown = true; | |||
| } | |||
| }); | |||
| const vendors = Array.from(vendorSet).sort(); | |||
| const getCount = (v) => { | |||
| if (v === 'all') return vendorModels.length; | |||
| if (v === 'unknown') return vendorModels.filter((m) => !m.vendor_name).length; | |||
| return vendorModels.filter((m) => m.vendor_name === v).length; | |||
| }; | |||
| const items = [ | |||
| { value: 'all', label: t('全部供应商'), tagCount: getCount('all'), disabled: (models || []).length === 0 }, | |||
| ]; | |||
| vendors.forEach((v) => { | |||
| const count = getCount(v); | |||
| items.push({ | |||
| value: v, | |||
| label: v, | |||
| icon: vendorIcons.get(v) ? getLobeHubIcon(vendorIcons.get(v), 16) : null, | |||
| tagCount: count, | |||
| disabled: count === 0, | |||
| }); | |||
| }); | |||
| if (hasUnknown) { | |||
| items.push({ | |||
| value: 'unknown', | |||
| label: t('未知供应商'), | |||
| tagCount: getCount('unknown'), | |||
| disabled: getCount('unknown') === 0, | |||
| }); | |||
| } | |||
| return items; | |||
| }, [pricingData.models, vendorModels, t]); | |||
| const typeItems = useMemo(() => { | |||
| const models = pricingData.models; | |||
| const getCount = (typeVal) => { | |||
| if (typeVal === 'all') return typeModels.length; | |||
| const n = Number(typeVal); | |||
| return typeModels.filter((m) => m.type === n).length; | |||
| }; | |||
| const items = [ | |||
| { value: 'all', label: t('全部类型'), tagCount: getCount('all'), disabled: (models || []).length === 0 }, | |||
| ]; | |||
| MODEL_TYPE_LIST.forEach(({ value, labelKey }) => { | |||
| const count = getCount(value); | |||
| items.push({ | |||
| value: String(value), | |||
| label: t(labelKey), | |||
| tagCount: count, | |||
| disabled: count === 0, | |||
| }); | |||
| }); | |||
| return items; | |||
| }, [pricingData.models, typeModels, t]); | |||
| return ( | |||
| <div className='w-full mt-8 md:mt-12'> | |||
| <div className='rounded-xl border border-semi-color-border bg-semi-color-bg-0 p-4'> | |||
| <HorizontalFilterRow | |||
| title={t('供应商')} | |||
| items={vendorItems} | |||
| activeValue={pricingData.filterVendor} | |||
| onChange={pricingData.setFilterVendor} | |||
| loading={pricingData.loading} | |||
| t={t} | |||
| /> | |||
| <HorizontalFilterRow | |||
| title={t('模型类型')} | |||
| items={typeItems} | |||
| activeValue={pricingData.filterType} | |||
| onChange={pricingData.setFilterType} | |||
| loading={pricingData.loading} | |||
| t={t} | |||
| /> | |||
| </div> | |||
| <div className='mt-6'> | |||
| <PricingCardView | |||
| filteredModels={pricingData.filteredModels} | |||
| loading={pricingData.loading} | |||
| rowSelection={null} | |||
| pageSize={pricingData.pageSize} | |||
| setPageSize={pricingData.setPageSize} | |||
| currentPage={pricingData.currentPage} | |||
| setCurrentPage={pricingData.setCurrentPage} | |||
| selectedGroup={pricingData.selectedGroup} | |||
| groupRatio={pricingData.groupRatio} | |||
| copyText={pricingData.copyText} | |||
| setModalImageUrl={pricingData.setModalImageUrl} | |||
| setIsModalOpenurl={pricingData.setIsModalOpenurl} | |||
| currency={pricingData.currency} | |||
| tokenUnit={pricingData.tokenUnit} | |||
| displayPrice={pricingData.displayPrice} | |||
| showRatio={showRatio} | |||
| t={t} | |||
| selectedRowKeys={[]} | |||
| openModelDetail={pricingData.openModelDetail} | |||
| gridColsClass='grid-cols-1 md:grid-cols-2 lg:grid-cols-3' | |||
| showPagination={false} | |||
| /> | |||
| </div> | |||
| <ImagePreview | |||
| src={pricingData.modalImageUrl} | |||
| visible={pricingData.isModalOpenurl} | |||
| onVisibleChange={(visible) => pricingData.setIsModalOpenurl(visible)} | |||
| /> | |||
| <ModelDetailSideSheet | |||
| visible={pricingData.showModelDetail} | |||
| onClose={pricingData.closeModelDetail} | |||
| modelData={pricingData.selectedModel} | |||
| groupRatio={pricingData.groupRatio} | |||
| usableGroup={pricingData.usableGroup} | |||
| currency={pricingData.currency} | |||
| tokenUnit={pricingData.tokenUnit} | |||
| displayPrice={pricingData.displayPrice} | |||
| showRatio={showRatio} | |||
| vendorsMap={pricingData.vendorsMap} | |||
| endpointMap={pricingData.endpointMap} | |||
| autoGroups={pricingData.autoGroups} | |||
| t={t} | |||
| /> | |||
| </div> | |||
| ); | |||
| }; | |||
| export default HomePricingFilters; | |||
| @@ -40,6 +40,7 @@ import { | |||
| } from '@douyinfe/semi-icons'; | |||
| import { Link } from 'react-router-dom'; | |||
| import NoticeModal from '../../components/layout/NoticeModal'; | |||
| import HomePricingFilters from './HomePricingFilters'; | |||
| import { | |||
| Moonshot, | |||
| OpenAI, | |||
| @@ -162,9 +163,9 @@ const Home = () => { | |||
| {/* 背景模糊晕染球 */} | |||
| <div className='blur-ball blur-ball-indigo' /> | |||
| <div className='blur-ball blur-ball-teal' /> | |||
| <div className='flex items-center justify-center h-full px-4 py-20 md:py-24 lg:py-32 mt-10'> | |||
| <div className='flex flex-col items-center w-full px-4 py-20 md:py-24 lg:py-32 mt-10'> | |||
| {/* 居中内容区 */} | |||
| <div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto'> | |||
| <div className='flex flex-col items-center justify-center text-center max-w-4xl mx-auto w-full'> | |||
| <div className='flex flex-col items-center justify-center mb-6 md:mb-8'> | |||
| <h1 | |||
| className={`text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold text-semi-color-text-0 leading-tight ${isChinese ? 'tracking-wide md:tracking-wider' : ''}`} | |||
| @@ -252,84 +253,10 @@ const Home = () => { | |||
| )} | |||
| </div> | |||
| {/* 框架兼容性图标 */} | |||
| <div className='mt-12 md:mt-16 lg:mt-20 w-full'> | |||
| <div className='flex items-center mb-6 md:mb-8 justify-center'> | |||
| <Text | |||
| type='tertiary' | |||
| className='text-lg md:text-xl lg:text-2xl font-light' | |||
| > | |||
| {t('支持众多的大模型供应商')} | |||
| </Text> | |||
| </div> | |||
| <div className='flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:gap-6 lg:gap-8 max-w-5xl mx-auto px-4'> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Moonshot size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <OpenAI size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <XAI size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Zhipu.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Volcengine.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Cohere.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Claude.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Gemini.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Suno size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Minimax.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Wenxin.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Spark.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Qingyan.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <DeepSeek.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Qwen.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Midjourney size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Grok size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <AzureAI.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Hunyuan.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Xinference.Color size={40} /> | |||
| </div> | |||
| <div className='w-8 h-8 sm:w-10 sm:h-10 md:w-12 md:h-12 flex items-center justify-center'> | |||
| <Typography.Text className='!text-lg sm:!text-xl md:!text-2xl lg:!text-3xl font-bold'> | |||
| 30+ | |||
| </Typography.Text> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {/* 模型广场筛选项与列表 */} | |||
| <div className='w-full max-w-6xl mt-8 px-4'> | |||
| <HomePricingFilters t={t} /> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -29,6 +29,11 @@ export default defineConfig({ | |||
| resolve: { | |||
| alias: { | |||
| '@': path.resolve(__dirname, './src'), | |||
| // 绕过 semi-ui exports 限制,dist/css/semi.css 不在 package.json exports 中 | |||
| '@douyinfe/semi-ui/dist/css/semi.css': path.resolve( | |||
| __dirname, | |||
| 'node_modules/@douyinfe/semi-ui/dist/css/semi.css', | |||
| ), | |||
| }, | |||
| }, | |||
| plugins: [ | |||