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

View File

@ -4652,11 +4652,6 @@
"count": 3
}
},
"app/components/header/account-setting/model-provider-page/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 5
}
},
"app/components/header/account-setting/model-provider-page/install-from-marketplace.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3

View File

@ -390,6 +390,8 @@
"modelProvider.models": "Models",
"modelProvider.modelsNum": "{{num}} Models",
"modelProvider.noModelFound": "No model found for {{model}}",
"modelProvider.noProviderInstalled": "No model provider installed. Install one to configure system models.",
"modelProvider.noneConfigured": "Configure a default system model to run applications",
"modelProvider.notConfigured": "The system model has not yet been fully configured",
"modelProvider.parameters": "PARAMETERS",
"modelProvider.parametersInvalidRemoved": "Some parameters are invalid and have been removed",
@ -413,7 +415,7 @@
"modelProvider.showMoreModelProvider": "Show more model provider",
"modelProvider.speechToTextModel.key": "Speech-to-Text Model",
"modelProvider.speechToTextModel.tip": "Set the default model for speech-to-text input in conversation.",
"modelProvider.systemModelSettings": "System Model Settings",
"modelProvider.systemModelSettings": "Default Model Settings",
"modelProvider.systemModelSettingsLink": "Why is it necessary to set up a system model?",
"modelProvider.systemReasoningModel.key": "System Reasoning Model",
"modelProvider.systemReasoningModel.tip": "Set the default inference model to be used for creating applications, as well as features such as dialogue name generation and next question suggestion will also use the default inference model.",

View File

@ -390,6 +390,8 @@
"modelProvider.models": "模型列表",
"modelProvider.modelsNum": "{{num}} 个模型",
"modelProvider.noModelFound": "找不到模型 {{model}}",
"modelProvider.noProviderInstalled": "尚未安装模型供应商,请先安装以配置系统模型。",
"modelProvider.noneConfigured": "配置默认系统模型以运行应用",
"modelProvider.notConfigured": "系统模型尚未完全配置",
"modelProvider.parameters": "参数",
"modelProvider.parametersInvalidRemoved": "部分参数无效,已移除",
@ -413,7 +415,7 @@
"modelProvider.showMoreModelProvider": "显示更多模型提供商",
"modelProvider.speechToTextModel.key": "语音转文本模型",
"modelProvider.speechToTextModel.tip": "设置对话中语音转文字输入的默认使用模型。",
"modelProvider.systemModelSettings": "系统模型设置",
"modelProvider.systemModelSettings": "默认模型设置",
"modelProvider.systemModelSettingsLink": "为什么需要设置系统模型?",
"modelProvider.systemReasoningModel.key": "系统推理模型",
"modelProvider.systemReasoningModel.tip": "设置创建应用使用的默认推理模型,以及对话名称生成、下一步问题建议等功能也会使用该默认推理模型。",