Compare commits

...

6 Commits

Author SHA1 Message Date
CaIon 8907e5cf6d feat: Add ChannelOtherSettings to manage additional channel configurations 2025-08-10 20:21:30 +08:00
t0ng7u 2cabe4f6ac 🔠 refactor: refine group label formatting in price info
Summary:
• Updated `helpers/utils.js` to display the “group” label without a colon, ensuring consistent typography with other price elements.

Details:
1. `formatPriceInfo`
   – Changed `{t('分组')}:` to `{t('分组')}` for a cleaner look.
   – Keeps spacing intact between label and selected group name.
2. No functional impact; purely visual polish.
2025-08-10 17:17:49 +08:00
t0ng7u ca85d224f1 📱 style: Hide vendor introduction on mobile devices
Summary:
• Updated `PricingTopSection.jsx` to conditionally render `PricingVendorIntroWithSkeleton` only when `isMobile` is false.

Details:
1. Wrapped vendor-intro block in `!isMobile` check, preventing unnecessary content on small screens.
2. Kept desktop experience unchanged; no impact on other features.
3. Lint check passed with no new issues.

Result:
Cleaner mobile UI with improved performance and visual focus.
2025-08-10 14:10:50 +08:00
t0ng7u b6dd701ce8 feat: Add tag-based filtering & refactor filter counts logic
Overview:
• Introduced a new “Model Tag” filter across pricing screens
• Refactored `usePricingFilterCounts` to eliminate duplicated logic
• Improved tag handling to be case-insensitive and deduplicated
• Extended utilities to reset & persist the new filter

Details:
1. Added `filterTag` state to `useModelPricingData` and integrated it into all filtering paths.
2. Created reusable `PricingTags` component using `SelectableButtonGroup`.
3. Incorporated tag filter into `PricingSidebar` and mobile `PricingFilterModal`, including reset support.
4. Enhanced `resetPricingFilters` (helpers/utils) to restore tag filter defaults.
5. Refactored `usePricingFilterCounts.js`:
   • Centralized predicate `matchesFilters` to remove redundancy
   • Normalized tag parsing via `normalizeTags` helper
   • Memoized model subsets with concise filter calls
6. Updated lints – zero errors after refactor.

Result:
Users can now filter models by custom tags with consistent UX, and internal logic is cleaner, faster, and easier to extend.
2025-08-10 14:05:25 +08:00
t0ng7u 7130ec8e32 🐛 fix(PricingCardView): hide placeholder dash when no custom tags
Previously, the card view displayed a “-” whenever a model had no custom tags,
because `renderLimitedItems` returned a dash for an empty array.
Now the function is only invoked when `customTags.length > 0`, removing the
unwanted placeholder and keeping the UI clean.

File affected:
- web/src/components/table/model-pricing/view/card/PricingCardView.jsx
2025-08-10 13:45:16 +08:00
t0ng7u c79b6cea32 fix: prevent model name flicker when closing SideSheet
- Delay clearing selectedModel until SideSheet close animation completes
- Prevents brief display of 'AI/Unknown Model' text during closing transition
- Improves user experience by eliminating visual flicker
- Uses 300ms timeout matching Semi UI default animation duration
2025-08-10 13:41:19 +08:00
17 changed files with 422 additions and 155 deletions
+1
View File
@@ -22,6 +22,7 @@ const (
ContextKeyChannelBaseUrl ContextKey = "base_url"
ContextKeyChannelType ContextKey = "channel_type"
ContextKeyChannelSetting ContextKey = "channel_setting"
ContextKeyChannelOtherSetting ContextKey = "channel_other_setting"
ContextKeyChannelParamOverride ContextKey = "param_override"
ContextKeyChannelOrganization ContextKey = "channel_organization"
ContextKeyChannelAutoBan ContextKey = "auto_ban"
+4
View File
@@ -8,3 +8,7 @@ type ChannelSettings struct {
SystemPrompt string `json:"system_prompt,omitempty"`
SystemPromptOverride bool `json:"system_prompt_override,omitempty"`
}
type ChannelOtherSettings struct {
AzureResponsesVersion string `json:"azure_responses_version,omitempty"`
}
+1
View File
@@ -244,6 +244,7 @@ func SetupContextForSelectedChannel(c *gin.Context, channel *model.Channel, mode
common.SetContextKey(c, constant.ContextKeyChannelType, channel.Type)
common.SetContextKey(c, constant.ContextKeyChannelCreateTime, channel.CreatedTime)
common.SetContextKey(c, constant.ContextKeyChannelSetting, channel.GetSetting())
common.SetContextKey(c, constant.ContextKeyChannelOtherSetting, channel.GetOtherSettings())
common.SetContextKey(c, constant.ContextKeyChannelParamOverride, channel.GetParamOverride())
if nil != channel.OpenAIOrganization && *channel.OpenAIOrganization != "" {
common.SetContextKey(c, constant.ContextKeyChannelOrganization, *channel.OpenAIOrganization)
+23 -1
View File
@@ -42,7 +42,7 @@ type Channel struct {
Priority *int64 `json:"priority" gorm:"bigint;default:0"`
AutoBan *int `json:"auto_ban" gorm:"default:1"`
OtherInfo string `json:"other_info"`
Settings string `json:"settings"`
OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置
Tag *string `json:"tag" gorm:"index"`
Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置
ParamOverride *string `json:"param_override" gorm:"type:text"`
@@ -838,6 +838,28 @@ func (channel *Channel) SetSetting(setting dto.ChannelSettings) {
channel.Setting = common.GetPointer[string](string(settingBytes))
}
func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings {
setting := dto.ChannelOtherSettings{}
if channel.OtherSettings != "" {
err := common.UnmarshalJsonStr(channel.OtherSettings, &setting)
if err != nil {
common.SysError("failed to unmarshal setting: " + err.Error())
channel.OtherSettings = "{}" // 清空设置以避免后续错误
_ = channel.Save() // 保存修改
}
}
return setting
}
func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) {
settingBytes, err := common.Marshal(setting)
if err != nil {
common.SysError("failed to marshal setting: " + err.Error())
return
}
channel.OtherSettings = string(settingBytes)
}
func (channel *Channel) GetParamOverride() map[string]interface{} {
paramOverride := make(map[string]interface{})
if channel.ParamOverride != nil && *channel.ParamOverride != "" {
+5 -1
View File
@@ -128,7 +128,11 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
// 特殊处理 responses API
if info.RelayMode == relayconstant.RelayModeResponses {
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=preview")
responsesApiVersion := "preview"
if info.ChannelOtherSettings.AzureResponsesVersion != "" {
responsesApiVersion = info.ChannelOtherSettings.AzureResponsesVersion
}
requestURL = fmt.Sprintf("/openai/v1/responses?api-version=%s", responsesApiVersion)
return relaycommon.GetFullRequestURL(info.BaseUrl, requestURL, info.ChannelType), nil
}
+7
View File
@@ -102,6 +102,7 @@ type RelayInfo struct {
AudioUsage bool
ReasoningEffort string
ChannelSetting dto.ChannelSettings
ChannelOtherSettings dto.ChannelOtherSettings
ParamOverride map[string]interface{}
UserSetting dto.UserSetting
UserEmail string
@@ -292,6 +293,12 @@ func GenRelayInfo(c *gin.Context) *RelayInfo {
if ok {
info.ChannelSetting = channelSetting
}
channelOtherSettings, ok := common.GetContextKeyType[dto.ChannelOtherSettings](c, constant.ContextKeyChannelOtherSetting)
if ok {
info.ChannelOtherSettings = channelOtherSettings
}
userSetting, ok := common.GetContextKeyType[dto.UserSetting](c, constant.ContextKeyUserSetting)
if ok {
info.UserSetting = userSetting
@@ -132,6 +132,7 @@ const EditChannelModal = (props) => {
pass_through_body_enabled: false,
system_prompt: '',
system_prompt_override: false,
settings: '',
};
const [batch, setBatch] = useState(false);
const [multiToSingle, setMultiToSingle] = useState(false);
@@ -187,38 +188,31 @@ const EditChannelModal = (props) => {
handleInputChange('setting', settingsJson);
};
// 解析渠道设置JSON为单独的状态
const parseChannelSettings = (settingJson) => {
try {
if (settingJson && settingJson.trim()) {
const parsed = JSON.parse(settingJson);
setChannelSettings({
force_format: parsed.force_format || false,
thinking_to_content: parsed.thinking_to_content || false,
proxy: parsed.proxy || '',
pass_through_body_enabled: parsed.pass_through_body_enabled || false,
system_prompt: parsed.system_prompt || '',
});
} else {
setChannelSettings({
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
});
}
} catch (error) {
console.error('解析渠道设置失败:', error);
setChannelSettings({
force_format: false,
thinking_to_content: false,
proxy: '',
pass_through_body_enabled: false,
system_prompt: '',
});
const handleChannelOtherSettingsChange = (key, value) => {
// 更新内部状态
setChannelSettings(prev => ({ ...prev, [key]: value }));
// 同步更新到表单字段
if (formApiRef.current) {
formApiRef.current.setValue(key, value);
}
};
// 同步更新inputs状态
setInputs(prev => ({ ...prev, [key]: value }));
// 需要更新settings,是一个json,例如{"azure_responses_version": "preview"}
let settings = {};
if (inputs.settings) {
try {
settings = JSON.parse(inputs.settings);
} catch (error) {
console.error('解析设置失败:', error);
}
}
settings[key] = value;
const settingsJson = JSON.stringify(settings);
handleInputChange('settings', settingsJson);
}
const handleInputChange = (name, value) => {
if (formApiRef.current) {
@@ -360,6 +354,17 @@ const EditChannelModal = (props) => {
data.system_prompt_override = false;
}
if (data.settings) {
try {
const parsedSettings = JSON.parse(data.settings);
data.azure_responses_version = parsedSettings.azure_responses_version || '';
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
data.region = '';
}
}
setInputs(data);
if (formApiRef.current) {
formApiRef.current.setValues(data);
@@ -1377,6 +1382,15 @@ const EditChannelModal = (props) => {
showClear
/>
</div>
<div>
<Form.Input
field='azure_responses_version'
label={t('默认 Responses API 版本,为空则使用上方版本')}
placeholder={t('例如:preview')}
onChange={(value) => handleChannelOtherSettingsChange('azure_responses_version', value)}
showClear
/>
</div>
</>
)}
@@ -0,0 +1,100 @@
/*
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 from 'react';
import SelectableButtonGroup from '../../../common/ui/SelectableButtonGroup';
/**
* 模型标签筛选组件
* @param {string|'all'} filterTag 当前选中的标签
* @param {Function} setFilterTag setter
* @param {Array} models 当前过滤后模型列表(用于计数)
* @param {Array} allModels 所有模型列表(用于获取所有标签)
* @param {boolean} loading 是否加载中
* @param {Function} t i18n
*/
const PricingTags = ({ filterTag, setFilterTag, models = [], allModels = [], loading = false, t }) => {
// 提取系统所有标签
const getAllTags = React.useMemo(() => {
const tagSet = new Set();
(allModels.length > 0 ? allModels : models).forEach(model => {
if (model.tags) {
model.tags
.split(/[,;|\s]+/) // 逗号、分号、竖线或空白字符
.map(tag => tag.trim())
.filter(Boolean)
.forEach(tag => tagSet.add(tag.toLowerCase()));
}
});
return Array.from(tagSet).sort((a, b) => a.localeCompare(b));
}, [allModels, models]);
// 计算标签对应的模型数量
const getTagCount = React.useCallback((tag) => {
if (tag === 'all') return models.length;
const tagLower = tag.toLowerCase();
return models.filter(model => {
if (!model.tags) return false;
return model.tags
.toLowerCase()
.split(/[,;|\s]+/)
.map(tg => tg.trim())
.includes(tagLower);
}).length;
}, [models]);
const items = React.useMemo(() => {
const result = [
{
value: 'all',
label: t('全部标签'),
tagCount: getTagCount('all'),
disabled: models.length === 0,
}
];
getAllTags.forEach(tag => {
const count = getTagCount(tag);
result.push({
value: tag,
label: tag,
tagCount: count,
disabled: count === 0,
});
});
return result;
}, [getAllTags, getTagCount, t, models.length]);
return (
<SelectableButtonGroup
title={t('标签')}
items={items}
activeValue={filterTag}
onChange={setFilterTag}
loading={loading}
t={t}
/>
);
};
export default PricingTags;
@@ -23,6 +23,7 @@ import PricingGroups from '../filter/PricingGroups';
import PricingQuotaTypes from '../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../filter/PricingEndpointTypes';
import PricingVendors from '../filter/PricingVendors';
import PricingTags from '../filter/PricingTags';
import PricingDisplaySettings from '../filter/PricingDisplaySettings';
import { resetPricingFilters } from '../../../../helpers/utils';
import { usePricingFilterCounts } from '../../../../hooks/model-pricing/usePricingFilterCounts';
@@ -47,6 +48,8 @@ const PricingSidebar = ({
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
currentPage,
setCurrentPage,
tokenUnit,
@@ -60,6 +63,7 @@ const PricingSidebar = ({
quotaTypeModels,
endpointTypeModels,
vendorModels,
tagModels,
groupCountModels,
} = usePricingFilterCounts({
models: categoryProps.models,
@@ -67,6 +71,7 @@ const PricingSidebar = ({
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue: categoryProps.searchValue,
});
@@ -81,6 +86,7 @@ const PricingSidebar = ({
setFilterQuotaType,
setFilterEndpointType,
setFilterVendor,
setFilterTag,
setCurrentPage,
setTokenUnit,
});
@@ -125,6 +131,15 @@ const PricingSidebar = ({
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={handleGroupClick}
@@ -50,6 +50,7 @@ const PricingTopSection = ({
onCompositionEnd={handleCompositionEnd}
onChange={handleChange}
showClear
style={{ backgroundColor: 'transparent' }}
/>
</div>
@@ -81,14 +82,16 @@ const PricingTopSection = ({
return (
<>
{/* 供应商介绍区域(含骨架屏 */}
<PricingVendorIntroWithSkeleton
loading={loading}
filterVendor={filterVendor}
models={filteredModels}
allModels={models}
t={t}
/>
{/* 供应商介绍区域(桌面端显示 */}
{!isMobile && (
<PricingVendorIntroWithSkeleton
loading={loading}
filterVendor={filterVendor}
models={filteredModels}
allModels={models}
t={t}
/>
)}
{/* 搜索和操作区域 */}
{SearchAndActions}
@@ -40,6 +40,7 @@ const PricingFilterModal = ({
setFilterQuotaType: sidebarProps.setFilterQuotaType,
setFilterEndpointType: sidebarProps.setFilterEndpointType,
setFilterVendor: sidebarProps.setFilterVendor,
setFilterTag: sidebarProps.setFilterTag,
setCurrentPage: sidebarProps.setCurrentPage,
setTokenUnit: sidebarProps.setTokenUnit,
});
@@ -23,6 +23,7 @@ import PricingGroups from '../../filter/PricingGroups';
import PricingQuotaTypes from '../../filter/PricingQuotaTypes';
import PricingEndpointTypes from '../../filter/PricingEndpointTypes';
import PricingVendors from '../../filter/PricingVendors';
import PricingTags from '../../filter/PricingTags';
import { usePricingFilterCounts } from '../../../../../hooks/model-pricing/usePricingFilterCounts';
const FilterModalContent = ({ sidebarProps, t }) => {
@@ -45,6 +46,8 @@ const FilterModalContent = ({ sidebarProps, t }) => {
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
tokenUnit,
setTokenUnit,
loading,
@@ -55,6 +58,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
quotaTypeModels,
endpointTypeModels,
vendorModels,
tagModels,
groupCountModels,
} = usePricingFilterCounts({
models: categoryProps.models,
@@ -62,6 +66,7 @@ const FilterModalContent = ({ sidebarProps, t }) => {
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue: sidebarProps.searchValue,
});
@@ -91,6 +96,15 @@ const FilterModalContent = ({ sidebarProps, t }) => {
t={t}
/>
<PricingTags
filterTag={filterTag}
setFilterTag={setFilterTag}
models={tagModels}
allModels={categoryProps.models}
loading={loading}
t={t}
/>
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={setFilterGroup}
@@ -136,7 +136,7 @@ const PricingCardView = ({
groupRatio,
tokenUnit,
displayPrice,
currency
currency,
});
return formatPriceInfo(priceData, t);
};
@@ -171,7 +171,7 @@ const PricingCardView = ({
{billingTag}
</div>
<div className="flex items-center gap-1">
{renderLimitedItems({
{customTags.length > 0 && renderLimitedItems({
items: customTags.map((tag, idx) => ({ key: `custom-${idx}`, element: tag })),
renderItem: (item, idx) => item.element,
maxDisplay: 3
@@ -302,7 +302,7 @@ const PricingCardView = ({
{t('补全')}: {model.quota_type === 0 ? parseFloat(model.completion_ratio.toFixed(3)) : t('无')}
</div>
<div>
{t('分组')}: {groupRatio[selectedGroup]}
{t('分组')}: {priceData.usedGroupRatio}
</div>
</div>
</div>
+59 -18
View File
@@ -581,13 +581,37 @@ export const calculateModelPrice = ({
tokenUnit,
displayPrice,
currency,
precision = 4
precision = 4,
}) => {
// 1. 选择实际使用的分组
let usedGroup = selectedGroup;
let usedGroupRatio = groupRatio[selectedGroup];
if (selectedGroup === 'all' || usedGroupRatio === undefined) {
// 在模型可用分组中选择倍率最小的分组,若无则使用 1
let minRatio = Number.POSITIVE_INFINITY;
if (Array.isArray(record.enable_groups) && record.enable_groups.length > 0) {
record.enable_groups.forEach((g) => {
const r = groupRatio[g];
if (r !== undefined && r < minRatio) {
minRatio = r;
usedGroup = g;
usedGroupRatio = r;
}
});
}
// 如果找不到合适分组倍率,回退为 1
if (usedGroupRatio === undefined) {
usedGroupRatio = 1;
}
}
// 2. 根据计费类型计算价格
if (record.quota_type === 0) {
// 按量计费
const inputRatioPriceUSD = record.model_ratio * 2 * groupRatio[selectedGroup];
const completionRatioPriceUSD =
record.model_ratio * record.completion_ratio * 2 * groupRatio[selectedGroup];
const inputRatioPriceUSD = record.model_ratio * 2 * usedGroupRatio;
const completionRatioPriceUSD = record.model_ratio * record.completion_ratio * 2 * usedGroupRatio;
const unitDivisor = tokenUnit === 'K' ? 1000 : 1;
const unitLabel = tokenUnit === 'K' ? 'K' : 'M';
@@ -602,22 +626,32 @@ export const calculateModelPrice = ({
inputPrice: `${currency === 'CNY' ? '¥' : '$'}${numInput.toFixed(precision)}`,
completionPrice: `${currency === 'CNY' ? '¥' : '$'}${numCompletion.toFixed(precision)}`,
unitLabel,
isPerToken: true
};
} else {
// 按次计费
const priceUSD = parseFloat(record.model_price) * groupRatio[selectedGroup];
const displayVal = displayPrice(priceUSD);
return {
price: displayVal,
isPerToken: false
isPerToken: true,
usedGroup,
usedGroupRatio,
};
}
// 按次计费
const priceUSD = parseFloat(record.model_price) * usedGroupRatio;
const displayVal = displayPrice(priceUSD);
return {
price: displayVal,
isPerToken: false,
usedGroup,
usedGroupRatio,
};
};
// 格式化价格信息(用于卡片视图)
export const formatPriceInfo = (priceData, t) => {
const groupTag = priceData.usedGroup ? (
<span style={{ color: 'var(--semi-color-text-1)' }} className="ml-1 text-xs">
{t('分组')} {priceData.usedGroup}
</span>
) : null;
if (priceData.isPerToken) {
return (
<>
@@ -627,15 +661,19 @@ export const formatPriceInfo = (priceData, t) => {
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('补全')} {priceData.completionPrice}/{priceData.unitLabel}
</span>
{groupTag}
</>
);
} else {
return (
}
return (
<>
<span style={{ color: 'var(--semi-color-text-1)' }}>
{t('模型价格')} {priceData.price}
</span>
);
}
{groupTag}
</>
);
};
// -------------------------------
@@ -699,6 +737,7 @@ const DEFAULT_PRICING_FILTERS = {
filterQuotaType: 'all',
filterEndpointType: 'all',
filterVendor: 'all',
filterTag: 'all',
currentPage: 1,
};
@@ -713,6 +752,7 @@ export const resetPricingFilters = ({
setFilterQuotaType,
setFilterEndpointType,
setFilterVendor,
setFilterTag,
setCurrentPage,
setTokenUnit,
}) => {
@@ -726,5 +766,6 @@ export const resetPricingFilters = ({
setFilterQuotaType?.(DEFAULT_PRICING_FILTERS.filterQuotaType);
setFilterEndpointType?.(DEFAULT_PRICING_FILTERS.filterEndpointType);
setFilterVendor?.(DEFAULT_PRICING_FILTERS.filterVendor);
setFilterTag?.(DEFAULT_PRICING_FILTERS.filterTag);
setCurrentPage?.(DEFAULT_PRICING_FILTERS.currentPage);
};
@@ -31,13 +31,14 @@ export const useModelPricingData = () => {
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
const [modalImageUrl, setModalImageUrl] = useState('');
const [isModalOpenurl, setIsModalOpenurl] = useState(false);
const [selectedGroup, setSelectedGroup] = useState('default');
const [selectedGroup, setSelectedGroup] = useState('all');
const [showModelDetail, setShowModelDetail] = useState(false);
const [selectedModel, setSelectedModel] = useState(null);
const [filterGroup, setFilterGroup] = useState('all'); // 用于 Table 的可用分组筛选,"all" 表示不过滤
const [filterQuotaType, setFilterQuotaType] = useState('all'); // 计费类型筛选: 'all' | 0 | 1
const [filterEndpointType, setFilterEndpointType] = useState('all'); // 端点类型筛选: 'all' | string
const [filterVendor, setFilterVendor] = useState('all'); // 供应商筛选: 'all' | 'unknown' | string
const [filterTag, setFilterTag] = useState('all'); // 模型标签筛选: 'all' | string
const [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
const [currency, setCurrency] = useState('USD');
@@ -88,6 +89,20 @@ export const useModelPricingData = () => {
}
}
// 标签筛选
if (filterTag !== 'all') {
const tagLower = filterTag.toLowerCase();
result = result.filter(model => {
if (!model.tags) return false;
const tagsArr = model.tags
.toLowerCase()
.split(/[,;|\s]+/)
.map(tag => tag.trim())
.filter(Boolean);
return tagsArr.includes(tagLower);
});
}
// 搜索筛选
if (searchValue.length > 0) {
const searchTerm = searchValue.toLowerCase();
@@ -100,7 +115,7 @@ export const useModelPricingData = () => {
}
return result;
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor]);
}, [models, searchValue, filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag]);
const rowSelection = useMemo(
() => ({
@@ -165,7 +180,7 @@ export const useModelPricingData = () => {
if (success) {
setGroupRatio(group_ratio);
setUsableGroup(usable_group);
setSelectedGroup(userState.user ? userState.user.group : 'default');
setSelectedGroup('all');
// 构建供应商 Map 方便查找
const vendorMap = {};
if (Array.isArray(vendors)) {
@@ -218,12 +233,17 @@ export const useModelPricingData = () => {
setSelectedGroup(group);
// 同时将分组过滤设置为该分组
setFilterGroup(group);
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group],
}),
);
if (group === 'all') {
showInfo(t('已切换至最优倍率视图,每个模型使用其最低倍率分组'));
} else {
showInfo(
t('当前查看的分组为:{{group}},倍率为:{{ratio}}', {
group: group,
ratio: groupRatio[group] ?? 1,
}),
);
}
};
const openModelDetail = (model) => {
@@ -233,7 +253,9 @@ export const useModelPricingData = () => {
const closeModelDetail = () => {
setShowModelDetail(false);
setSelectedModel(null);
setTimeout(() => {
setSelectedModel(null);
}, 300);
};
useEffect(() => {
@@ -243,7 +265,7 @@ export const useModelPricingData = () => {
// 当筛选条件变化时重置到第一页
useEffect(() => {
setCurrentPage(1);
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
}, [filterGroup, filterQuotaType, filterEndpointType, filterVendor, filterTag, searchValue]);
return {
// 状态
@@ -269,6 +291,8 @@ export const useModelPricingData = () => {
setFilterEndpointType,
filterVendor,
setFilterVendor,
filterTag,
setFilterTag,
pageSize,
setPageSize,
currentPage,
@@ -17,115 +17,128 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
/*
统一计算模型筛选后的各种集合与动态计数,供多个组件复用
*/
import { useMemo } from 'react';
// 工具函数:将 tags 字符串转为小写去重数组
const normalizeTags = (tags = '') =>
tags
.toLowerCase()
.split(/[,;|\s]+/)
.map((t) => t.trim())
.filter(Boolean);
/**
* 统一计算模型筛选后的各种集合与动态计数,供多个组件复用
*/
export const usePricingFilterCounts = ({
models = [],
filterGroup = 'all',
filterQuotaType = 'all',
filterEndpointType = 'all',
filterVendor = 'all',
filterTag = 'all',
searchValue = '',
}) => {
// 所有模型(不再需要分类过滤)
// 均使用同一份模型列表,避免创建新引用
const allModels = models;
// 针对计费类型按钮计数
const quotaTypeModels = useMemo(() => {
let result = allModels;
if (filterGroup !== 'all') {
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
/**
* 通用过滤函数
* @param {Object} model
* @param {Array<string>} ignore 需要忽略的过滤条件 key
* @returns {boolean}
*/
const matchesFilters = (model, ignore = []) => {
// 分组
if (!ignore.includes('group') && filterGroup !== 'all') {
if (!model.enable_groups || !model.enable_groups.includes(filterGroup)) return false;
}
if (filterEndpointType !== 'all') {
result = result.filter(m =>
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
);
// 计费类型
if (!ignore.includes('quota') && filterQuotaType !== 'all') {
if (model.quota_type !== filterQuotaType) return false;
}
if (filterVendor !== 'all') {
// 端点类型
if (!ignore.includes('endpoint') && filterEndpointType !== 'all') {
if (
!model.supported_endpoint_types ||
!model.supported_endpoint_types.includes(filterEndpointType)
)
return false;
}
// 供应商
if (!ignore.includes('vendor') && filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter(m => !m.vendor_name);
} else {
result = result.filter(m => m.vendor_name === filterVendor);
if (model.vendor_name) return false;
} else if (model.vendor_name !== filterVendor) {
return false;
}
}
return result;
}, [allModels, filterGroup, filterEndpointType, filterVendor]);
// 针对端点类型按钮计数
const endpointTypeModels = useMemo(() => {
let result = allModels;
if (filterGroup !== 'all') {
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
// 标签
if (!ignore.includes('tag') && filterTag !== 'all') {
const tagsArr = normalizeTags(model.tags);
if (!tagsArr.includes(filterTag.toLowerCase())) return false;
}
if (filterQuotaType !== 'all') {
result = result.filter(m => m.quota_type === filterQuotaType);
}
if (filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter(m => !m.vendor_name);
} else {
result = result.filter(m => m.vendor_name === filterVendor);
}
}
return result;
}, [allModels, filterGroup, filterQuotaType, filterVendor]);
// === 可用令牌分组计数模型(排除 group 过滤,保留其余过滤) ===
const groupCountModels = useMemo(() => {
let result = allModels;
// 不应用 filterGroup 本身
if (filterQuotaType !== 'all') {
result = result.filter(m => m.quota_type === filterQuotaType);
}
if (filterEndpointType !== 'all') {
result = result.filter(m =>
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
);
}
if (filterVendor !== 'all') {
if (filterVendor === 'unknown') {
result = result.filter(m => !m.vendor_name);
} else {
result = result.filter(m => m.vendor_name === filterVendor);
}
}
if (searchValue && searchValue.length > 0) {
// 搜索
if (!ignore.includes('search') && searchValue) {
const term = searchValue.toLowerCase();
result = result.filter(m =>
m.model_name.toLowerCase().includes(term) ||
(m.description && m.description.toLowerCase().includes(term)) ||
(m.tags && m.tags.toLowerCase().includes(term)) ||
(m.vendor_name && m.vendor_name.toLowerCase().includes(term))
);
const tags = model.tags ? model.tags.toLowerCase() : '';
if (
!(
model.model_name.toLowerCase().includes(term) ||
(model.description && model.description.toLowerCase().includes(term)) ||
tags.includes(term) ||
(model.vendor_name && model.vendor_name.toLowerCase().includes(term))
)
)
return false;
}
return result;
}, [allModels, filterQuotaType, filterEndpointType, filterVendor, searchValue]);
// 针对供应商按钮计数
const vendorModels = useMemo(() => {
let result = allModels;
if (filterGroup !== 'all') {
result = result.filter(m => m.enable_groups && m.enable_groups.includes(filterGroup));
}
if (filterQuotaType !== 'all') {
result = result.filter(m => m.quota_type === filterQuotaType);
}
if (filterEndpointType !== 'all') {
result = result.filter(m =>
m.supported_endpoint_types && m.supported_endpoint_types.includes(filterEndpointType)
);
}
return result;
}, [allModels, filterGroup, filterQuotaType, filterEndpointType]);
return true;
};
// 生成不同视图所需的模型集合
const quotaTypeModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['quota'])),
[allModels, filterGroup, filterEndpointType, filterVendor, filterTag]
);
const endpointTypeModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['endpoint'])),
[allModels, filterGroup, filterQuotaType, filterVendor, filterTag]
);
const vendorModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['vendor'])),
[allModels, filterGroup, filterQuotaType, filterEndpointType, filterTag]
);
const tagModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['tag'])),
[allModels, filterGroup, filterQuotaType, filterEndpointType, filterVendor]
);
const groupCountModels = useMemo(
() => allModels.filter((m) => matchesFilters(m, ['group'])),
[
allModels,
filterQuotaType,
filterEndpointType,
filterVendor,
filterTag,
searchValue,
]
);
return {
quotaTypeModels,
endpointTypeModels,
vendorModels,
groupCountModels,
tagModels,
};
};
+4 -1
View File
@@ -1876,6 +1876,7 @@
"全部分组": "All groups",
"全部类型": "All types",
"全部端点": "All endpoints",
"全部标签": "All tags",
"显示倍率": "Show ratio",
"表格视图": "Table view",
"模型的详细描述和基本特性": "Detailed description and basic characteristics of the model",
@@ -1914,5 +1915,7 @@
"精确名称匹配": "Exact name matching",
"前缀名称匹配": "Prefix name matching",
"后缀名称匹配": "Suffix name matching",
"包含名称匹配": "Contains name matching"
"包含名称匹配": "Contains name matching",
"展开更多": "Expand more",
"已切换至最优倍率视图,每个模型使用其最低倍率分组": "Switched to the optimal ratio view, each model uses its lowest ratio group"
}