feat: enhance model plugin workflow checks and model provider management UX (#33289)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: statxc <tyleradams93226@gmail.com>
This commit is contained in:
yyh
2026-03-18 10:16:15 +08:00
committed by GitHub
parent aa4a9877f5
commit bbe975c6bc
319 changed files with 19582 additions and 5541 deletions

View File

@ -1,15 +1,16 @@
import type { ComponentProps, ReactNode } from 'react'
import type { AccountSettingTab } from '../constants'
import type { AppContextValue } from '@/context/app-context'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useEffect } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { useAppContext } from '@/context/app-context'
import { baseProviderContextValue, useProviderContext } from '@/context/provider-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { ACCOUNT_SETTING_TAB } from '../constants'
import AccountSetting from '../index'
const mockResetModelProviderListExpanded = vi.fn()
vi.mock('@/context/provider-context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/context/provider-context')>()
return {
@ -46,65 +47,27 @@ vi.mock('@/hooks/use-breakpoints', () => ({
default: vi.fn(),
}))
vi.mock('@/app/components/billing/billing-page', () => ({
default: () => <div data-testid="billing-page">Billing Page</div>,
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useDefaultModel: vi.fn(() => ({ data: null, isLoading: false })),
useUpdateDefaultModel: vi.fn(() => ({ trigger: vi.fn() })),
useUpdateModelList: vi.fn(() => vi.fn()),
useInvalidateDefaultModel: vi.fn(() => vi.fn()),
useModelList: vi.fn(() => ({ data: [], isLoading: false })),
useSystemDefaultModelAndModelList: vi.fn(() => [null, vi.fn()]),
}))
vi.mock('@/app/components/custom/custom-page', () => ({
default: () => <div data-testid="custom-page">Custom Page</div>,
vi.mock('@/app/components/header/account-setting/model-provider-page/atoms', () => ({
useResetModelProviderListExpanded: () => mockResetModelProviderListExpanded,
}))
vi.mock('@/app/components/header/account-setting/api-based-extension-page', () => ({
default: () => <div data-testid="api-based-extension-page">API Based Extension Page</div>,
vi.mock('@/service/use-datasource', () => ({
useGetDataSourceListAuth: vi.fn(() => ({ data: { result: [] } })),
}))
vi.mock('@/app/components/header/account-setting/data-source-page-new', () => ({
default: () => <div data-testid="data-source-page">Data Source Page</div>,
}))
vi.mock('@/app/components/header/account-setting/language-page', () => ({
default: () => <div data-testid="language-page">Language Page</div>,
}))
vi.mock('@/app/components/header/account-setting/members-page', () => ({
default: () => <div data-testid="members-page">Members Page</div>,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page', () => ({
default: ({ searchText }: { searchText: string }) => (
<div data-testid="provider-page">
{`provider-search:${searchText}`}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/menu-dialog', () => ({
default: function MockMenuDialog({
children,
onClose,
show,
}: {
children: ReactNode
onClose: () => void
show?: boolean
}) {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape')
onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [onClose])
if (!show)
return null
return <div role="dialog">{children}</div>
},
vi.mock('@/service/use-common', () => ({
useApiBasedExtensions: vi.fn(() => ({ data: [], isPending: false })),
useMembers: vi.fn(() => ({ data: { accounts: [] }, refetch: vi.fn() })),
useProviderContext: vi.fn(),
}))
const baseAppContextValue: AppContextValue = {
@ -151,30 +114,37 @@ const baseAppContextValue: AppContextValue = {
describe('AccountSetting', () => {
const mockOnCancel = vi.fn()
const mockOnTabChange = vi.fn()
const renderAccountSetting = (props?: {
initialTab?: AccountSettingTab
onCancel?: () => void
onTabChange?: (tab: AccountSettingTab) => void
}) => {
const {
initialTab = ACCOUNT_SETTING_TAB.MEMBERS,
onCancel = mockOnCancel,
onTabChange = mockOnTabChange,
} = props ?? {}
const renderAccountSetting = (props: Partial<ComponentProps<typeof AccountSetting>> = {}) => {
const queryClient = new QueryClient()
const mergedProps: ComponentProps<typeof AccountSetting> = {
onCancel: mockOnCancel,
...props,
const StatefulAccountSetting = () => {
const [activeTab, setActiveTab] = useState<AccountSettingTab>(initialTab)
return (
<AccountSetting
onCancelAction={onCancel}
activeTab={activeTab}
onTabChangeAction={(tab) => {
setActiveTab(tab)
onTabChange(tab)
}}
/>
)
}
const view = render(
<QueryClientProvider client={queryClient}>
<AccountSetting {...mergedProps} />
return render(
<QueryClientProvider client={new QueryClient()}>
<StatefulAccountSetting />
</QueryClientProvider>,
)
return {
...view,
rerenderAccountSetting(nextProps: Partial<ComponentProps<typeof AccountSetting>>) {
view.rerender(
<QueryClientProvider client={queryClient}>
<AccountSetting {...mergedProps} {...nextProps} />
</QueryClientProvider>,
)
},
}
}
beforeEach(() => {
@ -190,155 +160,171 @@ describe('AccountSetting', () => {
describe('Rendering', () => {
it('should render the sidebar with correct menu items', () => {
// Act
renderAccountSetting()
// Assert
expect(screen.getByText('common.userProfile.settings')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.provider')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.members')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.billing')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByTitle('custom.custom')).toBeInTheDocument()
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
expect(screen.getByTestId('members-page')).toBeInTheDocument()
expect(screen.getByText('common.settings.provider')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(0)
expect(screen.getByText('common.settings.billing')).toBeInTheDocument()
expect(screen.getByText('common.settings.dataSource')).toBeInTheDocument()
expect(screen.getByText('common.settings.apiBasedExtension')).toBeInTheDocument()
expect(screen.getByText('custom.custom')).toBeInTheDocument()
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(0)
})
it('should respect the activeTab prop', () => {
renderAccountSetting({ activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
it('should respect the initial tab', () => {
// Act
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.DATA_SOURCE })
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
})
it('should sync the rendered page when activeTab changes', async () => {
const { rerenderAccountSetting } = renderAccountSetting({
activeTab: ACCOUNT_SETTING_TAB.DATA_SOURCE,
})
expect(screen.getByTestId('data-source-page')).toBeInTheDocument()
rerenderAccountSetting({
activeTab: ACCOUNT_SETTING_TAB.CUSTOM,
})
await waitFor(() => {
expect(screen.getByTestId('custom-page')).toBeInTheDocument()
})
// Assert
// Check that the active item title is Data Source
const titles = screen.getAllByText('common.settings.dataSource')
// One in sidebar, one in header.
expect(titles.length).toBeGreaterThan(1)
})
it('should hide sidebar labels on mobile', () => {
// Arrange
vi.mocked(useBreakpoints).mockReturnValue(MediaType.mobile)
// Act
renderAccountSetting()
// Assert
// On mobile, the labels should not be rendered as per the implementation
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
})
it('should filter items for dataset operator', () => {
// Arrange
vi.mocked(useAppContext).mockReturnValue({
...baseAppContextValue,
isCurrentWorkspaceDatasetOperator: true,
})
// Act
renderAccountSetting()
expect(screen.queryByTitle('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByTitle('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByTitle('common.settings.language')).toBeInTheDocument()
// Assert
expect(screen.queryByText('common.settings.provider')).not.toBeInTheDocument()
expect(screen.queryByText('common.settings.members')).not.toBeInTheDocument()
expect(screen.getByText('common.settings.language')).toBeInTheDocument()
})
it('should hide billing and custom tabs when disabled', () => {
// Arrange
vi.mocked(useProviderContext).mockReturnValue({
...baseProviderContextValue,
enableBilling: false,
enableReplaceWebAppLogo: false,
})
// Act
renderAccountSetting()
expect(screen.queryByTitle('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByTitle('custom.custom')).not.toBeInTheDocument()
// Assert
expect(screen.queryByText('common.settings.billing')).not.toBeInTheDocument()
expect(screen.queryByText('custom.custom')).not.toBeInTheDocument()
})
})
describe('Tab Navigation', () => {
it('should change active tab when clicking on a menu item', async () => {
const user = userEvent.setup()
it('should change active tab when clicking on menu item', () => {
// Arrange
renderAccountSetting({ onTabChange: mockOnTabChange })
await user.click(screen.getByTitle('common.settings.provider'))
// Act
fireEvent.click(screen.getByText('common.settings.provider'))
// Assert
expect(mockOnTabChange).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.PROVIDER)
expect(screen.getByTestId('provider-page')).toBeInTheDocument()
// Check for content from ModelProviderPage
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
})
it.each([
['common.settings.billing', 'billing-page'],
['common.settings.dataSource', 'data-source-page'],
['common.settings.apiBasedExtension', 'api-based-extension-page'],
['custom.custom', 'custom-page'],
['common.settings.language', 'language-page'],
['common.settings.members', 'members-page'],
])('should render the "%s" page when its sidebar item is selected', async (menuTitle, pageTestId) => {
const user = userEvent.setup()
it('should navigate through various tabs and show correct details', () => {
// Act & Assert
renderAccountSetting()
await user.click(screen.getByTitle(menuTitle))
// Billing
fireEvent.click(screen.getByText('common.settings.billing'))
// Billing Page renders plansCommon.plan if data is loaded, or generic text.
// Checking for title in header which is always there
expect(screen.getAllByText('common.settings.billing').length).toBeGreaterThan(1)
expect(screen.getByTestId(pageTestId)).toBeInTheDocument()
// Data Source
fireEvent.click(screen.getByText('common.settings.dataSource'))
expect(screen.getAllByText('common.settings.dataSource').length).toBeGreaterThan(1)
// API Based Extension
fireEvent.click(screen.getByText('common.settings.apiBasedExtension'))
expect(screen.getAllByText('common.settings.apiBasedExtension').length).toBeGreaterThan(1)
// Custom
fireEvent.click(screen.getByText('custom.custom'))
// Custom Page uses 'custom.custom' key as well.
expect(screen.getAllByText('custom.custom').length).toBeGreaterThan(1)
// Language
fireEvent.click(screen.getAllByText('common.settings.language')[0])
expect(screen.getAllByText('common.settings.language').length).toBeGreaterThan(1)
// Members
fireEvent.click(screen.getAllByText('common.settings.members')[0])
expect(screen.getAllByText('common.settings.members').length).toBeGreaterThan(1)
})
})
describe('Interactions', () => {
it('should call onCancel when clicking the close button', async () => {
const user = userEvent.setup()
it('should call onCancel when clicking close button', () => {
// Act
renderAccountSetting()
const closeIcon = document.querySelector('.i-ri-close-line')
const closeButton = closeIcon?.closest('button')
expect(closeButton).not.toBeNull()
fireEvent.click(closeButton!)
const closeControls = screen.getByText('ESC').parentElement
expect(closeControls).not.toBeNull()
if (!closeControls)
throw new Error('Close controls are missing')
await user.click(within(closeControls).getByRole('button'))
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when pressing Escape key', () => {
// Act
renderAccountSetting()
fireEvent.keyDown(document, { key: 'Escape' })
// Assert
expect(mockOnCancel).toHaveBeenCalled()
})
it('should update search value in the provider tab', async () => {
const user = userEvent.setup()
renderAccountSetting()
await user.click(screen.getByTitle('common.settings.provider'))
it('should update search value in provider tab', () => {
// Arrange
renderAccountSetting({ initialTab: ACCOUNT_SETTING_TAB.PROVIDER })
// Act
const input = screen.getByRole('textbox')
await user.type(input, 'test-search')
fireEvent.change(input, { target: { value: 'test-search' } })
// Assert
expect(input).toHaveValue('test-search')
expect(screen.getByText('provider-search:test-search')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.models')).toBeInTheDocument()
})
it('should handle scroll event in panel', () => {
// Act
renderAccountSetting()
const scrollContainer = screen.getByRole('dialog').querySelector('.overflow-y-auto')
// Assert
expect(scrollContainer).toBeInTheDocument()
if (scrollContainer) {
// Scroll down
fireEvent.scroll(scrollContainer, { target: { scrollTop: 100 } })
expect(scrollContainer).toHaveClass('overflow-y-auto')
// Scroll back up
fireEvent.scroll(scrollContainer, { target: { scrollTop: 0 } })
}
})

View File

@ -40,8 +40,7 @@ describe('MenuDialog', () => {
)
// Assert
const panel = screen.getByRole('dialog').querySelector('.custom-class')
expect(panel).toBeInTheDocument()
expect(screen.getByRole('dialog')).toHaveClass('custom-class')
})
})

View File

@ -1,6 +1,6 @@
'use client'
import type { AccountSettingTab } from '@/app/components/header/account-setting/constants'
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import SearchInput from '@/app/components/base/search-input'
import BillingPage from '@/app/components/billing/billing-page'
@ -20,15 +20,16 @@ import DataSourcePage from './data-source-page-new'
import LanguagePage from './language-page'
import MembersPage from './members-page'
import ModelProviderPage from './model-provider-page'
import { useResetModelProviderListExpanded } from './model-provider-page/atoms'
const iconClassName = `
w-5 h-5 mr-2
`
type IAccountSettingProps = {
onCancel: () => void
activeTab?: AccountSettingTab
onTabChange?: (tab: AccountSettingTab) => void
onCancelAction: () => void
activeTab: AccountSettingTab
onTabChangeAction: (tab: AccountSettingTab) => void
}
type GroupItem = {
@ -40,14 +41,12 @@ type GroupItem = {
}
export default function AccountSetting({
onCancel,
activeTab = ACCOUNT_SETTING_TAB.MEMBERS,
onTabChange,
onCancelAction,
activeTab,
onTabChangeAction,
}: IAccountSettingProps) {
const [activeMenu, setActiveMenu] = useState<AccountSettingTab>(activeTab)
useEffect(() => {
setActiveMenu(activeTab)
}, [activeTab])
const resetModelProviderListExpanded = useResetModelProviderListExpanded()
const activeMenu = activeTab
const { t } = useTranslation()
const { enableBilling, enableReplaceWebAppLogo } = useProviderContext()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
@ -148,10 +147,22 @@ export default function AccountSetting({
const [searchValue, setSearchValue] = useState<string>('')
const handleTabChange = useCallback((tab: AccountSettingTab) => {
if (tab === ACCOUNT_SETTING_TAB.PROVIDER)
resetModelProviderListExpanded()
onTabChangeAction(tab)
}, [onTabChangeAction, resetModelProviderListExpanded])
const handleClose = useCallback(() => {
resetModelProviderListExpanded()
onCancelAction()
}, [onCancelAction, resetModelProviderListExpanded])
return (
<MenuDialog
show
onClose={onCancel}
onClose={handleClose}
>
<div className="mx-auto flex h-[100vh] max-w-[1048px]">
<div className="flex w-[44px] flex-col border-r border-divider-burn pl-4 pr-6 sm:w-[224px]">
@ -166,21 +177,22 @@ export default function AccountSetting({
<div>
{
menuItem.items.map(item => (
<div
<button
type="button"
key={item.key}
className={cn(
'mb-0.5 flex h-[37px] cursor-pointer items-center rounded-lg p-1 pl-3 text-sm',
'mb-0.5 flex h-[37px] w-full items-center rounded-lg p-1 pl-3 text-left text-sm',
activeMenu === item.key ? 'bg-state-base-active text-components-menu-item-text-active system-sm-semibold' : 'text-components-menu-item-text system-sm-medium',
)}
aria-label={item.name}
title={item.name}
onClick={() => {
setActiveMenu(item.key)
onTabChange?.(item.key)
handleTabChange(item.key)
}}
>
{activeMenu === item.key ? item.activeIcon : item.icon}
{!isMobile && <div className="truncate">{item.name}</div>}
</div>
</button>
))
}
</div>
@ -195,7 +207,8 @@ export default function AccountSetting({
variant="tertiary"
size="large"
className="px-2"
onClick={onCancel}
aria-label={t('operation.close', { ns: 'common' })}
onClick={handleClose}
>
<span className="i-ri-close-line h-5 w-5" />
</Button>

View File

@ -1,6 +1,6 @@
import type { AppContextValue } from '@/context/app-context'
import type { ICurrentWorkspace } from '@/models/common'
import { render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
@ -40,35 +40,24 @@ describe('EditWorkspaceModal', () => {
expect(await screen.findByDisplayValue('Test Workspace')).toBeInTheDocument()
})
it('should render on the base/ui overlay layer', async () => {
renderModal()
expect(await screen.findByRole('dialog')).toHaveClass('z-[1002]')
})
it('should let user edit workspace name', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
})
it('should reset name to current workspace name when cleared', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
await user.clear(input)
await user.type(input, 'New Workspace Name')
expect(input).toHaveValue('New Workspace Name')
// Click the clear button (Input component clear button)
const clearBtn = screen.getByTestId('input-clear')
await user.click(clearBtn)
expect(input).toHaveValue('Test Workspace')
})
it('should submit update when confirming as owner', async () => {
const user = userEvent.setup()
const mockAssign = vi.fn()
@ -77,10 +66,10 @@ describe('EditWorkspaceModal', () => {
renderModal()
const input = screen.getByPlaceholderText(/account\.workspaceNamePlaceholder/i)
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'Renamed Workspace')
await user.click(screen.getByTestId('edit-workspace-confirm'))
await user.click(screen.getByTestId('edit-workspace-save'))
await waitFor(() => {
expect(updateWorkspaceInfo).toHaveBeenCalledWith({
@ -89,6 +78,8 @@ describe('EditWorkspaceModal', () => {
})
expect(mockAssign).toHaveBeenCalledWith('http://localhost')
})
expect(mockOnCancel).not.toHaveBeenCalled()
})
it('should show error toast when update fails', async () => {
@ -98,7 +89,10 @@ describe('EditWorkspaceModal', () => {
renderModal()
await user.click(screen.getByTestId('edit-workspace-confirm'))
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
await user.type(input, 'Broken Workspace')
await user.click(screen.getByTestId('edit-workspace-save'))
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
@ -107,6 +101,40 @@ describe('EditWorkspaceModal', () => {
})
})
it('should disable save button when there are no changes', async () => {
renderModal()
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
})
it('should disable save button and show error when the name is empty', async () => {
const user = userEvent.setup()
renderModal()
const input = screen.getByLabelText(/account\.workspaceName/i)
await user.clear(input)
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
expect(input).toHaveAttribute('aria-invalid', 'true')
expect(screen.getByTestId('edit-workspace-error')).toBeInTheDocument()
})
it('should not submit when the form is submitted while save is disabled', async () => {
renderModal()
const saveButton = screen.getByTestId('edit-workspace-save')
const form = saveButton.closest('form')
expect(saveButton).toBeDisabled()
expect(form).not.toBeNull()
fireEvent.submit(form!)
expect(updateWorkspaceInfo).not.toHaveBeenCalled()
expect(mockNotify).not.toHaveBeenCalled()
})
it('should disable confirm button for non-owners', async () => {
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' } as ICurrentWorkspace,
@ -115,7 +143,7 @@ describe('EditWorkspaceModal', () => {
renderModal()
expect(screen.getByTestId('edit-workspace-confirm')).toBeDisabled()
expect(screen.getByTestId('edit-workspace-save')).toBeDisabled()
})
it('should call onCancel when close icon is clicked', async () => {
@ -133,4 +161,14 @@ describe('EditWorkspaceModal', () => {
await user.click(screen.getByTestId('edit-workspace-cancel'))
expect(mockOnCancel).toHaveBeenCalled()
})
it('should call onCancel when Escape key is pressed', async () => {
renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
await waitFor(() => {
expect(mockOnCancel).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,57 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { useAppContext } from '@/context/app-context'
import EditWorkspaceModal from './index'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogCloseButton: ({ ...props }: Record<string, unknown>) => <button {...props} />,
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
DialogTitle: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: vi.fn(),
}))
describe('EditWorkspaceModal dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
vi.mocked(useAppContext).mockReturnValue({
currentWorkspace: { name: 'Test Workspace' },
isCurrentWorkspaceOwner: true,
} as never)
})
it('should only call onCancel when the dialog requests closing', () => {
const onCancel = vi.fn()
render(
<ToastContext.Provider value={{ notify: vi.fn(), close: vi.fn() }}>
<EditWorkspaceModal onCancel={onCancel} />
</ToastContext.Provider>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onCancel).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,20 +1,19 @@
'use client'
import { noop } from 'es-toolkit/function'
import { useState } from 'react'
import { useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast/context'
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@/app/components/base/ui/dialog'
import { useAppContext } from '@/context/app-context'
import { updateWorkspaceInfo } from '@/service/common'
import { cn } from '@/utils/classnames'
import s from './index.module.css'
type IEditWorkspaceModalProps = {
onCancel: () => void
}
const EditWorkspaceModal = ({
onCancel,
}: IEditWorkspaceModalProps) => {
@ -22,13 +21,33 @@ const EditWorkspaceModal = ({
const { notify } = useContext(ToastContext)
const { currentWorkspace, isCurrentWorkspaceOwner } = useAppContext()
const [name, setName] = useState<string>(currentWorkspace.name)
const [isSubmitting, setIsSubmitting] = useState(false)
const inputId = useId()
const errorId = useId()
const normalizedName = name.trim()
const hasChanges = normalizedName !== currentWorkspace.name
const hasError = normalizedName.length === 0
const isSaveDisabled = !isCurrentWorkspaceOwner || !hasChanges || hasError || isSubmitting
const nameErrorMessage = useMemo(() => {
if (!hasError)
return ''
const changeWorkspaceInfo = async (name: string) => {
return t('errorMsg.fieldRequired', {
ns: 'common',
field: t('account.workspaceName', { ns: 'common' }),
})
}, [hasError, t])
const changeWorkspaceInfo = async () => {
if (isSaveDisabled)
return
setIsSubmitting(true)
try {
await updateWorkspaceInfo({
url: '/workspaces/info',
body: {
name,
name: normalizedName,
},
})
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
@ -37,33 +56,74 @@ const EditWorkspaceModal = ({
catch {
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
}
finally {
setIsSubmitting(false)
}
}
return (
<div className={cn(s.wrap)}>
<Modal overflowVisible isShow onClose={noop} className={cn(s.modal)}>
<div className="mb-2 flex justify-between">
<div className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">{t('account.editWorkspaceInfo', { ns: 'common' })}</div>
<div className="i-ri-close-line h-4 w-4 cursor-pointer text-text-tertiary" data-testid="edit-workspace-close" onClick={onCancel} />
</div>
<div>
<div className="mb-2 text-sm font-medium text-text-primary">{t('account.workspaceName', { ns: 'common' })}</div>
<Input
className="mb-2"
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {
setName(e.target.value)
}}
onClear={() => {
setName(currentWorkspace.name)
}}
showClearIcon
/>
<Dialog
open
onOpenChange={(open) => {
if (!open)
onCancel()
}}
>
<DialogContent
backdropProps={{ forceRender: true }}
className="overflow-visible"
>
<DialogCloseButton data-testid="edit-workspace-close" />
<form
className="flex flex-col"
onSubmit={(e) => {
e.preventDefault()
void changeWorkspaceInfo()
}}
>
<div className="mb-4 pr-8">
<DialogTitle className="text-xl font-semibold text-text-primary" data-testid="edit-workspace-title">
{t('account.editWorkspaceInfo', { ns: 'common' })}
</DialogTitle>
</div>
<div className="space-y-2">
<label htmlFor={inputId} className="block text-sm font-medium text-text-primary">
{t('account.workspaceName', { ns: 'common' })}
</label>
<Input
id={inputId}
autoFocus
value={name}
placeholder={t('account.workspaceNamePlaceholder', { ns: 'common' })}
onChange={(e) => {
setName(e.target.value)
}}
aria-invalid={hasError}
aria-describedby={hasError ? errorId : undefined}
className={cn(
hasError && 'border-components-input-border-destructive bg-components-input-bg-destructive hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
)}
/>
<div className="min-h-6">
{hasError && (
<p
id={errorId}
data-testid="edit-workspace-error"
className="text-text-destructive system-xs-regular"
role="alert"
>
{nameErrorMessage}
</p>
)}
</div>
</div>
<div className="sticky bottom-0 -mx-2 mt-2 flex flex-wrap items-center justify-end gap-x-2 bg-components-panel-bg px-2 pt-4">
<Button
size="large"
type="button"
data-testid="edit-workspace-cancel"
onClick={onCancel}
>
@ -71,21 +131,22 @@ const EditWorkspaceModal = ({
</Button>
<Button
size="large"
type="submit"
variant="primary"
data-testid="edit-workspace-confirm"
onClick={() => {
changeWorkspaceInfo(name)
onCancel()
}}
disabled={!isCurrentWorkspaceOwner}
data-testid="edit-workspace-save"
disabled={isSaveDisabled}
loading={isSubmitting}
>
{t('operation.confirm', { ns: 'common' })}
{t(
isSubmitting ? 'operation.saving' : 'operation.save',
{ ns: 'common' },
)}
</Button>
</div>
</div>
</Modal>
</div>
</form>
</DialogContent>
</Dialog>
)
}
export default EditWorkspaceModal

View File

@ -3,7 +3,7 @@ import type { InvitationResult } from '@/models/common'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Avatar } from '@/app/components/base/avatar'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
@ -59,20 +59,25 @@ const MembersPage = () => {
<span>{currentWorkspace?.name}</span>
{isCurrentWorkspaceOwner && (
<span>
<Tooltip
popupContent={t('account.editWorkspaceInfo', { ns: 'common' })}
>
<div
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
onClick={() => {
setEditWorkspaceModalVisible(true)
}}
>
<div
data-testid="edit-workspace-pencil"
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
/>
</div>
<Tooltip>
<TooltipTrigger
render={(
<div
className="cursor-pointer rounded-md p-1 hover:bg-black/5"
onClick={() => {
setEditWorkspaceModalVisible(true)
}}
>
<div
data-testid="edit-workspace-pencil"
className="i-ri-pencil-line h-4 w-4 text-text-tertiary"
/>
</div>
)}
/>
<TooltipContent>
{t('account.editWorkspaceInfo', { ns: 'common' })}
</TooltipContent>
</Tooltip>
</span>
)}

View File

@ -97,7 +97,7 @@ const Operation = ({
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(prev => !prev)}>
<div className={cn('system-sm-regular group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
<div className={cn('group flex h-full w-full cursor-pointer items-center justify-between px-3 text-text-secondary system-sm-regular hover:bg-state-base-hover', open && 'bg-state-base-hover')}>
{RoleMap[member.role] || RoleMap.normal}
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 group-hover:block', open ? 'block' : 'hidden')} />
</div>
@ -114,8 +114,8 @@ const Operation = ({
: <div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
}
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-secondary system-sm-semibold">{t(roleI18nKeyMap[role].label, { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-tertiary system-xs-regular">{t(roleI18nKeyMap[role].tip, { ns: 'common' })}</div>
</div>
</div>
))
@ -125,8 +125,8 @@ const Operation = ({
<div className="flex cursor-pointer rounded-lg px-3 py-2 hover:bg-state-base-hover" onClick={handleDeleteMemberOrCancelInvitation}>
<div className="mr-1 mt-[2px] h-4 w-4 text-text-accent" />
<div>
<div className="system-sm-semibold whitespace-nowrap text-text-secondary">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="system-xs-regular whitespace-nowrap text-text-tertiary">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-secondary system-sm-semibold">{t('members.removeFromTeam', { ns: 'common' })}</div>
<div className="whitespace-nowrap text-text-tertiary system-xs-regular">{t('members.removeFromTeamTip', { ns: 'common' })}</div>
</div>
</div>
</div>

View File

@ -0,0 +1,42 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import MenuDialog from './menu-dialog'
type DialogProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: DialogProps['onOpenChange']
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="dialog">{children}</div>
},
DialogContent: ({ children, className }: { children: ReactNode, className?: string }) => (
<div className={className}>{children}</div>
),
}))
describe('MenuDialog dialog lifecycle', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should only call onClose when the dialog requests closing', () => {
const onClose = vi.fn()
render(
<MenuDialog show={true} onClose={onClose}>
<div>Content</div>
</MenuDialog>,
)
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
expect(onClose).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,7 +1,6 @@
import type { ReactNode } from 'react'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { noop } from 'es-toolkit/function'
import { Fragment, useCallback, useEffect } from 'react'
import { useCallback } from 'react'
import { Dialog, DialogContent } from '@/app/components/base/ui/dialog'
import { cn } from '@/utils/classnames'
type DialogProps = {
@ -19,42 +18,25 @@ const MenuDialog = ({
}: DialogProps) => {
const close = useCallback(() => onClose?.(), [onClose])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
close()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [close])
return (
<Transition appear show={show} as={Fragment}>
<Dialog as="div" className="relative z-[60]" onClose={noop}>
<div className="fixed inset-0">
<div className="flex min-h-full flex-col items-center justify-center">
<TransitionChild>
<DialogPanel className={cn(
'relative h-full w-full grow overflow-hidden bg-background-sidenav-bg p-0 text-left align-middle backdrop-blur-md transition-all',
'duration-300 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
'data-[enter]:scale-100 data-[enter]:opacity-100',
'data-[enter]:scale-95 data-[leave]:opacity-0',
className,
)}
>
<div className="absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg" />
{children}
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
<Dialog
open={show}
onOpenChange={(open) => {
if (!open)
close()
}}
>
<DialogContent
overlayClassName="bg-transparent"
className={cn(
'left-0 top-0 h-full max-h-none w-full max-w-none translate-x-0 translate-y-0 overflow-hidden rounded-none border-none bg-background-sidenav-bg p-0 shadow-none backdrop-blur-md',
className,
)}
>
<div className="absolute right-0 top-0 h-full w-1/2 bg-components-panel-bg" />
{children}
</DialogContent>
</Dialog>
)
}

View File

@ -9,6 +9,7 @@ import type {
} from '../declarations'
import { act, renderHook, waitFor } from '@testing-library/react'
import { useLocale } from '@/context/i18n'
import { consoleQuery } from '@/service/client'
import { fetchDefaultModal, fetchModelList, fetchModelProviderCredentials } from '@/service/common'
import {
ConfigurationMethodEnum,
@ -23,6 +24,7 @@ import {
useAnthropicBuyQuota,
useCurrentProviderAndModel,
useDefaultModel,
useInvalidateDefaultModel,
useLanguage,
useMarketplaceAllPlugins,
useModelList,
@ -36,7 +38,6 @@ import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from '../provider-added-card'
// Mock dependencies
vi.mock('@tanstack/react-query', () => ({
@ -78,14 +79,6 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: vi.fn(() => ({
eventEmitter: {
emit: vi.fn(),
},
})),
}))
vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
useMarketplacePlugins: vi.fn(() => ({
plugins: [],
@ -99,12 +92,16 @@ vi.mock('@/app/components/plugins/marketplace/hooks', () => ({
})),
}))
vi.mock('../atoms', () => ({
useExpandModelProviderList: vi.fn(() => vi.fn()),
}))
const { useQuery, useQueryClient } = await import('@tanstack/react-query')
const { getPayUrl } = await import('@/service/common')
const { useProviderContext } = await import('@/context/provider-context')
const { useModalContextSelector } = await import('@/context/modal-context')
const { useEventEmitterContextContext } = await import('@/context/event-emitter')
const { useMarketplacePlugins, useMarketplacePluginsByCollectionId } = await import('@/app/components/plugins/marketplace/hooks')
const { useExpandModelProviderList } = await import('../atoms')
describe('hooks', () => {
beforeEach(() => {
@ -913,6 +910,38 @@ describe('hooks', () => {
})
})
describe('useInvalidateDefaultModel', () => {
it('should invalidate default model queries', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const { result } = renderHook(() => useInvalidateDefaultModel())
act(() => {
result.current(ModelTypeEnum.textGeneration)
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['default-model', ModelTypeEnum.textGeneration],
})
})
it('should handle multiple model types', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const { result } = renderHook(() => useInvalidateDefaultModel())
act(() => {
result.current(ModelTypeEnum.textGeneration)
result.current(ModelTypeEnum.textEmbedding)
result.current(ModelTypeEnum.rerank)
})
expect(invalidateQueries).toHaveBeenCalledTimes(3)
})
})
describe('useAnthropicBuyQuota', () => {
beforeEach(() => {
Object.defineProperty(window, 'location', {
@ -1275,39 +1304,52 @@ describe('hooks', () => {
it('should refresh providers and model lists', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = createMockProvider()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider)
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-providers'] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textEmbedding] })
})
it('should emit event when refreshModelList is true and custom config is active', () => {
it('should expand target provider list when refreshModelList is true and custom config is active', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
const expandModelProviderList = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
const provider = createMockProvider()
const customFields: CustomConfigurationModelFixedFields = {
__model_name: 'gpt-4',
__model_type: ModelTypeEnum.textGeneration,
}
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
@ -1315,23 +1357,30 @@ describe('hooks', () => {
result.current.handleRefreshModel(provider, customFields, true)
})
expect(emit).toHaveBeenCalledWith({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: 'openai',
expect(expandModelProviderList).toHaveBeenCalledWith('openai')
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['model-list', ModelTypeEnum.textGeneration] })
})
it('should not emit event when custom config is not active', () => {
it('should not expand provider list when custom config is not active', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
const expandModelProviderList = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
; (useExpandModelProviderList as Mock).mockReturnValue(expandModelProviderList)
const provider = { ...createMockProvider(), custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure } }
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
@ -1339,17 +1388,43 @@ describe('hooks', () => {
result.current.handleRefreshModel(provider, undefined, true)
})
expect(emit).not.toHaveBeenCalled()
expect(expandModelProviderList).not.toHaveBeenCalled()
expect(invalidateQueries).not.toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
})
it('should emit event and invalidate all supported model types when __model_type is undefined', () => {
it('should refetch active model provider list when custom refresh callback is absent', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
const provider = createMockProvider()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const { result } = renderHook(() => useRefreshModel())
act(() => {
result.current.handleRefreshModel(provider, undefined, true)
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
})
it('should invalidate all supported model types when __model_type is undefined', () => {
const invalidateQueries = vi.fn()
const emit = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit },
})
const provider = createMockProvider()
const customFields = { __model_name: 'my-model', __model_type: undefined } as unknown as CustomConfigurationModelFixedFields
@ -1360,11 +1435,7 @@ describe('hooks', () => {
result.current.handleRefreshModel(provider, customFields, true)
})
expect(emit).toHaveBeenCalledWith({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: 'openai',
})
// When __model_type is undefined, all supported model types are invalidated
// When __model_type is undefined, all supported model types are invalidated.
const modelListCalls = invalidateQueries.mock.calls.filter(
call => call[0]?.queryKey?.[0] === 'model-list',
)
@ -1375,9 +1446,6 @@ describe('hooks', () => {
const invalidateQueries = vi.fn()
; (useQueryClient as Mock).mockReturnValue({ invalidateQueries })
; (useEventEmitterContextContext as Mock).mockReturnValue({
eventEmitter: { emit: vi.fn() },
})
const provider = {
...createMockProvider(),

View File

@ -7,16 +7,7 @@ import {
} from '../declarations'
import ModelProviderPage from '../index'
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
mutateCurrentWorkspace: vi.fn(),
isValidatingCurrentWorkspace: false,
}),
}))
const mockGlobalState = {
systemFeatures: { enable_marketplace: true },
}
let mockEnableMarketplace = true
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
@ -28,7 +19,11 @@ const mockQuotaConfig = {
}
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (s: { systemFeatures: { enable_marketplace: boolean } }) => unknown) => selector(mockGlobalState),
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: mockEnableMarketplace,
},
}),
}))
const mockProviders = [
@ -60,21 +55,16 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
type MockDefaultModelData = {
model: string
provider?: { provider: string }
} | null
const mockDefaultModelState: {
data: MockDefaultModelData
isLoading: boolean
} = {
data: null,
isLoading: false,
const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> = {
'llm': { data: null, isLoading: false },
'text-embedding': { data: null, isLoading: false },
'rerank': { data: null, isLoading: false },
'speech2text': { data: null, isLoading: false },
'tts': { data: null, isLoading: false },
}
vi.mock('../hooks', () => ({
useDefaultModel: () => mockDefaultModelState,
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
}))
vi.mock('../install-from-marketplace', () => ({
@ -93,13 +83,31 @@ vi.mock('../system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
checkInstalled: { queryOptions: () => ({}) },
latestVersions: { queryOptions: () => ({}) },
},
},
}))
describe('ModelProviderPage', () => {
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
mockGlobalState.systemFeatures.enable_marketplace = true
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = false
mockEnableMarketplace = true
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: false }
})
mockProviders.splice(0, mockProviders.length, {
provider: 'openai',
label: { en_US: 'OpenAI' },
@ -157,13 +165,76 @@ describe('ModelProviderPage', () => {
})
it('should hide marketplace section when marketplace feature is disabled', () => {
mockGlobalState.systemFeatures.enable_marketplace = false
mockEnableMarketplace = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument()
})
describe('system model config status', () => {
it('should not show top warning when no configured providers exist (empty state card handles it)', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'anthropic',
label: { en_US: 'Anthropic' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.emptyProviderTitle')).toBeInTheDocument()
})
it('should show none-configured warning when providers exist but no default models set', () => {
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.noneConfigured')).toBeInTheDocument()
})
it('should show partially-configured warning when some default models are set', () => {
mockDefaultModels.llm = {
data: { model: 'gpt-4', model_type: 'llm', provider: { provider: 'openai', icon_small: { en_US: '' } } },
isLoading: false,
}
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
it('should not show warning when all default models are configured', () => {
const makeModel = (model: string, type: string) => ({
data: { model, model_type: type, provider: { provider: 'openai', icon_small: { en_US: '' } } },
isLoading: false,
})
mockDefaultModels.llm = makeModel('gpt-4', 'llm')
mockDefaultModels['text-embedding'] = makeModel('text-embedding-3', 'text-embedding')
mockDefaultModels.rerank = makeModel('rerank-v3', 'rerank')
mockDefaultModels.speech2text = makeModel('whisper-1', 'speech2text')
mockDefaultModels.tts = makeModel('tts-1', 'tts')
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show warning while loading', () => {
Object.keys(mockDefaultModels).forEach((key) => {
mockDefaultModels[key] = { data: null, isLoading: true }
})
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.noProviderInstalled')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.noneConfigured')).not.toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
})
it('should prioritize fixed providers in visible order', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'zeta-provider',
@ -204,129 +275,4 @@ describe('ModelProviderPage', () => {
])
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should show not configured alert when all default models are absent', () => {
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.notConfigured')).toBeInTheDocument()
})
it('should not show not configured alert when default model is loading', () => {
mockDefaultModelState.data = null
mockDefaultModelState.isLoading = true
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should filter providers by label text', () => {
render(<ModelProviderPage searchText="OpenAI" />)
act(() => {
vi.advanceTimersByTime(600)
})
expect(screen.getByText('openai')).toBeInTheDocument()
expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
})
it('should classify system-enabled providers with matching quota as configured', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'sys-provider',
label: { en_US: 'System Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('sys-provider')).toBeInTheDocument()
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
})
it('should classify system-enabled provider with no matching quota as not configured', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'sys-no-quota',
label: { en_US: 'System No Quota' },
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
render(<ModelProviderPage searchText="" />)
expect(screen.getByText('sys-no-quota')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
})
it('should preserve order of two non-fixed providers (sort returns 0)', () => {
mockProviders.splice(0, mockProviders.length, {
provider: 'alpha-provider',
label: { en_US: 'Alpha Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}, {
provider: 'beta-provider',
label: { en_US: 'Beta Provider' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
})
render(<ModelProviderPage searchText="" />)
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
expect(renderedProviders).toEqual(['alpha-provider', 'beta-provider'])
})
it('should not show not configured alert when shared default model mock has data', () => {
mockDefaultModelState.data = { model: 'embed-model' }
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when rerankDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'rerank-model', provider: { provider: 'cohere' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when ttsDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'tts-model', provider: { provider: 'openai' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
it('should not show not configured alert when speech2textDefaultModel has data', () => {
mockDefaultModelState.data = { model: 'whisper', provider: { provider: 'openai' } }
mockDefaultModelState.isLoading = false
render(<ModelProviderPage searchText="" />)
expect(screen.queryByText('common.modelProvider.notConfigured')).not.toBeInTheDocument()
})
})

View File

@ -61,8 +61,15 @@ describe('InstallFromMarketplace', () => {
it('should collapse when clicked', () => {
render(<InstallFromMarketplace providers={mockProviders} searchText="" />)
fireEvent.click(screen.getByText('common.modelProvider.installProvider'))
const toggle = screen.getByRole('button', { name: /common\.modelProvider\.installProvider/ })
fireEvent.click(toggle)
expect(screen.queryByTestId('plugin-list')).not.toBeInTheDocument()
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('plugin-list')).toBeInTheDocument()
})
it('should show loading state', () => {

View File

@ -16,6 +16,7 @@ import {
genModelNameFormSchema,
genModelTypeFormSchema,
modelTypeFormat,
providerToPluginId,
removeCredentials,
saveCredentials,
savePredefinedLoadBalancingConfig,
@ -47,6 +48,16 @@ describe('utils', () => {
})
})
describe('providerToPluginId', () => {
it('should return the plugin id prefix when the provider key contains a provider segment', () => {
expect(providerToPluginId('langgenius/openai/openai')).toBe('langgenius/openai')
})
it('should return an empty string when the provider key has no plugin prefix', () => {
expect(providerToPluginId('openai')).toBe('')
})
})
describe('modelTypeFormat', () => {
it('should format text embedding type', () => {
expect(modelTypeFormat(ModelTypeEnum.textEmbedding)).toBe('TEXT EMBEDDING')

View File

@ -0,0 +1,399 @@
import type { ReactNode } from 'react'
import { act, renderHook } from '@testing-library/react'
import { Provider } from 'jotai'
import { beforeEach, describe, expect, it } from 'vitest'
import {
useExpandModelProviderList,
useModelProviderListExpanded,
useResetModelProviderListExpanded,
useSetModelProviderListExpanded,
} from './atoms'
const createWrapper = () => {
return ({ children }: { children: ReactNode }) => (
<Provider>{children}</Provider>
)
}
describe('atoms', () => {
let wrapper: ReturnType<typeof createWrapper>
beforeEach(() => {
wrapper = createWrapper()
})
// Read hook: returns whether a specific provider is expanded
describe('useModelProviderListExpanded', () => {
it('should return false when provider has not been expanded', () => {
const { result } = renderHook(
() => useModelProviderListExpanded('openai'),
{ wrapper },
)
expect(result.current).toBe(false)
})
it('should return false for any unknown provider name', () => {
const { result } = renderHook(
() => useModelProviderListExpanded('nonexistent-provider'),
{ wrapper },
)
expect(result.current).toBe(false)
})
it('should return true when provider has been expanded via setter', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
expect(result.current.expanded).toBe(true)
})
})
// Setter hook: toggles expanded state for a specific provider
describe('useSetModelProviderListExpanded', () => {
it('should expand a provider when called with true', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('anthropic'),
setExpanded: useSetModelProviderListExpanded('anthropic'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
expect(result.current.expanded).toBe(true)
})
it('should collapse a provider when called with false', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('anthropic'),
setExpanded: useSetModelProviderListExpanded('anthropic'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
act(() => {
result.current.setExpanded(false)
})
expect(result.current.expanded).toBe(false)
})
it('should not affect other providers when setting one', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
setOpenai: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.setOpenai(true)
})
expect(result.current.openaiExpanded).toBe(true)
expect(result.current.anthropicExpanded).toBe(false)
})
})
// Expand hook: expands any provider by name
describe('useExpandModelProviderList', () => {
it('should expand the specified provider', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('google'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('google')
})
expect(result.current.expanded).toBe(true)
})
it('should expand multiple providers independently', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
expect(result.current.openaiExpanded).toBe(true)
expect(result.current.anthropicExpanded).toBe(true)
})
it('should not collapse already expanded providers when expanding another', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
expect(result.current.openaiExpanded).toBe(true)
})
})
// Reset hook: clears all expanded state back to empty
describe('useResetModelProviderListExpanded', () => {
it('should reset all expanded providers to false', () => {
const { result } = renderHook(
() => ({
openaiExpanded: useModelProviderListExpanded('openai'),
anthropicExpanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.expand('anthropic')
})
act(() => {
result.current.reset()
})
expect(result.current.openaiExpanded).toBe(false)
expect(result.current.anthropicExpanded).toBe(false)
})
it('should be safe to call when no providers are expanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
it('should allow re-expanding providers after reset', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
expand: useExpandModelProviderList(),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
act(() => {
result.current.reset()
})
act(() => {
result.current.expand('openai')
})
expect(result.current.expanded).toBe(true)
})
})
// Cross-hook interaction: verify hooks cooperate through the shared atom
describe('Cross-hook interaction', () => {
it('should reflect state set by useSetModelProviderListExpanded in useModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
expect(result.current.expanded).toBe(true)
})
it('should reflect state set by useExpandModelProviderList in useModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('anthropic'),
expand: useExpandModelProviderList(),
}),
{ wrapper },
)
act(() => {
result.current.expand('anthropic')
})
expect(result.current.expanded).toBe(true)
})
it('should allow useSetModelProviderListExpanded to collapse a provider expanded by useExpandModelProviderList', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
expand: useExpandModelProviderList(),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
act(() => {
result.current.expand('openai')
})
expect(result.current.expanded).toBe(true)
act(() => {
result.current.setExpanded(false)
})
expect(result.current.expanded).toBe(false)
})
it('should reset state set by useSetModelProviderListExpanded via useResetModelProviderListExpanded', () => {
const { result } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
reset: useResetModelProviderListExpanded(),
}),
{ wrapper },
)
act(() => {
result.current.setExpanded(true)
})
act(() => {
result.current.reset()
})
expect(result.current.expanded).toBe(false)
})
})
// selectAtom granularity: changing one provider should not affect unrelated reads
describe('selectAtom granularity', () => {
it('should not cause unrelated provider reads to change when one provider is toggled', () => {
const { result } = renderHook(
() => ({
openai: useModelProviderListExpanded('openai'),
anthropic: useModelProviderListExpanded('anthropic'),
google: useModelProviderListExpanded('google'),
setOpenai: useSetModelProviderListExpanded('openai'),
}),
{ wrapper },
)
const anthropicBefore = result.current.anthropic
const googleBefore = result.current.google
act(() => {
result.current.setOpenai(true)
})
expect(result.current.openai).toBe(true)
expect(result.current.anthropic).toBe(anthropicBefore)
expect(result.current.google).toBe(googleBefore)
})
it('should keep individual provider states independent across multiple expansions and collapses', () => {
const { result } = renderHook(
() => ({
openai: useModelProviderListExpanded('openai'),
anthropic: useModelProviderListExpanded('anthropic'),
setOpenai: useSetModelProviderListExpanded('openai'),
setAnthropic: useSetModelProviderListExpanded('anthropic'),
}),
{ wrapper },
)
act(() => {
result.current.setOpenai(true)
})
act(() => {
result.current.setAnthropic(true)
})
act(() => {
result.current.setOpenai(false)
})
expect(result.current.openai).toBe(false)
expect(result.current.anthropic).toBe(true)
})
})
// Isolation: separate Provider instances have independent state
describe('Provider isolation', () => {
it('should have independent state across different Provider instances', () => {
const wrapper1 = createWrapper()
const wrapper2 = createWrapper()
const { result: result1 } = renderHook(
() => ({
expanded: useModelProviderListExpanded('openai'),
setExpanded: useSetModelProviderListExpanded('openai'),
}),
{ wrapper: wrapper1 },
)
const { result: result2 } = renderHook(
() => useModelProviderListExpanded('openai'),
{ wrapper: wrapper2 },
)
act(() => {
result1.current.setExpanded(true)
})
expect(result1.current.expanded).toBe(true)
expect(result2.current).toBe(false)
})
})
})

View File

@ -0,0 +1,35 @@
import { atom, useAtomValue, useSetAtom } from 'jotai'
import { selectAtom } from 'jotai/utils'
import { useCallback, useMemo } from 'react'
const expandedAtom = atom<Record<string, boolean>>({})
export function useModelProviderListExpanded(providerName: string) {
return useAtomValue(
useMemo(
() => selectAtom(expandedAtom, s => !!s[providerName]),
[providerName],
),
)
}
export function useSetModelProviderListExpanded(providerName: string) {
const set = useSetAtom(expandedAtom)
return useCallback(
(expanded: boolean) => set(prev => ({ ...prev, [providerName]: expanded })),
[providerName, set],
)
}
export function useExpandModelProviderList() {
const set = useSetAtom(expandedAtom)
return useCallback(
(providerName: string) => set(prev => ({ ...prev, [providerName]: true })),
[set],
)
}
export function useResetModelProviderListExpanded() {
const set = useSetAtom(expandedAtom)
return useCallback(() => set({}), [set])
}

View File

@ -0,0 +1,160 @@
import type { Model, ModelItem, ModelProvider } from './declarations'
import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from './declarations'
import { deriveModelStatus } from './derive-model-status'
const createCredentialState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 100,
...overrides,
})
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const createModelProvider = (): ModelProvider =>
({ provider: 'openai' } as ModelProvider)
const createModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [createModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('deriveModelStatus', () => {
it('should return empty when model id or provider name is missing', () => {
expect(
deriveModelStatus('', 'openai', createModelProvider(), createModelItem(), createCredentialState()),
).toBe('empty')
expect(
deriveModelStatus('text-embedding-3-large', '', createModelProvider(), createModelItem(), createCredentialState()),
).toBe('empty')
})
it('should return incompatible when provider plugin is missing', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', undefined, createModelItem(), createCredentialState()),
).toBe('incompatible')
})
it('should return incompatible when model is missing from the provider list', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', createModel(), undefined, createCredentialState()),
).toBe('incompatible')
})
it('should return credits-exhausted when model is missing and AI credits are exhausted without api key', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
undefined,
createCredentialState({
priority: 'apiKey',
hasCredentials: false,
isCreditsExhausted: true,
}),
),
).toBe('credits-exhausted')
})
it('should return configure-required when the model status is no-configure', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.noConfigure }), createCredentialState()),
).toBe('configure-required')
})
it('should return disabled when the model status is disabled', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem({ status: ModelStatusEnum.disabled }), createCredentialState()),
).toBe('disabled')
})
it('should return credits-exhausted when credential state takes priority', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem(),
createCredentialState({ isCreditsExhausted: true }),
),
).toBe('credits-exhausted')
})
it('should return api-key-unavailable when credential state is api-unavailable', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem(),
createCredentialState({ variant: 'api-unavailable', priority: 'apiKey' }),
),
).toBe('api-key-unavailable')
})
it('should return credits-exhausted when model status is quota exceeded', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.quotaExceeded }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('credits-exhausted')
})
it('should return api-key-unavailable when model status is credential removed', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.credentialRemoved }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('api-key-unavailable')
})
it('should return incompatible when model status is no-permission', () => {
expect(
deriveModelStatus(
'text-embedding-3-large',
'openai',
createModelProvider(),
createModelItem({ status: ModelStatusEnum.noPermission }),
createCredentialState({ priority: 'apiKey' }),
),
).toBe('incompatible')
})
it('should return active when model and credential state are available', () => {
expect(
deriveModelStatus('text-embedding-3-large', 'openai', createModelProvider(), createModelItem(), createCredentialState()),
).toBe('active')
})
})

View File

@ -0,0 +1,72 @@
import type { Model, ModelItem, ModelProvider } from './declarations'
import type { CredentialPanelState } from './provider-added-card/use-credential-panel-state'
import { ModelStatusEnum } from './declarations'
export type DerivedModelStatus
= | 'empty'
| 'active'
| 'configure-required'
| 'credits-exhausted'
| 'api-key-unavailable'
| 'disabled'
| 'incompatible'
export const DERIVED_MODEL_STATUS_BADGE_I18N = {
'configure-required': 'modelProvider.selector.configureRequired',
'credits-exhausted': 'modelProvider.selector.creditsExhausted',
'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailable',
'disabled': 'modelProvider.selector.disabled',
'incompatible': 'modelProvider.selector.incompatible',
} as const satisfies Partial<Record<DerivedModelStatus, string>>
export const DERIVED_MODEL_STATUS_TOOLTIP_I18N = {
'credits-exhausted': 'modelProvider.selector.creditsExhaustedTip',
'api-key-unavailable': 'modelProvider.selector.apiKeyUnavailableTip',
'incompatible': 'modelProvider.selector.incompatibleTip',
} as const satisfies Partial<Record<DerivedModelStatus, string>>
export const deriveModelStatus = (
modelId: string | undefined,
providerName: string | undefined,
currentModelProvider: ModelProvider | Model | undefined,
currentModel: ModelItem | undefined,
credentialState: CredentialPanelState,
): DerivedModelStatus => {
if (!modelId || !providerName)
return 'empty'
if (!currentModelProvider)
return 'incompatible'
const isCreditsExhaustedWithoutApiKey = credentialState.supportsCredits
&& credentialState.isCreditsExhausted
&& !credentialState.hasCredentials
const isCreditsPriorityExhausted = credentialState.priority === 'credits'
&& credentialState.supportsCredits
&& credentialState.isCreditsExhausted
if (isCreditsPriorityExhausted || isCreditsExhaustedWithoutApiKey)
return 'credits-exhausted'
if (!currentModel)
return 'incompatible'
if (credentialState.variant === 'api-unavailable')
return 'api-key-unavailable'
switch (currentModel.status) {
case ModelStatusEnum.active:
return 'active'
case ModelStatusEnum.noConfigure:
return 'configure-required'
case ModelStatusEnum.quotaExceeded:
return 'credits-exhausted'
case ModelStatusEnum.credentialRemoved:
return 'api-key-unavailable'
case ModelStatusEnum.disabled:
return 'disabled'
case ModelStatusEnum.noPermission:
default:
return 'incompatible'
}
}

View File

@ -21,10 +21,10 @@ import {
useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks'
import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useLocale } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import {
fetchDefaultModal,
fetchModelList,
@ -32,12 +32,12 @@ import {
getPayUrl,
} from '@/service/common'
import { commonQueryKeys } from '@/service/use-common'
import { useExpandModelProviderList } from './atoms'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelStatusEnum,
} from './declarations'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
type UseDefaultModelAndModelList = (
defaultModel: DefaultModelResponse | undefined,
@ -57,15 +57,21 @@ export const useSystemDefaultModelAndModelList: UseDefaultModelAndModelList = (
return currentDefaultModel
}, [defaultModel, modelList])
const currentDefaultModelKey = currentDefaultModel
? `${currentDefaultModel.provider}:${currentDefaultModel.model}`
: ''
const [defaultModelState, setDefaultModelState] = useState<DefaultModel | undefined>(currentDefaultModel)
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
setDefaultModelState(model)
}, [])
useEffect(() => {
setDefaultModelState(currentDefaultModel)
}, [currentDefaultModel])
const [defaultModelSourceKey, setDefaultModelSourceKey] = useState(currentDefaultModelKey)
const selectedDefaultModel = defaultModelSourceKey === currentDefaultModelKey
? defaultModelState
: currentDefaultModel
return [defaultModelState, handleDefaultModelChange]
const handleDefaultModelChange = useCallback((model: DefaultModel) => {
setDefaultModelSourceKey(currentDefaultModelKey)
setDefaultModelState(model)
}, [currentDefaultModelKey])
return [selectedDefaultModel, handleDefaultModelChange]
}
export const useLanguage = () => {
@ -116,7 +122,7 @@ export const useProviderCredentialsAndLoadBalancing = (
predefinedFormSchemasValue?.credentials,
])
const mutate = useMemo(() => () => {
const mutate = useCallback(() => {
if (predefinedEnabled)
queryClient.invalidateQueries({ queryKey: ['model-providers', 'credentials', provider, credentialId] })
if (customEnabled)
@ -222,6 +228,14 @@ export const useUpdateModelList = () => {
return updateModelList
}
export const useInvalidateDefaultModel = () => {
const queryClient = useQueryClient()
return useCallback((type: ModelTypeEnum) => {
queryClient.invalidateQueries({ queryKey: commonQueryKeys.defaultModel(type) })
}, [queryClient])
}
export const useAnthropicBuyQuota = () => {
const [loading, setLoading] = useState(false)
@ -314,7 +328,8 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
}
export const useRefreshModel = () => {
const { eventEmitter } = useEventEmitterContextContext()
const expandModelProviderList = useExpandModelProviderList()
const queryClient = useQueryClient()
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const handleRefreshModel = useCallback((
@ -322,6 +337,19 @@ export const useRefreshModel = () => {
CustomConfigurationModelFixedFields?: CustomConfigurationModelFixedFields,
refreshModelList?: boolean,
) => {
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()
provider.supported_model_types.forEach((type) => {
@ -329,15 +357,17 @@ export const useRefreshModel = () => {
})
if (refreshModelList && provider.custom_configuration.status === CustomConfigurationStatusEnum.active) {
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
expandModelProviderList(provider.provider)
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'active',
})
if (CustomConfigurationModelFixedFields?.__model_type)
updateModelList(CustomConfigurationModelFixedFields.__model_type)
}
}, [eventEmitter, updateModelList, updateModelProviders])
}, [expandModelProviderList, queryClient, updateModelList, updateModelProviders])
return {
handleRefreshModel,

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import {
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
QuotaUnitEnum,
} from './declarations'
import ModelProviderPage from './index'
const mockQuotaConfig = {
quota_type: CurrentSystemQuotaTypeEnum.free,
quota_unit: QuotaUnitEnum.times,
quota_limit: 100,
quota_used: 1,
last_used: 0,
is_valid: true,
}
vi.mock('@/config', () => ({
IS_CLOUD_EDITION: false,
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: {
enable_marketplace: false,
},
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{
provider: 'openai',
label: { en_US: 'OpenAI' },
custom_configuration: { status: CustomConfigurationStatusEnum.active },
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [mockQuotaConfig],
},
}],
}),
}))
vi.mock('./hooks', () => ({
useDefaultModel: () => ({ data: null, isLoading: false }),
}))
vi.mock('./provider-added-card', () => ({
default: () => <div data-testid="provider-card" />,
}))
vi.mock('./provider-added-card/quota-panel', () => ({
default: () => <div data-testid="quota-panel" />,
}))
vi.mock('./system-model-selector', () => ({
default: () => <div data-testid="system-model-selector" />,
}))
vi.mock('./install-from-marketplace', () => ({
default: () => <div data-testid="install-from-marketplace" />,
}))
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('@/service/client', () => ({
consoleQuery: {
plugins: {
checkInstalled: { queryOptions: () => ({}) },
latestVersions: { queryOptions: () => ({}) },
},
},
}))
describe('ModelProviderPage non-cloud branch', () => {
it('should skip the quota panel when cloud edition is disabled', () => {
render(<ModelProviderPage searchText="" />)
expect(screen.getByTestId('system-model-selector')).toBeInTheDocument()
expect(screen.queryByTestId('quota-panel')).not.toBeInTheDocument()
})
})

View File

@ -1,17 +1,16 @@
import type {
ModelProvider,
} from './declarations'
import {
RiAlertFill,
RiBrainLine,
} from '@remixicon/react'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useQuery } from '@tanstack/react-query'
import { useDebounce } from 'ahooks'
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
import { IS_CLOUD_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useProviderContext } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { cn } from '@/utils/classnames'
import {
CustomConfigurationStatusEnum,
@ -24,6 +23,9 @@ import InstallFromMarketplace from './install-from-marketplace'
import ProviderAddedCard from './provider-added-card'
import QuotaPanel from './provider-added-card/quota-panel'
import SystemModelSelector from './system-model-selector'
import { providerToPluginId } from './utils'
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
type Props = {
searchText: string
@ -34,20 +36,35 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({ searchText }: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const { mutateCurrentWorkspace, isValidatingCurrentWorkspace } = useAppContext()
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers } = useProviderContext()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const { data: systemFeatures } = useSystemFeaturesQuery()
const allPluginIds = useMemo(() => {
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
}, [providers])
const { data: installedPlugins } = useQuery(consoleQuery.plugins.checkInstalled.queryOptions({
input: { body: { plugin_ids: allPluginIds } },
enabled: allPluginIds.length > 0,
staleTime: 0,
}))
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
const pluginDetailMap = useMemo(() => {
const map = new Map<string, PluginDetail>()
for (const plugin of enrichedPlugins)
map.set(plugin.plugin_id, plugin)
return map
}, [enrichedPlugins])
const enableMarketplace = systemFeatures?.enable_marketplace ?? false
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading
|| isRerankDefaultModelLoading
|| isSpeech2textDefaultModelLoading
|| isTTSDefaultModelLoading
const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
const configuredProviders: ModelProvider[] = []
const notConfiguredProviders: ModelProvider[] = []
@ -57,7 +74,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
provider.custom_configuration.status === CustomConfigurationStatusEnum.active
|| (
provider.system_configuration.enabled === true
&& provider.system_configuration.quota_configurations.find(item => item.quota_type === provider.system_configuration.current_quota_type)
&& provider.system_configuration.quota_configurations.some(item => item.quota_type === provider.system_configuration.current_quota_type)
)
) {
configuredProviders.push(provider)
@ -79,6 +96,26 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [configuredProviders, notConfiguredProviders]
}, [providers])
const systemModelConfigStatus: SystemModelConfigStatus = useMemo(() => {
const defaultModels = [textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel]
const configuredCount = defaultModels.filter(Boolean).length
if (configuredCount === 0 && configuredProviders.length === 0)
return 'no-provider'
if (configuredCount === 0)
return 'none-configured'
if (configuredCount < defaultModels.length)
return 'partially-configured'
return 'fully-configured'
}, [configuredProviders, textGenerationDefaultModel, embeddingsDefaultModel, rerankDefaultModel, speech2textDefaultModel, ttsDefaultModel])
const warningTextKey
= systemModelConfigStatus === 'none-configured'
? 'modelProvider.noneConfigured'
: systemModelConfigStatus === 'partially-configured'
? 'modelProvider.notConfigured'
: null
const showWarning = !isDefaultModelLoading && !!warningTextKey
const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => {
const filteredConfiguredProviders = configuredProviders.filter(
provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase())
@ -92,28 +129,24 @@ const ModelProviderPage = ({ searchText }: Props) => {
return [filteredConfiguredProviders, filteredNotConfiguredProviders]
}, [configuredProviders, debouncedSearchText, notConfiguredProviders])
useEffect(() => {
mutateCurrentWorkspace()
}, [mutateCurrentWorkspace])
return (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>
<div className="grow text-text-primary system-md-semibold">{t('modelProvider.models', { ns: 'common' })}</div>
<div className={cn(
'relative flex shrink-0 items-center justify-end gap-2 rounded-lg border border-transparent p-px',
defaultModelNotConfigured && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
showWarning && 'border-components-panel-border bg-components-panel-bg-blur pl-2 shadow-xs',
)}
>
{defaultModelNotConfigured && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
{defaultModelNotConfigured && (
{showWarning && <div className="absolute bottom-0 left-0 right-0 top-0 opacity-40" style={{ background: 'linear-gradient(92deg, rgba(247, 144, 9, 0.25) 0%, rgba(255, 255, 255, 0.00) 100%)' }} />}
{showWarning && (
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
<RiAlertFill className="h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t('modelProvider.notConfigured', { ns: 'common' })}>{t('modelProvider.notConfigured', { ns: 'common' })}</span>
<span className="i-ri-alert-fill h-4 w-4 text-text-warning-secondary" />
<span className="max-w-[460px] truncate" title={t(warningTextKey, { ns: 'common' })}>{t(warningTextKey, { ns: 'common' })}</span>
</div>
)}
<SystemModelSelector
notConfigured={defaultModelNotConfigured}
notConfigured={showWarning}
textGenerationDefaultModel={textGenerationDefaultModel}
embeddingsDefaultModel={embeddingsDefaultModel}
rerankDefaultModel={rerankDefaultModel}
@ -123,11 +156,11 @@ const ModelProviderPage = ({ searchText }: Props) => {
/>
</div>
</div>
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} isLoading={isValidatingCurrentWorkspace} />}
{IS_CLOUD_EDITION && <QuotaPanel providers={providers} />}
{!filteredConfiguredProviders?.length && (
<div className="mb-2 rounded-[10px] bg-workflow-process-bg p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur">
<RiBrainLine className="h-5 w-5 text-text-primary" />
<span className="i-ri-brain-line h-5 w-5 text-text-primary" />
</div>
<div className="mt-2 text-text-secondary system-sm-medium">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
<div className="mt-1 text-text-tertiary system-xs-regular">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</div>
@ -139,6 +172,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
<ProviderAddedCard
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
@ -152,13 +186,14 @@ const ModelProviderPage = ({ searchText }: Props) => {
notConfigured
key={provider.provider}
provider={provider}
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
/>
))}
</div>
</>
)}
{
enable_marketplace && (
enableMarketplace && (
<InstallFromMarketplace
providers={providers}
searchText={searchText}

View File

@ -2,10 +2,6 @@ import type {
ModelProvider,
} from './declarations'
import type { Plugin } from '@/app/components/plugins/types'
import {
RiArrowDownSLine,
RiArrowRightUpLine,
} from '@remixicon/react'
import { useTheme } from 'next-themes'
import Link from 'next/link'
import { useCallback, useState } from 'react'
@ -47,15 +43,25 @@ const InstallFromMarketplace = ({
<div className="mb-2">
<Divider className="!mt-4 h-px" />
<div className="flex items-center justify-between">
<div className="system-md-semibold flex cursor-pointer items-center gap-1 text-text-primary" onClick={() => setCollapse(!collapse)}>
<RiArrowDownSLine className={cn('h-4 w-4', collapse && '-rotate-90')} />
<button
type="button"
className="flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0 text-left text-text-primary system-md-semibold"
onClick={() => setCollapse(prev => !prev)}
aria-expanded={!collapse}
>
<span className={cn('i-ri-arrow-down-s-line h-4 w-4', collapse && '-rotate-90')} />
{t('modelProvider.installProvider', { ns: 'common' })}
</div>
</button>
<div className="mb-2 flex items-center pt-2">
<span className="system-sm-regular pr-1 text-text-tertiary">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
<Link target="_blank" href={getMarketplaceUrl('', { theme })} className="system-sm-medium inline-flex items-center text-text-accent">
<span className="pr-1 text-text-tertiary system-sm-regular">{t('modelProvider.discoverMore', { ns: 'common' })}</span>
<Link
target="_blank"
rel="noopener noreferrer"
href={getMarketplaceUrl('', { theme })}
className="inline-flex items-center text-text-accent system-sm-medium"
>
{t('marketplace.difyMarketplace', { ns: 'plugin' })}
<RiArrowRightUpLine className="h-4 w-4" />
<span className="i-ri-arrow-right-up-line h-4 w-4" />
</Link>
</div>
</div>

View File

@ -2,12 +2,6 @@ import type { Credential } from '../../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import CredentialItem from '../credential-item'
vi.mock('@remixicon/react', () => ({
RiCheckLine: () => <div data-testid="check-icon" />,
RiDeleteBinLine: () => <div data-testid="delete-icon" />,
RiEqualizer2Line: () => <div data-testid="edit-icon" />,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: () => <div data-testid="indicator" />,
}))
@ -61,8 +55,12 @@ describe('CredentialItem', () => {
render(<CredentialItem credential={credential} onEdit={onEdit} onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('edit-icon').closest('button') as HTMLButtonElement)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
const buttons = screen.getAllByRole('button')
const editButton = buttons.find(b => b.querySelector('.i-ri-equalizer-2-line'))!
const deleteButton = buttons.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(editButton)
fireEvent.click(deleteButton)
expect(onEdit).toHaveBeenCalledWith(credential)
expect(onDelete).toHaveBeenCalledWith(credential)
@ -81,7 +79,10 @@ describe('CredentialItem', () => {
/>,
)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
const deleteButton = screen.getAllByRole('button')
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(deleteButton)
expect(onDelete).not.toHaveBeenCalled()
})
@ -121,14 +122,16 @@ describe('CredentialItem', () => {
render(<CredentialItem credential={credential} disabled onDelete={onDelete} />)
fireEvent.click(screen.getByTestId('delete-icon').closest('button') as HTMLButtonElement)
const deleteButton = screen.getAllByRole('button')
.find(b => b.querySelector('.i-ri-delete-bin-line'))!
fireEvent.click(deleteButton)
expect(onDelete).not.toHaveBeenCalled()
})
// showSelectedIcon=true: check icon area is always rendered; check icon only appears when IDs match
it('should render check icon area when showSelectedIcon=true and selectedCredentialId matches', () => {
render(
const { container } = render(
<CredentialItem
credential={credential}
showSelectedIcon
@ -136,7 +139,7 @@ describe('CredentialItem', () => {
/>,
)
expect(screen.getByTestId('check-icon')).toBeInTheDocument()
expect(container.querySelector('.i-ri-check-line')).toBeInTheDocument()
})
it('should not render check icon when showSelectedIcon=true but selectedCredentialId does not match', () => {

View File

@ -1,9 +1,4 @@
import type { Credential } from '../../declarations'
import {
RiCheckLine,
RiDeleteBinLine,
RiEqualizer2Line,
} from '@remixicon/react'
import {
memo,
useMemo,
@ -11,7 +6,7 @@ import {
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Badge from '@/app/components/base/badge'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import Indicator from '@/app/components/header/indicator'
import { cn } from '@/utils/classnames'
@ -56,7 +51,7 @@ const CredentialItem = ({
key={credential.credential_id}
className={cn(
'group flex h-8 items-center rounded-lg p-1 hover:bg-state-base-hover',
(disabled || credential.not_allowed_to_use) && 'cursor-not-allowed opacity-50',
(disabled || credential.not_allowed_to_use) ? 'cursor-not-allowed opacity-50' : onItemClick && 'cursor-pointer',
)}
onClick={() => {
if (disabled || credential.not_allowed_to_use)
@ -70,7 +65,7 @@ const CredentialItem = ({
<div className="h-4 w-4">
{
selectedCredentialId === credential.credential_id && (
<RiCheckLine className="h-4 w-4 text-text-accent" />
<span className="i-ri-check-line h-4 w-4 text-text-accent" />
)
}
</div>
@ -78,7 +73,7 @@ const CredentialItem = ({
}
<Indicator className="ml-2 mr-1.5 shrink-0" />
<div
className="system-md-regular truncate text-text-secondary"
className="truncate text-text-secondary system-md-regular"
title={credential.credential_name}
>
{credential.credential_name}
@ -96,38 +91,50 @@ const CredentialItem = ({
<div className="ml-2 hidden shrink-0 items-center group-hover:flex">
{
!disableEdit && !credential.not_allowed_to_use && (
<Tooltip popupContent={t('operation.edit', { ns: 'common' })}>
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(credential)
}}
>
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
</ActionButton>
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onEdit?.(credential)
}}
>
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</ActionButton>
)}
/>
<TooltipContent>{t('operation.edit', { ns: 'common' })}</TooltipContent>
</Tooltip>
)
}
{
!disableDelete && (
<Tooltip popupContent={disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}>
<ActionButton
className="hover:bg-transparent"
onClick={(e) => {
if (disabled || disableDeleteWhenSelected)
return
e.stopPropagation()
onDelete?.(credential)
}}
>
<RiDeleteBinLine className={cn(
'h-4 w-4 text-text-tertiary',
!disableDeleteWhenSelected && 'hover:text-text-destructive',
disableDeleteWhenSelected && 'opacity-50',
<Tooltip>
<TooltipTrigger
render={(
<ActionButton
className="hover:bg-transparent"
onClick={(e) => {
if (disabled || disableDeleteWhenSelected)
return
e.stopPropagation()
onDelete?.(credential)
}}
>
<span className={cn(
'i-ri-delete-bin-line h-4 w-4 text-text-tertiary',
!disableDeleteWhenSelected && 'hover:text-text-destructive',
disableDeleteWhenSelected && 'opacity-50',
)}
/>
</ActionButton>
)}
/>
</ActionButton>
/>
<TooltipContent>
{disableDeleteWhenSelected ? disableDeleteTip : t('operation.delete', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)
}
@ -139,8 +146,9 @@ const CredentialItem = ({
if (credential.not_allowed_to_use) {
return (
<Tooltip popupContent={t('auth.customCredentialUnavailable', { ns: 'plugin' })}>
{Item}
<Tooltip>
<TooltipTrigger render={Item} />
<TooltipContent>{t('auth.customCredentialUnavailable', { ns: 'plugin' })}</TooltipContent>
</Tooltip>
)
}

View File

@ -53,4 +53,14 @@ describe('useCredentialStatus', () => {
expect(result.current.hasCredential).toBe(false)
expect(result.current.available_credentials).toBeUndefined()
})
it('handles undefined provider gracefully', () => {
const { result } = renderHook(() => useCredentialStatus(undefined))
expect(result.current.hasCredential).toBe(false)
expect(result.current.authorized).toBeFalsy()
expect(result.current.authRemoved).toBe(false)
expect(result.current.available_credentials).toBeUndefined()
expect(result.current.current_credential_id).toBeUndefined()
expect(result.current.current_credential_name).toBeUndefined()
})
})

View File

@ -3,12 +3,12 @@ import type {
} from '../../declarations'
import { useMemo } from 'react'
export const useCredentialStatus = (provider: ModelProvider) => {
export const useCredentialStatus = (provider: ModelProvider | undefined) => {
const {
current_credential_id,
current_credential_name,
available_credentials,
} = provider.custom_configuration
} = provider?.custom_configuration ?? {}
const hasCredential = !!available_credentials?.length
const authorized = current_credential_id && current_credential_name
const authRemoved = hasCredential && !current_credential_id && !current_credential_name

View File

@ -10,7 +10,7 @@ const ModelBadge: FC<ModelBadgeProps> = ({
children,
}) => {
return (
<div className={cn('system-2xs-medium-uppercase flex h-[18px] cursor-default items-center rounded-[5px] border border-divider-deep px-1 text-text-tertiary', className)}>
<div className={cn('inline-flex h-[18px] shrink-0 items-center justify-center whitespace-nowrap rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-[5px] text-text-tertiary system-2xs-medium-uppercase', className)}>
{children}
</div>
)

View File

@ -1,7 +1,6 @@
import type { ComponentProps } from 'react'
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import type * as React from 'react'
import type { Credential, CredentialFormSchema, ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@ -45,6 +44,15 @@ const mockHandlers = vi.hoisted(() => ({
handleActiveCredential: vi.fn(),
}))
type FormResponse = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const mockFormState = vi.hoisted(() => ({
responses: [] as FormResponse[],
setFieldValue: vi.fn(),
}))
vi.mock('../../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
@ -79,6 +87,36 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
const React = await import('react')
const AuthForm = React.forwardRef(({
onChange,
}: {
onChange?: (field: string, value: string) => void
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
}))
return (
<div>
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
</div>
)
})
return { default: AuthForm }
})
vi.mock('../../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
</div>
),
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
@ -121,7 +159,7 @@ const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
...overrides,
})
const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) => {
const renderModal = (overrides?: Partial<React.ComponentProps<typeof ModelModal>>) => {
const provider = createProvider()
const props = {
provider,
@ -131,50 +169,13 @@ const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) =>
onRemove: vi.fn(),
...overrides,
}
render(<ModelModal {...props} />)
return props
const view = render(<ModelModal {...props} />)
return {
...props,
unmount: view.unmount,
}
}
const mockFormRef1 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
const mockFormRef2 = {
getFormValues: vi.fn(),
getForm: vi.fn(() => ({ setFieldValue: vi.fn() })),
}
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: React.forwardRef((props: { formSchemas: Record<string, unknown>[], onChange?: (f: string, v: string) => void }, ref: React.ForwardedRef<unknown>) => {
React.useImperativeHandle(ref, () => {
// Return the mock depending on schemas passed (hacky but works for refs)
if (props.formSchemas.length > 0 && props.formSchemas[0].name === '__model_name')
return mockFormRef1
return mockFormRef2
})
return (
<div data-testid="auth-form" onClick={() => props.onChange?.('test-field', 'val')}>
AuthForm Mock (
{props.formSchemas.length}
{' '}
fields)
</div>
)
}),
}))
vi.mock('../../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (val: unknown) => void }) => (
<button onClick={() => onSelect({ addNewCredential: true })} data-testid="credential-selector">
Select Credential
</button>
),
useAuth: vi.fn(),
useCredentialData: vi.fn(),
useModelFormSchemas: vi.fn(),
}))
describe('ModelModal', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -187,131 +188,168 @@ describe('ModelModal', () => {
mockState.formValues = {}
mockState.modelNameAndTypeFormSchemas = []
mockState.modelNameAndTypeFormValues = {}
// reset form refs
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __model_name: 'test', __model_type: ModelTypeEnum.textGeneration } })
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'test_auth', api_key: 'sk-test' } })
mockFormState.responses = []
})
it('should render title and loading state for predefined credential modal', () => {
it('should show title, description, and loading state for predefined models', () => {
mockState.isLoading = true
renderModal()
const predefined = renderModal()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.title')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.auth.apiKeyModal.desc')).toBeInTheDocument()
})
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.save' })).toBeDisabled()
it('should render model credential title when mode is configModelCredential', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration },
})
predefined.unmount()
const customizable = renderModal({ configurateMethod: ConfigurationMethodEnum.customizableModel })
expect(screen.queryByText('common.modelProvider.auth.apiKeyModal.desc')).not.toBeInTheDocument()
customizable.unmount()
mockState.credentialData = { credentials: {}, available_credentials: [] }
renderModal({ mode: ModelModalModeEnum.configModelCredential, model: { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration } })
expect(screen.getByText('common.modelProvider.auth.addModelCredential')).toBeInTheDocument()
})
it('should render edit credential title when credential exists', () => {
renderModal({
mode: ModelModalModeEnum.configModelCredential,
credential: { credential_id: '1' } as unknown as Credential,
})
expect(screen.getByText('common.modelProvider.auth.editModelCredential')).toBeInTheDocument()
it('should reveal the credential label when adding a new credential', () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList })
expect(screen.queryByText('common.modelProvider.auth.modelCredential')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Add New'))
expect(screen.getByText('common.modelProvider.auth.modelCredential')).toBeInTheDocument()
})
it('should change title to Add Model when mode is configCustomModel', () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
renderModal({ mode: ModelModalModeEnum.configCustomModel })
expect(screen.getByText('common.modelProvider.auth.addModel')).toBeInTheDocument()
it('should call onCancel when the cancel button is clicked', () => {
const { onCancel } = renderModal()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should validate and fail save if form is invalid in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
mockFormRef1.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should call onCancel when the escape key is pressed', () => {
const { onCancel } = renderModal()
it('should validate and save new credential and model in configCustomModel mode', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text' } as unknown as CredentialFormSchema]
const props = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
model: 'test',
model_type: ModelTypeEnum.textGeneration,
})
expect(props.onSave).toHaveBeenCalled()
})
})
it('should save credential only in standard configProviderCredential mode', async () => {
const { onSave } = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'sk-test' },
name: 'test_auth',
})
expect(onSave).toHaveBeenCalled()
})
})
it('should save active credential and cancel when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm1', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// By default selected is undefined so button clicks form
// Let's not click credential selector, so it evaluates without it. If selectedCredential is undefined, form validation is checked.
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: false, values: {} })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleSaveCredential).not.toHaveBeenCalled()
})
it('should save active credential when picking existing credential in addCustomModelToModelList mode', async () => {
renderModal({ mode: ModelModalModeEnum.addCustomModelToModelList, model: { model: 'm2', model_type: ModelTypeEnum.textGeneration } as unknown as CustomModel })
// Select existing credential (addNewCredential: true simulates new but we can simulate false if we just hack the mocked state in the component, but it's internal.
// The credential selector sets selectedCredential.
fireEvent.click(screen.getByTestId('credential-selector')) // Sets addNewCredential = true internally, so it proceeds to form save
mockFormRef2.getFormValues.mockReturnValue({ isCheckValidated: true, values: { __authorization_name__: 'auth', api: 'key' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api: 'key' },
name: 'auth',
model: 'm2',
model_type: ModelTypeEnum.textGeneration,
})
})
})
it('should open and confirm deletion of credential', () => {
mockState.credentialData = { credentials: { api_key: '123' }, available_credentials: [] }
mockState.formValues = { api_key: '123' } // To trigger isEditMode = true
const credential = { credential_id: 'c1' } as unknown as Credential
renderModal({ credential })
// Open Delete Confirm
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith(credential, undefined)
// Simulate the dialog appearing and confirming
mockState.deleteCredentialId = 'c1'
renderModal({ credential }) // Re-render logic mock
fireEvent.click(screen.getAllByRole('button', { name: 'common.operation.confirm' })[0])
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalled()
})
it('should bind escape key to cancel', () => {
const props = renderModal()
fireEvent.keyDown(document, { key: 'Escape' })
expect(props.onCancel).toHaveBeenCalled()
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should confirm deletion when a delete dialog is shown', () => {
mockState.credentialData = { credentials: { api_key: 'secret' }, available_credentials: [] }
mockState.deleteCredentialId = 'delete-id'
const credential: Credential = { credential_id: 'cred-1' }
const { onCancel } = renderModal({ credential })
const alertDialog = screen.getByRole('alertdialog', { hidden: true })
expect(alertDialog).toHaveTextContent('common.modelProvider.confirmDelete')
fireEvent.click(within(alertDialog).getByRole('button', { hidden: true, name: 'common.operation.confirm' }))
expect(mockHandlers.handleConfirmDelete).toHaveBeenCalledTimes(1)
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should handle save flows for different modal modes', async () => {
mockState.modelNameAndTypeFormSchemas = [{ variable: '__model_name', type: 'text-input' } as unknown as CredentialFormSchema]
mockState.formSchemas = [{ variable: 'api_key', type: 'secret-input' } as unknown as CredentialFormSchema]
mockFormState.responses = [
{ isCheckValidated: true, values: { __model_name: 'custom-model', __model_type: ModelTypeEnum.textGeneration } },
{ isCheckValidated: true, values: { __authorization_name__: 'Auth Name', api_key: 'secret' } },
]
const configCustomModel = renderModal({ mode: ModelModalModeEnum.configCustomModel })
fireEvent.click(screen.getAllByText('Model Name Change')[0])
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockFormState.setFieldValue).toHaveBeenCalledWith('__model_name', 'updated-model')
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'secret' },
name: 'Auth Name',
model: 'custom-model',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configCustomModel.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Auth Name', api_key: 'secret' })
configCustomModel.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Model Auth', api_key: 'abc' } }]
const model = { model: 'gpt-4', model_type: ModelTypeEnum.textGeneration }
const configModelCredential = renderModal({
mode: ModelModalModeEnum.configModelCredential,
model,
credential: { credential_id: 'cred-123' },
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: 'cred-123',
credentials: { api_key: 'abc' },
name: 'Model Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
expect(configModelCredential.onSave).toHaveBeenCalledWith({ __authorization_name__: 'Model Auth', api_key: 'abc' })
configModelCredential.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'Provider Auth', api_key: 'provider-key' } }]
const configProviderCredential = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'provider-key' },
name: 'Provider Auth',
})
})
configProviderCredential.unmount()
const addToModelList = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Choose Existing'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
expect(mockHandlers.handleActiveCredential).toHaveBeenCalledWith({ credential_id: 'existing' }, model)
expect(addToModelList.onCancel).toHaveBeenCalled()
addToModelList.unmount()
mockFormState.responses = [{ isCheckValidated: true, values: { __authorization_name__: 'New Auth', api_key: 'new-key' } }]
const addToModelListWithNew = renderModal({
mode: ModelModalModeEnum.addCustomModelToModelList,
model,
})
fireEvent.click(screen.getByText('Add New'))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.add' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledWith({
credential_id: undefined,
credentials: { api_key: 'new-key' },
name: 'New Auth',
model: 'gpt-4',
model_type: ModelTypeEnum.textGeneration,
})
})
addToModelListWithNew.unmount()
mockFormState.responses = [{ isCheckValidated: false, values: {} }]
const invalidSave = renderModal({ mode: ModelModalModeEnum.configProviderCredential })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
await waitFor(() => {
expect(mockHandlers.handleSaveCredential).toHaveBeenCalledTimes(4)
})
invalidSave.unmount()
mockState.credentialData = { credentials: { api_key: 'value' }, available_credentials: [] }
mockState.formValues = { api_key: 'value' }
const removable = renderModal({ credential: { credential_id: 'remove-1' } })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.remove' }))
expect(mockHandlers.openConfirmDelete).toHaveBeenCalledWith({ credential_id: 'remove-1' }, undefined)
removable.unmount()
})
})

View File

@ -0,0 +1,271 @@
import type { ReactNode } from 'react'
import type { Credential, ModelProvider } from '../declarations'
import { act, render, screen } from '@testing-library/react'
import { ConfigurationMethodEnum, ModelModalModeEnum } from '../declarations'
import ModelModal from './index'
type DialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let mockLanguage = 'en_US'
let latestDialogOnOpenChange: DialogProps['onOpenChange']
let latestAlertDialogOnOpenChange: AlertDialogProps['onOpenChange']
let mockAvailableCredentials: Credential[] | undefined = []
let mockDeleteCredentialId: string | null = null
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({
default: () => <div data-testid="auth-form" />,
}))
vi.mock('../model-auth', () => ({
CredentialSelector: ({ credentials }: { credentials: Credential[] }) => <div>{`credentials:${credentials.length}`}</div>,
}))
vi.mock('@/app/components/base/ui/dialog', () => ({
Dialog: ({ children, onOpenChange }: DialogProps) => {
latestDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
DialogCloseButton: () => <button type="button">close</button>,
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestAlertDialogOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: false,
credentialData: {
credentials: {},
available_credentials: mockAvailableCredentials,
},
}),
useAuth: () => ({
handleSaveCredential: vi.fn(),
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
closeConfirmDelete: mockCloseConfirmDelete,
openConfirmDelete: vi.fn(),
doingAction: false,
handleActiveCredential: vi.fn(),
}),
useModelFormSchemas: () => ({
formSchemas: [],
formValues: {},
modelNameAndTypeFormSchemas: [],
modelNameAndTypeFormValues: {},
}),
}))
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
isCurrentWorkspaceManager: true,
}),
}))
vi.mock('@/hooks/use-i18n', () => ({
useRenderI18nObject: () => (value: Record<string, string>) => value[mockLanguage] || value.en_US,
}))
vi.mock('../hooks', () => ({
useLanguage: () => mockLanguage,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
help: {
title: { en_US: 'Help', zh_Hans: '帮助' },
url: { en_US: 'https://example.com', zh_Hans: 'https://example.cn' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [],
configurate_methods: [],
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: { label: { en_US: 'Model', zh_Hans: '模型' }, placeholder: { en_US: 'Select', zh_Hans: '选择' } },
credential_form_schemas: [],
},
custom_configuration: {
status: 'active',
available_credentials: [],
custom_models: [],
can_added_models: [],
},
system_configuration: {
enabled: true,
current_quota_type: 'trial',
quota_configurations: [],
},
allow_custom_token: true,
...overrides,
} as unknown as ModelProvider)
describe('ModelModal dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
latestDialogOnOpenChange = undefined
latestAlertDialogOnOpenChange = undefined
mockAvailableCredentials = []
mockDeleteCredentialId = null
})
it('should only cancel when the dialog reports it has closed', () => {
const onCancel = vi.fn()
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={onCancel}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestDialogOnOpenChange?.(true)
latestDialogOnOpenChange?.(false)
})
expect(onCancel).toHaveBeenCalledTimes(1)
})
it('should only close the confirm dialog when the alert dialog closes', () => {
mockDeleteCredentialId = 'cred-1'
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
act(() => {
latestAlertDialogOnOpenChange?.(true)
latestAlertDialogOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
it('should pass an empty credential list to the selector when no credentials are available', () => {
mockAvailableCredentials = undefined
render(
<ModelModal
provider={createProvider()}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
mode={ModelModalModeEnum.addCustomModelToModelList}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should hide the help link when provider help is missing', () => {
render(
<ModelModal
provider={createProvider({ help: undefined })}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.queryByRole('link', { name: 'Help' })).not.toBeInTheDocument()
})
it('should prevent navigation when help text exists without a help url', () => {
mockLanguage = 'zh_Hans'
render(
<ModelModal
provider={createProvider({
help: {
title: { en_US: 'English Help' },
url: '' as unknown as ModelProvider['help']['url'],
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
const link = screen.getByText('English Help').closest('a')
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
expect(link).not.toBeNull()
link!.dispatchEvent(clickEvent)
expect(clickEvent.defaultPrevented).toBe(true)
})
it('should fall back to localized and english help urls when titles are missing', () => {
mockLanguage = 'zh_Hans'
const { rerender } = render(
<ModelModal
provider={createProvider({
help: {
url: { zh_Hans: 'https://example.cn', en_US: 'https://example.com' },
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByRole('link', { name: 'https://example.cn' })).toHaveAttribute('href', 'https://example.cn')
rerender(
<ModelModal
provider={createProvider({
help: {
url: { en_US: 'https://example.com' },
} as ModelProvider['help'],
})}
configurateMethod={ConfigurationMethodEnum.predefinedModel}
onCancel={vi.fn()}
onSave={vi.fn()}
onRemove={vi.fn()}
/>,
)
const link = screen.getByRole('link', { name: 'https://example.com' })
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
link.dispatchEvent(clickEvent)
expect(link).toHaveAttribute('href', 'https://example.com')
expect(clickEvent.defaultPrevented).toBe(false)
})
})

View File

@ -9,11 +9,9 @@ import type {
FormRefObject,
FormSchema,
} from '@/app/components/base/form/types'
import { RiCloseLine } from '@remixicon/react'
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
@ -21,15 +19,23 @@ import {
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import Confirm from '@/app/components/base/confirm'
import AuthForm from '@/app/components/base/form/form-scenarios/auth'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Loading from '@/app/components/base/loading'
import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
Dialog,
DialogCloseButton,
DialogContent,
} from '@/app/components/base/ui/dialog'
import {
useAuth,
useCredentialData,
@ -197,7 +203,7 @@ const ModelModal: FC<ModelModalProps> = ({
}
return (
<div className="title-2xl-semi-bold text-text-primary">
<div className="text-text-primary title-2xl-semi-bold">
{label}
</div>
)
@ -206,7 +212,7 @@ const ModelModal: FC<ModelModalProps> = ({
const modalDesc = useMemo(() => {
if (providerFormSchemaPredefined) {
return (
<div className="system-xs-regular mt-1 text-text-tertiary">
<div className="mt-1 text-text-tertiary system-xs-regular">
{t('modelProvider.auth.apiKeyModal.desc', { ns: 'common' })}
</div>
)
@ -223,7 +229,7 @@ const ModelModal: FC<ModelModalProps> = ({
className="mr-2 h-4 w-4 shrink-0"
provider={provider}
/>
<div className="system-md-regular mr-1 text-text-secondary">{renderI18nObject(provider.label)}</div>
<div className="mr-1 text-text-secondary system-md-regular">{renderI18nObject(provider.label)}</div>
</div>
)
}
@ -235,7 +241,7 @@ const ModelModal: FC<ModelModalProps> = ({
provider={provider}
modelName={model.model}
/>
<div className="system-md-regular mr-1 text-text-secondary">{model.model}</div>
<div className="mr-1 text-text-secondary system-md-regular">{model.model}</div>
<Badge>{model.model_type}</Badge>
</div>
)
@ -275,174 +281,171 @@ const ModelModal: FC<ModelModalProps> = ({
}, [])
const notAllowCustomCredential = provider.allow_custom_token === false
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.stopPropagation()
onCancel()
}
}
document.addEventListener('keydown', handleKeyDown, true)
return () => {
document.removeEventListener('keydown', handleKeyDown, true)
}
const handleOpenChange = useCallback((open: boolean) => {
if (!open)
onCancel()
}, [onCancel])
const handleConfirmOpenChange = useCallback((open: boolean) => {
if (!open)
closeConfirmDelete()
}, [closeConfirmDelete])
return (
<PortalToFollowElem open>
<PortalToFollowElemContent className="z-[60] h-full w-full">
<div className="fixed inset-0 flex items-center justify-center bg-black/[.25]">
<div className="relative w-[640px] rounded-2xl bg-components-panel-bg shadow-xl">
<div
className="absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center"
onClick={onCancel}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
<div className="p-6 pb-3">
{modalTitle}
{modalDesc}
{modalModel}
</div>
<div className="max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3">
{
mode === ModelModalModeEnum.configCustomModel && (
<AuthForm
formSchemas={modelNameAndTypeFormSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
}
}) as FormSchema[]}
defaultValues={modelNameAndTypeFormValues}
inputClassName="justify-start"
ref={formRef1}
onChange={handleModelNameAndTypeChange}
/>
)
}
{
mode === ModelModalModeEnum.addCustomModelToModelList && (
<CredentialSelector
credentials={available_credentials || []}
onSelect={setSelectedCredential}
selectedCredential={selectedCredential}
disabled={isLoading}
notAllowAddNewCredential={notAllowCustomCredential}
/>
)
}
{
showCredentialLabel && (
<div className="system-xs-medium-uppercase mb-3 mt-6 flex items-center text-text-tertiary">
{t('modelProvider.auth.modelCredential', { ns: 'common' })}
<div className="ml-2 h-px grow bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent" />
</div>
)
}
{
isLoading && (
<div className="mt-3 flex items-center justify-center">
<Loading />
</div>
)
}
{
!isLoading
&& showCredentialForm
&& (
<AuthForm
formSchemas={formSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
showRadioUI: formSchema.type === FormTypeEnum.radio,
}
}) as FormSchema[]}
defaultValues={formValues}
inputClassName="justify-start"
ref={formRef2}
/>
)
}
</div>
<div className="flex justify-between p-6 pt-5">
{
(provider.help && (provider.help.title || provider.help.url))
? (
<a
href={provider.help?.url[language] || provider.help?.url.en_US}
target="_blank"
rel="noopener noreferrer"
className="system-xs-regular mt-2 inline-block align-middle text-text-accent"
onClick={e => !provider.help.url && e.preventDefault()}
>
{provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
<LinkExternal02 className="ml-1 mt-[-2px] inline-block h-3 w-3" />
</a>
)
: <div />
}
<div className="ml-2 flex items-center justify-end space-x-2">
{
isEditMode && (
<Button
variant="warning"
onClick={() => openConfirmDelete(credential, model)}
>
{t('operation.remove', { ns: 'common' })}
</Button>
)
}
<Button
onClick={onCancel}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isLoading || doingAction}
>
{saveButtonText}
</Button>
</div>
</div>
{
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
<div className="border-t-[0.5px] border-t-divider-regular">
<div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
<a
className="mx-1 text-text-accent"
target="_blank"
rel="noopener noreferrer"
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
>
PKCS1_OAEP
</a>
{t('modelProvider.encrypted.back', { ns: 'common' })}
</div>
</div>
)
}
</div>
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
backdropProps={{ forceRender: true }}
className="w-[640px] max-w-[640px] overflow-hidden p-0"
>
<DialogCloseButton className="right-5 top-5 h-8 w-8" />
<div className="p-6 pb-3">
{modalTitle}
{modalDesc}
{modalModel}
</div>
<div className="max-h-[calc(100vh-320px)] overflow-y-auto px-6 py-3">
{
deleteCredentialId && (
<Confirm
isShow
title={t('modelProvider.confirmDelete', { ns: 'common' })}
isDisabled={doingAction}
onCancel={closeConfirmDelete}
onConfirm={handleDeleteCredential}
mode === ModelModalModeEnum.configCustomModel && (
<AuthForm
formSchemas={modelNameAndTypeFormSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
}
}) as FormSchema[]}
defaultValues={modelNameAndTypeFormValues}
inputClassName="justify-start"
ref={formRef1}
onChange={handleModelNameAndTypeChange}
/>
)
}
{
mode === ModelModalModeEnum.addCustomModelToModelList && (
<CredentialSelector
credentials={available_credentials || []}
onSelect={setSelectedCredential}
selectedCredential={selectedCredential}
disabled={isLoading}
notAllowAddNewCredential={notAllowCustomCredential}
/>
)
}
{
showCredentialLabel && (
<div className="mb-3 mt-6 flex items-center text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.auth.modelCredential', { ns: 'common' })}
<div className="ml-2 h-px grow bg-gradient-to-r from-divider-regular to-background-gradient-mask-transparent" />
</div>
)
}
{
isLoading && (
<div className="mt-3 flex items-center justify-center">
<Loading />
</div>
)
}
{
!isLoading
&& showCredentialForm
&& (
<AuthForm
formSchemas={formSchemas.map((formSchema) => {
return {
...formSchema,
name: formSchema.variable,
showRadioUI: formSchema.type === FormTypeEnum.radio,
}
}) as FormSchema[]}
defaultValues={formValues}
inputClassName="justify-start"
ref={formRef2}
/>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
<div className="flex justify-between p-6 pt-5">
{
(provider.help && (provider.help.title || provider.help.url))
? (
<a
href={provider.help?.url[language] || provider.help?.url.en_US}
target="_blank"
rel="noopener noreferrer"
className="mt-2 inline-block align-middle text-text-accent system-xs-regular"
onClick={e => !provider.help.url && e.preventDefault()}
>
{provider.help.title?.[language] || provider.help.url[language] || provider.help.title?.en_US || provider.help.url.en_US}
<LinkExternal02 className="ml-1 mt-[-2px] inline-block h-3 w-3" />
</a>
)
: <div />
}
<div className="ml-2 flex items-center justify-end space-x-2">
{
isEditMode && (
<Button
variant="warning"
onClick={() => openConfirmDelete(credential, model)}
>
{t('operation.remove', { ns: 'common' })}
</Button>
)
}
<Button
onClick={onCancel}
>
{t('operation.cancel', { ns: 'common' })}
</Button>
<Button
variant="primary"
onClick={handleSave}
disabled={isLoading || doingAction}
>
{saveButtonText}
</Button>
</div>
</div>
{
(mode === ModelModalModeEnum.configCustomModel || mode === ModelModalModeEnum.configProviderCredential) && (
<div className="border-t-[0.5px] border-t-divider-regular">
<div className="flex items-center justify-center rounded-b-2xl bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
<a
className="mx-1 text-text-accent"
target="_blank"
rel="noopener noreferrer"
href="https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html"
>
PKCS1_OAEP
</a>
{t('modelProvider.encrypted.back', { ns: 'common' })}
</div>
</div>
)
}
</DialogContent>
<AlertDialog open={!!deleteCredentialId} onOpenChange={handleConfirmOpenChange}>
<AlertDialogContent backdropProps={{ forceRender: true }}>
<div className="flex flex-col gap-2 p-6 pb-4">
<AlertDialogTitle className="text-text-primary title-2xl-semi-bold">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
</div>
<AlertDialogActions>
<AlertDialogCancelButton>{t('operation.cancel', { ns: 'common' })}</AlertDialogCancelButton>
<AlertDialogConfirmButton
disabled={doingAction}
onClick={handleDeleteCredential}
>
{t('operation.confirm', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}

View File

@ -39,7 +39,7 @@ const ModelName: FC<ModelNameProps> = ({
if (!modelItem)
return null
return (
<div className={cn('system-sm-regular flex items-center gap-0.5 overflow-hidden truncate text-ellipsis text-components-input-text-filled', className)}>
<div className={cn('flex items-center gap-0.5 overflow-hidden truncate text-ellipsis text-components-input-text-filled system-sm-regular', className)}>
<div
className="truncate"
title={modelItem.label[language] || modelItem.label.en_US}

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ModelParameterModal from '../index'
let isAPIKeySet = true
@ -49,7 +49,7 @@ vi.mock('@/service/use-common', () => ({
data: {
data: parameterRules,
},
isPending: isRulesLoading,
isLoading: isRulesLoading,
}),
}))
@ -77,9 +77,10 @@ vi.mock('../parameter-item', () => ({
}))
vi.mock('../../model-selector', () => ({
default: ({ onSelect }: { onSelect: (value: { provider: string, model: string }) => void }) => (
default: ({ onHide, onSelect }: { onHide: () => void, onSelect: (value: { provider: string, model: string }) => void }) => (
<div data-testid="model-selector">
<button onClick={() => onSelect({ provider: 'openai', model: 'gpt-4.1' })}>Select GPT-4.1</button>
<button onClick={onHide}>hide</button>
</div>
),
}))
@ -91,7 +92,7 @@ vi.mock('../presets-parameter', () => ({
}))
vi.mock('../trigger', () => ({
default: () => <button>Open Settings</button>,
default: () => <button type="button">Open Settings</button>,
}))
vi.mock('@/config', async (importOriginal) => {
@ -231,4 +232,67 @@ describe('ModelParameterModal', () => {
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
it('should support custom triggers, workflow mode, and missing default model values', async () => {
render(
<ModelParameterModal
{...defaultProps}
provider=""
modelId=""
isInWorkflow
renderTrigger={({ open }) => <span>{open ? 'Custom Open' : 'Custom Closed'}</span>}
/>,
)
fireEvent.click(screen.getByText('Custom Closed'))
expect(screen.getByText('Custom Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
fireEvent.click(screen.getByText('hide'))
await waitFor(() => {
expect(screen.queryByTestId('model-selector')).not.toBeInTheDocument()
})
})
it('should append the stop parameter in advanced mode and show the single-model debug label', () => {
render(
<ModelParameterModal
{...defaultProps}
isAdvancedMode
debugWithMultipleModel
/>,
)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByTestId('param-stop')).toBeInTheDocument()
expect(screen.getByText(/debugAsSingleModel/i)).toBeInTheDocument()
})
it('should render the empty loading fallback when rules resolve to an empty list', () => {
parameterRules = []
isRulesLoading = true
render(<ModelParameterModal {...defaultProps} />)
fireEvent.click(screen.getByText('Open Settings'))
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByTestId('param-temperature')).not.toBeInTheDocument()
})
it('should support custom trigger placement outside workflow mode', () => {
render(
<ModelParameterModal
{...defaultProps}
renderTrigger={({ open }) => <span>{open ? 'Popup Open' : 'Popup Closed'}</span>}
/>,
)
fireEvent.click(screen.getByText('Popup Closed'))
expect(screen.getByText('Popup Open')).toBeInTheDocument()
expect(screen.getByTestId('model-selector')).toBeInTheDocument()
})
})

View File

@ -109,7 +109,7 @@ describe('ParameterItem', () => {
it('should render select for string with options', () => {
render(<ParameterItem parameterRule={createRule({ type: 'string', options: ['a', 'b'] })} value="a" />)
// SimpleSelect renders an element with text 'a'
// Select renders the selected value in the trigger
expect(screen.getByText('a')).toBeInTheDocument()
})

View File

@ -18,13 +18,12 @@ describe('PresetsParameter', () => {
expect(onSelect).toHaveBeenCalledWith(1)
})
// open=true: trigger has bg-state-base-hover class
it('should apply hover background class when open is true', () => {
it('should mark trigger as open when dropdown is expanded', () => {
render(<PresetsParameter onSelect={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i }))
const button = screen.getByRole('button', { name: /common\.modelProvider\.loadPresets/i })
expect(button).toHaveClass('bg-state-base-hover')
expect(button).toHaveAttribute('data-popup-open')
})
// Tone map branch 2: Balanced → Scales02 icon

View File

@ -1,140 +1,322 @@
import type { ComponentProps } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Trigger from '../trigger'
const mockUseCredentialPanelState = vi.fn()
vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
modelProviders: [{ provider: 'openai', label: { en_US: 'OpenAI' } }],
modelProviders: [{
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
}],
}),
}))
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: () => mockUseCredentialPanelState(),
}))
vi.mock('../../model-icon', () => ({
default: () => <div data-testid="model-icon">Icon</div>,
}))
vi.mock('../../model-name', () => ({
default: ({ modelItem }: { modelItem: { model: string } }) => <div>{modelItem.model}</div>,
default: ({
modelItem,
showMode,
showFeatures,
}: {
modelItem: { model: string }
showMode?: boolean
showFeatures?: boolean
}) => (
<div>
<span>{modelItem.model}</span>
{showMode && <span data-testid="model-name-mode">mode</span>}
{showFeatures && <span data-testid="model-name-features">features</span>}
</div>
),
}))
const activeCredentialState = {
variant: 'api-active' as const,
supportsCredits: true,
isCreditsExhausted: false,
priority: 'apiKey' as const,
showPrioritySwitcher: true,
hasCredentials: true,
credentialName: 'Primary Key',
credits: 10,
}
describe('Trigger', () => {
const currentProvider = { provider: 'openai', label: { en_US: 'OpenAI' } } as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = { model: 'gpt-4' } as unknown as ComponentProps<typeof Trigger>['currentModel']
const currentProvider = {
provider: 'openai',
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
} as unknown as ComponentProps<typeof Trigger>['currentProvider']
const currentModel = {
model: 'gpt-4',
status: 'active',
} as unknown as ComponentProps<typeof Trigger>['currentModel']
beforeEach(() => {
vi.clearAllMocks()
mockUseCredentialPanelState.mockReturnValue(activeCredentialState)
})
it('should render initialized state', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
describe('Rendering', () => {
it('should render active state with model features in non-workflow mode', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
expect(screen.getByTestId('model-name-mode')).toBeInTheDocument()
expect(screen.getByTestId('model-name-features')).toBeInTheDocument()
})
it('should render fallback model id when current model is missing', () => {
render(
<Trigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
})
it('should render split layout with workflow styles when workflow mode is enabled', () => {
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
isInWorkflow
providerName="openai"
modelId="gpt-4"
/>,
)
const leftPanel = container.querySelector('.rounded-l-lg')
expect(leftPanel).toBeInTheDocument()
expect(leftPanel).toHaveClass('border-workflow-block-parma-bg')
const rightPanel = container.querySelector('.rounded-r-lg')
expect(rightPanel).toBeInTheDocument()
expect(rightPanel).toHaveClass('border-workflow-block-parma-bg')
})
it('should render empty state when no provider or model is selected', () => {
render(<Trigger isInWorkflow />)
expect(screen.getByText('workflow:errorMsg.configureModel')).toBeInTheDocument()
})
it('should render non-workflow empty state with warning border', () => {
const { container } = render(<Trigger />)
expect(screen.getByText('workflow:errorMsg.configureModel')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('border-text-warning')
})
})
it('should render fallback model id when current model is missing', () => {
render(
<Trigger
modelId="gpt-4"
providerName="openai"
/>,
)
expect(screen.getByText('gpt-4')).toBeInTheDocument()
describe('Status badges', () => {
it('should render credits exhausted badge in non-workflow mode', () => {
mockUseCredentialPanelState.mockReturnValue({
...activeCredentialState,
variant: 'credits-exhausted',
isCreditsExhausted: true,
priority: 'credits',
})
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
expect(screen.queryByTestId('model-name-mode')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-name-features')).not.toBeInTheDocument()
})
it('should render api unavailable badge in non-workflow mode', () => {
mockUseCredentialPanelState.mockReturnValue({
...activeCredentialState,
variant: 'api-unavailable',
})
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
})
it('should render credits exhausted badge in workflow mode', () => {
mockUseCredentialPanelState.mockReturnValue({
...activeCredentialState,
variant: 'credits-exhausted',
isCreditsExhausted: true,
priority: 'credits',
})
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
isInWorkflow
/>,
)
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
})
it('should render api unavailable badge in workflow mode', () => {
mockUseCredentialPanelState.mockReturnValue({
...activeCredentialState,
variant: 'api-unavailable',
})
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
isInWorkflow
/>,
)
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
})
it('should render incompatible badge when model is deprecated (currentModel missing)', () => {
render(
<Trigger
currentProvider={currentProvider}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
})
it('should render credits exhausted badge when model is missing and AI credits are exhausted without api key', () => {
mockUseCredentialPanelState.mockReturnValue({
...activeCredentialState,
variant: 'no-usage',
priority: 'apiKey',
hasCredentials: false,
isCreditsExhausted: true,
credentialName: undefined,
})
render(
<Trigger
currentProvider={currentProvider}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
})
it('should render configure required badge when model status is no-configure', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={{ ...currentModel, status: 'no-configure' } as typeof currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
})
it('should render disabled badge when model status is disabled', () => {
render(
<Trigger
currentProvider={currentProvider}
currentModel={{ ...currentModel, status: 'disabled' } as typeof currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
})
it('should render incompatible badge when provider plugin is not installed', () => {
render(
<Trigger
modelId="gpt-4"
providerName="unknown-provider"
/>,
)
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
})
})
// isInWorkflow=true: workflow border class + RiArrowDownSLine arrow
it('should render workflow styles when isInWorkflow is true', () => {
// Act
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
isInWorkflow
/>,
)
describe('Split layout', () => {
it('should use split layout with settings button in non-workflow mode', () => {
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
// Assert
expect(container.firstChild).toHaveClass('border-workflow-block-parma-bg')
expect(container.firstChild).toHaveClass('bg-workflow-block-parma-bg')
expect(container.querySelectorAll('svg').length).toBe(2)
})
const splitContainer = container.querySelector('.rounded-l-lg')
expect(splitContainer).toBeInTheDocument()
const settingsButton = container.querySelector('.rounded-r-lg')
expect(settingsButton).toBeInTheDocument()
})
// disabled=true + hasDeprecated=true: AlertTriangle + deprecated tooltip
it('should show deprecated warning when disabled with hasDeprecated', () => {
// Act
render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
disabled
hasDeprecated
/>,
)
it('should use split layout for error states in non-workflow mode', () => {
mockUseCredentialPanelState.mockReturnValue({
...activeCredentialState,
variant: 'api-unavailable',
})
// Assert - AlertTriangle renders with warning color
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
})
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
providerName="openai"
modelId="gpt-4"
/>,
)
// disabled=true + modelDisabled=true: status text tooltip
it('should show model status tooltip when disabled with modelDisabled', () => {
// Act
render(
<Trigger
currentProvider={currentProvider}
currentModel={{ ...currentModel, status: 'no-configure' } as unknown as typeof currentModel}
disabled
modelDisabled
/>,
)
// Assert - AlertTriangle warning icon should be present
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
})
it('should render empty tooltip content when disabled without deprecated or modelDisabled', async () => {
const user = userEvent.setup()
const { container } = render(
<Trigger
currentProvider={currentProvider}
currentModel={currentModel}
disabled
hasDeprecated={false}
modelDisabled={false}
/>,
)
const warningIcon = document.querySelector('.text-\\[\\#F79009\\]')
expect(warningIcon).toBeInTheDocument()
const trigger = container.querySelector('[data-state]')
expect(trigger).toBeInTheDocument()
await user.hover(trigger as HTMLElement)
const tooltip = screen.queryByRole('tooltip')
if (tooltip)
expect(tooltip).toBeEmptyDOMElement()
expect(screen.queryByText('modelProvider.deprecated')).not.toBeInTheDocument()
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
})
// providerName not matching any provider: find() returns undefined
it('should render without crashing when providerName does not match any provider', () => {
// Act
render(
<Trigger
modelId="gpt-4"
providerName="unknown-provider"
/>,
)
// Assert
expect(screen.getByText('gpt-4')).toBeInTheDocument()
const splitContainer = container.querySelector('.rounded-l-lg')
expect(splitContainer).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,112 @@
import type { ModelItem, ModelProvider } from '../declarations'
import type { CredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import { ModelStatusEnum } from '../declarations'
import { deriveTriggerStatus } from './derive-trigger-status'
const baseCredentialState: CredentialPanelState = {
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary Key',
credits: 10,
}
const mockProvider = { provider: 'openai' } as ModelProvider
const mockModel = { model: 'gpt-4', status: ModelStatusEnum.active } as ModelItem
describe('deriveTriggerStatus', () => {
it('returns empty when modelId is missing', () => {
expect(deriveTriggerStatus(undefined, 'openai', mockProvider, mockModel, baseCredentialState)).toBe('empty')
})
it('returns empty when providerName is missing', () => {
expect(deriveTriggerStatus('gpt-4', undefined, mockProvider, mockModel, baseCredentialState)).toBe('empty')
})
it('returns incompatible when provider plugin is not installed', () => {
expect(deriveTriggerStatus('gpt-4', 'openai', undefined, mockModel, baseCredentialState)).toBe('incompatible')
})
it('returns credits-exhausted when credits priority and exhausted', () => {
const state: CredentialPanelState = {
...baseCredentialState,
priority: 'credits',
isCreditsExhausted: true,
}
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('credits-exhausted')
})
it('returns active when credits priority but not exhausted', () => {
const state: CredentialPanelState = {
...baseCredentialState,
priority: 'credits',
isCreditsExhausted: false,
}
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('active')
})
it('returns api-key-unavailable when variant is api-unavailable', () => {
const state: CredentialPanelState = {
...baseCredentialState,
variant: 'api-unavailable',
}
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('api-key-unavailable')
})
it('returns incompatible when currentModel is missing (deprecated)', () => {
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, baseCredentialState)).toBe('incompatible')
})
it('returns credits-exhausted when currentModel is missing and AI credits are exhausted without api key', () => {
const state: CredentialPanelState = {
...baseCredentialState,
priority: 'apiKey',
hasCredentials: false,
isCreditsExhausted: true,
credentialName: undefined,
}
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, undefined, state)).toBe('credits-exhausted')
})
it('returns configure-required when model status is no-configure', () => {
const model = { ...mockModel, status: ModelStatusEnum.noConfigure } as ModelItem
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('configure-required')
})
it('returns incompatible when model status is noPermission', () => {
const model = { ...mockModel, status: ModelStatusEnum.noPermission } as ModelItem
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('incompatible')
})
it('returns disabled when model status is disabled', () => {
const model = { ...mockModel, status: ModelStatusEnum.disabled } as ModelItem
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, model, baseCredentialState)).toBe('disabled')
})
it('returns active when all conditions are satisfied', () => {
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, baseCredentialState)).toBe('active')
})
it('prioritises credits-exhausted over api-unavailable', () => {
const state: CredentialPanelState = {
...baseCredentialState,
priority: 'credits',
isCreditsExhausted: true,
variant: 'api-unavailable',
}
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('credits-exhausted')
})
it('does not return credits-exhausted when supportsCredits is false', () => {
const state: CredentialPanelState = {
...baseCredentialState,
priority: 'credits',
isCreditsExhausted: true,
supportsCredits: false,
}
expect(deriveTriggerStatus('gpt-4', 'openai', mockProvider, mockModel, state)).toBe('active')
})
})

View File

@ -0,0 +1,12 @@
import type { DerivedModelStatus } from '../derive-model-status'
import {
DERIVED_MODEL_STATUS_BADGE_I18N,
DERIVED_MODEL_STATUS_TOOLTIP_I18N,
deriveModelStatus,
} from '../derive-model-status'
export type TriggerStatus = DerivedModelStatus
export const deriveTriggerStatus = deriveModelStatus
export const TRIGGER_STATUS_BADGE_I18N = DERIVED_MODEL_STATUS_BADGE_I18N
export const TRIGGER_STATUS_TOOLTIP_I18N = DERIVED_MODEL_STATUS_TOOLTIP_I18N

View File

@ -9,20 +9,19 @@ import type {
} from '../declarations'
import type { ParameterValue } from './parameter-item'
import type { TriggerProps } from './trigger'
import { useMemo, useState } from 'react'
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
import Loading from '@/app/components/base/loading'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { PROVIDER_WITH_PRESET_TONE, STOP_PARAMETER_RULE, TONE_LIST } from '@/config'
import { useProviderContext } from '@/context/provider-context'
import { useModelParameterRules } from '@/service/use-common'
import { cn } from '@/utils/classnames'
import { ModelStatusEnum } from '../declarations'
import {
useTextGenerationCurrentProviderAndModelAndModelList,
} from '../hooks'
@ -66,9 +65,9 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
isInWorkflow,
}) => {
const { t } = useTranslation()
const { isAPIKeySet } = useProviderContext()
const [open, setOpen] = useState(false)
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
const settingsIconRef = useRef<HTMLDivElement>(null)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
const {
currentProvider,
currentModel,
@ -77,10 +76,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
{ provider, model: modelId },
)
const hasDeprecated = !currentProvider || !currentModel
const modelDisabled = currentModel?.status !== ModelStatusEnum.active
const disabled = !isAPIKeySet || hasDeprecated || modelDisabled
const parameterRules: ModelParameterRule[] = useMemo(() => {
return parameterRulesData?.data || []
}, [parameterRulesData])
@ -129,117 +124,118 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement={isInWorkflow ? 'left' : 'bottom-end'}
offset={4}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
>
<div className="relative">
<PortalToFollowElemTrigger
onClick={() => {
if (readonly)
return
setOpen(v => !v)
}}
className="block"
>
{
renderTrigger
? renderTrigger({
open,
disabled,
modelDisabled,
hasDeprecated,
currentProvider,
currentModel,
providerName: provider,
modelId,
})
: (
<Trigger
disabled={disabled}
isInWorkflow={isInWorkflow}
modelDisabled={modelDisabled}
hasDeprecated={hasDeprecated}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={provider}
modelId={modelId}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={cn('z-[60]', portalToFollowElemContentClassName)}>
<div className={cn(popupClassName, 'w-[389px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg')}>
<div className={cn('max-h-[420px] overflow-y-auto p-4 pt-3')}>
<div className="relative">
<div className={cn('system-sm-semibold mb-1 flex h-6 items-center text-text-secondary')}>
{t('modelProvider.model', { ns: 'common' }).toLocaleUpperCase()}
</div>
<ModelSelector
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
onSelect={handleChangeModel}
/>
</div>
{
!!parameterRules.length && (
<div className="my-3 h-px bg-divider-subtle" />
)
}
{
isLoading && (
<div className="mt-5"><Loading /></div>
)
}
{
!isLoading && !!parameterRules.length && (
<div className="mb-2 flex items-center justify-between">
<div className={cn('system-sm-semibold flex h-6 items-center text-text-secondary')}>{t('modelProvider.parameters', { ns: 'common' })}</div>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
)
}
</div>
)
}
{
!isLoading && !!parameterRules.length && (
[
...parameterRules,
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
parameterRule={parameter}
value={completionParams?.[parameter.name]}
onChange={v => handleParamChange(parameter.name, v)}
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
<PopoverTrigger
render={(
<button type="button" className="block w-full border-none bg-transparent p-0 text-left [color:inherit] [font:inherit]">
{
renderTrigger
? renderTrigger({
open,
currentProvider,
currentModel,
providerName: provider,
modelId,
})
: (
<Trigger
isInWorkflow={isInWorkflow}
currentProvider={currentProvider}
currentModel={currentModel}
providerName={provider}
modelId={modelId}
settingsRef={settingsIconRef}
/>
))
)
}
</div>
{!hideDebugWithMultipleModel && (
<div
className="bg-components-section-burn system-sm-regular flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent"
onClick={() => onDebugWithMultipleModelChange?.()}
>
{
debugWithMultipleModel
? t('debugAsSingleModel', { ns: 'appDebug' })
: t('debugAsMultipleModel', { ns: 'appDebug' })
}
<ArrowNarrowLeft className="h-3 w-3 rotate-180" />
</div>
)}
)
}
</button>
)}
/>
<PopoverContent
placement={isInWorkflow ? 'left' : (renderTrigger ? 'bottom-end' : 'left-start')}
sideOffset={4}
className={portalToFollowElemContentClassName}
popupClassName={cn(popupClassName, 'w-[400px] rounded-2xl')}
positionerProps={!renderTrigger ? { anchor: settingsIconRef } : undefined}
>
<div className="relative px-3 pb-1 pt-3.5">
<div className="pl-1 pr-8 text-text-primary system-xl-semibold">
{t('modelProvider.modelSettings', { ns: 'common' })}
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
<PopoverClose className="absolute right-2.5 top-2.5 flex items-center justify-center rounded-lg p-1.5 hover:bg-state-base-hover">
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
</PopoverClose>
</div>
<div className="max-h-[420px] overflow-y-auto">
<div className="px-4 pb-4 pt-2">
<ModelSelector
defaultModel={(provider || modelId) ? { provider, model: modelId } : undefined}
modelList={activeTextGenerationModelList}
onSelect={handleChangeModel}
onHide={() => setOpen(false)}
/>
</div>
{
!!parameterRules.length && (
<div className="flex flex-col gap-2 border-t border-divider-subtle px-4 pb-4 pt-3">
<div className="flex items-center gap-1">
<div className="flex flex-1 items-center text-text-secondary system-sm-semibold-uppercase">{t('modelProvider.parameters', { ns: 'common' })}</div>
{
PROVIDER_WITH_PRESET_TONE.includes(provider) && (
<PresetsParameter onSelect={handleSelectPresetParameter} />
)
}
</div>
{
isLoading
? <div className="py-5"><Loading /></div>
: (
[
...parameterRules,
...(isAdvancedMode ? [STOP_PARAMETER_RULE] : []),
].map(parameter => (
<ParameterItem
key={`${modelId}-${parameter.name}`}
parameterRule={parameter}
value={completionParams?.[parameter.name]}
onChange={v => handleParamChange(parameter.name, v)}
onSwitch={(checked, assignValue) => handleSwitch(parameter.name, checked, assignValue)}
isInWorkflow={isInWorkflow}
/>
))
)
}
</div>
)
}
{
!parameterRules.length && isLoading && (
<div className="px-4 py-5"><Loading /></div>
)
}
</div>
{!hideDebugWithMultipleModel && (
<div
className="flex h-[50px] cursor-pointer items-center justify-between rounded-b-xl border-t border-t-divider-subtle px-4 text-text-accent system-sm-regular"
onClick={() => onDebugWithMultipleModelChange?.()}
>
{
debugWithMultipleModel
? t('debugAsSingleModel', { ns: 'appDebug' })
: t('debugAsMultipleModel', { ns: 'appDebug' })
}
<ArrowNarrowLeft className="h-3 w-3 rotate-180" />
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,48 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import ParameterItem from './parameter-item'
vi.mock('../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/ui/select', () => ({
Select: ({ children, onValueChange }: { children: ReactNode, onValueChange: (value: string | undefined) => void }) => (
<div>
<button type="button" onClick={() => onValueChange('updated')}>select-updated</button>
<button type="button" onClick={() => onValueChange(undefined)}>select-empty</button>
{children}
</div>
),
SelectContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectTrigger: ({ children }: { children: ReactNode }) => <div>{children}</div>,
SelectValue: () => <div>SelectValue</div>,
}))
describe('ParameterItem select mode', () => {
it('should propagate both explicit and empty select values', () => {
const onChange = vi.fn()
render(
<ParameterItem
parameterRule={{
name: 'format',
label: { en_US: 'Format', zh_Hans: 'Format' },
type: 'string',
options: ['json', 'text'],
required: false,
help: { en_US: 'Help', zh_Hans: 'Help' },
}}
value="json"
onChange={onChange}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'select-updated' }))
fireEvent.click(screen.getByRole('button', { name: 'select-empty' }))
expect(onChange).toHaveBeenNthCalledWith(1, 'updated')
expect(onChange).toHaveBeenNthCalledWith(2, undefined)
})
})

View File

@ -1,12 +1,11 @@
import type { FC } from 'react'
import type { ModelParameterRule } from '../declarations'
import { useEffect, useRef, useState } from 'react'
import Radio from '@/app/components/base/radio'
import { SimpleSelect } from '@/app/components/base/select'
import Slider from '@/app/components/base/slider'
import Switch from '@/app/components/base/switch'
import TagInput from '@/app/components/base/tag-input'
import Tooltip from '@/app/components/base/tooltip'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/app/components/base/ui/select'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import { useLanguage } from '../hooks'
import { isNullOrUndefined } from '../utils'
@ -20,13 +19,13 @@ type ParameterItemProps = {
onSwitch?: (checked: boolean, assignValue: ParameterValue) => void
isInWorkflow?: boolean
}
const ParameterItem: FC<ParameterItemProps> = ({
function ParameterItem({
parameterRule,
value,
onChange,
onSwitch,
isInWorkflow,
}) => {
}: ParameterItemProps) {
const language = useLanguage()
const [localValue, setLocalValue] = useState(value)
const numberInputRef = useRef<HTMLInputElement>(null)
@ -99,10 +98,6 @@ const ParameterItem: FC<ParameterItemProps> = ({
handleInputChange(e.target.value)
}
const handleSelect = (option: { value: string | number, name: string }) => {
handleInputChange(option.value)
}
const handleTagChange = (newSequences: string[]) => {
handleInputChange(newSequences)
}
@ -222,13 +217,19 @@ const ParameterItem: FC<ParameterItemProps> = ({
if (parameterRule.type === 'string' && !!parameterRule?.options?.length) {
return (
<SimpleSelect
className="!py-0"
wrapperClassName={cn('!h-8 w-full')}
defaultValue={renderValue as string}
onSelect={handleSelect}
items={parameterRule.options.map(option => ({ value: option, name: option }))}
/>
<Select
value={renderValue as string}
onValueChange={v => handleInputChange(v ?? undefined)}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{parameterRule.options!.map(option => (
<SelectItem key={option} value={option}>{option}</SelectItem>
))}
</SelectContent>
</Select>
)
}
@ -272,13 +273,18 @@ const ParameterItem: FC<ParameterItemProps> = ({
</div>
{
parameterRule.help && (
<Tooltip
popupContent={(
<Tooltip>
<TooltipTrigger
render={(
<span className="mr-1 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary" />
</span>
)}
/>
<TooltipContent popupClassName="mr-1">
<div className="w-[150px] whitespace-pre-wrap">{parameterRule.help[language] || parameterRule.help.en_US}</div>
)}
popupClassName="mr-1"
triggerClassName="mr-1 w-4 h-4 shrink-0"
/>
</TooltipContent>
</Tooltip>
)
}
</div>

View File

@ -1,14 +1,16 @@
import type { FC } from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import { useCallback } from 'react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Dropdown from '@/app/components/base/dropdown'
import { Brush01 } from '@/app/components/base/icons/src/vender/solid/editor'
import { Scales02 } from '@/app/components/base/icons/src/vender/solid/FinanceAndECommerce'
import { Target04 } from '@/app/components/base/icons/src/vender/solid/general'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { TONE_LIST } from '@/config'
import { cn } from '@/utils/classnames'
const toneI18nKeyMap = {
Creative: 'model.tone.Creative',
@ -17,53 +19,42 @@ const toneI18nKeyMap = {
Custom: 'model.tone.Custom',
} as const
const TONE_ICONS: Record<number, ReactNode> = {
1: <Brush01 className="mr-2 h-[14px] w-[14px] text-[#6938EF]" />,
2: <Scales02 className="mr-2 h-[14px] w-[14px] text-indigo-600" />,
3: <Target04 className="mr-2 h-[14px] w-[14px] text-[#107569]" />,
}
type PresetsParameterProps = {
onSelect: (toneId: number) => void
}
const PresetsParameter: FC<PresetsParameterProps> = ({
onSelect,
}) => {
function PresetsParameter({ onSelect }: PresetsParameterProps) {
const { t } = useTranslation()
const renderTrigger = useCallback((open: boolean) => {
return (
<Button
size="small"
variant="secondary"
className={cn(open && 'bg-state-base-hover')}
>
{t('modelProvider.loadPresets', { ns: 'common' })}
<RiArrowDownSLine className="ml-0.5 h-3.5 w-3.5" />
</Button>
)
}, [t])
const getToneIcon = (toneId: number) => {
const className = 'mr-2 w-[14px] h-[14px]'
const res = ({
1: <Brush01 className={`${className} text-[#6938EF]`} />,
2: <Scales02 className={`${className} text-indigo-600`} />,
3: <Target04 className={`${className} text-[#107569]`} />,
})[toneId]
return res
}
const options = TONE_LIST.slice(0, 3).map((tone) => {
return {
value: tone.id,
text: (
<div className="flex h-full items-center">
{getToneIcon(tone.id)}
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
</div>
),
}
})
return (
<Dropdown
renderTrigger={renderTrigger}
items={options}
onSelect={item => onSelect(item.value as number)}
popupClassName="z-[1003]"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={(
<Button
size="small"
variant="secondary"
className="data-[popup-open]:bg-state-base-hover"
/>
)}
>
{t('modelProvider.loadPresets', { ns: 'common' })}
<span className="i-ri-arrow-down-s-line ml-0.5 h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent>
{TONE_LIST.slice(0, 3).map(tone => (
<DropdownMenuItem key={tone.id} onClick={() => onSelect(tone.id)}>
{TONE_ICONS[tone.id]}
{t(toneI18nKeyMap[tone.name], { ns: 'common' })}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@ -1,110 +1,136 @@
import type { FC } from 'react'
import type { FC, Ref } from 'react'
import type {
Model,
ModelItem,
ModelProvider,
} from '../declarations'
import { RiArrowDownSLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { SlidersH } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import { MODEL_STATUS_TEXT } from '../declarations'
import { useLanguage } from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import {
deriveTriggerStatus,
TRIGGER_STATUS_BADGE_I18N,
TRIGGER_STATUS_TOOLTIP_I18N,
} from './derive-trigger-status'
export type TriggerProps = {
open?: boolean
disabled?: boolean
currentProvider?: ModelProvider | Model
currentModel?: ModelItem
providerName?: string
modelId?: string
hasDeprecated?: boolean
modelDisabled?: boolean
isInWorkflow?: boolean
settingsRef?: Ref<HTMLDivElement>
}
const Trigger: FC<TriggerProps> = ({
disabled,
currentProvider,
currentModel,
providerName,
modelId,
hasDeprecated,
modelDisabled,
isInWorkflow,
settingsRef,
}) => {
const { t } = useTranslation()
const language = useLanguage()
const { modelProviders } = useProviderContext()
const currentModelProvider = modelProviders.find(p => p.provider === providerName)
const credentialState = useCredentialPanelState(currentModelProvider)
const status = deriveTriggerStatus(modelId, providerName, currentModelProvider, currentModel, credentialState)
const badgeKey = TRIGGER_STATUS_BADGE_I18N[status as keyof typeof TRIGGER_STATUS_BADGE_I18N]
const tooltipKey = TRIGGER_STATUS_TOOLTIP_I18N[status as keyof typeof TRIGGER_STATUS_TOOLTIP_I18N]
const badgeLabel = badgeKey ? t(badgeKey, { ns: 'common' }) : null
const tooltipLabel = tooltipKey ? t(tooltipKey, { ns: 'common' }) : null
const isActive = status === 'active'
const iconProvider = currentProvider || modelProviders.find(item => item.provider === providerName)
if (status === 'empty') {
return (
<div
className={cn(
'relative flex h-8 min-w-[296px] cursor-pointer items-center rounded-lg px-2',
isInWorkflow
? 'border border-text-warning bg-state-warning-hover pr-[30px]'
: 'border border-text-warning bg-state-warning-hover ring-inset ring-text-warning hover:ring-[0.5px]',
)}
>
<div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line h-3.5 w-3.5 text-text-quaternary" />
</div>
</div>
<div className="mr-1 flex-1 truncate text-[13px] font-normal text-text-secondary">
{t('workflow:errorMsg.configureModel')}
</div>
<span className={cn('i-ri-arrow-down-s-line h-4 w-4 shrink-0 text-text-tertiary', isInWorkflow && 'absolute right-2 top-[9px] h-3.5 w-3.5')} />
</div>
)
}
return (
<div
className={cn(
'relative flex h-8 cursor-pointer items-center rounded-lg px-2',
!isInWorkflow && 'border ring-inset hover:ring-[0.5px]',
!isInWorkflow && (disabled ? 'border-text-warning bg-state-warning-hover ring-text-warning' : 'border-util-colors-indigo-indigo-600 bg-state-accent-hover ring-util-colors-indigo-indigo-600'),
isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg pr-[30px] hover:border-components-input-border-active',
)}
>
{
currentProvider && (
<ModelIcon
className="mr-1.5 !h-5 !w-5"
provider={currentProvider}
modelName={currentModel?.model}
/>
)
}
{
!currentProvider && (
<ModelIcon
className="mr-1.5 !h-5 !w-5"
provider={modelProviders.find(item => item.provider === providerName)}
modelName={modelId}
/>
)
}
{
currentModel && (
<ModelName
className="mr-1.5 text-text-primary"
modelItem={currentModel}
showMode
showFeatures
/>
)
}
{
!currentModel && (
<div className="mr-1 truncate text-[13px] font-medium text-text-primary">
{modelId}
<div className="flex h-8 min-w-[296px] cursor-pointer items-center gap-px overflow-hidden rounded-lg">
<div className={cn('flex flex-1 items-center gap-0.5 rounded-l-lg p-1', isInWorkflow ? 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg' : 'bg-components-input-bg-normal')}>
<ModelIcon
className="p-0.5"
provider={iconProvider}
modelName={currentModel?.model || modelId}
/>
<div className="flex flex-1 items-center truncate px-1 py-[3px]">
{currentModel
? (
<ModelName
className="grow"
modelItem={currentModel}
showMode={isActive}
showFeatures={isActive}
/>
)
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
</div>
{badgeKey && (
tooltipLabel
? (
<Tooltip>
<TooltipTrigger
render={(
<div className="flex shrink-0 items-center pr-0.5">
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{badgeLabel}
</span>
</div>
</div>
)}
/>
<TooltipContent placement="top">
{tooltipLabel}
</TooltipContent>
</Tooltip>
)
: (
<div className="flex shrink-0 items-center pr-0.5">
<div className="flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning bg-components-badge-bg-dimm px-[5px] py-0.5">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{badgeLabel}
</span>
</div>
</div>
)
)}
{!badgeKey && (
<div className="flex shrink-0 items-center pr-1">
<span className="i-ri-arrow-down-s-line h-4 w-4 text-text-tertiary" />
</div>
)
}
{
disabled
? (
<Tooltip
popupContent={
hasDeprecated
? t('modelProvider.deprecated', { ns: 'common' })
: (modelDisabled && currentModel)
? MODEL_STATUS_TEXT[currentModel.status as string][language]
: ''
}
>
<AlertTriangle className="h-4 w-4 text-[#F79009]" />
</Tooltip>
)
: (
<SlidersH className={cn(!isInWorkflow ? 'text-indigo-600' : 'text-text-tertiary', 'h-4 w-4 shrink-0')} />
)
}
{isInWorkflow && (<RiArrowDownSLine className="absolute right-2 top-[9px] h-3.5 w-3.5 text-text-tertiary" />)}
)}
</div>
<div ref={settingsRef} className={cn('flex shrink-0 items-center justify-center rounded-r-lg p-2', isInWorkflow ? 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg' : 'bg-components-button-tertiary-bg')}>
<span className="i-ri-equalizer-2-line h-4 w-4 text-text-tertiary" />
</div>
</div>
)
}

View File

@ -1,61 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import DeprecatedModelTrigger from '../deprecated-model-trigger'
vi.mock('../../model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
}))
const mockUseProviderContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
describe('DeprecatedModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'someone-else' }, { provider: 'openai' }],
})
})
it('should render model name', () => {
render(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
})
it('should show deprecated tooltip when warn icon is hovered', async () => {
const { container } = render(
<DeprecatedModelTrigger
modelName="gpt-deprecated"
providerName="openai"
showWarnIcon
/>,
)
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
fireEvent.mouseEnter(tooltipTrigger)
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
})
it('should render when provider is not found', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'someone-else' }],
})
render(<DeprecatedModelTrigger modelName="gpt-deprecated" providerName="openai" />)
expect(screen.getAllByText('gpt-deprecated').length).toBeGreaterThan(0)
})
it('should not show deprecated tooltip when warn icon is disabled', async () => {
render(
<DeprecatedModelTrigger
modelName="gpt-deprecated"
providerName="openai"
showWarnIcon={false}
/>,
)
expect(screen.queryByText('common.modelProvider.deprecated')).not.toBeInTheDocument()
})
})

View File

@ -1,31 +0,0 @@
import { render, screen } from '@testing-library/react'
import EmptyTrigger from '../empty-trigger'
describe('EmptyTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render configure model text', () => {
render(<EmptyTrigger open={false} />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
// open=true: hover bg class present
it('should apply hover background class when open is true', () => {
// Act
const { container } = render(<EmptyTrigger open={true} />)
// Assert
expect(container.firstChild).toHaveClass('bg-components-input-bg-hover')
})
// className prop truthy: custom className appears on root
it('should apply custom className when provided', () => {
// Act
const { container } = render(<EmptyTrigger open={false} className="custom-class" />)
// Assert
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@ -1,4 +1,6 @@
import type { Model, ModelItem } from '../../declarations'
import type { ReactNode } from 'react'
import type { DefaultModel, Model, ModelItem } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
@ -7,16 +9,20 @@ import {
} from '../../declarations'
import ModelSelector from '../index'
vi.mock('../model-trigger', () => ({
default: () => <div>model-trigger</div>,
}))
vi.mock('../model-selector-trigger', () => ({
default: ({
currentProvider,
currentModel,
defaultModel,
}: { currentProvider?: Model, currentModel?: ModelItem, defaultModel?: DefaultModel }) => {
if (currentProvider && currentModel)
return <div>model-trigger</div>
vi.mock('../deprecated-model-trigger', () => ({
default: ({ modelName }: { modelName: string }) => <div>{`deprecated:${modelName}`}</div>,
}))
if (defaultModel)
return <div>{`deprecated:${defaultModel.model}`}</div>
vi.mock('../empty-trigger', () => ({
default: () => <div>empty-trigger</div>,
return <div>empty-trigger</div>
},
}))
vi.mock('../popup', () => ({
@ -52,24 +58,43 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
...overrides,
})
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
const renderWithQueryClient = (node: ReactNode) => {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>,
)
}
describe('ModelSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should toggle popup and close it after selecting a model', () => {
render(<ModelSelector modelList={[makeModel()]} />)
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} />)
fireEvent.click(screen.getByText('empty-trigger'))
const triggerButton = screen.getByRole('button', { name: 'empty-trigger' })
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('select')).toBeInTheDocument()
fireEvent.click(screen.getByText('select'))
expect(screen.queryByText('select')).not.toBeInTheDocument()
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should call onSelect when provided', () => {
const onSelect = vi.fn()
render(<ModelSelector modelList={[makeModel()]} onSelect={onSelect} />)
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} onSelect={onSelect} />)
fireEvent.click(screen.getByText('empty-trigger'))
fireEvent.click(screen.getByText('select'))
@ -78,24 +103,26 @@ describe('ModelSelector', () => {
})
it('should close popup when popup requests hide', () => {
render(<ModelSelector modelList={[makeModel()]} />)
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} />)
fireEvent.click(screen.getByText('empty-trigger'))
const triggerButton = screen.getByRole('button', { name: 'empty-trigger' })
fireEvent.click(triggerButton)
expect(triggerButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('hide')).toBeInTheDocument()
fireEvent.click(screen.getByText('hide'))
expect(screen.queryByText('hide')).not.toBeInTheDocument()
expect(triggerButton).toHaveAttribute('aria-expanded', 'false')
})
it('should not open popup when readonly', () => {
render(<ModelSelector modelList={[makeModel()]} readonly />)
renderWithQueryClient(<ModelSelector modelList={[makeModel()]} readonly />)
fireEvent.click(screen.getByText('empty-trigger'))
expect(screen.queryByText('select')).not.toBeInTheDocument()
})
it('should render deprecated trigger when defaultModel is not in list', () => {
const { rerender } = render(
const { unmount } = renderWithQueryClient(
<ModelSelector
defaultModel={{ provider: 'openai', model: 'missing-model' }}
modelList={[makeModel()]}
@ -104,7 +131,8 @@ describe('ModelSelector', () => {
expect(screen.getByText('deprecated:missing-model')).toBeInTheDocument()
rerender(
unmount()
renderWithQueryClient(
<ModelSelector
defaultModel={{ provider: '', model: '' }}
modelList={[makeModel()]}
@ -114,7 +142,7 @@ describe('ModelSelector', () => {
})
it('should render model trigger when defaultModel matches', () => {
render(
renderWithQueryClient(
<ModelSelector
defaultModel={{ provider: 'openai', model: 'gpt-4' }}
modelList={[makeModel()]}

View File

@ -1,91 +0,0 @@
import type { Model, ModelItem } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../../declarations'
import ModelTrigger from '../model-trigger'
vi.mock('../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
return {
...actual,
useLanguage: () => 'en_US',
}
})
vi.mock('../../model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span>{modelName}</span>,
}))
vi.mock('../../model-name', () => ({
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
}))
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: ModelTypeEnum.textGeneration,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const makeModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [makeModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should show model name', () => {
render(
<ModelTrigger
open
provider={makeModel()}
model={makeModelItem()}
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should show status tooltip content when model is not active', async () => {
const { container } = render(
<ModelTrigger
open={false}
provider={makeModel()}
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
/>,
)
const tooltipTrigger = container.querySelector('[data-state]') as HTMLElement
fireEvent.mouseEnter(tooltipTrigger)
expect(await screen.findByText('No Configure')).toBeInTheDocument()
})
it('should not show status icon when readonly', () => {
render(
<ModelTrigger
open={false}
provider={makeModel()}
model={makeModelItem({ status: ModelStatusEnum.noConfigure })}
readonly
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.queryByText('No Configure')).not.toBeInTheDocument()
})
})

View File

@ -2,21 +2,23 @@ import type { DefaultModel, Model, ModelItem } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '../../declarations'
import PopupItem from '../popup-item'
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockUpdateModelProviders = vi.hoisted(() => vi.fn())
const mockLanguageRef = vi.hoisted(() => ({ value: 'en_US' }))
const mockUseLanguage = vi.hoisted(() => vi.fn(() => 'en_US'))
vi.mock('../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
return {
...actual,
useLanguage: () => mockLanguageRef.value,
useLanguage: mockUseLanguage,
useUpdateModelList: () => mockUpdateModelList,
useUpdateModelProviders: () => mockUpdateModelProviders,
}
@ -34,6 +36,36 @@ vi.mock('../../model-name', () => ({
default: ({ modelItem }: { modelItem: ModelItem }) => <span>{modelItem.label.en_US}</span>,
}))
vi.mock('../feature-icon', () => ({
default: ({ feature }: { feature: string }) => <span data-testid="feature-icon">{feature}</span>,
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
const mockCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('../../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockCredentialPanelState,
}))
vi.mock('../../provider-added-card/use-change-provider-priority', () => ({
useChangeProviderPriority: () => ({
isChangingPriority: false,
handleChangePriority: vi.fn(),
}),
}))
vi.mock('../../provider-added-card/model-auth-dropdown/dropdown-content', () => ({
default: ({ onClose }: { onClose: () => void }) => <button type="button" onClick={onClose}>close dropdown</button>,
}))
const mockSetShowModelModal = vi.hoisted(() => vi.fn())
vi.mock('@/context/modal-context', () => ({
useModalContext: () => ({
@ -46,6 +78,11 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
const mockUseAppContext = vi.hoisted(() => vi.fn())
vi.mock('@/context/app-context', () => ({
useAppContext: mockUseAppContext,
}))
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@ -67,18 +104,53 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
...overrides,
})
const makeProvider = (overrides: Record<string, unknown> = {}) => ({
provider: 'openai',
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_name: 'my-api-key',
},
...overrides,
})
describe('PopupItem', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguageRef.value = 'en_US'
mockUseLanguage.mockReturnValue('en_US')
mockUseProviderContext.mockReturnValue({
modelProviders: [{ provider: 'openai' }],
modelProviders: [makeProvider()],
})
mockUseAppContext.mockReturnValue({
currentWorkspace: { trial_credits: 200, trial_credits_used: 0 },
})
mockCredentialPanelState.mockReturnValue({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'my-api-key',
credits: 200,
})
})
it('should render nothing when provider is not found in modelProviders', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [],
})
const { container } = render(
<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />,
)
expect(container.innerHTML).toBe('')
})
it('should call onSelect when clicking an active model', () => {
const onSelect = vi.fn()
render(<PopupItem model={makeModel()} onSelect={onSelect} />)
render(<PopupItem model={makeModel()} onSelect={onSelect} onHide={vi.fn()} />)
fireEvent.click(screen.getByText('GPT-4'))
@ -91,6 +163,7 @@ describe('PopupItem', () => {
<PopupItem
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.disabled })] })}
onSelect={onSelect}
onHide={vi.fn()}
/>,
)
@ -104,6 +177,7 @@ describe('PopupItem', () => {
<PopupItem
model={makeModel({ models: [makeModelItem({ status: ModelStatusEnum.noConfigure })] })}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -123,6 +197,7 @@ describe('PopupItem', () => {
models: [makeModelItem({ status: ModelStatusEnum.noConfigure, model_type: undefined as unknown as ModelTypeEnum })],
})}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
@ -141,92 +216,148 @@ describe('PopupItem', () => {
defaultModel={defaultModel}
model={makeModel()}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should not show check icon when model matches but provider does not', () => {
const defaultModel: DefaultModel = { provider: 'anthropic', model: 'gpt-4' }
it('should fall back to english labels when the current language is unavailable', () => {
mockUseLanguage.mockReturnValue('zh_Hans')
render(
<PopupItem
defaultModel={defaultModel}
model={makeModel()}
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()}
/>,
)
const checkIcons = document.querySelectorAll('.h-4.w-4.shrink-0.text-text-accent')
expect(checkIcons.length).toBe(0)
expect(screen.getByText('OpenAI only')).toBeInTheDocument()
expect(screen.getByText('GPT-4 only')).toBeInTheDocument()
})
it('should not show mode badge when model_properties.mode is absent', () => {
const modelItem = makeModelItem({ model_properties: {} })
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
it('should toggle collapsed state when clicking provider header', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
expect(screen.getByText('GPT-4')).toBeInTheDocument()
fireEvent.click(screen.getByText('OpenAI'))
expect(screen.queryByText('GPT-4')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('OpenAI'))
expect(screen.getByText('GPT-4')).toBeInTheDocument()
})
it('should fall back to en_US label when current locale translation is empty', () => {
mockLanguageRef.value = 'zh_Hans'
const model = makeModel({
label: { en_US: 'English Label', zh_Hans: '' },
it('should show credential name when using custom provider', () => {
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText('my-api-key')).toBeInTheDocument()
})
it('should render the inactive credential badge when the api key is not active', () => {
mockCredentialPanelState.mockReturnValue({
variant: 'api-inactive',
priority: 'apiKey',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'stale-key',
credits: 200,
})
render(<PopupItem model={model} onSelect={vi.fn()} />)
expect(screen.getByText('English Label')).toBeInTheDocument()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText('stale-key')).toBeInTheDocument()
expect(document.querySelector('.bg-components-badge-status-light-error-bg')).not.toBeNull()
})
it('should not show context_size badge when absent', () => {
const modelItem = makeModelItem({ model_properties: { mode: 'chat' } })
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText(/K$/)).not.toBeInTheDocument()
})
it('should not show capabilities section when features are empty', () => {
const modelItem = makeModelItem({ features: [] })
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
})
it('should not show capabilities for non-qualifying model types', () => {
const modelItem = makeModelItem({
model_type: ModelTypeEnum.tts,
features: [ModelFeatureEnum.vision],
it('should show configure required when no credential name', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
current_credential_name: '',
},
})],
})
mockCredentialPanelState.mockReturnValue({
variant: 'api-required-configure',
priority: 'apiKey',
supportsCredits: false,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 0,
})
render(
<PopupItem
model={makeModel({ models: [modelItem] })}
onSelect={vi.fn()}
/>,
)
expect(screen.queryByText('common.model.capabilities')).not.toBeInTheDocument()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.configureRequired/)).toBeInTheDocument()
})
it('should show en_US label when language is fr_FR and fr_FR key is absent', () => {
mockLanguageRef.value = 'fr_FR'
const model = makeModel({ label: { en_US: 'FallbackLabel', zh_Hans: 'FallbackLabel' } })
render(<PopupItem model={model} onSelect={vi.fn()} />)
it('should show credits info when using system provider with remaining credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
})],
})
mockCredentialPanelState.mockReturnValue({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 200,
})
expect(screen.getByText('FallbackLabel')).toBeInTheDocument()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.aiCredits/)).toBeInTheDocument()
})
it('should show credits exhausted when system provider has no credits', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [makeProvider({
preferred_provider_type: PreferredProviderTypeEnum.system,
})],
})
mockUseAppContext.mockReturnValue({
currentWorkspace: { trial_credits: 100, trial_credits_used: 100 },
})
mockCredentialPanelState.mockReturnValue({
variant: 'credits-exhausted',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: true,
credentialName: undefined,
credits: 0,
})
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={vi.fn()} />)
expect(screen.getByText(/modelProvider\.selector\.creditsExhausted/)).toBeInTheDocument()
})
it('should close the dropdown through dropdown content callbacks', () => {
const onHide = vi.fn()
render(<PopupItem model={makeModel()} onSelect={vi.fn()} onHide={onHide} />)
fireEvent.click(screen.getByRole('button', { name: 'close dropdown' }))
expect(onHide).toHaveBeenCalled()
})
})

View File

@ -1,6 +1,5 @@
import type { Model, ModelItem } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import type { Model, ModelItem, ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@ -23,11 +22,32 @@ vi.mock('@/utils/tool-call', () => ({
supportFunctionCall: mockSupportFunctionCall,
}))
type MockMarketplacePlugin = {
plugin_id: string
latest_package_identifier: string
}
type MockContextProvider = Pick<ModelProvider, 'provider' | 'label' | 'icon_small' | 'icon_small_dark' | 'custom_configuration' | 'system_configuration'>
const mockMarketplacePlugins = vi.hoisted(() => ({
current: [] as MockMarketplacePlugin[],
isLoading: false,
}))
const mockContextModelProviders = vi.hoisted(() => ({
current: [] as MockContextProvider[],
}))
const mockTrialModels = vi.hoisted(() => ({
current: ['test-openai', 'test-anthropic'] as string[],
}))
vi.mock('../../hooks', async () => {
const actual = await vi.importActual<typeof import('../../hooks')>('../../hooks')
return {
...actual,
useLanguage: () => mockLanguage,
useMarketplaceAllPlugins: () => ({
plugins: mockMarketplacePlugins.current,
isLoading: mockMarketplacePlugins.isLoading,
}),
}
})
@ -35,6 +55,81 @@ vi.mock('../popup-item', () => ({
default: ({ model }: { model: Model }) => <div>{model.provider}</div>,
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({ modelProviders: mockContextModelProviders.current }),
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({
data: { trial_models: mockTrialModels.current },
}),
}))
const mockTrialCredits = vi.hoisted(() => ({
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined as number | undefined,
}))
vi.mock('../../provider-added-card/use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('../../provider-added-card/model-auth-dropdown/credits-exhausted-alert', () => ({
default: ({ hasApiKeyFallback }: { hasApiKeyFallback: boolean }) => (
<div data-testid="credits-exhausted-alert" data-has-api-key-fallback={String(hasApiKeyFallback)} />
),
}))
vi.mock('next-themes', () => ({
useTheme: () => ({ theme: 'light' }),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const mockInstallMutateAsync = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-plugins', () => ({
useInstallPackageFromMarketPlace: () => ({ mutateAsync: mockInstallMutateAsync }),
}))
const mockRefreshPluginList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list', () => ({
default: () => ({ refreshPluginList: mockRefreshPluginList }),
}))
const mockCheck = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/plugins/install-plugin/base/check-task-status', () => ({
default: () => ({ check: mockCheck }),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: vi.fn(() => 'https://marketplace.example.com'),
}))
vi.mock('../../utils', async () => {
const actual = await vi.importActual<typeof import('../../utils')>('../../utils')
return {
...actual,
MODEL_PROVIDER_QUOTA_GET_PAID: ['test-openai', 'test-anthropic'],
providerIconMap: {
'test-openai': ({ className }: { className?: string }) => <span className={className}>OAI</span>,
'test-anthropic': ({ className }: { className?: string }) => <span className={className}>ANT</span>,
},
modelNameMap: {
'test-openai': 'TestOpenAI',
'test-anthropic': 'TestAnthropic',
},
providerKeyToPluginId: {
'test-openai': 'langgenius/openai',
'test-anthropic': 'langgenius/anthropic',
},
}
})
const makeModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
@ -55,18 +150,40 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
...overrides,
})
describe('Popup', () => {
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
const makeContextProvider = (overrides: Partial<MockContextProvider> = {}): MockContextProvider => ({
provider: 'test-openai',
label: { en_US: 'Test OpenAI', zh_Hans: 'Test OpenAI' },
icon_small: { en_US: '', zh_Hans: '' },
icon_small_dark: { en_US: '', zh_Hans: '' },
custom_configuration: {
status: 'no-configure',
} as MockContextProvider['custom_configuration'],
system_configuration: {
enabled: false,
} as MockContextProvider['system_configuration'],
...overrides,
})
describe('Popup', () => {
beforeEach(() => {
vi.clearAllMocks()
mockLanguage = 'en_US'
mockSupportFunctionCall.mockReturnValue(true)
closeActiveTooltipSpy = vi.spyOn(tooltipManager, 'closeActiveTooltip')
mockMarketplacePlugins.current = []
mockMarketplacePlugins.isLoading = false
mockContextModelProviders.current = []
mockTrialModels.current = ['test-openai', 'test-anthropic']
Object.assign(mockTrialCredits, {
credits: 200,
totalCredits: 200,
isExhausted: false,
isLoading: false,
nextCreditResetDate: undefined,
})
})
it('should filter models by search and allow clearing search', () => {
render(
const { container } = render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -78,11 +195,39 @@ describe('Popup', () => {
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'not-found' } })
expect(screen.getByText('No model found for not-found')).toBeInTheDocument()
expect(screen.getByText('No model found for \u201Cnot-found\u201D')).toBeInTheDocument()
fireEvent.change(input, { target: { value: '' } })
const clearIcon = container.querySelector('.i-custom-vender-solid-general-x-circle')
expect(clearIcon).toBeInTheDocument()
fireEvent.click(clearIcon!)
expect((input as HTMLInputElement).value).toBe('')
expect(screen.getByText('openai')).toBeInTheDocument()
})
it('should not show compatible-only helper text when no scope features are applied', () => {
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
})
it('should show compatible-only helper banner when scope features are applied', () => {
const { container } = render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByTestId('compatible-models-banner')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown')).toBeInTheDocument()
expect(container.querySelector('.i-ri-information-2-fill')).toBeInTheDocument()
})
it('should filter by scope features including toolCall and non-toolCall checks', () => {
@ -90,7 +235,6 @@ describe('Popup', () => {
makeModel({ models: [makeModelItem({ features: [ModelFeatureEnum.toolCall, ModelFeatureEnum.vision] })] }),
]
// When tool-call support is missing, it should be filtered out.
mockSupportFunctionCall.mockReturnValue(false)
const { unmount } = render(
<Popup
@ -100,9 +244,8 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.toolCall, ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
// When tool-call support exists, the non-toolCall feature check should also pass.
unmount()
mockSupportFunctionCall.mockReturnValue(true)
const { unmount: unmount2 } = render(
@ -126,7 +269,6 @@ describe('Popup', () => {
)
expect(screen.getByText('openai')).toBeInTheDocument()
// When features are missing, non-toolCall feature checks should fail.
unmount3()
render(
<Popup
@ -136,15 +278,23 @@ describe('Popup', () => {
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
expect(screen.getByText('No model found for “”')).toBeInTheDocument()
expect(screen.getByText('No model found for \u201C\u201D')).toBeInTheDocument()
})
it('should match labels from other languages when current language key is missing', () => {
it('should match model labels from fallback languages when current language key is missing', () => {
mockLanguage = 'fr_FR'
render(
<Popup
modelList={[makeModel()]}
modelList={[
makeModel({
models: [
makeModelItem({
label: { en_US: 'OpenAI GPT', zh_Hans: 'OpenAI GPT' },
}),
],
}),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
@ -152,32 +302,28 @@ describe('Popup', () => {
fireEvent.change(
screen.getByPlaceholderText('datasetSettings.form.searchModel'),
{ target: { value: 'gpt' } },
{ target: { value: 'openai' } },
)
expect(screen.getByText('openai')).toBeInTheDocument()
})
it('should filter out model when features array exists but does not include required scopeFeature', () => {
const modelWithToolCallOnly = makeModel({
models: [makeModelItem({ features: [ModelFeatureEnum.toolCall] })],
it('should show credits exhausted alert when an exhausted provider supports credits', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
render(
<Popup
modelList={[modelWithToolCallOnly]}
onSelect={vi.fn()}
onHide={vi.fn()}
scopeFeatures={[ModelFeatureEnum.vision]}
/>,
)
// The model item should be filtered out because it has toolCall but not vision
expect(screen.queryByText('openai')).not.toBeInTheDocument()
})
it('should close tooltip on scroll', () => {
const { container } = render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
@ -185,54 +331,315 @@ describe('Popup', () => {
/>,
)
fireEvent.scroll(container.firstElementChild as HTMLElement)
expect(closeActiveTooltipSpy).toHaveBeenCalled()
expect(screen.getByTestId('credits-exhausted-alert')).toHaveAttribute('data-has-api-key-fallback', 'false')
})
it('should not show credits exhausted alert when only non-trial system providers are exhausted', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockTrialModels.current = ['test-anthropic']
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument()
})
it('should not mark api key fallback for non-trial system providers', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockTrialModels.current = ['test-anthropic']
mockContextModelProviders.current = [
makeContextProvider({
provider: 'test-openai',
custom_configuration: {
status: 'active',
} as MockContextProvider['custom_configuration'],
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
}),
]
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByTestId('credits-exhausted-alert')).not.toBeInTheDocument()
})
it('should open provider settings when clicking footer link', () => {
const onHide = vi.fn()
render(
<Popup
modelList={[makeModel()]}
onSelect={vi.fn()}
onHide={vi.fn()}
onHide={onHide}
/>,
)
fireEvent.click(screen.getByText('common.model.settingsLink'))
fireEvent.click(screen.getByText('common.modelProvider.selector.modelProviderSettings'))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
})
it('should call onHide when footer settings link is clicked', () => {
const mockOnHide = vi.fn()
it('should show empty state when no providers are configured', () => {
const onHide = vi.fn()
render(
<Popup
modelList={[makeModel()]}
modelList={[]}
onSelect={vi.fn()}
onHide={mockOnHide}
onHide={onHide}
/>,
)
fireEvent.click(screen.getByText('common.model.settingsLink'))
expect(screen.getByText(/modelProvider\.selector\.noProviderConfigured(?!Desc)/)).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.noProviderConfiguredDesc/)).toBeInTheDocument()
expect(mockOnHide).toHaveBeenCalled()
fireEvent.click(screen.getByText(/modelProvider\.selector\.configure/))
expect(onHide).toHaveBeenCalled()
expect(mockSetShowAccountSettingModal).toHaveBeenCalledWith({
payload: 'provider',
})
})
it('should match model label when searchText is non-empty and label key exists for current language', () => {
it('should render marketplace providers that are not installed', () => {
mockContextModelProviders.current = [makeContextProvider({ provider: 'test-openai' })]
render(
<Popup
modelList={[makeModel()]}
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
// GPT-4 label has en_US key, so modelItem.label[language] is defined
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'gpt' } })
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
expect(screen.getByText('TestAnthropic')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.fromMarketplace/)).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.selector\.discoverMoreInMarketplace/)).toBeInTheDocument()
})
expect(screen.getByText('openai')).toBeInTheDocument()
it('should show installed marketplace providers without models when AI credits are available', () => {
mockContextModelProviders.current = [makeContextProvider({
provider: 'test-anthropic',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
})]
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('test-anthropic')).toBeInTheDocument()
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should hide installed marketplace providers without models when AI credits are exhausted', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [makeContextProvider({
provider: 'test-anthropic',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
})]
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByText('test-anthropic')).not.toBeInTheDocument()
expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument()
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should toggle marketplace section collapse', () => {
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should install plugin when clicking install button', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should handle install failure gracefully', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalled()
})
// Should not crash, install buttons should still be available
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
})
it('should run checkTaskStatus when not all_installed', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue(undefined)
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockCheck).toHaveBeenCalledWith({
taskId: 'task-1',
pluginUniqueIdentifier: 'langgenius/openai:1.0.0',
})
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should skip install requests when marketplace plugins are still loading', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockMarketplacePlugins.isLoading = true
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should skip install requests when the marketplace plugin cannot be found', async () => {
mockMarketplacePlugins.current = []
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should sort the selected provider to the top when a default model is provided', () => {
render(
<Popup
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()}
/>,
)
const providerLabels = screen.getAllByText(/openai|anthropic/)
expect(providerLabels[0]).toHaveTextContent('anthropic')
})
})

View File

@ -1,54 +0,0 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
import ModelIcon from '../model-icon'
type ModelTriggerProps = {
modelName: string
providerName: string
className?: string
showWarnIcon?: boolean
contentClassName?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
modelName,
providerName,
className,
showWarnIcon,
contentClassName,
}) => {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
const currentProvider = modelProviders.find(provider => provider.provider === providerName)
return (
<div
className={cn('group box-content flex h-8 grow cursor-pointer items-center gap-1 rounded-lg bg-components-input-bg-disabled p-[3px] pl-1', className)}
>
<div className={cn('flex w-full items-center', contentClassName)}>
<div className="flex min-w-0 flex-1 items-center gap-1 py-[1px]">
<ModelIcon
className="h-4 w-4"
provider={currentProvider}
modelName={modelName}
/>
<div className="system-sm-regular truncate text-components-input-text-filled">
{modelName}
</div>
</div>
<div className="flex shrink-0 items-center justify-center">
{showWarnIcon && (
<Tooltip popupContent={t('modelProvider.deprecated', { ns: 'common' })}>
<AlertTriangle className="h-4 w-4 text-text-warning-secondary" />
</Tooltip>
)}
</div>
</div>
</div>
)
}
export default ModelTrigger

View File

@ -1,42 +0,0 @@
import type { FC } from 'react'
import { RiEqualizer2Line } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { CubeOutline } from '@/app/components/base/icons/src/vender/line/shapes'
import { cn } from '@/utils/classnames'
type ModelTriggerProps = {
open: boolean
className?: string
}
const ModelTrigger: FC<ModelTriggerProps> = ({
open,
className,
}) => {
const { t } = useTranslation()
return (
<div
className={cn(
'flex cursor-pointer items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1 hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
className,
)}
>
<div className="flex grow items-center">
<div className="mr-1.5 flex h-4 w-4 items-center justify-center rounded-[5px] border border-dashed border-divider-regular">
<CubeOutline className="h-3 w-3 text-text-quaternary" />
</div>
<div
className="truncate text-[13px] text-text-tertiary"
title="Configure model"
>
{t('detailPanel.configureModel', { ns: 'plugin' })}
</div>
</div>
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<RiEqualizer2Line className="h-3.5 w-3.5 text-text-tertiary" />
</div>
</div>
)
}
export default ModelTrigger

View File

@ -7,15 +7,12 @@ import type {
} from '../declarations'
import { useState } from 'react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { useCurrentProviderAndModel } from '../hooks'
import DeprecatedModelTrigger from './deprecated-model-trigger'
import EmptyTrigger from './empty-trigger'
import ModelTrigger from './model-trigger'
import ModelSelectorTrigger from './model-selector-trigger'
import Popup from './popup'
type ModelSelectorProps = {
@ -24,6 +21,7 @@ type ModelSelectorProps = {
triggerClassName?: string
popupClassName?: string
onSelect?: (model: DefaultModel) => void
onHide?: () => void
readonly?: boolean
scopeFeatures?: ModelFeatureEnum[]
deprecatedClassName?: string
@ -35,10 +33,11 @@ const ModelSelector: FC<ModelSelectorProps> = ({
triggerClassName,
popupClassName,
onSelect,
onHide,
readonly,
scopeFeatures = [],
deprecatedClassName,
showDeprecatedWarnIcon = false,
showDeprecatedWarnIcon = true,
}) => {
const [open, setOpen] = useState(false)
const {
@ -56,67 +55,54 @@ const ModelSelector: FC<ModelSelectorProps> = ({
onSelect({ provider, model: model.model })
}
const handleToggle = () => {
if (readonly)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
<Popover
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
onOpenChange={(newOpen) => {
if (readonly)
return
setOpen(newOpen)
}}
>
<div className={cn('relative')}>
<PortalToFollowElemTrigger
onClick={handleToggle}
className="block"
>
{
currentModel && currentProvider && (
<ModelTrigger
open={open}
provider={currentProvider}
model={currentModel}
className={triggerClassName}
readonly={readonly}
/>
)
}
{
!currentModel && defaultModel && (
<DeprecatedModelTrigger
modelName={defaultModel?.model || ''}
providerName={defaultModel?.provider || ''}
className={triggerClassName}
showWarnIcon={showDeprecatedWarnIcon}
contentClassName={deprecatedClassName}
/>
)
}
{
!defaultModel && (
<EmptyTrigger
open={open}
className={triggerClassName}
/>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className={`z-[1002] ${popupClassName}`}>
<Popup
defaultModel={defaultModel}
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => setOpen(false)}
/>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
<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
placement="bottom-start"
sideOffset={4}
className={popupClassName}
popupClassName="overflow-hidden rounded-lg"
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
>
<Popup
defaultModel={defaultModel}
modelList={modelList}
onSelect={handleSelect}
scopeFeatures={scopeFeatures}
onHide={() => {
setOpen(false)
onHide?.()
}}
/>
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,290 @@
import type { Model, ModelItem } from '../declarations'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '../declarations'
import ModelSelectorTrigger from './model-selector-trigger'
const mockUseProviderContext = vi.hoisted(() => vi.fn())
const mockUseCredentialPanelState = vi.hoisted(() => vi.fn())
vi.mock('@/context/provider-context', () => ({
useProviderContext: mockUseProviderContext,
}))
vi.mock('../provider-added-card/use-credential-panel-state', () => ({
useCredentialPanelState: mockUseCredentialPanelState,
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'gpt-4',
label: { en_US: 'GPT-4', zh_Hans: 'GPT-4' },
model_type: ModelTypeEnum.textGeneration,
features: [ModelFeatureEnum.vision],
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: { mode: 'chat', context_size: 4096 },
load_balancing_enabled: false,
...overrides,
})
const createModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'openai',
icon_small: {
en_US: 'https://example.com/openai-light.png',
zh_Hans: 'https://example.com/openai-light.png',
},
icon_small_dark: {
en_US: 'https://example.com/openai-dark.png',
zh_Hans: 'https://example.com/openai-dark.png',
},
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [createModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('ModelSelectorTrigger', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
modelProviders: [createModel()],
})
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 100,
})
})
describe('Rendering', () => {
it('should render empty state when no model is selected', () => {
const { container } = render(<ModelSelectorTrigger />)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
})
it('should render selected model details when model is active', () => {
const currentProvider = createModel()
const currentModel = createModelItem()
const { container } = render(
<ModelSelectorTrigger
currentProvider={currentProvider}
currentModel={currentModel}
/>,
)
expect(screen.getByText('GPT-4')).toBeInTheDocument()
expect(screen.getByText('CHAT')).toBeInTheDocument()
expect(container.querySelector('.i-ri-arrow-down-s-line')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-normal')
})
it('should render deprecated default model and disabled style when selection is missing', () => {
const { container } = render(
<ModelSelectorTrigger
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
/>,
)
expect(screen.getByText('legacy-model')).toBeInTheDocument()
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-disabled')
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply custom className to root element', () => {
const { container } = render(<ModelSelectorTrigger className="custom-trigger" />)
expect(container.firstElementChild).toHaveClass('custom-trigger')
})
it('should apply open background style when open is true and model is active', () => {
const { container } = render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
open
/>,
)
expect(container.firstElementChild).toHaveClass('bg-components-input-bg-hover')
})
it('should hide the expand arrow when readonly is true', () => {
const { container } = render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
readonly
/>,
)
expect(container.querySelector('.i-ri-arrow-down-s-line')).not.toBeInTheDocument()
})
})
describe('Status Handling', () => {
it('should show status badge when selected model is not active and not readonly', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
/>,
)
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
})
it('should apply credits exhausted badge style when model quota is exceeded', () => {
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-exhausted',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: true,
credentialName: undefined,
credits: 0,
})
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
/>,
)
expect(screen.getByText('common.modelProvider.selector.creditsExhausted').parentElement).toHaveClass('bg-components-badge-bg-dimm')
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
})
it('should hide model meta when api key is unavailable', () => {
mockUseCredentialPanelState.mockReturnValue({
variant: 'api-unavailable',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'Primary Key',
credits: 0,
})
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem()}
/>,
)
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
})
it('should show disabled badge when selected model is disabled', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.disabled })}
/>,
)
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
})
it('should not show status badge when selected model is readonly', () => {
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noConfigure })}
readonly
/>,
)
expect(screen.queryByText('common.modelProvider.selector.configureRequired')).not.toBeInTheDocument()
})
it('should show incompatible tooltip when hovering no-permission status badge', async () => {
const user = userEvent.setup()
render(
<ModelSelectorTrigger
currentProvider={createModel()}
currentModel={createModelItem({ status: ModelStatusEnum.noPermission })}
/>,
)
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should show incompatible badge for deprecated selection', async () => {
const user = userEvent.setup()
render(
<ModelSelectorTrigger
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
/>,
)
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
})
it('should show credits exhausted badge for deprecated selection when ai credits are exhausted without api key', async () => {
const user = userEvent.setup()
mockUseCredentialPanelState.mockImplementation(provider => ({
variant: provider ? 'no-usage' : 'credits-active',
priority: provider ? 'apiKey' : 'credits',
supportsCredits: !!provider,
showPrioritySwitcher: true,
hasCredentials: false,
isCreditsExhausted: !!provider,
credentialName: undefined,
credits: provider ? 0 : 100,
}))
render(
<ModelSelectorTrigger
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
/>,
)
expect(mockUseCredentialPanelState).toHaveBeenCalledWith(expect.objectContaining({ provider: 'openai' }))
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
await user.hover(screen.getByText('common.modelProvider.selector.creditsExhausted'))
expect(await screen.findByText('common.modelProvider.selector.creditsExhaustedTip')).toBeInTheDocument()
})
it('should render fallback icon when deprecated provider is not found', () => {
mockUseProviderContext.mockReturnValue({
modelProviders: [],
})
const { container } = render(
<ModelSelectorTrigger
defaultModel={{ provider: 'unknown-provider', model: 'legacy-model' }}
/>,
)
expect(container.querySelector('img[alt="model-icon"]')).not.toBeInTheDocument()
expect(container.querySelector('svg')).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,175 @@
import type { FC } from 'react'
import type {
DefaultModel,
Model,
ModelItem,
} from '../declarations'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
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'
type ModelSelectorTriggerProps = {
currentProvider?: Model
currentModel?: ModelItem
defaultModel?: DefaultModel
open?: boolean
readonly?: boolean
className?: string
deprecatedClassName?: string
showDeprecatedWarnIcon?: boolean
}
const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
currentProvider,
currentModel,
defaultModel,
open,
readonly,
className,
deprecatedClassName,
showDeprecatedWarnIcon = true,
}) => {
const { t } = useTranslation()
const { modelProviders } = useProviderContext()
const isSelected = !!currentProvider && !!currentModel
const isDeprecated = !isSelected && !!defaultModel
const isEmpty = !isSelected && !defaultModel
const selectedProvider = isSelected
? modelProviders.find(provider => provider.provider === currentProvider.provider)
: undefined
const deprecatedProvider = isDeprecated
? modelProviders.find(p => p.provider === defaultModel.provider)
: undefined
const resolvedProvider = isSelected ? selectedProvider : deprecatedProvider
const selectedProviderState = useCredentialPanelState(resolvedProvider)
const status = deriveModelStatus(
isSelected ? currentModel?.model : defaultModel?.model,
isSelected ? currentProvider?.provider : defaultModel?.provider,
resolvedProvider,
currentModel,
selectedProviderState,
)
const isActive = status === 'active'
const isDisabled = status !== 'active' && status !== 'empty'
const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[status as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N]
const tooltipI18nKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status as keyof typeof DERIVED_MODEL_STATUS_TOOLTIP_I18N]
const statusLabel = statusI18nKey ? t(statusI18nKey, { ns: 'common' }) : null
const tooltipLabel = tooltipI18nKey ? t(tooltipI18nKey, { ns: 'common' }) : null
const isCreditsExhausted = status === 'credits-exhausted'
const shouldShowModelMeta = status === 'active'
const deprecatedStatusLabel = statusLabel || t('modelProvider.selector.incompatible', { ns: 'common' })
const deprecatedTooltipLabel = tooltipLabel || t('modelProvider.selector.incompatibleTip', { ns: 'common' })
return (
<div
className={cn(
'group flex h-8 items-center gap-0.5 rounded-lg p-1',
isDisabled
? 'bg-components-input-bg-disabled'
: 'bg-components-input-bg-normal',
!readonly && !isDisabled && 'cursor-pointer hover:bg-components-input-bg-hover',
open && !isDisabled && 'bg-components-input-bg-hover',
className,
)}
>
{isEmpty
? (
<div className="flex h-6 w-6 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line h-3.5 w-3.5 text-text-quaternary" />
</div>
</div>
)
: (
<ModelIcon
className="p-0.5"
provider={isSelected ? currentProvider : deprecatedProvider}
modelName={isSelected ? currentModel.model : defaultModel?.model}
/>
)}
<div className={cn('flex grow items-center gap-1 truncate px-1 py-[3px]', isDeprecated && deprecatedClassName)}>
{isSelected && (
<ModelName
className="grow"
modelItem={currentModel}
showMode={shouldShowModelMeta}
showFeatures={shouldShowModelMeta}
/>
)}
{isDeprecated && (
<div className="grow truncate text-components-input-text-filled system-sm-regular">
{defaultModel.model}
</div>
)}
{isEmpty && (
<div className="grow truncate text-[13px] text-text-quaternary">
{t('detailPanel.configureModel', { ns: 'plugin' })}
</div>
)}
{isSelected && !readonly && !isActive && statusI18nKey && (
<Tooltip>
<TooltipTrigger
disabled={!tooltipLabel}
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',
)}
>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{statusLabel}
</span>
</div>
)}
/>
{tooltipLabel && (
<TooltipContent placement="top">
{tooltipLabel}
</TooltipContent>
)}
</Tooltip>
)}
{isDeprecated && showDeprecatedWarnIcon && (
<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">
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{deprecatedStatusLabel}
</span>
</div>
)}
/>
<TooltipContent placement="top">
{deprecatedTooltipLabel}
</TooltipContent>
</Tooltip>
)}
{!readonly && (isActive || isEmpty) && (
<span className="i-ri-arrow-down-s-line h-3.5 w-3.5 shrink-0 text-text-tertiary" />
)}
</div>
</div>
)
}
export default ModelSelectorTrigger

View File

@ -1,78 +0,0 @@
import type { FC } from 'react'
import type {
Model,
ModelItem,
} from '../declarations'
import { RiArrowDownSLine } from '@remixicon/react'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Tooltip from '@/app/components/base/tooltip'
import { cn } from '@/utils/classnames'
import {
MODEL_STATUS_TEXT,
ModelStatusEnum,
} from '../declarations'
import { useLanguage } from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
type ModelTriggerProps = {
open: boolean
provider: Model
model: ModelItem
className?: string
readonly?: boolean
}
const ModelTrigger: FC<ModelTriggerProps> = ({
open,
provider,
model,
className,
readonly,
}) => {
const language = useLanguage()
return (
<div
className={cn(
'group flex h-8 items-center gap-0.5 rounded-lg bg-components-input-bg-normal p-1',
!readonly && 'cursor-pointer hover:bg-components-input-bg-hover',
open && 'bg-components-input-bg-hover',
model.status !== ModelStatusEnum.active && 'bg-components-input-bg-disabled hover:bg-components-input-bg-disabled',
className,
)}
>
<ModelIcon
className="p-0.5"
provider={provider}
modelName={model.model}
/>
<div className="flex grow items-center gap-1 truncate px-1 py-[3px]">
<ModelName
className="grow"
modelItem={model}
showMode
showFeatures
/>
{!readonly && (
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
{
model.status !== ModelStatusEnum.active
? (
<Tooltip popupContent={MODEL_STATUS_TEXT[model.status][language]}>
<AlertTriangle className="h-4 w-4 text-text-warning-secondary" />
</Tooltip>
)
: (
<RiArrowDownSLine
className="h-3.5 w-3.5 text-text-tertiary"
/>
)
}
</div>
)}
</div>
</div>
)
}
export default ModelTrigger

View File

@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import { act, 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,
currentModel: undefined,
}),
}))
vi.mock('@/app/components/base/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>
{open ? 'open' : 'closed'}
-
{readonly ? 'readonly' : 'editable'}
</span>
),
}))
vi.mock('./popup', () => ({
default: ({ onHide }: { onHide: () => void }) => (
<div data-testid="popup">
<button type="button" onClick={onHide}>hide-popup</button>
</div>
),
}))
describe('ModelSelector popover branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should open and close through popover callbacks when editable', () => {
const onHide = vi.fn()
render(<ModelSelector modelList={[]} onHide={onHide} />)
act(() => {
latestOnOpenChange?.(true)
})
expect(screen.getByText('open-editable')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'hide-popup' }))
expect(screen.getByText('closed-editable')).toBeInTheDocument()
expect(onHide).toHaveBeenCalledTimes(1)
})
it('should ignore popover open changes when readonly', () => {
render(<ModelSelector modelList={[]} readonly />)
act(() => {
latestOnOpenChange?.(true)
})
expect(screen.getByText('closed-readonly')).toBeInTheDocument()
})
})

View File

@ -4,10 +4,15 @@ import type {
Model,
ModelItem,
} from '../declarations'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import Tooltip from '@/app/components/base/tooltip'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -25,6 +30,9 @@ import {
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,
@ -35,19 +43,23 @@ type PopupItemProps = {
defaultModel?: DefaultModel
model: Model
onSelect: (provider: string, model: ModelItem) => void
onHide: () => void
}
const PopupItem: FC<PopupItemProps> = ({
defaultModel,
model,
onSelect,
onHide,
}) => {
const [collapsed, setCollapsed] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const { t } = useTranslation()
const language = useLanguage()
const { setShowModelModal } = useModalContext()
const { modelProviders } = useProviderContext()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)!
const currentProvider = modelProviders.find(provider => provider.provider === model.provider)
const handleSelect = (provider: string, modelItem: ModelItem) => {
if (modelItem.status !== ModelStatusEnum.active)
return
@ -55,6 +67,8 @@ const PopupItem: FC<PopupItemProps> = ({
onSelect(provider, modelItem)
}
const handleOpenModelModal = () => {
if (!currentProvider)
return
setShowModelModal({
payload: {
currentProvider,
@ -71,101 +85,169 @@ const PopupItem: FC<PopupItemProps> = ({
})
}
const state = useCredentialPanelState(currentProvider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(currentProvider)
const isUsingCredits = state.priority === 'credits'
const hasCredits = !state.isCreditsExhausted
const isApiKeyActive = state.variant === 'api-active' || state.variant === 'api-fallback'
const { credentialName } = state
const handleCloseDropdown = useCallback(() => {
setDropdownOpen(false)
onHide()
}, [onHide])
if (!currentProvider)
return null
return (
<div className="mb-1">
<div className="flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary">
{model.label[language] || model.label.en_US}
<div className="sticky top-12 z-[2] 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"
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>
<Popover open={dropdownOpen} onOpenChange={setDropdownOpen}>
<PopoverTrigger
render={(
<button type="button" className="flex cursor-pointer items-center rounded-md px-1.5 py-1 text-text-tertiary system-xs-medium 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="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>
</>
)
)
: credentialName
? (
<>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-[2px] 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="h-1.5 w-1.5 shrink-0 rounded-[2px] 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="i-ri-arrow-down-s-line !h-[14px] !w-[14px] translate-y-px text-text-tertiary" />
</button>
)}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={currentProvider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
onClose={handleCloseDropdown}
/>
</PopoverContent>
</Popover>
</div>
{
model.models.map(modelItem => (
<Tooltip
key={modelItem.model}
position="right"
popupClassName="p-3 !w-[206px] bg-components-panel-bg-blur backdrop-blur-sm border-[0.5px] border-components-panel-border rounded-xl"
popupContent={(
<div className="flex flex-col gap-1">
<div className="flex flex-col items-start gap-2">
{!collapsed && model.models.map(modelItem => (
<Tooltip key={modelItem.model}>
<TooltipTrigger
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)}
>
<div className="flex items-center gap-2">
<ModelIcon
className={cn('h-5 w-5 shrink-0')}
provider={model}
modelName={modelItem.model}
/>
<div className="system-md-medium text-wrap break-words text-text-primary">{modelItem.label[language] || modelItem.label.en_US}</div>
<ModelName
className={cn('text-text-secondary system-sm-medium', modelItem.status !== ModelStatusEnum.active && 'opacity-60')}
modelItem={modelItem}
/>
</div>
{/* {currentProvider?.description && (
<div className='text-text-tertiary system-xs-regular'>{currentProvider?.description?.[language] || currentProvider?.description?.en_US}</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="system-2xs-medium-uppercase mb-1 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>
{
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>
)}
</div>
)
}
</button>
)}
/>
<TooltipContent
placement="right"
variant="plain"
popupClassName="w-[206px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 backdrop-blur-sm"
>
<div
key={modelItem.model}
className={cn('group relative flex h-8 items-center gap-1 rounded-lg px-3 py-1.5', 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)}
>
<div className="flex items-center gap-2">
<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="text-wrap break-words text-text-primary system-md-medium">{modelItem.label[language] || modelItem.label.en_US}</div>
</div>
{
defaultModel?.model === modelItem.model && defaultModel.provider === currentProvider.provider && (
<Check className="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 text-text-tertiary system-2xs-medium-uppercase">{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>
</Tooltip>
))
}
</TooltipContent>
</Tooltip>
))}
</div>
)
}

View File

@ -4,19 +4,31 @@ import type {
Model,
ModelItem,
} from '../declarations'
import {
RiArrowRightUpLine,
RiSearchLine,
} from '@remixicon/react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { XCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import Button from '@/app/components/base/button'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import checkTaskStatus from '@/app/components/plugins/install-plugin/base/check-task-status'
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { supportFunctionCall } from '@/utils/tool-call'
import { ModelFeatureEnum } from '../declarations'
import { useLanguage } from '../hooks'
import { getMarketplaceUrl } from '@/utils/var'
import {
CustomConfigurationStatusEnum,
ModelFeatureEnum,
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, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import PopupItem from './popup-item'
type PopupProps = {
@ -34,32 +46,101 @@ const Popup: FC<PopupProps> = ({
onHide,
}) => {
const { t } = useTranslation()
const { theme } = useTheme()
const language = useLanguage()
const [searchText, setSearchText] = useState('')
const [marketplaceCollapsed, setMarketplaceCollapsed] = useState(false)
const { setShowAccountSettingModal } = useModalContext()
const scrollRef = useRef<HTMLDivElement>(null)
const { modelProviders } = useProviderContext()
const {
plugins: allPlugins,
isLoading: isMarketplacePluginsLoading,
} = useMarketplaceAllPlugins(modelProviders, '')
const { mutateAsync: installPackageFromMarketPlace } = useInstallPackageFromMarketPlace()
const { refreshPluginList } = useRefreshPluginList()
const [installingProvider, setInstallingProvider] = useState<ModelProviderQuotaGetPaid | null>(null)
const { isExhausted: isCreditsExhausted } = useTrialCredits()
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models
const installedProviderMap = useMemo(() => new Map(
modelProviders.map(provider => [provider.provider, provider]),
), [modelProviders])
const aiCreditVisibleProviders = useMemo(() => {
if (isCreditsExhausted)
return new Set<string>()
// Close any open tooltips when the user scrolls to prevent them from appearing
// in incorrect positions or becoming detached from their trigger elements
useEffect(() => {
const handleTooltipCloseOnScroll = () => {
tooltipManager.closeActiveTooltip()
}
return new Set(
modelProviders
.filter(provider => providerSupportsCredits(provider, trialModels))
.map(provider => provider.provider),
)
}, [isCreditsExhausted, modelProviders, trialModels])
const showCreditsExhaustedAlert = isCreditsExhausted
&& modelProviders.some(provider => providerSupportsCredits(provider, trialModels))
const hasApiKeyFallback = modelProviders.some((provider) => {
const isApiKeyActive = provider.custom_configuration?.status === CustomConfigurationStatusEnum.active
return isApiKeyActive && providerSupportsCredits(provider, trialModels)
})
const scrollContainer = scrollRef.current
if (!scrollContainer)
const handleInstallPlugin = useCallback(async (key: ModelProviderQuotaGetPaid) => {
if (!allPlugins || isMarketplacePluginsLoading || installingProvider)
return
const pluginId = providerKeyToPluginId[key]
const plugin = allPlugins.find(p => p.plugin_id === pluginId)
if (!plugin)
return
// Use passive listener for better performance since we don't prevent default
scrollContainer.addEventListener('scroll', handleTooltipCloseOnScroll, { passive: true })
return () => {
scrollContainer.removeEventListener('scroll', handleTooltipCloseOnScroll)
const uniqueIdentifier = plugin.latest_package_identifier
setInstallingProvider(key)
try {
const { all_installed, task_id } = await installPackageFromMarketPlace(uniqueIdentifier)
if (!all_installed) {
const { check } = checkTaskStatus()
await check({ taskId: task_id, pluginUniqueIdentifier: uniqueIdentifier })
}
refreshPluginList(plugin)
}
}, [])
catch { }
finally {
setInstallingProvider(null)
}
}, [allPlugins, installPackageFromMarketPlace, installingProvider, isMarketplacePluginsLoading, refreshPluginList])
const installedModelList = useMemo(() => {
const modelMap = new Map(modelList.map(model => [model.provider, model]))
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
const installedProvider = installedProviderMap.get(providerKey)
if (!installedProvider)
return []
const matchedModel = modelMap.get(providerKey)
if (matchedModel)
return [matchedModel]
if (!aiCreditVisibleProviders.has(providerKey))
return []
return [{
provider: installedProvider.provider,
icon_small: installedProvider.icon_small,
icon_small_dark: installedProvider.icon_small_dark,
label: installedProvider.label,
models: [],
status: ModelStatusEnum.active,
}]
})
const otherModels = modelList.filter(model => !MODEL_PROVIDER_QUOTA_GET_PAID.includes(model.provider as ModelProviderQuotaGetPaid))
return [...installedMarketplaceModels, ...otherModels]
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => {
return modelList.map((model) => {
const filtered = installedModelList.map((model) => {
const matchesProviderSearch = !searchText
|| model.provider.toLowerCase().includes(searchText.toLowerCase())
|| Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
const filteredModels = model.models
.filter((modelItem) => {
if (modelItem.label[language] !== undefined)
@ -77,21 +158,39 @@ const Popup: FC<PopupProps> = ({
return modelItem.features?.includes(feature) ?? false
})
})
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
return null
return { ...model, models: filteredModels }
}).filter(model => model.models.length > 0)
}, [language, modelList, scopeFeatures, searchText])
}).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 marketplaceProviders = useMemo(() => {
const installedProviders = new Set(modelProviders.map(provider => provider.provider))
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [modelProviders])
return (
<div ref={scrollRef} className="max-h-[480px] w-[320px] overflow-y-auto rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg">
<div className="max-h-[480px] overflow-y-auto no-scrollbar">
<div className="sticky top-0 z-10 bg-components-panel-bg pb-1 pl-3 pr-2 pt-3">
<div className={`
flex h-8 items-center rounded-lg border pl-[9px] pr-[10px]
${searchText ? 'border-components-input-border-active bg-components-input-bg-active shadow-xs' : 'border-transparent bg-components-input-bg-normal'}
`}
>
<RiSearchLine
<span
className={`
mr-[7px] h-[14px] w-[14px] shrink-0
i-ri-search-line mr-[7px] h-[14px] w-[14px] shrink-0
${searchText ? 'text-text-tertiary' : 'text-text-quaternary'}
`}
/>
@ -103,15 +202,29 @@ const Popup: FC<PopupProps> = ({
/>
{
searchText && (
<XCircle
className="ml-1.5 h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
<span
className="i-custom-vender-solid-general-x-circle ml-1.5 h-[14px] w-[14px] shrink-0 cursor-pointer text-text-quaternary"
onClick={() => setSearchText('')}
/>
)
}
</div>
{scopeFeatures.length > 0 && (
<div
data-testid="compatible-models-banner"
className="mt-2 flex items-center gap-1 rounded-lg bg-background-section-burn px-2.5 py-2"
>
<span className="i-ri-information-2-fill h-4 w-4 shrink-0 text-text-accent" />
<p className="text-text-secondary system-xs-medium">
{t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
</p>
</div>
)}
</div>
<div className="p-1">
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<div className="px-1 pb-1">
{
filteredModelList.map(model => (
<PopupItem
@ -119,26 +232,112 @@ const Popup: FC<PopupProps> = ({
defaultModel={defaultModel}
model={model}
onSelect={onSelect}
onHide={onHide}
/>
))
}
{
!filteredModelList.length && (
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
{`No model found for “${searchText}`}
{!filteredModelList.length && !installedModelList.length && (
<div className="flex flex-col gap-2 rounded-[10px] bg-gradient-to-r from-state-base-hover to-background-gradient-mask-transparent p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
)
}
<div className="flex flex-col gap-1">
<p className="text-text-secondary system-sm-medium">
{t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
</p>
<p className="text-text-tertiary system-xs-regular">
{t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
</p>
</div>
<Button
variant="primary"
className="w-[108px]"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
{t('modelProvider.selector.configure', { ns: 'common' })}
<span className="i-ri-arrow-right-line h-4 w-4" />
</Button>
</div>
)}
{!filteredModelList.length && installedModelList.length > 0 && (
<div className="break-all px-3 py-1.5 text-center text-xs leading-[18px] text-text-tertiary">
{`No model found for \u201C${searchText}\u201D`}
</div>
)}
{marketplaceProviders.length > 0 && (
<>
<div className="mx-2 my-1 border-t border-divider-subtle" />
<div className="mb-1">
<div className="flex h-[22px] items-center px-3">
<div
className="flex flex-1 cursor-pointer items-center text-text-primary system-sm-medium"
onClick={() => setMarketplaceCollapsed(prev => !prev)}
>
{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>
</div>
{!marketplaceCollapsed && (
<>
{marketplaceProviders.map((key) => {
const Icon = providerIconMap[key]
const isInstalling = installingProvider === key
return (
<div
key={key}
className="group flex cursor-pointer items-center gap-1 rounded-lg py-0.5 pl-3 pr-0.5 hover:bg-state-base-hover"
>
<div className="flex flex-1 items-center gap-2 py-0.5">
<Icon className="h-5 w-5 shrink-0 rounded-md" />
<span className="text-text-secondary system-sm-regular">{modelNameMap[key]}</span>
</div>
<Button
variant="secondary"
size="small"
className={cn(
'shrink-0 backdrop-blur-[5px]',
!isInstalling && 'hidden group-hover:flex',
)}
disabled={isInstalling || isMarketplacePluginsLoading}
onClick={() => handleInstallPlugin(key)}
>
{isInstalling && <span className="i-ri-loader-2-line h-3.5 w-3.5 animate-spin" />}
{isInstalling
? t('installModal.installing', { ns: 'plugin' })
: t('modelProvider.selector.install', { ns: 'common' })}
</Button>
</div>
)
})}
<a
className="flex cursor-pointer items-center gap-0.5 px-3 pt-1.5"
href={getMarketplaceUrl('', { theme })}
target="_blank"
rel="noopener noreferrer"
>
<span className="flex-1 text-text-accent system-xs-regular">
{t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
</span>
<span className="i-ri-arrow-right-up-line !h-3 !w-3 text-text-accent" />
</a>
</>
)}
</div>
</>
)}
</div>
<div
className="sticky bottom-0 flex cursor-pointer items-center rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-4 py-2 text-text-accent-light-mode-only"
className="sticky bottom-0 flex cursor-pointer items-center gap-1 rounded-b-lg border-t border-divider-subtle bg-components-panel-bg px-3 py-2 text-text-tertiary hover:text-text-secondary"
onClick={() => {
onHide()
setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
}}
>
<span className="system-xs-medium">{t('model.settingsLink', { ns: 'common' })}</span>
<RiArrowRightUpLine className="ml-0.5 h-3 w-3" />
<span className="i-ri-equalizer-2-line h-4 w-4 shrink-0" />
<span className="system-xs-medium">{t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}</span>
</div>
</div>
)

View File

@ -1,17 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react'
import AddModelButton from '../add-model-button'
describe('AddModelButton', () => {
it('should render button with text', () => {
render(<AddModelButton onClick={vi.fn()} />)
expect(screen.getByText('common.modelProvider.addModel')).toBeInTheDocument()
})
it('should call onClick when clicked', () => {
const handleClick = vi.fn()
render(<AddModelButton onClick={handleClick} />)
const button = screen.getByText('common.modelProvider.addModel')
fireEvent.click(button)
expect(handleClick).toHaveBeenCalledTimes(1)
})
})

View File

@ -1,56 +1,58 @@
import type { ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { ToastContext } from '@/app/components/base/toast/context'
import { changeModelProviderPriority } from '@/service/common'
import { ConfigurationMethodEnum } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../../declarations'
import CredentialPanel from '../credential-panel'
const mockEventEmitter = { emit: vi.fn() }
const mockNotify = vi.fn()
const mockUpdateModelList = vi.fn()
const mockUpdateModelProviders = vi.fn()
const mockCredentialStatus = {
hasCredential: true,
authorized: true,
authRemoved: false,
current_credential_name: 'test-credential',
notAllowedToUse: false,
}
const {
mockToastNotify,
mockUpdateModelList,
mockUpdateModelProviders,
mockTrialCredits,
mockChangePriorityFn,
} = vi.hoisted(() => ({
mockToastNotify: vi.fn(),
mockUpdateModelList: vi.fn(),
mockUpdateModelProviders: vi.fn(),
mockTrialCredits: { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined },
mockChangePriorityFn: vi.fn().mockResolvedValue({ result: 'success' }),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CLOUD_EDITION: true,
}
return { ...actual, IS_CLOUD_EDITION: true }
})
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
}),
}
})
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({ data: { trial_models: ['langgenius/openai/openai'] } }),
}))
vi.mock('@/service/common', () => ({
changeModelProviderPriority: vi.fn(),
vi.mock('@/app/components/base/toast', () => ({
default: { notify: mockToastNotify },
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth', () => ({
ConfigProvider: () => <div data-testid="config-provider" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth/hooks', () => ({
useCredentialStatus: () => mockCredentialStatus,
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryKey: ({ input }: { input: { params: { provider: string } } }) => ['console', 'modelProviders', 'models', input.params.provider],
},
changePreferredProviderType: {
mutationOptions: (opts: Record<string, unknown>) => ({
mutationFn: (...args: unknown[]) => {
mockChangePriorityFn(...args)
return Promise.resolve({ result: 'success' })
},
...opts,
}),
},
},
},
}))
vi.mock('../../hooks', () => ({
@ -58,161 +60,395 @@ vi.mock('../../hooks', () => ({
useUpdateModelProviders: () => mockUpdateModelProviders,
}))
vi.mock('../priority-selector', () => ({
default: ({ value, onSelect }: { value: string, onSelect: (key: string) => void }) => (
<button data-testid="priority-selector" onClick={() => onSelect('custom')}>
Priority Selector
{' '}
{value}
</button>
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('../model-auth-dropdown', () => ({
default: ({ state, onChangePriority }: { state: { variant: string, hasCredentials: boolean }, onChangePriority: (key: string) => void }) => (
<div data-testid="model-auth-dropdown" data-variant={state.variant}>
<button data-testid="change-priority-btn" onClick={() => onChangePriority('custom')}>
Change Priority
</button>
</div>
),
}))
vi.mock('../priority-use-tip', () => ({
default: () => <div data-testid="priority-use-tip">Priority Tip</div>,
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid="indicator" data-color={color} />,
}))
vi.mock('@/app/components/header/indicator', () => ({
default: ({ color }: { color: string }) => <div data-testid="indicator">{color}</div>,
vi.mock('@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning', () => ({
default: (props: Record<string, unknown>) => <div data-testid="warning-icon" className={props.className as string} />,
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
provider_credential_schema: { credential_form_schemas: [] },
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'test-credential',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'test-credential' }],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
const renderWithQueryClient = (provider: ModelProvider) => {
const queryClient = createTestQueryClient()
return render(
<QueryClientProvider client={queryClient}>
<CredentialPanel provider={provider} />
</QueryClientProvider>,
)
}
describe('CredentialPanel', () => {
const mockProvider: ModelProvider = {
provider: 'test-provider',
provider_credential_schema: true,
custom_configuration: { status: 'active' },
system_configuration: { enabled: true },
preferred_provider_type: 'system',
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['gpt-4'],
} as unknown as ModelProvider
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockCredentialStatus, {
hasCredential: true,
authorized: true,
authRemoved: false,
current_credential_name: 'test-credential',
notAllowedToUse: false,
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
describe('Text label variants', () => {
it('should show "AI credits in use" for credits-active variant', () => {
renderWithQueryClient(createProvider())
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show "Credits exhausted" for credits-exhausted variant (no credentials)', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByText(/quotaExhausted/)).toBeInTheDocument()
})
it('should show "No available usage" for no-usage variant (exhausted + credential unauthorized)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/noAvailableUsage/)).toBeInTheDocument()
})
it('should show "AI credits in use" with warning for credits-fallback (custom priority, no credentials, credits available)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show "AI credits in use" with warning for credits-fallback (custom priority, credential unauthorized, credits available)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByText(/aiCreditsInUse/)).toBeInTheDocument()
})
it('should show warning icon for credits-fallback variant', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('warning-icon')).toBeInTheDocument()
})
})
const renderCredentialPanel = (provider: ModelProvider) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<CredentialPanel provider={provider} />
</ToastContext.Provider>,
)
describe('Status label variants', () => {
it('should show green indicator and credential name for api-fallback (exhausted + authorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
it('should show credential name and configuration actions', () => {
renderCredentialPanel(mockProvider)
it('should show warning icon for api-fallback variant', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('warning-icon')).toBeInTheDocument()
})
expect(screen.getByText('test-credential')).toBeInTheDocument()
expect(screen.getByTestId('config-provider')).toBeInTheDocument()
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
})
it('should show green indicator for api-active (custom priority + authorized)', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'green')
expect(screen.getByText('test-credential')).toBeInTheDocument()
})
it('should show unauthorized status label when credential is missing', () => {
mockCredentialStatus.hasCredential = false
renderCredentialPanel(mockProvider)
it('should NOT show warning icon for api-active variant', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.queryByTestId('warning-icon')).not.toBeInTheDocument()
})
expect(screen.getByText(/modelProvider\.auth\.unAuthorized/)).toBeInTheDocument()
})
it('should show removed credential label and priority tip for custom preference', () => {
mockCredentialStatus.authorized = false
mockCredentialStatus.authRemoved = true
renderCredentialPanel({ ...mockProvider, preferred_provider_type: 'custom' } as ModelProvider)
expect(screen.getByText(/modelProvider\.auth\.authRemoved/)).toBeInTheDocument()
expect(screen.getByTestId('priority-use-tip')).toBeInTheDocument()
})
it('should change priority and refresh related data after success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
renderCredentialPanel(mockProvider)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('gpt-4')
expect(mockEventEmitter.emit).toHaveBeenCalled()
it('should show red indicator and credential name for api-unavailable (exhausted + named unauthorized key)', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('indicator')).toHaveAttribute('data-color', 'red')
expect(screen.getByText('Bad Key')).toBeInTheDocument()
})
})
it('should render standalone priority selector without provider schema', () => {
const providerNoSchema = {
...mockProvider,
provider_credential_schema: null,
} as unknown as ModelProvider
renderCredentialPanel(providerNoSchema)
expect(screen.getByTestId('priority-selector')).toBeInTheDocument()
expect(screen.queryByTestId('config-provider')).not.toBeInTheDocument()
})
it('should show gray indicator when notAllowedToUse is true', () => {
mockCredentialStatus.notAllowedToUse = true
renderCredentialPanel(mockProvider)
expect(screen.getByTestId('indicator')).toHaveTextContent('gray')
})
it('should not notify or update when priority change returns non-success', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'error' })
renderCredentialPanel(mockProvider)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
describe('Destructive styling', () => {
it('should apply destructive container for credits-exhausted', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
expect(mockNotify).not.toHaveBeenCalled()
expect(mockUpdateModelProviders).not.toHaveBeenCalled()
expect(mockEventEmitter.emit).not.toHaveBeenCalled()
})
it('should show empty label when authorized is false and authRemoved is false', () => {
mockCredentialStatus.authorized = false
mockCredentialStatus.authRemoved = false
renderCredentialPanel(mockProvider)
expect(screen.queryByText(/modelProvider\.auth\.unAuthorized/)).not.toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.auth\.authRemoved/)).not.toBeInTheDocument()
})
it('should not show PriorityUseTip when priorityUseType is system', () => {
renderCredentialPanel(mockProvider)
expect(screen.queryByTestId('priority-use-tip')).not.toBeInTheDocument()
})
it('should not iterate configurateMethods for non-predefinedModel methods', async () => {
const mockChangePriority = changeModelProviderPriority as ReturnType<typeof vi.fn>
mockChangePriority.mockResolvedValue({ result: 'success' })
const providerWithCustomMethod = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
renderCredentialPanel(providerWithCustomMethod)
fireEvent.click(screen.getByTestId('priority-selector'))
await waitFor(() => {
expect(mockChangePriority).toHaveBeenCalled()
expect(mockNotify).toHaveBeenCalled()
it('should apply destructive container for no-usage variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply destructive container for api-unavailable variant', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(container.querySelector('[class*="border-state-destructive"]')).toBeTruthy()
})
it('should apply default container for credits-active', () => {
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
it('should apply default container for api-active', () => {
const { container } = renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
it('should apply default container for api-fallback', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('[class*="bg-white"]')).toBeTruthy()
})
expect(mockUpdateModelList).not.toHaveBeenCalled()
})
it('should show red indicator when hasCredential is false', () => {
mockCredentialStatus.hasCredential = false
renderCredentialPanel(mockProvider)
describe('Text color', () => {
it('should use destructive text color for credits-exhausted label', () => {
mockTrialCredits.isExhausted = true
const { container } = renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(container.querySelector('.text-text-destructive')).toBeTruthy()
})
expect(screen.getByTestId('indicator')).toHaveTextContent('red')
it('should use secondary text color for credits-active label', () => {
const { container } = renderWithQueryClient(createProvider())
expect(container.querySelector('.text-text-secondary')).toBeTruthy()
})
it('should use destructive text color for api-unavailable credential name', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByText('Bad Key')).toHaveClass('text-text-destructive')
})
it('should use secondary text color for api-active credential name', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByText('test-credential')).toHaveClass('text-text-secondary')
})
})
describe('Priority change', () => {
it('should call mutation with correct params on priority change', async () => {
renderWithQueryClient(createProvider())
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockChangePriorityFn.mock.calls[0]?.[0]).toEqual({
params: { provider: 'langgenius/openai/openai' },
body: { preferred_provider_type: 'custom' },
})
})
})
it('should show success toast and refresh data after successful mutation', async () => {
renderWithQueryClient(createProvider())
await act(async () => {
fireEvent.click(screen.getByTestId('change-priority-btn'))
})
await waitFor(() => {
expect(mockToastNotify).toHaveBeenCalledWith(
expect.objectContaining({ type: 'success' }),
)
expect(mockUpdateModelProviders).toHaveBeenCalled()
expect(mockUpdateModelList).toHaveBeenCalledWith('llm')
})
})
})
describe('ModelAuthDropdown integration', () => {
it('should pass credits-active variant to dropdown when credits available', () => {
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-active')
})
it('should pass api-fallback variant to dropdown when exhausted with valid key', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider())
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-fallback')
})
it('should pass credits-exhausted variant when exhausted with no credentials', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-exhausted')
})
it('should pass api-active variant for custom priority with authorized key', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
it('should pass credits-fallback variant for custom priority with no credentials and credits available', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
})
it('should pass credits-fallback variant for custom priority with named unauthorized key and credits available', () => {
renderWithQueryClient(createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'credits-fallback')
})
it('should pass no-usage variant when exhausted + credential but unauthorized', () => {
mockTrialCredits.isExhausted = true
renderWithQueryClient(createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1' }],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'no-usage')
})
})
describe('apiKeyOnly priority (system disabled)', () => {
it('should derive api-required-add when system config disabled and no credentials', () => {
renderWithQueryClient(createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-required-add')
expect(screen.getByText(/apiKeyRequired/)).toBeInTheDocument()
})
it('should derive api-active when system config disabled but has authorized key', () => {
renderWithQueryClient(createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
}))
expect(screen.getByTestId('model-auth-dropdown')).toHaveAttribute('data-variant', 'api-active')
})
})
})

View File

@ -1,17 +1,28 @@
import type { ModelItem, ModelProvider } from '../../declarations'
import type { ReactNode } from 'react'
import type { ModelProvider } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fetchModelProviderModelList } from '@/service/common'
import { createStore, Provider as JotaiProvider } from 'jotai'
import { useExpandModelProviderList } from '../../atoms'
import { ConfigurationMethodEnum } from '../../declarations'
import ProviderAddedCard from '../index'
let mockIsCurrentWorkspaceManager = true
const mockEventEmitter = {
useSubscription: vi.fn(),
emit: vi.fn(),
}
const mockFetchModelProviderModels = vi.fn()
const mockQueryOptions = vi.fn(({ input, ...options }: { input: { params: { provider: string } }, enabled?: boolean }) => ({
queryKey: ['console', 'modelProviders', 'models', input.params.provider],
queryFn: () => mockFetchModelProviderModels(input.params.provider),
...options,
}))
vi.mock('@/service/common', () => ({
fetchModelProviderModelList: vi.fn(),
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryOptions: (options: { input: { params: { provider: string } }, enabled?: boolean }) => mockQueryOptions(options),
},
},
},
}))
vi.mock('@/context/app-context', () => ({
@ -20,12 +31,6 @@ vi.mock('@/context/app-context', () => ({
}),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: mockEventEmitter,
}),
}))
// Mock internal components to simplify testing of the index file
vi.mock('../credential-panel', () => ({
default: () => <div data-testid="credential-panel" />,
@ -53,6 +58,38 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/model-auth'
ManageCustomModelCredentials: () => <div data-testid="manage-custom-model" />,
}))
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
},
})
const renderWithQueryClient = (node: ReactNode) => {
const queryClient = createTestQueryClient()
const store = createStore()
return render(
<JotaiProvider store={store}>
<QueryClientProvider client={queryClient}>
{node}
</QueryClientProvider>
</JotaiProvider>,
)
}
const ExternalExpandControls = () => {
const expandModelProviderList = useExpandModelProviderList()
return (
<>
<button type="button" data-testid="expand-other-provider" onClick={() => expandModelProviderList('langgenius/anthropic/anthropic')}>
expand other
</button>
<button type="button" data-testid="expand-current-provider" onClick={() => expandModelProviderList('langgenius/openai/openai')}>
expand current
</button>
</>
)
}
describe('ProviderAddedCard', () => {
const mockProvider = {
provider: 'langgenius/openai/openai',
@ -67,19 +104,21 @@ describe('ProviderAddedCard', () => {
})
it('should render provider added card component', () => {
render(<ProviderAddedCard provider={mockProvider} />)
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
expect(screen.getByTestId('provider-added-card')).toBeInTheDocument()
expect(screen.getByTestId('provider-icon')).toBeInTheDocument()
})
it('should open, refresh and collapse model list', async () => {
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [{ model: 'gpt-4' }] } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getByTestId('show-models-button')
fireEvent.click(showModelsBtn)
expect(fetchModelProviderModelList).toHaveBeenCalledWith(`/workspaces/current/model-providers/${mockProvider.provider}/models`)
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
})
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
// Test line 71-72: Opening when already fetched
@ -90,13 +129,13 @@ describe('ProviderAddedCard', () => {
// Explicitly re-find and click to re-open
fireEvent.click(screen.getByTestId('show-models-button'))
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1) // Should not fetch again
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(2) // Re-open fetches again with default stale/gc behavior
// Refresh list from ModelList
const refreshBtn = screen.getByRole('button', { name: 'refresh list' })
fireEvent.click(refreshBtn)
await waitFor(() => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(2)
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(3)
})
})
@ -105,18 +144,20 @@ describe('ProviderAddedCard', () => {
const promise = new Promise((resolve) => {
resolveOuter = resolve
})
vi.mocked(fetchModelProviderModelList).mockReturnValue(promise as unknown as ReturnType<typeof fetchModelProviderModelList>)
mockFetchModelProviderModels.mockReturnValue(promise)
render(<ProviderAddedCard provider={mockProvider} />)
renderWithQueryClient(<ProviderAddedCard provider={mockProvider} />)
const showModelsBtn = screen.getByTestId('show-models-button')
// First call sets loading to true
fireEvent.click(showModelsBtn)
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
})
// Second call should return early because loading is true
fireEvent.click(showModelsBtn)
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
await act(async () => {
resolveOuter({ data: [] })
@ -125,46 +166,25 @@ describe('ProviderAddedCard', () => {
expect(await screen.findByTestId('model-list')).toBeInTheDocument()
})
it('should show loading spinner while model list is being fetched', async () => {
let resolvePromise: (value: unknown) => void = () => {}
const pendingPromise = new Promise((resolve) => {
resolvePromise = resolve
it('should only react to external expansion for the matching provider', async () => {
mockFetchModelProviderModels.mockResolvedValue({ data: [{ model: 'gpt-4' }] })
renderWithQueryClient(
<>
<ProviderAddedCard provider={mockProvider} />
<ExternalExpandControls />
</>,
)
fireEvent.click(screen.getByTestId('expand-other-provider'))
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(0)
})
vi.mocked(fetchModelProviderModelList).mockReturnValue(pendingPromise as ReturnType<typeof fetchModelProviderModelList>)
render(<ProviderAddedCard provider={mockProvider} />)
fireEvent.click(screen.getByTestId('show-models-button'))
expect(document.querySelector('.i-ri-loader-2-line.animate-spin')).toBeInTheDocument()
await act(async () => {
resolvePromise({ data: [] })
fireEvent.click(screen.getByTestId('expand-current-provider'))
await waitFor(() => {
expect(mockFetchModelProviderModels).toHaveBeenCalledWith(mockProvider.provider)
})
})
it('should show modelsNum text after models have loaded', async () => {
const models = [
{ model: 'gpt-4' },
{ model: 'gpt-3.5' },
]
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: models } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
fireEvent.click(screen.getByTestId('show-models-button'))
await screen.findByTestId('model-list')
const collapseBtn = screen.getByRole('button', { name: 'collapse list' })
fireEvent.click(collapseBtn)
await waitFor(() => expect(screen.queryByTestId('model-list')).not.toBeInTheDocument())
const numTexts = screen.getAllByText(/modelProvider\.modelsNum/)
expect(numTexts.length).toBeGreaterThan(0)
expect(screen.getByText(/modelProvider\.showModelsNum/)).toBeInTheDocument()
expect(mockFetchModelProviderModels).toHaveBeenCalledTimes(1)
})
it('should render configure tip when provider is not in quota list and not configured', () => {
@ -172,93 +192,23 @@ describe('ProviderAddedCard', () => {
...mockProvider,
provider: 'custom/provider',
} as unknown as ModelProvider
render(<ProviderAddedCard provider={providerWithoutQuota} notConfigured />)
renderWithQueryClient(<ProviderAddedCard provider={providerWithoutQuota} notConfigured />)
expect(screen.getByText('common.modelProvider.configureTip')).toBeInTheDocument()
})
it('should refresh model list on event subscription', async () => {
let capturedHandler: (v: { type: string, payload: string } | null) => void = () => { }
mockEventEmitter.useSubscription.mockImplementation((handler: (v: unknown) => void) => {
capturedHandler = handler as (v: { type: string, payload: string } | null) => void
})
vi.mocked(fetchModelProviderModelList).mockResolvedValue({ data: [] } as unknown as { data: ModelItem[] })
render(<ProviderAddedCard provider={mockProvider} />)
expect(capturedHandler).toBeDefined()
act(() => {
capturedHandler({
type: 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST',
payload: mockProvider.provider,
})
})
await waitFor(() => {
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
// Should ignore non-matching events
act(() => {
capturedHandler({ type: 'OTHER', payload: '' })
capturedHandler(null)
})
expect(fetchModelProviderModelList).toHaveBeenCalledTimes(1)
})
it('should apply anthropic background class for anthropic provider', () => {
const anthropicProvider = {
...mockProvider,
provider: 'langgenius/anthropic/anthropic',
} as unknown as ModelProvider
const { container } = render(<ProviderAddedCard provider={anthropicProvider} />)
expect(container.querySelector('.bg-third-party-model-bg-anthropic')).toBeInTheDocument()
})
it('should render custom model actions for workspace managers', () => {
const customConfigProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.customizableModel],
} as unknown as ModelProvider
const { rerender } = render(<ProviderAddedCard provider={customConfigProvider} />)
const { unmount } = renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.getByTestId('manage-custom-model')).toBeInTheDocument()
expect(screen.getByTestId('add-custom-model')).toBeInTheDocument()
unmount()
mockIsCurrentWorkspaceManager = false
rerender(<ProviderAddedCard provider={customConfigProvider} />)
renderWithQueryClient(<ProviderAddedCard provider={customConfigProvider} />)
expect(screen.queryByTestId('manage-custom-model')).not.toBeInTheDocument()
})
it('should render credential panel when showCredential is true', () => {
// Arrange: use ConfigurationMethodEnum.predefinedModel ('predefined-model') so showCredential=true
const predefinedProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = true
// Act
render(<ProviderAddedCard provider={predefinedProvider} />)
// Assert: credential-panel is rendered (showCredential = true branch)
expect(screen.getByTestId('credential-panel')).toBeInTheDocument()
})
it('should not render credential panel when user is not workspace manager', () => {
// Arrange: predefined-model but manager=false so showCredential=false
const predefinedProvider = {
...mockProvider,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
} as unknown as ModelProvider
mockIsCurrentWorkspaceManager = false
// Act
render(<ProviderAddedCard provider={predefinedProvider} />)
// Assert: credential-panel is not rendered (showCredential = false)
expect(screen.queryByTestId('credential-panel')).not.toBeInTheDocument()
})
})

View File

@ -1,9 +1,17 @@
import type { ModelItem, ModelProvider } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { disableModel, enableModel } from '@/service/common'
import { ModelStatusEnum } from '../../declarations'
import ModelListItem from '../model-list-item'
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
let mockModelLoadBalancingEnabled = false
let mockPlanType: string = 'pro'
@ -71,6 +79,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
expect(screen.getByTestId('model-name')).toBeInTheDocument()
@ -85,6 +94,7 @@ describe('ModelListItem', () => {
isConfigurable={false}
onChange={onChange}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByRole('switch'))
@ -104,6 +114,7 @@ describe('ModelListItem', () => {
isConfigurable={false}
onChange={onChange}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByRole('switch'))
@ -124,6 +135,7 @@ describe('ModelListItem', () => {
isConfigurable={false}
onModifyLoadBalancing={onModifyLoadBalancing}
/>,
{ wrapper: createWrapper() },
)
fireEvent.click(screen.getByRole('button', { name: 'modify load balancing' }))
@ -143,6 +155,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
// Assert
@ -168,6 +181,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
// Assert - Badge component should render
@ -188,6 +202,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
// Assert - ConfigModel should show because plan.type === 'sandbox'
@ -207,6 +222,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
// Assert - ConfigModel should NOT show because plan.type !== 'sandbox' and load balancing is disabled
@ -226,6 +242,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={false}
/>,
{ wrapper: createWrapper() },
)
// Assert - ConfigModel should not render because status is not active/disabled
@ -247,6 +264,7 @@ describe('ModelListItem', () => {
provider={mockProvider}
isConfigurable={true}
/>,
{ wrapper: createWrapper() },
)
// Assert

View File

@ -1,30 +1,45 @@
import type { ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import QuotaPanel from '../quota-panel'
let mockWorkspace = {
let mockWorkspaceData: {
trial_credits: number
trial_credits_used: number
next_credit_reset_date: string
} | undefined = {
trial_credits: 100,
trial_credits_used: 30,
next_credit_reset_date: '2024-12-31',
}
let mockTrialModels: string[] = ['langgenius/openai/openai']
let mockWorkspaceIsPending = false
let mockTrialModels: string[] | undefined = ['langgenius/openai/openai']
let mockPlugins = [{
plugin_id: 'langgenius/openai',
latest_package_identifier: 'openai@1.0.0',
}]
vi.mock('@/context/app-context', () => ({
useAppContext: () => ({
currentWorkspace: mockWorkspace,
vi.mock('@/app/components/base/icons/src/public/llm', () => {
const Icon = ({ label }: { label: string }) => <span>{label}</span>
return {
OpenaiSmall: () => <Icon label="openai" />,
AnthropicShortLight: () => <Icon label="anthropic" />,
Gemini: () => <Icon label="gemini" />,
Grok: () => <Icon label="x" />,
Deepseek: () => <Icon label="deepseek" />,
Tongyi: () => <Icon label="tongyi" />,
}
})
vi.mock('@/service/use-common', () => ({
useCurrentWorkspace: () => ({
data: mockWorkspaceData,
isPending: mockWorkspaceIsPending,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { trial_models: string[] } }) => unknown) => selector({
systemFeatures: {
trial_models: mockTrialModels,
},
useSystemFeaturesQuery: () => ({
data: mockTrialModels ? { trial_models: mockTrialModels } : undefined,
}),
}))
@ -60,34 +75,21 @@ describe('QuotaPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkspace = {
mockWorkspaceData = {
trial_credits: 100,
trial_credits_used: 30,
next_credit_reset_date: '2024-12-31',
}
mockWorkspaceIsPending = false
mockTrialModels = ['langgenius/openai/openai']
mockPlugins = [{ plugin_id: 'langgenius/openai', latest_package_identifier: 'openai@1.0.0' }]
})
const getTrialProviderIconTrigger = (container: HTMLElement) => {
const providerIcon = container.querySelector('svg.h-6.w-6.rounded-lg')
expect(providerIcon).toBeInTheDocument()
const trigger = providerIcon?.closest('[data-state]') as HTMLDivElement | null
expect(trigger).toBeInTheDocument()
return trigger as HTMLDivElement
}
const clickFirstTrialProviderIcon = (container: HTMLElement) => {
fireEvent.click(getTrialProviderIconTrigger(container))
}
it('should render loading state', () => {
render(
<QuotaPanel
providers={mockProviders}
isLoading
/>,
)
mockWorkspaceData = undefined
mockWorkspaceIsPending = true
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByRole('status')).toBeInTheDocument()
})
@ -103,8 +105,17 @@ describe('QuotaPanel', () => {
expect(screen.getByText(/modelProvider\.resetDate/)).toBeInTheDocument()
})
it('should keep quota content during background refetch when cached workspace exists', () => {
mockWorkspaceIsPending = true
render(<QuotaPanel providers={mockProviders} />)
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('70')).toBeInTheDocument()
})
it('should floor credits at zero when usage is higher than quota', () => {
mockWorkspace = {
mockWorkspaceData = {
trial_credits: 10,
trial_credits_used: 999,
next_credit_reset_date: '',
@ -112,22 +123,22 @@ describe('QuotaPanel', () => {
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByText('0')).toBeInTheDocument()
expect(screen.getByText(/modelProvider\.card\.quotaExhausted/)).toBeInTheDocument()
expect(screen.queryByText(/modelProvider\.resetDate/)).not.toBeInTheDocument()
})
it('should open install modal when clicking an unsupported trial provider', () => {
const { container } = render(<QuotaPanel providers={[]} />)
render(<QuotaPanel providers={[]} />)
clickFirstTrialProviderIcon(container)
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
})
it('should close install modal when provider becomes installed', async () => {
const { rerender, container } = render(<QuotaPanel providers={[]} />)
const { rerender } = render(<QuotaPanel providers={[]} />)
clickFirstTrialProviderIcon(container)
fireEvent.click(screen.getByText('openai'))
expect(screen.getByText('install modal')).toBeInTheDocument()
rerender(<QuotaPanel providers={mockProviders} />)
@ -137,60 +148,36 @@ describe('QuotaPanel', () => {
})
})
it('should not open install modal when clicking an already installed provider', () => {
const { container } = render(<QuotaPanel providers={mockProviders} />)
it('should tolerate missing trial model configuration', () => {
mockTrialModels = undefined
clickFirstTrialProviderIcon(container)
render(<QuotaPanel providers={mockProviders} />)
expect(screen.queryByText('openai')).not.toBeInTheDocument()
})
it('should render installed custom providers without opening the install modal', () => {
render(<QuotaPanel providers={mockProviders} />)
expect(screen.getByLabelText(/modelAPI/)).toBeInTheDocument()
fireEvent.click(screen.getByText('openai'))
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
it('should not open install modal when plugin is not found in marketplace', () => {
mockPlugins = []
const { container } = render(<QuotaPanel providers={[]} />)
it('should show the supported-model tooltip for installed non-custom providers', () => {
render(
<QuotaPanel providers={[
{
provider: 'langgenius/openai/openai',
preferred_provider_type: 'system',
custom_configuration: { available_credentials: [] },
},
] as unknown as ModelProvider[]}
/>,
)
clickFirstTrialProviderIcon(container)
expect(screen.queryByText('install modal')).not.toBeInTheDocument()
})
it('should show destructive border when credits are zero or negative', () => {
mockWorkspace = {
trial_credits: 0,
trial_credits_used: 0,
next_credit_reset_date: '',
}
const { container } = render(<QuotaPanel providers={mockProviders} />)
expect(container.querySelector('.border-state-destructive-border')).toBeInTheDocument()
})
it('should show modelAPI tooltip for configured provider with custom preference', async () => {
const user = userEvent.setup()
const { container } = render(<QuotaPanel providers={mockProviders} />)
const trigger = getTrialProviderIconTrigger(container)
await user.hover(trigger as HTMLElement)
expect(await screen.findByText(/common\.modelProvider\.card\.modelAPI/)).toHaveTextContent('OpenAI')
})
it('should show modelSupported tooltip for installed provider without custom config', async () => {
const user = userEvent.setup()
const systemProviders = [
{
provider: 'langgenius/openai/openai',
preferred_provider_type: 'system',
custom_configuration: { available_credentials: [] },
},
] as unknown as ModelProvider[]
const { container } = render(<QuotaPanel providers={systemProviders} />)
const trigger = getTrialProviderIconTrigger(container)
await user.hover(trigger as HTMLElement)
expect(await screen.findByText(/common\.modelProvider\.card\.modelSupported/)).toHaveTextContent('OpenAI')
expect(screen.getByLabelText(/modelSupported/)).toBeInTheDocument()
})
})

View File

@ -1,27 +0,0 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { PlusCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { cn } from '@/utils/classnames'
type AddModelButtonProps = {
className?: string
onClick: () => void
}
const AddModelButton: FC<AddModelButtonProps> = ({
className,
onClick,
}) => {
const { t } = useTranslation()
return (
<span
className={cn('system-xs-medium flex h-6 shrink-0 cursor-pointer items-center rounded-md px-1.5 text-text-tertiary hover:bg-components-button-ghost-bg-hover hover:text-components-button-ghost-text', className)}
onClick={onClick}
>
<PlusCircle className="mr-1 h-3 w-3" />
{t('modelProvider.addModel', { ns: 'common' })}
</span>
)
}
export default AddModelButton

View File

@ -1,151 +1,105 @@
import type {
ModelProvider,
} from '../declarations'
import { useMemo } from 'react'
import type { ModelProvider } from '../declarations'
import type { CardVariant } from './use-credential-panel-state'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useToastContext } from '@/app/components/base/toast/context'
import { ConfigProvider } from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import Warning from '@/app/components/base/icons/src/vender/line/alertsAndFeedback/Warning'
import Indicator from '@/app/components/header/indicator'
import { IS_CLOUD_EDITION } from '@/config'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { changeModelProviderPriority } from '@/service/common'
import { cn } from '@/utils/classnames'
import {
ConfigurationMethodEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './index'
import PrioritySelector from './priority-selector'
import PriorityUseTip from './priority-use-tip'
import ModelAuthDropdown from './model-auth-dropdown'
import SystemQuotaCard from './system-quota-card'
import { useChangeProviderPriority } from './use-change-provider-priority'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
type CredentialPanelProps = {
provider: ModelProvider
}
const TEXT_LABEL_VARIANTS = new Set<CardVariant>([
'credits-active',
'credits-fallback',
'credits-exhausted',
'no-usage',
'api-required-add',
'api-required-configure',
])
const CredentialPanel = ({
provider,
}: CredentialPanelProps) => {
const state = useCredentialPanelState(provider)
const { isChangingPriority, handleChangePriority } = useChangeProviderPriority(provider)
const { variant, credentialName } = state
const isDestructive = isDestructiveVariant(variant)
const isTextLabel = TEXT_LABEL_VARIANTS.has(variant)
const needsGap = !isTextLabel || variant === 'credits-fallback'
return (
<SystemQuotaCard variant={isDestructive ? 'destructive' : 'default'}>
<SystemQuotaCard.Label className={needsGap ? 'gap-1' : undefined}>
{isTextLabel
? <TextLabel variant={variant} />
: <StatusLabel variant={variant} credentialName={credentialName} />}
</SystemQuotaCard.Label>
<SystemQuotaCard.Actions>
<ModelAuthDropdown
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={handleChangePriority}
/>
</SystemQuotaCard.Actions>
</SystemQuotaCard>
)
}
const TEXT_LABEL_KEYS = {
'credits-active': 'modelProvider.card.aiCreditsInUse',
'credits-fallback': 'modelProvider.card.aiCreditsInUse',
'credits-exhausted': 'modelProvider.card.quotaExhausted',
'no-usage': 'modelProvider.card.noAvailableUsage',
'api-required-add': 'modelProvider.card.apiKeyRequired',
'api-required-configure': 'modelProvider.card.apiKeyRequired',
} as const satisfies Partial<Record<CardVariant, string>>
function TextLabel({ variant }: { variant: CardVariant }) {
const { t } = useTranslation()
const { notify } = useToastContext()
const { eventEmitter } = useEventEmitterContextContext()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const customConfig = provider.custom_configuration
const systemConfig = provider.system_configuration
const priorityUseType = provider.preferred_provider_type
const isCustomConfigured = customConfig.status === CustomConfigurationStatusEnum.active
const configurateMethods = provider.configurate_methods
const {
hasCredential,
authorized,
authRemoved,
current_credential_name,
notAllowedToUse,
} = useCredentialStatus(provider)
const showPrioritySelector = systemConfig.enabled && isCustomConfigured && IS_CLOUD_EDITION
const handleChangePriority = async (key: PreferredProviderTypeEnum) => {
const res = await changeModelProviderPriority({
url: `/workspaces/current/model-providers/${provider.provider}/preferred-provider-type`,
body: {
preferred_provider_type: key,
},
})
if (res.result === 'success') {
notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
updateModelProviders()
configurateMethods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider.supported_model_types.forEach(modelType => updateModelList(modelType))
})
eventEmitter?.emit({
type: UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST,
payload: provider.provider,
} as any)
}
}
const credentialLabel = useMemo(() => {
if (!hasCredential)
return t('modelProvider.auth.unAuthorized', { ns: 'common' })
if (authorized)
return current_credential_name
if (authRemoved)
return t('modelProvider.auth.authRemoved', { ns: 'common' })
return ''
}, [authorized, authRemoved, current_credential_name, hasCredential])
const color = useMemo(() => {
if (authRemoved || !hasCredential)
return 'red'
if (notAllowedToUse)
return 'gray'
return 'green'
}, [authRemoved, notAllowedToUse, hasCredential])
const isDestructive = isDestructiveVariant(variant)
const labelKey = TEXT_LABEL_KEYS[variant as keyof typeof TEXT_LABEL_KEYS]
return (
<>
{
provider.provider_credential_schema && (
<div className={cn(
'relative ml-1 w-[120px] shrink-0 rounded-lg border-[0.5px] border-components-panel-border bg-white/[0.18] p-1',
authRemoved && 'border-state-destructive-border bg-state-destructive-hover',
)}
>
<div className="system-xs-medium mb-1 flex h-5 items-center justify-between pl-2 pr-[7px] pt-1 text-text-tertiary">
<div
className={cn(
'grow truncate',
authRemoved && 'text-text-destructive',
)}
title={credentialLabel}
>
{credentialLabel}
</div>
<Indicator className="shrink-0" color={color} />
</div>
<div className="flex items-center gap-0.5">
<ConfigProvider
provider={provider}
/>
{
showPrioritySelector && (
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
)
}
</div>
{
priorityUseType === PreferredProviderTypeEnum.custom && systemConfig.enabled && (
<PriorityUseTip />
)
}
</div>
)
}
{
showPrioritySelector && !provider.provider_credential_schema && (
<div className="ml-1">
<PrioritySelector
value={priorityUseType}
onSelect={handleChangePriority}
/>
</div>
)
}
<span className={isDestructive ? 'text-text-destructive' : 'text-text-secondary'}>
{t(labelKey, { ns: 'common' })}
</span>
{variant === 'credits-fallback' && (
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
)}
</>
)
}
export default CredentialPanel
function StatusLabel({ variant, credentialName }: {
variant: CardVariant
credentialName: string | undefined
}) {
const isDestructive = isDestructiveVariant(variant)
const dotColor = isDestructive ? 'red' : 'green'
const showWarning = variant === 'api-fallback'
return (
<>
<Indicator className="shrink-0" color={dotColor} />
<span
className={`truncate ${isDestructive ? 'text-text-destructive' : 'text-text-secondary'}`}
title={credentialName}
>
{credentialName}
</span>
{showWarning && (
<Warning className="ml-auto h-3 w-3 shrink-0 text-text-warning" />
)}
</>
)
}
export default memo(CredentialPanel)

View File

@ -1,11 +1,12 @@
import type { FC } from 'react'
import type {
ModelItem,
ModelProvider,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '../utils'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AddCustomModel,
@ -13,9 +14,10 @@ import {
} from '@/app/components/header/account-setting/model-provider-page/model-auth'
import { IS_CE_EDITION } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { fetchModelProviderModelList } from '@/service/common'
import { useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { cn } from '@/utils/classnames'
import { useModelProviderListExpanded, useSetModelProviderListExpanded } from '../atoms'
import { ConfigurationMethodEnum } from '../declarations'
import ModelBadge from '../model-badge'
import ProviderIcon from '../provider-icon'
@ -25,121 +27,123 @@ import {
} from '../utils'
import CredentialPanel from './credential-panel'
import ModelList from './model-list'
import ProviderCardActions from './provider-card-actions'
export const UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST = 'UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST'
type ProviderAddedCardProps = {
notConfigured?: boolean
provider: ModelProvider
pluginDetail?: PluginDetail
}
const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
notConfigured,
provider,
pluginDetail,
}) => {
const { t } = useTranslation()
const { eventEmitter } = useEventEmitterContextContext()
const [fetched, setFetched] = useState(false)
const [loading, setLoading] = useState(false)
const [collapsed, setCollapsed] = useState(true)
const [modelList, setModelList] = useState<ModelItem[]>([])
const configurationMethods = provider.configurate_methods.filter(method => method !== ConfigurationMethodEnum.fetchFromRemote)
const refreshModelProviders = useProviderContextSelector(state => state.refreshModelProviders)
const currentProviderName = provider.provider
const expanded = useModelProviderListExpanded(currentProviderName)
const setExpanded = useSetModelProviderListExpanded(currentProviderName)
const supportsPredefinedModel = provider.configurate_methods.includes(ConfigurationMethodEnum.predefinedModel)
const supportsCustomizableModel = provider.configurate_methods.includes(ConfigurationMethodEnum.customizableModel)
const systemConfig = provider.system_configuration
const hasModelList = fetched && !!modelList.length
const {
data: modelList = [],
isFetching: loading,
isSuccess: hasFetchedModelList,
refetch: refetchModelList,
} = useQuery(consoleQuery.modelProviders.models.queryOptions({
input: { params: { provider: currentProviderName } },
enabled: expanded,
refetchOnWindowFocus: false,
select: response => response.data,
}))
const hasModelList = hasFetchedModelList && !!modelList.length
const showCollapsedSection = !expanded || !hasFetchedModelList
const { isCurrentWorkspaceManager } = useAppContext()
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(provider.provider as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
const showCredential = configurationMethods.includes(ConfigurationMethodEnum.predefinedModel) && isCurrentWorkspaceManager
const showModelProvider = systemConfig.enabled && MODEL_PROVIDER_QUOTA_GET_PAID.includes(currentProviderName as ModelProviderQuotaGetPaid) && !IS_CE_EDITION
const showCredential = supportsPredefinedModel && isCurrentWorkspaceManager
const showCustomModelActions = supportsCustomizableModel && isCurrentWorkspaceManager
const getModelList = async (providerName: string) => {
const refreshModelList = useCallback((targetProviderName: string) => {
if (targetProviderName !== currentProviderName)
return
if (!expanded)
setExpanded(true)
refetchModelList().catch(() => {})
}, [currentProviderName, expanded, refetchModelList, setExpanded])
const handleOpenModelList = useCallback(() => {
if (loading)
return
try {
setLoading(true)
const modelsData = await fetchModelProviderModelList(`/workspaces/current/model-providers/${providerName}/models`)
setModelList(modelsData.data)
setCollapsed(false)
setFetched(true)
}
finally {
setLoading(false)
}
}
const handleOpenModelList = () => {
if (fetched) {
setCollapsed(false)
if (!expanded) {
setExpanded(true)
return
}
getModelList(provider.provider)
}
eventEmitter?.useSubscription((v: any) => {
if (v?.type === UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST && v.payload === provider.provider)
getModelList(v.payload)
})
refetchModelList().catch(() => {})
}, [expanded, loading, refetchModelList, setExpanded])
return (
<div
data-testid="provider-added-card"
className={cn(
'mb-2 rounded-xl border-[0.5px] border-divider-regular bg-third-party-model-bg-default shadow-xs',
provider.provider === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
provider.provider === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
currentProviderName === 'langgenius/openai/openai' && 'bg-third-party-model-bg-openai',
currentProviderName === 'langgenius/anthropic/anthropic' && 'bg-third-party-model-bg-anthropic',
)}
>
<div className="flex rounded-t-xl py-2 pl-3 pr-2">
<div className="grow px-1 pb-0.5 pt-1">
<ProviderIcon
className="mb-2"
provider={provider}
/>
<div className="mb-2 flex items-center gap-1">
<ProviderIcon provider={provider} />
{pluginDetail && (
<ProviderCardActions
detail={pluginDetail}
onUpdate={refreshModelProviders}
/>
)}
</div>
<div className="flex gap-0.5">
{
provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))
}
{provider.supported_model_types.map(modelType => (
<ModelBadge key={modelType}>
{modelTypeFormat(modelType)}
</ModelBadge>
))}
</div>
</div>
{
showCredential && (
<CredentialPanel
provider={provider}
/>
)
}
{showCredential && (
<CredentialPanel
provider={provider}
/>
)}
</div>
{
collapsed && (
showCollapsedSection && (
<div className="group flex items-center justify-between border-t border-t-divider-subtle py-1.5 pl-2 pr-[11px] text-text-tertiary system-xs-medium">
{(showModelProvider || !notConfigured) && (
<>
<div className="flex h-6 items-center pl-1 pr-1.5 leading-6 group-hover:hidden">
{
hasModelList
? t('modelProvider.modelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
</div>
<div
data-testid="show-models-button"
className="hidden h-6 cursor-pointer items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover group-hover:flex"
onClick={handleOpenModelList}
>
{
hasModelList
? t('modelProvider.showModelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
{
loading && (
<div className="i-ri-loader-2-line ml-0.5 h-3 w-3 animate-spin" />
)
}
</div>
</>
<button
type="button"
data-testid="show-models-button"
className="flex h-6 items-center rounded-lg pl-1 pr-1.5 hover:bg-components-button-ghost-bg-hover"
aria-label={t('modelProvider.showModels', { ns: 'common' })}
onClick={handleOpenModelList}
>
{
hasModelList
? t('modelProvider.modelsNum', { ns: 'common', num: modelList.length })
: t('modelProvider.showModels', { ns: 'common' })
}
{!loading && <div className="i-ri-arrow-right-s-line h-4 w-4" />}
{
loading && (
<div className="i-ri-loader-2-line ml-0.5 h-3 w-3 animate-spin" />
)
}
</button>
)}
{!showModelProvider && notConfigured && (
<div className="flex h-6 items-center pl-1 pr-1.5">
@ -148,7 +152,7 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
</div>
)}
{
configurationMethods.includes(ConfigurationMethodEnum.customizableModel) && isCurrentWorkspaceManager && (
showCustomModelActions && (
<div className="flex grow justify-end">
<ManageCustomModelCredentials
provider={provider}
@ -166,12 +170,12 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)
}
{
!collapsed && (
!showCollapsedSection && (
<ModelList
provider={provider}
models={modelList}
onCollapse={() => setCollapsed(true)}
onChange={(provider: string) => getModelList(provider)}
onCollapse={() => setExpanded(false)}
onChange={refreshModelList}
/>
)
}
@ -179,4 +183,4 @@ const ProviderAddedCard: FC<ProviderAddedCardProps> = ({
)
}
export default ProviderAddedCard
export default memo(ProviderAddedCard)

View File

@ -0,0 +1,142 @@
import type { Credential, ModelProvider } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import ApiKeySection from './api-key-section'
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
credential_id: 'cred-1',
credential_name: 'Test API Key',
...overrides,
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test-provider',
allow_custom_token: true,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
...overrides,
} as unknown as ModelProvider)
describe('ApiKeySection', () => {
const handlers = {
onItemClick: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
onAdd: vi.fn(),
}
beforeEach(() => {
vi.clearAllMocks()
})
// Empty state
describe('Empty state (no credentials)', () => {
it('should show empty state message', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument()
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should show Add API Key button', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should call onAdd when Add API Key is clicked', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
expect(handlers.onAdd).toHaveBeenCalledTimes(1)
})
it('should hide Add API Key button when allow_custom_token is false', () => {
render(
<ApiKeySection
provider={createProvider({ allow_custom_token: false })}
credentials={[]}
selectedCredentialId={undefined}
{...handlers}
/>,
)
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
})
})
// With credentials
describe('With credentials', () => {
const credentials = [
createCredential({ credential_id: 'cred-1', credential_name: 'Key Alpha' }),
createCredential({ credential_id: 'cred-2', credential_name: 'Key Beta' }),
]
it('should render credential list with header', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.getByText(/apiKeys/)).toBeInTheDocument()
expect(screen.getByText('Key Alpha')).toBeInTheDocument()
expect(screen.getByText('Key Beta')).toBeInTheDocument()
})
it('should show Add API Key button in footer', () => {
render(
<ApiKeySection
provider={createProvider()}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should hide Add API Key when allow_custom_token is false', () => {
render(
<ApiKeySection
provider={createProvider({ allow_custom_token: false })}
credentials={credentials}
selectedCredentialId="cred-1"
{...handlers}
/>,
)
expect(screen.queryByRole('button', { name: /addApiKey/ })).not.toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,91 @@
import type { Credential, CustomModel, ModelProvider } from '../../declarations'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import CredentialItem from '../../model-auth/authorized/credential-item'
type ApiKeySectionProps = {
provider: ModelProvider
credentials: Credential[]
selectedCredentialId: string | undefined
isActivating?: boolean
onItemClick: (credential: Credential, model?: CustomModel) => void
onEdit: (credential?: Credential) => void
onDelete: (credential?: Credential) => void
onAdd: () => void
}
function ApiKeySection({
provider,
credentials,
selectedCredentialId,
isActivating,
onItemClick,
onEdit,
onDelete,
onAdd,
}: ApiKeySectionProps) {
const { t } = useTranslation()
const notAllowCustomCredential = provider.allow_custom_token === false
if (!credentials.length) {
return (
<div className="flex flex-col gap-2 p-2">
<div className="rounded-[10px] bg-gradient-to-r from-state-base-hover to-transparent p-4">
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-sm-medium">
{t('modelProvider.card.noApiKeysTitle', { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.noApiKeysDescription', { ns: 'common' })}
</div>
</div>
</div>
{!notAllowCustomCredential && (
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
)}
</div>
)
}
return (
<div className="border-t border-t-divider-subtle">
<div className="px-1">
<div className="pb-1 pl-7 pr-2 pt-3 text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.auth.apiKeys', { ns: 'common' })}
</div>
<div className="max-h-[200px] overflow-y-auto">
{credentials.map(credential => (
<CredentialItem
key={credential.credential_id}
credential={credential}
disabled={isActivating}
showSelectedIcon
selectedCredentialId={selectedCredentialId}
onItemClick={onItemClick}
onEdit={onEdit}
onDelete={onDelete}
/>
))}
</div>
</div>
{!notAllowCustomCredential && (
<div className="p-2">
<Button
onClick={onAdd}
className="w-full"
>
{t('modelProvider.auth.addApiKey', { ns: 'common' })}
</Button>
</div>
)}
</div>
)
}
export default memo(ApiKeySection)

View File

@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const mockTrialCredits = { credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false, nextCreditResetDate: undefined }
const mockSetShowPricingModal = vi.fn()
vi.mock('react-i18next', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-i18next')>()
return {
...actual,
Trans: ({
i18nKey,
components,
}: {
i18nKey?: string
components: { upgradeLink: ReactNode }
}) => (
<>
{i18nKey}
{components.upgradeLink}
</>
),
}
})
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('@/context/modal-context', () => ({
useModalContextSelector: () => mockSetShowPricingModal,
}))
describe('CreditsExhaustedAlert', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 10_000 })
})
// Without API key fallback
describe('Without API key fallback', () => {
it('should show exhausted message', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument()
})
it('should show description with upgrade link', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/creditsExhaustedDescription/)).toBeInTheDocument()
})
})
// With API key fallback
describe('With API key fallback', () => {
it('should show fallback message', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback />)
expect(screen.getByText(/creditsExhaustedFallback(?!Description)/)).toBeInTheDocument()
})
it('should show fallback description', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback />)
expect(screen.getByText(/creditsExhaustedFallbackDescription/)).toBeInTheDocument()
})
})
// Usage display
describe('Usage display', () => {
it('should show usage label', () => {
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/usageLabel/)).toBeInTheDocument()
})
it('should show usage amounts', () => {
mockTrialCredits.credits = 200
render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(screen.getByText(/9,800/)).toBeInTheDocument()
expect(screen.getByText(/10,000/)).toBeInTheDocument()
})
it('should cap progress at 100 percent when total credits are zero', () => {
Object.assign(mockTrialCredits, { credits: 0, totalCredits: 0 })
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
expect(container.querySelector('.bg-components-progress-error-progress')).toHaveStyle({ width: '100%' })
})
it('should open the pricing modal when the upgrade link is clicked', () => {
const { container } = render(<CreditsExhaustedAlert hasApiKeyFallback={false} />)
fireEvent.click(container.querySelector('button') as HTMLButtonElement)
expect(mockSetShowPricingModal).toHaveBeenCalledTimes(1)
})
})
})

View File

@ -0,0 +1,78 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ICurrentWorkspace } from '@/models/common'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import CreditsExhaustedAlert from './credits-exhausted-alert'
const baseWorkspace: ICurrentWorkspace = {
id: 'ws-1',
name: 'Test Workspace',
plan: 'sandbox',
status: 'normal',
created_at: Date.now(),
role: 'owner',
providers: [],
trial_credits: 200,
trial_credits_used: 200,
next_credit_reset_date: Date.now() + 86400000,
}
function createSeededQueryClient(overrides?: Partial<ICurrentWorkspace>) {
const qc = new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: false } },
})
qc.setQueryData(['common', 'current-workspace'], { ...baseWorkspace, ...overrides })
return qc
}
const meta = {
title: 'ModelProvider/CreditsExhaustedAlert',
component: CreditsExhaustedAlert,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Alert shown when trial credits are exhausted, with usage progress bar and upgrade link.',
},
},
},
tags: ['autodocs'],
decorators: [
(Story) => {
return (
<QueryClientProvider client={createSeededQueryClient()}>
<div className="w-[320px]">
<Story />
</div>
</QueryClientProvider>
)
},
],
args: {
hasApiKeyFallback: false,
},
} satisfies Meta<typeof CreditsExhaustedAlert>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithApiKeyFallback: Story = {
args: {
hasApiKeyFallback: true,
},
}
export const PartialUsage: Story = {
decorators: [
(Story) => {
return (
<QueryClientProvider client={createSeededQueryClient({ trial_credits: 500, trial_credits_used: 480 })}>
<div className="w-[320px]">
<Story />
</div>
</QueryClientProvider>
)
},
],
}

View File

@ -0,0 +1,71 @@
import { Trans, useTranslation } from 'react-i18next'
import { CreditsCoin } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import { useModalContextSelector } from '@/context/modal-context'
import { formatNumber } from '@/utils/format'
import { useTrialCredits } from '../use-trial-credits'
type CreditsExhaustedAlertProps = {
hasApiKeyFallback: boolean
}
export default function CreditsExhaustedAlert({ hasApiKeyFallback }: CreditsExhaustedAlertProps) {
const { t } = useTranslation()
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
const { credits, totalCredits } = useTrialCredits()
const titleKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallback'
: 'modelProvider.card.creditsExhaustedMessage'
const descriptionKey = hasApiKeyFallback
? 'modelProvider.card.creditsExhaustedFallbackDescription'
: 'modelProvider.card.creditsExhaustedDescription'
const usedCredits = totalCredits - credits
const usagePercent = totalCredits > 0 ? Math.min((usedCredits / totalCredits) * 100, 100) : 100
return (
<div className="mx-2 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
<div className="text-text-tertiary system-xs-regular">
<Trans
i18nKey={descriptionKey}
ns="common"
components={{
upgradeLink: (
<button
type="button"
className="cursor-pointer border-0 bg-transparent p-0 text-left text-text-accent system-xs-medium"
onClick={() => setShowPricingModal()}
/>
),
}}
/>
</div>
</div>
<div className="mt-3 flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className="text-text-tertiary system-xs-medium">
{t('modelProvider.card.usageLabel', { ns: 'common' })}
</span>
<div className="flex items-center gap-0.5 text-text-tertiary system-xs-regular">
<CreditsCoin className="h-3 w-3" />
<span>
{formatNumber(usedCredits)}
/
{formatNumber(totalCredits)}
</span>
</div>
</div>
<div className="h-1 overflow-hidden rounded-[6px] bg-components-progress-error-bg">
<div
className="h-full rounded-l-[6px] bg-components-progress-error-progress"
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,28 @@
import { useTranslation } from 'react-i18next'
type CreditsFallbackAlertProps = {
hasCredentials: boolean
}
export default function CreditsFallbackAlert({ hasCredentials }: CreditsFallbackAlertProps) {
const { t } = useTranslation()
const titleKey = hasCredentials
? 'modelProvider.card.apiKeyUnavailableFallback'
: 'modelProvider.card.noApiKeysFallback'
return (
<div className="mx-2 mb-1 mt-0.5 rounded-lg bg-background-section-burn p-3">
<div className="flex flex-col gap-1">
<div className="text-text-primary system-sm-medium">
{t(titleKey, { ns: 'common' })}
</div>
{hasCredentials && (
<div className="text-text-tertiary system-xs-regular">
{t('modelProvider.card.apiKeyUnavailableFallbackDescription', { ns: 'common' })}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,152 @@
import type { ReactNode } from 'react'
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { act, fireEvent, render, screen } from '@testing-library/react'
import DropdownContent from './dropdown-content'
type AlertDialogProps = {
children: ReactNode
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: AlertDialogProps['onOpenChange']
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
const mockHandleOpenModal = vi.fn()
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: 'cred-1',
handleOpenModal: mockHandleOpenModal,
}),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: 'cred-1',
isActivating: false,
activate: vi.fn(),
}),
}))
vi.mock('@/app/components/base/ui/alert-dialog', () => ({
AlertDialog: ({ children, onOpenChange }: AlertDialogProps) => {
latestOnOpenChange = onOpenChange
return <div>{children}</div>
},
AlertDialogActions: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogCancelButton: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
AlertDialogConfirmButton: ({ children, onClick }: { children: ReactNode, onClick?: () => void }) => <button type="button" onClick={onClick}>{children}</button>,
AlertDialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
AlertDialogDescription: () => <div />,
AlertDialogTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))
vi.mock('./api-key-section', () => ({
default: ({ credentials, onDelete }: { credentials: unknown[], onDelete: (credential?: unknown) => void }) => (
<div>
<span>{`credentials:${credentials.length}`}</span>
<button type="button" onClick={() => onDelete(undefined)}>delete-undefined</button>
</div>
),
}))
vi.mock('./credits-exhausted-alert', () => ({
default: () => <div>credits alert</div>,
}))
vi.mock('./credits-fallback-alert', () => ({
default: () => <div>fallback alert</div>,
}))
vi.mock('./usage-priority-section', () => ({
default: () => <div>priority section</div>,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
available_credentials: undefined,
},
system_configuration: {
enabled: true,
quota_configurations: [],
current_quota_type: 'trial',
},
configurate_methods: [],
supported_model_types: [],
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 0,
...overrides,
})
describe('DropdownContent dialog branches', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
})
it('should fall back to an empty credential list when the provider has no credentials', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
expect(screen.getByText('credentials:0')).toBeInTheDocument()
})
it('should ignore delete requests without a credential payload', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'delete-undefined' }))
expect(mockOpenConfirmDelete).not.toHaveBeenCalled()
})
it('should only close the confirm dialog when the alert dialog reports closed', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={vi.fn()}
onClose={vi.fn()}
/>,
)
act(() => {
latestOnOpenChange?.(true)
latestOnOpenChange?.(false)
})
expect(mockCloseConfirmDelete).toHaveBeenCalledTimes(1)
})
})

View File

@ -0,0 +1,435 @@
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { fireEvent, render, screen } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import DropdownContent from './dropdown-content'
const mockHandleOpenModal = vi.fn()
const mockActivate = vi.fn()
const mockOpenConfirmDelete = vi.fn()
const mockCloseConfirmDelete = vi.fn()
const mockHandleConfirmDelete = vi.fn()
let mockDeleteCredentialId: string | null = null
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: 'cred-1',
isActivating: false,
activate: mockActivate,
}),
}))
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: mockOpenConfirmDelete,
closeConfirmDelete: mockCloseConfirmDelete,
doingAction: false,
handleConfirmDelete: mockHandleConfirmDelete,
deleteCredentialId: mockDeleteCredentialId,
handleOpenModal: mockHandleOpenModal,
}),
}))
vi.mock('../../model-auth/authorized/credential-item', () => ({
default: ({ credential, onItemClick, onEdit, onDelete }: {
credential: { credential_id: string, credential_name: string }
onItemClick?: (c: unknown) => void
onEdit?: (c: unknown) => void
onDelete?: (c: unknown) => void
}) => (
<div data-testid={`credential-${credential.credential_id}`}>
<span>{credential.credential_name}</span>
<button data-testid={`click-${credential.credential_id}`} onClick={() => onItemClick?.(credential)}>select</button>
<button data-testid={`edit-${credential.credential_id}`} onClick={() => onEdit?.(credential)}>edit</button>
<button data-testid={`delete-${credential.credential_id}`} onClick={() => onDelete?.(credential)}>delete</button>
</div>
),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'My Key',
available_credentials: [
{ credential_id: 'cred-1', credential_name: 'My Key' },
{ credential_id: 'cred-2', credential_name: 'Other Key' },
],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: ['predefined-model'],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
showPrioritySwitcher: true,
hasCredentials: true,
isCreditsExhausted: false,
credentialName: 'My Key',
credits: 100,
...overrides,
})
describe('DropdownContent', () => {
const onChangePriority = vi.fn()
const onClose = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
mockDeleteCredentialId = null
})
describe('UsagePrioritySection visibility', () => {
it('should show when showPrioritySwitcher is true', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/usagePriority/)).toBeInTheDocument()
})
it('should hide when showPrioritySwitcher is false', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ showPrioritySwitcher: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/usagePriority/)).not.toBeInTheDocument()
})
})
describe('CreditsExhaustedAlert', () => {
it('should show when credits exhausted and supports credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/creditsExhausted/).length).toBeGreaterThan(0)
})
it('should hide when credits not exhausted', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument()
})
it('should hide when credits exhausted but supportsCredits is false', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({ isCreditsExhausted: true, supportsCredits: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/creditsExhausted/)).not.toBeInTheDocument()
})
it('should show fallback message when api-fallback variant with exhausted credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-fallback',
isCreditsExhausted: true,
supportsCredits: true,
priority: 'credits',
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/creditsExhaustedFallback/).length).toBeGreaterThan(0)
})
it('should show non-fallback message when credits-exhausted variant', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'credits-exhausted',
isCreditsExhausted: true,
supportsCredits: true,
hasCredentials: false,
priority: 'credits',
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/creditsExhaustedMessage/)).toBeInTheDocument()
})
})
describe('CreditsFallbackAlert', () => {
it('should show when priority is apiKey, supports credits, not exhausted, and variant is not api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-required-add',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
hasCredentials: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/noApiKeysFallback/)).toBeInTheDocument()
})
it('should show unavailable message when priority is apiKey with credentials but not api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-unavailable',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
hasCredentials: true,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getAllByText(/apiKeyUnavailableFallback/).length).toBeGreaterThan(0)
})
it('should NOT show when variant is api-active', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'api-active',
priority: 'apiKey',
supportsCredits: true,
isCreditsExhausted: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument()
expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument()
})
it('should NOT show when priority is credits', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
isCreditsExhausted: false,
})}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/noApiKeysFallback/)).not.toBeInTheDocument()
expect(screen.queryByText(/apiKeyUnavailableFallback/)).not.toBeInTheDocument()
})
})
describe('API key section', () => {
it('should render all credential items', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText('My Key')).toBeInTheDocument()
expect(screen.getByText('Other Key')).toBeInTheDocument()
})
it('should show empty state when no credentials', () => {
render(
<DropdownContent
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/noApiKeysTitle/)).toBeInTheDocument()
expect(screen.getByText(/noApiKeysDescription/)).toBeInTheDocument()
})
it('should call activate without closing on credential item click', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('click-cred-2'))
expect(mockActivate).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
expect(onClose).not.toHaveBeenCalled()
})
it('should call handleOpenModal and close on edit credential', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('edit-cred-2'))
expect(mockHandleOpenModal).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
expect(onClose).toHaveBeenCalled()
})
it('should call openConfirmDelete on delete credential', () => {
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByTestId('delete-cred-2'))
expect(mockOpenConfirmDelete).toHaveBeenCalledWith(
expect.objectContaining({ credential_id: 'cred-2' }),
)
})
})
describe('Add API Key', () => {
it('should call handleOpenModal with no args and close on add', () => {
render(
<DropdownContent
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})}
state={createState({ hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /addApiKey/ }))
expect(mockHandleOpenModal).toHaveBeenCalledWith()
expect(onClose).toHaveBeenCalled()
})
})
describe('AlertDialog for delete confirmation', () => {
it('should show confirm dialog when deleteCredentialId is set', () => {
mockDeleteCredentialId = 'cred-1'
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.getByText(/confirmDelete/)).toBeInTheDocument()
})
it('should not show confirm dialog when deleteCredentialId is null', () => {
mockDeleteCredentialId = null
render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(screen.queryByText(/confirmDelete/)).not.toBeInTheDocument()
})
})
describe('Layout', () => {
it('should have 320px width container', () => {
const { container } = render(
<DropdownContent
provider={createProvider()}
state={createState()}
isChangingPriority={false}
onChangePriority={onChangePriority}
onClose={onClose}
/>,
)
expect(container.querySelector('.w-\\[320px\\]')).toBeTruthy()
})
})
})

View File

@ -0,0 +1,131 @@
import type { Credential, ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import { ConfigurationMethodEnum } from '../../declarations'
import { useAuth } from '../../model-auth/hooks'
import ApiKeySection from './api-key-section'
import CreditsExhaustedAlert from './credits-exhausted-alert'
import CreditsFallbackAlert from './credits-fallback-alert'
import UsagePrioritySection from './usage-priority-section'
import { useActivateCredential } from './use-activate-credential'
const EMPTY_CREDENTIALS: Credential[] = []
type DropdownContentProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
onClose: () => void
}
function DropdownContent({
provider,
state,
isChangingPriority,
onChangePriority,
onClose,
}: DropdownContentProps) {
const { t } = useTranslation()
const { available_credentials } = provider.custom_configuration
const {
openConfirmDelete,
closeConfirmDelete,
doingAction,
handleConfirmDelete,
deleteCredentialId,
handleOpenModal,
} = useAuth(provider, ConfigurationMethodEnum.predefinedModel)
const { selectedCredentialId, isActivating, activate } = useActivateCredential(provider)
const handleEdit = useCallback((credential?: Credential) => {
handleOpenModal(credential)
onClose()
}, [handleOpenModal, onClose])
const handleDelete = useCallback((credential?: Credential) => {
if (credential)
openConfirmDelete(credential)
}, [openConfirmDelete])
const handleAdd = useCallback(() => {
handleOpenModal()
onClose()
}, [handleOpenModal, onClose])
const showCreditsExhaustedAlert = state.isCreditsExhausted && state.supportsCredits
const hasApiKeyFallback = state.variant === 'api-fallback'
|| (state.variant === 'api-active' && state.priority === 'apiKey')
const showCreditsFallbackAlert = state.priority === 'apiKey'
&& state.supportsCredits
&& !state.isCreditsExhausted
&& state.variant !== 'api-active'
return (
<>
<div className="w-[320px]">
{state.showPrioritySwitcher && (
<UsagePrioritySection
value={state.priority}
disabled={isChangingPriority}
onSelect={onChangePriority}
/>
)}
{showCreditsFallbackAlert && (
<CreditsFallbackAlert hasCredentials={state.hasCredentials} />
)}
{showCreditsExhaustedAlert && (
<CreditsExhaustedAlert hasApiKeyFallback={hasApiKeyFallback} />
)}
<ApiKeySection
provider={provider}
credentials={available_credentials ?? EMPTY_CREDENTIALS}
selectedCredentialId={selectedCredentialId}
isActivating={isActivating}
onItemClick={activate}
onEdit={handleEdit}
onDelete={handleDelete}
onAdd={handleAdd}
/>
</div>
<AlertDialog
open={!!deleteCredentialId}
onOpenChange={(open) => {
if (!open)
closeConfirmDelete()
}}
>
<AlertDialogContent>
<div className="p-6 pb-0">
<AlertDialogTitle className="text-text-primary system-xl-semibold">
{t('modelProvider.confirmDelete', { ns: 'common' })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-1 text-text-secondary system-sm-regular" />
</div>
<AlertDialogActions>
<AlertDialogCancelButton disabled={doingAction}>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton disabled={doingAction} onClick={handleConfirmDelete}>
{t('operation.delete', { ns: 'common' })}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default memo(DropdownContent)

View File

@ -0,0 +1,211 @@
import type { ModelProvider } from '../../declarations'
import type { CredentialPanelState } from '../use-credential-panel-state'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { CustomConfigurationStatusEnum, PreferredProviderTypeEnum } from '../../declarations'
import ModelAuthDropdown from './index'
vi.mock('../../model-auth/hooks', () => ({
useAuth: () => ({
openConfirmDelete: vi.fn(),
closeConfirmDelete: vi.fn(),
doingAction: false,
handleConfirmDelete: vi.fn(),
deleteCredentialId: null,
handleOpenModal: vi.fn(),
}),
}))
vi.mock('./use-activate-credential', () => ({
useActivateCredential: () => ({
selectedCredentialId: undefined,
isActivating: false,
activate: vi.fn(),
}),
}))
vi.mock('../use-trial-credits', () => ({
useTrialCredits: () => ({ credits: 0, totalCredits: 10_000, isExhausted: true, isLoading: false }),
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'test',
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
...overrides,
} as unknown as ModelProvider)
const createState = (overrides: Partial<CredentialPanelState> = {}): CredentialPanelState => ({
variant: 'credits-active',
priority: 'credits',
supportsCredits: true,
showPrioritySwitcher: false,
hasCredentials: false,
isCreditsExhausted: false,
credentialName: undefined,
credits: 100,
...overrides,
})
describe('ModelAuthDropdown', () => {
const onChangePriority = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
describe('Button text', () => {
it('should show "Add API Key" when no credentials for credits-active', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: false, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" when has credentials for api-active', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Add API Key" for api-required-add variant', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" for api-required-configure variant', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Configure" for credits-active when has credentials', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ hasCredentials: true, variant: 'credits-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Add API Key" for credits-exhausted (no credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'credits-exhausted', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /addApiKey/ })).toBeInTheDocument()
})
it('should show "Configure" for api-unavailable (has credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-unavailable', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
it('should show "Configure" for api-fallback (has credentials)', () => {
render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-fallback', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
expect(screen.getByRole('button', { name: /config/i })).toBeInTheDocument()
})
})
describe('Button variant styling', () => {
it('should use primary for api-required-add', () => {
const { container } = render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-add', hasCredentials: false })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const button = container.querySelector('button')
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/primary/)
})
it('should use secondary-accent for api-required-configure', () => {
const { container } = render(
<ModelAuthDropdown
provider={createProvider()}
state={createState({ variant: 'api-required-configure', hasCredentials: true })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
const button = container.querySelector('button')
expect(button?.getAttribute('data-variant') ?? button?.className).toMatch(/accent/)
})
})
describe('Popover behavior', () => {
it('should open popover on button click and show dropdown content', async () => {
render(
<ModelAuthDropdown
provider={createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
available_credentials: [{ credential_id: 'c1', credential_name: 'Key 1' }],
current_credential_id: 'c1',
current_credential_name: 'Key 1',
},
})}
state={createState({ hasCredentials: true, variant: 'api-active' })}
isChangingPriority={false}
onChangePriority={onChangePriority}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /config/i }))
await waitFor(() => {
expect(screen.getByText('Key 1')).toBeInTheDocument()
})
})
})
})

View File

@ -0,0 +1,85 @@
import type { ModelProvider, PreferredProviderTypeEnum } from '../../declarations'
import type { CardVariant, CredentialPanelState } from '../use-credential-panel-state'
import { memo, useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import DropdownContent from './dropdown-content'
type ModelAuthDropdownProps = {
provider: ModelProvider
state: CredentialPanelState
isChangingPriority: boolean
onChangePriority: (key: PreferredProviderTypeEnum) => void
}
function getButtonConfig(variant: CardVariant, hasCredentials: boolean, t: (key: string, opts?: Record<string, string>) => string) {
if (variant === 'api-required-add') {
return {
text: t('modelProvider.auth.addApiKey', { ns: 'common' }),
variant: 'primary' as const,
}
}
if (variant === 'api-required-configure') {
return {
text: t('operation.config', { ns: 'common' }),
variant: 'secondary-accent' as const,
}
}
const text = hasCredentials
? t('operation.config', { ns: 'common' })
: t('modelProvider.auth.addApiKey', { ns: 'common' })
return { text, variant: 'secondary' as const }
}
function ModelAuthDropdown({
provider,
state,
isChangingPriority,
onChangePriority,
}: ModelAuthDropdownProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const handleClose = useCallback(() => setOpen(false), [])
const buttonConfig = getButtonConfig(state.variant, state.hasCredentials, t)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={(
<Button
className="flex grow"
size="small"
variant={buttonConfig.variant}
title={buttonConfig.text}
>
<span className="i-ri-equalizer-2-line mr-1 h-3.5 w-3.5 shrink-0" />
<span className="w-0 grow truncate text-left">
{buttonConfig.text}
</span>
</Button>
)}
/>
<PopoverContent placement="bottom-end">
<DropdownContent
provider={provider}
state={state}
isChangingPriority={isChangingPriority}
onChangePriority={onChangePriority}
onClose={handleClose}
/>
</PopoverContent>
</Popover>
)
}
export default memo(ModelAuthDropdown)

View File

@ -0,0 +1,66 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { PreferredProviderTypeEnum } from '../../declarations'
import UsagePrioritySection from './usage-priority-section'
describe('UsagePrioritySection', () => {
const onSelect = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering
describe('Rendering', () => {
it('should render title and both option buttons', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
expect(screen.getByText(/usagePriority/)).toBeInTheDocument()
expect(screen.getAllByRole('button')).toHaveLength(2)
})
})
// Selection state
describe('Selection state', () => {
it('should highlight AI credits option when value is credits', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0].className).toContain('border-components-option-card-option-selected-border')
expect(buttons[1].className).not.toContain('border-components-option-card-option-selected-border')
})
it('should highlight API key option when value is apiKey', () => {
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[0].className).not.toContain('border-components-option-card-option-selected-border')
expect(buttons[1].className).toContain('border-components-option-card-option-selected-border')
})
it('should highlight API key option when value is apiKeyOnly', () => {
render(<UsagePrioritySection value="apiKeyOnly" onSelect={onSelect} />)
const buttons = screen.getAllByRole('button')
expect(buttons[1].className).toContain('border-components-option-card-option-selected-border')
})
})
// User interactions
describe('User interactions', () => {
it('should call onSelect with system when clicking AI credits option', () => {
render(<UsagePrioritySection value="apiKey" onSelect={onSelect} />)
fireEvent.click(screen.getAllByRole('button')[0])
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.system)
})
it('should call onSelect with custom when clicking API key option', () => {
render(<UsagePrioritySection value="credits" onSelect={onSelect} />)
fireEvent.click(screen.getAllByRole('button')[1])
expect(onSelect).toHaveBeenCalledWith(PreferredProviderTypeEnum.custom)
})
})
})

View File

@ -0,0 +1,70 @@
import type { UsagePriority } from '../use-credential-panel-state'
import { useTranslation } from 'react-i18next'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import { PreferredProviderTypeEnum } from '../../declarations'
type UsagePrioritySectionProps = {
value: UsagePriority
disabled?: boolean
onSelect: (key: PreferredProviderTypeEnum) => void
}
const options = [
{ key: PreferredProviderTypeEnum.system, labelKey: 'modelProvider.card.aiCreditsOption' },
{ key: PreferredProviderTypeEnum.custom, labelKey: 'modelProvider.card.apiKeyOption' },
] as const
export default function UsagePrioritySection({ value, disabled, onSelect }: UsagePrioritySectionProps) {
const { t } = useTranslation()
const selectedKey = value === 'credits'
? PreferredProviderTypeEnum.system
: PreferredProviderTypeEnum.custom
return (
<div className="p-1">
<div className="flex items-center gap-1 rounded-lg p-1">
<div className="shrink-0 px-0.5 py-1">
<span className="i-ri-arrow-up-double-line block h-4 w-4 text-text-tertiary" />
</div>
<div className="flex min-w-0 flex-1 items-center gap-0.5 py-0.5">
<span className="truncate text-text-secondary system-sm-medium">
{t('modelProvider.card.usagePriority', { ns: 'common' })}
</span>
<Tooltip>
<TooltipTrigger
aria-label={t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
delay={0}
render={(
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{t('modelProvider.card.usagePriorityTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
</div>
<div className="flex shrink-0 items-center gap-1">
{options.map(option => (
<button
key={option.key}
type="button"
className={cn(
'shrink-0 whitespace-nowrap rounded-md px-2 py-1 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-button-primary-border disabled:opacity-50',
selectedKey === option.key
? 'border-[1.5px] border-components-option-card-option-selected-border bg-components-panel-bg text-text-primary shadow-xs system-xs-medium'
: 'border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary system-xs-regular hover:bg-components-option-card-option-bg-hover',
)}
disabled={disabled}
onClick={() => onSelect(option.key)}
>
{t(option.labelKey, { ns: 'common' })}
</button>
))}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,127 @@
import type { Credential, ModelProvider } from '../../declarations'
import { act, renderHook } from '@testing-library/react'
import Toast from '@/app/components/base/toast'
import { useActivateCredential } from './use-activate-credential'
const mockMutate = vi.fn()
const mockUpdateModelProviders = vi.fn()
const mockUpdateModelList = vi.fn()
let mockIsPending = false
vi.mock('@/service/use-models', () => ({
useActiveProviderCredential: () => ({
mutate: mockMutate,
isPending: mockIsPending,
}),
}))
vi.mock('../../hooks', () => ({
useUpdateModelProviders: () => mockUpdateModelProviders,
useUpdateModelList: () => mockUpdateModelList,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
supported_model_types: ['llm', 'text-embedding'],
custom_configuration: {
current_credential_id: 'cred-1',
available_credentials: [
{ credential_id: 'cred-1', credential_name: 'Primary' },
{ credential_id: 'cred-2', credential_name: 'Backup' },
],
},
...overrides,
} as unknown as ModelProvider)
const createCredential = (overrides: Partial<Credential> = {}): Credential => ({
credential_id: 'cred-2',
credential_name: 'Backup',
...overrides,
} as Credential)
describe('useActivateCredential', () => {
beforeEach(() => {
vi.clearAllMocks()
mockIsPending = false
vi.spyOn(Toast, 'notify').mockImplementation(() => ({ clear: vi.fn() }))
})
it('should expose the current credential id by default', () => {
const { result } = renderHook(() => useActivateCredential(createProvider()))
expect(result.current.selectedCredentialId).toBe('cred-1')
expect(result.current.isActivating).toBe(false)
})
it('should expose the pending mutation state', () => {
mockIsPending = true
const { result } = renderHook(() => useActivateCredential(createProvider()))
expect(result.current.isActivating).toBe(true)
})
it('should skip mutation when the selected credential is already active', () => {
const { result } = renderHook(() => useActivateCredential(createProvider()))
act(() => {
result.current.activate(createCredential({ credential_id: 'cred-1' }))
})
expect(mockMutate).not.toHaveBeenCalled()
expect(result.current.selectedCredentialId).toBe('cred-1')
})
it('should optimistically select the credential and refresh provider data on success', () => {
const { result } = renderHook(() => useActivateCredential(createProvider()))
act(() => {
result.current.activate(createCredential())
})
expect(result.current.selectedCredentialId).toBe('cred-2')
expect(mockMutate).toHaveBeenCalledWith(
{ credential_id: 'cred-2' },
expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}),
)
const [, callbacks] = mockMutate.mock.calls[0]
act(() => {
callbacks.onSuccess()
})
expect(Toast.notify).toHaveBeenCalledWith({
type: 'success',
message: 'common.api.actionSuccess',
})
expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1)
expect(mockUpdateModelList).toHaveBeenNthCalledWith(1, 'llm')
expect(mockUpdateModelList).toHaveBeenNthCalledWith(2, 'text-embedding')
})
it('should reset the optimistic selection and show an error toast when activation fails', () => {
const { result } = renderHook(() => useActivateCredential(createProvider()))
act(() => {
result.current.activate(createCredential())
})
expect(result.current.selectedCredentialId).toBe('cred-2')
const [, callbacks] = mockMutate.mock.calls[0]
act(() => {
callbacks.onError()
})
expect(result.current.selectedCredentialId).toBe('cred-1')
expect(Toast.notify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
})

View File

@ -0,0 +1,52 @@
import type { Credential, ModelProvider } from '../../declarations'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { useActiveProviderCredential } from '@/service/use-models'
import {
useUpdateModelList,
useUpdateModelProviders,
} from '../../hooks'
export function useActivateCredential(provider: ModelProvider) {
const { t } = useTranslation()
const updateModelProviders = useUpdateModelProviders()
const updateModelList = useUpdateModelList()
const { mutate, isPending } = useActiveProviderCredential(provider.provider)
const [optimisticId, setOptimisticId] = useState<string>()
const currentId = provider.custom_configuration.current_credential_id
const selectedCredentialId = optimisticId ?? currentId
const selectedIdRef = useRef(selectedCredentialId)
selectedIdRef.current = selectedCredentialId
const supportedModelTypesRef = useRef(provider.supported_model_types)
supportedModelTypesRef.current = provider.supported_model_types
const activate = useCallback((credential: Credential) => {
if (credential.credential_id === selectedIdRef.current)
return
setOptimisticId(credential.credential_id)
mutate(
{ credential_id: credential.credential_id },
{
onSuccess: () => {
Toast.notify({ type: 'success', message: t('api.actionSuccess', { ns: 'common' }) })
updateModelProviders()
supportedModelTypesRef.current.forEach(type => updateModelList(type))
},
onError: () => {
setOptimisticId(undefined)
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
},
)
}, [mutate, t, updateModelProviders, updateModelList])
return {
selectedCredentialId,
isActivating: isPending,
activate,
}
}

View File

@ -1,4 +1,5 @@
import type { ModelItem, ModelProvider } from '../declarations'
import { useQueryClient } from '@tanstack/react-query'
import { useDebounceFn } from 'ahooks'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
@ -9,6 +10,7 @@ import Tooltip from '@/app/components/base/tooltip'
import { Plan } from '@/app/components/billing/type'
import { useAppContext } from '@/context/app-context'
import { useProviderContext, useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { disableModel, enableModel } from '@/service/common'
import { cn } from '@/utils/classnames'
import { ModelStatusEnum } from '../declarations'
@ -30,16 +32,30 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
const { plan } = useProviderContext()
const modelLoadBalancingEnabled = useProviderContextSelector(state => state.modelLoadBalancingEnabled)
const { isCurrentWorkspaceManager } = useAppContext()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: provider.provider,
},
},
})
const toggleModelEnablingStatus = useCallback(async (enabled: boolean) => {
if (enabled)
await enableModel(`/workspaces/current/model-providers/${provider.provider}/models/enable`, { model: model.model, model_type: model.model_type })
else
await disableModel(`/workspaces/current/model-providers/${provider.provider}/models/disable`, { model: model.model, model_type: model.model_type })
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelList(model.model_type)
onChange?.(provider.provider)
}, [model.model, model.model_type, onChange, provider.provider, updateModelList])
}, [model.model, model.model_type, modelProviderModelListQueryKey, onChange, provider.provider, queryClient, updateModelList])
const { run: debouncedToggleModelEnablingStatus } = useDebounceFn(toggleModelEnablingStatus, { wait: 500 })
@ -58,7 +74,7 @@ const ModelListItem = ({ model, provider, isConfigurable, onChange, onModifyLoad
modelName={model.model}
/>
<ModelName
className="system-md-regular grow text-text-secondary"
className="grow text-text-secondary system-md-regular"
modelItem={model}
showModelType
showMode

View File

@ -0,0 +1,264 @@
import type { ReactNode } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { PluginSource } from '@/app/components/plugins/types'
import ProviderCardActions from './provider-card-actions'
const mockHandleUpdate = vi.fn()
const mockHandleUpdatedFromMarketplace = vi.fn()
const mockHandleDelete = vi.fn()
const mockGetMarketplaceUrl = vi.fn()
const mockShowPluginInfo = vi.fn()
const mockShowDeleteConfirm = vi.fn()
const mockSetTargetVersion = vi.fn()
const mockSetVersionPickerOpen = vi.fn()
let mockHeaderState = {
modalStates: {
showPluginInfo: mockShowPluginInfo,
showDeleteConfirm: mockShowDeleteConfirm,
},
versionPicker: {
isShow: false,
setIsShow: mockSetVersionPickerOpen,
setTargetVersion: mockSetTargetVersion,
targetVersion: undefined,
isDowngrade: false,
},
hasNewVersion: true,
isAutoUpgradeEnabled: false,
isFromMarketplace: true,
isFromGitHub: false,
}
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/hooks', () => ({
useDetailHeaderState: () => mockHeaderState,
usePluginOperations: () => ({
handleUpdate: mockHandleUpdate,
handleUpdatedFromMarketplace: mockHandleUpdatedFromMarketplace,
handleDelete: mockHandleDelete,
}),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/detail-header/components', () => ({
HeaderModals: ({ targetVersion, isDowngrade, isAutoUpgradeEnabled }: {
targetVersion?: { version: string, unique_identifier: string }
isDowngrade: boolean
isAutoUpgradeEnabled: boolean
}) => (
<div
data-testid="header-modals"
data-target-version={targetVersion?.version ?? ''}
data-is-downgrade={String(isDowngrade)}
data-auto-upgrade={String(isAutoUpgradeEnabled)}
/>
),
}))
vi.mock('@/app/components/plugins/plugin-detail-panel/operation-dropdown', () => ({
default: ({ detailUrl, onInfo, onCheckVersion, onRemove }: {
detailUrl: string
onInfo: () => void
onCheckVersion: () => void
onRemove: () => void
}) => (
<div data-testid="operation-dropdown" data-detail-url={detailUrl}>
<button type="button" onClick={onInfo}>info</button>
<button type="button" onClick={onCheckVersion}>check version</button>
<button type="button" onClick={onRemove}>remove</button>
</div>
),
}))
vi.mock('@/app/components/plugins/update-plugin/plugin-version-picker', () => ({
default: ({ trigger, onSelect, disabled }: {
trigger: ReactNode
onSelect: (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => void
disabled?: boolean
}) => (
<div data-testid="plugin-version-picker" data-disabled={String(Boolean(disabled))}>
{trigger}
<button
type="button"
onClick={() => onSelect({ version: '2.0.0', unique_identifier: 'plugin@2.0.0', isDowngrade: true })}
>
select version
</button>
</div>
),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/hooks/use-theme', () => ({
default: () => ({ theme: 'light' }),
}))
vi.mock('@/utils/var', () => ({
getMarketplaceUrl: (...args: unknown[]) => mockGetMarketplaceUrl(...args),
}))
const createDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
plugin_id: 'plugin-id',
plugin_unique_identifier: 'plugin-id@1.0.0',
name: 'provider-plugin',
source: PluginSource.marketplace,
version: '1.0.0',
latest_version: '2.0.0',
latest_unique_identifier: 'plugin-id@2.0.0',
declaration: {
author: 'langgenius',
name: 'provider-plugin',
},
meta: undefined,
...overrides,
} as PluginDetail)
describe('ProviderCardActions', () => {
beforeEach(() => {
vi.clearAllMocks()
mockHeaderState = {
modalStates: {
showPluginInfo: mockShowPluginInfo,
showDeleteConfirm: mockShowDeleteConfirm,
},
versionPicker: {
isShow: false,
setIsShow: mockSetVersionPickerOpen,
setTargetVersion: mockSetTargetVersion,
targetVersion: undefined,
isDowngrade: false,
},
hasNewVersion: true,
isAutoUpgradeEnabled: false,
isFromMarketplace: true,
isFromGitHub: false,
}
mockGetMarketplaceUrl.mockReturnValue('https://marketplace.example.com/plugins/langgenius/provider-plugin')
})
it('should render version controls for marketplace plugins and handle manual version selection', () => {
render(<ProviderCardActions detail={createDetail()} />)
expect(screen.getByText('1.0.0')).toBeInTheDocument()
expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'false')
fireEvent.click(screen.getByRole('button', { name: 'select version' }))
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '2.0.0',
unique_identifier: 'plugin@2.0.0',
isDowngrade: true,
})
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
})
it('should trigger the latest marketplace update when clicking the update button', () => {
render(<ProviderCardActions detail={createDetail()} />)
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
expect(mockSetTargetVersion).toHaveBeenCalledWith({
version: '2.0.0',
unique_identifier: 'plugin-id@2.0.0',
})
expect(mockHandleUpdate).toHaveBeenCalledWith()
})
it('should pass the marketplace detail url to the operation dropdown', () => {
render(<ProviderCardActions detail={createDetail()} />)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins/langgenius/provider-plugin', {
language: 'en-US',
theme: 'light',
})
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute(
'data-detail-url',
'https://marketplace.example.com/plugins/langgenius/provider-plugin',
)
})
it('should relay operation dropdown actions', () => {
render(<ProviderCardActions detail={createDetail()} />)
fireEvent.click(screen.getByRole('button', { name: 'info' }))
fireEvent.click(screen.getByRole('button', { name: 'check version' }))
fireEvent.click(screen.getByRole('button', { name: 'remove' }))
expect(mockShowPluginInfo).toHaveBeenCalledTimes(1)
expect(mockHandleUpdate).toHaveBeenCalledTimes(1)
expect(mockShowDeleteConfirm).toHaveBeenCalledTimes(1)
})
it('should use the GitHub repo url and skip marketplace version preselection for GitHub plugins', () => {
mockHeaderState = {
...mockHeaderState,
hasNewVersion: false,
isFromMarketplace: false,
isFromGitHub: true,
}
render(
<ProviderCardActions detail={createDetail({
source: PluginSource.github,
meta: {
repo: 'langgenius/provider-plugin',
version: '1.0.0',
package: 'provider-plugin.difypkg',
},
})}
/>,
)
expect(screen.getByTestId('plugin-version-picker')).toHaveAttribute('data-disabled', 'true')
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute(
'data-detail-url',
'https://github.com/langgenius/provider-plugin',
)
fireEvent.click(screen.getByRole('button', { name: 'plugin.detailPanel.operation.update' }))
expect(mockSetTargetVersion).not.toHaveBeenCalled()
expect(mockHandleUpdate).toHaveBeenCalledWith()
})
it('should fall back to the detail name when declaration metadata is missing', () => {
render(
<ProviderCardActions
detail={createDetail({
declaration: undefined,
})}
/>,
)
expect(mockGetMarketplaceUrl).toHaveBeenCalledWith('/plugins//provider-plugin', {
language: 'en-US',
theme: 'light',
})
})
it('should leave the detail url empty when a GitHub plugin has no repo or the source is unsupported', () => {
const { rerender } = render(
<ProviderCardActions
detail={createDetail({
source: PluginSource.github,
meta: undefined,
})}
/>,
)
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
rerender(
<ProviderCardActions
detail={createDetail({
source: PluginSource.local,
})}
/>,
)
expect(screen.getByTestId('operation-dropdown')).toHaveAttribute('data-detail-url', '')
})
})

View File

@ -0,0 +1,149 @@
import type { FC } from 'react'
import type { PluginDetail } from '@/app/components/plugins/types'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import Button from '@/app/components/base/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { HeaderModals } from '@/app/components/plugins/plugin-detail-panel/detail-header/components'
import { useDetailHeaderState, usePluginOperations } from '@/app/components/plugins/plugin-detail-panel/detail-header/hooks'
import OperationDropdown from '@/app/components/plugins/plugin-detail-panel/operation-dropdown'
import { PluginSource } from '@/app/components/plugins/types'
import PluginVersionPicker from '@/app/components/plugins/update-plugin/plugin-version-picker'
import { useLocale } from '@/context/i18n'
import useTheme from '@/hooks/use-theme'
import { cn } from '@/utils/classnames'
import { getMarketplaceUrl } from '@/utils/var'
type Props = {
detail: PluginDetail
onUpdate?: () => void
}
const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
const { t } = useTranslation()
const { theme } = useTheme()
const locale = useLocale()
const { source, version, latest_version, latest_unique_identifier, meta } = detail
const author = detail.declaration?.author ?? ''
const name = detail.declaration?.name ?? detail.name
const {
modalStates,
versionPicker,
hasNewVersion,
isAutoUpgradeEnabled,
isFromMarketplace,
isFromGitHub,
} = useDetailHeaderState(detail)
const {
handleUpdate,
handleUpdatedFromMarketplace,
handleDelete,
} = usePluginOperations({
detail,
modalStates,
versionPicker,
isFromMarketplace,
onUpdate,
})
const handleVersionSelect = (state: { version: string, unique_identifier: string, isDowngrade?: boolean }) => {
versionPicker.setTargetVersion(state)
handleUpdate(state.isDowngrade)
}
const handleTriggerLatestUpdate = () => {
if (isFromMarketplace) {
versionPicker.setTargetVersion({
version: latest_version,
unique_identifier: latest_unique_identifier,
})
}
handleUpdate()
}
const detailUrl = useMemo(() => {
if (source === PluginSource.github)
return meta?.repo ? `https://github.com/${meta.repo}` : ''
if (source === PluginSource.marketplace)
return getMarketplaceUrl(`/plugins/${author}/${name}`, { language: locale, theme })
return ''
}, [source, meta?.repo, author, name, locale, theme])
return (
<>
{!!version && (
<PluginVersionPicker
disabled={!isFromMarketplace}
isShow={versionPicker.isShow}
onShowChange={versionPicker.setIsShow}
pluginID={detail.plugin_id}
currentVersion={version}
onSelect={handleVersionSelect}
sideOffset={4}
alignOffset={0}
trigger={(
<Badge
className={cn(
isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
)}
uppercase={false}
text={(
<>
<span>{version}</span>
{isFromMarketplace && <span className="i-ri-arrow-left-right-line ml-1 h-3 w-3" />}
</>
)}
hasRedCornerMark={hasNewVersion}
/>
)}
/>
)}
{(hasNewVersion || isFromGitHub) && (
<Tooltip>
<TooltipTrigger
delay={300}
render={(
<Button
variant="secondary-accent"
size="small"
className="!h-5"
onClick={handleTriggerLatestUpdate}
>
{t('detailPanel.operation.update', { ns: 'plugin' })}
</Button>
)}
/>
<TooltipContent>
{t('detailPanel.operation.updateTooltip', { ns: 'plugin' })}
</TooltipContent>
</Tooltip>
)}
<OperationDropdown
source={source}
onInfo={modalStates.showPluginInfo}
onCheckVersion={() => handleUpdate()}
onRemove={modalStates.showDeleteConfirm}
detailUrl={detailUrl}
placement="bottom-start"
/>
<HeaderModals
detail={detail}
modalStates={modalStates}
targetVersion={versionPicker.targetVersion}
isDowngrade={versionPicker.isDowngrade}
isAutoUpgradeEnabled={isAutoUpgradeEnabled}
onUpdatedFromMarketplace={handleUpdatedFromMarketplace}
onDelete={handleDelete}
/>
</>
)
}
export default ProviderCardActions

View File

@ -0,0 +1,8 @@
.gridBg {
background-size: 4px 4px;
background-image:
linear-gradient(to right, var(--color-divider-subtle) 0.5px, transparent 0.5px),
linear-gradient(to bottom, var(--color-divider-subtle) 0.5px, transparent 0.5px);
-webkit-mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
mask-image: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6), transparent 70%);
}

View File

@ -1,63 +1,39 @@
import type { ComponentType, FC } from 'react'
import type { FC } from 'react'
import type { ModelProvider } from '../declarations'
import type { Plugin } from '@/app/components/plugins/types'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AnthropicShortLight, Deepseek, Gemini, Grok, OpenaiSmall, Tongyi } from '@/app/components/base/icons/src/public/llm'
import Loading from '@/app/components/base/loading'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
import { useAppContext } from '@/context/app-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import useTimestamp from '@/hooks/use-timestamp'
import { ModelProviderQuotaGetPaid } from '@/types/model-provider'
import { cn } from '@/utils/classnames'
import { formatNumber } from '@/utils/format'
import { PreferredProviderTypeEnum } from '../declarations'
import { useMarketplaceAllPlugins } from '../hooks'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap } from '../utils'
import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
import styles from './quota-panel.module.css'
import { useTrialCredits } from './use-trial-credits'
// Icon map for each provider - single source of truth for provider icons
const providerIconMap: Record<ModelProviderQuotaGetPaid, ComponentType<{ className?: string }>> = {
[ModelProviderQuotaGetPaid.OPENAI]: OpenaiSmall,
[ModelProviderQuotaGetPaid.ANTHROPIC]: AnthropicShortLight,
[ModelProviderQuotaGetPaid.GEMINI]: Gemini,
[ModelProviderQuotaGetPaid.X]: Grok,
[ModelProviderQuotaGetPaid.DEEPSEEK]: Deepseek,
[ModelProviderQuotaGetPaid.TONGYI]: Tongyi,
}
// Derive allProviders from the shared constant
const allProviders = MODEL_PROVIDER_QUOTA_GET_PAID.map(key => ({
key,
Icon: providerIconMap[key],
}))
// Map provider key to plugin ID
// provider key format: langgenius/provider/model, plugin ID format: langgenius/provider
const providerKeyToPluginId: Record<ModelProviderQuotaGetPaid, string> = {
[ModelProviderQuotaGetPaid.OPENAI]: 'langgenius/openai',
[ModelProviderQuotaGetPaid.ANTHROPIC]: 'langgenius/anthropic',
[ModelProviderQuotaGetPaid.GEMINI]: 'langgenius/gemini',
[ModelProviderQuotaGetPaid.X]: 'langgenius/x',
[ModelProviderQuotaGetPaid.DEEPSEEK]: 'langgenius/deepseek',
[ModelProviderQuotaGetPaid.TONGYI]: 'langgenius/tongyi',
}
type QuotaPanelProps = {
providers: ModelProvider[]
isLoading?: boolean
}
const QuotaPanel: FC<QuotaPanelProps> = ({
providers,
isLoading = false,
}) => {
const { t } = useTranslation()
const { currentWorkspace } = useAppContext()
const { trial_models } = useGlobalPublicStore(s => s.systemFeatures)
const credits = Math.max((currentWorkspace.trial_credits - currentWorkspace.trial_credits_used) || 0, 0)
const { credits, isExhausted, isLoading, nextCreditResetDate } = useTrialCredits()
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models ?? []
const providerMap = useMemo(() => new Map(
providers.map(p => [p.provider, p.preferred_provider_type]),
), [providers])
@ -98,6 +74,11 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
}, [providers, isShowInstallModal, hideInstallFromMarketplace])
const tipText = t('modelProvider.card.tip', {
ns: 'common',
modelNames: trialModels.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', '),
})
if (isLoading) {
return (
<div className="my-2 flex min-h-[72px] items-center justify-center rounded-xl border-[0.5px] border-components-panel-border bg-third-party-model-bg-default shadow-xs">
@ -107,59 +88,88 @@ const QuotaPanel: FC<QuotaPanelProps> = ({
}
return (
<div className={cn('my-2 min-w-[72px] shrink-0 rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs', credits <= 0 ? 'border-state-destructive-border hover:bg-state-destructive-hover' : 'border-components-panel-border bg-third-party-model-bg-default')}>
<div className="system-xs-medium-uppercase mb-2 flex h-4 items-center text-text-tertiary">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip popupContent={t('modelProvider.card.tip', { ns: 'common', modelNames: trial_models.map(key => modelNameMap[key as keyof typeof modelNameMap]).filter(Boolean).join(', ') })} />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
<span className="system-md-semibold-uppercase mr-0.5 text-text-secondary">{formatNumber(credits)}</span>
<span>{t('modelProvider.credits', { ns: 'common' })}</span>
{currentWorkspace.next_credit_reset_date
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(currentWorkspace.next_credit_reset_date, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
: null}
<div className={cn(
'relative my-2 min-w-[72px] shrink-0 overflow-hidden rounded-xl border-[0.5px] pb-2.5 pl-4 pr-2.5 pt-3 shadow-xs',
isExhausted
? 'border-state-destructive-border hover:bg-state-destructive-hover'
: 'border-components-panel-border bg-third-party-model-bg-default',
)}
>
<div className={cn('pointer-events-none absolute inset-0', styles.gridBg)} />
<div className="relative">
<div className="mb-2 flex h-4 items-center text-text-tertiary system-xs-medium-uppercase">
{t('modelProvider.quota', { ns: 'common' })}
<Tooltip>
<TooltipTrigger
aria-label={tipText}
delay={0}
render={(
<span className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{tipText}
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trial_models.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0 // means the provider is configured API key
const getTooltipKey = () => {
// if provider type is not set, it means the provider is not installed
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelSupported'
}
return (
<Tooltip
key={key}
popupContent={t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })}
>
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
</Tooltip>
)
})}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-xs text-text-tertiary">
{credits > 0
? <span className="mr-0.5 text-text-secondary system-xl-semibold">{formatNumber(credits)}</span>
: <span className="mr-0.5 text-text-destructive system-xl-semibold">{t('modelProvider.card.quotaExhausted', { ns: 'common' })}</span>}
{nextCreditResetDate
? (
<>
<span>·</span>
<span>
{t('modelProvider.resetDate', {
ns: 'common',
date: formatTime(nextCreditResetDate, t('dateFormat', { ns: 'appLog' })),
interpolation: { escapeValue: false },
})}
</span>
</>
)
: null}
</div>
<div className="flex items-center gap-1">
{allProviders.filter(({ key }) => trialModels.includes(key)).map(({ key, Icon }) => {
const providerType = providerMap.get(key)
const isConfigured = (installedProvidersMap.get(key)?.length ?? 0) > 0
const getTooltipKey = () => {
if (!providerType)
return 'modelProvider.card.modelNotSupported'
if (isConfigured && providerType === PreferredProviderTypeEnum.custom)
return 'modelProvider.card.modelAPI'
return 'modelProvider.card.modelSupported'
}
const tooltipText = t(getTooltipKey(), { modelName: modelNameMap[key], ns: 'common' })
return (
<Tooltip key={key}>
<TooltipTrigger
aria-label={tooltipText}
delay={0}
render={(
<div
className={cn('relative h-6 w-6', !providerType && 'cursor-pointer hover:opacity-80')}
onClick={() => handleIconClick(key)}
>
<Icon className="h-6 w-6 rounded-lg" />
{!providerType && (
<div className="absolute inset-0 rounded-lg border-[0.5px] border-components-panel-border-subtle bg-background-default-dodge opacity-30" />
)}
</div>
)}
/>
<TooltipContent>
{tooltipText}
</TooltipContent>
</Tooltip>
)
})}
</div>
</div>
</div>
{isShowInstallModal && selectedPlugin && (

View File

@ -0,0 +1,89 @@
import { render, screen } from '@testing-library/react'
import SystemQuotaCard from './system-quota-card'
describe('SystemQuotaCard', () => {
// Renders container with children
describe('Rendering', () => {
it('should render children', () => {
render(
<SystemQuotaCard>
<span>content</span>
</SystemQuotaCard>,
)
expect(screen.getByText('content')).toBeInTheDocument()
})
it('should apply default variant styles', () => {
const { container } = render(
<SystemQuotaCard>
<span>test</span>
</SystemQuotaCard>,
)
const card = container.firstElementChild!
expect(card.className).toContain('bg-white')
})
it('should apply destructive variant styles', () => {
const { container } = render(
<SystemQuotaCard variant="destructive">
<span>test</span>
</SystemQuotaCard>,
)
const card = container.firstElementChild!
expect(card.className).toContain('border-state-destructive-border')
})
})
// Label sub-component
describe('Label', () => {
it('should apply default variant text color when no className provided', () => {
render(
<SystemQuotaCard>
<SystemQuotaCard.Label>Default label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
expect(screen.getByText('Default label').className).toContain('text-text-secondary')
})
it('should apply destructive variant text color when no className provided', () => {
render(
<SystemQuotaCard variant="destructive">
<SystemQuotaCard.Label>Error label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
expect(screen.getByText('Error label').className).toContain('text-text-destructive')
})
it('should override variant color with custom className', () => {
render(
<SystemQuotaCard variant="destructive">
<SystemQuotaCard.Label className="gap-1">Custom label</SystemQuotaCard.Label>
</SystemQuotaCard>,
)
const label = screen.getByText('Custom label')
expect(label.className).toContain('gap-1')
expect(label.className).not.toContain('text-text-destructive')
})
})
// Actions sub-component
describe('Actions', () => {
it('should render action children', () => {
render(
<SystemQuotaCard>
<SystemQuotaCard.Actions>
<button>Click me</button>
</SystemQuotaCard.Actions>
</SystemQuotaCard>,
)
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,67 @@
import type { ReactNode } from 'react'
import { createContext, useContext } from 'react'
import { cn } from '@/utils/classnames'
import styles from './quota-panel.module.css'
type Variant = 'default' | 'destructive'
const VariantContext = createContext<Variant>('default')
const containerVariants: Record<Variant, string> = {
default: 'border-components-panel-border bg-white/[0.18]',
destructive: 'border-state-destructive-border bg-state-destructive-hover',
}
const labelVariants: Record<Variant, string> = {
default: 'text-text-secondary',
destructive: 'text-text-destructive',
}
type SystemQuotaCardProps = {
variant?: Variant
children: ReactNode
}
const SystemQuotaCard = ({
variant = 'default',
children,
}: SystemQuotaCardProps) => {
return (
<VariantContext.Provider value={variant}>
<div className={cn(
'relative isolate ml-1 flex w-[128px] shrink-0 flex-col justify-between rounded-lg border-[0.5px] p-1 shadow-xs',
containerVariants[variant],
)}
>
<div className={cn('pointer-events-none absolute inset-0 rounded-[7px]', styles.gridBg)} />
{children}
</div>
</VariantContext.Provider>
)
}
const Label = ({ children, className }: { children: ReactNode, className?: string }) => {
const variant = useContext(VariantContext)
return (
<div className={cn(
'relative z-[1] flex items-center gap-1 truncate px-1.5 pt-1 system-xs-medium',
className ?? labelVariants[variant],
)}
>
{children}
</div>
)
}
const Actions = ({ children }: { children: ReactNode }) => {
return (
<div className="relative z-[1] flex items-center gap-0.5">
{children}
</div>
)
}
SystemQuotaCard.Label = Label
SystemQuotaCard.Actions = Actions
export default SystemQuotaCard

View File

@ -0,0 +1,208 @@
import type { ReactNode } from 'react'
import type { ModelProvider } from '../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import * as React from 'react'
import { ConfigurationMethodEnum, ModelTypeEnum, PreferredProviderTypeEnum } from '../declarations'
import { useChangeProviderPriority } from './use-change-provider-priority'
const mockUpdateModelList = vi.fn()
const mockUpdateModelProviders = vi.fn()
const mockNotify = vi.fn()
const mockQueryKey = vi.fn(({ input }: { input: { params: { provider: string } } }) => ['model-providers', 'models', input.params.provider])
const mockChangePreferredProviderType = vi.fn()
const mockMutationOptions = vi.fn((options: Record<string, unknown>) => ({
mutationFn: (variables: unknown) => mockChangePreferredProviderType(variables),
...options,
}))
vi.mock('@/app/components/base/toast', () => ({
default: {
notify: (...args: unknown[]) => mockNotify(...args),
},
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryKey: (options: { input: { params: { provider: string } } }) => mockQueryKey(options),
},
changePreferredProviderType: {
mutationOptions: (options: Record<string, unknown>) => mockMutationOptions(options),
},
},
},
}))
vi.mock('../hooks', () => ({
useUpdateModelList: () => mockUpdateModelList,
useUpdateModelProviders: () => mockUpdateModelProviders,
}))
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
configurate_methods: [
ConfigurationMethodEnum.customizableModel,
ConfigurationMethodEnum.predefinedModel,
],
supported_model_types: [ModelTypeEnum.textGeneration, ModelTypeEnum.textEmbedding],
label: { en_US: 'OpenAI' },
icon_small: { en_US: 'https://example.com/icon.png' },
provider_credential_schema: { credential_form_schemas: [] },
model_credential_schema: {
model: {
label: { en_US: 'Model' },
placeholder: { en_US: 'Select model' },
},
credential_form_schemas: [],
},
...overrides,
} as ModelProvider)
const createTestQueryClient = () => new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
})
const createWrapper = (queryClient: QueryClient) => {
return ({ children }: { children: ReactNode }) => (
React.createElement(QueryClientProvider, { client: queryClient }, children)
)
}
describe('useChangeProviderPriority', () => {
beforeEach(() => {
vi.clearAllMocks()
mockChangePreferredProviderType.mockResolvedValue(undefined)
})
describe('when changing provider priority', () => {
it('should submit the selected preferred provider type for the current provider', async () => {
const queryClient = createTestQueryClient()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined)
const provider = createProvider()
const { result } = renderHook(() => useChangeProviderPriority(provider), {
wrapper: createWrapper(queryClient),
})
act(() => {
result.current.handleChangePriority(PreferredProviderTypeEnum.custom)
})
await waitFor(() => {
expect(mockChangePreferredProviderType).toHaveBeenCalledWith({
params: { provider: 'langgenius/openai/openai' },
body: { preferred_provider_type: PreferredProviderTypeEnum.custom },
})
})
expect(mockQueryKey).toHaveBeenCalledWith({
input: {
params: {
provider: 'langgenius/openai/openai',
},
},
})
expect(mockMutationOptions).toHaveBeenCalled()
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-providers', 'models', 'langgenius/openai/openai'],
exact: true,
refetchType: 'none',
})
expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1)
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
expect(mockUpdateModelList).toHaveBeenNthCalledWith(1, ModelTypeEnum.textGeneration)
expect(mockUpdateModelList).toHaveBeenNthCalledWith(2, ModelTypeEnum.textEmbedding)
expect(mockNotify).toHaveBeenCalledWith({
type: 'success',
message: 'common.actionMsg.modifiedSuccessfully',
})
expect(result.current.isChangingPriority).toBe(false)
})
it('should tolerate an undefined provider and still submit a request without refreshing model lists', async () => {
const queryClient = createTestQueryClient()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined)
const { result } = renderHook(() => useChangeProviderPriority(undefined), {
wrapper: createWrapper(queryClient),
})
act(() => {
result.current.handleChangePriority(PreferredProviderTypeEnum.system)
})
await waitFor(() => {
expect(mockChangePreferredProviderType).toHaveBeenCalledWith({
params: { provider: '' },
body: { preferred_provider_type: PreferredProviderTypeEnum.system },
})
})
expect(invalidateQueries).toHaveBeenCalledWith({
queryKey: ['model-providers', 'models', ''],
exact: true,
refetchType: 'none',
})
expect(mockUpdateModelProviders).toHaveBeenCalledTimes(1)
expect(mockUpdateModelList).not.toHaveBeenCalled()
})
})
describe('when the mutation is not successful immediately', () => {
it('should show an error toast when the mutation fails', async () => {
const queryClient = createTestQueryClient()
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries').mockResolvedValue(undefined)
mockChangePreferredProviderType.mockRejectedValueOnce(new Error('network error'))
const { result } = renderHook(() => useChangeProviderPriority(createProvider()), {
wrapper: createWrapper(queryClient),
})
act(() => {
result.current.handleChangePriority(PreferredProviderTypeEnum.custom)
})
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'common.actionMsg.modifiedUnsuccessfully',
})
})
expect(invalidateQueries).not.toHaveBeenCalled()
expect(mockUpdateModelProviders).not.toHaveBeenCalled()
expect(mockUpdateModelList).not.toHaveBeenCalled()
expect(result.current.isChangingPriority).toBe(false)
})
it('should expose the pending mutation state while the request is in flight', async () => {
let resolveMutation: (() => void) | undefined
mockChangePreferredProviderType.mockImplementationOnce(() => new Promise<void>((resolve) => {
resolveMutation = resolve
}))
const queryClient = createTestQueryClient()
const { result } = renderHook(() => useChangeProviderPriority(createProvider()), {
wrapper: createWrapper(queryClient),
})
act(() => {
result.current.handleChangePriority(PreferredProviderTypeEnum.custom)
})
await waitFor(() => {
expect(result.current.isChangingPriority).toBe(true)
})
await act(async () => {
resolveMutation?.()
})
await waitFor(() => {
expect(result.current.isChangingPriority).toBe(false)
})
})
})
})

View File

@ -0,0 +1,53 @@
import type { ModelProvider, PreferredProviderTypeEnum } from '../declarations'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import { consoleQuery } from '@/service/client'
import { ConfigurationMethodEnum } from '../declarations'
import { useUpdateModelList, useUpdateModelProviders } from '../hooks'
export function useChangeProviderPriority(provider: ModelProvider | undefined) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const updateModelList = useUpdateModelList()
const updateModelProviders = useUpdateModelProviders()
const providerName = provider?.provider ?? ''
const modelProviderModelListQueryKey = consoleQuery.modelProviders.models.queryKey({
input: {
params: {
provider: providerName,
},
},
})
const { mutate: changePriority, isPending: isChangingPriority } = useMutation(
consoleQuery.modelProviders.changePreferredProviderType.mutationOptions({
onSuccess: () => {
Toast.notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) })
queryClient.invalidateQueries({
queryKey: modelProviderModelListQueryKey,
exact: true,
refetchType: 'none',
})
updateModelProviders()
provider?.configurate_methods.forEach((method) => {
if (method === ConfigurationMethodEnum.predefinedModel)
provider?.supported_model_types.forEach(modelType => updateModelList(modelType))
})
},
onError: () => {
Toast.notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
},
}),
)
const handleChangePriority = (key: PreferredProviderTypeEnum) => {
changePriority({
params: { provider: providerName },
body: { preferred_provider_type: key },
})
}
return { isChangingPriority, handleChangePriority }
}

View File

@ -0,0 +1,295 @@
import type { ModelProvider } from '../declarations'
import { renderHook } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
PreferredProviderTypeEnum,
} from '../declarations'
import { isDestructiveVariant, useCredentialPanelState } from './use-credential-panel-state'
const mockTrialCredits = { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false, nextCreditResetDate: undefined }
const mockTrialModels = ['langgenius/openai/openai', 'langgenius/anthropic/anthropic']
vi.mock('./use-trial-credits', () => ({
useTrialCredits: () => mockTrialCredits,
}))
vi.mock('@/context/global-public-context', () => ({
useSystemFeaturesQuery: () => ({ data: { trial_models: mockTrialModels } }),
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const createProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
provider_credential_schema: { credential_form_schemas: [] },
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: 'My Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
system_configuration: { enabled: true, current_quota_type: 'trial', quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
supported_model_types: ['llm'],
...overrides,
} as unknown as ModelProvider)
describe('useCredentialPanelState', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.assign(mockTrialCredits, { credits: 100, totalCredits: 10_000, isExhausted: false, isLoading: false })
})
// Credits priority variants
describe('Credits priority variants', () => {
it('should return credits-active when credits available', () => {
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.variant).toBe('credits-active')
expect(result.current.priority).toBe('credits')
expect(result.current.supportsCredits).toBe(true)
})
it('should return api-fallback when credits exhausted but API key authorized', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.variant).toBe('api-fallback')
})
it('should return no-usage when credits exhausted and API key unauthorized', () => {
mockTrialCredits.isExhausted = true
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('no-usage')
})
it('should return credits-exhausted when credits exhausted and no credentials', () => {
mockTrialCredits.isExhausted = true
const provider = createProvider({
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-exhausted')
})
})
// API key priority variants
describe('API key priority variants', () => {
it('should return api-active when API key authorized', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-active')
expect(result.current.priority).toBe('apiKey')
})
it('should return credits-fallback when API key unauthorized and credits available', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'My Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-fallback')
})
it('should return credits-fallback when no credentials and credits available', () => {
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('credits-fallback')
})
it('should return no-usage when no credentials and credits exhausted', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.noConfigure,
available_credentials: [],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('no-usage')
})
it('should return api-unavailable when credential with name unauthorized and credits exhausted', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: undefined,
current_credential_name: 'Bad Key',
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-unavailable')
})
it('should return api-required-configure when credentials exist but the current credential is incomplete', () => {
mockTrialCredits.isExhausted = true
mockTrialCredits.credits = 0
const provider = createProvider({
preferred_provider_type: PreferredProviderTypeEnum.custom,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
current_credential_id: 'cred-1',
current_credential_name: undefined,
available_credentials: [{ credential_id: 'cred-1', credential_name: 'Bad Key' }],
},
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.variant).toBe('api-required-configure')
})
})
// apiKeyOnly priority
describe('apiKeyOnly priority (non-cloud / system disabled / not in trial_models)', () => {
it('should return apiKeyOnly when system config disabled', () => {
const provider = createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
})
it('should return apiKeyOnly when provider not in trial_models even if system enabled', () => {
const provider = createProvider({
provider: 'langgenius/minimax/minimax',
system_configuration: { enabled: true, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
preferred_provider_type: PreferredProviderTypeEnum.system,
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
expect(result.current.showPrioritySwitcher).toBe(false)
})
})
// Undefined provider
describe('Undefined provider', () => {
it('should return safe defaults when provider is undefined', () => {
const { result } = renderHook(() => useCredentialPanelState(undefined))
expect(result.current.priority).toBe('apiKeyOnly')
expect(result.current.supportsCredits).toBe(false)
expect(result.current.hasCredentials).toBe(false)
expect(result.current.credentialName).toBeUndefined()
})
})
// Derived metadata
describe('Derived metadata', () => {
it('should show priority switcher when credits supported and custom config active', () => {
const provider = createProvider()
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(true)
})
it('should hide priority switcher when system config disabled', () => {
const provider = createProvider({
system_configuration: { enabled: false, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(false)
})
it('should hide priority switcher when provider not in trial_models', () => {
const provider = createProvider({
provider: 'langgenius/zhipuai/zhipuai',
system_configuration: { enabled: true, current_quota_type: CurrentSystemQuotaTypeEnum.trial, quota_configurations: [] },
})
const { result } = renderHook(() => useCredentialPanelState(provider))
expect(result.current.showPrioritySwitcher).toBe(false)
})
it('should expose credential name from provider', () => {
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.credentialName).toBe('My Key')
})
it('should expose credits amount', () => {
mockTrialCredits.credits = 500
const { result } = renderHook(() => useCredentialPanelState(createProvider()))
expect(result.current.credits).toBe(500)
})
})
})
describe('isDestructiveVariant', () => {
it.each([
['credits-exhausted', true],
['no-usage', true],
['api-unavailable', true],
['credits-active', false],
['api-fallback', false],
['api-active', false],
['api-required-add', false],
['api-required-configure', false],
] as const)('should return %s for variant %s', (variant, expected) => {
expect(isDestructiveVariant(variant)).toBe(expected)
})
})

View File

@ -0,0 +1,109 @@
import type { ModelProvider } from '../declarations'
import { useCredentialStatus } from '@/app/components/header/account-setting/model-provider-page/model-auth/hooks'
import { useSystemFeaturesQuery } from '@/context/global-public-context'
import {
PreferredProviderTypeEnum,
} from '../declarations'
import { providerSupportsCredits } from '../supports-credits'
import { useTrialCredits } from './use-trial-credits'
export type UsagePriority = 'credits' | 'apiKey' | 'apiKeyOnly'
export type CardVariant
= | 'credits-active'
| 'credits-fallback'
| 'credits-exhausted'
| 'no-usage'
| 'api-fallback'
| 'api-active'
| 'api-required-add'
| 'api-required-configure'
| 'api-unavailable'
export type CredentialPanelState = {
variant: CardVariant
priority: UsagePriority
supportsCredits: boolean
showPrioritySwitcher: boolean
hasCredentials: boolean
isCreditsExhausted: boolean
credentialName: string | undefined
credits: number
}
const DESTRUCTIVE_VARIANTS = new Set<CardVariant>([
'credits-exhausted',
'no-usage',
'api-unavailable',
])
export const isDestructiveVariant = (variant: CardVariant) =>
DESTRUCTIVE_VARIANTS.has(variant)
function deriveVariant(
priority: UsagePriority,
isExhausted: boolean,
hasCredential: boolean,
authorized: boolean | undefined,
credentialName: string | undefined,
): CardVariant {
if (priority === 'credits') {
if (!isExhausted)
return 'credits-active'
if (hasCredential && authorized)
return 'api-fallback'
if (hasCredential && !authorized)
return 'no-usage'
return 'credits-exhausted'
}
if (hasCredential && authorized)
return 'api-active'
if (priority === 'apiKey' && !isExhausted)
return 'credits-fallback'
if (priority === 'apiKey' && !hasCredential)
return 'no-usage'
if (hasCredential && !authorized)
return credentialName ? 'api-unavailable' : 'api-required-configure'
return 'api-required-add'
}
export function useCredentialPanelState(provider: ModelProvider | undefined): CredentialPanelState {
const { isExhausted, credits } = useTrialCredits()
const {
hasCredential,
authorized,
current_credential_name,
} = useCredentialStatus(provider)
const { data: systemFeatures } = useSystemFeaturesQuery()
const trialModels = systemFeatures?.trial_models
const preferredType = provider?.preferred_provider_type
const supportsCredits = providerSupportsCredits(provider, trialModels)
const priority: UsagePriority = !supportsCredits
? 'apiKeyOnly'
: preferredType === PreferredProviderTypeEnum.system
? 'credits'
: 'apiKey'
const showPrioritySwitcher = supportsCredits
const variant = deriveVariant(priority, isExhausted, hasCredential, !!authorized, current_credential_name)
return {
variant,
priority,
supportsCredits,
showPrioritySwitcher,
hasCredentials: hasCredential,
isCreditsExhausted: isExhausted,
credentialName: current_credential_name,
credits,
}
}

View File

@ -0,0 +1,88 @@
import { renderHook } from '@testing-library/react'
import { useTrialCredits } from './use-trial-credits'
const mockUseCurrentWorkspace = vi.fn()
vi.mock('@/service/use-common', () => ({
useCurrentWorkspace: () => mockUseCurrentWorkspace(),
}))
describe('useTrialCredits', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseCurrentWorkspace.mockReturnValue({
data: {
trial_credits: 100,
trial_credits_used: 40,
next_credit_reset_date: '2026-04-01',
},
isPending: false,
})
})
describe('when workspace data is available', () => {
it('should return the remaining credits and reset date', () => {
const { result } = renderHook(() => useTrialCredits())
expect(result.current).toEqual({
credits: 60,
totalCredits: 100,
isExhausted: false,
isLoading: false,
nextCreditResetDate: '2026-04-01',
})
})
it('should keep the hook out of loading state during a background refetch', () => {
mockUseCurrentWorkspace.mockReturnValue({
data: {
trial_credits: 80,
trial_credits_used: 20,
next_credit_reset_date: '2026-05-01',
},
isPending: true,
})
const { result } = renderHook(() => useTrialCredits())
expect(result.current.isLoading).toBe(false)
expect(result.current.credits).toBe(60)
expect(result.current.isExhausted).toBe(false)
})
})
describe('when workspace data is missing or exhausted', () => {
it('should report loading while the first workspace request is pending', () => {
mockUseCurrentWorkspace.mockReturnValue({
data: undefined,
isPending: true,
})
const { result } = renderHook(() => useTrialCredits())
expect(result.current).toEqual({
credits: 0,
totalCredits: 0,
isExhausted: true,
isLoading: true,
nextCreditResetDate: undefined,
})
})
it('should clamp negative remaining credits to zero', () => {
mockUseCurrentWorkspace.mockReturnValue({
data: {
trial_credits: 10,
trial_credits_used: 99,
next_credit_reset_date: undefined,
},
isPending: false,
})
const { result } = renderHook(() => useTrialCredits())
expect(result.current.credits).toBe(0)
expect(result.current.isExhausted).toBe(true)
})
})
})

View File

@ -0,0 +1,15 @@
import { useCurrentWorkspace } from '@/service/use-common'
export const useTrialCredits = () => {
const { data: currentWorkspace, isPending } = useCurrentWorkspace()
const totalCredits = currentWorkspace?.trial_credits ?? 0
const credits = Math.max(totalCredits - (currentWorkspace?.trial_credits_used ?? 0), 0)
return {
credits,
totalCredits,
isExhausted: credits <= 0,
isLoading: isPending && !currentWorkspace,
nextCreditResetDate: currentWorkspace?.next_credit_reset_date,
}
}

View File

@ -74,6 +74,26 @@ describe('ProviderIcon', () => {
expect(screen.getByTestId('openai-icon')).toBeInTheDocument()
})
it('should apply custom className to special provider wrappers', () => {
const { rerender, container } = render(
<ProviderIcon
provider={createProvider({ provider: 'langgenius/anthropic/anthropic' })}
className="custom-wrapper"
/>,
)
expect(container.firstChild).toHaveClass('custom-wrapper')
rerender(
<ProviderIcon
provider={createProvider({ provider: 'langgenius/openai/openai' })}
className="custom-wrapper"
/>,
)
expect(container.firstChild).toHaveClass('custom-wrapper')
})
it('should render generic provider with image and label', () => {
const provider = createProvider({ label: { en_US: 'Custom', zh_Hans: '自定义' } })
render(<ProviderIcon provider={provider} />)
@ -94,4 +114,19 @@ describe('ProviderIcon', () => {
const img = screen.getByAltText('provider-icon') as HTMLImageElement
expect(img.src).toBe('https://example.com/dark.png')
})
it('should fall back to localized labels when available', () => {
const mockLang = vi.mocked(useLanguage)
mockLang.mockReturnValue('zh_Hans')
render(
<ProviderIcon
provider={createProvider({
label: { en_US: 'Custom', zh_Hans: '自定义' },
})}
/>,
)
expect(screen.getByText('自定义')).toBeInTheDocument()
})
})

View File

@ -21,7 +21,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/anthropic/anthropic') {
return (
<div className="mb-2 py-[7px]">
<div className={cn('py-[7px]', className)}>
{theme === Theme.dark && <AnthropicLight className="h-2.5 w-[90px]" />}
{theme === Theme.light && <AnthropicDark className="h-2.5 w-[90px]" />}
</div>
@ -30,7 +30,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
if (provider.provider === 'langgenius/openai/openai') {
return (
<div className="mb-2">
<div className={className}>
<Openai className="h-6 w-auto text-text-inverted-dimmed" />
</div>
)
@ -48,7 +48,7 @@ const ProviderIcon: FC<ProviderIconProps> = ({
)}
className="h-6 w-6"
/>
<div className="system-md-semibold text-text-primary">
<div className="text-text-primary system-md-semibold">
{renderI18nObject(provider.label, language)}
</div>
</div>

View File

@ -0,0 +1,9 @@
import { ModelStatusEnum } from './declarations'
export const MODEL_STATUS_I18N_KEY: Partial<Record<ModelStatusEnum, string>> = {
[ModelStatusEnum.quotaExceeded]: 'modelProvider.selector.creditsExhausted',
[ModelStatusEnum.noConfigure]: 'modelProvider.selector.configureRequired',
[ModelStatusEnum.noPermission]: 'modelProvider.selector.incompatible',
[ModelStatusEnum.disabled]: 'modelProvider.selector.disabled',
[ModelStatusEnum.credentialRemoved]: 'modelProvider.selector.apiKeyUnavailable',
}

View File

@ -0,0 +1,42 @@
import type { ModelProvider } from './declarations'
import { CurrentSystemQuotaTypeEnum } from './declarations'
import { providerSupportsCredits } from './supports-credits'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return { ...actual, IS_CLOUD_EDITION: true }
})
const makeProvider = (overrides: Partial<ModelProvider> = {}): ModelProvider => ({
provider: 'langgenius/openai/openai',
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
...overrides,
} as ModelProvider)
describe('providerSupportsCredits', () => {
it('returns true when the provider is system-enabled and listed in trial_models', () => {
expect(providerSupportsCredits(makeProvider(), ['langgenius/openai/openai'])).toBe(true)
})
it('returns false when the provider is not listed in trial_models', () => {
expect(providerSupportsCredits(makeProvider(), ['langgenius/anthropic/anthropic'])).toBe(false)
})
it('returns false when system hosting is disabled', () => {
expect(providerSupportsCredits(makeProvider({
system_configuration: {
enabled: false,
current_quota_type: CurrentSystemQuotaTypeEnum.trial,
quota_configurations: [],
},
}), ['langgenius/openai/openai'])).toBe(false)
})
it('returns false for an undefined provider', () => {
expect(providerSupportsCredits(undefined, ['langgenius/openai/openai'])).toBe(false)
})
})

View File

@ -0,0 +1,14 @@
import type { ModelProvider } from './declarations'
import { IS_CLOUD_EDITION } from '@/config'
type CreditAwareProvider = Pick<ModelProvider, 'provider' | 'system_configuration'>
export const providerSupportsCredits = (
provider: CreditAwareProvider | undefined,
trialModels: readonly string[] | undefined,
): boolean => {
if (!IS_CLOUD_EDITION || !provider?.system_configuration.enabled)
return false
return !!provider.provider && !!trialModels?.includes(provider.provider)
}

View File

@ -1,7 +1,6 @@
import type { DefaultModelResponse } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
import { ModelTypeEnum } from '../../declarations'
import SystemModel from '../index'
@ -27,6 +26,7 @@ vi.mock('react-i18next', async () => {
const mockNotify = vi.hoisted(() => vi.fn())
const mockUpdateModelList = vi.hoisted(() => vi.fn())
const mockInvalidateDefaultModel = vi.hoisted(() => vi.fn())
const mockUpdateDefaultModel = vi.hoisted(() => vi.fn(() => Promise.resolve({ result: 'success' })))
let mockIsCurrentWorkspaceManager = true
@ -43,15 +43,11 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
vi.mock('@/app/components/base/toast/context', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/base/toast/context')>()
return {
...actual,
useToastContext: () => ({
notify: mockNotify,
}),
}
})
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('../../hooks', () => ({
useModelList: () => ({
@ -62,6 +58,7 @@ vi.mock('../../hooks', () => ({
vi.fn(),
],
useUpdateModelList: () => mockUpdateModelList,
useInvalidateDefaultModel: () => mockInvalidateDefaultModel,
}))
vi.mock('@/service/common', () => ({
@ -94,24 +91,18 @@ const defaultProps = {
}
describe('SystemModel', () => {
const renderSystemModel = (props: typeof defaultProps) => render(
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
<SystemModel {...props} />
</ToastContext.Provider>,
)
beforeEach(() => {
vi.clearAllMocks()
mockIsCurrentWorkspaceManager = true
})
it('should render settings button', () => {
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
expect(screen.getByRole('button', { name: /system model settings/i })).toBeInTheDocument()
})
it('should open modal when button is clicked', async () => {
renderSystemModel(defaultProps)
it('should open dialog when button is clicked', async () => {
render(<SystemModel {...defaultProps} />)
const button = screen.getByRole('button', { name: /system model settings/i })
fireEvent.click(button)
await waitFor(() => {
@ -120,12 +111,18 @@ describe('SystemModel', () => {
})
it('should disable button when loading', () => {
renderSystemModel({ ...defaultProps, isLoading: true })
render(<SystemModel {...defaultProps} isLoading />)
expect(screen.getByRole('button', { name: /system model settings/i })).toBeDisabled()
})
it('should close modal when cancel is clicked', async () => {
renderSystemModel(defaultProps)
it('should render the primary button variant when configuration is required', () => {
render(<SystemModel {...defaultProps} notConfigured />)
expect(screen.getByRole('button', { name: /system model settings/i })).toHaveClass('btn-primary')
})
it('should close dialog when cancel is clicked', async () => {
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
@ -137,7 +134,7 @@ describe('SystemModel', () => {
})
it('should save selected models and show success feedback', async () => {
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
@ -155,109 +152,39 @@ describe('SystemModel', () => {
type: 'success',
message: 'Modified successfully',
})
expect(mockInvalidateDefaultModel).toHaveBeenCalledTimes(5)
expect(mockUpdateModelList).toHaveBeenCalledTimes(5)
})
})
it('should keep the dialog open when saving does not succeed', async () => {
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'failed' })
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
})
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
expect(mockNotify).not.toHaveBeenCalled()
expect(mockInvalidateDefaultModel).not.toHaveBeenCalled()
expect(mockUpdateModelList).not.toHaveBeenCalled()
})
it('should disable save when user is not workspace manager', async () => {
mockIsCurrentWorkspaceManager = false
renderSystemModel(defaultProps)
render(<SystemModel {...defaultProps} />)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled()
})
})
it('should render primary variant button when notConfigured is true', () => {
renderSystemModel({ ...defaultProps, notConfigured: true })
const button = screen.getByRole('button', { name: /system model settings/i })
expect(button.className).toContain('btn-primary')
})
it('should keep modal open when save returns non-success result', async () => {
mockUpdateDefaultModel.mockResolvedValueOnce({ result: 'error' })
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
selectorButtons.forEach(button => fireEvent.click(button))
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
expect(mockNotify).not.toHaveBeenCalled()
})
// Modal should still be open after failed save
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument()
})
it('should not add duplicate model type to changedModelTypes when same type is selected twice', async () => {
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
// Click the first selector twice (textGeneration type)
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
fireEvent.click(selectorButtons[0])
fireEvent.click(selectorButtons[0])
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateDefaultModel).toHaveBeenCalledTimes(1)
// textGeneration was changed, so updateModelList is called once for it
expect(mockUpdateModelList).toHaveBeenCalledTimes(1)
})
})
it('should call updateModelList for speech2text and tts types on save', async () => {
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
// Click speech2text (index 3) and tts (index 4) selectors
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
fireEvent.click(selectorButtons[3])
fireEvent.click(selectorButtons[4])
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
})
})
it('should call updateModelList for each unique changed model type on save', async () => {
renderSystemModel(defaultProps)
fireEvent.click(screen.getByRole('button', { name: /system model settings/i }))
await waitFor(() => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument()
})
// Click embedding and rerank selectors (indices 1 and 2)
const selectorButtons = screen.getAllByRole('button', { name: 'Mock Model Selector' })
fireEvent.click(selectorButtons[1])
fireEvent.click(selectorButtons[2])
fireEvent.click(screen.getByRole('button', { name: /save/i }))
await waitFor(() => {
expect(mockUpdateModelList).toHaveBeenCalledTimes(2)
})
})
})

Some files were not shown because too many files have changed in this diff Show More