diff --git a/model/ability.go b/model/ability.go
index 1d7c53f..3f26aa5 100644
--- a/model/ability.go
+++ b/model/ability.go
@@ -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
}
diff --git a/model/model_meta.go b/model/model_meta.go
index 860b960..4f76898 100644
--- a/model/model_meta.go
+++ b/model/model_meta.go
@@ -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"`
diff --git a/model/pricing.go b/model/pricing.go
index cb687d0..6dfe69b 100644
--- a/model/pricing.go
+++ b/model/pricing.go
@@ -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 {
diff --git a/web/src/components/common/ui/HorizontalFilterRow.jsx b/web/src/components/common/ui/HorizontalFilterRow.jsx
new file mode 100644
index 0000000..b98cce6
--- /dev/null
+++ b/web/src/components/common/ui/HorizontalFilterRow.jsx
@@ -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 .
+
+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 (
+
+
+
+ {[1, 2, 3, 4, 5].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {title}
+
+
+
}
+ onClick={() => handleScroll('left')}
+ className='flex-shrink-0'
+ aria-label={t('向左')}
+ />
+
+ {items.map((item) => {
+ const isDisabled =
+ item.disabled ||
+ (typeof item.tagCount === 'number' && item.tagCount === 0);
+ const isActive = activeValue === item.value;
+
+ return (
+
+ );
+ })}
+
+
}
+ onClick={() => handleScroll('right')}
+ className='flex-shrink-0'
+ aria-label={t('向右')}
+ />
+
+
+ );
+};
+
+export default HorizontalFilterRow;
diff --git a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx
index 6d53286..849ea97 100644
--- a/web/src/components/table/model-pricing/view/card/PricingCardView.jsx
+++ b/web/src/components/table/model-pricing/view/card/PricingCardView.jsx
@@ -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 (
-
+
{paginatedModels.map((model, index) => {
const modelKey = getModelKey(model);
const isSelected = selectedRowKeys.includes(modelKey);
@@ -357,7 +358,7 @@ const PricingCardView = ({
{/* 分页 */}
- {filteredModels.length > 0 && (
+ {showPagination && filteredModels.length > 0 && (
{
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,
diff --git a/web/src/hooks/model-pricing/usePricingFilterCounts.js b/web/src/hooks/model-pricing/usePricingFilterCounts.js
index 3bb3d85..dd422d2 100644
--- a/web/src/hooks/model-pricing/usePricingFilterCounts.js
+++ b/web/src/hooks/model-pricing/usePricingFilterCounts.js
@@ -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,
};
};
diff --git a/web/src/pages/Home/HomePricingFilters.jsx b/web/src/pages/Home/HomePricingFilters.jsx
new file mode 100644
index 0000000..7726a0d
--- /dev/null
+++ b/web/src/pages/Home/HomePricingFilters.jsx
@@ -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 .
+
+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 (
+
+
+
+
+
+
+
+
+
pricingData.setIsModalOpenurl(visible)}
+ />
+
+
+
+ );
+};
+
+export default HomePricingFilters;
diff --git a/web/src/pages/Home/index.jsx b/web/src/pages/Home/index.jsx
index c153c1b..e1a238c 100644
--- a/web/src/pages/Home/index.jsx
+++ b/web/src/pages/Home/index.jsx
@@ -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 = () => {
{/* 背景模糊晕染球 */}
-
+
{/* 居中内容区 */}
-
+
{
)}
- {/* 框架兼容性图标 */}
-
-
-
- {t('支持众多的大模型供应商')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 30+
-
-
-
-
+
+ {/* 模型广场筛选项与列表 */}
+
+
diff --git a/web/vite.config.js b/web/vite.config.js
index 73e4621..65828e0 100644
--- a/web/vite.config.js
+++ b/web/vite.config.js
@@ -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: [