feat: user filter models (#8953)

* feat(组件): 添加模型标签组件并重构相关引用

添加新的模型标签组件(EmbeddingTag, ReasoningTag等)并集中导出
重构ModelTagsWithLabel组件使用新的标签组件
移除旧的ModelCapabilities引用

* refactor(Tags): 移除FreeTag组件中未使用的showLabel属性

* feat(模型选择弹窗): 添加按标签筛选模型功能

在模型选择弹窗中新增标签筛选功能,支持按视觉、网页搜索、推理、工具调用、免费、嵌入和重排等标签类型筛选模型

* feat(i18n): 添加按标签筛选的翻译文本

* feat(SelectModelPopup): 优化模型筛选逻辑并添加类型安全的objectKeys工具函数

重构模型筛选逻辑,简化条件判断并支持外部传入的filterTypes控制显示。添加类型安全的objectKeys工具函数用于获取对象键名。调整筛选标签的显示逻辑,仅在相关filterTypes存在时显示对应标签。

* refactor(SelectModelPopup): 移除已注释的冗余代码

* fix(SelectModelPopup): 修复布局偏移问题并调整内边距

为弹出容器添加动态高度以避免布局偏移
调整过滤容器的内边距

* refactor(model): 将isFreeModel函数移动到单独的文件并添加模型标签功能

重构模型相关工具函数,将isFreeModel从utils/index.ts移动到utils/model.ts
新增getModelTags函数用于获取模型标签状态
更新相关导入路径以保持一致性

* refactor(SelectModelPopup): 重构扁平列表项类型定义以提高类型安全性

将 FlatListItem 拆分为 FlatListGroup 和 FlatListModel 两种具体类型
确保模型项必须包含 model 属性而分组项不包含
更新相关组件代码以适配新的类型定义

* docs(utils/model): 添加getModelTags函数的注释说明

* refactor(SelectModelPopup): 重构模型筛选逻辑,优化标签管理

- 将模型筛选逻辑拆分为用户筛选和搜索筛选两部分
- 去除内置筛选
- 使用getModelTags获取模型标签
- 优化筛选条件判断逻辑,提高可读性

* refactor(SelectModelButton): 重构模型选择按钮的过滤逻辑

使用模型类型判断函数替代硬编码的过滤类型数组

* fix(MessageMenubar): 修复模型提及过滤逻辑,排除嵌入和重排模型

默认过滤条件现在会排除嵌入和重排模型,确保视觉模型检查时也应用此过滤

* feat(AssistantModelSettings): 添加模型过滤功能以排除嵌入和重排模型

* perf(SelectModelPopup): 使用useMemo优化模型列表计算性能

避免在每次渲染时重新计算模型列表,仅在modelFilter或providers变化时重新计算

* test(model): 将 isFreeModel 测试迁移到 model.test.ts 并添加 getModelTags 测试

* feat(types): 添加类型安全的对象键值对转换函数

* feat(模型选择弹窗): 优化标签筛选功能并添加已选标签显示

重构标签筛选逻辑,使用更简洁的预测函数配置方式
添加已选标签显示区域,提升用户操作体验
更新国际化文件添加"已选标签"翻译

* feat(i18n): 添加多语言翻译的"selected tags"字段和"code"模块

为过滤功能添加"selected tags"翻译字段
新增"code"模块的多语言翻译内容

* refactor(SelectModelPopup): 优化标签筛选逻辑和样式布局

移除重复的标签选择逻辑,合并为单一标签组件
调整筛选区域的布局和样式,简化界面结构
将PAGE_SIZE从11改为12以适应布局需求
This commit is contained in:
Phantom 2025-08-27 22:55:14 +08:00 committed by GitHub
parent 6376bbb9a7
commit aaa0eb7140
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 462 additions and 56 deletions

View File

@ -8,20 +8,19 @@ import {
} from '@renderer/config/models'
import i18n from '@renderer/i18n'
import { Model } from '@renderer/types'
import { isFreeModel } from '@renderer/utils'
import { isFreeModel } from '@renderer/utils/model'
import { FC, memo, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import CustomTag from './Tags/CustomTag'
import {
EmbeddingTag,
FreeTag,
ReasoningTag,
RerankerTag,
ToolsCallingTag,
VisionTag,
WebSearchTag
} from './Tags/ModelCapabilities'
} from './Tags/Model'
interface ModelTagsProps {
model: Model
@ -44,7 +43,6 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
showTooltip = true,
style
}) => {
const { t } = useTranslation()
const [shouldShowLabel, setShouldShowLabel] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const resizeObserver = useRef<ResizeObserver | null>(null)
@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC<ModelTagsProps> = ({
<ToolsCallingTag size={size} showTooltip={showTooltip} showLabel={shouldShowLabel} />
)}
{isEmbeddingModel(model) && <EmbeddingTag size={size} />}
{showFree && isFreeModel(model) && <CustomTag size={size} color="#7cb305" icon={t('models.type.free')} />}
{showFree && isFreeModel(model) && <FreeTag size={size} />}
{isRerankModel(model) && <RerankerTag size={size} />}
</Container>
)

View File

@ -1,17 +1,36 @@
import { PushpinOutlined } from '@ant-design/icons'
import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel'
import {
EmbeddingTag,
FreeTag,
ReasoningTag,
RerankerTag,
ToolsCallingTag,
VisionTag,
WebSearchTag
} from '@renderer/components/Tags/Model'
import { TopView } from '@renderer/components/TopView'
import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList'
import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import {
getModelLogo,
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model, Provider } from '@renderer/types'
import { Model, ModelTag, ModelType, objectEntries, Provider } from '@renderer/types'
import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
import { Avatar, Button, Divider, Empty, Modal, Tooltip } from 'antd'
import { getModelTags, isFreeModel } from '@renderer/utils/model'
import { Avatar, Button, Divider, Empty, Flex, Modal, Tooltip } from 'antd'
import { first, sortBy } from 'lodash'
import { SettingsIcon } from 'lucide-react'
import React, {
ReactNode,
startTransition,
useCallback,
useDeferredValue,
@ -25,22 +44,28 @@ import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
import SelectModelSearchBar from './searchbar'
import { FlatListItem } from './types'
import { FlatListItem, FlatListModel } from './types'
const PAGE_SIZE = 11
const PAGE_SIZE = 12
const ITEM_HEIGHT = 36
type ModelPredict = (m: Model) => boolean
interface PopupParams {
model?: Model
modelFilter?: (model: Model) => boolean
userFilterDisabled?: boolean
}
interface Props extends PopupParams {
resolve: (value: Model | undefined) => void
modelFilter?: (model: Model) => boolean
}
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
export type FilterType = Exclude<ModelType, 'text'> | 'free'
// const logger = loggerService.withContext('SelectModelPopup')
const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter, userFilterDisabled }) => {
const { t } = useTranslation()
const { providers } = useProviders()
const { pinnedModels, togglePinnedModel, loading } = usePinnedModels()
@ -49,6 +74,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const [_searchText, setSearchText] = useState('')
const searchText = useDeferredValue(_searchText)
const allModels: Model[] = useMemo(
() => providers.flatMap((p) => p.models).filter(modelFilter ?? (() => true)),
[modelFilter, providers]
)
// 当前选中的模型ID
const currentModelId = model ? getModelUniqId(model) : ''
@ -63,10 +93,99 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
})
}, [])
// 管理用户筛选状态
/** 从模型列表获取的需要显示的标签 */
const availableTags = useMemo(
() =>
objectEntries(getModelTags(allModels))
.filter(([, state]) => state)
.map(([tag]) => tag),
[allModels]
)
const filterConfig: Record<ModelTag, ModelPredict> = useMemo(
() => ({
vision: isVisionModel,
embedding: isEmbeddingModel,
reasoning: isReasoningModel,
function_calling: isFunctionCallingModel,
web_search: isWebSearchModel,
rerank: isRerankModel,
free: isFreeModel
}),
[]
)
/** 当前选择的标签表示是否启用特定tag的筛选 */
const [filterTags, setFilterTags] = useState<Record<ModelTag, boolean>>({
vision: false,
embedding: false,
reasoning: false,
function_calling: false,
web_search: false,
rerank: false,
free: false
})
const selectedFilterTags = useMemo(
() =>
objectEntries(filterTags)
.filter(([, state]) => state)
.map(([tag]) => tag),
[filterTags]
)
const userFilter = useCallback(
(model: Model) => {
return selectedFilterTags
.map((tag) => [tag, filterConfig[tag]] as const)
.reduce((prev, [tag, predict]) => {
return prev && (!filterTags[tag] || predict(model))
}, true)
},
[filterConfig, filterTags, selectedFilterTags]
)
const onClickTag = useCallback((type: ModelTag) => {
startTransition(() => {
setFilterTags((prev) => ({ ...prev, [type]: !prev[type] }))
})
}, [])
// 筛选项列表
const tagsItems: Record<ModelTag, ReactNode> = useMemo(
() => ({
vision: <VisionTag showLabel inactive={!filterTags.vision} onClick={() => onClickTag('vision')} />,
embedding: <EmbeddingTag inactive={!filterTags.embedding} onClick={() => onClickTag('embedding')} />,
reasoning: <ReasoningTag showLabel inactive={!filterTags.reasoning} onClick={() => onClickTag('reasoning')} />,
function_calling: (
<ToolsCallingTag
showLabel
inactive={!filterTags.function_calling}
onClick={() => onClickTag('function_calling')}
/>
),
web_search: <WebSearchTag showLabel inactive={!filterTags.web_search} onClick={() => onClickTag('web_search')} />,
rerank: <RerankerTag inactive={!filterTags.rerank} onClick={() => onClickTag('rerank')} />,
free: <FreeTag inactive={!filterTags.free} onClick={() => onClickTag('free')} />
}),
[
filterTags.embedding,
filterTags.free,
filterTags.function_calling,
filterTags.reasoning,
filterTags.rerank,
filterTags.vision,
filterTags.web_search,
onClickTag
]
)
// 要显示的筛选项
const displayedTags = useMemo(() => availableTags.map((tag) => tagsItems[tag]), [availableTags, tagsItems])
// 根据输入的文本筛选模型
const getFilteredModels = useCallback(
const searchFilter = useCallback(
(provider: Provider) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
let models = provider.models
if (searchText.trim()) {
models = filterModelsByKeywords(searchText, models, provider)
@ -79,7 +198,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 创建模型列表项
const createModelItem = useCallback(
(model: Model, provider: Provider, isPinned: boolean): FlatListItem => {
(model: Model, provider: Provider, isPinned: boolean): FlatListModel => {
const modelId = getModelUniqId(model)
const groupName = getFancyProviderName(provider)
@ -114,7 +233,11 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
const { listItems, modelItems } = useMemo(() => {
const items: FlatListItem[] = []
const pinnedModelIds = new Set(pinnedModels)
const finalModelFilter = modelFilter || (() => true)
const finalModelFilter = (model: Model) => {
const _userFilter = userFilterDisabled || userFilter(model)
const _modelFilter = modelFilter === undefined || modelFilter(model)
return _userFilter && _modelFilter
}
// 添加置顶模型分组(仅在无搜索文本时)
if (searchText.length === 0 && pinnedModelIds.size > 0) {
@ -140,7 +263,7 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
// 添加常规模型分组
providers.forEach((p) => {
const filteredModels = getFilteredModels(p)
const filteredModels = searchFilter(p)
.filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m)))
.filter(finalModelFilter)
@ -174,9 +297,20 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
})
// 获取可选择的模型项(过滤掉分组标题)
const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[]
const modelItems = items.filter((item) => item.type === 'model')
return { listItems: items, modelItems }
}, [pinnedModels, modelFilter, searchText.length, providers, createModelItem, t, getFilteredModels, resolve])
}, [
pinnedModels,
searchText.length,
providers,
userFilterDisabled,
userFilter,
modelFilter,
createModelItem,
t,
searchFilter,
resolve
])
const listHeight = useMemo(() => {
return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT
@ -374,7 +508,9 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
borderRadius: 20,
padding: 0,
overflow: 'hidden',
paddingBottom: 16
paddingBottom: 16,
// 需要稳定高度避免布局偏移
height: userFilterDisabled ? undefined : 530
},
body: {
maxHeight: 'inherit',
@ -386,6 +522,17 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
{/* 搜索框 */}
<SelectModelSearchBar onSearch={setSearchText} />
<Divider style={{ margin: 0, marginTop: 4, borderBlockStartWidth: 0.5 }} />
{!userFilterDisabled && (
<>
<FilterContainer>
<Flex wrap="wrap" gap={4}>
<FilterText>{t('models.filter.by_tag')}</FilterText>
{displayedTags.map((item) => item)}
</Flex>
</FilterContainer>
<Divider style={{ margin: 0, borderBlockStartWidth: 0.5 }} />
</>
)}
{listItems.length > 0 ? (
<ListContainer onMouseMove={() => !isMouseOver && setIsMouseOver(true)}>
@ -411,6 +558,16 @@ const PopupContainer: React.FC<Props> = ({ model, resolve, modelFilter }) => {
)
}
const FilterContainer = styled.div`
padding: 8px;
padding-left: 18px;
`
const FilterText = styled.span`
color: var(--color-text-3);
font-size: 12px;
`
const ListContainer = styled.div`
position: relative;
overflow: hidden;

View File

@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next'
import CustomTag, { CustomTagProps } from '../CustomTag'
type Props = {
size?: number
showTooltip?: boolean
} & Omit<CustomTagProps, 'size' | 'tooltip' | 'icon' | 'color' | 'children'>
export const FreeTag = ({ size, showTooltip, ...restProps }: Props) => {
const { t } = useTranslation()
return (
<CustomTag
size={size}
color="#7cb305"
icon={t('models.type.free')}
tooltip={showTooltip ? t('models.type.free') : undefined}
{...restProps}></CustomTag>
)
}

View File

@ -1,8 +1,9 @@
import { EmbeddingTag } from './EmbeddingTag'
import { FreeTag } from './FreeTag'
import { ReasoningTag } from './ReasoningTag'
import { RerankerTag } from './RerankerTag'
import { ToolsCallingTag } from './ToolsCallingTag'
import { VisionTag } from './VisionTag'
import { WebSearchTag } from './WebSearchTag'
export { EmbeddingTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }
export { EmbeddingTag, FreeTag, ReasoningTag, RerankerTag, ToolsCallingTag, VisionTag, WebSearchTag }

View File

@ -1521,6 +1521,10 @@
"embedding_model": "Embedding Model",
"embedding_model_tooltip": "Add in Settings->Model Provider->Manage",
"enable_tool_use": "Enable Tool Use",
"filter": {
"by_tag": "Filter by tag",
"selected": "Selected tags"
},
"function_calling": "Function Calling",
"no_matches": "No models available",
"parameter_name": "Parameter Name",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "埋め込み模型",
"embedding_model_tooltip": "設定->モデルサービス->管理で追加",
"enable_tool_use": "ツール呼び出し",
"filter": {
"by_tag": "タグでフィルター",
"selected": "選択済みのタグ"
},
"function_calling": "関数呼び出し",
"no_matches": "利用可能なモデルがありません",
"parameter_name": "パラメータ名",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "Встраиваемые модели",
"embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление",
"enable_tool_use": "Вызов инструмента",
"filter": {
"by_tag": "Фильтрация по тегам",
"selected": "Выбранные теги"
},
"function_calling": "Вызов функции",
"no_matches": "Нет доступных моделей",
"parameter_name": "Имя параметра",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "嵌入模型",
"embedding_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加",
"enable_tool_use": "工具调用",
"filter": {
"by_tag": "按标签筛选",
"selected": "已选标签"
},
"function_calling": "函数调用",
"no_matches": "无可用模型",
"parameter_name": "参数名称",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "嵌入模型",
"embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增",
"enable_tool_use": "工具調用",
"filter": {
"by_tag": "按標籤篩選",
"selected": "已選標籤"
},
"function_calling": "函數調用",
"no_matches": "無可用模型",
"parameter_name": "參數名稱",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "Μοντέλο ενσωμάτωσης",
"embedding_model_tooltip": "Κάντε κλικ στο κουμπί Διαχείριση στο παράθυρο Ρυθμίσεις -> Υπηρεσία Μοντέλων",
"enable_tool_use": "Ενεργοποίηση κλήσης εργαλείου",
"filter": {
"by_tag": "Φιλτράρισμα κατά ετικέτα",
"selected": "Επιλεγμένη ετικέτα"
},
"function_calling": "Ξεχωριστική Κλήση Συναρτήσεων",
"no_matches": "Δεν υπάρχουν διαθέσιμα μοντέλα",
"parameter_name": "Όνομα παραμέτρου",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "Modelo de inmersión",
"embedding_model_tooltip": "Haga clic en el botón Administrar en Configuración->Servicio de modelos para agregar",
"enable_tool_use": "Habilitar uso de herramientas",
"filter": {
"by_tag": "Filtrar por etiqueta",
"selected": "Etiquetas seleccionadas"
},
"function_calling": "Llamada a función",
"no_matches": "No hay modelos disponibles",
"parameter_name": "Nombre del parámetro",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "Modèle d'incrustation",
"embedding_model_tooltip": "Cliquez sur le bouton Gérer dans Paramètres -> Services de modèles pour ajouter",
"enable_tool_use": "Appel d'outil",
"filter": {
"by_tag": "Filtrer par étiquette",
"selected": "Étiquette sélectionnée"
},
"function_calling": "Appel de fonction",
"no_matches": "Aucun modèle disponible",
"parameter_name": "Nom du paramètre",

View File

@ -1521,6 +1521,10 @@
"embedding_model": "Modelo de inscrição",
"embedding_model_tooltip": "Clique no botão Gerenciar em Configurações -> Serviço de modelos para adicionar",
"enable_tool_use": "Chamada de ferramentas",
"filter": {
"by_tag": "Filtrar por etiqueta",
"selected": "Etiqueta selecionada"
},
"function_calling": "Chamada de função",
"no_matches": "Nenhum modelo disponível",
"parameter_name": "Nome do parâmetro",

View File

@ -4,7 +4,7 @@ import { CopyIcon, DeleteIcon, EditIcon, RefreshIcon } from '@renderer/component
import ObsidianExportPopup from '@renderer/components/Popups/ObsidianExportPopup'
import SaveToKnowledgePopup from '@renderer/components/Popups/SaveToKnowledgePopup'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { isVisionModel } from '@renderer/config/models'
import { isEmbeddingModel, isRerankModel, isVisionModel } from '@renderer/config/models'
import { useMessageEditing } from '@renderer/context/MessageEditingContext'
import { useChatContext } from '@renderer/hooks/useChatContext'
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
@ -382,8 +382,10 @@ const MessageMenubar: FC<Props> = (props) => {
// 按条件筛选能够提及的模型该函数仅在isAssistantMessage时会用到
const mentionModelFilter = useMemo(() => {
const defaultFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
if (!isAssistantMessage) {
return () => true
return defaultFilter
}
const state = store.getState()
const topicMessages: Message[] = selectMessagesForTopic(state, topic.id)
@ -393,7 +395,7 @@ const MessageMenubar: FC<Props> = (props) => {
})
// 无关联用户消息时,默认返回所有模型
if (!relatedUserMessage) {
return () => true
return defaultFilter
}
const relatedUserMessageBlocks = relatedUserMessage.blocks.map((msgBlockId) =>
@ -401,13 +403,13 @@ const MessageMenubar: FC<Props> = (props) => {
)
if (!relatedUserMessageBlocks) {
return () => true
return defaultFilter
}
if (relatedUserMessageBlocks.some((block) => block && block.type === MessageBlockType.IMAGE)) {
return (m: Model) => isVisionModel(m)
return (m: Model) => isVisionModel(m) && defaultFilter(m)
} else {
return () => true
return defaultFilter
}
}, [isAssistantMessage, message.askId, topic.id])

View File

@ -1,10 +1,10 @@
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import { isLocalAi } from '@renderer/config/env'
import { isWebSearchModel } from '@renderer/config/models'
import { isEmbeddingModel, isRerankModel, isWebSearchModel } from '@renderer/config/models'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { getProviderName } from '@renderer/services/ProviderService'
import { Assistant } from '@renderer/types'
import { Assistant, Model } from '@renderer/types'
import { Button } from 'antd'
import { ChevronsUpDown } from 'lucide-react'
import { FC, useEffect, useRef } from 'react'
@ -20,9 +20,11 @@ const SelectModelButton: FC<Props> = ({ assistant }) => {
const { t } = useTranslation()
const timerRef = useRef<NodeJS.Timeout>(undefined)
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
const onSelectModel = async (event: React.MouseEvent<HTMLElement>) => {
event.currentTarget.blur()
const selectedModel = await SelectModelPopup.show({ model })
const selectedModel = await SelectModelPopup.show({ model, modelFilter })
if (selectedModel) {
// 避免更新数据造成关闭弹框的卡顿
clearTimeout(timerRef.current)

View File

@ -6,9 +6,10 @@ import { HStack } from '@renderer/components/Layout'
import SelectModelPopup from '@renderer/components/Popups/SelectModelPopup'
import Selector from '@renderer/components/Selector'
import { DEFAULT_CONTEXTCOUNT, DEFAULT_TEMPERATURE, MAX_CONTEXT_COUNT } from '@renderer/config/constant'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { useTimer } from '@renderer/hooks/useTimer'
import { SettingRow } from '@renderer/pages/settings'
import { Assistant, AssistantSettingCustomParameters, AssistantSettings } from '@renderer/types'
import { Assistant, AssistantSettingCustomParameters, AssistantSettings, Model } from '@renderer/types'
import { modalConfirm } from '@renderer/utils'
import { Button, Col, Divider, Input, InputNumber, Row, Select, Slider, Switch, Tooltip } from 'antd'
import { isNull } from 'lodash'
@ -179,10 +180,11 @@ const AssistantModelSettings: FC<Props> = ({ assistant, updateAssistant, updateA
toolUseMode: 'prompt'
})
}
const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model)
const onSelectModel = useCallback(async () => {
const currentModel = defaultModel ? assistant?.model : undefined
const selectedModel = await SelectModelPopup.show({ model: currentModel })
const selectedModel = await SelectModelPopup.show({ model: currentModel, modelFilter })
if (selectedModel) {
setDefaultModel(selectedModel)
updateAssistant({

View File

@ -6,7 +6,7 @@ import {
ToolsCallingTag,
VisionTag,
WebSearchTag
} from '@renderer/components/Tags/ModelCapabilities'
} from '@renderer/components/Tags/Model'
import WarnTooltip from '@renderer/components/WarnTooltip'
import { endpointTypeOptions } from '@renderer/config/endpointTypes'
import {

View File

@ -18,7 +18,8 @@ import NewApiAddModelPopup from '@renderer/pages/settings/ProviderSettings/Model
import NewApiBatchAddModelPopup from '@renderer/pages/settings/ProviderSettings/ModelList/NewApiBatchAddModelPopup'
import { fetchModels } from '@renderer/services/ApiService'
import { Model, Provider } from '@renderer/types'
import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName, isFreeModel } from '@renderer/utils'
import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName } from '@renderer/utils'
import { isFreeModel } from '@renderer/utils/model'
import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd'
import Input from 'antd/es/input/Input'
import { groupBy, isEmpty, uniqBy } from 'lodash'

View File

@ -338,6 +338,8 @@ export type ProviderType =
export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search' | 'rerank'
export type ModelTag = Exclude<ModelType, 'text'> | 'free'
export type EndpointType = 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank'
export type ModelPricing = {
@ -1163,6 +1165,48 @@ export interface MemoryListOptions extends MemoryEntity {
export interface MemoryDeleteAllOptions extends MemoryEntity {}
// ========================================================================
/**
*
* @param obj -
* @returns
* @example
* ```ts
* const obj = { foo: 1, bar: 'hello' };
* const keys = objectKeys(obj); // ['foo', 'bar']
* ```
*/
export function objectKeys<T extends object>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[]
}
/**
*
* @template T -
* @param obj -
* @returns
* @example
* const obj = { name: 'John', age: 30 };
* const entries = objectEntries(obj); // [['name', 'John'], ['age', 30]]
*/
export function objectEntries<T extends object>(obj: T): [keyof T, T[keyof T]][] {
return Object.entries(obj) as [keyof T, T[keyof T]][]
}
/**
*
* @template T - stringnumber或symbol
* @param obj -
* @returns
* @example
* const obj = { name: 'John', age: 30 };
* const entries = objectEntriesStrict(obj); // [['name', string], ['age', number]]
*/
export function objectEntriesStrict<T extends Record<string | number | symbol, unknown>>(
obj: T
): { [K in keyof T]: [K, T[K]] }[keyof T][] {
return Object.entries(obj) as { [K in keyof T]: [K, T[K]] }[keyof T][]
}
/**
* T中指定的所有键U
* string类型的键U

View File

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { runAsyncFunction } from '../index'
import { hasPath, isFreeModel, isValidProxyUrl, removeQuotes, removeSpecialCharacters } from '../index'
import { hasPath, isValidProxyUrl, removeQuotes, removeSpecialCharacters } from '../index'
describe('Unclassified Utils', () => {
describe('runAsyncFunction', () => {
@ -24,22 +24,6 @@ describe('Unclassified Utils', () => {
})
})
describe('isFreeModel', () => {
const base = { provider: '', group: '' }
it('should return true if id or name contains "free" (case-insensitive)', () => {
expect(isFreeModel({ id: 'free-model', name: 'test', ...base })).toBe(true)
expect(isFreeModel({ id: 'model', name: 'FreePlan', ...base })).toBe(true)
expect(isFreeModel({ id: 'model', name: 'notfree', ...base })).toBe(true)
expect(isFreeModel({ id: 'model', name: 'test', ...base })).toBe(false)
})
it('should handle empty id or name', () => {
expect(isFreeModel({ id: '', name: 'free', ...base })).toBe(true)
expect(isFreeModel({ id: 'free', name: '', ...base })).toBe(true)
expect(isFreeModel({ id: '', name: '', ...base })).toBe(false)
})
})
describe('removeQuotes', () => {
it('should remove all single and double quotes', () => {
expect(removeQuotes('"hello"')).toBe('hello')

View File

@ -0,0 +1,95 @@
import { Model, ModelTag } from '@renderer/types'
import { describe, expect, it, vi } from 'vitest'
import { getModelTags, isFreeModel } from '../model'
// Mock the model checking functions from @renderer/config/models
vi.mock('@renderer/config/models', () => ({
isVisionModel: vi.fn().mockImplementation((m: Model) => m.id === 'vision'),
isEmbeddingModel: vi.fn().mockImplementation((m: Model) => m.id === 'embedding'),
isReasoningModel: vi.fn().mockImplementation((m: Model) => m.id === 'reasoning'),
isFunctionCallingModel: vi.fn().mockImplementation((m: Model) => m.id === 'tool'),
isWebSearchModel: vi.fn().mockImplementation((m: Model) => m.id === 'search'),
isRerankModel: vi.fn().mockImplementation((m: Model) => m.id === 'rerank')
}))
describe('model', () => {
describe('isFreeModel', () => {
const base = { provider: '', group: '' }
it('should return true if id or name contains "free" (case-insensitive)', () => {
expect(isFreeModel({ id: 'free-model', name: 'test', ...base })).toBe(true)
expect(isFreeModel({ id: 'model', name: 'FreePlan', ...base })).toBe(true)
expect(isFreeModel({ id: 'model', name: 'notfree', ...base })).toBe(true)
expect(isFreeModel({ id: 'model', name: 'test', ...base })).toBe(false)
})
it('should handle empty id or name', () => {
expect(isFreeModel({ id: '', name: 'free', ...base })).toBe(true)
expect(isFreeModel({ id: 'free', name: '', ...base })).toBe(true)
expect(isFreeModel({ id: '', name: '', ...base })).toBe(false)
})
})
describe('getModelTags', () => {
const baseModel: Model = {
id: 'test',
provider: 'test',
group: 'test',
name: 'test'
}
const visionModel: Model = {
...baseModel,
id: 'vision'
}
const embeddingModel: Model = {
...baseModel,
id: 'embedding'
}
const reasoningModel: Model = {
...baseModel,
id: 'reasoning'
}
const searchModel: Model = {
...baseModel,
id: 'search'
}
const rerankModel: Model = {
...baseModel,
id: 'rerank'
}
const toolModel: Model = {
...baseModel,
id: 'tool'
}
const freeModel: Model = {
...baseModel,
id: 'free'
}
it('should get correct tags', () => {
const models_1 = [visionModel, embeddingModel, reasoningModel, searchModel]
const expected_1: Record<ModelTag, boolean> = {
vision: true,
embedding: true,
reasoning: true,
rerank: false,
free: false,
function_calling: false,
web_search: true
}
expect(getModelTags(models_1)).toStrictEqual(expected_1)
const models_2 = [rerankModel, toolModel, freeModel]
const expected_2: Record<ModelTag, boolean> = {
vision: false,
embedding: false,
reasoning: false,
rerank: true,
free: true,
function_calling: true,
web_search: false
}
expect(getModelTags(models_2)).toStrictEqual(expected_2)
})
})
})

View File

@ -57,10 +57,6 @@ export const waitAsyncFunction = (
export const uuid = () => uuidv4()
export function isFreeModel(model: Model) {
return (model.id + model.name).toLocaleLowerCase().includes('free')
}
/**
*
* @param {any} error

View File

@ -0,0 +1,68 @@
import {
isEmbeddingModel,
isFunctionCallingModel,
isReasoningModel,
isRerankModel,
isVisionModel,
isWebSearchModel
} from '@renderer/config/models'
import { Model, ModelTag, objectKeys } from '@renderer/types'
/**
*
* @param models -
* @returns
*/
export const getModelTags = (models: Model[]): Record<ModelTag, boolean> => {
const result: Record<ModelTag, boolean> = {
vision: false,
embedding: false,
reasoning: false,
function_calling: false,
web_search: false,
rerank: false,
free: false
}
const total = objectKeys(result).length
let satisfied = 0
for (const model of models) {
// 如果所有标签都已满足,提前退出
if (satisfied === total) break
if (!result.vision && isVisionModel(model)) {
satisfied += 1
result.vision = true
}
if (!result.embedding && isEmbeddingModel(model)) {
satisfied += 1
result.embedding = true
}
if (!result.reasoning && isReasoningModel(model)) {
satisfied += 1
result.reasoning = true
}
if (!result.function_calling && isFunctionCallingModel(model)) {
satisfied += 1
result.function_calling = true
}
if (!result.web_search && isWebSearchModel(model)) {
satisfied += 1
result.web_search = true
}
if (!result.rerank && isRerankModel(model)) {
satisfied += 1
result.rerank = true
}
if (!result.free && isFreeModel(model)) {
satisfied += 1
result.free = true
}
}
return result
}
export function isFreeModel(model: Model) {
return (model.id + model.name).toLocaleLowerCase().includes('free')
}