diff --git a/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts b/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts new file mode 100644 index 00000000000..1665c1c89cb --- /dev/null +++ b/web/features/agent-v2/agent-composer/__tests__/knowledge-validation.spec.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { LogicalOperator, MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { RETRIEVE_TYPE } from '@/types/app' +import { validateKnowledgeRetrievals } from '../knowledge-validation' + +describe('validateKnowledgeRetrievals', () => { + it('should accept a valid generated-query knowledge retrieval', () => { + const result = validateKnowledgeRetrievals([ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [{ id: 'dataset-1', name: 'Docs' }], + queryMode: 'agent', + retrievalMode: RETRIEVE_TYPE.multiWay, + }, + ]) + + expect(result).toMatchObject({ + isValid: true, + byId: {}, + firstIssue: undefined, + }) + }) + + it('should reject blank and duplicate names', () => { + const result = validateKnowledgeRetrievals([ + { + id: 'retrieval-1', + name: ' ', + datasetRefs: [{ id: 'dataset-1', name: 'Docs' }], + }, + { + id: 'retrieval-2', + name: 'Docs Search', + datasetRefs: [{ id: 'dataset-2', name: 'FAQ' }], + }, + { + id: 'retrieval-3', + name: ' docs search ', + datasetRefs: [{ id: 'dataset-3', name: 'Guide' }], + }, + ]) + + expect(result.isValid).toBe(false) + expect(result.byId['retrieval-1']).toMatchObject({ name: 'name_required' }) + expect(result.byId['retrieval-2']).toMatchObject({ name: 'name_duplicate' }) + expect(result.byId['retrieval-3']).toMatchObject({ name: 'name_duplicate' }) + }) + + it('should reject missing datasets, blank custom queries, and missing single-retrieval model', () => { + const result = validateKnowledgeRetrievals([ + { + id: 'retrieval-1', + name: 'Product Docs', + queryMode: 'custom', + customQuery: ' ', + retrievalMode: RETRIEVE_TYPE.oneWay, + singleRetrievalConfig: { + model: { + provider: '', + name: '', + mode: 'chat', + completion_params: {}, + }, + }, + }, + ]) + + expect(result.isValid).toBe(false) + expect(result.byId['retrieval-1']).toMatchObject({ + datasets: 'datasets_required', + query: 'custom_query_required', + retrieval: 'single_model_required', + }) + }) + + it('should reject invalid metadata filtering requirements', () => { + const result = validateKnowledgeRetrievals([ + { + id: 'retrieval-1', + name: 'Auto Metadata', + datasetRefs: [{ id: 'dataset-1', name: 'Docs' }], + metadataFilterMode: MetadataFilteringModeEnum.automatic, + }, + { + id: 'retrieval-2', + name: 'Manual Metadata', + datasetRefs: [{ id: 'dataset-2', name: 'FAQ' }], + metadataFilterMode: MetadataFilteringModeEnum.manual, + metadataFilteringConditions: { + logical_operator: LogicalOperator.and, + conditions: [], + }, + }, + ]) + + expect(result.isValid).toBe(false) + expect(result.byId['retrieval-1']).toMatchObject({ metadata: 'metadata_model_required' }) + expect(result.byId['retrieval-2']).toMatchObject({ metadata: 'metadata_conditions_required' }) + }) +}) diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index a48369d876a..5e0867f5761 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -4,7 +4,7 @@ import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conve import { defaultAgentSoulConfigFormState } from '../form-state' describe('agent composer store conversions', () => { - it('should hydrate editable form state from an AgentSoulConfig and preserve it in publish payload', () => { + it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => { const baseConfig: AgentSoulConfig = { app_features: { opening_statement: 'Hello', @@ -27,20 +27,30 @@ describe('agent composer store conversions', () => { ], }, knowledge: { - datasets: [ + sets: [ { - id: 'dataset-1', + id: 'support', name: 'Product Docs', description: 'Docs corpus', + datasets: [ + { + id: 'dataset-1', + name: 'Product Docs', + description: 'Docs corpus', + }, + ], + query: { + mode: 'user_query', + value: 'release notes', + }, + retrieval: { + mode: 'multiple', + top_k: 8, + score_threshold: 0.72, + reranking_enable: false, + }, }, ], - query_config: { - query: 'release notes', - score_threshold: 0.72, - score_threshold_enabled: true, - top_k: 8, - }, - query_mode: 'user_query', }, model: { model: 'gpt-4.1', @@ -100,7 +110,7 @@ describe('agent composer store conversions', () => { }, knowledgeRetrievals: [ expect.objectContaining({ - id: 'dataset-1', + id: 'support', name: 'Product Docs', queryMode: 'custom', customQuery: 'release notes', @@ -166,20 +176,30 @@ describe('agent composer store conversions', () => { }), ]) expect(publishConfig.knowledge).toMatchObject({ - datasets: [ + sets: [ { - id: 'dataset-1', + id: 'support', name: 'Product Docs', description: 'Docs corpus', + datasets: [ + { + id: 'dataset-1', + name: 'Product Docs', + description: 'Docs corpus', + }, + ], + query: { + mode: 'user_query', + value: 'release notes', + }, + retrieval: { + mode: 'multiple', + top_k: 8, + score_threshold: 0.72, + reranking_enable: false, + }, }, ], - query_config: { - query: 'release notes', - score_threshold: 0.72, - score_threshold_enabled: true, - top_k: 8, - }, - query_mode: 'user_query', }) expect(publishConfig.env).toMatchObject({ variables: [ @@ -285,21 +305,232 @@ describe('agent composer store conversions', () => { }) }) - it('should not hydrate a knowledge retrieval row when the config has no datasets', () => { + it('should not hydrate a knowledge retrieval row when the config has no sets', () => { const formState = agentSoulConfigToFormState({ knowledge: { - datasets: [], - query_config: { - top_k: 4, - }, - query_mode: 'generated_query', + sets: [], }, }) expect(formState.knowledgeRetrievals).toEqual([]) }) - it('should omit incomplete environment variables from the publish payload', () => { + it('should keep explicitly cleared selected datasets instead of falling back to dataset refs', () => { + const publishConfig = formStateToAgentSoulConfig({ + formState: { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + selectedDatasets: [], + datasetRefs: [ + { + id: 'dataset-stale', + name: 'Stale Docs', + description: 'Should stay cleared', + }, + ], + }, + ], + }, + }) + + expect(publishConfig.knowledge).toMatchObject({ + sets: [ + { + id: 'retrieval-1', + datasets: [], + }, + ], + }) + }) + + it('should round-trip single retrieval model config', () => { + const baseConfig: AgentSoulConfig = { + knowledge: { + sets: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasets: [{ id: 'dataset-1', name: 'Docs' }], + query: { mode: 'generated_query' }, + retrieval: { + mode: 'single', + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4.1', + mode: 'chat', + completion_params: { temperature: 0.1 }, + }, + }, + }, + ], + }, + } + + const formState = agentSoulConfigToFormState(baseConfig) + const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(formState.knowledgeRetrievals).toEqual([ + expect.objectContaining({ + id: 'retrieval-1', + retrievalMode: 'single', + singleRetrievalConfig: { + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4.1', + mode: 'chat', + completion_params: { temperature: 0.1 }, + }, + }, + }), + ]) + expect(publishConfig.knowledge).toMatchObject({ + sets: [ + { + id: 'retrieval-1', + retrieval: { + mode: 'single', + model: { + provider: 'langgenius/openai/openai', + name: 'gpt-4.1', + mode: 'chat', + completion_params: { temperature: 0.1 }, + }, + }, + }, + ], + }) + }) + + it('should round-trip automatic metadata filtering model config', () => { + const baseConfig: AgentSoulConfig = { + knowledge: { + sets: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasets: [{ id: 'dataset-1', name: 'Docs' }], + query: { mode: 'generated_query' }, + retrieval: { mode: 'multiple', top_k: 4 }, + metadata_filtering: { + mode: 'automatic', + model_config: { + provider: 'langgenius/openai/openai', + name: 'gpt-4.1-mini', + mode: 'chat', + completion_params: { temperature: 0.2 }, + }, + }, + }, + ], + }, + } + + const formState = agentSoulConfigToFormState(baseConfig) + const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(formState.knowledgeRetrievals).toEqual([ + expect.objectContaining({ + id: 'retrieval-1', + metadataFilterMode: 'automatic', + metadataModelConfig: { + provider: 'langgenius/openai/openai', + name: 'gpt-4.1-mini', + mode: 'chat', + completion_params: { temperature: 0.2 }, + }, + }), + ]) + expect(publishConfig.knowledge).toMatchObject({ + sets: [ + { + id: 'retrieval-1', + metadata_filtering: { + mode: 'automatic', + model_config: { + provider: 'langgenius/openai/openai', + name: 'gpt-4.1-mini', + mode: 'chat', + completion_params: { temperature: 0.2 }, + }, + }, + }, + ], + }) + }) + + it('should round-trip manual metadata filtering conditions', () => { + const baseConfig: AgentSoulConfig = { + knowledge: { + sets: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasets: [{ id: 'dataset-1', name: 'Docs' }], + query: { mode: 'generated_query' }, + retrieval: { mode: 'multiple', top_k: 4 }, + metadata_filtering: { + mode: 'manual', + conditions: { + logical_operator: 'and', + conditions: [ + { + name: 'language', + comparison_operator: 'is', + value: 'en', + }, + ], + }, + }, + }, + ], + }, + } + + const formState = agentSoulConfigToFormState(baseConfig) + const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState }) + + expect(formState.knowledgeRetrievals).toEqual([ + expect.objectContaining({ + id: 'retrieval-1', + metadataFilterMode: 'manual', + metadataFilteringConditions: { + logical_operator: 'and', + conditions: [ + { + name: 'language', + comparison_operator: 'is', + value: 'en', + }, + ], + }, + }), + ]) + expect(publishConfig.knowledge).toMatchObject({ + sets: [ + { + id: 'retrieval-1', + metadata_filtering: { + mode: 'manual', + conditions: { + logical_operator: 'and', + conditions: [ + { + name: 'language', + comparison_operator: 'is', + value: 'en', + }, + ], + }, + }, + }, + ], + }) + }) + + it('should omit incomplete environment variables from the config snapshot', () => { const publishConfig = formStateToAgentSoulConfig({ formState: { ...defaultAgentSoulConfigFormState, diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index e9d0fe9fecd..949b8f17e91 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -1,4 +1,10 @@ -import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' +import type { + AgentKnowledgeMetadataConditions, + AgentKnowledgeModelConfig, + AgentKnowledgeRetrievalConfig, + AgentKnowledgeSetConfig, + AgentSoulConfig, +} from '@dify/contracts/api/console/agent/types.gen' import type { AgentCliTool, AgentKnowledgeRetrievalItem, @@ -8,80 +14,132 @@ import type { EnvVariable, } from './form-state' import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { + MetadataFilteringConditions, + MultipleRetrievalConfig, + SingleRetrievalConfig, +} from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import type { ModelConfig } from '@/app/components/workflow/types' +import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { DATASET_DEFAULT } from '@/config' +import { RETRIEVE_TYPE } from '@/types/app' import { checkKey } from '@/utils/var' import { defaultAgentSoulConfigFormState } from './form-state' +import { getKnowledgeRetrievalSetName } from './knowledge-validation' type AgentSoulDifyToolConfig = NonNullable['dify_tools']>[number] type AgentSoulCliToolConfig = NonNullable['cli_tools']>[number] type AgentSoulToolRuntimeParameterValue = NonNullable[string] type AgentSoulEnvVariableConfig = NonNullable['variables']>[number] -const getKnowledgeRetrievalName = (item: AgentKnowledgeRetrievalItem) => item.name ?? item.nameKey ?? item.id - -const toKnowledgeDatasets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]) => knowledgeRetrievals.flatMap((item) => { - if (item.selectedDatasets?.length) { +const toKnowledgeDatasetRefs = (item: AgentKnowledgeRetrievalItem) => { + if (item.selectedDatasets !== undefined) { return item.selectedDatasets.map(dataset => ({ description: dataset.description, id: dataset.id, name: dataset.name, })) } - if (item.datasetRefs?.length) - return item.datasetRefs - return [{ - id: item.id, - name: getKnowledgeRetrievalName(item), - }] + return item.datasetRefs ?? [] +} + +const toRetrievalConfig = (item: AgentKnowledgeRetrievalItem): AgentKnowledgeRetrievalConfig => { + if (item.retrievalMode === RETRIEVE_TYPE.oneWay) { + return { + mode: 'single', + model: item.singleRetrievalConfig?.model, + } + } + + const config = item.multipleRetrievalConfig + return { + mode: 'multiple', + top_k: config?.top_k ?? DATASET_DEFAULT.top_k, + score_threshold: config?.score_threshold ?? undefined, + reranking_mode: config?.reranking_mode, + reranking_enable: config?.reranking_enable ?? false, + reranking_model: config?.reranking_model, + weights: config?.weights, + } +} + +const toModelFormState = (model?: AgentKnowledgeModelConfig | null): ModelConfig | undefined => { + if (!model) + return undefined + + return { + provider: model.provider, + name: model.name, + mode: model.mode, + completion_params: model.completion_params ?? {}, + } +} + +const toMultipleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): MultipleRetrievalConfig => ({ + top_k: config?.top_k ?? DATASET_DEFAULT.top_k, + score_threshold: config?.score_threshold ?? null, + reranking_model: config?.reranking_model ?? undefined, + reranking_mode: config?.reranking_mode as MultipleRetrievalConfig['reranking_mode'], + weights: config?.weights as MultipleRetrievalConfig['weights'], + reranking_enable: config?.reranking_enable ?? false, }) +const toSingleRetrievalFormState = (config?: AgentKnowledgeRetrievalConfig): SingleRetrievalConfig | undefined => ( + config?.model + ? { + model: toModelFormState(config.model)!, + } + : undefined +) + +const toMetadataFilteringConfig = (item: AgentKnowledgeRetrievalItem): AgentKnowledgeSetConfig['metadata_filtering'] => { + const mode = item.metadataFilterMode ?? MetadataFilteringModeEnum.disabled + + return { + mode, + model_config: mode === MetadataFilteringModeEnum.automatic ? item.metadataModelConfig : undefined, + conditions: mode === MetadataFilteringModeEnum.manual + ? item.metadataFilteringConditions as AgentKnowledgeMetadataConditions | undefined + : undefined, + } +} + +const toKnowledgeSets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]): AgentKnowledgeSetConfig[] => knowledgeRetrievals.map(item => ({ + id: item.id, + name: getKnowledgeRetrievalSetName(item), + description: item.description, + datasets: toKnowledgeDatasetRefs(item), + query: { + mode: item.queryMode === 'custom' ? ('user_query' as const) : ('generated_query' as const), + value: item.queryMode === 'custom' ? (item.customQuery?.trim() || undefined) : undefined, + }, + retrieval: toRetrievalConfig(item), + metadata_filtering: toMetadataFilteringConfig(item), +})) + const toKnowledgeRetrievalFormState = (config?: AgentSoulConfig): AgentKnowledgeRetrievalItem[] => { - const knowledge = config?.knowledge - const datasets = knowledge?.datasets ?? [] - - if (datasets.length === 0) - return [] - - return [{ - id: datasets[0]?.id ?? 'knowledge-retrieval', - name: datasets[0]?.name ?? 'Knowledge Retrieval', - queryMode: knowledge?.query_mode === 'user_query' ? 'custom' : 'agent', - customQuery: knowledge?.query_config?.query ?? undefined, - datasetRefs: datasets, - multipleRetrievalConfig: { - top_k: knowledge?.query_config?.top_k ?? 4, - score_threshold: knowledge?.query_config?.score_threshold ?? null, - reranking_enable: false, - }, - }] + return (config?.knowledge?.sets ?? []).map(knowledgeSet => ({ + id: knowledgeSet.id, + name: knowledgeSet.name, + description: knowledgeSet.description ?? undefined, + queryMode: knowledgeSet.query.mode === 'user_query' ? 'custom' : 'agent', + customQuery: knowledgeSet.query.value ?? undefined, + datasetRefs: knowledgeSet.datasets, + retrievalMode: knowledgeSet.retrieval.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay, + multipleRetrievalConfig: toMultipleRetrievalFormState(knowledgeSet.retrieval), + singleRetrievalConfig: toSingleRetrievalFormState(knowledgeSet.retrieval), + metadataFilterMode: (knowledgeSet.metadata_filtering?.mode ?? MetadataFilteringModeEnum.disabled) as MetadataFilteringModeEnum, + metadataFilteringConditions: knowledgeSet.metadata_filtering?.conditions as MetadataFilteringConditions | undefined, + metadataModelConfig: toModelFormState(knowledgeSet.metadata_filtering?.model_config), + })) } const toKnowledgeConfig = ( - baseKnowledge: AgentSoulConfig['knowledge'], knowledgeRetrievals: AgentKnowledgeRetrievalItem[], -): AgentSoulConfig['knowledge'] => { - const primaryRetrieval = knowledgeRetrievals.find(retrieval => - retrieval.queryMode === 'custom' - || retrieval.customQuery - || retrieval.multipleRetrievalConfig - || retrieval.selectedDatasets?.length, - ) ?? knowledgeRetrievals[0] - const multipleRetrievalConfig = primaryRetrieval?.multipleRetrievalConfig - const scoreThreshold = multipleRetrievalConfig?.score_threshold - - return { - ...baseKnowledge, - datasets: toKnowledgeDatasets(knowledgeRetrievals), - query_mode: primaryRetrieval?.queryMode === 'custom' ? 'user_query' : 'generated_query', - query_config: { - ...baseKnowledge?.query_config, - query: primaryRetrieval?.queryMode === 'custom' ? primaryRetrieval.customQuery : null, - score_threshold: scoreThreshold, - score_threshold_enabled: scoreThreshold !== undefined && scoreThreshold !== null, - top_k: multipleRetrievalConfig?.top_k ?? baseKnowledge?.query_config?.top_k, - }, - } -} +): AgentSoulConfig['knowledge'] => ({ + sets: toKnowledgeSets(knowledgeRetrievals), +}) const isToolRuntimeParameterValue = (value: unknown): value is AgentSoulToolRuntimeParameterValue => { if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') @@ -396,7 +454,7 @@ export const formStateToAgentSoulConfig = ({ cli_tools: toCliToolConfigs(formState.tools), }, app_features: formState.appFeatures ?? baseConfig?.app_features, - knowledge: toKnowledgeConfig(baseConfig?.knowledge, formState.knowledgeRetrievals), + knowledge: toKnowledgeConfig(formState.knowledgeRetrievals), env: toEnvConfig(formState.envVariables), } } diff --git a/web/features/agent-v2/agent-composer/form-state.ts b/web/features/agent-v2/agent-composer/form-state.ts index 80429d8be84..2818351524b 100644 --- a/web/features/agent-v2/agent-composer/form-state.ts +++ b/web/features/agent-v2/agent-composer/form-state.ts @@ -6,6 +6,7 @@ import type { MetadataFilteringConditions, MetadataFilteringModeEnum, MultipleRetrievalConfig, + SingleRetrievalConfig, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { ModelConfig } from '@/app/components/workflow/types' import type { DataSet } from '@/models/datasets' @@ -42,6 +43,7 @@ export type AgentFileNode = { export type AgentKnowledgeRetrievalItem = { id: string name?: string + description?: string nameKey?: I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.knowledgeRetrieval.'> queryMode?: 'agent' | 'custom' customQuery?: string @@ -49,6 +51,7 @@ export type AgentKnowledgeRetrievalItem = { selectedDatasets?: DataSet[] retrievalMode?: RETRIEVE_TYPE multipleRetrievalConfig?: MultipleRetrievalConfig + singleRetrievalConfig?: SingleRetrievalConfig metadataFilterMode?: MetadataFilteringModeEnum metadataFilteringConditions?: MetadataFilteringConditions metadataModelConfig?: ModelConfig diff --git a/web/features/agent-v2/agent-composer/knowledge-validation.ts b/web/features/agent-v2/agent-composer/knowledge-validation.ts new file mode 100644 index 00000000000..29fb8292974 --- /dev/null +++ b/web/features/agent-v2/agent-composer/knowledge-validation.ts @@ -0,0 +1,175 @@ +import type { AgentKnowledgeRetrievalItem } from './form-state' +import { useTranslation } from 'react-i18next' +import { MetadataFilteringModeEnum } from '@/app/components/workflow/nodes/knowledge-retrieval/types' +import { RETRIEVE_TYPE } from '@/types/app' + +export type KnowledgeValidationIssueCode = + | 'name_required' + | 'name_duplicate' + | 'datasets_required' + | 'custom_query_required' + | 'single_model_required' + | 'metadata_model_required' + | 'metadata_conditions_required' + +export type KnowledgeValidationField = 'name' | 'datasets' | 'query' | 'retrieval' | 'metadata' + +export type KnowledgeValidationIssue = { + itemId: string + code: KnowledgeValidationIssueCode + field: KnowledgeValidationField +} + +export type KnowledgeValidationResult = { + byId: Record>> + firstIssue?: KnowledgeValidationIssue + isValid: boolean +} + +const getKnowledgeRetrievalConcreteName = (item: AgentKnowledgeRetrievalItem) => item.name?.trim() ?? '' + +export const getKnowledgeRetrievalSetName = (item: AgentKnowledgeRetrievalItem) => + getKnowledgeRetrievalConcreteName(item) || item.id + +export const useKnowledgeValidationMessage = () => { + const { t } = useTranslation('agentV2') + const { t: tCommon } = useTranslation('common') + const { t: tAppDebug } = useTranslation('appDebug') + const { t: tWorkflow } = useTranslation('workflow') + + return (issueCode?: KnowledgeValidationIssueCode) => { + switch (issueCode) { + case 'name_required': + return tCommon('errorMsg.fieldRequired', { + field: t('agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'), + }) + case 'name_duplicate': + return tAppDebug('varKeyError.keyAlreadyExists', { + key: t('agentDetail.configure.knowledgeRetrieval.dialog.nameLabel'), + }) + case 'datasets_required': + return tCommon('errorMsg.fieldRequired', { + field: t('agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label'), + }) + case 'custom_query_required': + return tCommon('errorMsg.fieldRequired', { + field: t('agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel'), + }) + case 'single_model_required': + case 'metadata_model_required': + return tCommon('errorMsg.fieldRequired', { + field: tCommon('modelProvider.systemReasoningModel.key'), + }) + case 'metadata_conditions_required': + return tCommon('errorMsg.fieldRequired', { + field: tWorkflow('nodes.knowledgeRetrieval.metadata.panel.conditions'), + }) + default: + return undefined + } + } +} + +const getKnowledgeDatasetCount = (item: AgentKnowledgeRetrievalItem) => + item.selectedDatasets?.length ?? item.datasetRefs?.length ?? 0 + +const getNormalizedKnowledgeName = (item: AgentKnowledgeRetrievalItem) => getKnowledgeRetrievalConcreteName(item).toLowerCase() + +export const validateKnowledgeRetrievals = ( + retrievals: AgentKnowledgeRetrievalItem[], +): KnowledgeValidationResult => { + const byId: KnowledgeValidationResult['byId'] = {} + const issues: KnowledgeValidationIssue[] = [] + const nameCounts = new Map() + + retrievals.forEach((item) => { + const normalizedName = getNormalizedKnowledgeName(item) + if (!normalizedName) + return + + nameCounts.set(normalizedName, (nameCounts.get(normalizedName) ?? 0) + 1) + }) + + const pushIssue = (issue: KnowledgeValidationIssue) => { + byId[issue.itemId] ??= {} + const itemIssues = byId[issue.itemId] + if (itemIssues) + itemIssues[issue.field] ??= issue.code + issues.push(issue) + } + + retrievals.forEach((item) => { + const setName = getKnowledgeRetrievalConcreteName(item) + const normalizedName = setName.toLowerCase() + + if (!setName) { + pushIssue({ + itemId: item.id, + code: 'name_required', + field: 'name', + }) + } + else if ((nameCounts.get(normalizedName) ?? 0) > 1) { + pushIssue({ + itemId: item.id, + code: 'name_duplicate', + field: 'name', + }) + } + + if (!getKnowledgeDatasetCount(item)) { + pushIssue({ + itemId: item.id, + code: 'datasets_required', + field: 'datasets', + }) + } + + if (item.queryMode === 'custom' && !item.customQuery?.trim()) { + pushIssue({ + itemId: item.id, + code: 'custom_query_required', + field: 'query', + }) + } + + if ( + item.retrievalMode === RETRIEVE_TYPE.oneWay + && (!item.singleRetrievalConfig?.model?.provider || !item.singleRetrievalConfig.model.name) + ) { + pushIssue({ + itemId: item.id, + code: 'single_model_required', + field: 'retrieval', + }) + } + + if ( + item.metadataFilterMode === MetadataFilteringModeEnum.automatic + && (!item.metadataModelConfig?.provider || !item.metadataModelConfig.name) + ) { + pushIssue({ + itemId: item.id, + code: 'metadata_model_required', + field: 'metadata', + }) + } + + if ( + item.metadataFilterMode === MetadataFilteringModeEnum.manual + && !item.metadataFilteringConditions?.conditions.length + ) { + pushIssue({ + itemId: item.id, + code: 'metadata_conditions_required', + field: 'metadata', + }) + } + }) + + return { + byId, + firstIssue: issues[0], + isValid: issues.length === 0, + } +} diff --git a/web/features/agent-v2/agent-composer/store.ts b/web/features/agent-v2/agent-composer/store.ts index 446529ba556..55049d92a14 100644 --- a/web/features/agent-v2/agent-composer/store.ts +++ b/web/features/agent-v2/agent-composer/store.ts @@ -1,10 +1,9 @@ import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentSoulConfigFormState } from './form-state' -import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' import isEqual from 'fast-deep-equal' import { atom, useAtomValue, useSetAtom } from 'jotai' -import { useEffect, useMemo, useRef } from 'react' -import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from './conversions' +import { useEffect, useRef } from 'react' +import { agentSoulConfigToFormState } from './conversions' import { defaultAgentSoulConfigFormState } from './form-state' export const agentComposerOriginalConfigAtom = atom(undefined) @@ -94,39 +93,3 @@ export function useHydrateAgentSoulConfigDraft({ export function useHasAgentComposerUnpublishedChanges() { return useAtomValue(hasAgentComposerUnpublishedChangesAtom) } - -export function useAgentComposerConfigSnapshot({ - baseConfig, - currentModel, -}: { - baseConfig?: AgentSoulConfig - currentModel?: DefaultModel -}) { - const draft = useAtomValue(agentComposerDraftAtom) - - return useMemo(() => formStateToAgentSoulConfig({ - baseConfig, - formState: draft, - currentModel, - }), [baseConfig, currentModel, draft]) -} - -export function useConfigPublishPayload({ - agentId, - baseConfig, - currentModel, -}: { - agentId: string - baseConfig?: AgentSoulConfig - currentModel?: DefaultModel -}) { - const configSnapshot = useAgentComposerConfigSnapshot({ - baseConfig, - currentModel, - }) - - return useMemo(() => ({ - agent_id: agentId, - config_snapshot: configSnapshot, - }), [agentId, configSnapshot]) -} diff --git a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx index 69f8ff0735a..1400f6b7ca5 100644 --- a/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/__tests__/use-agent-configure-sync.spec.tsx @@ -145,6 +145,30 @@ describe('useAgentConfigureSync', () => { expect(result.current.draftSavedAt).toBe(1710000105000) }) + it('should skip autosave when knowledge retrieval validation fails', async () => { + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [], + }, + ], + }) + }) + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000) + }) + + expect(composerPutMutationFn).not.toHaveBeenCalled() + expect(result.current.draftSavedAt).toBeUndefined() + }) + it('should save the latest draft immediately when requested', async () => { vi.setSystemTime(1710000200000) const { result, store } = renderUseAgentConfigureSync() @@ -178,22 +202,41 @@ describe('useAgentConfigureSync', () => { expect(result.current.draftSavedAt).toBe(1710000200000) }) + it('should reject manual save when knowledge retrieval validation fails', async () => { + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [], + }, + ], + }) + }) + + await expect(result.current.saveDraft()).rejects.toThrow('Agent knowledge retrieval configuration is invalid.') + expect(composerPutMutationFn).not.toHaveBeenCalled() + }) + it('should publish only when publishDraft is called explicitly', async () => { - const { queryClient, result } = renderUseAgentConfigureSync() + const { queryClient, result, store } = renderUseAgentConfigureSync() queryClient.setQueryData(['agent-detail', 'agent-1'], { active_config_is_published: false, name: 'Agent', }) + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Published prompt', + }) + }) await act(async () => { - await result.current.publishDraft({ - agent_id: 'agent-1', - config_snapshot: { - prompt: { - system_prompt: 'Published prompt', - }, - }, - }) + await result.current.publishDraft() }) expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ @@ -203,14 +246,14 @@ describe('useAgentConfigureSync', () => { body: expect.objectContaining({ variant: 'agent_app', save_strategy: 'save_as_new_version', - agent_soul: { - prompt: { + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ system_prompt: 'Published prompt', - }, - }, + }), + }), }), })) - expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({ + expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toMatchObject({ agent_soul: { prompt: { system_prompt: 'Published prompt', @@ -223,22 +266,58 @@ describe('useAgentConfigureSync', () => { }) }) + it('should publish the current draft snapshot instead of a stale caller payload', async () => { + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Current draft prompt', + }) + }) + + await act(async () => { + await result.current.publishDraft() + }) + + expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Current draft prompt', + }), + }), + }), + })) + }) + + it('should reject publish when knowledge retrieval validation fails', async () => { + const { result, store } = renderUseAgentConfigureSync() + + act(() => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [], + }, + ], + }) + }) + + await expect(result.current.publishDraft()).rejects.toThrow('Agent knowledge retrieval configuration is invalid.') + expect(composerPutMutationFn).not.toHaveBeenCalled() + }) + it('should expose publishing status from the publish mutation while publish is pending', async () => { const publishDeferred = createDeferredPromise<{ agent_soul: Record }>() composerPutMutationFn.mockReturnValueOnce(publishDeferred.promise) const { result } = renderUseAgentConfigureSync() - const publishPayload = { - agent_id: 'agent-1', - config_snapshot: { - prompt: { - system_prompt: 'Published prompt', - }, - }, - } - let publishPromise!: Promise act(() => { - publishPromise = result.current.publishDraft(publishPayload) + publishPromise = result.current.publishDraft() }) await act(async () => { @@ -250,7 +329,11 @@ describe('useAgentConfigureSync', () => { await act(async () => { publishDeferred.resolve({ - agent_soul: publishPayload.config_snapshot, + agent_soul: { + prompt: { + system_prompt: '', + }, + }, }) await publishPromise await vi.advanceTimersByTimeAsync(0) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx index e72d19d7a0e..a220f1f4c41 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/__tests__/publish-bar.spec.tsx @@ -248,6 +248,33 @@ describe('AgentConfigurePublishBar', () => { ) }) + it('should block publish when knowledge retrieval validation fails', () => { + const { onPublish } = renderPublishBar({ + setupStore: (store) => { + store.set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [], + }, + ], + }) + }, + }) + + expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })).toBeDisabled() + expect(screen.getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}')).toBeInTheDocument() + expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual( + expect.objectContaining({ enabled: false, ignoreInputs: false }), + ) + + fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.publish/ })) + + expect(onPublish).not.toHaveBeenCalled() + }) + it('should restore the selected version from view-only mode', async () => { const selectedVersionSnapshot = { ...activeConfigSnapshot, @@ -335,9 +362,7 @@ describe('AgentConfigurePublishBar', () => { fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) await waitFor(() => { - expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ - agent_id: 'agent-1', - })) + expect(onPublish).toHaveBeenCalledTimes(1) }) expect(screen.queryByRole('region', { name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, @@ -355,14 +380,7 @@ describe('AgentConfigurePublishBar', () => { fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) await waitFor(() => { - expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ - agent_id: 'agent-1', - config_snapshot: expect.objectContaining({ - prompt: expect.objectContaining({ - system_prompt: 'Updated system prompt', - }), - }), - })) + expect(onPublish).toHaveBeenCalledTimes(1) }) }) @@ -473,9 +491,7 @@ describe('AgentConfigurePublishBar', () => { })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) - expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ - agent_id: 'agent-1', - })) + expect(onPublish).toHaveBeenCalledTimes(1) expect(screen.getByRole('region', { name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, })).toBeInTheDocument() @@ -539,9 +555,7 @@ describe('AgentConfigurePublishBar', () => { await hotkeyRegistrations.get('Mod+Shift+P')?.callback({ preventDefault: vi.fn() }) }) - expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ - agent_id: 'agent-1', - })) + expect(onPublish).toHaveBeenCalledTimes(1) }) it('should publish directly from the publish shortcut when no workflows reference the agent', async () => { @@ -558,8 +572,6 @@ describe('AgentConfigurePublishBar', () => { expect(screen.queryByRole('region', { name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, })).not.toBeInTheDocument() - expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ - agent_id: 'agent-1', - })) + expect(onPublish).toHaveBeenCalledTimes(1) }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx index cddbec00d68..67925fd09fc 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/index.tsx @@ -1,7 +1,6 @@ 'use client' import type { AgentConfigSnapshotDetailResponse, AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen' -import type { AgentConfigurePublishPayload } from './publish-bar' import type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations' import { cn } from '@langgenius/dify-ui/cn' import { ScrollArea } from '@langgenius/dify-ui/scroll-area' @@ -38,7 +37,7 @@ type AgentOrchestratePanelProps = { showHeader?: boolean showPublishBar?: boolean onSelectModel: (model: DefaultModel) => void - onPublish: (payload: AgentConfigurePublishPayload) => void | Promise + onPublish: () => void | Promise onExitVersions?: () => void onOpenVersions: () => void } @@ -121,9 +120,7 @@ export function AgentOrchestratePanel({ agentId={agentId} activeConfigIsPublished={activeConfigIsPublished} activeConfigSnapshot={activeConfigSnapshot} - agentSoulConfig={agentSoulConfig} agentName={agentName} - currentModel={currentModel} draftSavedAt={draftSavedAt} isPublishing={isPublishing} selectedVersionSnapshot={selectedVersionSnapshot} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx index 8f3e683002e..842f5c21a88 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/__tests__/index.spec.tsx @@ -1,11 +1,13 @@ import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, within } from '@testing-library/react' +import { fireEvent, render, screen, within } from '@testing-library/react' +import { useAtomValue } from 'jotai' import userEvent from '@testing-library/user-event' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' -import { useConfigPublishPayload } from '@/features/agent-v2/agent-composer/store' +import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store' import { AgentOrchestrateReadOnlyContext } from '../../read-only-context' import { AgentKnowledgeRetrieval } from '../index' @@ -14,17 +16,18 @@ const agentKnowledgeDraft = { knowledgeRetrievals: [ { id: 'retrieval-1', - nameKey: 'agentDetail.configure.knowledgeRetrieval.retrievalOne', + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne', }, ], } satisfies AgentSoulConfigFormState -function PublishPayloadPreview() { - const payload = useConfigPublishPayload({ agentId: 'agent-1' }) +function ConfigSnapshotPreview() { + const draft = useAtomValue(agentComposerDraftAtom) + const configSnapshot = formStateToAgentSoulConfig({ formState: draft }) return ( - - {JSON.stringify(payload.config_snapshot.knowledge)} + + {JSON.stringify(configSnapshot.knowledge)} ) } @@ -32,11 +35,11 @@ function PublishPayloadPreview() { function renderKnowledgeRetrieval({ initialDraft = agentKnowledgeDraft, readOnly = false, - showPublishPayload = false, + showConfigSnapshot = false, }: { initialDraft?: AgentSoulConfigFormState readOnly?: boolean - showPublishPayload?: boolean + showConfigSnapshot?: boolean } = {}) { const queryClient = new QueryClient() @@ -46,7 +49,7 @@ function renderKnowledgeRetrieval({ - {showPublishPayload && } + {showConfigSnapshot && } , ) @@ -159,9 +162,68 @@ describe('AgentKnowledgeRetrieval', () => { expect(within(dialog).queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription')).not.toBeInTheDocument() }) - it('should save newly added retrieval data into the publish config', async () => { + it('should show inline validation for missing datasets and blank custom queries', async () => { const user = userEvent.setup() - renderKnowledgeRetrieval({ showPublishPayload: true }) + renderKnowledgeRetrieval() + + await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) + const dialog = screen.getByRole('dialog', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', + }) + + expect(within(dialog).getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.knowledge.label"}')).toBeInTheDocument() + + await user.click(within(dialog).getByRole('radio', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.custom', + })) + + expect(within(dialog).getByText('common.errorMsg.fieldRequired:{"field":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel"}')).toBeInTheDocument() + }) + + it('should show duplicate-name validation in the dialog', async () => { + const user = userEvent.setup() + renderKnowledgeRetrieval({ + initialDraft: { + ...defaultAgentSoulConfigFormState, + knowledgeRetrievals: [ + { + id: 'retrieval-1', + name: 'Docs Search', + datasetRefs: [{ id: 'dataset-1', name: 'Docs' }], + }, + { + id: 'retrieval-2', + name: 'FAQ Search', + datasetRefs: [{ id: 'dataset-2', name: 'FAQ' }], + }, + ], + }, + }) + + await user.click(screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"FAQ Search"}', + })) + + const dialog = screen.getByRole('dialog', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.title', + }) + + await user.click(within(dialog).getByRole('button', { + name: 'FAQ Search', + })) + const nameInput = within(dialog).getByRole('textbox', { + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel', + }) + await user.clear(nameInput) + await user.type(nameInput, 'Docs Search') + fireEvent.blur(nameInput) + + expect(within(dialog).getByText('appDebug.varKeyError.keyAlreadyExists:{"key":"agentV2.agentDetail.configure.knowledgeRetrieval.dialog.nameLabel"}')).toBeInTheDocument() + }) + + it('should save newly added retrieval data into the config snapshot', async () => { + const user = userEvent.setup() + renderKnowledgeRetrieval({ showConfigSnapshot: true }) await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' })) const dialog = screen.getByRole('dialog', { @@ -179,23 +241,29 @@ describe('AgentKnowledgeRetrieval', () => { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', }), 'new release notes') - const knowledgeConfig = JSON.parse(screen.getByLabelText('publish payload').textContent ?? '{}') - expect(knowledgeConfig.datasets).toEqual(expect.arrayContaining([ - { + const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') + expect(knowledgeConfig.sets).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'retrieval-1', - name: 'agentDetail.configure.knowledgeRetrieval.retrievalOne', - }, + name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne', + datasets: [], + query: { + mode: 'generated_query', + }, + }), expect.objectContaining({ name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo', + datasets: [], + query: { + mode: 'user_query', + value: 'new release notes', + }, + retrieval: expect.objectContaining({ + mode: 'multiple', + top_k: 4, + }), }), ])) - expect(knowledgeConfig).toMatchObject({ - query_config: { - query: 'new release notes', - top_k: 4, - }, - query_mode: 'user_query', - }) }) it('should open the knowledge retrieval dialog from the edit button', async () => { @@ -251,9 +319,9 @@ describe('AgentKnowledgeRetrieval', () => { expect(within(dialog).queryByText('appDebug.datasetConfig.knowledgeTip')).not.toBeInTheDocument() }) - it('should save edited retrieval data into the publish config', async () => { + it('should save edited retrieval data into the config snapshot', async () => { const user = userEvent.setup() - renderKnowledgeRetrieval({ showPublishPayload: true }) + renderKnowledgeRetrieval({ showConfigSnapshot: true }) await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', @@ -277,19 +345,23 @@ describe('AgentKnowledgeRetrieval', () => { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', }), 'release notes') - const knowledgeConfig = JSON.parse(screen.getByLabelText('publish payload').textContent ?? '{}') + const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}') expect(knowledgeConfig).toMatchObject({ - datasets: [ + sets: [ { id: 'retrieval-1', name: 'Release Search', + datasets: [], + query: { + mode: 'user_query', + value: 'release notes', + }, + retrieval: { + mode: 'multiple', + top_k: 4, + }, }, ], - query_config: { - query: 'release notes', - top_k: 4, - }, - query_mode: 'user_query', }) }) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx index a059a769988..dccbba87eed 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/knowledge/dialog.tsx @@ -6,6 +6,7 @@ import type { MetadataFilteringCondition, MetadataFilteringModeEnum, MultipleRetrievalConfig, + SingleRetrievalConfig, } from '@/app/components/workflow/nodes/knowledge-retrieval/types' import type { ModelConfig } from '@/app/components/workflow/types' import type { AgentKnowledgeRetrievalItem } from '@/features/agent-v2/agent-composer/form-state' @@ -17,6 +18,7 @@ import { RadioRoot } from '@langgenius/dify-ui/radio' import { RadioGroup } from '@langgenius/dify-ui/radio-group' import { Textarea } from '@langgenius/dify-ui/textarea' import { intersectionBy } from 'es-toolkit/compat' +import { useAtomValue } from 'jotai' import { useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { IndexingType } from '@/app/components/datasets/create/step-two/hooks/use-indexing-config' @@ -35,6 +37,8 @@ import { DATASET_DEFAULT } from '@/config' import { useDocLink } from '@/context/i18n' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { AppModeEnum, RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' +import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge' +import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation' type KnowledgeRetrievalQueryMode = 'agent' | 'custom' type MetadataFilteringConditions = { @@ -55,6 +59,7 @@ type KnowledgeRetrievalDialogState = { rerankModelOpen: boolean retrievalMode: typeof RETRIEVE_TYPE[keyof typeof RETRIEVE_TYPE] selectedDatasets: DataSet[] + singleRetrievalConfig?: SingleRetrievalConfig } const queryModeOptions: KnowledgeRetrievalQueryMode[] = ['agent', 'custom'] @@ -198,6 +203,7 @@ const createDialogState = ( rerankModelOpen: false, retrievalMode: item?.retrievalMode ?? RETRIEVE_TYPE.multiWay, selectedDatasets: getSelectedDatasets(item), + singleRetrievalConfig: item?.singleRetrievalConfig, }) const createMetadataCondition = ({ id, name, type }: MetadataInDoc): MetadataFilteringCondition => ({ @@ -224,6 +230,8 @@ export function AgentKnowledgeRetrievalDialog({ }) { const { t } = useTranslation('agentV2') const docLink = useDocLink() + const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom) + const getValidationMessage = useKnowledgeValidationMessage() const fallbackName = t('agentDetail.configure.knowledgeRetrieval.retrievalOne') const hydrationKey = open ? (item?.id ?? initialName ?? 'new') : null const [dialogState, setDialogState] = useState(() => createDialogState(item, initialName, fallbackName, hydrationKey ?? 'new')) @@ -242,6 +250,7 @@ export function AgentKnowledgeRetrievalDialog({ rerankModelOpen, retrievalMode, selectedDatasets, + singleRetrievalConfig, } = dialogState const patchDialogState = (patch: Partial) => { setDialogState(current => ({ @@ -273,6 +282,18 @@ export function AgentKnowledgeRetrievalDialog({ } }) } + const setSingleRetrievalConfig = (update: SetStateAction) => { + setDialogState((current) => { + const nextSingleRetrievalConfig = typeof update === 'function' + ? update(current.singleRetrievalConfig) + : update + + return { + ...current, + singleRetrievalConfig: nextSingleRetrievalConfig, + } + }) + } const updateItem = (patch: Partial) => { if (!item) return @@ -285,6 +306,7 @@ export function AgentKnowledgeRetrievalDialog({ selectedDatasets, retrievalMode, multipleRetrievalConfig, + singleRetrievalConfig, metadataFilterMode, metadataFilteringConditions, metadataModelConfig, @@ -299,6 +321,13 @@ export function AgentKnowledgeRetrievalDialog({ return intersectionBy(...datasetsWithMetadata.map(dataset => dataset.doc_metadata!), 'name') }, [selectedDatasets]) + const validation = useMemo(() => validateKnowledgeRetrievals(retrievals), [retrievals]) + const itemValidation = item ? validation.byId[item.id] : undefined + const nameError = getValidationMessage(itemValidation?.name) + const datasetsError = getValidationMessage(itemValidation?.datasets) + const queryError = getValidationMessage(itemValidation?.query) + const retrievalError = getValidationMessage(itemValidation?.retrieval) + const metadataError = getValidationMessage(itemValidation?.metadata) useEffect(() => { if (hydratedKey !== hydrationKey) { @@ -338,6 +367,7 @@ export function AgentKnowledgeRetrievalDialog({ patchDialogState({ isEditingName: false })} @@ -365,6 +395,11 @@ export function AgentKnowledgeRetrievalDialog({ )} + {nameError && ( +
+ {nameError} +
+ )}
@@ -402,6 +437,7 @@ export function AgentKnowledgeRetrievalDialog({