feat(model-selector): add status badges and empty states for model trigger

- Add credits exhausted and API key unavailable split layout using useCredentialPanelState
  - Replace deprecated AlertTriangle icon with Incompatible badge and tooltip
  - Add empty state with brain icon placeholder and configure model text
  - Move STATUS_I18N_KEY to declarations.ts as shared constant
  - Redesign HasNotSetAPI as inline card layout, remove WarningMask overlay
  - Move no-API-key warning inline in debug panel, add no-model-selected state
  - Add i18n keys for en-US, ja-JP, zh-Hans
This commit is contained in:
CodingOnStar
2026-03-10 18:01:53 +08:00
parent fda5d12107
commit 7ed7562be6
10 changed files with 189 additions and 86 deletions

View File

@ -2,25 +2,19 @@ import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import HasNotSetAPI from './has-not-set-api'
describe('HasNotSetAPI WarningMask', () => {
it('should show default title when trial not finished', () => {
render(<HasNotSetAPI isTrailFinished={false} onSetting={vi.fn()} />)
describe('HasNotSetAPI', () => {
it('should render the empty state copy', () => {
render(<HasNotSetAPI onSetting={vi.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.title')).toBeInTheDocument()
expect(screen.getByText('appDebug.notSetAPIKey.description')).toBeInTheDocument()
expect(screen.getByText('appDebug.noModelProviderConfigured')).toBeInTheDocument()
expect(screen.getByText('appDebug.noModelProviderConfiguredTip')).toBeInTheDocument()
})
it('should show trail finished title when flag is true', () => {
render(<HasNotSetAPI isTrailFinished onSetting={vi.fn()} />)
expect(screen.getByText('appDebug.notSetAPIKey.trailFinished')).toBeInTheDocument()
})
it('should call onSetting when primary button clicked', () => {
it('should call onSetting when manage models button is clicked', () => {
const onSetting = vi.fn()
render(<HasNotSetAPI isTrailFinished={false} onSetting={onSetting} />)
render(<HasNotSetAPI onSetting={onSetting} />)
fireEvent.click(screen.getByRole('button', { name: 'appDebug.notSetAPIKey.settingBtn' }))
fireEvent.click(screen.getByRole('button', { name: 'appDebug.manageModels' }))
expect(onSetting).toHaveBeenCalledTimes(1)
})
})

View File

@ -2,38 +2,38 @@
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import WarningMask from '.'
export type IHasNotSetAPIProps = {
isTrailFinished: boolean
onSetting: () => void
}
const icon = (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 6.00001L14 2.00001M14 2.00001H9.99999M14 2.00001L8 8M6.66667 2H5.2C4.0799 2 3.51984 2 3.09202 2.21799C2.71569 2.40973 2.40973 2.71569 2.21799 3.09202C2 3.51984 2 4.07989 2 5.2V10.8C2 11.9201 2 12.4802 2.21799 12.908C2.40973 13.2843 2.71569 13.5903 3.09202 13.782C3.51984 14 4.07989 14 5.2 14H10.8C11.9201 14 12.4802 14 12.908 13.782C13.2843 13.5903 13.5903 13.2843 13.782 12.908C14 12.4802 14 11.9201 14 10.8V9.33333" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({
isTrailFinished,
onSetting,
}) => {
const { t } = useTranslation()
return (
<WarningMask
title={isTrailFinished ? t('notSetAPIKey.trailFinished', { ns: 'appDebug' }) : t('notSetAPIKey.title', { ns: 'appDebug' })}
description={t('notSetAPIKey.description', { ns: 'appDebug' })}
footer={(
<Button variant="primary" className="flex space-x-2" onClick={onSetting}>
<span>{t('notSetAPIKey.settingBtn', { ns: 'appDebug' })}</span>
{icon}
</Button>
)}
/>
<div className="flex grow flex-col items-center justify-center pb-[120px]">
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-md-semibold">{t('noModelProviderConfigured', { ns: 'appDebug' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('noModelProviderConfiguredTip', { ns: 'appDebug' })}</div>
</div>
<button
type="button"
className="flex w-fit items-center gap-1 rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 py-2 shadow-xs backdrop-blur-[5px]"
onClick={onSetting}
>
<span className="text-components-button-secondary-accent-text system-sm-medium">{t('manageModels', { ns: 'appDebug' })}</span>
<span className="i-ri-arrow-right-line h-4 w-4 text-components-button-secondary-accent-text" />
</button>
</div>
</div>
)
}
export default React.memo(HasNotSetAPI)

View File

@ -33,7 +33,7 @@ import { ToastContext } from '@/app/components/base/toast/context'
import TooltipPlus from '@/app/components/base/tooltip'
import { ModelFeatureEnum, ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG, IS_CE_EDITION } from '@/config'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
@ -505,6 +505,26 @@ const Debug: FC<IDebug> = ({
{
!debugWithMultipleModel && (
<div className="flex grow flex-col" ref={ref}>
{/* No model provider configured */}
{(!modelConfig.provider || !isAPIKeySet) && (
<HasNotSetAPIKEY onSetting={onSetting} />
)}
{/* No model selected */}
{modelConfig.provider && isAPIKeySet && !modelConfig.model_id && (
<div className="flex grow flex-col items-center justify-center pb-[120px]">
<div className="flex w-full max-w-[400px] flex-col gap-2 px-4 py-4">
<div className="flex h-10 w-10 items-center justify-center rounded-[10px]">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-[10px] border-[0.5px] border-components-card-border bg-components-card-bg p-1 shadow-lg backdrop-blur-[5px]">
<span className="i-ri-brain-2-line h-5 w-5 text-text-tertiary" />
</div>
</div>
<div className="flex flex-col gap-1">
<div className="text-text-secondary system-md-semibold">{t('noModelSelected', { ns: 'appDebug' })}</div>
<div className="text-text-tertiary system-xs-regular">{t('noModelSelectedTip', { ns: 'appDebug' })}</div>
</div>
</div>
</div>
)}
{/* Chat */}
{mode !== AppModeEnum.COMPLETION && (
<div className="h-0 grow overflow-hidden">
@ -570,7 +590,6 @@ const Debug: FC<IDebug> = ({
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
</>
)
}

View File

@ -68,7 +68,7 @@ const ModelParameterModal: FC<ModelParameterModalProps> = ({
const { t } = useTranslation()
const { isAPIKeySet } = useProviderContext()
const [open, setOpen] = useState(false)
const { data: parameterRulesData, isPending: isLoading } = useModelParameterRules(provider, modelId)
const { data: parameterRulesData, isLoading } = useModelParameterRules(provider, modelId)
const {
currentProvider,
currentModel,

View File

@ -15,6 +15,7 @@ import { MODEL_STATUS_TEXT } from '../declarations'
import { useLanguage } from '../hooks'
import ModelIcon from '../model-icon'
import ModelName from '../model-name'
import { useCredentialPanelState } from '../provider-added-card/use-credential-panel-state'
export type TriggerProps = {
open?: boolean
@ -40,18 +41,65 @@ const Trigger: FC<TriggerProps> = ({
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 showApiKeyUnavailable = !isEmpty && state.variant === 'api-unavailable'
// Non-workflow status error: split layout with badge + settings button
if ((showCreditsExhausted || showApiKeyUnavailable) && !isInWorkflow) {
return (
<div className="flex h-8 min-w-[296px] cursor-pointer items-center gap-px overflow-hidden rounded-lg">
<div className="flex flex-1 items-center gap-0.5 rounded-l-lg bg-components-input-bg-normal p-1">
<ModelIcon
className="p-0.5"
provider={currentProvider || modelProviders.find(item => item.provider === providerName)}
modelName={currentModel?.model}
/>
<div className="flex flex-1 items-center truncate px-1 py-[3px]">
{currentModel
? <ModelName className="grow" modelItem={currentModel} showMode showFeatures />
: <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">
<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(showCreditsExhausted ? 'modelProvider.selector.creditsExhausted' : 'modelProvider.selector.apiKeyUnavailable', { ns: 'common' })}
</span>
</div>
</div>
</div>
<div className="flex shrink-0 items-center justify-center rounded-r-lg bg-components-button-tertiary-bg p-2">
<SlidersH className="h-4 w-4 text-text-tertiary" />
</div>
</div>
)
}
return (
<div
className={cn(
'relative flex h-8 cursor-pointer items-center rounded-lg px-2',
'relative flex h-8 min-w-[296px] cursor-pointer items-center rounded-lg px-2',
!isInWorkflow && 'border ring-inset hover:ring-[0.5px]',
!isInWorkflow && (disabled ? 'border-text-warning bg-state-warning-hover ring-text-warning' : 'border-util-colors-indigo-indigo-600 bg-state-accent-hover ring-util-colors-indigo-indigo-600'),
isInWorkflow && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg pr-[30px] hover:border-components-input-border-active',
isInWorkflow && !isEmpty && 'border border-workflow-block-parma-bg bg-workflow-block-parma-bg pr-[30px] hover:border-components-input-border-active',
isInWorkflow && isEmpty && 'border border-text-warning bg-state-warning-hover pr-[30px]',
)}
>
{
currentProvider && (
isEmpty && (
<div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center">
<div className="flex h-5 w-5 items-center justify-center rounded-md border-[0.5px] border-components-panel-border-subtle bg-background-default-subtle">
<span className="i-ri-brain-2-line h-3.5 w-3.5 text-text-quaternary" />
</div>
</div>
)
}
{
!isEmpty && currentProvider && (
<ModelIcon
className="mr-1.5 !h-5 !w-5"
provider={currentProvider}
@ -60,7 +108,7 @@ const Trigger: FC<TriggerProps> = ({
)
}
{
!currentProvider && (
!isEmpty && !currentProvider && (
<ModelIcon
className="mr-1.5 !h-5 !w-5"
provider={modelProviders.find(item => item.provider === providerName)}
@ -69,7 +117,7 @@ const Trigger: FC<TriggerProps> = ({
)
}
{
currentModel && (
!isEmpty && currentModel && (
<ModelName
className="mr-1.5 text-text-primary"
modelItem={currentModel}
@ -79,32 +127,57 @@ const Trigger: FC<TriggerProps> = ({
)
}
{
!currentModel && (
!isEmpty && !currentModel && (
<div className="mr-1 truncate text-[13px] font-medium text-text-primary">
{modelId}
</div>
)
}
{
disabled
? (
<Tooltip
popupContent={
hasDeprecated
? t('modelProvider.deprecated', { ns: 'common' })
: (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')} />
)
isEmpty && (
<div className="mr-1 flex-1 truncate text-[13px] font-normal text-text-secondary">
{t('workflow:errorMsg.configureModel')}
</div>
)
}
{isInWorkflow && (<RiArrowDownSLine className="absolute right-2 top-[9px] h-3.5 w-3.5 text-text-tertiary" />)}
{
!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">
<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>
</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')} />
)
)
}
{
isEmpty && (
<RiArrowDownSLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isInWorkflow && 'absolute right-2 top-[9px] h-3.5 w-3.5')} />
)
}
{!isEmpty && isInWorkflow && (<RiArrowDownSLine className="absolute right-2 top-[9px] h-3.5 w-3.5 text-text-tertiary" />)}
</div>
)
}

View File

@ -1,6 +1,11 @@
import type { TypeWithI18N } from '../header/account-setting/model-provider-page/declarations'
import type { VarType } from '../workflow/types'
type LocalizedText<T = string> = {
en_US: T
zh_Hans: T
[key: string]: T
}
export enum LOC {
tools = 'tools',
app = 'app',
@ -47,10 +52,10 @@ export type Collection = {
id: string
name: string
author: string
description: TypeWithI18N
description: LocalizedText
icon: string | Emoji
icon_dark?: string | Emoji
label: TypeWithI18N
label: LocalizedText
type: CollectionType | string
team_credentials: Record<string, any>
is_team_authorization: boolean
@ -84,8 +89,8 @@ export type Collection = {
export type ToolParameter = {
name: string
label: TypeWithI18N
human_description: TypeWithI18N
label: LocalizedText
human_description: LocalizedText
type: string
form: string
llm_description: string
@ -93,7 +98,7 @@ export type ToolParameter = {
multiple: boolean
default: string
options?: {
label: TypeWithI18N
label: LocalizedText
value: string
}[]
min?: number
@ -102,8 +107,8 @@ export type ToolParameter = {
export type TriggerParameter = {
name: string
label: TypeWithI18N
human_description: TypeWithI18N
label: LocalizedText
human_description: LocalizedText
type: string
form: string
llm_description: string
@ -111,7 +116,7 @@ export type TriggerParameter = {
multiple: boolean
default: string
options?: {
label: TypeWithI18N
label: LocalizedText
value: string
}[]
}
@ -120,8 +125,8 @@ export type TriggerParameter = {
export type Event = {
name: string
author: string
label: TypeWithI18N
description: TypeWithI18N
label: LocalizedText
description: LocalizedText
parameters: TriggerParameter[]
labels: string[]
output_schema: Record<string, any>
@ -130,7 +135,7 @@ export type Event = {
export type Tool = {
name: string
author: string
label: TypeWithI18N
label: LocalizedText
description: any
parameters: ToolParameter[]
labels: string[]
@ -139,14 +144,14 @@ export type Tool = {
export type ToolCredential = {
name: string
label: TypeWithI18N
help: TypeWithI18N | null
placeholder: TypeWithI18N
label: LocalizedText
help: LocalizedText | null
placeholder: LocalizedText
type: string
required: boolean
default: string
options?: {
label: TypeWithI18N
label: LocalizedText
value: string
}[]
}
@ -167,8 +172,8 @@ export type CustomCollectionBackend = {
export type ParamItem = {
name: string
label: TypeWithI18N
human_description: TypeWithI18N
label: LocalizedText
human_description: LocalizedText
llm_description: string
type: string
form: string
@ -177,7 +182,7 @@ export type ParamItem = {
min?: number
max?: number
options?: {
label: TypeWithI18N
label: LocalizedText
value: string
}[]
}
@ -233,8 +238,8 @@ export type WorkflowToolProviderResponse = {
tool: {
author: string
name: string
label: TypeWithI18N
description: TypeWithI18N
label: LocalizedText
description: LocalizedText
labels: string[]
parameters: ParamItem[]
output_schema: WorkflowToolProviderOutputSchema

View File

@ -7016,9 +7016,6 @@
"app/components/header/account-setting/model-provider-page/model-parameter-modal/trigger.tsx": {
"no-restricted-imports": {
"count": 1
},
"tailwindcss/no-unnecessary-whitespace": {
"count": 2
}
},
"app/components/header/account-setting/model-provider-page/model-selector/feature-icon.tsx": {

View File

@ -235,11 +235,16 @@
"inputs.run": "RUN",
"inputs.title": "Debug & Preview",
"inputs.userInputField": "User Input Field",
"manageModels": "Manage models",
"modelConfig.modeType.chat": "Chat",
"modelConfig.modeType.completion": "Complete",
"modelConfig.model": "Model",
"modelConfig.setTone": "Set tone of responses",
"modelConfig.title": "Model and Parameters",
"noModelProviderConfigured": "No model provider configured",
"noModelProviderConfiguredTip": "Install or configure a model provider to get started.",
"noModelSelected": "No model selected",
"noModelSelectedTip": "configure a model above to continue.",
"noResult": "Output will be displayed here.",
"notSetAPIKey.description": "The LLM provider key has not been set, and it needs to be set before debugging.",
"notSetAPIKey.settingBtn": "Go to settings",

View File

@ -235,11 +235,16 @@
"inputs.run": "実行",
"inputs.title": "デバッグとプレビュー",
"inputs.userInputField": "ユーザー入力フィールド",
"manageModels": "モデルを管理",
"modelConfig.modeType.chat": "チャット",
"modelConfig.modeType.completion": "完成",
"modelConfig.model": "モデル",
"modelConfig.setTone": "応答のトーンを設定する",
"modelConfig.title": "モデルとパラメータ",
"noModelProviderConfigured": "モデルプロバイダーが設定されていません",
"noModelProviderConfiguredTip": "モデルプロバイダーをインストールまたは設定して開始してください。",
"noModelSelected": "モデルが選択されていません",
"noModelSelectedTip": "続行するには、上でモデルを設定してください。",
"noResult": "出力はここに表示されます。",
"notSetAPIKey.description": "LLM プロバイダーキーが設定されていません。デバッグする前に設定する必要があります。",
"notSetAPIKey.settingBtn": "設定に移動",

View File

@ -235,11 +235,16 @@
"inputs.run": "运行",
"inputs.title": "调试与预览",
"inputs.userInputField": "用户输入",
"manageModels": "管理模型",
"modelConfig.modeType.chat": "对话型",
"modelConfig.modeType.completion": "补全型",
"modelConfig.model": "语言模型",
"modelConfig.setTone": "模型设置",
"modelConfig.title": "模型及参数",
"noModelProviderConfigured": "未配置模型供应商",
"noModelProviderConfiguredTip": "请先安装或配置模型供应商以开始使用。",
"noModelSelected": "未选择模型",
"noModelSelectedTip": "请先在上方配置模型以继续。",
"noResult": "输出结果展示在这",
"notSetAPIKey.description": "在调试之前需要设置 LLM 提供者的密钥。",
"notSetAPIKey.settingBtn": "去设置",