Merge branch 'feat/model-plugins-implementing' into deploy/dev

This commit is contained in:
CodingOnStar
2026-03-17 16:47:55 +08:00
204 changed files with 56660 additions and 380950 deletions

View File

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

View File

@ -36,7 +36,6 @@ import {
useUpdateModelList,
useUpdateModelProviders,
} from '../hooks'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from '../provider-added-card'
// Mock dependencies
vi.mock('@tanstack/react-query', () => ({
@ -99,12 +98,17 @@ 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(() => {

View File

@ -74,7 +74,7 @@ const mockDefaultModelState: {
}
vi.mock('../hooks', () => ({
useDefaultModel: () => mockDefaultModelState,
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
}))
vi.mock('../install-from-marketplace', () => ({

View File

@ -64,6 +64,22 @@ describe('deriveModelStatus', () => {
).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()),

View File

@ -35,14 +35,21 @@ export const deriveModelStatus = (
if (!modelId || !providerName)
return 'empty'
if (!currentModelProvider || !currentModel)
if (!currentModelProvider)
return 'incompatible'
if (credentialState.priority === 'credits'
const isCreditsExhaustedWithoutApiKey = credentialState.supportsCredits
&& credentialState.isCreditsExhausted
&& !credentialState.hasCredentials
const isCreditsPriorityExhausted = credentialState.priority === 'credits'
&& credentialState.supportsCredits
&& credentialState.isCreditsExhausted) {
&& credentialState.isCreditsExhausted
if (isCreditsPriorityExhausted || isCreditsExhaustedWithoutApiKey)
return 'credits-exhausted'
}
if (!currentModel)
return 'incompatible'
if (credentialState.variant === 'api-unavailable')
return 'api-key-unavailable'

View File

@ -1,7 +1,6 @@
import type { ComponentProps } from 'react'
import type { Credential, CredentialFormSchema, CustomModel, ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import type * as React from 'react'
import type { Credential, CredentialFormSchema, ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
@ -45,6 +44,15 @@ const mockHandlers = vi.hoisted(() => ({
handleActiveCredential: vi.fn(),
}))
type FormResponse = {
isCheckValidated: boolean
values: Record<string, unknown>
}
const mockFormState = vi.hoisted(() => ({
responses: [] as FormResponse[],
setFieldValue: vi.fn(),
}))
vi.mock('../../model-auth/hooks', () => ({
useCredentialData: () => ({
isLoading: mockState.isLoading,
@ -79,6 +87,36 @@ vi.mock('../../hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/base/form/form-scenarios/auth', async () => {
const React = await import('react')
const AuthForm = React.forwardRef(({
onChange,
}: {
onChange?: (field: string, value: string) => void
}, ref: React.ForwardedRef<{ getFormValues: () => FormResponse, getForm: () => { setFieldValue: (field: string, value: string) => void } }>) => {
React.useImperativeHandle(ref, () => ({
getFormValues: () => mockFormState.responses.shift() || { isCheckValidated: false, values: {} },
getForm: () => ({ setFieldValue: mockFormState.setFieldValue }),
}))
return (
<div>
<button type="button" onClick={() => onChange?.('__model_name', 'updated-model')}>Model Name Change</button>
</div>
)
})
return { default: AuthForm }
})
vi.mock('../../model-auth', () => ({
CredentialSelector: ({ onSelect }: { onSelect: (credential: Credential & { addNewCredential?: boolean }) => void }) => (
<div>
<button type="button" onClick={() => onSelect({ credential_id: 'existing' })}>Choose Existing</button>
<button type="button" onClick={() => onSelect({ credential_id: 'new', addNewCredential: true })}>Add New</button>
</div>
),
}))
const createI18n = (text: string) => ({ en_US: text, zh_Hans: text })
const createProvider = (overrides?: Partial<ModelProvider>): ModelProvider => ({
@ -135,46 +173,6 @@ const renderModal = (overrides?: Partial<ComponentProps<typeof ModelModal>>) =>
return props
}
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()

View File

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import ModelParameterModal from '../index'
let isAPIKeySet = true
@ -77,7 +77,7 @@ 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>
</div>
@ -91,7 +91,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) => {

View File

@ -1,8 +1,9 @@
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',
}))
@ -13,12 +14,30 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
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>
),
}))
describe('Trigger', () => {
@ -40,14 +59,156 @@ describe('Trigger', () => {
expect(screen.getByTestId('model-icon')).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()
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

View File

@ -60,6 +60,17 @@ describe('deriveTriggerStatus', () => {
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')

View File

@ -49,6 +49,8 @@ const Trigger: FC<TriggerProps> = ({
const status = disabled ? 'incompatible' : derivedStatus
const badgeKey = TRIGGER_STATUS_BADGE_I18N[status]
const tooltipKey = TRIGGER_STATUS_TOOLTIP_I18N[status]
const badgeLabel = badgeKey ? t(badgeKey, { ns: 'common', defaultValue: badgeKey }) : null
const tooltipLabel = tooltipKey ? t(tooltipKey, { ns: 'common', defaultValue: tooltipKey }) : null
const isActive = status === 'active'
const iconProvider = currentProvider || modelProviders.find(item => item.provider === providerName)
@ -96,7 +98,7 @@ const Trigger: FC<TriggerProps> = ({
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
</div>
{badgeKey && (
tooltipKey
tooltipLabel
? (
<Tooltip>
<TooltipTrigger
@ -105,14 +107,14 @@ const Trigger: FC<TriggerProps> = ({
<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">
{t(badgeKey as 'modelProvider.selector.incompatible', { ns: 'common' })}
{badgeLabel}
</span>
</div>
</div>
)}
/>
<TooltipContent placement="top">
{t(tooltipKey as 'modelProvider.selector.incompatibleTip', { ns: 'common' })}
{tooltipLabel}
</TooltipContent>
</Tooltip>
)
@ -121,7 +123,7 @@ const Trigger: FC<TriggerProps> = ({
<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">
{t(badgeKey as 'modelProvider.selector.incompatible', { ns: 'common' })}
{badgeLabel}
</span>
</div>
</div>

View File

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

View File

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

View File

@ -1,4 +1,6 @@
import type { Model, ModelItem } from '../../declarations'
import type { ReactNode } from 'react'
import type { DefaultModel, Model, ModelItem } from '../../declarations'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
@ -7,16 +9,20 @@ import {
} from '../../declarations'
import ModelSelector from '../index'
vi.mock('../model-trigger', () => ({
default: () => <div>model-trigger</div>,
}))
vi.mock('../model-selector-trigger', () => ({
default: ({
currentProvider,
currentModel,
defaultModel,
}: { currentProvider?: Model, currentModel?: ModelItem, defaultModel?: DefaultModel }) => {
if (currentProvider && currentModel)
return <div>model-trigger</div>
vi.mock('../deprecated-model-trigger', () => ({
default: ({ modelName }: { modelName: string }) => <div>{`deprecated:${modelName}`}</div>,
}))
if (defaultModel)
return <div>{`deprecated:${defaultModel.model}`}</div>
vi.mock('../empty-trigger', () => ({
default: () => <div>empty-trigger</div>,
return <div>empty-trigger</div>
},
}))
vi.mock('../popup', () => ({

View File

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

View File

@ -5,6 +5,7 @@ import {
ModelFeatureEnum,
ModelStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '../../declarations'
import PopupItem from '../popup-item'
@ -34,6 +35,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: () => ({

View File

@ -1,6 +1,5 @@
import type { Model, ModelItem } from '../../declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import { tooltipManager } from '@/app/components/base/tooltip/TooltipManager'
import type { Model, ModelItem, ModelProvider } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelFeatureEnum,
@ -23,6 +22,23 @@ 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 {
@ -35,6 +51,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,6 +146,20 @@ const makeModel = (overrides: Partial<Model> = {}): Model => ({
...overrides,
})
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', () => {
let closeActiveTooltipSpy: ReturnType<typeof vi.spyOn>
@ -233,6 +338,200 @@ describe('Popup', () => {
const input = screen.getByPlaceholderText('datasetSettings.form.searchModel')
fireEvent.change(input, { target: { value: 'gpt' } })
expect(screen.getByText('openai')).toBeInTheDocument()
it('should show installed marketplace providers without models when AI credits are available', () => {
mockContextModelProviders.current = [makeContextProvider({
provider: 'test-anthropic',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
})]
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('test-anthropic')).toBeInTheDocument()
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should hide installed marketplace providers without models when AI credits are exhausted', () => {
Object.assign(mockTrialCredits, {
credits: 0,
totalCredits: 200,
isExhausted: true,
})
mockContextModelProviders.current = [makeContextProvider({
provider: 'test-anthropic',
system_configuration: {
enabled: true,
} as MockContextProvider['system_configuration'],
})]
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.queryByText('test-anthropic')).not.toBeInTheDocument()
expect(screen.queryByText('TestAnthropic')).not.toBeInTheDocument()
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should toggle marketplace section collapse', () => {
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.queryByText('TestOpenAI')).not.toBeInTheDocument()
fireEvent.click(screen.getByText(/modelProvider\.selector\.fromMarketplace/))
expect(screen.getByText('TestOpenAI')).toBeInTheDocument()
})
it('should install plugin when clicking install button', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: true, task_id: 'task-1' })
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalledWith('langgenius/openai:1.0.0')
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should handle install failure gracefully', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockRejectedValue(new Error('Install failed'))
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockInstallMutateAsync).toHaveBeenCalled()
})
// Should not crash, install buttons should still be available
expect(screen.getAllByText(/common\.modelProvider\.selector\.install/).length).toBeGreaterThan(0)
})
it('should run checkTaskStatus when not all_installed', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockInstallMutateAsync.mockResolvedValue({ all_installed: false, task_id: 'task-1' })
mockCheck.mockResolvedValue(undefined)
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const installButtons = screen.getAllByText(/common\.modelProvider\.selector\.install/)
fireEvent.click(installButtons[0])
await waitFor(() => {
expect(mockCheck).toHaveBeenCalledWith({
taskId: 'task-1',
pluginUniqueIdentifier: 'langgenius/openai:1.0.0',
})
})
expect(mockRefreshPluginList).toHaveBeenCalled()
})
it('should skip install requests when marketplace plugins are still loading', async () => {
mockMarketplacePlugins.current = [
{ plugin_id: 'langgenius/openai', latest_package_identifier: 'langgenius/openai:1.0.0' },
]
mockMarketplacePlugins.isLoading = true
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should skip install requests when the marketplace plugin cannot be found', async () => {
mockMarketplacePlugins.current = []
render(
<Popup
modelList={[]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
fireEvent.click(screen.getAllByText(/common\.modelProvider\.selector\.install/)[0])
await waitFor(() => {
expect(mockInstallMutateAsync).not.toHaveBeenCalled()
})
})
it('should sort the selected provider to the top when a default model is provided', () => {
render(
<Popup
defaultModel={{ provider: 'anthropic', model: 'claude-3' }}
modelList={[
makeModel({ provider: 'openai', label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' } }),
makeModel({ provider: 'anthropic', label: { en_US: 'Anthropic', zh_Hans: 'Anthropic' } }),
]}
onSelect={vi.fn()}
onHide={vi.fn()}
/>,
)
const providerLabels = screen.getAllByText(/openai|anthropic/)
expect(providerLabels[0]).toHaveTextContent('anthropic')
})
})

View File

@ -64,6 +64,8 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
const isDisabled = status !== 'active' && status !== 'empty'
const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[status]
const tooltipI18nKey = DERIVED_MODEL_STATUS_TOOLTIP_I18N[status]
const statusLabel = statusI18nKey ? t(statusI18nKey, { ns: 'common', defaultValue: statusI18nKey }) : null
const tooltipLabel = tooltipI18nKey ? t(tooltipI18nKey, { ns: 'common', defaultValue: tooltipI18nKey }) : null
const isCreditsExhausted = status === 'credits-exhausted'
const shouldShowModelMeta = status === 'active'
@ -118,7 +120,7 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
{isSelected && !readonly && !isActive && statusI18nKey && (
<Tooltip>
<TooltipTrigger
disabled={!tooltipI18nKey}
disabled={!tooltipLabel}
render={(
<div
className={cn(
@ -128,14 +130,14 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
>
<span className="i-ri-alert-fill h-3 w-3 text-text-warning" />
<span className="whitespace-nowrap text-text-warning system-xs-medium">
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
{statusLabel}
</span>
</div>
)}
/>
{tooltipI18nKey && (
{tooltipLabel && (
<TooltipContent placement="top">
{t(tooltipI18nKey as 'modelProvider.selector.incompatibleTip', { ns: 'common' })}
{tooltipLabel}
</TooltipContent>
)}
</Tooltip>

View File

@ -19,7 +19,11 @@ import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { cn } from '@/utils/classnames'
import { supportFunctionCall } from '@/utils/tool-call'
import { getMarketplaceUrl } from '@/utils/var'
import { CustomConfigurationStatusEnum, ModelFeatureEnum } from '../declarations'
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'
@ -58,6 +62,19 @@ const Popup: FC<PopupProps> = ({
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>()
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) => {
@ -92,18 +109,31 @@ const Popup: FC<PopupProps> = ({
const installedModelList = useMemo(() => {
const modelMap = new Map(modelList.map(model => [model.provider, model]))
const installedMarketplaceModels = MODEL_PROVIDER_QUOTA_GET_PAID.flatMap((providerKey) => {
const installedProvider = modelProviders.find(provider => provider.provider === providerKey)
const installedProvider = installedProviderMap.get(providerKey)
if (!installedProvider)
return []
const matchedModel = modelMap.get(providerKey)
return matchedModel ? [matchedModel] : []
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]
}, [modelList, modelProviders])
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => {
const filtered = installedModelList.map((model) => {
@ -128,7 +158,7 @@ const Popup: FC<PopupProps> = ({
return modelItem.features?.includes(feature) ?? false
})
})
if (!matchesProviderSearch || filteredModels.length === 0)
if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
return null
return { ...model, models: filteredModels }
@ -143,7 +173,7 @@ const Popup: FC<PopupProps> = ({
}
return filtered
}, [defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
}, [aiCreditVisibleProviders, defaultModel?.provider, installedModelList, language, scopeFeatures, searchText])
const marketplaceProviders = useMemo(() => {
const installedProviders = new Set(modelProviders.map(provider => provider.provider))

View File

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

View File

@ -1,8 +1,12 @@
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() }
@ -58,18 +62,22 @@ 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', () => ({

View File

@ -1,6 +1,9 @@
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'

View File

@ -1,4 +1,5 @@
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'

View File

@ -1,6 +1,5 @@
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 = {

View File

@ -1,7 +1,6 @@
import type { DefaultModelResponse } from '../../declarations'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { vi } from 'vitest'
import { ToastContext } from '@/app/components/base/toast/context'
import { ModelTypeEnum } from '../../declarations'
import SystemModel from '../index'