diff --git a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx index 1f1832628c..50e86b6fd8 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.spec.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.spec.tsx @@ -60,13 +60,16 @@ vi.mock('@/context/provider-context', () => ({ }), })) -const mockDefaultModelState = { - data: null, - isLoading: false, +const mockDefaultModels: Record = { + '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', () => ({ @@ -90,8 +93,9 @@ describe('ModelProviderPage', () => { vi.useFakeTimers() vi.clearAllMocks() mockGlobalState.systemFeatures.enable_marketplace = true - mockDefaultModelState.data = null - mockDefaultModelState.isLoading = false + Object.keys(mockDefaultModels).forEach((key) => { + mockDefaultModels[key] = { data: null, isLoading: false } + }) mockProviders.splice(0, mockProviders.length, { provider: 'openai', label: { en_US: 'OpenAI' }, @@ -156,6 +160,67 @@ describe('ModelProviderPage', () => { expect(screen.queryByTestId('install-from-marketplace')).not.toBeInTheDocument() }) + describe('system model config status', () => { + it('should show no-provider warning when no configured providers exist', () => { + 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() + expect(screen.getByText('common.modelProvider.noProviderInstalled')).toBeInTheDocument() + }) + + it('should show none-configured warning when providers exist but no default models set', () => { + render() + 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() + 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() + 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() + 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', diff --git a/web/app/components/header/account-setting/model-provider-page/index.tsx b/web/app/components/header/account-setting/model-provider-page/index.tsx index 7606bbc04f..ec95dde24f 100644 --- a/web/app/components/header/account-setting/model-provider-page/index.tsx +++ b/web/app/components/header/account-setting/model-provider-page/index.tsx @@ -1,10 +1,6 @@ import type { ModelProvider, } from './declarations' -import { - RiAlertFill, - RiBrainLine, -} from '@remixicon/react' import { useDebounce } from 'ahooks' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -25,6 +21,14 @@ import ProviderAddedCard from './provider-added-card' import QuotaPanel from './provider-added-card/quota-panel' import SystemModelSelector from './system-model-selector' +type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured' + +const WARNING_TEXT_KEYS = { + 'no-provider': 'modelProvider.noProviderInstalled', + 'none-configured': 'modelProvider.noneConfigured', + 'partially-configured': 'modelProvider.notConfigured', +} as const + type Props = { searchText: string } @@ -47,7 +51,6 @@ const ModelProviderPage = ({ searchText }: Props) => { || isRerankDefaultModelLoading || isSpeech2textDefaultModelLoading || isTTSDefaultModelLoading - const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel const [configuredProviders, notConfiguredProviders] = useMemo(() => { const configuredProviders: ModelProvider[] = [] const notConfiguredProviders: ModelProvider[] = [] @@ -79,6 +82,23 @@ 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 showWarning = !isDefaultModelLoading && systemModelConfigStatus !== 'fully-configured' + const warningTextKey = systemModelConfigStatus !== 'fully-configured' + ? WARNING_TEXT_KEYS[systemModelConfigStatus] + : undefined + const [filteredConfiguredProviders, filteredNotConfiguredProviders] = useMemo(() => { const filteredConfiguredProviders = configuredProviders.filter( provider => provider.provider.toLowerCase().includes(debouncedSearchText.toLowerCase()) @@ -99,21 +119,21 @@ const ModelProviderPage = ({ searchText }: Props) => { return (
-
{t('modelProvider.models', { ns: 'common' })}
+
{t('modelProvider.models', { ns: 'common' })}
- {defaultModelNotConfigured &&
} - {defaultModelNotConfigured && ( -
- - {t('modelProvider.notConfigured', { ns: 'common' })} + {showWarning &&
} + {showWarning && warningTextKey && ( +
+ + {t(warningTextKey, { ns: 'common' })}
)} { {!filteredConfiguredProviders?.length && (
- +
-
{t('modelProvider.emptyProviderTitle', { ns: 'common' })}
-
{t('modelProvider.emptyProviderTip', { ns: 'common' })}
+
{t('modelProvider.emptyProviderTitle', { ns: 'common' })}
+
{t('modelProvider.emptyProviderTip', { ns: 'common' })}
)} {!!filteredConfiguredProviders?.length && ( @@ -145,7 +165,7 @@ const ModelProviderPage = ({ searchText }: Props) => { )} {!!filteredNotConfiguredProviders?.length && ( <> -
{t('modelProvider.toBeConfigured', { ns: 'common' })}
+
{t('modelProvider.toBeConfigured', { ns: 'common' })}
{filteredNotConfiguredProviders?.map(provider => (