mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 03:10:08 +08:00
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:
parent
6376bbb9a7
commit
aaa0eb7140
@ -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>
|
||||
)
|
||||
|
||||
@ -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;
|
||||
|
||||
20
src/renderer/src/components/Tags/Model/FreeTag.tsx
Normal file
20
src/renderer/src/components/Tags/Model/FreeTag.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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 }
|
||||
@ -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",
|
||||
|
||||
@ -1521,6 +1521,10 @@
|
||||
"embedding_model": "埋め込み模型",
|
||||
"embedding_model_tooltip": "設定->モデルサービス->管理で追加",
|
||||
"enable_tool_use": "ツール呼び出し",
|
||||
"filter": {
|
||||
"by_tag": "タグでフィルター",
|
||||
"selected": "選択済みのタグ"
|
||||
},
|
||||
"function_calling": "関数呼び出し",
|
||||
"no_matches": "利用可能なモデルがありません",
|
||||
"parameter_name": "パラメータ名",
|
||||
|
||||
@ -1521,6 +1521,10 @@
|
||||
"embedding_model": "Встраиваемые модели",
|
||||
"embedding_model_tooltip": "Добавьте в настройки->модель сервиса->управление",
|
||||
"enable_tool_use": "Вызов инструмента",
|
||||
"filter": {
|
||||
"by_tag": "Фильтрация по тегам",
|
||||
"selected": "Выбранные теги"
|
||||
},
|
||||
"function_calling": "Вызов функции",
|
||||
"no_matches": "Нет доступных моделей",
|
||||
"parameter_name": "Имя параметра",
|
||||
|
||||
@ -1521,6 +1521,10 @@
|
||||
"embedding_model": "嵌入模型",
|
||||
"embedding_model_tooltip": "在设置 -> 模型服务中点击管理按钮添加",
|
||||
"enable_tool_use": "工具调用",
|
||||
"filter": {
|
||||
"by_tag": "按标签筛选",
|
||||
"selected": "已选标签"
|
||||
},
|
||||
"function_calling": "函数调用",
|
||||
"no_matches": "无可用模型",
|
||||
"parameter_name": "参数名称",
|
||||
|
||||
@ -1521,6 +1521,10 @@
|
||||
"embedding_model": "嵌入模型",
|
||||
"embedding_model_tooltip": "在設定 -> 模型服務中點選管理按鈕新增",
|
||||
"enable_tool_use": "工具調用",
|
||||
"filter": {
|
||||
"by_tag": "按標籤篩選",
|
||||
"selected": "已選標籤"
|
||||
},
|
||||
"function_calling": "函數調用",
|
||||
"no_matches": "無可用模型",
|
||||
"parameter_name": "參數名稱",
|
||||
|
||||
@ -1521,6 +1521,10 @@
|
||||
"embedding_model": "Μοντέλο ενσωμάτωσης",
|
||||
"embedding_model_tooltip": "Κάντε κλικ στο κουμπί Διαχείριση στο παράθυρο Ρυθμίσεις -> Υπηρεσία Μοντέλων",
|
||||
"enable_tool_use": "Ενεργοποίηση κλήσης εργαλείου",
|
||||
"filter": {
|
||||
"by_tag": "Φιλτράρισμα κατά ετικέτα",
|
||||
"selected": "Επιλεγμένη ετικέτα"
|
||||
},
|
||||
"function_calling": "Ξεχωριστική Κλήση Συναρτήσεων",
|
||||
"no_matches": "Δεν υπάρχουν διαθέσιμα μοντέλα",
|
||||
"parameter_name": "Όνομα παραμέτρου",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 - 对象类型,键必须是string、number或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
|
||||
|
||||
@ -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')
|
||||
|
||||
95
src/renderer/src/utils/__tests__/model.test.ts
Normal file
95
src/renderer/src/utils/__tests__/model.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 错误对象或字符串
|
||||
|
||||
68
src/renderer/src/utils/model.ts
Normal file
68
src/renderer/src/utils/model.ts
Normal 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')
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user