feat(web): refine system model settings to 4 distinct config states

Replace the single `defaultModelNotConfigured` boolean with a derived
`systemModelConfigStatus` that distinguishes between no-provider,
none-configured, partially-configured, and fully-configured states,
each showing a context-appropriate warning message. Also updates the
button label from "System Model Settings" to "Default Model Settings"
and migrates remixicon imports to Tailwind CSS icon classes.
This commit is contained in:
yyh
2026-03-04 16:58:46 +08:00
parent 5a3348ec8d
commit 70e677a6ac
5 changed files with 114 additions and 30 deletions

View File

@ -60,13 +60,16 @@ vi.mock('@/context/provider-context', () => ({
}),
}))
const mockDefaultModelState = {
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', () => ({
@ -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(<ModelProviderPage searchText="" />)
expect(screen.getByText('common.modelProvider.noProviderInstalled')).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',

View File

@ -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 (
<div className="relative -mt-2 pt-1">
<div className={cn('mb-2 flex items-center')}>
<div className="system-md-semibold grow text-text-primary">{t('modelProvider.models', { ns: 'common' })}</div>
<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 && (
<div className="system-xs-medium flex items-center gap-1 text-text-primary">
<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>
{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 && warningTextKey && (
<div className="flex items-center gap-1 text-text-primary system-xs-medium">
<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}
@ -127,10 +147,10 @@ const ModelProviderPage = ({ searchText }: Props) => {
{!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="system-sm-medium mt-2 text-text-secondary">{t('modelProvider.emptyProviderTitle', { ns: 'common' })}</div>
<div className="system-xs-regular mt-1 text-text-tertiary">{t('modelProvider.emptyProviderTip', { ns: 'common' })}</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>
</div>
)}
{!!filteredConfiguredProviders?.length && (
@ -145,7 +165,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
)}
{!!filteredNotConfiguredProviders?.length && (
<>
<div className="system-md-semibold mb-2 flex items-center pt-2 text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<div className="mb-2 flex items-center pt-2 text-text-primary system-md-semibold">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
<div className="relative">
{filteredNotConfiguredProviders?.map(provider => (
<ProviderAddedCard