mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 01:48:04 +08:00
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:
@ -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 } })
|
||||
}
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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])
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
@ -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()]}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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', '')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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%);
|
||||
}
|
||||
@ -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 && (
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
@ -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
Reference in New Issue
Block a user