| null }) => {
+ // 单个 provider 的模型选项
+ const getModelOptions = useCallback(
+ (p: Provider, fancyName: string) => {
+ const suffix = showSuffix ? {` | ${fancyName}`} : null
+ return sortBy(p.models, 'name')
+ .filter((model) => predicate?.(model) ?? true)
+ .map((m) => ({
+ label: (
+
+ {showAvatar && }
+
+ {m.name}
+ {suffix}
+
+
+ ),
+ title: `${m.name} | ${fancyName}`,
+ value: getModelUniqId(m)
+ }))
+ },
+ [predicate, showAvatar, showSuffix]
+ )
+
+ // 所有 provider 的模型选项
+ const options = useMemo((): SelectOption[] => {
+ if (!providers) return []
+
+ if (grouped) {
+ return providers.flatMap((p) => {
+ const fancyName = getFancyProviderName(p)
+ const modelOptions = getModelOptions(p, fancyName)
+ return modelOptions.length > 0
+ ? [
+ {
+ label: fancyName,
+ title: p.name,
+ options: modelOptions
+ } as GroupedModelOption
+ ]
+ : []
+ })
+ }
+ return providers.flatMap((p) => getModelOptions(p, getFancyProviderName(p)))
+ }, [providers, grouped, getModelOptions])
+
+ return
+}
+
+export default memo(ModelSelector)
+
+/**
+ * 用于 antd Select 组件的 filterOption,统一搜索行为:
+ * - 优先使用 title 匹配
+ * - 其次使用 label 匹配
+ * - 最后使用 value 匹配
+ *
+ * @param input 用户输入的搜索字符串
+ * @param option Select 选项对象,包含 label 或 value
+ * @returns 是否匹配
+ */
+export function modelSelectFilter(input: string, option: any): boolean {
+ const target =
+ typeof option?.title === 'string'
+ ? option.title
+ : typeof option?.label === 'string'
+ ? option.label
+ : typeof option?.value === 'string'
+ ? option.value
+ : ''
+ return matchKeywordsInString(input, target)
+}
diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
index 446e3a8885..cad5daf1c5 100644
--- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
+++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx
@@ -7,7 +7,7 @@ import { usePinnedModels } from '@renderer/hooks/usePinnedModels'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
-import { classNames } from '@renderer/utils/style'
+import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils'
import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd'
import { first, sortBy } from 'lodash'
import { Search } from 'lucide-react'
@@ -102,27 +102,19 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => {
let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m))
if (searchText.trim()) {
- const keywords = searchText.toLowerCase().split(/\s+/).filter(Boolean)
- models = models.filter((m) => {
- const fullName = provider.isSystem
- ? `${m.name} ${provider.name} ${t('provider.' + provider.id)}`
- : `${m.name} ${provider.name}`
-
- const lowerFullName = fullName.toLowerCase()
- return keywords.every((keyword) => lowerFullName.includes(keyword))
- })
+ models = filterModelsByKeywords(searchText, models, provider)
}
return sortBy(models, ['group', 'name'])
},
- [searchText, t]
+ [searchText]
)
// 创建模型列表项
const createModelItem = useCallback(
(model: Model, provider: any, isPinned: boolean): FlatListItem => {
const modelId = getModelUniqId(model)
- const groupName = provider.isSystem ? t(`provider.${provider.id}`) : provider.name
+ const groupName = getFancyProviderName(provider)
return {
key: isPinned ? `${modelId}_pinned` : modelId,
@@ -148,7 +140,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => {
isSelected: modelId === currentModelId
}
},
- [t, currentModelId]
+ [currentModelId]
)
// 构建扁平化列表数据
@@ -189,7 +181,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => {
items.push({
key: `provider-${p.id}`,
type: 'group',
- name: p.isSystem ? t(`provider.${p.id}`) : p.name,
+ name: getFancyProviderName(p),
isSelected: false
})
diff --git a/src/renderer/src/components/__tests__/ModelSelector.test.tsx b/src/renderer/src/components/__tests__/ModelSelector.test.tsx
new file mode 100644
index 0000000000..2504bba01f
--- /dev/null
+++ b/src/renderer/src/components/__tests__/ModelSelector.test.tsx
@@ -0,0 +1,225 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+
+// Mock the imported modules
+vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({
+ default: ({ model, size }: any) => (
+
+ {model.name.charAt(0)}
+
+ )
+}))
+
+vi.mock('@renderer/services/ModelService', () => ({
+ getModelUniqId: (model: any) => `${model.provider}-${model.id}`
+}))
+
+vi.mock('@renderer/utils', () => ({
+ matchKeywordsInString: (input: string, target: string) => target.toLowerCase().includes(input.toLowerCase())
+}))
+
+vi.mock('@renderer/utils/naming', () => ({
+ getFancyProviderName: (provider: any) => provider.name
+}))
+
+// Import after mocking
+import { Provider } from '@renderer/types'
+
+import ModelSelector, { modelSelectFilter } from '../ModelSelector'
+
+describe('ModelSelector', () => {
+ const mockProviders: Provider[] = [
+ {
+ id: 'openai',
+ name: 'OpenAI',
+ type: 'openai',
+ apiKey: '123',
+ apiHost: 'https://api.openai.com',
+ models: [
+ { id: 'text-embedding-ada-002', name: 'text-embedding-ada-002', provider: 'openai', group: 'embedding' },
+ { id: 'gpt-4.1', name: 'GPT-4.1', provider: 'openai', group: 'chat' }
+ ]
+ },
+ {
+ id: 'cohere',
+ name: 'Cohere',
+ type: 'openai',
+ apiKey: '123',
+ apiHost: 'https://api.cohere.com',
+ models: [
+ { id: 'embed-english-v3.0', name: 'embed-english-v3.0', provider: 'cohere', group: 'embedding' },
+ { id: 'rerank-english-v2.0', name: 'rerank-english-v2.0', provider: 'cohere', group: 'rerank' }
+ ]
+ },
+ {
+ id: 'empty-provider',
+ name: 'EmptyProvider',
+ type: 'openai',
+ apiKey: '123',
+ apiHost: 'https://api.cohere.com',
+ models: []
+ }
+ ]
+
+ describe('grouped mode (grouped=true)', () => {
+ it('should render grouped options and apply predicate', () => {
+ render(
+ model.group === 'embedding'}
+ open // Keep dropdown open for testing
+ />
+ )
+
+ // Check for group labels
+ expect(screen.getByText('OpenAI')).toBeInTheDocument()
+ expect(screen.getByText('Cohere')).toBeInTheDocument()
+ expect(screen.queryByText('EmptyProvider')).not.toBeInTheDocument()
+
+ // Check for correct models
+ const ada = screen.getByText('text-embedding-ada-002')
+ const cohere = screen.getByText('embed-english-v3.0')
+ expect(ada).toBeInTheDocument()
+ expect(cohere).toBeInTheDocument()
+ // Check suffix is present by default
+ expect(ada.textContent).toContain(' | OpenAI')
+ expect(cohere.textContent).toContain(' | Cohere')
+
+ // Check that filtered models are not present
+ expect(screen.queryByText('GPT-4.1')).not.toBeInTheDocument()
+ expect(screen.queryByText('rerank-english-v2.0')).not.toBeInTheDocument()
+ })
+
+ it('should hide suffix when showSuffix is false', () => {
+ render(
+ model.group === 'embedding'}
+ showSuffix={false}
+ open
+ />
+ )
+
+ const ada = screen.getByText('text-embedding-ada-002')
+ expect(ada.textContent).toBe('text-embedding-ada-002')
+ expect(ada.textContent).not.toContain(' | OpenAI')
+ })
+
+ it('should hide avatar when showAvatar is false', () => {
+ render()
+ expect(screen.queryByTestId('model-avatar')).not.toBeInTheDocument()
+ })
+
+ it('should show avatar when showAvatar is true', () => {
+ render()
+ // 4 models in total from mockProviders
+ expect(screen.getAllByTestId('model-avatar')).toHaveLength(4)
+ })
+ })
+
+ describe('flat mode (grouped=false)', () => {
+ it('should render flat options and apply predicate', () => {
+ render(
+ model.group === 'embedding'}
+ grouped={false}
+ open
+ />
+ )
+
+ // In flat mode, there are no group labels in the dropdown structure
+ expect(document.querySelector('.ant-select-item-option-group')).toBeNull()
+
+ // Check for correct models
+ const ada = screen.getByText('text-embedding-ada-002')
+ const cohere = screen.getByText('embed-english-v3.0')
+ expect(ada).toBeInTheDocument()
+ expect(cohere).toBeInTheDocument()
+ // Check suffix is present by default
+ expect(ada.textContent).toContain(' | OpenAI')
+ expect(cohere.textContent).toContain(' | Cohere')
+
+ // Check that filtered models are not present
+ expect(screen.queryByText('GPT-4.1')).not.toBeInTheDocument()
+ expect(screen.queryByText('rerank-english-v2.0')).not.toBeInTheDocument()
+ })
+
+ it('should hide suffix when showSuffix is false', () => {
+ render()
+
+ const gpt4 = screen.getByText('GPT-4.1')
+ expect(gpt4.textContent).toBe('GPT-4.1')
+ expect(gpt4.textContent).not.toContain(' | OpenAI')
+ })
+ })
+
+ describe('edge cases', () => {
+ it('should handle empty providers array', () => {
+ render()
+ expect(document.querySelector('.ant-select-item-option')).toBeNull()
+ })
+
+ it('should handle undefined providers', () => {
+ render()
+ expect(document.querySelector('.ant-select-item-option')).toBeNull()
+ })
+ })
+
+ describe('modelSelectFilter function', () => {
+ it('should filter by provider name in title', () => {
+ const mockOption = {
+ title: 'GPT-4.1 | OpenAI',
+ value: 'openai-gpt-4.1'
+ }
+ expect(modelSelectFilter('openai', mockOption)).toBe(true)
+ })
+
+ it('should filter by model name in title', () => {
+ const mockOption = {
+ title: 'embed-english-v3.0 | Cohere',
+ value: 'cohere-embed-english-v3.0'
+ }
+ expect(modelSelectFilter('english', mockOption)).toBe(true)
+ })
+
+ it('should filter by value if title is not present', () => {
+ const mockOption = {
+ value: 'openai-gpt-4.1'
+ }
+ expect(modelSelectFilter('gpt', mockOption)).toBe(true)
+ })
+
+ it('should return false for no match', () => {
+ const mockOption = {
+ title: 'GPT-4.1 | OpenAI',
+ value: 'openai-gpt-4.1'
+ }
+ expect(modelSelectFilter('nonexistent', mockOption)).toBe(false)
+ })
+ })
+
+ describe('integration', () => {
+ it('should filter options correctly when user types in search input', async () => {
+ const user = userEvent.setup()
+ render()
+
+ // Find the search input field, which is a combobox
+ const searchInput = screen.getByRole('combobox')
+ await user.type(searchInput, 'embed')
+
+ // After filtering, only embedding models should be visible
+ expect(screen.getByText('text-embedding-ada-002')).toBeInTheDocument()
+ expect(screen.getByText('embed-english-v3.0')).toBeInTheDocument()
+
+ // Other models should not be visible
+ expect(screen.queryByText('GPT-4.1')).not.toBeInTheDocument()
+ expect(screen.queryByText('rerank-english-v2.0')).not.toBeInTheDocument()
+
+ // The group titles for visible items should still be there
+ expect(screen.getByText('OpenAI')).toBeInTheDocument()
+ expect(screen.getByText('Cohere')).toBeInTheDocument()
+ })
+ })
+})
diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx
index 1198fdfc90..e76b85877b 100644
--- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx
+++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx
@@ -6,6 +6,7 @@ import db from '@renderer/databases'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { FileType, Model } from '@renderer/types'
+import { getFancyProviderName } from '@renderer/utils'
import { Avatar, Tooltip } from 'antd'
import { useLiveQuery } from 'dexie-react-hooks'
import { first, sortBy } from 'lodash'
@@ -62,7 +63,7 @@ const MentionModelsButton: FC = ({
.map((m) => ({
label: (
<>
- {p.isSystem ? t(`provider.${p.id}`) : p.name}
+ {getFancyProviderName(p)}
| {m.name}
>
),
@@ -72,7 +73,7 @@ const MentionModelsButton: FC = ({
{first(m.name)}
),
- filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
+ filterText: getFancyProviderName(p) + m.name,
action: () => onMentionModel(m),
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
@@ -95,7 +96,7 @@ const MentionModelsButton: FC = ({
const providerModelItems = providerModels.map((m) => ({
label: (
<>
- {p.isSystem ? t(`provider.${p.id}`) : p.name}
+ {getFancyProviderName(p)}
| {m.name}
>
),
@@ -105,7 +106,7 @@ const MentionModelsButton: FC = ({
{first(m.name)}
),
- filterText: (p.isSystem ? t(`provider.${p.id}`) : p.name) + m.name,
+ filterText: getFancyProviderName(p) + m.name,
action: () => onMentionModel(m),
isSelected: mentionedModels.some((selected) => getModelUniqId(selected) === getModelUniqId(m))
}))
diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx
index fff733c74f..1e62b15f85 100644
--- a/src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx
+++ b/src/renderer/src/pages/home/Inputbar/MentionModelsInput.tsx
@@ -2,8 +2,8 @@ import CustomTag from '@renderer/components/CustomTag'
import { useProviders } from '@renderer/hooks/useProvider'
import { getModelUniqId } from '@renderer/services/ModelService'
import { Model } from '@renderer/types'
+import { getFancyProviderName } from '@renderer/utils'
import { FC } from 'react'
-import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const MentionModelsInput: FC<{
@@ -11,11 +11,10 @@ const MentionModelsInput: FC<{
onRemoveModel: (model: Model) => void
}> = ({ selectedModels, onRemoveModel }) => {
const { providers } = useProviders()
- const { t } = useTranslation()
const getProviderName = (model: Model) => {
const provider = providers.find((p) => p.id === model?.provider)
- return provider ? (provider.isSystem ? t(`provider.${provider.id}`) : provider.name) : ''
+ return provider ? getFancyProviderName(provider) : ''
}
return (
diff --git a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx
index a1742dcb2a..b152529f13 100644
--- a/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx
+++ b/src/renderer/src/pages/knowledge/components/AddKnowledgePopup.tsx
@@ -2,11 +2,11 @@ import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons'
import { loggerService } from '@logger'
import AiProvider from '@renderer/aiCore'
import { HStack } from '@renderer/components/Layout'
+import ModelSelector from '@renderer/components/ModelSelector'
import { TopView } from '@renderer/components/TopView'
import { DEFAULT_KNOWLEDGE_DOCUMENT_COUNT, isMac } from '@renderer/config/constant'
import { getEmbeddingMaxContext } from '@renderer/config/embedings'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
-import { NOT_SUPPORTED_REANK_PROVIDERS } from '@renderer/config/providers'
import { useKnowledgeBases } from '@renderer/hooks/useKnowledge'
import { useOcrProviders } from '@renderer/hooks/useOcr'
import { usePreprocessProviders } from '@renderer/hooks/usePreprocess'
@@ -16,7 +16,7 @@ import { getModelUniqId } from '@renderer/services/ModelService'
import { KnowledgeBase, Model, OcrProvider, PreprocessProvider } from '@renderer/types'
import { getErrorMessage } from '@renderer/utils/error'
import { Alert, Input, InputNumber, Modal, Select, Slider, Switch, Tooltip } from 'antd'
-import { find, sortBy } from 'lodash'
+import { find } from 'lodash'
import { ChevronDown } from 'lucide-react'
import { nanoid } from 'nanoid'
import { useEffect, useMemo, useRef, useState } from 'react'
@@ -65,41 +65,6 @@ const PopupContainer: React.FC = ({ title, resolve }) => {
const nameInputRef = useRef(null)
const scrollContainerRef = useRef(null)
- const embeddingSelectOptions = useMemo(() => {
- return providers
- .filter((p) => p.models.length > 0)
- .map((p) => ({
- label: p.isSystem ? t(`provider.${p.id}`) : p.name,
- title: p.name,
- options: sortBy(p.models, 'name')
- .filter((model) => isEmbeddingModel(model))
- .map((m) => ({
- label: m.name,
- value: getModelUniqId(m),
- providerId: p.id,
- modelId: m.id
- }))
- }))
- .filter((group) => group.options.length > 0)
- }, [providers, t])
-
- const rerankSelectOptions = useMemo(() => {
- return providers
- .filter((p) => p.models.length > 0)
- .filter((p) => !NOT_SUPPORTED_REANK_PROVIDERS.includes(p.id))
- .map((p) => ({
- label: p.isSystem ? t(`provider.${p.id}`) : p.name,
- title: p.name,
- options: sortBy(p.models, 'name')
- .filter((model) => isRerankModel(model))
- .map((m) => ({
- label: m.name,
- value: getModelUniqId(m)
- }))
- }))
- .filter((group) => group.options.length > 0)
- }, [providers, t])
-
const preprocessOrOcrSelectOptions = useMemo(() => {
const preprocessOptions = {
label: t('settings.tool.preprocess.provider'),
@@ -292,9 +257,10 @@ const PopupContainer: React.FC = ({ title, resolve }) => {
-