feat(web): support agent v2 knowledge sets

This commit is contained in:
Yanli 盐粒 2026-06-23 16:43:37 +08:00
parent c06d924094
commit 3efc1f93b9
15 changed files with 1067 additions and 264 deletions

View File

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

View File

@ -4,7 +4,7 @@ import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conve
import { defaultAgentSoulConfigFormState } from '../form-state' import { defaultAgentSoulConfigFormState } from '../form-state'
describe('agent composer store conversions', () => { 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 = { const baseConfig: AgentSoulConfig = {
app_features: { app_features: {
opening_statement: 'Hello', opening_statement: 'Hello',
@ -27,20 +27,30 @@ describe('agent composer store conversions', () => {
], ],
}, },
knowledge: { knowledge: {
datasets: [ sets: [
{ {
id: 'dataset-1', id: 'support',
name: 'Product Docs', name: 'Product Docs',
description: 'Docs corpus', 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: {
model: 'gpt-4.1', model: 'gpt-4.1',
@ -100,7 +110,7 @@ describe('agent composer store conversions', () => {
}, },
knowledgeRetrievals: [ knowledgeRetrievals: [
expect.objectContaining({ expect.objectContaining({
id: 'dataset-1', id: 'support',
name: 'Product Docs', name: 'Product Docs',
queryMode: 'custom', queryMode: 'custom',
customQuery: 'release notes', customQuery: 'release notes',
@ -166,20 +176,30 @@ describe('agent composer store conversions', () => {
}), }),
]) ])
expect(publishConfig.knowledge).toMatchObject({ expect(publishConfig.knowledge).toMatchObject({
datasets: [ sets: [
{ {
id: 'dataset-1', id: 'support',
name: 'Product Docs', name: 'Product Docs',
description: 'Docs corpus', 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({ expect(publishConfig.env).toMatchObject({
variables: [ 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({ const formState = agentSoulConfigToFormState({
knowledge: { knowledge: {
datasets: [], sets: [],
query_config: {
top_k: 4,
},
query_mode: 'generated_query',
}, },
}) })
expect(formState.knowledgeRetrievals).toEqual([]) 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({ const publishConfig = formStateToAgentSoulConfig({
formState: { formState: {
...defaultAgentSoulConfigFormState, ...defaultAgentSoulConfigFormState,

View File

@ -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 { import type {
AgentCliTool, AgentCliTool,
AgentKnowledgeRetrievalItem, AgentKnowledgeRetrievalItem,
@ -8,80 +14,132 @@ import type {
EnvVariable, EnvVariable,
} from './form-state' } from './form-state'
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations' 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 { checkKey } from '@/utils/var'
import { defaultAgentSoulConfigFormState } from './form-state' import { defaultAgentSoulConfigFormState } from './form-state'
import { getKnowledgeRetrievalSetName } from './knowledge-validation'
type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number] type AgentSoulDifyToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number]
type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number] type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number]
type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string] type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string]
type AgentSoulEnvVariableConfig = NonNullable<NonNullable<AgentSoulConfig['env']>['variables']>[number] type AgentSoulEnvVariableConfig = NonNullable<NonNullable<AgentSoulConfig['env']>['variables']>[number]
const getKnowledgeRetrievalName = (item: AgentKnowledgeRetrievalItem) => item.name ?? item.nameKey ?? item.id const toKnowledgeDatasetRefs = (item: AgentKnowledgeRetrievalItem) => {
if (item.selectedDatasets !== undefined) {
const toKnowledgeDatasets = (knowledgeRetrievals: AgentKnowledgeRetrievalItem[]) => knowledgeRetrievals.flatMap((item) => {
if (item.selectedDatasets?.length) {
return item.selectedDatasets.map(dataset => ({ return item.selectedDatasets.map(dataset => ({
description: dataset.description, description: dataset.description,
id: dataset.id, id: dataset.id,
name: dataset.name, name: dataset.name,
})) }))
} }
if (item.datasetRefs?.length)
return item.datasetRefs
return [{ return item.datasetRefs ?? []
id: item.id, }
name: getKnowledgeRetrievalName(item),
}] 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 toKnowledgeRetrievalFormState = (config?: AgentSoulConfig): AgentKnowledgeRetrievalItem[] => {
const knowledge = config?.knowledge return (config?.knowledge?.sets ?? []).map(knowledgeSet => ({
const datasets = knowledge?.datasets ?? [] id: knowledgeSet.id,
name: knowledgeSet.name,
if (datasets.length === 0) description: knowledgeSet.description ?? undefined,
return [] queryMode: knowledgeSet.query.mode === 'user_query' ? 'custom' : 'agent',
customQuery: knowledgeSet.query.value ?? undefined,
return [{ datasetRefs: knowledgeSet.datasets,
id: datasets[0]?.id ?? 'knowledge-retrieval', retrievalMode: knowledgeSet.retrieval.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay,
name: datasets[0]?.name ?? 'Knowledge Retrieval', multipleRetrievalConfig: toMultipleRetrievalFormState(knowledgeSet.retrieval),
queryMode: knowledge?.query_mode === 'user_query' ? 'custom' : 'agent', singleRetrievalConfig: toSingleRetrievalFormState(knowledgeSet.retrieval),
customQuery: knowledge?.query_config?.query ?? undefined, metadataFilterMode: (knowledgeSet.metadata_filtering?.mode ?? MetadataFilteringModeEnum.disabled) as MetadataFilteringModeEnum,
datasetRefs: datasets, metadataFilteringConditions: knowledgeSet.metadata_filtering?.conditions as MetadataFilteringConditions | undefined,
multipleRetrievalConfig: { metadataModelConfig: toModelFormState(knowledgeSet.metadata_filtering?.model_config),
top_k: knowledge?.query_config?.top_k ?? 4, }))
score_threshold: knowledge?.query_config?.score_threshold ?? null,
reranking_enable: false,
},
}]
} }
const toKnowledgeConfig = ( const toKnowledgeConfig = (
baseKnowledge: AgentSoulConfig['knowledge'],
knowledgeRetrievals: AgentKnowledgeRetrievalItem[], knowledgeRetrievals: AgentKnowledgeRetrievalItem[],
): AgentSoulConfig['knowledge'] => { ): AgentSoulConfig['knowledge'] => ({
const primaryRetrieval = knowledgeRetrievals.find(retrieval => sets: toKnowledgeSets(knowledgeRetrievals),
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,
},
}
}
const isToolRuntimeParameterValue = (value: unknown): value is AgentSoulToolRuntimeParameterValue => { const isToolRuntimeParameterValue = (value: unknown): value is AgentSoulToolRuntimeParameterValue => {
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
@ -396,7 +454,7 @@ export const formStateToAgentSoulConfig = ({
cli_tools: toCliToolConfigs(formState.tools), cli_tools: toCliToolConfigs(formState.tools),
}, },
app_features: formState.appFeatures ?? baseConfig?.app_features, app_features: formState.appFeatures ?? baseConfig?.app_features,
knowledge: toKnowledgeConfig(baseConfig?.knowledge, formState.knowledgeRetrievals), knowledge: toKnowledgeConfig(formState.knowledgeRetrievals),
env: toEnvConfig(formState.envVariables), env: toEnvConfig(formState.envVariables),
} }
} }

View File

@ -6,6 +6,7 @@ import type {
MetadataFilteringConditions, MetadataFilteringConditions,
MetadataFilteringModeEnum, MetadataFilteringModeEnum,
MultipleRetrievalConfig, MultipleRetrievalConfig,
SingleRetrievalConfig,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types' } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { ModelConfig } from '@/app/components/workflow/types' import type { ModelConfig } from '@/app/components/workflow/types'
import type { DataSet } from '@/models/datasets' import type { DataSet } from '@/models/datasets'
@ -42,6 +43,7 @@ export type AgentFileNode = {
export type AgentKnowledgeRetrievalItem = { export type AgentKnowledgeRetrievalItem = {
id: string id: string
name?: string name?: string
description?: string
nameKey?: I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.knowledgeRetrieval.'> nameKey?: I18nKeysWithPrefix<'agentV2', 'agentDetail.configure.knowledgeRetrieval.'>
queryMode?: 'agent' | 'custom' queryMode?: 'agent' | 'custom'
customQuery?: string customQuery?: string
@ -49,6 +51,7 @@ export type AgentKnowledgeRetrievalItem = {
selectedDatasets?: DataSet[] selectedDatasets?: DataSet[]
retrievalMode?: RETRIEVE_TYPE retrievalMode?: RETRIEVE_TYPE
multipleRetrievalConfig?: MultipleRetrievalConfig multipleRetrievalConfig?: MultipleRetrievalConfig
singleRetrievalConfig?: SingleRetrievalConfig
metadataFilterMode?: MetadataFilteringModeEnum metadataFilterMode?: MetadataFilteringModeEnum
metadataFilteringConditions?: MetadataFilteringConditions metadataFilteringConditions?: MetadataFilteringConditions
metadataModelConfig?: ModelConfig metadataModelConfig?: ModelConfig

View File

@ -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<string, Partial<Record<KnowledgeValidationField, KnowledgeValidationIssueCode>>>
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<string, number>()
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,
}
}

View File

@ -1,10 +1,9 @@
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import type { AgentSoulConfigFormState } from './form-state' 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 isEqual from 'fast-deep-equal'
import { atom, useAtomValue, useSetAtom } from 'jotai' import { atom, useAtomValue, useSetAtom } from 'jotai'
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useRef } from 'react'
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from './conversions' import { agentSoulConfigToFormState } from './conversions'
import { defaultAgentSoulConfigFormState } from './form-state' import { defaultAgentSoulConfigFormState } from './form-state'
export const agentComposerOriginalConfigAtom = atom<AgentSoulConfig | undefined>(undefined) export const agentComposerOriginalConfigAtom = atom<AgentSoulConfig | undefined>(undefined)
@ -94,39 +93,3 @@ export function useHydrateAgentSoulConfigDraft({
export function useHasAgentComposerUnpublishedChanges() { export function useHasAgentComposerUnpublishedChanges() {
return useAtomValue(hasAgentComposerUnpublishedChangesAtom) 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])
}

View File

@ -145,6 +145,30 @@ describe('useAgentConfigureSync', () => {
expect(result.current.draftSavedAt).toBe(1710000105000) 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 () => { it('should save the latest draft immediately when requested', async () => {
vi.setSystemTime(1710000200000) vi.setSystemTime(1710000200000)
const { result, store } = renderUseAgentConfigureSync() const { result, store } = renderUseAgentConfigureSync()
@ -178,22 +202,41 @@ describe('useAgentConfigureSync', () => {
expect(result.current.draftSavedAt).toBe(1710000200000) 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 () => { 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'], { queryClient.setQueryData(['agent-detail', 'agent-1'], {
active_config_is_published: false, active_config_is_published: false,
name: 'Agent', name: 'Agent',
}) })
act(() => {
store.set(agentComposerDraftAtom, {
...defaultAgentSoulConfigFormState,
prompt: 'Published prompt',
})
})
await act(async () => { await act(async () => {
await result.current.publishDraft({ await result.current.publishDraft()
agent_id: 'agent-1',
config_snapshot: {
prompt: {
system_prompt: 'Published prompt',
},
},
})
}) })
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({ expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
@ -203,14 +246,14 @@ describe('useAgentConfigureSync', () => {
body: expect.objectContaining({ body: expect.objectContaining({
variant: 'agent_app', variant: 'agent_app',
save_strategy: 'save_as_new_version', save_strategy: 'save_as_new_version',
agent_soul: { agent_soul: expect.objectContaining({
prompt: { prompt: expect.objectContaining({
system_prompt: 'Published prompt', system_prompt: 'Published prompt',
}, }),
}, }),
}), }),
})) }))
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({ expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toMatchObject({
agent_soul: { agent_soul: {
prompt: { prompt: {
system_prompt: 'Published 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 () => { it('should expose publishing status from the publish mutation while publish is pending', async () => {
const publishDeferred = createDeferredPromise<{ agent_soul: Record<string, unknown> }>() const publishDeferred = createDeferredPromise<{ agent_soul: Record<string, unknown> }>()
composerPutMutationFn.mockReturnValueOnce(publishDeferred.promise) composerPutMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result } = renderUseAgentConfigureSync() const { result } = renderUseAgentConfigureSync()
const publishPayload = {
agent_id: 'agent-1',
config_snapshot: {
prompt: {
system_prompt: 'Published prompt',
},
},
}
let publishPromise!: Promise<void> let publishPromise!: Promise<void>
act(() => { act(() => {
publishPromise = result.current.publishDraft(publishPayload) publishPromise = result.current.publishDraft()
}) })
await act(async () => { await act(async () => {
@ -250,7 +329,11 @@ describe('useAgentConfigureSync', () => {
await act(async () => { await act(async () => {
publishDeferred.resolve({ publishDeferred.resolve({
agent_soul: publishPayload.config_snapshot, agent_soul: {
prompt: {
system_prompt: '',
},
},
}) })
await publishPromise await publishPromise
await vi.advanceTimersByTimeAsync(0) await vi.advanceTimersByTimeAsync(0)

View File

@ -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 () => { it('should restore the selected version from view-only mode', async () => {
const selectedVersionSnapshot = { const selectedVersionSnapshot = {
...activeConfigSnapshot, ...activeConfigSnapshot,
@ -335,9 +362,7 @@ describe('AgentConfigurePublishBar', () => {
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
await waitFor(() => { await waitFor(() => {
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ expect(onPublish).toHaveBeenCalledTimes(1)
agent_id: 'agent-1',
}))
}) })
expect(screen.queryByRole('region', { expect(screen.queryByRole('region', {
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
@ -355,14 +380,7 @@ describe('AgentConfigurePublishBar', () => {
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
await waitFor(() => { await waitFor(() => {
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ expect(onPublish).toHaveBeenCalledTimes(1)
agent_id: 'agent-1',
config_snapshot: expect.objectContaining({
prompt: expect.objectContaining({
system_prompt: 'Updated system prompt',
}),
}),
}))
}) })
}) })
@ -473,9 +491,7 @@ describe('AgentConfigurePublishBar', () => {
})).toBeInTheDocument() })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })) fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ expect(onPublish).toHaveBeenCalledTimes(1)
agent_id: 'agent-1',
}))
expect(screen.getByRole('region', { expect(screen.getByRole('region', {
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
})).toBeInTheDocument() })).toBeInTheDocument()
@ -539,9 +555,7 @@ describe('AgentConfigurePublishBar', () => {
await hotkeyRegistrations.get('Mod+Shift+P')?.callback({ preventDefault: vi.fn() }) await hotkeyRegistrations.get('Mod+Shift+P')?.callback({ preventDefault: vi.fn() })
}) })
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ expect(onPublish).toHaveBeenCalledTimes(1)
agent_id: 'agent-1',
}))
}) })
it('should publish directly from the publish shortcut when no workflows reference the agent', async () => { 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', { expect(screen.queryByRole('region', {
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/, name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
})).not.toBeInTheDocument() })).not.toBeInTheDocument()
expect(onPublish).toHaveBeenCalledWith(expect.objectContaining({ expect(onPublish).toHaveBeenCalledTimes(1)
agent_id: 'agent-1',
}))
}) })
}) })

View File

@ -1,7 +1,6 @@
'use client' 'use client'
import type { AgentConfigSnapshotDetailResponse, AgentConfigSnapshotSummaryResponse } from '@dify/contracts/api/console/agent/types.gen' 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 type { DefaultModel, Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { ScrollArea } from '@langgenius/dify-ui/scroll-area' import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
@ -38,7 +37,7 @@ type AgentOrchestratePanelProps = {
showHeader?: boolean showHeader?: boolean
showPublishBar?: boolean showPublishBar?: boolean
onSelectModel: (model: DefaultModel) => void onSelectModel: (model: DefaultModel) => void
onPublish: (payload: AgentConfigurePublishPayload) => void | Promise<void> onPublish: () => void | Promise<void>
onExitVersions?: () => void onExitVersions?: () => void
onOpenVersions: () => void onOpenVersions: () => void
} }
@ -121,9 +120,7 @@ export function AgentOrchestratePanel({
agentId={agentId} agentId={agentId}
activeConfigIsPublished={activeConfigIsPublished} activeConfigIsPublished={activeConfigIsPublished}
activeConfigSnapshot={activeConfigSnapshot} activeConfigSnapshot={activeConfigSnapshot}
agentSoulConfig={agentSoulConfig}
agentName={agentName} agentName={agentName}
currentModel={currentModel}
draftSavedAt={draftSavedAt} draftSavedAt={draftSavedAt}
isPublishing={isPublishing} isPublishing={isPublishing}
selectedVersionSnapshot={selectedVersionSnapshot} selectedVersionSnapshot={selectedVersionSnapshot}

View File

@ -1,11 +1,13 @@
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state' import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' 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 userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' 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 { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import { AgentKnowledgeRetrieval } from '../index' import { AgentKnowledgeRetrieval } from '../index'
@ -14,17 +16,18 @@ const agentKnowledgeDraft = {
knowledgeRetrievals: [ knowledgeRetrievals: [
{ {
id: 'retrieval-1', id: 'retrieval-1',
nameKey: 'agentDetail.configure.knowledgeRetrieval.retrievalOne', name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne',
}, },
], ],
} satisfies AgentSoulConfigFormState } satisfies AgentSoulConfigFormState
function PublishPayloadPreview() { function ConfigSnapshotPreview() {
const payload = useConfigPublishPayload({ agentId: 'agent-1' }) const draft = useAtomValue(agentComposerDraftAtom)
const configSnapshot = formStateToAgentSoulConfig({ formState: draft })
return ( return (
<output aria-label="publish payload"> <output aria-label="config snapshot">
{JSON.stringify(payload.config_snapshot.knowledge)} {JSON.stringify(configSnapshot.knowledge)}
</output> </output>
) )
} }
@ -32,11 +35,11 @@ function PublishPayloadPreview() {
function renderKnowledgeRetrieval({ function renderKnowledgeRetrieval({
initialDraft = agentKnowledgeDraft, initialDraft = agentKnowledgeDraft,
readOnly = false, readOnly = false,
showPublishPayload = false, showConfigSnapshot = false,
}: { }: {
initialDraft?: AgentSoulConfigFormState initialDraft?: AgentSoulConfigFormState
readOnly?: boolean readOnly?: boolean
showPublishPayload?: boolean showConfigSnapshot?: boolean
} = {}) { } = {}) {
const queryClient = new QueryClient() const queryClient = new QueryClient()
@ -46,7 +49,7 @@ function renderKnowledgeRetrieval({
<AgentOrchestrateReadOnlyContext value={readOnly}> <AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentKnowledgeRetrieval /> <AgentKnowledgeRetrieval />
</AgentOrchestrateReadOnlyContext> </AgentOrchestrateReadOnlyContext>
{showPublishPayload && <PublishPayloadPreview />} {showConfigSnapshot && <ConfigSnapshotPreview />}
</AgentComposerProvider> </AgentComposerProvider>
</QueryClientProvider>, </QueryClientProvider>,
) )
@ -159,9 +162,68 @@ describe('AgentKnowledgeRetrieval', () => {
expect(within(dialog).queryByText('agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.agentDescription')).not.toBeInTheDocument() 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() 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' })) await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add' }))
const dialog = screen.getByRole('dialog', { const dialog = screen.getByRole('dialog', {
@ -179,23 +241,29 @@ describe('AgentKnowledgeRetrieval', () => {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel', name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel',
}), 'new release notes') }), 'new release notes')
const knowledgeConfig = JSON.parse(screen.getByLabelText('publish payload').textContent ?? '{}') const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
expect(knowledgeConfig.datasets).toEqual(expect.arrayContaining([ expect(knowledgeConfig.sets).toEqual(expect.arrayContaining([
{ expect.objectContaining({
id: 'retrieval-1', id: 'retrieval-1',
name: 'agentDetail.configure.knowledgeRetrieval.retrievalOne', name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne',
}, datasets: [],
query: {
mode: 'generated_query',
},
}),
expect.objectContaining({ expect.objectContaining({
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.retrievalTwo', 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 () => { 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() 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() const user = userEvent.setup()
renderKnowledgeRetrieval({ showPublishPayload: true }) renderKnowledgeRetrieval({ showConfigSnapshot: true })
await user.click(screen.getByRole('button', { await user.click(screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.edit:{"name":"agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne"}', 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', name: 'agentV2.agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel',
}), 'release notes') }), 'release notes')
const knowledgeConfig = JSON.parse(screen.getByLabelText('publish payload').textContent ?? '{}') const knowledgeConfig = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
expect(knowledgeConfig).toMatchObject({ expect(knowledgeConfig).toMatchObject({
datasets: [ sets: [
{ {
id: 'retrieval-1', id: 'retrieval-1',
name: 'Release Search', 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',
}) })
}) })

View File

@ -6,6 +6,7 @@ import type {
MetadataFilteringCondition, MetadataFilteringCondition,
MetadataFilteringModeEnum, MetadataFilteringModeEnum,
MultipleRetrievalConfig, MultipleRetrievalConfig,
SingleRetrievalConfig,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types' } from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import type { ModelConfig } from '@/app/components/workflow/types' import type { ModelConfig } from '@/app/components/workflow/types'
import type { AgentKnowledgeRetrievalItem } from '@/features/agent-v2/agent-composer/form-state' 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 { RadioGroup } from '@langgenius/dify-ui/radio-group'
import { Textarea } from '@langgenius/dify-ui/textarea' import { Textarea } from '@langgenius/dify-ui/textarea'
import { intersectionBy } from 'es-toolkit/compat' import { intersectionBy } from 'es-toolkit/compat'
import { useAtomValue } from 'jotai'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { IndexingType } from '@/app/components/datasets/create/step-two/hooks/use-indexing-config' 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 { useDocLink } from '@/context/i18n'
import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets' import { ChunkingMode, DatasetPermission, DataSourceType } from '@/models/datasets'
import { AppModeEnum, RETRIEVE_METHOD, RETRIEVE_TYPE } from '@/types/app' 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 KnowledgeRetrievalQueryMode = 'agent' | 'custom'
type MetadataFilteringConditions = { type MetadataFilteringConditions = {
@ -55,6 +59,7 @@ type KnowledgeRetrievalDialogState = {
rerankModelOpen: boolean rerankModelOpen: boolean
retrievalMode: typeof RETRIEVE_TYPE[keyof typeof RETRIEVE_TYPE] retrievalMode: typeof RETRIEVE_TYPE[keyof typeof RETRIEVE_TYPE]
selectedDatasets: DataSet[] selectedDatasets: DataSet[]
singleRetrievalConfig?: SingleRetrievalConfig
} }
const queryModeOptions: KnowledgeRetrievalQueryMode[] = ['agent', 'custom'] const queryModeOptions: KnowledgeRetrievalQueryMode[] = ['agent', 'custom']
@ -198,6 +203,7 @@ const createDialogState = (
rerankModelOpen: false, rerankModelOpen: false,
retrievalMode: item?.retrievalMode ?? RETRIEVE_TYPE.multiWay, retrievalMode: item?.retrievalMode ?? RETRIEVE_TYPE.multiWay,
selectedDatasets: getSelectedDatasets(item), selectedDatasets: getSelectedDatasets(item),
singleRetrievalConfig: item?.singleRetrievalConfig,
}) })
const createMetadataCondition = ({ id, name, type }: MetadataInDoc): MetadataFilteringCondition => ({ const createMetadataCondition = ({ id, name, type }: MetadataInDoc): MetadataFilteringCondition => ({
@ -224,6 +230,8 @@ export function AgentKnowledgeRetrievalDialog({
}) { }) {
const { t } = useTranslation('agentV2') const { t } = useTranslation('agentV2')
const docLink = useDocLink() const docLink = useDocLink()
const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
const getValidationMessage = useKnowledgeValidationMessage()
const fallbackName = t('agentDetail.configure.knowledgeRetrieval.retrievalOne') const fallbackName = t('agentDetail.configure.knowledgeRetrieval.retrievalOne')
const hydrationKey = open ? (item?.id ?? initialName ?? 'new') : null const hydrationKey = open ? (item?.id ?? initialName ?? 'new') : null
const [dialogState, setDialogState] = useState(() => createDialogState(item, initialName, fallbackName, hydrationKey ?? 'new')) const [dialogState, setDialogState] = useState(() => createDialogState(item, initialName, fallbackName, hydrationKey ?? 'new'))
@ -242,6 +250,7 @@ export function AgentKnowledgeRetrievalDialog({
rerankModelOpen, rerankModelOpen,
retrievalMode, retrievalMode,
selectedDatasets, selectedDatasets,
singleRetrievalConfig,
} = dialogState } = dialogState
const patchDialogState = (patch: Partial<KnowledgeRetrievalDialogState>) => { const patchDialogState = (patch: Partial<KnowledgeRetrievalDialogState>) => {
setDialogState(current => ({ setDialogState(current => ({
@ -273,6 +282,18 @@ export function AgentKnowledgeRetrievalDialog({
} }
}) })
} }
const setSingleRetrievalConfig = (update: SetStateAction<SingleRetrievalConfig | undefined>) => {
setDialogState((current) => {
const nextSingleRetrievalConfig = typeof update === 'function'
? update(current.singleRetrievalConfig)
: update
return {
...current,
singleRetrievalConfig: nextSingleRetrievalConfig,
}
})
}
const updateItem = (patch: Partial<AgentKnowledgeRetrievalItem>) => { const updateItem = (patch: Partial<AgentKnowledgeRetrievalItem>) => {
if (!item) if (!item)
return return
@ -285,6 +306,7 @@ export function AgentKnowledgeRetrievalDialog({
selectedDatasets, selectedDatasets,
retrievalMode, retrievalMode,
multipleRetrievalConfig, multipleRetrievalConfig,
singleRetrievalConfig,
metadataFilterMode, metadataFilterMode,
metadataFilteringConditions, metadataFilteringConditions,
metadataModelConfig, metadataModelConfig,
@ -299,6 +321,13 @@ export function AgentKnowledgeRetrievalDialog({
return intersectionBy(...datasetsWithMetadata.map(dataset => dataset.doc_metadata!), 'name') return intersectionBy(...datasetsWithMetadata.map(dataset => dataset.doc_metadata!), 'name')
}, [selectedDatasets]) }, [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(() => { useEffect(() => {
if (hydratedKey !== hydrationKey) { if (hydratedKey !== hydrationKey) {
@ -338,6 +367,7 @@ export function AgentKnowledgeRetrievalDialog({
<Input <Input
ref={nameInputRef} ref={nameInputRef}
aria-label={t('agentDetail.configure.knowledgeRetrieval.dialog.nameLabel')} aria-label={t('agentDetail.configure.knowledgeRetrieval.dialog.nameLabel')}
aria-invalid={nameError ? true : undefined}
className="h-7 min-w-0 flex-1 rounded-md px-1 py-0 system-xl-semibold text-text-primary" className="h-7 min-w-0 flex-1 rounded-md px-1 py-0 system-xl-semibold text-text-primary"
value={name} value={name}
onBlur={() => patchDialogState({ isEditingName: false })} onBlur={() => patchDialogState({ isEditingName: false })}
@ -365,6 +395,11 @@ export function AgentKnowledgeRetrievalDialog({
)} )}
<DialogCloseButton className="static size-7 shrink-0 rounded-md" /> <DialogCloseButton className="static size-7 shrink-0 rounded-md" />
</div> </div>
{nameError && (
<div role="alert" className="px-4 pt-1 system-xs-regular text-text-destructive">
{nameError}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col gap-1 py-2"> <div className="flex min-h-0 flex-1 flex-col gap-1 py-2">
<div className="flex flex-col gap-1 px-4 py-2"> <div className="flex flex-col gap-1 px-4 py-2">
@ -402,6 +437,7 @@ export function AgentKnowledgeRetrievalDialog({
<div className="pt-1"> <div className="pt-1">
<Textarea <Textarea
aria-label={t('agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel')} aria-label={t('agentDetail.configure.knowledgeRetrieval.dialog.query.customInputLabel')}
aria-invalid={queryError ? true : undefined}
className="h-20 resize-none rounded-lg px-3 py-2 system-sm-regular" className="h-20 resize-none rounded-lg px-3 py-2 system-sm-regular"
placeholder={t('agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder')} placeholder={t('agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder')}
value={customQuery} value={customQuery}
@ -414,6 +450,11 @@ export function AgentKnowledgeRetrievalDialog({
<p className="system-xs-regular text-text-tertiary"> <p className="system-xs-regular text-text-tertiary">
{t('agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription')} {t('agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription')}
</p> </p>
{queryError && (
<p role="alert" className="system-xs-regular text-text-destructive">
{queryError}
</p>
)}
</> </>
) )
: ( : (
@ -433,6 +474,7 @@ export function AgentKnowledgeRetrievalDialog({
payload={{ payload={{
retrieval_mode: retrievalMode, retrieval_mode: retrievalMode,
multiple_retrieval_config: multipleRetrievalConfig, multiple_retrieval_config: multipleRetrievalConfig,
single_retrieval_config: singleRetrievalConfig,
}} }}
onRetrievalModeChange={(nextRetrievalMode) => { onRetrievalModeChange={(nextRetrievalMode) => {
patchDialogState({ retrievalMode: nextRetrievalMode }) patchDialogState({ retrievalMode: nextRetrievalMode })
@ -442,6 +484,35 @@ export function AgentKnowledgeRetrievalDialog({
patchDialogState({ multipleRetrievalConfig: nextMultipleRetrievalConfig }) patchDialogState({ multipleRetrievalConfig: nextMultipleRetrievalConfig })
updateItem({ multipleRetrievalConfig: nextMultipleRetrievalConfig }) updateItem({ multipleRetrievalConfig: nextMultipleRetrievalConfig })
}} }}
singleRetrievalModelConfig={singleRetrievalConfig?.model}
onSingleRetrievalModelChange={(model) => {
setSingleRetrievalConfig((current) => {
const nextSingleRetrievalConfig = {
model: {
provider: model.provider,
name: model.modelId,
mode: model.mode ?? current?.model.mode ?? AppModeEnum.CHAT,
completion_params: current?.model.completion_params ?? { temperature: 0.7 },
},
}
updateItem({ singleRetrievalConfig: nextSingleRetrievalConfig })
return nextSingleRetrievalConfig
})
}}
onSingleRetrievalModelParamsChange={(completionParams) => {
setSingleRetrievalConfig((current) => {
const nextSingleRetrievalConfig = {
model: {
provider: current?.model.provider ?? '',
name: current?.model.name ?? '',
mode: current?.model.mode ?? AppModeEnum.CHAT,
completion_params: completionParams,
},
}
updateItem({ singleRetrievalConfig: nextSingleRetrievalConfig })
return nextSingleRetrievalConfig
})
}}
readonly={!selectedDatasets.length} readonly={!selectedDatasets.length}
modal modal
rerankModalOpen={rerankModelOpen} rerankModalOpen={rerankModelOpen}
@ -460,14 +531,24 @@ export function AgentKnowledgeRetrievalDialog({
</div> </div>
)} )}
> >
<DatasetList <>
list={selectedDatasets} <DatasetList
onChange={nextDatasets => patchDialogState({ selectedDatasets: nextDatasets })} list={selectedDatasets}
settingsDrawerBackdropClassName="bg-background-overlay" onChange={(nextDatasets) => {
settingsDrawerBackdropForceRender patchDialogState({ selectedDatasets: nextDatasets })
settingsDrawerPopupClassName="data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:bottom-6" updateItem({ selectedDatasets: nextDatasets })
settingsModalHeight="100%" }}
/> settingsDrawerBackdropClassName="bg-background-overlay"
settingsDrawerBackdropForceRender
settingsDrawerPopupClassName="data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:bottom-6"
settingsModalHeight="100%"
/>
{(datasetsError || retrievalError) && (
<div role="alert" className="pt-2 system-xs-regular text-text-destructive">
{datasetsError ?? retrievalError}
</div>
)}
</>
</Field> </Field>
</div> </div>
@ -549,6 +630,11 @@ export function AgentKnowledgeRetrievalDialog({
}) })
}} }}
/> />
{metadataError && (
<div role="alert" className="px-4 pt-2 system-xs-regular text-text-destructive">
{metadataError}
</div>
)}
</div> </div>
</div> </div>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { AgentConfigSnapshotDetailResponse, AgentConfigSnapshotSummaryResponse, AgentReferencingWorkflowResponse, AgentReferencingWorkflowsResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen' import type { AgentConfigSnapshotSummaryResponse, AgentReferencingWorkflowResponse, AgentReferencingWorkflowsResponse } from '@dify/contracts/api/console/agent/types.gen'
import type { RegisterableHotkey } from '@tanstack/react-hotkeys' import type { RegisterableHotkey } from '@tanstack/react-hotkeys'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Button } from '@langgenius/dify-ui/button' import { Button } from '@langgenius/dify-ui/button'
@ -10,9 +10,12 @@ import { StatusDot } from '@langgenius/dify-ui/status-dot'
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys' import { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react' import { useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useConfigPublishPayload, useHasAgentComposerUnpublishedChanges } from '@/features/agent-v2/agent-composer/store' import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation'
import { useHasAgentComposerUnpublishedChanges } from '@/features/agent-v2/agent-composer/store'
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client' import { consoleQuery } from '@/service/client'
@ -20,11 +23,6 @@ import { AgentPublishImpactDetails } from './publish-impact-details'
const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey const PUBLISH_AGENT_HOTKEY = 'Mod+Shift+P' satisfies RegisterableHotkey
export type AgentConfigurePublishPayload = {
agent_id: string
config_snapshot: AgentSoulConfig
}
type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished' type AgentConfigurePublishState = 'draft' | 'publishing' | 'published' | 'unpublished'
type PublishBarMode = { status: 'compact' } type PublishBarMode = { status: 'compact' }
@ -34,16 +32,11 @@ type AgentConfigurePublishBarProps = {
agentId: string agentId: string
activeConfigIsPublished?: boolean activeConfigIsPublished?: boolean
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot']
agentName?: string | null agentName?: string | null
currentModel?: {
provider: string
model: string
}
draftSavedAt?: number draftSavedAt?: number
isPublishing?: boolean isPublishing?: boolean
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
onPublish?: (payload: AgentConfigurePublishPayload) => void | Promise<void> onPublish?: () => void | Promise<void>
onExitVersions?: () => void onExitVersions?: () => void
onOpenVersions: () => void onOpenVersions: () => void
} }
@ -85,9 +78,7 @@ export function AgentConfigurePublishBar({
agentId, agentId,
activeConfigIsPublished, activeConfigIsPublished,
activeConfigSnapshot, activeConfigSnapshot,
agentSoulConfig,
agentName, agentName,
currentModel,
draftSavedAt, draftSavedAt,
isPublishing = false, isPublishing = false,
selectedVersionSnapshot, selectedVersionSnapshot,
@ -101,11 +92,9 @@ export function AgentConfigurePublishBar({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' }) const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' })
const hasUnpublishedChanges = useHasAgentComposerUnpublishedChanges() const hasUnpublishedChanges = useHasAgentComposerUnpublishedChanges()
const publishPayload = useConfigPublishPayload({ const knowledgeRetrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
agentId, const knowledgeValidation = validateKnowledgeRetrievals(knowledgeRetrievals)
baseConfig: agentSoulConfig, const getValidationMessage = useKnowledgeValidationMessage()
currentModel,
})
const publishableState = getPublishState({ const publishableState = getPublishState({
activeConfigIsPublished, activeConfigIsPublished,
activeConfigSnapshot, activeConfigSnapshot,
@ -119,6 +108,7 @@ export function AgentConfigurePublishBar({
isPublishing, isPublishing,
}) })
const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished') const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished')
const publishValidationMessage = getValidationMessage(knowledgeValidation.firstIssue?.code)
const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({ const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
input: { input: {
params: { params: {
@ -131,7 +121,7 @@ export function AgentConfigurePublishBar({
enabled: publishIsAvailable && !selectedVersionSnapshot, enabled: publishIsAvailable && !selectedVersionSnapshot,
}) })
const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions()) const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions())
const canPublish = publishIsAvailable const canPublish = publishIsAvailable && knowledgeValidation.isValid
const handleRestoreVersion = (versionId: string) => { const handleRestoreVersion = (versionId: string) => {
if (restoreVersionMutation.isPending) if (restoreVersionMutation.isPending)
@ -178,7 +168,7 @@ export function AgentConfigurePublishBar({
if (!canPublish) if (!canPublish)
return return
await onPublish?.(publishPayload) await onPublish?.()
setPublishBarMode({ status: 'compact' }) setPublishBarMode({ status: 'compact' })
} }
@ -275,6 +265,9 @@ export function AgentConfigurePublishBar({
const currentStateMeta = stateMeta[publishState] const currentStateMeta = stateMeta[publishState]
const isConfirmingImpact = publishBarMode.status === 'confirmingImpact' && (canPublish || isPublishing) const isConfirmingImpact = publishBarMode.status === 'confirmingImpact' && (canPublish || isPublishing)
const impactReferences = publishBarMode.status === 'confirmingImpact' ? publishBarMode.references : [] const impactReferences = publishBarMode.status === 'confirmingImpact' ? publishBarMode.references : []
const effectiveMetaLabel = publishValidationMessage && publishIsAvailable
? publishValidationMessage
: currentStateMeta.metaLabel
return ( return (
<PublishBarBottomActions> <PublishBarBottomActions>
@ -294,7 +287,7 @@ export function AgentConfigurePublishBar({
actionLabel={currentStateMeta.actionLabel} actionLabel={currentStateMeta.actionLabel}
dotStatus={currentStateMeta.dotStatus} dotStatus={currentStateMeta.dotStatus}
isPublishing={isPublishing} isPublishing={isPublishing}
metaLabel={currentStateMeta.metaLabel} metaLabel={effectiveMetaLabel}
showShortcut={currentStateMeta.showShortcut} showShortcut={currentStateMeta.showShortcut}
statusLabel={currentStateMeta.statusLabel} statusLabel={currentStateMeta.statusLabel}
canPublish={canPublish} canPublish={canPublish}

View File

@ -1,11 +1,13 @@
import { toast } from '@langgenius/dify-ui/toast' import { toast } from '@langgenius/dify-ui/toast'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { useAtomValue } from 'jotai'
import userEvent from '@testing-library/user-event' import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' 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 { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider' import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
import { useAgentComposerConfigSnapshot } from '@/features/agent-v2/agent-composer/store' import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
import { AgentDriveApiContextProvider } from '../../drive-context' import { AgentDriveApiContextProvider } from '../../drive-context'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context' import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import { AgentSkills } from '../index' import { AgentSkills } from '../index'
@ -191,7 +193,8 @@ function renderReadonlyAgentSkills() {
} }
function ConfigSnapshotProbe() { function ConfigSnapshotProbe() {
const configSnapshot = useAgentComposerConfigSnapshot({}) const draft = useAtomValue(agentComposerDraftAtom)
const configSnapshot = formStateToAgentSoulConfig({ formState: draft })
return ( return (
<pre data-testid="config-snapshot-probe"> <pre data-testid="config-snapshot-probe">

View File

@ -140,6 +140,34 @@ const toCliTool = (tool: AgentCliToolConfig) => ({
enabled: tool.enabled ?? true, enabled: tool.enabled ?? true,
}) })
const toLegacyPreviewDatasetConfigs = (
knowledge?: AgentSoulConfig['knowledge'],
): NonNullable<ChatConfig['dataset_configs']> => {
// Temporary preview adapter: composer state is knowledge.sets, but this
// legacy chat preview contract still accepts one flat dataset_configs block.
// Preview currently flattens the first configured set only.
const previewKnowledgeSet = knowledge?.sets?.[0]
const datasets = previewKnowledgeSet?.datasets ?? []
const retrieval = previewKnowledgeSet?.retrieval
return {
retrieval_model: retrieval?.mode === 'single' ? RETRIEVE_TYPE.oneWay : RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: retrieval?.reranking_model?.provider ?? '',
reranking_model_name: retrieval?.reranking_model?.model ?? '',
},
top_k: retrieval?.top_k ?? 4,
score_threshold_enabled: retrieval?.score_threshold !== undefined && retrieval?.score_threshold !== null,
score_threshold: retrieval?.score_threshold ?? 0.8,
datasets: {
datasets: datasets.map(dataset => ({
enabled: true,
id: dataset.id ?? '',
})).filter(dataset => dataset.id),
},
}
}
const stopAgentChatMessageResponding = (agentId: string, taskId: string) => { const stopAgentChatMessageResponding = (agentId: string, taskId: string) => {
return consoleClient.agent.byAgentId.chatMessages.byTaskId.stop.post({ return consoleClient.agent.byAgentId.chatMessages.byTaskId.stop.post({
params: { params: {
@ -295,7 +323,6 @@ const buildChatConfig = ({
}): AgentPreviewChatConfig => { }): AgentPreviewChatConfig => {
const modelSettings = getModelSettings(agentSoulConfig) const modelSettings = getModelSettings(agentSoulConfig)
const appFeatures = agentSoulConfig?.app_features ?? {} const appFeatures = agentSoulConfig?.app_features ?? {}
const datasets = agentSoulConfig?.knowledge?.datasets ?? []
const difyTools = agentSoulConfig?.tools?.dify_tools ?? [] const difyTools = agentSoulConfig?.tools?.dify_tools ?? []
const cliTools = agentSoulConfig?.tools?.cli_tools ?? [] const cliTools = agentSoulConfig?.tools?.cli_tools ?? []
@ -354,22 +381,7 @@ const buildChatConfig = ({
echo: false, echo: false,
}, },
}, },
dataset_configs: { dataset_configs: toLegacyPreviewDatasetConfigs(agentSoulConfig?.knowledge),
retrieval_model: RETRIEVE_TYPE.multiWay,
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: agentSoulConfig?.knowledge?.query_config?.top_k ?? 4,
score_threshold_enabled: agentSoulConfig?.knowledge?.query_config?.score_threshold_enabled ?? false,
score_threshold: agentSoulConfig?.knowledge?.query_config?.score_threshold ?? 0.8,
datasets: {
datasets: datasets.map(dataset => ({
enabled: true,
id: dataset.id ?? '',
})).filter(dataset => dataset.id),
},
},
file_upload: disabledFileUploadConfig, file_upload: disabledFileUploadConfig,
system_parameters: defaultSystemParameters, system_parameters: defaultSystemParameters,
supportCitationHitInfo: true, supportCitationHitInfo: true,

View File

@ -8,6 +8,7 @@ import { useSetAtom, useStore } from 'jotai'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback' import { useSerialAsyncCallback } from '@/app/components/workflow/hooks/use-serial-async-callback'
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions' import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions'
import { validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation'
import { import {
agentComposerDraftAtom, agentComposerDraftAtom,
agentComposerOriginalConfigAtom, agentComposerOriginalConfigAtom,
@ -19,9 +20,10 @@ import { consoleQuery } from '@/service/client'
const DRAFT_AUTOSAVE_WAIT = 5000 const DRAFT_AUTOSAVE_WAIT = 5000
type AgentConfigurePublishPayload = { class InvalidKnowledgeConfigurationError extends Error {
agent_id: string constructor() {
config_snapshot: AgentSoulConfig super('Agent knowledge retrieval configuration is invalid.')
}
} }
export function useAgentConfigureSync({ export function useAgentConfigureSync({
@ -117,6 +119,10 @@ export function useAgentConfigureSync({
const latestDraftSaveRef = useRef<() => void>(() => undefined) const latestDraftSaveRef = useRef<() => void>(() => undefined)
latestDraftSaveRef.current = () => { latestDraftSaveRef.current = () => {
const draft = store.get(agentComposerDraftAtom)
if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid)
return
void saveComposer('save_to_current_version', getAgentSoulDraft()) void saveComposer('save_to_current_version', getAgentSoulDraft())
} }
@ -128,9 +134,13 @@ export function useAgentConfigureSync({
if (!enabledRef.current) if (!enabledRef.current)
return return
const draft = store.get(agentComposerDraftAtom)
if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid)
throw new InvalidKnowledgeConfigurationError()
debouncedSaveDraft.cancel?.() debouncedSaveDraft.cancel?.()
await saveComposer('save_to_current_version', getAgentSoulDraft()) await saveComposer('save_to_current_version', getAgentSoulDraft())
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer]) }, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
useEffect(() => { useEffect(() => {
return store.sub(agentComposerDraftAtom, () => { return store.sub(agentComposerDraftAtom, () => {
@ -140,6 +150,7 @@ export function useAgentConfigureSync({
if ( if (
!enabledRef.current !enabledRef.current
|| !store.get(isAgentComposerDirtyAtom) || !store.get(isAgentComposerDirtyAtom)
|| !validateKnowledgeRetrievals(store.get(agentComposerDraftAtom).knowledgeRetrievals).isValid
|| lastAutosavedDraftKeyRef.current === agentSoulDraftKey || lastAutosavedDraftKeyRef.current === agentSoulDraftKey
) { ) {
return return
@ -155,15 +166,18 @@ export function useAgentConfigureSync({
} }
}, [debouncedSaveDraft]) }, [debouncedSaveDraft])
const publishDraft = useCallback(async (payload: AgentConfigurePublishPayload) => { const publishDraft = useCallback(async () => {
const draft = store.get(agentComposerDraftAtom)
if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid)
throw new InvalidKnowledgeConfigurationError()
debouncedSaveDraft.cancel?.() debouncedSaveDraft.cancel?.()
try { await saveComposer('save_as_new_version', formStateToAgentSoulConfig({
await saveComposer('save_as_new_version', payload.config_snapshot) baseConfig: baseConfigRef.current,
} formState: draft,
catch { currentModel: currentModelRef.current,
// Draft sync follows workflow autosave behavior: save failures are silent and keep the local draft intact. }))
} }, [debouncedSaveDraft, saveComposer, store])
}, [debouncedSaveDraft, saveComposer])
return { return {
draftSavedAt, draftSavedAt,