mirror of
https://github.com/langgenius/dify.git
synced 2026-05-09 04:36:31 +08:00
refactor: improve model selector search (#35875)
This commit is contained in:
parent
1e2d309122
commit
bb3de5dd32
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@ -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'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()}
|
||||
/>,
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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
|
||||
@ -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: {
|
||||
|
||||
@ -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:",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user