From bb3de5dd32fe47b4fb081163637b3018193e0395 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 7 May 2026 15:05:14 +0800 Subject: [PATCH] refactor: improve model selector search (#35875) --- pnpm-lock.yaml | 13 + pnpm-workspace.yaml | 1 + .../model-selector/__tests__/index.spec.tsx | 34 +- .../model-selector/__tests__/popover.spec.tsx | 34 +- .../__tests__/popup-item.spec.tsx | 95 +++--- .../model-selector/__tests__/popup.spec.tsx | 312 ++++++++++++++---- .../model-selector/feature-icon.tsx | 76 +---- .../model-selector/index.tsx | 147 ++++++--- .../model-selector/marketplace-section.tsx | 14 +- .../model-selector/model-search.ts | 194 +++++++++++ .../model-selector/model-selector-trigger.tsx | 25 +- .../model-selector/popup-empty-state.tsx | 5 +- .../model-selector/popup-item.tsx | 276 ++++++++-------- .../model-selector/popup-layout.tsx | 75 +++-- .../model-selector/popup.tsx | 133 +++----- .../model-selector/types.ts | 11 + .../__tests__/input-var-list.spec.tsx | 4 +- web/package.json | 1 + 18 files changed, 887 insertions(+), 563 deletions(-) create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/types.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2903da1ad..e367aaef67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ee6ccf00df..868920cf94 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx index 2bcb7b712b..4119bca6ae 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/index.spec.tsx @@ -25,18 +25,22 @@ vi.mock('../model-selector-trigger', () => ({ }, })) -vi.mock('../popup', () => ({ - default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (provider: string, model: ModelItem) => void }) => ( - <> - - - - ), -})) +vi.mock('../popup', async () => { + const { ComboboxItem } = await vi.importActual('@langgenius/dify-ui/combobox') + + return { + default: ({ onHide }: { onHide: () => void }) => ( + <> + + select + + + + ), + } +}) const makeModelItem = (overrides: Partial = {}): ModelItem => ({ model: 'gpt-4', @@ -82,7 +86,7 @@ describe('ModelSelector', () => { it('should toggle popup and close it after selecting a model', () => { renderWithQueryClient() - 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() - 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() - 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() diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx index d7501672f4..528a5416ee 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popover.spec.tsx @@ -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
{children}
- }, - PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}, - PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, -})) - vi.mock('../model-selector-trigger', () => ({ default: ({ open, readonly }: { open: boolean, readonly?: boolean }) => ( @@ -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() - 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() - act(() => { - latestOnOpenChange?.(true) - }) + fireEvent.click(screen.getByRole('combobox')) expect(screen.getByText('closed-readonly')).toBeInTheDocument() }) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx index 341a9c6abc..3c4fea6f51 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup-item.spec.tsx @@ -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 }) => {children}, + default: ({ children }: { children: ReactNode }) => {children}, })) vi.mock('../../model-icon', () => ({ @@ -41,13 +43,7 @@ vi.mock('../feature-icon', () => ({ })) vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - -vi.mock('@langgenius/dify-ui/popover', () => ({ - Popover: ({ children }: { children: React.ReactNode }) =>
{children}
, - PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}, - PopoverContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + default: ({ children }: { children: ReactNode }) =>
{children}
, })) const mockCredentialPanelState = vi.hoisted(() => vi.fn()) @@ -114,6 +110,24 @@ const makeProvider = (overrides: Record = {}) => ({ ...overrides, }) +const createComboboxNode = ( + node: ReactElement, + onValueChange = vi.fn(), +) => ( + + {node} + +) + +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( - , + const { container } = renderWithCombobox( + , ) - expect(container.innerHTML).toBe('') + expect(container.textContent).toBe('') }) - it('should call onSelect when clicking an active model', () => { - const onSelect = vi.fn() - render() + it('should select the combobox value when clicking an active model', () => { + const onValueChange = vi.fn() + renderWithCombobox(, 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( , + 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( - , + const onValueChange = vi.fn() + const { rerender } = renderWithCombobox( + , + 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( , - ) + )) 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( , ) @@ -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( , ) @@ -242,7 +256,7 @@ describe('PopupItem', () => { }) it('should toggle collapsed state when clicking provider header', () => { - render() + renderWithCombobox() expect(screen.getByText('GPT-4'))!.toBeInTheDocument() @@ -256,7 +270,7 @@ describe('PopupItem', () => { }) it('should show credential name when using custom provider', () => { - render() + renderWithCombobox() expect(screen.getByText('my-api-key'))!.toBeInTheDocument() }) @@ -273,7 +287,7 @@ describe('PopupItem', () => { credits: 200, }) - render() + renderWithCombobox() 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() + renderWithCombobox() expect(screen.getByText(/modelProvider\.selector\.configureRequired/))!.toBeInTheDocument() }) @@ -321,7 +335,7 @@ describe('PopupItem', () => { credits: 200, }) - render() + renderWithCombobox() expect(screen.getByText(/modelProvider\.selector\.aiCredits/))!.toBeInTheDocument() }) @@ -346,7 +360,7 @@ describe('PopupItem', () => { credits: 0, }) - render() + renderWithCombobox() 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() + renderWithCombobox() + fireEvent.click(screen.getByRole('button', { name: /my-api-key/ })) fireEvent.click(screen.getByRole('button', { name: 'close dropdown' })) expect(onHide).toHaveBeenCalled() diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx index 42232a71c0..4776ad2268 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx @@ -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 + +function PopupHarness(props: PopupTestProps) { + const [inputValue, setInputValue] = useState('') + + return ( + { + if (details.reason !== 'item-press') + setInputValue(newInputValue) + }} + > + + + ) +} + +const renderPopup = (ui: ReactElement) => 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( - { + const user = userEvent.setup() + + renderPopup( + , ) @@ -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( - { 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( - { ], }), ]} - 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( - { ], }), ]} - 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -337,7 +536,7 @@ describe('Popup', () => { mockSupportFunctionCall.mockReturnValue(false) renderPopup( - { ], }), ]} - 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( - , ) @@ -378,9 +575,8 @@ describe('Popup', () => { it('should show compatible-only helper text when scope features are applied', () => { renderPopup( - , @@ -392,9 +588,8 @@ describe('Popup', () => { it('should keep search and footer outside the scrollable model list', () => { renderPopup( - , @@ -417,9 +612,8 @@ describe('Popup', () => { mockSupportFunctionCall.mockReturnValue(false) const { unmount } = renderPopup( - , @@ -429,9 +623,8 @@ describe('Popup', () => { unmount() mockSupportFunctionCall.mockReturnValue(true) const { unmount: unmount2 } = renderPopup( - , @@ -440,9 +633,8 @@ describe('Popup', () => { unmount2() const { unmount: unmount3 } = renderPopup( - , @@ -451,9 +643,8 @@ describe('Popup', () => { unmount3() renderPopup( - , @@ -465,7 +656,7 @@ describe('Popup', () => { mockLanguage = 'fr_FR' renderPopup( - { ], }), ]} - onSelect={vi.fn()} onHide={vi.fn()} />, ) @@ -504,9 +694,8 @@ describe('Popup', () => { ] renderPopup( - , ) @@ -531,9 +720,8 @@ describe('Popup', () => { ] renderPopup( - , ) @@ -561,9 +749,8 @@ describe('Popup', () => { ] renderPopup( - , ) @@ -574,9 +761,8 @@ describe('Popup', () => { it('should open provider settings when clicking footer link', () => { const onHide = vi.fn() renderPopup( - , ) @@ -592,9 +778,8 @@ describe('Popup', () => { it('should show empty state when no providers are configured', () => { const onHide = vi.fn() renderPopup( - , ) @@ -613,9 +798,8 @@ describe('Popup', () => { mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })] renderPopup( - , ) @@ -635,9 +819,8 @@ describe('Popup', () => { })] renderPopup( - , ) @@ -660,9 +843,8 @@ describe('Popup', () => { })] renderPopup( - , ) @@ -674,9 +856,8 @@ describe('Popup', () => { it('should toggle marketplace section collapse', () => { renderPopup( - , ) @@ -699,9 +880,8 @@ describe('Popup', () => { mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' }) renderPopup( - , ) @@ -722,9 +902,8 @@ describe('Popup', () => { mockInstallMutateAsync.mockRejectedValue(new Error('Install failed')) renderPopup( - , ) @@ -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( - , ) @@ -774,9 +951,8 @@ describe('Popup', () => { mockMarketplacePlugins.isLoading = true renderPopup( - , ) @@ -792,9 +968,8 @@ describe('Popup', () => { mockMarketplacePlugins.current = [] renderPopup( - , ) @@ -808,13 +983,12 @@ describe('Popup', () => { it('should sort the selected provider to the top when a default model is provided', () => { renderPopup( - , ) diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx index cc5e0154d7..69dff3a158 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx @@ -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 = ({ +function FeatureIcon({ className, feature, showFeaturesLabel, -}) => { +}: FeatureIconProps) { const { t } = useTranslation() - // if (feature === ModelFeatureEnum.agentThought) { - // return ( - // - // - // - // - // - // ) - // } - - // if (feature === ModelFeatureEnum.toolCall) { - // return ( - // - // - // - // - // - // ) - // } - - // if (feature === ModelFeatureEnum.multiToolCall) { - // return ( - // - // - // - // - // - // ) - // } - if (feature === ModelFeatureEnum.vision) { if (showFeaturesLabel) { return ( - + ) @@ -81,11 +35,11 @@ const FeatureIcon: FC = ({
- +
)} @@ -103,7 +57,7 @@ const FeatureIcon: FC = ({ - + ) @@ -116,11 +70,11 @@ const FeatureIcon: FC = ({
- +
)} @@ -138,7 +92,7 @@ const FeatureIcon: FC = ({ - + ) @@ -151,11 +105,11 @@ const FeatureIcon: FC = ({
- +
)} @@ -173,7 +127,7 @@ const FeatureIcon: FC = ({ - + ) @@ -186,11 +140,11 @@ const FeatureIcon: FC = ({
- +
)} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx index 835821fd59..debd06d7cd 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx @@ -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 = ({ +function ModelSelector({ defaultModel, modelList, triggerClassName, @@ -38,8 +34,10 @@ const ModelSelector: FC = ({ 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 = ({ modelList, defaultModel, ) + const currentValue = useMemo(() => { + 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 ( - + 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} > - - - - )} - /> - + + + { - setOpen(false) - onHide?.() - }} + onInputValueChange={setInputValue} + onHide={handleHide} /> - - + + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx index 469e3c201f..33079a80c6 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx @@ -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 } -const MarketplaceSection: FC = ({ +function MarketplaceSection({ marketplaceProviders, marketplaceCollapsed, installingProvider, @@ -24,7 +23,7 @@ const MarketplaceSection: FC = ({ theme, onMarketplaceCollapsedChange, onInstallPlugin, -}) => { +}: MarketplaceSectionProps) { const { t } = useTranslation() if (marketplaceProviders.length === 0) @@ -36,14 +35,15 @@ const MarketplaceSection: FC = ({
-
-
+
+
{!marketplaceCollapsed && (
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts new file mode 100644 index 0000000000..9870b70ea9 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-search.ts @@ -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 + models: Set +} + +type ModelSelectorSearchIndex = { + search: (query: string) => SearchMatches +} + +type FilterModelSelectorModelsParams = { + aiCreditVisibleProviders: Set + 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((model) => { + return { + provider: model.provider, + labels: getLabelSearchValues(model.label, language), + providerKeys: getProviderKeySearchValues(model.provider), + } + }) + const modelEntries = installedModelList.flatMap(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() + : 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() + + 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(), models: new Set() } + + 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 +} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx index 6b9bcae8dc..e63b94aea3 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/model-selector-trigger.tsx @@ -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 = ({ +function ModelSelectorTrigger({ currentProvider, currentModel, defaultModel, @@ -37,7 +28,7 @@ const ModelSelectorTrigger: FC = ({ className, deprecatedClassName, showDeprecatedWarnIcon = true, -}) => { +}: ModelSelectorTriggerProps) { const { t } = useTranslation() const { modelProviders } = useProviderContext() @@ -100,7 +91,7 @@ const ModelSelectorTrigger: FC = ({ /> )} -
+
{isSelected && ( = ({ render={(
@@ -150,7 +141,7 @@ const ModelSelectorTrigger: FC = ({ +
{deprecatedStatusLabel} diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx index dafd26387b..c68e2df8fe 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx @@ -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 = ({ +function ModelSelectorEmptyState({ onConfigure, -}) => { +}: ModelSelectorEmptyStateProps) { const { t } = useTranslation() return ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx index ff9e6575bb..7a1ed57856 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx @@ -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 = ({ +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 = ({ 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 = ({ 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 = ({ return null return ( -
- {/* Keep the sticky provider header above model rows while the list scrolls. */} -
-
+
+
+ {model.label[language] || model.label.en_US} + + + )} /> @@ -164,100 +138,118 @@ const PopupItem: FC = ({
- {!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. - - 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 = ( + <> +
+ + +
+ { + defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && ( + + + ) + } + + ) + const itemRender = modelItem.status === ModelStatusEnum.noConfigure + ? ( +
+ {rowContent} + +
+ ) + : ( + -
+ {rowContent} + + ) + + return ( + + + +
+
- +
{modelItem.label[language] || modelItem.label.en_US}
- { - defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && ( - - ) - } - { - modelItem.status === ModelStatusEnum.noConfigure && ( -
- {t('operation.add', { ns: 'common' }).toLocaleUpperCase()} +
+ {!!modelItem.model_type && ( + + {modelTypeFormat(modelItem.model_type)} + + )} + {!!modelItem.model_properties.mode && ( + + {(modelItem.model_properties.mode as string).toLocaleUpperCase()} + + )} + {!!modelItem.model_properties.context_size && ( + + {sizeFormat(modelItem.model_properties.context_size as number)} + + )} +
+ {[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)) + && ( +
+
{t('model.capabilities', { ns: 'common' })}
+
+ {modelItem.features?.map(feature => ( + + ))} +
- ) - } - - )} - /> - -
-
- -
{modelItem.label[language] || modelItem.label.en_US}
+ )}
-
- {!!modelItem.model_type && ( - - {modelTypeFormat(modelItem.model_type)} - - )} - {!!modelItem.model_properties.mode && ( - - {(modelItem.model_properties.mode as string).toLocaleUpperCase()} - - )} - {!!modelItem.model_properties.context_size && ( - - {sizeFormat(modelItem.model_properties.context_size as number)} - - )} -
- {[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)) - && ( -
-
{t('model.capabilities', { ns: 'common' })}
-
- {modelItem.features?.map(feature => ( - - ))} -
-
- )} -
-
- - ))} -
+ + + ) + })} + ) } diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx index 50bd098af1..95c930ef28 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx @@ -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 = ({ +export function ModelSelectorPopupFrame({ children, -}) => { +}: ModelSelectorPopupFrameProps) { return (
{children} @@ -23,44 +25,52 @@ export const ModelSelectorPopupFrame: FC = ({ } type ModelSelectorSearchHeaderProps = { - searchText: string - onSearchTextChange: (value: string) => void + inputValue: string + onInputValueChange: (value: string) => void } -export const ModelSelectorSearchHeader: FC = ({ - searchText, - onSearchTextChange, -}) => { +export function ModelSelectorSearchHeader({ + inputValue, + onInputValueChange, +}: ModelSelectorSearchHeaderProps) { const { t } = useTranslation() return (
-
+
) } @@ -70,22 +80,19 @@ type ModelSelectorScrollBodyProps = { label: string } -export const ModelSelectorScrollBody: FC = ({ +export function ModelSelectorScrollBody({ children, label, -}) => { +}: ModelSelectorScrollBodyProps) { return ( - - {children} - + {children} - {/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */} @@ -93,7 +100,7 @@ export const ModelSelectorScrollBody: FC = ({ ) } -export const CompatibleModelsNotice = () => { +export function CompatibleModelsNotice() { const { t } = useTranslation() return ( @@ -110,9 +117,9 @@ type ModelProviderSettingsFooterProps = { onOpenSettings: () => void } -export const ModelProviderSettingsFooter: FC = ({ +export function ModelProviderSettingsFooter({ onOpenSettings, -}) => { +}: ModelProviderSettingsFooterProps) { const { t } = useTranslation() return ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx index 86bac84310..d8d873f26e 100644 --- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx @@ -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 = ({ +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 = ({ return [...installedMarketplaceModels, ...otherModels] }, [aiCreditVisibleProviders, installedProviderMap, modelList]) - const filteredModelList = useMemo(() => { - const normalizedSearch = searchText.toLowerCase() - const matchesLabel = (label: Record) => { - 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 = ({ return ( {showCreditsExhaustedAlert && ( )} + +
+ { + filteredModelList.map(model => ( + + )) + } +
+
- { - filteredModelList.map(model => ( - - )) - } {!filteredModelList.length && !installedModelList.length && ( )} {!filteredModelList.length && installedModelList.length > 0 && ( -
- {`No model found for \u201C${searchText}\u201D`} +
+ {`No model found for \u201C${inputValue}\u201D`}
)} {scopeFeatures.length > 0 && ( diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/types.ts b/web/app/components/header/account-setting/model-provider-page/model-selector/types.ts new file mode 100644 index 0000000000..93314a6208 --- /dev/null +++ b/web/app/components/header/account-setting/model-provider-page/model-selector/types.ts @@ -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 diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx index 4ccf2b1061..01bfceb4a5 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/input-var-list.spec.tsx @@ -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: { diff --git a/web/package.json b/web/package.json index 917ace3025..0dd9dfbde5 100644 --- a/web/package.json +++ b/web/package.json @@ -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:",