fix: unify model status display across knowledge base and model triggers

This commit is contained in:
CodingOnStar
2026-03-12 14:00:04 +08:00
parent fee6d13f44
commit 911d52cafc
16 changed files with 746 additions and 198 deletions

View File

@ -0,0 +1,61 @@
import type {
Model,
ModelItem,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useMemo } from 'react'
import { deriveModelStatus } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import { useProviderContext } from '@/context/provider-context'
type UseEmbeddingModelStatusProps = {
embeddingModel?: string
embeddingModelProvider?: string
embeddingModelList: Model[]
}
type UseEmbeddingModelStatusResult = {
providerMeta: ModelProvider | undefined
modelProvider: Model | undefined
currentModel: ModelItem | undefined
status: ReturnType<typeof deriveModelStatus>
}
export const useEmbeddingModelStatus = ({
embeddingModel,
embeddingModelProvider,
embeddingModelList,
}: UseEmbeddingModelStatusProps): UseEmbeddingModelStatusResult => {
const { modelProviders } = useProviderContext()
const providerMeta = useMemo(() => {
return modelProviders.find(provider => provider.provider === embeddingModelProvider)
}, [embeddingModelProvider, modelProviders])
const modelProvider = useMemo(() => {
return embeddingModelList.find(provider => provider.provider === embeddingModelProvider)
}, [embeddingModelList, embeddingModelProvider])
const currentModel = useMemo(() => {
return modelProvider?.models.find(model => model.model === embeddingModel)
}, [embeddingModel, modelProvider])
const credentialState = useCredentialPanelState(providerMeta)
const status = useMemo(() => {
return deriveModelStatus(
embeddingModel,
embeddingModelProvider,
providerMeta,
currentModel,
credentialState,
)
}, [credentialState, currentModel, embeddingModel, embeddingModelProvider, providerMeta])
return {
providerMeta,
modelProvider,
currentModel,
status,
}
}

View File

@ -0,0 +1,161 @@
import type { KnowledgeBaseNodeType } from './types'
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from './node'
import {
ChunkStructureEnum,
IndexMethodEnum,
RetrievalSearchMethodEnum,
} from './types'
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockUseSettingsDisplay = vi.hoisted(() => vi.fn())
const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>()
return {
...actual,
useLanguage: () => 'en_US',
useModelList: mockUseModelList,
}
})
vi.mock('./hooks/use-settings-display', () => ({
useSettingsDisplay: mockUseSettingsDisplay,
}))
vi.mock('./hooks/use-embedding-model-status', () => ({
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const createNodeData = (overrides: Partial<CommonNodeType<KnowledgeBaseNodeType>> = {}): CommonNodeType<KnowledgeBaseNodeType> => ({
title: 'Knowledge Base',
desc: '',
type: BlockEnum.KnowledgeBase,
index_chunk_variable_selector: ['result'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
keyword_number: 10,
retrieval_model: {
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
search_method: RetrievalSearchMethodEnum.semantic,
},
...overrides,
})
describe('KnowledgeBaseNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModelList.mockReturnValue({ data: [] })
mockUseSettingsDisplay.mockReturnValue({
[IndexMethodEnum.QUALIFIED]: 'High Quality',
[RetrievalSearchMethodEnum.semantic]: 'Vector Search',
})
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: createModelItem(),
status: 'active',
})
})
// Embedding model row should mirror the selector status labels.
describe('Embedding Model Status', () => {
it('should render active embedding model label when the model is available', () => {
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('Text Embedding 3 Large')).toBeInTheDocument()
})
it('should render configure required when embedding model status requires configuration', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: createModelItem({ status: ModelStatusEnum.noConfigure }),
status: 'configure-required',
})
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
})
it('should render disabled when embedding model status is disabled', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: createModelItem({ status: ModelStatusEnum.disabled }),
status: 'disabled',
})
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
})
it('should render incompatible when embedding model status is incompatible', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: undefined,
status: 'incompatible',
})
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
})
it('should render configure model prompt when no embedding model is selected', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: undefined,
status: 'empty',
})
render(
<Node
id="knowledge-base-1"
data={createNodeData({
embedding_model: undefined,
embedding_model_provider: undefined,
})}
/>,
)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
})
})

View File

@ -10,12 +10,14 @@ import { useTranslation } from 'react-i18next'
import {
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { DERIVED_MODEL_STATUS_BADGE_I18N } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
import {
useLanguage,
useModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { consoleQuery } from '@/service/client'
import { cn } from '@/utils/classnames'
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
import { useSettingsDisplay } from './hooks/use-settings-display'
import {
IndexMethodEnum,
@ -23,7 +25,6 @@ import {
import {
getKnowledgeBaseValidationIssue,
getKnowledgeBaseValidationMessage,
isKnowledgeBaseEmbeddingIssue,
KnowledgeBaseValidationIssueCode,
} from './utils'
@ -128,6 +129,11 @@ const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => {
const validationIssueMessage = useMemo(() => {
return getKnowledgeBaseValidationMessage(validationIssue, t)
}, [validationIssue, t])
const { currentModel: currentEmbeddingModel, status: embeddingModelStatus } = useEmbeddingModelStatus({
embeddingModel: data.embedding_model,
embeddingModelProvider: data.embedding_model_provider,
embeddingModelList,
})
const chunksDisplayValue = useMemo(() => {
if (!data.index_chunk_variable_selector?.length)
@ -141,23 +147,24 @@ const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => {
if (data.indexing_technique !== IndexMethodEnum.QUALIFIED)
return '-'
if (isKnowledgeBaseEmbeddingIssue(validationIssue)) {
if (validationIssue?.code === KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
return t('nodes.knowledgeBase.notConfigured', { ns: 'workflow' })
return validationIssueMessage
if (embeddingModelStatus === 'empty')
return t('detailPanel.configureModel', { ns: 'plugin' })
if (embeddingModelStatus !== 'active') {
const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[embeddingModelStatus]
if (statusI18nKey)
return t(statusI18nKey as 'modelProvider.selector.incompatible', { ns: 'common' })
}
const currentEmbeddingModelProvider = embeddingModelList.find(provider => provider.provider === data.embedding_model_provider)
const currentEmbeddingModel = currentEmbeddingModelProvider?.models.find(model => model.model === data.embedding_model)
return currentEmbeddingModel?.label[language] || currentEmbeddingModel?.label.en_US || data.embedding_model || '-'
}, [data.embedding_model, data.embedding_model_provider, data.indexing_technique, embeddingModelList, language, validationIssue, validationIssueMessage, t])
}, [currentEmbeddingModel, data.embedding_model, data.indexing_technique, embeddingModelStatus, language, t])
const indexMethodDisplay = settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay] || '-'
const retrievalMethodDisplay = settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay] || '-'
const chunksWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired
const indexMethodWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.indexMethodRequired
const embeddingWarning = isKnowledgeBaseEmbeddingIssue(validationIssue)
const embeddingWarning = data.indexing_technique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active'
const showEmbeddingModelRow = data.indexing_technique === IndexMethodEnum.QUALIFIED
const retrievalWarning = !!(validationIssue && RETRIEVAL_WARNING_CODES.has(validationIssue.code))

View File

@ -27,13 +27,13 @@ import EmbeddingModel from './components/embedding-model'
import IndexMethod from './components/index-method'
import RetrievalSetting from './components/retrieval-setting'
import { useConfig } from './hooks/use-config'
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
import {
ChunkStructureEnum,
IndexMethodEnum,
} from './types'
import {
getKnowledgeBaseValidationIssue,
isKnowledgeBaseEmbeddingIssue,
KnowledgeBaseValidationIssueCode,
} from './utils'
@ -164,10 +164,15 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
const validationIssue = useMemo(() => {
return getKnowledgeBaseValidationIssue(validationPayload)
}, [validationPayload])
const { status: embeddingModelStatus } = useEmbeddingModelStatus({
embeddingModel,
embeddingModelProvider,
embeddingModelList,
})
const chunkStructureWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunkStructureRequired
const chunksInputWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired
const embeddingModelWarning = isKnowledgeBaseEmbeddingIssue(validationIssue)
const embeddingModelWarning = indexingTechnique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active'
return (
<div>

View File

@ -79,11 +79,11 @@ describe('knowledge-base validation issue', () => {
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunksVariableRequired)
})
it('maps no-configure to not configured', () => {
it('maps no-configure to configure required', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noConfigure) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired)
})
it('maps credential-removed to API key unavailable', () => {
@ -100,10 +100,20 @@ describe('knowledge-base validation issue', () => {
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted)
})
it('maps disabled to incompatible', () => {
it('maps disabled to disabled', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.disabled) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelDisabled)
})
it('maps missing provider plugin to incompatible when embedding model is already configured', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({
embedding_model_provider: 'missing-provider',
_embeddingProviderModelList: undefined,
}),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
})

View File

@ -1,6 +1,5 @@
import type { TFunction } from 'i18next'
import type { KnowledgeBaseNodeType } from './types'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import {
IndexingType,
} from '@/app/components/datasets/create/step-two'
@ -22,8 +21,10 @@ export enum KnowledgeBaseValidationIssueCode {
chunksVariableRequired = 'chunks-variable-required',
indexMethodRequired = 'index-method-required',
embeddingModelNotConfigured = 'embedding-model-not-configured',
embeddingModelConfigureRequired = 'embedding-model-configure-required',
embeddingModelApiKeyUnavailable = 'embedding-model-api-key-unavailable',
embeddingModelCreditsExhausted = 'embedding-model-credits-exhausted',
embeddingModelDisabled = 'embedding-model-disabled',
embeddingModelIncompatible = 'embedding-model-incompatible',
retrievalSettingRequired = 'retrieval-setting-required',
rerankingModelRequired = 'reranking-model-required',
@ -32,36 +33,23 @@ export enum KnowledgeBaseValidationIssueCode {
type KnowledgeBaseValidationIssue = {
code: KnowledgeBaseValidationIssueCode
i18nKey: I18nKeysWithPrefix<'workflow', 'nodes.knowledgeBase.'>
}
type KnowledgeBaseValidationPayload = Pick<KnowledgeBaseNodeType, 'chunk_structure' | 'index_chunk_variable_selector' | 'indexing_technique' | 'embedding_model' | 'embedding_model_provider' | '_embeddingModelList' | '_embeddingProviderModelList' | '_rerankModelList'> & {
retrieval_model?: Pick<KnowledgeBaseNodeType['retrieval_model'], 'search_method' | 'reranking_enable' | 'reranking_model'>
}
const ISSUE_I18N_KEY_MAP = {
[KnowledgeBaseValidationIssueCode.chunkStructureRequired]: 'nodes.knowledgeBase.chunkIsRequired',
[KnowledgeBaseValidationIssueCode.chunksVariableRequired]: 'nodes.knowledgeBase.chunksVariableIsRequired',
[KnowledgeBaseValidationIssueCode.indexMethodRequired]: 'nodes.knowledgeBase.indexMethodIsRequired',
[KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured]: 'nodes.knowledgeBase.embeddingModelNotConfigured',
[KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable]: 'nodes.knowledgeBase.embeddingModelApiKeyUnavailable',
[KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted]: 'nodes.knowledgeBase.embeddingModelCreditsExhausted',
[KnowledgeBaseValidationIssueCode.embeddingModelIncompatible]: 'nodes.knowledgeBase.embeddingModelIncompatible',
[KnowledgeBaseValidationIssueCode.retrievalSettingRequired]: 'nodes.knowledgeBase.retrievalSettingIsRequired',
[KnowledgeBaseValidationIssueCode.rerankingModelRequired]: 'nodes.knowledgeBase.rerankingModelIsRequired',
[KnowledgeBaseValidationIssueCode.rerankingModelInvalid]: 'nodes.knowledgeBase.rerankingModelIsInvalid',
} as const satisfies Record<KnowledgeBaseValidationIssueCode, I18nKeysWithPrefix<'workflow', 'nodes.knowledgeBase.'>>
const EMBEDDING_ISSUE_CODES = new Set<KnowledgeBaseValidationIssueCode>([
KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured,
KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired,
KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable,
KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted,
KnowledgeBaseValidationIssueCode.embeddingModelDisabled,
KnowledgeBaseValidationIssueCode.embeddingModelIncompatible,
])
const resolveIssue = (code: KnowledgeBaseValidationIssueCode): KnowledgeBaseValidationIssue => ({
code,
i18nKey: ISSUE_I18N_KEY_MAP[code],
})
const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => {
@ -83,6 +71,9 @@ const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): Knowled
const currentEmbeddingModel = embeddingModelCandidates?.find(model => model.model === embedding_model)
if (!currentEmbeddingModel) {
if (!currentEmbeddingModelProvider)
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
const providerExists = hasProviderScopedModelList || currentEmbeddingModelProvider
return resolveIssue(providerExists
? KnowledgeBaseValidationIssueCode.embeddingModelIncompatible
@ -93,13 +84,14 @@ const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): Knowled
case ModelStatusEnum.active:
return null
case ModelStatusEnum.noConfigure:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired)
case ModelStatusEnum.credentialRemoved:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable)
case ModelStatusEnum.quotaExceeded:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted)
case ModelStatusEnum.noPermission:
case ModelStatusEnum.disabled:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelDisabled)
case ModelStatusEnum.noPermission:
default:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
}
@ -158,7 +150,34 @@ export const getKnowledgeBaseValidationMessage = (
if (!issue)
return ''
return t(issue.i18nKey, { ns: 'workflow' })
switch (issue.code) {
case KnowledgeBaseValidationIssueCode.chunkStructureRequired:
return t('nodes.knowledgeBase.chunkIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.chunksVariableRequired:
return t('nodes.knowledgeBase.chunksVariableIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.indexMethodRequired:
return t('nodes.knowledgeBase.indexMethodIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured:
return t('nodes.knowledgeBase.embeddingModelNotConfigured', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired:
return t('modelProvider.selector.configureRequired', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable:
return t('modelProvider.selector.apiKeyUnavailable', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted:
return t('modelProvider.selector.creditsExhausted', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelDisabled:
return t('modelProvider.selector.disabled', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelIncompatible:
return t('modelProvider.selector.incompatible', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.retrievalSettingRequired:
return t('nodes.knowledgeBase.retrievalSettingIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.rerankingModelRequired:
return t('nodes.knowledgeBase.rerankingModelIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.rerankingModelInvalid:
return t('nodes.knowledgeBase.rerankingModelIsInvalid', { ns: 'workflow' })
default:
return ''
}
}
export const isKnowledgeBaseEmbeddingIssue = (issue: KnowledgeBaseValidationIssue | null | undefined) => {