From aaa0eb7140358241c38feda4017f9eae21cb75fd Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Wed, 27 Aug 2025 22:55:14 +0800 Subject: [PATCH] feat: user filter models (#8953) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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以适应布局需求 --- .../src/components/ModelTagsWithLabel.tsx | 10 +- .../Popups/SelectModelPopup/popup.tsx | 187 ++++++++++++++++-- .../EmbeddingTag.tsx | 0 .../src/components/Tags/Model/FreeTag.tsx | 20 ++ .../ReasoningTag.tsx | 0 .../RerankerTag.tsx | 0 .../ToolsCallingTag.tsx | 0 .../VisionTag.tsx | 0 .../WebSearchTag.tsx | 0 .../{ModelCapabilities => Model}/index.ts | 3 +- src/renderer/src/i18n/locales/en-us.json | 4 + src/renderer/src/i18n/locales/ja-jp.json | 4 + src/renderer/src/i18n/locales/ru-ru.json | 4 + src/renderer/src/i18n/locales/zh-cn.json | 4 + src/renderer/src/i18n/locales/zh-tw.json | 4 + src/renderer/src/i18n/translate/el-gr.json | 4 + src/renderer/src/i18n/translate/es-es.json | 4 + src/renderer/src/i18n/translate/fr-fr.json | 4 + src/renderer/src/i18n/translate/pt-pt.json | 4 + .../pages/home/Messages/MessageMenubar.tsx | 14 +- .../home/components/SelectModelButton.tsx | 8 +- .../AssistantModelSettings.tsx | 6 +- .../EditModelPopup/ModelEditContent.tsx | 2 +- .../ModelList/ManageModelsPopup.tsx | 3 +- src/renderer/src/types/index.ts | 44 +++++ .../src/utils/__tests__/index.test.ts | 18 +- .../src/utils/__tests__/model.test.ts | 95 +++++++++ src/renderer/src/utils/index.ts | 4 - src/renderer/src/utils/model.ts | 68 +++++++ 29 files changed, 462 insertions(+), 56 deletions(-) rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/EmbeddingTag.tsx (100%) create mode 100644 src/renderer/src/components/Tags/Model/FreeTag.tsx rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/ReasoningTag.tsx (100%) rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/RerankerTag.tsx (100%) rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/ToolsCallingTag.tsx (100%) rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/VisionTag.tsx (100%) rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/WebSearchTag.tsx (100%) rename src/renderer/src/components/Tags/{ModelCapabilities => Model}/index.ts (66%) create mode 100644 src/renderer/src/utils/__tests__/model.test.ts create mode 100644 src/renderer/src/utils/model.ts diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index 3da6ccfc8d..263292dcad 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -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 = ({ showTooltip = true, style }) => { - const { t } = useTranslation() const [shouldShowLabel, setShouldShowLabel] = useState(false) const containerRef = useRef(null) const resizeObserver = useRef(null) @@ -86,7 +84,7 @@ const ModelTagsWithLabel: FC = ({ )} {isEmbeddingModel(model) && } - {showFree && isFreeModel(model) && } + {showFree && isFreeModel(model) && } {isRerankModel(model) && } ) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index b45cb0a63c..ef616e71ac 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -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 = ({ model, resolve, modelFilter }) => { +export type FilterType = Exclude | 'free' + +// const logger = loggerService.withContext('SelectModelPopup') + +const PopupContainer: React.FC = ({ model, resolve, modelFilter, userFilterDisabled }) => { const { t } = useTranslation() const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() @@ -49,6 +74,11 @@ const PopupContainer: React.FC = ({ 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 = ({ model, resolve, modelFilter }) => { }) }, []) + // 管理用户筛选状态 + /** 从模型列表获取的需要显示的标签 */ + const availableTags = useMemo( + () => + objectEntries(getModelTags(allModels)) + .filter(([, state]) => state) + .map(([tag]) => tag), + [allModels] + ) + + const filterConfig: Record = useMemo( + () => ({ + vision: isVisionModel, + embedding: isEmbeddingModel, + reasoning: isReasoningModel, + function_calling: isFunctionCallingModel, + web_search: isWebSearchModel, + rerank: isRerankModel, + free: isFreeModel + }), + [] + ) + + /** 当前选择的标签,表示是否启用特定tag的筛选 */ + const [filterTags, setFilterTags] = useState>({ + 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 = useMemo( + () => ({ + vision: onClickTag('vision')} />, + embedding: onClickTag('embedding')} />, + reasoning: onClickTag('reasoning')} />, + function_calling: ( + onClickTag('function_calling')} + /> + ), + web_search: onClickTag('web_search')} />, + rerank: onClickTag('rerank')} />, + free: 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ model, resolve, modelFilter }) => { {/* 搜索框 */} + {!userFilterDisabled && ( + <> + + + {t('models.filter.by_tag')} + {displayedTags.map((item) => item)} + + + + + )} {listItems.length > 0 ? ( !isMouseOver && setIsMouseOver(true)}> @@ -411,6 +558,16 @@ const PopupContainer: React.FC = ({ 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; diff --git a/src/renderer/src/components/Tags/ModelCapabilities/EmbeddingTag.tsx b/src/renderer/src/components/Tags/Model/EmbeddingTag.tsx similarity index 100% rename from src/renderer/src/components/Tags/ModelCapabilities/EmbeddingTag.tsx rename to src/renderer/src/components/Tags/Model/EmbeddingTag.tsx diff --git a/src/renderer/src/components/Tags/Model/FreeTag.tsx b/src/renderer/src/components/Tags/Model/FreeTag.tsx new file mode 100644 index 0000000000..a046449dcf --- /dev/null +++ b/src/renderer/src/components/Tags/Model/FreeTag.tsx @@ -0,0 +1,20 @@ +import { useTranslation } from 'react-i18next' + +import CustomTag, { CustomTagProps } from '../CustomTag' + +type Props = { + size?: number + showTooltip?: boolean +} & Omit + +export const FreeTag = ({ size, showTooltip, ...restProps }: Props) => { + const { t } = useTranslation() + return ( + + ) +} diff --git a/src/renderer/src/components/Tags/ModelCapabilities/ReasoningTag.tsx b/src/renderer/src/components/Tags/Model/ReasoningTag.tsx similarity index 100% rename from src/renderer/src/components/Tags/ModelCapabilities/ReasoningTag.tsx rename to src/renderer/src/components/Tags/Model/ReasoningTag.tsx diff --git a/src/renderer/src/components/Tags/ModelCapabilities/RerankerTag.tsx b/src/renderer/src/components/Tags/Model/RerankerTag.tsx similarity index 100% rename from src/renderer/src/components/Tags/ModelCapabilities/RerankerTag.tsx rename to src/renderer/src/components/Tags/Model/RerankerTag.tsx diff --git a/src/renderer/src/components/Tags/ModelCapabilities/ToolsCallingTag.tsx b/src/renderer/src/components/Tags/Model/ToolsCallingTag.tsx similarity index 100% rename from src/renderer/src/components/Tags/ModelCapabilities/ToolsCallingTag.tsx rename to src/renderer/src/components/Tags/Model/ToolsCallingTag.tsx diff --git a/src/renderer/src/components/Tags/ModelCapabilities/VisionTag.tsx b/src/renderer/src/components/Tags/Model/VisionTag.tsx similarity index 100% rename from src/renderer/src/components/Tags/ModelCapabilities/VisionTag.tsx rename to src/renderer/src/components/Tags/Model/VisionTag.tsx diff --git a/src/renderer/src/components/Tags/ModelCapabilities/WebSearchTag.tsx b/src/renderer/src/components/Tags/Model/WebSearchTag.tsx similarity index 100% rename from src/renderer/src/components/Tags/ModelCapabilities/WebSearchTag.tsx rename to src/renderer/src/components/Tags/Model/WebSearchTag.tsx diff --git a/src/renderer/src/components/Tags/ModelCapabilities/index.ts b/src/renderer/src/components/Tags/Model/index.ts similarity index 66% rename from src/renderer/src/components/Tags/ModelCapabilities/index.ts rename to src/renderer/src/components/Tags/Model/index.ts index 990f66c580..b8bf424e36 100644 --- a/src/renderer/src/components/Tags/ModelCapabilities/index.ts +++ b/src/renderer/src/components/Tags/Model/index.ts @@ -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 } diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 19240331b4..9bc790caeb 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index d56c0590b3..c30c00a617 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1521,6 +1521,10 @@ "embedding_model": "埋め込み模型", "embedding_model_tooltip": "設定->モデルサービス->管理で追加", "enable_tool_use": "ツール呼び出し", + "filter": { + "by_tag": "タグでフィルター", + "selected": "選択済みのタグ" + }, "function_calling": "関数呼び出し", "no_matches": "利用可能なモデルがありません", "parameter_name": "パラメータ名", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 43922086c3..f9203453a3 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1521,6 +1521,10 @@ "embedding_model": "Встраиваемые модели", "embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление", "enable_tool_use": "Вызов инструмента", + "filter": { + "by_tag": "Фильтрация по тегам", + "selected": "Выбранные теги" + }, "function_calling": "Вызов функции", "no_matches": "Нет доступных моделей", "parameter_name": "Имя параметра", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index d90ab78caa..a80c3e8826 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1521,6 +1521,10 @@ "embedding_model": "嵌入模型", "embedding_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加", "enable_tool_use": "工具调用", + "filter": { + "by_tag": "按标签筛选", + "selected": "已选标签" + }, "function_calling": "函数调用", "no_matches": "无可用模型", "parameter_name": "参数名称", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 6bd3bcf2b9..ffb331f603 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1521,6 +1521,10 @@ "embedding_model": "嵌入模型", "embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增", "enable_tool_use": "工具調用", + "filter": { + "by_tag": "按標籤篩選", + "selected": "已選標籤" + }, "function_calling": "函數調用", "no_matches": "無可用模型", "parameter_name": "參數名稱", diff --git a/src/renderer/src/i18n/translate/el-gr.json b/src/renderer/src/i18n/translate/el-gr.json index c91c73d6e0..572b816e7d 100644 --- a/src/renderer/src/i18n/translate/el-gr.json +++ b/src/renderer/src/i18n/translate/el-gr.json @@ -1521,6 +1521,10 @@ "embedding_model": "Μοντέλο ενσωμάτωσης", "embedding_model_tooltip": "Κάντε κλικ στο κουμπί Διαχείριση στο παράθυρο Ρυθμίσεις -> Υπηρεσία Μοντέλων", "enable_tool_use": "Ενεργοποίηση κλήσης εργαλείου", + "filter": { + "by_tag": "Φιλτράρισμα κατά ετικέτα", + "selected": "Επιλεγμένη ετικέτα" + }, "function_calling": "Ξεχωριστική Κλήση Συναρτήσεων", "no_matches": "Δεν υπάρχουν διαθέσιμα μοντέλα", "parameter_name": "Όνομα παραμέτρου", diff --git a/src/renderer/src/i18n/translate/es-es.json b/src/renderer/src/i18n/translate/es-es.json index ec6c42f7cc..310b4a71da 100644 --- a/src/renderer/src/i18n/translate/es-es.json +++ b/src/renderer/src/i18n/translate/es-es.json @@ -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", diff --git a/src/renderer/src/i18n/translate/fr-fr.json b/src/renderer/src/i18n/translate/fr-fr.json index e3166691c9..27501b7d8f 100644 --- a/src/renderer/src/i18n/translate/fr-fr.json +++ b/src/renderer/src/i18n/translate/fr-fr.json @@ -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", diff --git a/src/renderer/src/i18n/translate/pt-pt.json b/src/renderer/src/i18n/translate/pt-pt.json index fd06442a63..b0623b2dee 100644 --- a/src/renderer/src/i18n/translate/pt-pt.json +++ b/src/renderer/src/i18n/translate/pt-pt.json @@ -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", diff --git a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx index 0c048c4c44..1ef3f99b9c 100644 --- a/src/renderer/src/pages/home/Messages/MessageMenubar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageMenubar.tsx @@ -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) => { // 按条件筛选能够提及的模型,该函数仅在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) => { }) // 无关联用户消息时,默认返回所有模型 if (!relatedUserMessage) { - return () => true + return defaultFilter } const relatedUserMessageBlocks = relatedUserMessage.blocks.map((msgBlockId) => @@ -401,13 +403,13 @@ const MessageMenubar: FC = (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]) diff --git a/src/renderer/src/pages/home/components/SelectModelButton.tsx b/src/renderer/src/pages/home/components/SelectModelButton.tsx index 0d13a2d592..a912fb78e5 100644 --- a/src/renderer/src/pages/home/components/SelectModelButton.tsx +++ b/src/renderer/src/pages/home/components/SelectModelButton.tsx @@ -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 = ({ assistant }) => { const { t } = useTranslation() const timerRef = useRef(undefined) + const modelFilter = (model: Model) => !isEmbeddingModel(model) && !isRerankModel(model) + const onSelectModel = async (event: React.MouseEvent) => { event.currentTarget.blur() - const selectedModel = await SelectModelPopup.show({ model }) + const selectedModel = await SelectModelPopup.show({ model, modelFilter }) if (selectedModel) { // 避免更新数据造成关闭弹框的卡顿 clearTimeout(timerRef.current) diff --git a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx index 54c90a6b85..6d3ab76bd2 100644 --- a/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx +++ b/src/renderer/src/pages/settings/AssistantSettings/AssistantModelSettings.tsx @@ -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 = ({ 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({ diff --git a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx index 7dfa6070e1..fce4ad0637 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/EditModelPopup/ModelEditContent.tsx @@ -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 { diff --git a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx index 6f32841434..036894dbee 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ModelList/ManageModelsPopup.tsx @@ -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' diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index ab7905fcee..1372d3aaba 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -338,6 +338,8 @@ export type ProviderType = export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'function_calling' | 'web_search' | 'rerank' +export type ModelTag = Exclude | '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(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(obj: T): [keyof T, T[keyof T]][] { + return Object.entries(obj) as [keyof T, T[keyof T]][] +} + +/** + * 将对象转换为键值对数组,提供更严格的类型检查 + * @template T - 对象类型,键必须是string、number或symbol,值可以是任意类型 + * @param obj - 要转换的对象 + * @returns 键值对数组,每个元素是一个包含键和值的元组,类型完全对应原对象的键值类型 + * @example + * const obj = { name: 'John', age: 30 }; + * const entries = objectEntriesStrict(obj); // [['name', string], ['age', number]] + */ +export function objectEntriesStrict>( + 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 diff --git a/src/renderer/src/utils/__tests__/index.test.ts b/src/renderer/src/utils/__tests__/index.test.ts index 2ac46975e3..7e3d6b2593 100644 --- a/src/renderer/src/utils/__tests__/index.test.ts +++ b/src/renderer/src/utils/__tests__/index.test.ts @@ -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') diff --git a/src/renderer/src/utils/__tests__/model.test.ts b/src/renderer/src/utils/__tests__/model.test.ts new file mode 100644 index 0000000000..4a854bf04a --- /dev/null +++ b/src/renderer/src/utils/__tests__/model.test.ts @@ -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 = { + 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 = { + vision: false, + embedding: false, + reasoning: false, + rerank: true, + free: true, + function_calling: true, + web_search: false + } + expect(getModelTags(models_2)).toStrictEqual(expected_2) + }) + }) +}) diff --git a/src/renderer/src/utils/index.ts b/src/renderer/src/utils/index.ts index 34e146e33b..a09d9aa7a8 100644 --- a/src/renderer/src/utils/index.ts +++ b/src/renderer/src/utils/index.ts @@ -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 错误对象或字符串 diff --git a/src/renderer/src/utils/model.ts b/src/renderer/src/utils/model.ts new file mode 100644 index 0000000000..72eab6787a --- /dev/null +++ b/src/renderer/src/utils/model.ts @@ -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 => { + const result: Record = { + 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') +}