mirror of
https://github.com/langgenius/dify.git
synced 2026-08-29 01:53:21 +08:00
feat(web): support agent v2 knowledge sets
This commit is contained in:
parent
c06d924094
commit
3efc1f93b9
@ -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' })
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
175
web/features/agent-v2/agent-composer/knowledge-validation.ts
Normal file
175
web/features/agent-v2/agent-composer/knowledge-validation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user