feat: implement model status mapping and enhance UI components

- Added a new status-mapping file to define internationalization keys for model statuses.
- Updated ModelName and Trigger components to conditionally display model metadata based on status.
- Enhanced tests for ModelSelectorTrigger to validate rendering behavior for different credential panel states.
- Improved styling and tooltip integration for status badges in the Trigger component.
This commit is contained in:
CodingOnStar
2026-03-11 14:36:07 +08:00
parent 0acc2eaa00
commit f18fd566ba
8 changed files with 213 additions and 65 deletions

View File

@ -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}

View File

@ -76,7 +76,6 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
} = useTextGenerationCurrentProviderAndModelAndModelList(
{ provider, model: modelId },
)
const hasDeprecated = !currentProvider || !currentModel
const modelDisabled = currentModel?.status !== ModelStatusEnum.active
const disabled = !isAPIKeySet || hasDeprecated || modelDisabled

View File

@ -26,7 +26,21 @@ vi.mock('../model-icon', () => ({
}))
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', () => {
@ -67,6 +81,8 @@ describe('Trigger', () => {
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', () => {
@ -128,6 +144,8 @@ describe('Trigger', () => {
expect(screen.getByText('common.modelProvider.selector.creditsExhausted')).toBeInTheDocument()
expect(screen.getByTestId('model-icon')).toBeInTheDocument()
expect(screen.queryByTestId('model-name-mode')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-name-features')).not.toBeInTheDocument()
})
it('should resolve provider from context when currentProvider is missing in split layout', () => {
@ -178,6 +196,56 @@ describe('Trigger', () => {
expect(screen.getByText('common.modelProvider.selector.apiKeyUnavailable')).toBeInTheDocument()
})
it('should render credits exhausted badge in workflow mode', () => {
mockUseCredentialPanelState.mockReturnValue({
variant: 'credits-exhausted',
supportsCredits: true,
isCreditsExhausted: true,
priority: 'credits',
showPrioritySwitcher: true,
hasCredentials: false,
credentialName: undefined,
credits: 0,
})
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({
variant: 'api-unavailable',
supportsCredits: true,
isCreditsExhausted: false,
priority: 'apiKey',
showPrioritySwitcher: true,
hasCredentials: true,
credentialName: 'Primary Key',
credits: 0,
})
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 deprecated model is disabled', () => {
render(
<Trigger
@ -191,9 +259,11 @@ describe('Trigger', () => {
)
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
expect(screen.queryByTestId('model-name-mode')).not.toBeInTheDocument()
expect(screen.queryByTestId('model-name-features')).not.toBeInTheDocument()
})
it('should render warning icon when model status is disabled but not deprecated', () => {
it('should render incompatible badge when model status is disabled but not deprecated', () => {
render(
<Trigger
currentProvider={currentProvider}
@ -205,7 +275,7 @@ describe('Trigger', () => {
/>,
)
expect(document.querySelector('.text-\\[\\#F79009\\]')).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
})
})

View File

@ -6,16 +6,15 @@ import type {
} 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 { ModelStatusEnum } from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
import { MODEL_STATUS_I18N_KEY } from '../status-mapping'
export type TriggerProps = {
open?: boolean
@ -35,18 +34,24 @@ const Trigger: FC<TriggerProps> = ({
providerName,
modelId,
hasDeprecated,
modelDisabled,
modelDisabled: _modelDisabled,
isInWorkflow,
}) => {
const { t } = useTranslation()
const language = useLanguage()
const { modelProviders } = useProviderContext()
const isEmpty = !modelId || !providerName
const currentModelProvider = modelProviders.find(p => p.provider === providerName)
const state = useCredentialPanelState(currentModelProvider)
const hasCredits = !state.isCreditsExhausted
const showCreditsExhausted = !isEmpty && !hasCredits && state.supportsCredits
const showCreditsExhausted = !isEmpty && state.priority === 'credits' && state.supportsCredits && state.isCreditsExhausted
const showApiKeyUnavailable = !isEmpty && state.variant === 'api-unavailable'
const effectiveStatus = showCreditsExhausted
? ModelStatusEnum.quotaExceeded
: showApiKeyUnavailable
? ModelStatusEnum.credentialRemoved
: currentModel?.status
const statusI18nKey = effectiveStatus ? MODEL_STATUS_I18N_KEY[effectiveStatus] : undefined
const isCreditsExhausted = effectiveStatus === ModelStatusEnum.quotaExceeded
const shouldShowModelMeta = effectiveStatus === ModelStatusEnum.active && !(disabled && hasDeprecated)
// Non-workflow status error: split layout with badge + settings button
if ((showCreditsExhausted || showApiKeyUnavailable) && !isInWorkflow) {
@ -60,7 +65,14 @@ const Trigger: FC<TriggerProps> = ({
/>
<div className="flex flex-1 items-center truncate px-1 py-[3px]">
{currentModel
? <ModelName className="grow" modelItem={currentModel} showMode showFeatures />
? (
<ModelName
className="grow"
modelItem={currentModel}
showMode={shouldShowModelMeta}
showFeatures={shouldShowModelMeta}
/>
)
: <div className="truncate text-[13px] font-normal text-components-input-text-filled">{modelId}</div>}
</div>
<div className="flex shrink-0 items-center pr-0.5">
@ -121,8 +133,8 @@ const Trigger: FC<TriggerProps> = ({
<ModelName
className="mr-1.5 text-text-primary"
modelItem={currentModel}
showMode
showFeatures
showMode={shouldShowModelMeta}
showFeatures={shouldShowModelMeta}
/>
)
}
@ -144,32 +156,49 @@ const Trigger: FC<TriggerProps> = ({
!isEmpty && (
disabled
? (
hasDeprecated
? (
<Tooltip popupContent={t('modelProvider.selector.incompatibleTip', { ns: 'common' })}>
<div className="ml-auto 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">
<Tooltip>
<TooltipTrigger
render={(
<div className="ml-auto 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('modelProvider.selector.incompatible', { ns: 'common' })}
</span>
</div>
)}
/>
<TooltipContent placement="top">
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)
: statusI18nKey
? (
<Tooltip>
<TooltipTrigger
disabled={effectiveStatus !== ModelStatusEnum.noPermission}
render={(
<div
className={cn(
'ml-auto flex min-w-[20px] shrink-0 items-center justify-center gap-[3px] rounded-md border border-text-warning px-[5px] py-0.5',
isCreditsExhausted && '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">
{t('modelProvider.selector.incompatible', { ns: 'common' })}
{t(statusI18nKey as 'modelProvider.selector.creditsExhausted', { ns: 'common' })}
</span>
</div>
</Tooltip>
)
: (
<Tooltip
popupContent={
(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')} />
)
)}
/>
<TooltipContent placement="top">
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)
: (
<SlidersH className={cn(!isInWorkflow ? 'text-indigo-600' : 'text-text-tertiary', 'h-4 w-4 shrink-0')} />
)
)
}
{

View File

@ -166,6 +166,30 @@ describe('ModelSelectorTrigger', () => {
)
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 not show status badge when selected model is readonly', () => {
@ -189,6 +213,7 @@ describe('ModelSelectorTrigger', () => {
/>,
)
expect(screen.queryByText('CHAT')).not.toBeInTheDocument()
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
@ -196,20 +221,18 @@ describe('ModelSelectorTrigger', () => {
})
describe('Edge Cases', () => {
it('should show deprecated tooltip when hovering warn icon', async () => {
it('should show incompatible badge for deprecated selection', async () => {
const user = userEvent.setup()
const { container } = render(
render(
<ModelSelectorTrigger
defaultModel={{ provider: 'openai', model: 'legacy-model' }}
/>,
)
const warnIcon = container.querySelector('.i-ri-alert-line')
expect(warnIcon).toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
await user.hover(screen.getByText('common.modelProvider.selector.incompatible'))
await user.hover(warnIcon as HTMLElement)
expect(await screen.findByText('common.modelProvider.deprecated')).toBeInTheDocument()
expect(await screen.findByText('common.modelProvider.selector.incompatibleTip')).toBeInTheDocument()
})
it('should render fallback icon when deprecated provider is not found', () => {

View File

@ -12,14 +12,7 @@ import { ModelStatusEnum } from '../declarations'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
const 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',
}
import { MODEL_STATUS_I18N_KEY } from '../status-mapping'
type ModelSelectorTriggerProps = {
currentProvider?: Model
@ -56,14 +49,18 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
&& selectedProviderState.priority === 'credits'
&& selectedProviderState.supportsCredits
&& selectedProviderState.isCreditsExhausted
const shouldShowApiKeyUnavailable = isSelected && selectedProviderState.variant === 'api-unavailable'
const effectiveStatus = shouldShowCreditsExhausted
? ModelStatusEnum.quotaExceeded
: currentModel?.status
: shouldShowApiKeyUnavailable
? ModelStatusEnum.credentialRemoved
: currentModel?.status
const isActive = isSelected && effectiveStatus === ModelStatusEnum.active
const isDisabled = isDeprecated || (isSelected && !isActive)
const statusI18nKey = isSelected && effectiveStatus ? STATUS_I18N_KEY[effectiveStatus] : undefined
const statusI18nKey = isSelected && effectiveStatus ? MODEL_STATUS_I18N_KEY[effectiveStatus] : undefined
const isCreditsExhausted = isSelected && effectiveStatus === ModelStatusEnum.quotaExceeded
const shouldShowModelMeta = effectiveStatus === ModelStatusEnum.active
const deprecatedProvider = isDeprecated
? modelProviders.find(p => p.provider === defaultModel.provider)
@ -102,8 +99,8 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
<ModelName
className="grow"
modelItem={currentModel}
showMode
showFeatures
showMode={shouldShowModelMeta}
showFeatures={shouldShowModelMeta}
/>
)}
{isDeprecated && (
@ -143,12 +140,18 @@ const ModelSelectorTrigger: FC<ModelSelectorTriggerProps> = ({
{isDeprecated && showDeprecatedWarnIcon && (
<Tooltip>
<TooltipTrigger render={(
<span className="i-ri-alert-line h-4 w-4 shrink-0 text-text-warning-secondary" />
)}
<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">
{t('modelProvider.selector.incompatible', { ns: 'common' })}
</span>
</div>
)}
/>
<TooltipContent placement="top">
{t('modelProvider.deprecated', { ns: 'common' })}
{t('modelProvider.selector.incompatibleTip', { ns: 'common' })}
</TooltipContent>
</Tooltip>
)}

View File

@ -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',
}

View File

@ -27,6 +27,7 @@ import {
VariableX,
WebhookLine,
} from '@/app/components/base/icons/src/vender/workflow'
import { API_PREFIX } from '@/config'
import { cn } from '@/utils/classnames'
import { BlockEnum } from './types'
@ -82,6 +83,17 @@ const getIcon = (type: BlockEnum, className: string) => {
return <DefaultIcon className={className} />
}
const normalizeToolIconUrl = (toolIcon: string) => {
const protectedPluginIconPath = '/workspaces/current/plugin/icon'
const pathIndex = toolIcon.indexOf(protectedPluginIconPath)
if (pathIndex < 0)
return toolIcon
return `${API_PREFIX}${toolIcon.slice(pathIndex)}`
}
const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
[BlockEnum.Start]: 'bg-util-colors-blue-brand-blue-brand-500',
[BlockEnum.LLM]: 'bg-util-colors-indigo-indigo-500',
@ -119,6 +131,9 @@ const BlockIcon: FC<BlockIconProps> = ({
}) => {
const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin
const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon
const resolvedToolIcon = typeof toolIcon === 'string'
? normalizeToolIconUrl(toolIcon)
: toolIcon
return (
<div className={
@ -142,12 +157,12 @@ const BlockIcon: FC<BlockIconProps> = ({
!showDefaultIcon && (
<>
{
typeof toolIcon === 'string'
typeof resolvedToolIcon === 'string'
? (
<div
className="h-full w-full shrink-0 rounded-md bg-cover bg-center"
style={{
backgroundImage: `url(${toolIcon})`,
backgroundImage: `url(${resolvedToolIcon})`,
}}
>
</div>
@ -156,8 +171,8 @@ const BlockIcon: FC<BlockIconProps> = ({
<AppIcon
className="!h-full !w-full shrink-0"
size="tiny"
icon={toolIcon?.content}
background={toolIcon?.background}
icon={resolvedToolIcon?.content}
background={resolvedToolIcon?.background}
/>
)
}