refactor: improve model selector search (#35875)

This commit is contained in:
yyh 2026-05-07 15:05:14 +08:00 committed by GitHub
parent 1e2d309122
commit bb3de5dd32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 887 additions and 563 deletions

13
pnpm-lock.yaml generated
View File

@ -342,6 +342,9 @@ catalogs:
fast-deep-equal:
specifier: 3.1.3
version: 3.1.3
fuse.js:
specifier: 7.2.0
version: 7.2.0
happy-dom:
specifier: 20.9.0
version: 20.9.0
@ -1024,6 +1027,9 @@ importers:
fast-deep-equal:
specifier: 'catalog:'
version: 3.1.3
fuse.js:
specifier: 'catalog:'
version: 7.2.0
hast-util-to-jsx-runtime:
specifier: 'catalog:'
version: 2.3.6
@ -6054,6 +6060,10 @@ packages:
functional-red-black-tree@1.0.1:
resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==}
fuse.js@7.2.0:
resolution: {integrity: sha512-zf4vdcIGpjNKTuXwug33Hm2okqX6a0t2ZEbez+o9oBJQSNhVJ5AqERfeiRD3r8HcLqP66MrjdkmzxrncbAOTUQ==}
engines: {node: '>=10'}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
@ -13637,6 +13647,8 @@ snapshots:
functional-red-black-tree@1.0.1: {}
fuse.js@7.2.0: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
@ -16793,6 +16805,7 @@ time:
eslint-markdown@0.7.0: '2026-04-25T11:31:20.226Z'
eslint-plugin-better-tailwindcss@4.5.0: '2026-04-28T06:24:47.281Z'
eslint@10.2.1: '2026-04-17T20:17:44.852Z'
fuse.js@7.2.0: '2026-04-02T21:14:38.087Z'
hono@4.12.15: '2026-04-24T06:51:10.290Z'
i18next@26.0.8: '2026-04-24T19:20:14.685Z'
js-yaml@4.1.1: '2025-11-12T15:18:03.524Z'

View File

@ -167,6 +167,7 @@ catalog:
eslint-plugin-sonarjs: 4.0.3
eslint-plugin-storybook: 10.3.5
fast-deep-equal: 3.1.3
fuse.js: 7.2.0
happy-dom: 20.9.0
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.15

View File

@ -25,18 +25,22 @@ vi.mock('../model-selector-trigger', () => ({
},
}))
vi.mock('../popup', () => ({
default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (provider: string, model: ModelItem) => void }) => (
<>
<button type="button" onClick={() => onSelect('openai', { model: 'gpt-4' } as ModelItem)}>
select
</button>
<button type="button" onClick={onHide}>
hide
</button>
</>
),
}))
vi.mock('../popup', async () => {
const { ComboboxItem } = await vi.importActual<typeof import('@langgenius/dify-ui/combobox')>('@langgenius/dify-ui/combobox')
return {
default: ({ onHide }: { onHide: () => void }) => (
<>
<ComboboxItem value={{ provider: 'openai', model: 'gpt-4' }}>
select
</ComboboxItem>
<button type="button" onClick={onHide}>
hide
</button>
</>
),
}
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
@ -82,7 +86,7 @@ describe('ModelSelector', () => {
it('should toggle popup and close it after selecting a model', () => {
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} />)
const triggerButton = screen.getByRole('button', { name: 'empty-trigger' })
const triggerButton = screen.getByRole('combobox')
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
@ -96,7 +100,7 @@ describe('ModelSelector', () => {
const onSelect = vi.fn()
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} onSelect={onSelect} />)
fireEvent.click(screen.getByText('empty-trigger'))
fireEvent.click(screen.getByRole('combobox'))
fireEvent.click(screen.getByText('select'))
expect(onSelect).toHaveBeenCalledWith({ provider: 'openai', model: 'gpt-4' })
@ -105,7 +109,7 @@ describe('ModelSelector', () => {
it('should close popup when popup requests hide', () => {
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} />)
const triggerButton = screen.getByRole('button', { name: 'empty-trigger' })
const triggerButton = screen.getByRole('combobox')
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('hide')).toBeInTheDocument()

View File

@ -1,14 +1,6 @@
import type { ReactNode } from 'react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import ModelSelector from '../index'
type PopoverProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('../../hooks', () => ({
useCurrentProviderAndModel: () => ({
currentProvider: undefined,
@ -16,15 +8,6 @@ vi.mock('../../hooks', () => ({
}),
}))
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../model-selector-trigger', () => ({
default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => (
<span>
@ -43,19 +26,16 @@ vi.mock('../popup', () => ({
),
}))
describe('ModelSelector popover branches', () => {
describe('ModelSelector combobox branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should open and close through popover callbacks when editable', () => {
it('should open and close through combobox trigger when editable', () => {
const onHide = vi.fn()
render(<ModelSelector modelList={[]} onHide={onHide} />)
act(() => {
latestOnOpenChange?.(true)
})
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByText('open-editable')).toBeInTheDocument()
@ -65,12 +45,10 @@ describe('ModelSelector popover branches', () => {
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should ignore popover open changes when readonly', () => {
it('should ignore combobox open requests when readonly', () => {
render(<ModelSelector modelList={[]} readonly />)
act(() => {
latestOnOpenChange?.(true)
})
fireEvent.click(screen.getByRole('combobox'))
expect(screen.getByText('closed-readonly')).toBeInTheDocument()
})

View File

@ -1,4 +1,6 @@
import type { ReactElement, ReactNode } from 'react'
import type { DefaultModel, Model, ModelItem } from '../../declarations'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
@ -25,7 +27,7 @@ vi.mock('../../hooks', async () => {
})
vi.mock('../../model-badge', () => ({
default: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
default: ({ children }: { children: ReactNode }) => <span>{children}</span>,
}))
vi.mock('../../model-icon', () => ({
@ -41,13 +43,7 @@ vi.mock('../feature-icon', () => ({
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@langgenius/dify-ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
@ -114,6 +110,24 @@ const makeProvider = (overrides: Record<string, unknown> = {}) => ({
...overrides,
})
const createComboboxNode = (
node: ReactElement,
onValueChange = vi.fn(),
) => (
<Combobox filter={null} open onValueChange={onValueChange}>
{node}
</Combobox>
)
const renderWithCombobox = (
node: ReactElement,
onValueChange = vi.fn(),
) => {
return render(
createComboboxNode(node, onValueChange),
)
}
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -141,48 +155,51 @@ describe('PopupItem', () => {
modelProviders: [],
})
const { container } = render(
<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />,
const { container } = renderWithCombobox(
<PopupItem model={makeModel()} onHide={vi.fn()} />,
)
expect(container.innerHTML).toBe('')
expect(container.textContent).toBe('')
})
it('should call onSelect when clicking an active model', () => {
const onSelect = vi.fn()
render(<PopupItem model={makeModel()} onSelect={onSelect} onHide={vi.fn()} />)
it('should select the combobox value when clicking an active model', () => {
const onValueChange = vi.fn()
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />, onValueChange)
fireEvent.click(screen.getByText('GPT-4'))
expect(onSelect).toHaveBeenCalledWith('openai', expect.objectContaining({ model: 'gpt-4' }))
expect(onValueChange).toHaveBeenCalledWith(
{ provider: 'openai', model: 'gpt-4' },
expect.objectContaining({ reason: 'item-press' }),
)
})
it('should not call onSelect when model is not active', () => {
const onSelect = vi.fn()
render(
it('should not select the combobox value when model is not active', () => {
const onValueChange = vi.fn()
renderWithCombobox(
<PopupItem
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
onSelect={onSelect}
onHide={vi.fn()}
/>,
onValueChange,
)
fireEvent.click(screen.getByText('GPT-4'))
expect(onSelect).not.toHaveBeenCalled()
expect(onValueChange).not.toHaveBeenCalled()
})
it('should open model modal when clicking add on unconfigured model', () => {
const { rerender } = render(
<PopupItem
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
const onValueChange = vi.fn()
const { rerender } = renderWithCombobox(
<PopupItem model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })} onHide={vi.fn()} />,
onValueChange,
)
fireEvent.click(screen.getByText('GPT-4'))
fireEvent.click(screen.getByText('COMMON.OPERATION.ADD'))
expect(onValueChange).not.toHaveBeenCalled()
expect(mockSetShowModelModal).toHaveBeenCalled()
const call = mockSetShowModelModal.mock.calls[0]![0] as { onSaveCallback?: () => void }
@ -191,15 +208,14 @@ describe('PopupItem', () => {
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith(ModelTypeEnum.textGeneration)
rerender(
rerender(createComboboxNode(
<PopupItem
model={makeModel({
models: [makeModelItem({ status: ModelStatusEnum.noConfigure, model_type: undefined as unknown as ModelTypeEnum })],
})}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
))
fireEvent.click(screen.getByText('COMMON.OPERATION.ADD'))
const call2 = mockSetShowModelModal.mock.calls.at(-1)?.[0] as { onSaveCallback?: () => void } | undefined
@ -211,11 +227,10 @@ describe('PopupItem', () => {
it('should show selected state when defaultModel matches', () => {
const defaultModel: DefaultModel = { provider: 'openai', model: 'gpt-4' }
render(
renderWithCombobox(
<PopupItem
defaultModel={defaultModel}
model={makeModel()}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -226,13 +241,12 @@ describe('PopupItem', () => {
it('should fall back to english labels when the current language is unavailable', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
render(
renderWithCombobox(
<PopupItem
model={makeModel({
label: { en_US: 'OpenAI only' } as Model['label'],
models: [makeModelItem({ label: { en_US: 'GPT-4 only' } as ModelItem['label'] })],
})}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -242,7 +256,7 @@ describe('PopupItem', () => {
})
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('GPT-4'))!.toBeInTheDocument()
@ -256,7 +270,7 @@ describe('PopupItem', () => {
})
it('should show credential name when using custom provider', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('my-api-key'))!.toBeInTheDocument()
})
@ -273,7 +287,7 @@ describe('PopupItem', () => {
credits: 200,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText('stale-key'))!.toBeInTheDocument()
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
@ -299,7 +313,7 @@ describe('PopupItem', () => {
credits: 0,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/))!.toBeInTheDocument()
})
@ -321,7 +335,7 @@ describe('PopupItem', () => {
credits: 200,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/))!.toBeInTheDocument()
})
@ -346,7 +360,7 @@ describe('PopupItem', () => {
credits: 0,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/))!.toBeInTheDocument()
})
@ -354,8 +368,9 @@ describe('PopupItem', () => {
it('should close the dropdown through dropdown content callbacks', () => {
const onHide = vi.fn()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={onHide} />)
renderWithCombobox(<PopupItem model={makeModel()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: /my-api-key/ }))
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
expect(onHide).toHaveBeenCalled()

View File

@ -1,7 +1,11 @@
import type { ReactElement } from 'react'
import type { Model, ModelItem, ModelProvider } from '../../declarations'
import type { PopupProps } from '../popup'
import type { SystemFeatures } from '@/types/feature'
import { Combobox } from '@langgenius/dify-ui/combobox'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
import {
ConfigurationMethodEnum,
@ -69,10 +73,31 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
}))
const renderPopup = (ui: ReactElement) => renderWithSystemFeatures(ui, {
// The Popup component never inspects trial_models beyond passing them
// through, so an opaque string[] is enough; cast to satisfy the
// ModelProviderQuotaGetPaid[] declared on SystemFeatures.
type PopupTestProps = Omit<PopupProps, 'inputValue' | 'onInputValueChange'>
function PopupHarness(props: PopupTestProps) {
const [inputValue, setInputValue] = useState('')
return (
<Combobox
filter={null}
inputValue={inputValue}
open
onInputValueChange={(newInputValue, details) => {
if (details.reason !== 'item-press')
setInputValue(newInputValue)
}}
>
<Popup
{...props}
inputValue={inputValue}
onInputValueChange={setInputValue}
/>
</Combobox>
)
}
const renderPopup = (ui: ReactElement<PopupTestProps>) => renderWithSystemFeatures(ui, {
systemFeatures: { trial_models: mockTrialModels.current as unknown as SystemFeatures['trial_models'] },
})
@ -193,11 +218,12 @@ describe('Popup', () => {
})
})
it('should filter models by search and allow clearing search', () => {
const { container } = renderPopup(
<Popup
it('should filter models by search and allow clearing search without blurring the input', async () => {
const user = userEvent.setup()
renderPopup(
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -205,18 +231,21 @@ describe('Popup', () => {
expect(screen.getByText('openai'))!.toBeInTheDocument()
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'not-found' } })
await user.click(input)
await user.keyboard('not-found')
expect(screen.getByText('No model found for \u201Cnot-found\u201D'))!.toBeInTheDocument()
const clearIcon = container.querySelector('.i-custom-vender-solid-general-x-circle')
expect(clearIcon)!.toBeInTheDocument()
fireEvent.click(clearIcon!)
const clearButton = screen.getByRole('button', { name: 'common.operation.clear' })
expect(clearButton)!.toBeInTheDocument()
await user.click(clearButton)
expect((input as HTMLInputElement).value).toBe('')
expect(input).toHaveFocus()
})
it('should show matching models when searching by model name', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[
makeModel({
models: [makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } })],
@ -227,7 +256,6 @@ describe('Popup', () => {
models: [makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } })],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -246,7 +274,7 @@ describe('Popup', () => {
it('should show empty search placeholder when no provider or model name matches', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[
makeModel({
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
@ -255,7 +283,6 @@ describe('Popup', () => {
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -272,7 +299,7 @@ describe('Popup', () => {
it('should show all models of a provider when searching by provider label', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[
makeModel({
provider: 'openai',
@ -290,7 +317,6 @@ describe('Popup', () => {
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -307,9 +333,183 @@ describe('Popup', () => {
expect(screen.queryByText('claude-3')).not.toBeInTheDocument()
})
it('should fuzzy match provider labels and keep all compatible provider models visible', () => {
renderPopup(
<PopupHarness
modelList={[
makeModel({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
makeModelItem({ model: 'gpt-4o', label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' } }),
],
}),
makeModel({
provider: 'anthropic',
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
models: [
makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }),
],
}),
]}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'opnai' } },
)
expect(screen.getByText('openai'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
expect(screen.getByText('gpt-4o'))!.toBeInTheDocument()
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
})
it('should match model labels without expanding unmatched provider models', () => {
renderPopup(
<PopupHarness
modelList={[
makeModel({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-4', label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' } }),
],
}),
makeModel({
provider: 'anthropic',
label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' },
models: [
makeModelItem({ model: 'claude-3', label: { en_US: 'Claude 3', zh_Hans: 'Claude 3' } }),
makeModelItem({ model: 'claude-instant', label: { en_US: 'Claude Instant', zh_Hans: 'Claude Instant' } }),
],
}),
]}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'claude3' } },
)
expect(screen.queryByText('openai')).not.toBeInTheDocument()
expect(screen.getByText('anthropic'))!.toBeInTheDocument()
expect(screen.getByText('claude-3'))!.toBeInTheDocument()
expect(screen.queryByText('claude-instant')).not.toBeInTheDocument()
})
it('should match model names without separators', () => {
renderPopup(
<PopupHarness
modelList={[
makeModel({
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [
makeModelItem({ model: 'gpt-5.4', label: { en_US: 'gpt-5.4', zh_Hans: 'gpt-5.4' } }),
makeModelItem({ model: 'gpt-5.4-2026-03-05', label: { en_US: 'gpt-5.4-2026-03-05', zh_Hans: 'gpt-5.4-2026-03-05' } }),
makeModelItem({ model: 'gpt-5.4-mini', label: { en_US: 'gpt-5.4-mini', zh_Hans: 'gpt-5.4-mini' } }),
makeModelItem({ model: 'gpt-5.4-nano', label: { en_US: 'gpt-5.4-nano', zh_Hans: 'gpt-5.4-nano' } }),
makeModelItem({ model: 'gpt-5.3-chat-latest', label: { en_US: 'gpt-5.3-chat-latest', zh_Hans: 'gpt-5.3-chat-latest' } }),
makeModelItem({ model: 'gpt-5.2', label: { en_US: 'gpt-5.2', zh_Hans: 'gpt-5.2' } }),
makeModelItem({ model: 'gpt-4.1', label: { en_US: 'gpt-4.1', zh_Hans: 'gpt-4.1' } }),
],
}),
]}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'gpt5.4' } },
)
expect(screen.getByText('gpt-5.4'))!.toBeInTheDocument()
expect(screen.getByText('gpt-5.4-2026-03-05'))!.toBeInTheDocument()
expect(screen.getByText('gpt-5.4-mini'))!.toBeInTheDocument()
expect(screen.getByText('gpt-5.4-nano'))!.toBeInTheDocument()
expect(screen.queryByText('gpt-5.3-chat-latest')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-5.2')).not.toBeInTheDocument()
expect(screen.queryByText('gpt-4.1')).not.toBeInTheDocument()
})
it('should not fuzzy match unrelated providers that share the langgenius namespace', () => {
renderPopup(
<PopupHarness
modelList={[
makeModel({
provider: 'langgenius/openai/openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [makeModelItem({ model: 'gpt-5.4', label: { en_US: 'gpt-5.4', zh_Hans: 'gpt-5.4' } })],
}),
makeModel({
provider: 'langgenius/openrouter/openrouter',
label: { en_US: 'OpenRouter', zh_Hans: 'OpenRouter' },
models: [makeModelItem({ model: 'openrouter-model', label: { en_US: 'OpenRouter Model', zh_Hans: 'OpenRouter Model' } })],
}),
makeModel({
provider: 'langgenius/openai_api_compatible/openai_api_compatible',
label: { en_US: 'OpenAI-API-compatible', zh_Hans: 'OpenAI-API-compatible' },
models: [makeModelItem({ model: 'compatible-model', label: { en_US: 'Compatible Model', zh_Hans: 'Compatible Model' } })],
}),
]}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'openai' } },
)
expect(screen.getByText('langgenius/openai/openai'))!.toBeInTheDocument()
expect(screen.getByText('langgenius/openai_api_compatible/openai_api_compatible'))!.toBeInTheDocument()
expect(screen.queryByText('langgenius/openrouter/openrouter')).not.toBeInTheDocument()
})
it('should fuzzy match provider names without matching every langgenius provider', () => {
renderPopup(
<PopupHarness
modelList={[
makeModel({
provider: 'langgenius/zhipuai/zhipuai',
label: { en_US: 'ZHIPU AI', zh_Hans: '智谱 AI' },
models: [makeModelItem({ model: 'glm-4.7', label: { en_US: 'GLM-4.7', zh_Hans: 'GLM-4.7' } })],
}),
makeModel({
provider: 'langgenius/gemini/google',
label: { en_US: 'Gemini', zh_Hans: 'Gemini' },
models: [makeModelItem({ model: 'gemini-3-flash-preview', label: { en_US: 'gemini-3-flash-preview', zh_Hans: 'gemini-3-flash-preview' } })],
}),
makeModel({
provider: 'langgenius/tongyi/tongyi',
label: { en_US: 'Tongyi', zh_Hans: '通义' },
models: [makeModelItem({ model: 'qwen-plus', label: { en_US: 'qwen-plus', zh_Hans: 'qwen-plus' } })],
}),
]}
onHide={vi.fn()}
/>,
)
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'gemni' } },
)
expect(screen.getByText('langgenius/gemini/google'))!.toBeInTheDocument()
expect(screen.queryByText('langgenius/zhipuai/zhipuai')).not.toBeInTheDocument()
expect(screen.queryByText('langgenius/tongyi/tongyi')).not.toBeInTheDocument()
})
it('should match by model provider key when model label does not contain the search text', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[
makeModel({
provider: 'azure_openai',
@ -319,7 +519,6 @@ describe('Popup', () => {
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -337,7 +536,7 @@ describe('Popup', () => {
mockSupportFunctionCall.mockReturnValue(false)
renderPopup(
<Popup
<PopupHarness
modelList={[
makeModel({
provider: 'openai',
@ -348,7 +547,6 @@ describe('Popup', () => {
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.toolCall]}
/>,
@ -366,9 +564,8 @@ describe('Popup', () => {
it('should not show compatible-only helper text when no scope features are applied', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -378,9 +575,8 @@ describe('Popup', () => {
it('should show compatible-only helper text when scope features are applied', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
@ -392,9 +588,8 @@ describe('Popup', () => {
it('should keep search and footer outside the scrollable model list', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
@ -417,9 +612,8 @@ describe('Popup', () => {
mockSupportFunctionCall.mockReturnValue(false)
const { unmount } = renderPopup(
<Popup
<PopupHarness
modelList={modelList}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
/>,
@ -429,9 +623,8 @@ describe('Popup', () => {
unmount()
mockSupportFunctionCall.mockReturnValue(true)
const { unmount: unmount2 } = renderPopup(
<Popup
<PopupHarness
modelList={modelList}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
/>,
@ -440,9 +633,8 @@ describe('Popup', () => {
unmount2()
const { unmount: unmount3 } = renderPopup(
<Popup
<PopupHarness
modelList={modelList}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
@ -451,9 +643,8 @@ describe('Popup', () => {
unmount3()
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel({ models: [makeModelItem({ features: undefined })] })]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
@ -465,7 +656,7 @@ describe('Popup', () => {
mockLanguage = 'fr_FR'
renderPopup(
<Popup
<PopupHarness
modelList={[
makeModel({
models: [
@ -475,7 +666,6 @@ describe('Popup', () => {
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -504,9 +694,8 @@ describe('Popup', () => {
]
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -531,9 +720,8 @@ describe('Popup', () => {
]
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -561,9 +749,8 @@ describe('Popup', () => {
]
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -574,9 +761,8 @@ describe('Popup', () => {
it('should open provider settings when clicking footer link', () => {
const onHide = vi.fn()
renderPopup(
<Popup
<PopupHarness
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={onHide}
/>,
)
@ -592,9 +778,8 @@ describe('Popup', () => {
it('should show empty state when no providers are configured', () => {
const onHide = vi.fn()
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={onHide}
/>,
)
@ -613,9 +798,8 @@ describe('Popup', () => {
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -635,9 +819,8 @@ describe('Popup', () => {
})]
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -660,9 +843,8 @@ describe('Popup', () => {
})]
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -674,9 +856,8 @@ describe('Popup', () => {
it('should toggle marketplace section collapse', () => {
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -699,9 +880,8 @@ describe('Popup', () => {
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -722,9 +902,8 @@ describe('Popup', () => {
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -736,7 +915,6 @@ describe('Popup', () => {
expect(mockInstallMutateAsync).toHaveBeenCalled()
})
// Should not crash, install buttons should still be available
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
})
@ -748,9 +926,8 @@ describe('Popup', () => {
mockCheck.mockResolvedValue(undefined)
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -774,9 +951,8 @@ describe('Popup', () => {
mockMarketplacePlugins.isLoading = true
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -792,9 +968,8 @@ describe('Popup', () => {
mockMarketplacePlugins.current = []
renderPopup(
<Popup
<PopupHarness
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -808,13 +983,12 @@ describe('Popup', () => {
it('should sort the selected provider to the top when a default model is provided', () => {
renderPopup(
<Popup
<PopupHarness
defaultModel={{ provider: 'anthropic', model: 'claude-3' }}
modelList={[
makeModel({ provider: 'openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' } }),
makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' } }),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)

View File

@ -1,17 +1,7 @@
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import {
RiFileTextLine,
RiFilmAiLine,
RiImageCircleAiLine,
RiVoiceAiFill,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
ModelFeatureEnum,
ModelFeatureTextEnum,
} from '../declarations'
import { ModelFeatureEnum, ModelFeatureTextEnum } from '../declarations'
import ModelBadge from '../model-badge'
type FeatureIconProps = {
@ -19,56 +9,20 @@ type FeatureIconProps = {
className?: string
showFeaturesLabel?: boolean
}
const FeatureIcon: FC<FeatureIconProps> = ({
function FeatureIcon({
className,
feature,
showFeaturesLabel,
}) => {
}: FeatureIconProps) {
const { t } = useTranslation()
// if (feature === ModelFeatureEnum.agentThought) {
// return (
// <Tooltip
// popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.agentThought })}
// >
// <ModelBadge className={`mr-0.5 px-0! w-[18px] justify-center text-gray-500 ${className}`}>
// <Robot className='w-3 h-3' />
// </ModelBadge>
// </Tooltip>
// )
// }
// if (feature === ModelFeatureEnum.toolCall) {
// return (
// <Tooltip
// popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.toolCall })}
// >
// <ModelBadge className={`mr-0.5 px-0! w-[18px] justify-center text-gray-500 ${className}`}>
// <MagicWand className='w-3 h-3' />
// </ModelBadge>
// </Tooltip>
// )
// }
// if (feature === ModelFeatureEnum.multiToolCall) {
// return (
// <Tooltip
// popupContent={t('common.modelProvider.featureSupported', { feature: ModelFeatureTextEnum.multiToolCall })}
// >
// <ModelBadge className={`mr-0.5 px-0! w-[18px] justify-center text-gray-500 ${className}`}>
// <MagicBox className='w-3 h-3' />
// </ModelBadge>
// </Tooltip>
// )
// }
if (feature === ModelFeatureEnum.vision) {
if (showFeaturesLabel) {
return (
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiImageCircleAiLine className="size-3" />
<span className="i-ri-image-circle-ai-line size-3" aria-hidden="true" />
<span>{ModelFeatureTextEnum.vision}</span>
</ModelBadge>
)
@ -81,11 +35,11 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<div className="inline-block cursor-help">
<ModelBadge
className={cn(
'w-[18px] justify-center px-0!',
'w-4.5 justify-center px-0!',
className,
)}
>
<RiImageCircleAiLine className="size-3" />
<span className="i-ri-image-circle-ai-line size-3" aria-hidden="true" />
</ModelBadge>
</div>
)}
@ -103,7 +57,7 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiFileTextLine className="size-3" />
<span className="i-ri-file-text-line size-3" aria-hidden="true" />
<span>{ModelFeatureTextEnum.document}</span>
</ModelBadge>
)
@ -116,11 +70,11 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<div className="inline-block cursor-help">
<ModelBadge
className={cn(
'w-[18px] justify-center px-0!',
'w-4.5 justify-center px-0!',
className,
)}
>
<RiFileTextLine className="size-3" />
<span className="i-ri-file-text-line size-3" aria-hidden="true" />
</ModelBadge>
</div>
)}
@ -138,7 +92,7 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiVoiceAiFill className="size-3" />
<span className="i-ri-voice-ai-fill size-3" aria-hidden="true" />
<span>{ModelFeatureTextEnum.audio}</span>
</ModelBadge>
)
@ -151,11 +105,11 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<div className="inline-block cursor-help">
<ModelBadge
className={cn(
'w-[18px] justify-center px-0!',
'w-4.5 justify-center px-0!',
className,
)}
>
<RiVoiceAiFill className="size-3" />
<span className="i-ri-voice-ai-fill size-3" aria-hidden="true" />
</ModelBadge>
</div>
)}
@ -173,7 +127,7 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<ModelBadge
className={cn('gap-x-0.5', className)}
>
<RiFilmAiLine className="size-3" />
<span className="i-ri-film-ai-line size-3" aria-hidden="true" />
<span>{ModelFeatureTextEnum.video}</span>
</ModelBadge>
)
@ -186,11 +140,11 @@ const FeatureIcon: FC<FeatureIconProps> = ({
<div className="inline-block cursor-help">
<ModelBadge
className={cn(
'w-[18px] justify-center px-0!',
'w-4.5 justify-center px-0!',
className,
)}
>
<RiFilmAiLine className="size-3" />
<span className="i-ri-film-ai-line size-3" aria-hidden="true" />
</ModelBadge>
</div>
)}

View File

@ -1,19 +1,15 @@
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelFeatureEnum,
ModelItem,
} from '../declarations'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { useState } from 'react'
import type { ComboboxRootChangeEventDetails } from '@langgenius/dify-ui/combobox'
import type { DefaultModel, Model, ModelFeatureEnum, ModelItem } from '../declarations'
import type { ModelSelectorValue } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { Combobox, ComboboxContent, ComboboxTrigger } from '@langgenius/dify-ui/combobox'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ModelStatusEnum } from '../declarations'
import { useCurrentProviderAndModel } from '../hooks'
import ModelSelectorTrigger from './model-selector-trigger'
import Popup from './popup'
import { getModelSelectorValueLabel, isSameModelSelectorValue } from './types'
type ModelSelectorProps = {
defaultModel?: DefaultModel
@ -27,7 +23,7 @@ type ModelSelectorProps = {
deprecatedClassName?: string
showDeprecatedWarnIcon?: boolean
}
const ModelSelector: FC<ModelSelectorProps> = ({
function ModelSelector({
defaultModel,
modelList,
triggerClassName,
@ -38,8 +34,10 @@ const ModelSelector: FC<ModelSelectorProps> = ({
scopeFeatures = [],
deprecatedClassName,
showDeprecatedWarnIcon = true,
}) => {
}: ModelSelectorProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [inputValue, setInputValue] = useState('')
const {
currentProvider,
currentModel,
@ -47,62 +45,103 @@ const ModelSelector: FC<ModelSelectorProps> = ({
modelList,
defaultModel,
)
const currentValue = useMemo<ModelSelectorValue | null>(() => {
if (!currentProvider || !currentModel)
return null
const handleSelect = (provider: string, model: ModelItem) => {
return {
provider: currentProvider.provider,
model: currentModel.model,
}
}, [currentModel, currentProvider])
const handleOpenChange = useCallback((newOpen: boolean) => {
if (readonly)
return
setOpen(newOpen)
if (!newOpen)
setInputValue('')
}, [readonly])
const handleSelect = useCallback((provider: string, model: ModelItem) => {
setOpen(false)
setInputValue('')
if (onSelect)
onSelect({ provider, model: model.model })
}
}, [onSelect])
const handleValueChange = useCallback((value: ModelSelectorValue | null) => {
if (!value)
return
const provider = modelList.find(model => model.provider === value.provider)
const model = provider?.models.find(model => model.model === value.model)
if (!provider || !model)
return
if (model.status !== ModelStatusEnum.active)
return
handleSelect(provider.provider, model)
}, [handleSelect, modelList])
const handleInputValueChange = useCallback((inputValue: string, details: ComboboxRootChangeEventDetails) => {
if (details.reason !== 'item-press')
setInputValue(inputValue)
}, [])
const handleHide = useCallback(() => {
setOpen(false)
setInputValue('')
onHide?.()
}, [onHide])
return (
<Popover
<Combobox<ModelSelectorValue>
filter={null}
inputValue={inputValue}
isItemEqualToValue={isSameModelSelectorValue}
itemToStringLabel={getModelSelectorValueLabel}
open={open}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
value={currentValue}
onInputValueChange={handleInputValueChange}
onOpenChange={handleOpenChange}
onValueChange={handleValueChange}
>
<PopoverTrigger
render={(
<button
type="button"
className="block w-full border-0 bg-transparent p-0 text-left"
disabled={readonly}
>
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
defaultModel={defaultModel}
open={open}
readonly={readonly}
className={triggerClassName}
deprecatedClassName={deprecatedClassName}
showDeprecatedWarnIcon={showDeprecatedWarnIcon}
/>
</button>
)}
/>
<PopoverContent
<ComboboxTrigger
aria-label={t('detailPanel.configureModel', { ns: 'plugin' })}
icon={false}
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-open:bg-transparent"
disabled={readonly}
>
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
defaultModel={defaultModel}
open={open}
readonly={readonly}
className={triggerClassName}
deprecatedClassName={deprecatedClassName}
showDeprecatedWarnIcon={showDeprecatedWarnIcon}
/>
</ComboboxTrigger>
<ComboboxContent
placement="bottom-start"
sideOffset={4}
className={popupClassName}
popupClassName="overflow-hidden rounded-xl"
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
popupClassName={cn('min-w-[320px] overflow-hidden rounded-xl', popupClassName)}
>
<Popup
defaultModel={defaultModel}
inputValue={inputValue}
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => {
setOpen(false)
onHide?.()
}}
onInputValueChange={setInputValue}
onHide={handleHide}
/>
</PopoverContent>
</Popover>
</ComboboxContent>
</Combobox>
)
}

View File

@ -1,4 +1,3 @@
import type { FC } from 'react'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@ -16,7 +15,7 @@ type MarketplaceSectionProps = {
onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise<void>
}
const MarketplaceSection: FC<MarketplaceSectionProps> = ({
function MarketplaceSection({
marketplaceProviders,
marketplaceCollapsed,
installingProvider,
@ -24,7 +23,7 @@ const MarketplaceSection: FC<MarketplaceSectionProps> = ({
theme,
onMarketplaceCollapsedChange,
onInstallPlugin,
}) => {
}: MarketplaceSectionProps) {
const { t } = useTranslation()
if (marketplaceProviders.length === 0)
@ -36,14 +35,15 @@ const MarketplaceSection: FC<MarketplaceSectionProps> = ({
<div className="h-px bg-divider-subtle" />
</div>
<div>
<div className="flex h-[22px] items-center pr-2 pl-4">
<div
className="flex flex-1 cursor-pointer items-center system-sm-medium text-text-primary"
<div className="flex h-5.5 items-center pr-2 pl-4">
<button
type="button"
className="flex flex-1 cursor-pointer items-center border-0 bg-transparent p-0 text-left system-sm-medium text-text-primary"
onClick={() => onMarketplaceCollapsedChange(!marketplaceCollapsed)}
>
{t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', marketplaceCollapsed && '-rotate-90')} />
</div>
</button>
</div>
{!marketplaceCollapsed && (
<div className="px-1 pb-1">

View File

@ -0,0 +1,194 @@
import type { DefaultModel, Model, ModelItem, TypeWithI18N } from '../declarations'
import Fuse from 'fuse.js'
import { supportFunctionCall } from '@/utils/tool-call'
import { ModelFeatureEnum } from '../declarations'
type ProviderSearchEntry = {
provider: string
labels: string[]
providerKeys: string[]
}
type ModelSearchEntry = {
provider: string
model: string
normalizedLabels: string[]
}
type SearchMatches = {
providers: Set<string>
models: Set<string>
}
type ModelSelectorSearchIndex = {
search: (query: string) => SearchMatches
}
type FilterModelSelectorModelsParams = {
aiCreditVisibleProviders: Set<string>
defaultModel?: DefaultModel
inputValue: string
installedModelList: Model[]
scopeFeatures: ModelFeatureEnum[]
searchIndex: ModelSelectorSearchIndex
}
const providerSearchOptions = {
ignoreDiacritics: true,
ignoreLocation: true,
minMatchCharLength: 2,
shouldSort: false,
threshold: 0.25,
keys: [
{ name: 'labels', weight: 2 },
{ name: 'providerKeys', weight: 1 },
],
}
const modelSearchOptions = {
ignoreDiacritics: true,
shouldSort: false,
useExtendedSearch: true,
keys: [
'normalizedLabels',
],
}
const normalizeModelSearchValue = (value: string) => (
value
.toLowerCase()
.normalize('NFKD')
.replace(/[^\p{Letter}\p{Number}]+/gu, '')
)
const looksLikeModelQuery = (value: string) => /\d/.test(value)
const getLabelSearchValues = (label: TypeWithI18N, language: string) => {
if (label[language] !== undefined)
return [label[language]]
return Array.from(new Set(Object.values(label)))
}
const getProviderKeySearchValues = (provider: string) => {
const keys = provider
.split('/')
.filter(part => part && part !== 'langgenius')
return Array.from(new Set([
...keys,
...keys.map(normalizeModelSearchValue),
]))
}
const createModelSearchKey = (provider: string, model: string) => `${provider}/${model}`
const modelSupportsScopeFeatures = (modelItem: ModelItem, scopeFeatures: ModelFeatureEnum[]) => {
if (scopeFeatures.length === 0)
return true
return scopeFeatures.every((feature) => {
if (feature === ModelFeatureEnum.toolCall)
return supportFunctionCall(modelItem.features)
return modelItem.features?.includes(feature) ?? false
})
}
export const createModelSelectorSearchIndex = (installedModelList: Model[], language: string): ModelSelectorSearchIndex => {
const providerEntries = installedModelList.map<ProviderSearchEntry>((model) => {
return {
provider: model.provider,
labels: getLabelSearchValues(model.label, language),
providerKeys: getProviderKeySearchValues(model.provider),
}
})
const modelEntries = installedModelList.flatMap<ModelSearchEntry>(model =>
model.models.map((modelItem) => {
const labels = getLabelSearchValues(modelItem.label, language)
return {
provider: model.provider,
model: modelItem.model,
normalizedLabels: Array.from(new Set([
modelItem.model,
...labels,
].map(normalizeModelSearchValue))),
}
}),
)
const providerFuse = new Fuse(providerEntries, providerSearchOptions)
const modelFuse = new Fuse(modelEntries, modelSearchOptions)
return {
search: (query) => {
const trimmedQuery = query.trim()
if (!trimmedQuery)
return { providers: new Set(), models: new Set() }
const normalizedQuery = normalizeModelSearchValue(trimmedQuery)
const providerMatches = looksLikeModelQuery(trimmedQuery)
? new Set<string>()
: new Set(providerFuse.search(trimmedQuery).map(({ item }) => item.provider))
const modelMatches = normalizedQuery
? new Set(
modelFuse
.search(`'${normalizedQuery}`)
.map(({ item }) => createModelSearchKey(item.provider, item.model)),
)
: new Set<string>()
return {
providers: providerMatches,
models: modelMatches,
}
},
}
}
export const filterModelSelectorModels = ({
aiCreditVisibleProviders,
defaultModel,
inputValue,
installedModelList,
scopeFeatures,
searchIndex,
}: FilterModelSelectorModelsParams) => {
const trimmedInputValue = inputValue.trim()
const matches = trimmedInputValue
? searchIndex.search(trimmedInputValue)
: { providers: new Set<string>(), models: new Set<string>() }
const filtered = installedModelList.map((model) => {
const providerMatched = matches.providers.has(model.provider)
const filteredModels = model.models
.filter((modelItem) => {
if (!trimmedInputValue || providerMatched)
return true
return matches.models.has(createModelSearchKey(model.provider, modelItem.model))
})
.filter(modelItem => modelSupportsScopeFeatures(modelItem, scopeFeatures))
if (
(trimmedInputValue && filteredModels.length === 0)
|| (!trimmedInputValue && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
) {
return null
}
return { ...model, models: filteredModels }
}).filter((model): model is Model => model !== null)
if (defaultModel?.provider) {
filtered.sort((a, b) => {
const aSelected = a.provider === defaultModel.provider ? 0 : 1
const bSelected = b.provider === defaultModel.provider ? 0 : 1
return aSelected - bSelected
})
}
return filtered
}

View File

@ -1,18 +1,9 @@
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import type { DefaultModel, Model, ModelItem } from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
import { useTranslation } from 'react-i18next'
import { useProviderContext } from '@/context/provider-context'
import {
DERIVED_MODEL_STATUS_BADGE_I18N,
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
deriveModelStatus,
} from '../derive-model-status'
import { DERIVED_MODEL_STATUS_BADGE_I18N, DERIVED_MODEL_STATUS_TOOLTIP_I18N, deriveModelStatus } from '../derive-model-status'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
@ -28,7 +19,7 @@ type ModelSelectorTriggerProps = {
showDeprecatedWarnIcon?: boolean
}
const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
function ModelSelectorTrigger({
currentProvider,
currentModel,
defaultModel,
@ -37,7 +28,7 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
className,
deprecatedClassName,
showDeprecatedWarnIcon = true,
}) => {
}: ModelSelectorTriggerProps) {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
@ -100,7 +91,7 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
/>
)}
<div className={cn('flex grow items-center gap-1 truncate px-1 py-[3px]', isDeprecated && deprecatedClassName)}>
<div className={cn('flex grow items-center gap-1 truncate px-1 py-0.75', isDeprecated && deprecatedClassName)}>
{isSelected && (
<ModelName
className="grow"
@ -127,8 +118,8 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
render={(
<div
className={cn(
'flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5',
isCreditsExhausted && 'min-w-[20px] justify-center bg-components-badge-bg-dimm',
'flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning px-1.25 py-0.5',
isCreditsExhausted && 'min-w-5 justify-center bg-components-badge-bg-dimm',
)}
>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
@ -150,7 +141,7 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
<Tooltip>
<TooltipTrigger
render={(
<div className="flex shrink-0 items-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
<div className="flex shrink-0 items-center gap-0.75 rounded-md border border-text-warning bg-components-badge-bg-dimm px-1.25 py-0.5">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="system-xs-medium whitespace-nowrap text-text-warning">
{deprecatedStatusLabel}

View File

@ -1,4 +1,3 @@
import type { FC } from 'react'
import { Button } from '@langgenius/dify-ui/button'
import { useTranslation } from 'react-i18next'
@ -6,9 +5,9 @@ type ModelSelectorEmptyStateProps = {
onConfigure: () => void
}
const ModelSelectorEmptyState: FC<ModelSelectorEmptyStateProps> = ({
function ModelSelectorEmptyState({
onConfigure,
}) => {
}: ModelSelectorEmptyStateProps) {
const { t } = useTranslation()
return (

View File

@ -1,60 +1,34 @@
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import type { DefaultModel, Model } from '../declarations'
import { cn } from '@langgenius/dify-ui/cn'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { useCallback, useState } from 'react'
import { ComboboxGroup, ComboboxItem, ComboboxItemIndicator } from '@langgenius/dify-ui/combobox'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { PreviewCard, PreviewCardContent, PreviewCardTrigger } from '@langgenius/dify-ui/preview-card'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import {
useLanguage,
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import { ConfigurationMethodEnum, ModelFeatureEnum, ModelStatusEnum, ModelTypeEnum } from '../declarations'
import { useLanguage, useUpdateModelList, useUpdateModelProviders } from '../hooks'
import ModelBadge from '../model-badge'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import DropdownContent from '../provider-added-card/model-auth-dropdown/dropdown-content'
import { useChangeProviderPriority } from '../provider-added-card/use-change-provider-priority'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import {
modelTypeFormat,
sizeFormat,
} from '../utils'
import { modelTypeFormat, sizeFormat } from '../utils'
import FeatureIcon from './feature-icon'
type PopupItemProps = {
defaultModel?: DefaultModel
model: Model
onSelect: (provider: string, model: ModelItem) => void
onHide: () => void
}
const PopupItem: FC<PopupItemProps> = ({
function PopupItem({
defaultModel,
model,
onSelect,
onHide,
}) => {
}: PopupItemProps) {
const [collapsed, setCollapsed] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation()
@ -64,12 +38,6 @@ const PopupItem: FC<PopupItemProps> = ({
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)
const handleSelect = (provider: string, modelItem: ModelItem) => {
if (modelItem.status !== ModelStatusEnum.active)
return
onSelect(provider, modelItem)
}
const handleOpenModelModal = () => {
if (!currentProvider)
return
@ -91,6 +59,12 @@ const PopupItem: FC<PopupItemProps> = ({
const state = useCredentialPanelState(currentProvider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
const groupItems = useMemo(() => model.models
.filter(modelItem => modelItem.status !== ModelStatusEnum.noConfigure)
.map(modelItem => ({
provider: model.provider,
model: modelItem.model,
})), [model.models, model.provider])
const isUsingCredits = state.priority === 'credits'
const hasCredits = !state.isCreditsExhausted
@ -106,33 +80,33 @@ const PopupItem: FC<PopupItemProps> = ({
return null
return (
<div className="mb-1">
{/* Keep the sticky provider header above model rows while the list scrolls. */}
<div className="sticky top-0 z-1 flex h-[22px] items-center justify-between bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<div
className="flex cursor-pointer items-center"
<ComboboxGroup className="mb-1" items={groupItems}>
<div className="sticky top-0 z-1 flex h-5.5 min-w-0 items-center justify-between gap-2 bg-components-panel-bg px-3 text-xs font-medium text-text-tertiary">
<button
type="button"
className="flex min-w-0 cursor-pointer items-center border-0 bg-transparent p-0 text-left"
onClick={() => setCollapsed(prev => !prev)}
>
{model.label[language] || model.label.en_US}
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 text-text-quaternary', collapsed && '-rotate-90')} />
</div>
<span className="truncate">{model.label[language] || model.label.en_US}</span>
<span className={cn('i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 shrink-0 text-text-quaternary', collapsed && '-rotate-90')} />
</button>
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger
render={(
<button type="button" className="flex cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover">
<button type="button" className="flex max-w-[50%] min-w-0 shrink-0 cursor-pointer items-center rounded-md px-1.5 py-1 system-xs-medium text-text-tertiary hover:bg-components-button-ghost-bg-hover">
{isUsingCredits
? (
hasCredits
? (
<>
<CreditsCoin className="h-3 w-3" />
<span className="ml-1">{t('modelProvider.selector.aiCredits', { ns: 'common' })}</span>
<span className="ml-1 truncate">{t('modelProvider.selector.aiCredits', { ns: 'common' })}</span>
</>
)
: (
<>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning-secondary" />
<span className="ml-1 text-text-warning">{t('modelProvider.selector.creditsExhausted', { ns: 'common' })}</span>
<span className="i-ri-alert-fill h-3 w-3 shrink-0 text-text-warning-secondary" />
<span className="ml-1 truncate text-text-warning">{t('modelProvider.selector.creditsExhausted', { ns: 'common' })}</span>
</>
)
)
@ -140,16 +114,16 @@ const PopupItem: FC<PopupItemProps> = ({
? (
<>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-xs border', isApiKeyActive ? 'border-components-badge-status-light-success-border-inner bg-components-badge-status-light-success-bg' : 'border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg')} />
<span className="ml-1 text-text-tertiary">{credentialName}</span>
<span className="ml-1 truncate text-text-tertiary">{credentialName}</span>
</>
)
: (
<>
<span className="h-1.5 w-1.5 shrink-0 rounded-xs border border-components-badge-status-light-disabled-border-inner bg-components-badge-status-light-disabled-bg" />
<span className="ml-1 text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
<span className="ml-1 truncate text-text-tertiary">{t('modelProvider.selector.configureRequired', { ns: 'common' })}</span>
</>
)}
<span className="i-ri-arrow-down-s-line h-[14px]! w-[14px]! translate-y-px text-text-tertiary" />
<span className="i-ri-arrow-down-s-line h-3.5! w-3.5! shrink-0 translate-y-px text-text-tertiary" />
</button>
)}
/>
@ -164,100 +138,118 @@ const PopupItem: FC<PopupItemProps> = ({
</PopoverContent>
</Popover>
</div>
{!collapsed && model.models.map(modelItem => (
// Preview is supplementary: every field in it (name / type / mode / context size / capabilities)
// is reachable from the model's own configuration surface once the row is selected.
// Touch + screen reader users rely on the button's primary onClick, not the preview.
<PreviewCard key={modelItem.model}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={(
<button
type="button"
className={cn('group relative flex h-8 w-full items-center gap-1 rounded-lg px-3 py-1.5 text-left', modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt')}
onClick={() => handleSelect(model.provider, modelItem)}
{!collapsed && model.models.map((modelItem) => {
const rowClassName = cn(
'group relative mx-1 flex h-8 min-w-0 items-center gap-1 rounded-lg px-3 py-1.5 text-left',
modelItem.status === ModelStatusEnum.active ? 'cursor-pointer hover:bg-state-base-hover' : 'cursor-not-allowed hover:bg-state-base-hover-alt',
)
const rowContent = (
<>
<div className="flex min-w-0 items-center gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn('system-sm-medium text-text-secondary', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<ComboboxItemIndicator className="shrink-0 text-text-accent">
<span className="i-custom-vender-line-general-check h-4 w-4" aria-hidden="true" />
</ComboboxItemIndicator>
)
}
</>
)
const itemRender = modelItem.status === ModelStatusEnum.noConfigure
? (
<div className={rowClassName} aria-disabled="true">
{rowContent}
<button
type="button"
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
>
{t('operation.add', { ns: 'common' }).toLocaleUpperCase()}
</button>
</div>
)
: (
<ComboboxItem
value={{
provider: model.provider,
model: modelItem.model,
}}
disabled={modelItem.status !== ModelStatusEnum.active}
className={rowClassName}
>
<div className="flex items-center gap-2">
{rowContent}
</ComboboxItem>
)
return (
<PreviewCard key={modelItem.model}>
<PreviewCardTrigger
delay={150}
closeDelay={150}
render={itemRender}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<ModelName
className={cn('system-sm-medium text-text-secondary', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
<div className="system-md-medium text-wrap wrap-break-word text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<span className="i-custom-vender-line-general-check h-4 w-4 shrink-0 text-text-accent" />
)
}
{
modelItem.status === ModelStatusEnum.noConfigure && (
<div
className="hidden cursor-pointer text-xs font-medium text-text-accent group-hover:block"
onClick={handleOpenModelModal}
>
{t('operation.add', { ns: 'common' }).toLocaleUpperCase()}
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t('model.capabilities', { ns: 'common' })}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
</div>
)
}
</button>
)}
/>
<PreviewCardContent
placement="right"
popupClassName="w-[206px] bg-components-panel-bg-blur p-3 shadow-none backdrop-blur-xs"
>
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<div className="system-md-medium text-wrap wrap-break-word text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
)}
</div>
<div className="flex flex-wrap gap-1">
{!!modelItem.model_type && (
<ModelBadge>
{modelTypeFormat(modelItem.model_type)}
</ModelBadge>
)}
{!!modelItem.model_properties.mode && (
<ModelBadge>
{(modelItem.model_properties.mode as string).toLocaleUpperCase()}
</ModelBadge>
)}
{!!modelItem.model_properties.context_size && (
<ModelBadge>
{sizeFormat(modelItem.model_properties.context_size as number)}
</ModelBadge>
)}
</div>
{[ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding, ModelTypeEnum.rerank].includes(modelItem.model_type as ModelTypeEnum)
&& modelItem.features?.some(feature => [ModelFeatureEnum.vision, ModelFeatureEnum.audio, ModelFeatureEnum.video, ModelFeatureEnum.document].includes(feature))
&& (
<div className="pt-2">
<div className="mb-1 system-2xs-medium-uppercase text-text-tertiary">{t('model.capabilities', { ns: 'common' })}</div>
<div className="flex flex-wrap gap-1">
{modelItem.features?.map(feature => (
<FeatureIcon
key={feature}
feature={feature}
showFeaturesLabel
/>
))}
</div>
</div>
)}
</div>
</PreviewCardContent>
</PreviewCard>
))}
</div>
</PreviewCardContent>
</PreviewCard>
)
})}
</ComboboxGroup>
)
}

View File

@ -1,4 +1,6 @@
import type { FC, ReactNode } from 'react'
import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import { ComboboxInput, ComboboxInputGroup } from '@langgenius/dify-ui/combobox'
import {
ScrollAreaContent,
ScrollAreaRoot,
@ -12,9 +14,9 @@ type ModelSelectorPopupFrameProps = {
children: ReactNode
}
export const ModelSelectorPopupFrame: FC<ModelSelectorPopupFrameProps> = ({
export function ModelSelectorPopupFrame({
children,
}) => {
}: ModelSelectorPopupFrameProps) {
return (
<div className="flex max-h-[min(624px,var(--available-height,624px))] flex-col overflow-hidden rounded-xl bg-components-panel-bg">
{children}
@ -23,44 +25,52 @@ export const ModelSelectorPopupFrame: FC<ModelSelectorPopupFrameProps> = ({
}
type ModelSelectorSearchHeaderProps = {
searchText: string
onSearchTextChange: (value: string) => void
inputValue: string
onInputValueChange: (value: string) => void
}
export const ModelSelectorSearchHeader: FC<ModelSelectorSearchHeaderProps> = ({
searchText,
onSearchTextChange,
}) => {
export function ModelSelectorSearchHeader({
inputValue,
onInputValueChange,
}: ModelSelectorSearchHeaderProps) {
const { t } = useTranslation()
return (
<div className="shrink-0 bg-components-panel-bg px-2 pt-2 pb-1">
<div className={`
flex h-8 items-center rounded-lg border px-2
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
<ComboboxInputGroup
className={cn(
'h-8 min-h-8 px-2',
inputValue
? 'border-components-input-border-active bg-components-input-bg-active shadow-xs'
: 'border-transparent bg-components-input-bg-normal',
)}
>
<span
className={`
mr-0.5 i-ri-search-line h-4 w-4 shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
${inputValue ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
aria-hidden="true"
/>
<input
className="block h-[18px] grow appearance-none bg-transparent px-1 text-[13px] text-text-primary outline-hidden"
<ComboboxInput
aria-label={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
className="block h-4.5 grow px-1 py-0 text-[13px] text-text-primary"
placeholder={t('form.searchModel', { ns: 'datasetSettings' }) || ''}
value={searchText}
onChange={e => onSearchTextChange(e.target.value)}
/>
{
searchText && (
<span
className="ml-1.5 i-custom-vender-solid-general-x-circle h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => onSearchTextChange('')}
/>
inputValue && (
<button
type="button"
aria-label={t('operation.clear', { ns: 'common' }) || 'Clear'}
className="ml-1.5 flex size-3.5 shrink-0 cursor-pointer items-center justify-center rounded-none text-text-quaternary outline-hidden hover:bg-transparent hover:text-text-quaternary focus-visible:bg-transparent focus-visible:ring-1 focus-visible:ring-components-input-border-active"
onClick={() => onInputValueChange('')}
onPointerDown={event => event.preventDefault()}
>
<span className="i-custom-vender-solid-general-x-circle h-3.5 w-3.5" aria-hidden="true" />
</button>
)
}
</div>
</ComboboxInputGroup>
</div>
)
}
@ -70,22 +80,19 @@ type ModelSelectorScrollBodyProps = {
label: string
}
export const ModelSelectorScrollBody: FC<ModelSelectorScrollBodyProps> = ({
export function ModelSelectorScrollBody({
children,
label,
}) => {
}: ModelSelectorScrollBodyProps) {
return (
<ScrollAreaRoot className="relative min-h-0 overflow-hidden overscroll-contain">
<ScrollAreaViewport
aria-label={label}
className="max-h-[calc(min(624px,var(--available-height,624px))-84px)] overscroll-contain"
className="max-h-[calc(min(624px,var(--available-height,624px))-84px)] overflow-x-hidden overscroll-contain"
role="region"
>
<ScrollAreaContent className="min-w-0">
{children}
</ScrollAreaContent>
<ScrollAreaContent className="min-w-0 overflow-x-hidden">{children}</ScrollAreaContent>
</ScrollAreaViewport>
{/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */}
<ScrollAreaScrollbar className="z-2 data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaThumb />
</ScrollAreaScrollbar>
@ -93,7 +100,7 @@ export const ModelSelectorScrollBody: FC<ModelSelectorScrollBodyProps> = ({
)
}
export const CompatibleModelsNotice = () => {
export function CompatibleModelsNotice() {
const { t } = useTranslation()
return (
@ -110,9 +117,9 @@ type ModelProviderSettingsFooterProps = {
onOpenSettings: () => void
}
export const ModelProviderSettingsFooter: FC<ModelProviderSettingsFooterProps> = ({
export function ModelProviderSettingsFooter({
onOpenSettings,
}) => {
}: ModelProviderSettingsFooterProps) {
const { t } = useTranslation()
return (

View File

@ -1,10 +1,6 @@
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import type { DefaultModel, Model, ModelFeatureEnum } from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { ComboboxList } from '@langgenius/dify-ui/combobox'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
@ -16,46 +12,37 @@ import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { supportFunctionCall } from '@/utils/tool-call'
import {
CustomConfigurationStatusEnum,
ModelFeatureEnum,
ModelStatusEnum,
} from '../declarations'
import { CustomConfigurationStatusEnum, ModelStatusEnum } from '../declarations'
import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
import MarketplaceSection from './marketplace-section'
import { createModelSelectorSearchIndex, filterModelSelectorModels } from './model-search'
import ModelSelectorEmptyState from './popup-empty-state'
import PopupItem from './popup-item'
import {
CompatibleModelsNotice,
ModelProviderSettingsFooter,
ModelSelectorPopupFrame,
ModelSelectorScrollBody,
ModelSelectorSearchHeader,
} from './popup-layout'
import { CompatibleModelsNotice, ModelProviderSettingsFooter, ModelSelectorPopupFrame, ModelSelectorScrollBody, ModelSelectorSearchHeader } from './popup-layout'
type PopupProps = {
export type PopupProps = {
defaultModel?: DefaultModel
inputValue: string
modelList: Model[]
onSelect: (provider: string, model: ModelItem) => void
scopeFeatures?: ModelFeatureEnum[]
onInputValueChange: (value: string) => void
onHide: () => void
}
const Popup: FC<PopupProps> = ({
function Popup({
defaultModel,
inputValue,
modelList,
onSelect,
scopeFeatures = [],
onInputValueChange,
onHide,
}) => {
}: PopupProps) {
const { t } = useTranslation()
const { theme } = useTheme()
const language = useLanguage()
const [searchText, setSearchText] = useState('')
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const { modelProviders } = useProviderContext()
@ -142,57 +129,18 @@ const Popup: FC<PopupProps> = ({
return [...installedMarketplaceModels, ...otherModels]
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => {
const normalizedSearch = searchText.toLowerCase()
const matchesLabel = (label: Record<string, string>) => {
if (label[language] !== undefined)
return label[language].toLowerCase().includes(normalizedSearch)
return Object.values(label).some(value =>
value.toLowerCase().includes(normalizedSearch),
)
}
const filtered = installedModelList.map((model) => {
const providerMatched = !!searchText && (
matchesLabel(model.label)
|| model.provider.toLowerCase().includes(normalizedSearch)
)
const filteredModels = model.models
.filter((modelItem) => {
if (!searchText || providerMatched)
return true
return matchesLabel(modelItem.label)
})
.filter((modelItem) => {
if (scopeFeatures.length === 0)
return true
return scopeFeatures.every((feature) => {
if (feature === ModelFeatureEnum.toolCall)
return supportFunctionCall(modelItem.features)
return modelItem.features?.includes(feature) ?? false
})
})
if (
(searchText && filteredModels.length === 0)
|| (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
) {
return null
}
return { ...model, models: filteredModels }
}).filter((model): model is Model => model !== null)
if (defaultModel?.provider) {
filtered.sort((a, b) => {
const aSelected = a.provider === defaultModel.provider ? 0 : 1
const bSelected = b.provider === defaultModel.provider ? 0 : 1
return aSelected - bSelected
})
}
return filtered
}, [aiCreditVisibleProviders, defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
const searchIndex = useMemo(
() => createModelSelectorSearchIndex(installedModelList, language),
[installedModelList, language],
)
const filteredModelList = useMemo(() => filterModelSelectorModels({
aiCreditVisibleProviders,
defaultModel,
inputValue,
installedModelList,
scopeFeatures,
searchIndex,
}), [aiCreditVisibleProviders, defaultModel, inputValue, installedModelList, scopeFeatures, searchIndex])
const marketplaceProviders = useMemo(() => {
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
@ -207,33 +155,36 @@ const Popup: FC<PopupProps> = ({
return (
<ModelSelectorPopupFrame>
<ModelSelectorSearchHeader
searchText={searchText}
onSearchTextChange={setSearchText}
inputValue={inputValue}
onInputValueChange={onInputValueChange}
/>
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ModelSelectorScrollBody label={t('modelProvider.models', { ns: 'common' })}>
<ComboboxList className="max-h-none overflow-visible p-0">
<div className="pb-1">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onHide={onHide}
/>
))
}
</div>
</ComboboxList>
<div className="pb-1">
{
filteredModelList.map(model => (
<PopupItem
key={model.provider}
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
/>
))
}
{!filteredModelList.length && !installedModelList.length && (
<ModelSelectorEmptyState
onConfigure={handleOpenSettings}
/>
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="px-3 py-1.5 text-center text-xs leading-[18px] break-all text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
<div className="px-3 py-1.5 text-center text-xs leading-4.5 break-all text-text-tertiary">
{`No model found for \u201C${inputValue}\u201D`}
</div>
)}
{scopeFeatures.length > 0 && (

View File

@ -0,0 +1,11 @@
export type ModelSelectorValue = {
provider: string
model: string
}
export const isSameModelSelectorValue = (
itemValue: ModelSelectorValue,
value: ModelSelectorValue,
) => itemValue.provider === value.provider && itemValue.model === value.model
export const getModelSelectorValueLabel = (itemValue: ModelSelectorValue) => itemValue.model

View File

@ -491,8 +491,8 @@ describe('InputVarList', () => {
})
await user.click(screen.getByText('workflow:errorMsg.configureModel'))
await user.click(await screen.findByRole('button', { name: 'plugin.detailPanel.configureModel' }))
await user.click(await screen.findByRole('button', { name: /GPT-4o/i }))
await user.click(await screen.findByRole('combobox', { name: 'plugin.detailPanel.configureModel' }))
await user.click(await screen.findByRole('option', { name: /GPT-4o/i }))
expect(onChange).toHaveBeenLastCalledWith({
assistant: {

View File

@ -96,6 +96,7 @@
"emoji-mart": "catalog:",
"es-toolkit": "catalog:",
"fast-deep-equal": "catalog:",
"fuse.js": "catalog:",
"hast-util-to-jsx-runtime": "catalog:",
"html-entities": "catalog:",
"html-to-image": "catalog:",