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

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 {
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<NonNullable<AgentSoulConfig['tools']>['dify_tools']>[number]
type AgentSoulCliToolConfig = NonNullable<NonNullable<AgentSoulConfig['tools']>['cli_tools']>[number]
type AgentSoulToolRuntimeParameterValue = NonNullable<AgentSoulDifyToolConfig['runtime_parameters']>[string]
type AgentSoulEnvVariableConfig = NonNullable<NonNullable<AgentSoulConfig['env']>['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),
}
}

View File

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

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 { 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<AgentSoulConfig | undefined>(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])
}

View File

@ -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<string, unknown> }>()
composerPutMutationFn.mockReturnValueOnce(publishDeferred.promise)
const { result } = renderUseAgentConfigureSync()
const publishPayload = {
agent_id: 'agent-1',
config_snapshot: {
prompt: {
system_prompt: 'Published prompt',
},
},
}
let publishPromise!: Promise<void>
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)

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 () => {
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)
})
})

View File

@ -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<void>
onPublish: () => void | Promise<void>
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}

View File

@ -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 (
<output aria-label="publish payload">
{JSON.stringify(payload.config_snapshot.knowledge)}
<output aria-label="config snapshot">
{JSON.stringify(configSnapshot.knowledge)}
</output>
)
}
@ -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({
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentKnowledgeRetrieval />
</AgentOrchestrateReadOnlyContext>
{showPublishPayload && <PublishPayloadPreview />}
{showConfigSnapshot && <ConfigSnapshotPreview />}
</AgentComposerProvider>
</QueryClientProvider>,
)
@ -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',
})
})

View File

@ -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<KnowledgeRetrievalDialogState>) => {
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>) => {
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({
<Input
ref={nameInputRef}
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"
value={name}
onBlur={() => patchDialogState({ isEditingName: false })}
@ -365,6 +395,11 @@ export function AgentKnowledgeRetrievalDialog({
)}
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
</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 flex-col gap-1 px-4 py-2">
@ -402,6 +437,7 @@ export function AgentKnowledgeRetrievalDialog({
<div className="pt-1">
<Textarea
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"
placeholder={t('agentDetail.configure.knowledgeRetrieval.dialog.query.customPlaceholder')}
value={customQuery}
@ -414,6 +450,11 @@ export function AgentKnowledgeRetrievalDialog({
<p className="system-xs-regular text-text-tertiary">
{t('agentDetail.configure.knowledgeRetrieval.dialog.query.customDescription')}
</p>
{queryError && (
<p role="alert" className="system-xs-regular text-text-destructive">
{queryError}
</p>
)}
</>
)
: (
@ -433,6 +474,7 @@ export function AgentKnowledgeRetrievalDialog({
payload={{
retrieval_mode: retrievalMode,
multiple_retrieval_config: multipleRetrievalConfig,
single_retrieval_config: singleRetrievalConfig,
}}
onRetrievalModeChange={(nextRetrievalMode) => {
patchDialogState({ retrievalMode: nextRetrievalMode })
@ -442,6 +484,35 @@ export function AgentKnowledgeRetrievalDialog({
patchDialogState({ 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}
modal
rerankModalOpen={rerankModelOpen}
@ -460,14 +531,24 @@ export function AgentKnowledgeRetrievalDialog({
</div>
)}
>
<DatasetList
list={selectedDatasets}
onChange={nextDatasets => patchDialogState({ selectedDatasets: nextDatasets })}
settingsDrawerBackdropClassName="bg-background-overlay"
settingsDrawerBackdropForceRender
settingsDrawerPopupClassName="data-[swipe-direction=right]:top-6 data-[swipe-direction=right]:bottom-6"
settingsModalHeight="100%"
/>
<>
<DatasetList
list={selectedDatasets}
onChange={(nextDatasets) => {
patchDialogState({ selectedDatasets: nextDatasets })
updateItem({ selectedDatasets: nextDatasets })
}}
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>
</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>

View File

@ -1,6 +1,6 @@
'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 { ReactNode } from 'react'
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 { formatForDisplay, useHotkey } from '@tanstack/react-hotkeys'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useState } from 'react'
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 useTimestamp from '@/hooks/use-timestamp'
import { consoleQuery } from '@/service/client'
@ -20,11 +23,6 @@ import { AgentPublishImpactDetails } from './publish-impact-details'
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 PublishBarMode = { status: 'compact' }
@ -34,16 +32,11 @@ type AgentConfigurePublishBarProps = {
agentId: string
activeConfigIsPublished?: boolean
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
agentSoulConfig?: AgentConfigSnapshotDetailResponse['config_snapshot']
agentName?: string | null
currentModel?: {
provider: string
model: string
}
draftSavedAt?: number
isPublishing?: boolean
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
onPublish?: (payload: AgentConfigurePublishPayload) => void | Promise<void>
onPublish?: () => void | Promise<void>
onExitVersions?: () => void
onOpenVersions: () => void
}
@ -85,9 +78,7 @@ export function AgentConfigurePublishBar({
agentId,
activeConfigIsPublished,
activeConfigSnapshot,
agentSoulConfig,
agentName,
currentModel,
draftSavedAt,
isPublishing = false,
selectedVersionSnapshot,
@ -101,11 +92,9 @@ export function AgentConfigurePublishBar({
const queryClient = useQueryClient()
const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' })
const hasUnpublishedChanges = useHasAgentComposerUnpublishedChanges()
const publishPayload = useConfigPublishPayload({
agentId,
baseConfig: agentSoulConfig,
currentModel,
})
const knowledgeRetrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
const knowledgeValidation = validateKnowledgeRetrievals(knowledgeRetrievals)
const getValidationMessage = useKnowledgeValidationMessage()
const publishableState = getPublishState({
activeConfigIsPublished,
activeConfigSnapshot,
@ -119,6 +108,7 @@ export function AgentConfigurePublishBar({
isPublishing,
})
const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished')
const publishValidationMessage = getValidationMessage(knowledgeValidation.firstIssue?.code)
const workflowReferencesQueryOptions = consoleQuery.agent.byAgentId.referencingWorkflows.get.queryOptions({
input: {
params: {
@ -131,7 +121,7 @@ export function AgentConfigurePublishBar({
enabled: publishIsAvailable && !selectedVersionSnapshot,
})
const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions())
const canPublish = publishIsAvailable
const canPublish = publishIsAvailable && knowledgeValidation.isValid
const handleRestoreVersion = (versionId: string) => {
if (restoreVersionMutation.isPending)
@ -178,7 +168,7 @@ export function AgentConfigurePublishBar({
if (!canPublish)
return
await onPublish?.(publishPayload)
await onPublish?.()
setPublishBarMode({ status: 'compact' })
}
@ -275,6 +265,9 @@ export function AgentConfigurePublishBar({
const currentStateMeta = stateMeta[publishState]
const isConfirmingImpact = publishBarMode.status === 'confirmingImpact' && (canPublish || isPublishing)
const impactReferences = publishBarMode.status === 'confirmingImpact' ? publishBarMode.references : []
const effectiveMetaLabel = publishValidationMessage && publishIsAvailable
? publishValidationMessage
: currentStateMeta.metaLabel
return (
<PublishBarBottomActions>
@ -294,7 +287,7 @@ export function AgentConfigurePublishBar({
actionLabel={currentStateMeta.actionLabel}
dotStatus={currentStateMeta.dotStatus}
isPublishing={isPublishing}
metaLabel={currentStateMeta.metaLabel}
metaLabel={effectiveMetaLabel}
showShortcut={currentStateMeta.showShortcut}
statusLabel={currentStateMeta.statusLabel}
canPublish={canPublish}

View File

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

View File

@ -140,6 +140,34 @@ const toCliTool = (tool: AgentCliToolConfig) => ({
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) => {
return consoleClient.agent.byAgentId.chatMessages.byTaskId.stop.post({
params: {
@ -295,7 +323,6 @@ const buildChatConfig = ({
}): AgentPreviewChatConfig => {
const modelSettings = getModelSettings(agentSoulConfig)
const appFeatures = agentSoulConfig?.app_features ?? {}
const datasets = agentSoulConfig?.knowledge?.datasets ?? []
const difyTools = agentSoulConfig?.tools?.dify_tools ?? []
const cliTools = agentSoulConfig?.tools?.cli_tools ?? []
@ -354,22 +381,7 @@ const buildChatConfig = ({
echo: false,
},
},
dataset_configs: {
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),
},
},
dataset_configs: toLegacyPreviewDatasetConfigs(agentSoulConfig?.knowledge),
file_upload: disabledFileUploadConfig,
system_parameters: defaultSystemParameters,
supportCitationHitInfo: true,

View File

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