mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
chore(agent-v2): sync changes (#38442)
Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: zyssyz123 <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one>
This commit is contained in:
co-authored by
Joel
zyssyz123
autofix-ci[bot]
林玮
盐粒 Yanli
parent
bdb3469ca0
commit
d0ea5a5e0d
@@ -0,0 +1,84 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultAgentSoulConfigFormState } from '../../form-state'
|
||||
import { agentComposerDraftAtom } from '../../store'
|
||||
import {
|
||||
addEnvVariableAtom,
|
||||
importEnvVariablesAtom,
|
||||
removeEnvVariableAtom,
|
||||
setEnvVariableKeyAtom,
|
||||
setEnvVariableValueAtom,
|
||||
} from '../env'
|
||||
|
||||
const starterVariable = {
|
||||
id: 'starter',
|
||||
key: '',
|
||||
value: '',
|
||||
scope: 'plain',
|
||||
} as const
|
||||
|
||||
describe('agent composer env store', () => {
|
||||
it('should promote the starter variable when editing an empty env list', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
|
||||
store.set(setEnvVariableKeyAtom, {
|
||||
id: starterVariable.id,
|
||||
key: 'API_KEY',
|
||||
starterVariable,
|
||||
})
|
||||
store.set(setEnvVariableValueAtom, {
|
||||
id: starterVariable.id,
|
||||
starterVariable,
|
||||
value: 'secret-value',
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).envVariables).toEqual([
|
||||
{
|
||||
id: 'starter',
|
||||
key: 'API_KEY',
|
||||
value: 'secret-value',
|
||||
scope: 'plain',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should add, import, and remove variables from the latest draft state', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
|
||||
store.set(addEnvVariableAtom, {
|
||||
starterVariable,
|
||||
variable: {
|
||||
id: 'env-1',
|
||||
key: 'FIRST_KEY',
|
||||
value: '',
|
||||
scope: 'plain',
|
||||
},
|
||||
})
|
||||
store.set(importEnvVariablesAtom, [
|
||||
{
|
||||
id: 'env-2',
|
||||
key: 'SECOND_KEY',
|
||||
value: 'enabled',
|
||||
scope: 'plain',
|
||||
},
|
||||
])
|
||||
store.set(removeEnvVariableAtom, 'starter')
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).envVariables).toEqual([
|
||||
{
|
||||
id: 'env-1',
|
||||
key: 'FIRST_KEY',
|
||||
value: '',
|
||||
scope: 'plain',
|
||||
},
|
||||
{
|
||||
id: 'env-2',
|
||||
key: 'SECOND_KEY',
|
||||
value: 'enabled',
|
||||
scope: 'plain',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultAgentSoulConfigFormState } from '../../form-state'
|
||||
import { agentComposerDraftAtom } from '../../store'
|
||||
import {
|
||||
clearAgentConfigNoteAtom,
|
||||
removeAgentFileAtom,
|
||||
upsertAgentFileAtom,
|
||||
} from '../files'
|
||||
|
||||
describe('agent composer files store', () => {
|
||||
it('should upsert and remove files from the latest draft state', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
files: [
|
||||
{
|
||||
id: 'folder',
|
||||
icon: 'folder',
|
||||
name: 'Folder',
|
||||
children: [
|
||||
{
|
||||
id: 'brief.md',
|
||||
icon: 'markdown',
|
||||
name: 'brief.md',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'diagram.png',
|
||||
icon: 'image',
|
||||
name: 'diagram.png',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.set(upsertAgentFileAtom, {
|
||||
id: 'diagram.png',
|
||||
icon: 'image',
|
||||
name: 'updated-diagram.png',
|
||||
})
|
||||
store.set(removeAgentFileAtom, 'brief.md')
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).files).toEqual([
|
||||
{
|
||||
id: 'folder',
|
||||
icon: 'folder',
|
||||
name: 'Folder',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: 'diagram.png',
|
||||
icon: 'image',
|
||||
name: 'updated-diagram.png',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should clear config note through the file action surface', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
configNote: 'Build note',
|
||||
})
|
||||
|
||||
store.set(clearAgentConfigNoteAtom)
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).configNote).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultAgentSoulConfigFormState } from '../../form-state'
|
||||
import { agentComposerDraftAtom } from '../../store'
|
||||
import {
|
||||
addKnowledgeRetrievalAtom,
|
||||
removeKnowledgeRetrievalAtom,
|
||||
updateKnowledgeRetrievalAtom,
|
||||
} from '../knowledge'
|
||||
|
||||
describe('agent composer knowledge store', () => {
|
||||
it('should apply retrieval list actions against the latest draft state', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
knowledgeRetrievals: [
|
||||
{
|
||||
id: 'retrieval-1',
|
||||
name: 'Docs Search',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.set(addKnowledgeRetrievalAtom, {
|
||||
id: 'retrieval-2',
|
||||
name: 'Release Search',
|
||||
})
|
||||
store.set(updateKnowledgeRetrievalAtom, {
|
||||
id: 'retrieval-1',
|
||||
name: 'Updated Docs Search',
|
||||
})
|
||||
store.set(removeKnowledgeRetrievalAtom, 'retrieval-2')
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).knowledgeRetrievals).toEqual([
|
||||
{
|
||||
id: 'retrieval-1',
|
||||
name: 'Updated Docs Search',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultAgentSoulConfigFormState } from '../../form-state'
|
||||
import { agentComposerDraftAtom } from '../../store'
|
||||
import {
|
||||
removeAgentSkillAtom,
|
||||
upsertAgentSkillAtom,
|
||||
} from '../skills'
|
||||
|
||||
describe('agent composer skills store', () => {
|
||||
it('should upsert and remove skills from the latest draft state', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
skills: [
|
||||
{
|
||||
id: 'Tender Analyzer',
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Extracts tender requirements.',
|
||||
fileId: 'tool-file-1',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
store.set(upsertAgentSkillAtom, {
|
||||
id: 'Tender Analyzer',
|
||||
name: 'Tender Analyzer',
|
||||
description: 'Updated skill.',
|
||||
fileId: 'tool-file-1',
|
||||
})
|
||||
store.set(upsertAgentSkillAtom, {
|
||||
id: 'Invoice Helper',
|
||||
name: 'Invoice Helper',
|
||||
fileId: 'tool-file-2',
|
||||
})
|
||||
store.set(removeAgentSkillAtom, 'Tender Analyzer')
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).skills).toEqual([
|
||||
{
|
||||
id: 'Invoice Helper',
|
||||
name: 'Invoice Helper',
|
||||
fileId: 'tool-file-2',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { AgentProviderToolDefaultValue } from '../tools'
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { defaultAgentSoulConfigFormState } from '../../form-state'
|
||||
import { agentComposerDraftAtom } from '../../store'
|
||||
import {
|
||||
addProviderTools,
|
||||
addProviderToolsAtom,
|
||||
removeProviderToolActionAtom,
|
||||
saveCliToolAtom,
|
||||
} from '../tools'
|
||||
|
||||
const noCredentialTool = {
|
||||
provider_id: 'duckduckgo',
|
||||
provider_type: 'builtin',
|
||||
provider_name: 'DuckDuckGo',
|
||||
provider_show_name: 'DuckDuckGo',
|
||||
tool_name: 'ddg_search',
|
||||
tool_label: 'DuckDuckGo Search',
|
||||
tool_description: 'Search the web.',
|
||||
title: 'DuckDuckGo Search',
|
||||
is_team_authorization: true,
|
||||
params: {},
|
||||
paramSchemas: [],
|
||||
allowDelete: false,
|
||||
credentialRequired: false,
|
||||
} satisfies AgentProviderToolDefaultValue
|
||||
|
||||
const unauthorizedCredentialTool = {
|
||||
...noCredentialTool,
|
||||
provider_id: 'google',
|
||||
provider_name: 'google',
|
||||
provider_show_name: 'Google',
|
||||
tool_name: 'search',
|
||||
tool_label: 'Google Search',
|
||||
title: 'Google Search',
|
||||
is_team_authorization: false,
|
||||
credentialRequired: true,
|
||||
} satisfies AgentProviderToolDefaultValue
|
||||
|
||||
const unauthorizedOAuthTool = {
|
||||
...unauthorizedCredentialTool,
|
||||
provider_id: 'slack',
|
||||
provider_name: 'slack',
|
||||
provider_show_name: 'Slack',
|
||||
credentialType: 'oauth2',
|
||||
} satisfies AgentProviderToolDefaultValue
|
||||
|
||||
describe('agent composer tools store', () => {
|
||||
describe('addProviderTools', () => {
|
||||
it('should not mark tools that do not need credentials as unauthorized', () => {
|
||||
const nextTools = addProviderTools([], [noCredentialTool])
|
||||
|
||||
expect(nextTools).toEqual([
|
||||
expect.objectContaining({
|
||||
credentialId: undefined,
|
||||
credentialType: undefined,
|
||||
credentialVariant: 'none',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should mark credential-required tools without credentials as unauthorized', () => {
|
||||
const nextTools = addProviderTools([], [unauthorizedCredentialTool])
|
||||
|
||||
expect(nextTools).toEqual([
|
||||
expect.objectContaining({
|
||||
credentialId: undefined,
|
||||
credentialType: 'unauthorized',
|
||||
credentialVariant: 'unauthorized',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve oauth credential type for credential-required OAuth tools', () => {
|
||||
const nextTools = addProviderTools([], [unauthorizedOAuthTool])
|
||||
|
||||
expect(nextTools).toEqual([
|
||||
expect.objectContaining({
|
||||
credentialId: undefined,
|
||||
credentialType: 'oauth2',
|
||||
credentialVariant: 'unauthorized',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('write actions', () => {
|
||||
it('should apply provider and CLI updates against the latest draft tools', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
|
||||
store.set(addProviderToolsAtom, [noCredentialTool])
|
||||
store.set(saveCliToolAtom, {
|
||||
id: 'cli-tool',
|
||||
kind: 'cli',
|
||||
name: 'CLI Tool',
|
||||
installCommand: 'pnpm install',
|
||||
})
|
||||
store.set(addProviderToolsAtom, [unauthorizedCredentialTool])
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).tools).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'duckduckgo',
|
||||
kind: 'provider',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'cli-tool',
|
||||
kind: 'cli',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'google',
|
||||
kind: 'provider',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should update existing CLI tools instead of appending duplicates', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
|
||||
store.set(saveCliToolAtom, {
|
||||
id: 'cli-tool',
|
||||
kind: 'cli',
|
||||
name: 'CLI Tool',
|
||||
})
|
||||
store.set(saveCliToolAtom, {
|
||||
id: 'cli-tool',
|
||||
kind: 'cli',
|
||||
name: 'Updated CLI Tool',
|
||||
installCommand: 'pnpm install',
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).tools).toEqual([
|
||||
{
|
||||
id: 'cli-tool',
|
||||
kind: 'cli',
|
||||
name: 'Updated CLI Tool',
|
||||
installCommand: 'pnpm install',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove provider action settings with the action', () => {
|
||||
const store = createStore()
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
tools: [
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
kind: 'provider',
|
||||
name: 'DuckDuckGo',
|
||||
iconClassName: 'i-simple-icons-duckduckgo',
|
||||
credentialVariant: 'none',
|
||||
actions: [
|
||||
{
|
||||
id: 'duckduckgo:ddg_search',
|
||||
name: 'DuckDuckGo Search',
|
||||
toolName: 'ddg_search',
|
||||
description: 'Search the web.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
toolSettings: {
|
||||
'duckduckgo:ddg_search': {
|
||||
query: 'docs',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
store.set(removeProviderToolActionAtom, {
|
||||
toolId: 'duckduckgo',
|
||||
actionId: 'duckduckgo:ddg_search',
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).tools).toEqual([])
|
||||
expect(store.get(agentComposerDraftAtom).toolSettings).toEqual({})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { EnvVariable } from '../form-state'
|
||||
import type { EnvScope, EnvVariable } from '../form-state'
|
||||
import type { DraftFieldUpdate } from './utils'
|
||||
import { atom } from 'jotai'
|
||||
import { agentComposerDraftAtom } from '../store'
|
||||
@@ -15,3 +15,95 @@ export const agentComposerEnvVariablesAtom = atom(
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const updateEnvVariable = (
|
||||
envVariables: EnvVariable[],
|
||||
starterVariable: EnvVariable,
|
||||
id: string,
|
||||
updater: (variable: EnvVariable) => EnvVariable,
|
||||
) => {
|
||||
const existingVariable = envVariables.find(variable => variable.id === id)
|
||||
|
||||
if (existingVariable) {
|
||||
return envVariables.map(variable => (
|
||||
variable.id === id ? updater(variable) : variable
|
||||
))
|
||||
}
|
||||
|
||||
if (id === starterVariable.id)
|
||||
return [updater(starterVariable)]
|
||||
|
||||
return envVariables
|
||||
}
|
||||
|
||||
export const setEnvVariableKeyAtom = atom(null, (_get, set, {
|
||||
id,
|
||||
key,
|
||||
starterVariable,
|
||||
}: {
|
||||
id: string
|
||||
key: string
|
||||
starterVariable: EnvVariable
|
||||
}) => {
|
||||
set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable(
|
||||
envVariables,
|
||||
starterVariable,
|
||||
id,
|
||||
variable => ({ ...variable, key }),
|
||||
))
|
||||
})
|
||||
|
||||
export const setEnvVariableScopeAtom = atom(null, (_get, set, {
|
||||
id,
|
||||
scope,
|
||||
starterVariable,
|
||||
}: {
|
||||
id: string
|
||||
scope: EnvScope
|
||||
starterVariable: EnvVariable
|
||||
}) => {
|
||||
set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable(
|
||||
envVariables,
|
||||
starterVariable,
|
||||
id,
|
||||
variable => ({ ...variable, scope }),
|
||||
))
|
||||
})
|
||||
|
||||
export const setEnvVariableValueAtom = atom(null, (_get, set, {
|
||||
id,
|
||||
starterVariable,
|
||||
value,
|
||||
}: {
|
||||
id: string
|
||||
starterVariable: EnvVariable
|
||||
value: string
|
||||
}) => {
|
||||
set(agentComposerEnvVariablesAtom, envVariables => updateEnvVariable(
|
||||
envVariables,
|
||||
starterVariable,
|
||||
id,
|
||||
variable => ({ ...variable, value }),
|
||||
))
|
||||
})
|
||||
|
||||
export const addEnvVariableAtom = atom(null, (_get, set, {
|
||||
starterVariable,
|
||||
variable,
|
||||
}: {
|
||||
starterVariable: EnvVariable
|
||||
variable: EnvVariable
|
||||
}) => {
|
||||
set(agentComposerEnvVariablesAtom, envVariables => [
|
||||
...(envVariables.length > 0 ? envVariables : [starterVariable]),
|
||||
variable,
|
||||
])
|
||||
})
|
||||
|
||||
export const importEnvVariablesAtom = atom(null, (_get, set, variables: EnvVariable[]) => {
|
||||
set(agentComposerEnvVariablesAtom, envVariables => [...envVariables, ...variables])
|
||||
})
|
||||
|
||||
export const removeEnvVariableAtom = atom(null, (_get, set, id: string) => {
|
||||
set(agentComposerEnvVariablesAtom, envVariables => envVariables.filter(variable => variable.id !== id))
|
||||
})
|
||||
|
||||
@@ -22,3 +22,33 @@ export const agentComposerFilesAtom = atom<AgentFileNode[], [DraftFieldUpdate<Ag
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const removeAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[] => files.flatMap((file) => {
|
||||
if (file.id === fileId)
|
||||
return []
|
||||
|
||||
if (file.children)
|
||||
return [{ ...file, children: removeAgentFileNode(file.children, fileId) }]
|
||||
|
||||
return [file]
|
||||
})
|
||||
|
||||
export const upsertAgentFileAtom = atom(null, (_get, set, file: AgentFileNode) => {
|
||||
set(agentComposerFilesAtom, files => [
|
||||
...removeAgentFileNode(files, file.id),
|
||||
file,
|
||||
])
|
||||
})
|
||||
|
||||
export const removeAgentFileAtom = atom(null, (_get, set, fileId: string) => {
|
||||
set(agentComposerFilesAtom, files => removeAgentFileNode(files, fileId))
|
||||
})
|
||||
|
||||
export const clearAgentConfigNoteAtom = atom(null, (get, set) => {
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
set(agentComposerDraftAtom, {
|
||||
...draft,
|
||||
configNote: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,3 +22,17 @@ export const agentComposerKnowledgeRetrievalsAtom = atom(
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const addKnowledgeRetrievalAtom = atom(null, (_get, set, retrieval: AgentKnowledgeRetrievalItem) => {
|
||||
set(agentComposerKnowledgeRetrievalsAtom, retrievals => [...retrievals, retrieval])
|
||||
})
|
||||
|
||||
export const updateKnowledgeRetrievalAtom = atom(null, (_get, set, retrieval: AgentKnowledgeRetrievalItem) => {
|
||||
set(agentComposerKnowledgeRetrievalsAtom, retrievals => retrievals.map(currentRetrieval => (
|
||||
currentRetrieval.id === retrieval.id ? retrieval : currentRetrieval
|
||||
)))
|
||||
})
|
||||
|
||||
export const removeKnowledgeRetrievalAtom = atom(null, (_get, set, retrievalId: string) => {
|
||||
set(agentComposerKnowledgeRetrievalsAtom, retrievals => retrievals.filter(retrieval => retrieval.id !== retrievalId))
|
||||
})
|
||||
|
||||
@@ -22,3 +22,14 @@ export const agentComposerSkillsAtom = atom<AgentSkill[], [DraftFieldUpdate<Agen
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export const upsertAgentSkillAtom = atom(null, (_get, set, skill: AgentSkill) => {
|
||||
set(agentComposerSkillsAtom, skills => [
|
||||
...skills.filter(item => item.id !== skill.id),
|
||||
skill,
|
||||
])
|
||||
})
|
||||
|
||||
export const removeAgentSkillAtom = atom(null, (_get, set, skillId: string) => {
|
||||
set(agentComposerSkillsAtom, skills => skills.filter(item => item.id !== skillId))
|
||||
})
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import type { AgentProviderTool, AgentSoulConfigFormState, AgentTool } from '../form-state'
|
||||
import type { AgentCliTool, AgentProviderTool, AgentSoulConfigFormState, AgentTool } from '../form-state'
|
||||
import type { DraftFieldUpdate } from './utils'
|
||||
import { atom, useSetAtom } from 'jotai'
|
||||
import { useCallback } from 'react'
|
||||
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
|
||||
import { atom } from 'jotai'
|
||||
import { syncCliToolReferenceLabels } from '../reference-labels'
|
||||
import { agentComposerDraftAtom } from '../store'
|
||||
import { resolveDraftFieldUpdate } from './utils'
|
||||
|
||||
export type AgentProviderToolDefaultValue = ToolDefaultValue & {
|
||||
allowDelete?: boolean
|
||||
credentialType?: AgentProviderTool['credentialType']
|
||||
credentialRequired?: boolean
|
||||
}
|
||||
|
||||
export const agentComposerToolsAtom = atom(
|
||||
get => get(agentComposerDraftAtom).tools,
|
||||
(get, set, toolsUpdate: DraftFieldUpdate<AgentTool[]>) => {
|
||||
@@ -24,6 +30,104 @@ export const agentComposerToolsAtom = atom(
|
||||
},
|
||||
)
|
||||
|
||||
const toProviderToolAction = (tool: AgentProviderToolDefaultValue) => ({
|
||||
id: `${tool.provider_id}:${tool.tool_name}`,
|
||||
name: tool.tool_label || tool.title || tool.tool_name,
|
||||
toolName: tool.tool_name,
|
||||
description: tool.tool_description || '',
|
||||
})
|
||||
|
||||
const getCredentialVariant = (tool: AgentProviderToolDefaultValue) => {
|
||||
if (!tool.credentialRequired)
|
||||
return 'none' as const
|
||||
|
||||
if (!tool.allowDelete)
|
||||
return tool.credential_id ? 'authorized' as const : 'unauthorized' as const
|
||||
|
||||
return tool.is_team_authorization ? 'authorized' as const : 'unauthorized' as const
|
||||
}
|
||||
|
||||
const getCredentialType = (tool: AgentProviderToolDefaultValue) => {
|
||||
if (!tool.credentialRequired)
|
||||
return undefined
|
||||
|
||||
if (tool.credentialType === 'oauth2')
|
||||
return 'oauth2' as const
|
||||
|
||||
if (!tool.allowDelete)
|
||||
return tool.credential_id ? 'api-key' as const : 'unauthorized' as const
|
||||
|
||||
return tool.is_team_authorization ? 'api-key' as const : 'unauthorized' as const
|
||||
}
|
||||
|
||||
export const addProviderTools = (
|
||||
currentTools: AgentTool[],
|
||||
selectedTools: AgentProviderToolDefaultValue[],
|
||||
): AgentTool[] => {
|
||||
if (selectedTools.length === 0)
|
||||
return currentTools
|
||||
|
||||
const nextTools = [...currentTools]
|
||||
|
||||
selectedTools.forEach((selectedTool) => {
|
||||
const action = toProviderToolAction(selectedTool)
|
||||
const existingToolIndex = nextTools.findIndex(tool => tool.kind === 'provider' && tool.id === selectedTool.provider_id)
|
||||
const existingTool = nextTools[existingToolIndex]
|
||||
|
||||
if (existingTool?.kind === 'provider') {
|
||||
if (existingTool.actions.some(existingAction => existingAction.toolName === action.toolName))
|
||||
return
|
||||
|
||||
nextTools[existingToolIndex] = {
|
||||
...existingTool,
|
||||
displayName: existingTool.displayName ?? selectedTool.provider_show_name,
|
||||
icon: existingTool.icon ?? selectedTool.provider_icon,
|
||||
iconDark: existingTool.iconDark ?? selectedTool.provider_icon_dark,
|
||||
allowDelete: existingTool.allowDelete ?? selectedTool.allowDelete,
|
||||
actions: [...existingTool.actions, action],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
nextTools.push({
|
||||
id: selectedTool.provider_id,
|
||||
name: selectedTool.provider_name,
|
||||
kind: 'provider',
|
||||
displayName: selectedTool.provider_show_name,
|
||||
iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary',
|
||||
icon: selectedTool.provider_icon,
|
||||
iconDark: selectedTool.provider_icon_dark,
|
||||
providerType: selectedTool.provider_type,
|
||||
allowDelete: selectedTool.allowDelete,
|
||||
credentialId: selectedTool.credential_id,
|
||||
credentialKey: selectedTool.is_team_authorization
|
||||
? 'agentDetail.configure.tools.credential.authOne'
|
||||
: undefined,
|
||||
credentialType: getCredentialType(selectedTool),
|
||||
credentialVariant: getCredentialVariant(selectedTool),
|
||||
actions: [action],
|
||||
})
|
||||
})
|
||||
|
||||
return nextTools
|
||||
}
|
||||
|
||||
export const addProviderToolsAtom = atom(null, (_get, set, selectedTools: AgentProviderToolDefaultValue[]) => {
|
||||
set(agentComposerToolsAtom, tools => addProviderTools(tools, selectedTools))
|
||||
})
|
||||
|
||||
export const saveCliToolAtom = atom(null, (_get, set, cliTool: AgentCliTool) => {
|
||||
set(agentComposerToolsAtom, tools => (
|
||||
tools.some(tool => tool.kind === 'cli' && tool.id === cliTool.id)
|
||||
? tools.map(tool => tool.id === cliTool.id ? cliTool : tool)
|
||||
: [...tools, cliTool]
|
||||
))
|
||||
})
|
||||
|
||||
export const removeCliToolAtom = atom(null, (_get, set, toolId: string) => {
|
||||
set(agentComposerToolsAtom, tools => tools.filter(tool => tool.id !== toolId))
|
||||
})
|
||||
|
||||
export const agentComposerToolSettingsAtom = atom(
|
||||
get => get(agentComposerDraftAtom).toolSettings,
|
||||
(get, set, toolSettingsUpdate: DraftFieldUpdate<Record<string, Record<string, unknown>>>) => {
|
||||
@@ -49,63 +153,79 @@ const omitToolSettings = (
|
||||
return nextToolSettings
|
||||
}
|
||||
|
||||
export function useRemoveProviderTool() {
|
||||
const setDraft = useSetAtom(agentComposerDraftAtom)
|
||||
export const removeProviderToolAtom = atom(null, (get, set, toolId: string) => {
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
const toolToRemove = draft.tools.find(tool => tool.kind === 'provider' && tool.id === toolId)
|
||||
const actionIds = toolToRemove?.kind === 'provider'
|
||||
? toolToRemove.actions.map(action => action.id)
|
||||
: []
|
||||
|
||||
return useCallback((toolId: string) => {
|
||||
setDraft((draft) => {
|
||||
const toolToRemove = draft.tools.find(tool => tool.kind === 'provider' && tool.id === toolId)
|
||||
const actionIds = toolToRemove?.kind === 'provider'
|
||||
? toolToRemove.actions.map(action => action.id)
|
||||
: []
|
||||
set(agentComposerDraftAtom, {
|
||||
...draft,
|
||||
tools: draft.tools.filter(tool => tool.id !== toolId),
|
||||
toolSettings: omitToolSettings(draft.toolSettings, actionIds),
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
...draft,
|
||||
tools: draft.tools.filter(tool => tool.id !== toolId),
|
||||
toolSettings: omitToolSettings(draft.toolSettings, actionIds),
|
||||
}
|
||||
})
|
||||
}, [setDraft])
|
||||
}
|
||||
export const removeProviderToolActionAtom = atom(null, (get, set, {
|
||||
toolId,
|
||||
actionId,
|
||||
}: {
|
||||
toolId: string
|
||||
actionId: string
|
||||
}) => {
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
export function useRemoveProviderToolAction() {
|
||||
const setDraft = useSetAtom(agentComposerDraftAtom)
|
||||
|
||||
return useCallback((toolId: string, actionId: string) => {
|
||||
setDraft(draft => ({
|
||||
...draft,
|
||||
tools: draft.tools.flatMap((tool) => {
|
||||
if (tool.kind !== 'provider' || tool.id !== toolId)
|
||||
return [tool]
|
||||
|
||||
const nextActions = tool.actions.filter(action => action.id !== actionId)
|
||||
return nextActions.length > 0
|
||||
? [{ ...tool, actions: nextActions }]
|
||||
: []
|
||||
}),
|
||||
toolSettings: omitToolSettings(draft.toolSettings, [actionId]),
|
||||
}))
|
||||
}, [setDraft])
|
||||
}
|
||||
|
||||
export function useSetProviderToolCredential() {
|
||||
const setTools = useSetAtom(agentComposerToolsAtom)
|
||||
|
||||
return useCallback((toolId: string, credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => {
|
||||
setTools(tools => tools.map((tool) => {
|
||||
set(agentComposerDraftAtom, {
|
||||
...draft,
|
||||
tools: draft.tools.flatMap((tool) => {
|
||||
if (tool.kind !== 'provider' || tool.id !== toolId)
|
||||
return tool
|
||||
return [tool]
|
||||
|
||||
const nextCredentialType = credentialType === 'oauth2' || tool.credentialType === 'oauth2'
|
||||
? 'oauth2'
|
||||
: 'api-key'
|
||||
const nextActions = tool.actions.filter(action => action.id !== actionId)
|
||||
return nextActions.length > 0
|
||||
? [{ ...tool, actions: nextActions }]
|
||||
: []
|
||||
}),
|
||||
toolSettings: omitToolSettings(draft.toolSettings, [actionId]),
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
...tool,
|
||||
credentialId,
|
||||
credentialType: nextCredentialType,
|
||||
credentialVariant: 'authorized',
|
||||
}
|
||||
}))
|
||||
}, [setTools])
|
||||
}
|
||||
export const setProviderToolCredentialAtom = atom(null, (_get, set, {
|
||||
toolId,
|
||||
credentialId,
|
||||
credentialType,
|
||||
}: {
|
||||
toolId: string
|
||||
credentialId?: string
|
||||
credentialType?: AgentProviderTool['credentialType']
|
||||
}) => {
|
||||
set(agentComposerToolsAtom, tools => tools.map((tool) => {
|
||||
if (tool.kind !== 'provider' || tool.id !== toolId)
|
||||
return tool
|
||||
|
||||
const nextCredentialType = credentialType === 'oauth2' || tool.credentialType === 'oauth2'
|
||||
? 'oauth2'
|
||||
: 'api-key'
|
||||
|
||||
return {
|
||||
...tool,
|
||||
credentialId,
|
||||
credentialType: nextCredentialType,
|
||||
credentialVariant: 'authorized',
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
export const saveProviderToolActionSettingsAtom = atom(null, (_get, set, {
|
||||
actionId,
|
||||
value,
|
||||
}: {
|
||||
actionId: string
|
||||
value: Record<string, unknown>
|
||||
}) => {
|
||||
set(agentComposerToolSettingsAtom, toolSettings => ({
|
||||
...toolSettings,
|
||||
[actionId]: value,
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { AgentDetailLayout } from '../layout'
|
||||
|
||||
const mockReplace = vi.hoisted(() => vi.fn())
|
||||
const mockAgentQuery = vi.hoisted(() => ({
|
||||
data: {
|
||||
name: 'Agent',
|
||||
} as { name: string } | undefined,
|
||||
error: null as unknown,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...actual,
|
||||
useQuery: vi.fn(() => ({
|
||||
data: {
|
||||
name: 'Agent',
|
||||
},
|
||||
})),
|
||||
useQuery: vi.fn(() => mockAgentQuery),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
push: vi.fn(),
|
||||
replace: mockReplace,
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({
|
||||
default: vi.fn(),
|
||||
}))
|
||||
@@ -32,6 +47,10 @@ vi.mock('@/service/client', () => ({
|
||||
describe('AgentDetailLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAgentQuery.data = {
|
||||
name: 'Agent',
|
||||
}
|
||||
mockAgentQuery.error = null
|
||||
})
|
||||
|
||||
it('should render detail content without owning navigation landmarks', () => {
|
||||
@@ -45,4 +64,20 @@ describe('AgentDetailLayout', () => {
|
||||
expect(screen.queryByRole('main')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should redirect to roster when agent detail returns 404', async () => {
|
||||
mockAgentQuery.data = undefined
|
||||
mockAgentQuery.error = new Response(null, { status: 404 })
|
||||
|
||||
render(
|
||||
<AgentDetailLayout agentId="missing-agent">
|
||||
<div>Agent detail content</div>
|
||||
</AgentDetailLayout>,
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockReplace).toHaveBeenCalledWith('/roster')
|
||||
})
|
||||
expect(screen.queryByText('Agent detail content')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+22
-3
@@ -5,9 +5,10 @@ import { WorkflowReferencesTable } from '../workflow-references-table'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queryFn: vi.fn(),
|
||||
queryOptions: vi.fn((input: unknown) => ({
|
||||
queryOptions: vi.fn(({ enabled = true, input }: { enabled?: boolean, input: unknown }) => ({
|
||||
queryKey: ['agent-referencing-workflows', input],
|
||||
queryFn: () => mocks.queryFn(input),
|
||||
enabled,
|
||||
})),
|
||||
}))
|
||||
|
||||
@@ -31,7 +32,7 @@ vi.mock('@/hooks/use-timestamp', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const renderTable = () => {
|
||||
const renderTable = ({ enabled }: { enabled?: boolean } = {}) => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -42,7 +43,7 @@ const renderTable = () => {
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowReferencesTable agentId="agent-1" />
|
||||
<WorkflowReferencesTable agentId="agent-1" enabled={enabled} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
@@ -67,9 +68,27 @@ describe('WorkflowReferencesTable', () => {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
enabled: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should not fetch workflow references when disabled', async () => {
|
||||
renderTable({ enabled: false })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.queryOptions).toHaveBeenCalledWith({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
},
|
||||
enabled: false,
|
||||
})
|
||||
})
|
||||
expect(mocks.queryFn).not.toHaveBeenCalled()
|
||||
expect(screen.queryByText('agentV2.agentDetail.access.workflow.loading')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Rendering', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { consoleQuery } from '@/service/client'
|
||||
|
||||
type WorkflowReferencesTableProps = {
|
||||
agentId: string
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
const workflowTableColSpan = 5
|
||||
@@ -20,6 +21,7 @@ const getWorkflowReferenceHref = (reference: AgentReferencingWorkflowResponse) =
|
||||
|
||||
export function WorkflowReferencesTable({
|
||||
agentId,
|
||||
enabled = true,
|
||||
}: WorkflowReferencesTableProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
@@ -29,6 +31,7 @@ export function WorkflowReferencesTable({
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
enabled,
|
||||
}))
|
||||
const workflowReferences = workflowReferencesQuery.data?.data ?? []
|
||||
|
||||
@@ -62,12 +65,12 @@ export function WorkflowReferencesTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="system-sm-regular text-text-secondary">
|
||||
{workflowReferencesQuery.isPending && (
|
||||
{enabled && workflowReferencesQuery.isPending && (
|
||||
<WorkflowAccessStateRow>
|
||||
{t('agentDetail.access.workflow.loading')}
|
||||
</WorkflowAccessStateRow>
|
||||
)}
|
||||
{workflowReferencesQuery.isError && (
|
||||
{enabled && workflowReferencesQuery.isError && (
|
||||
<WorkflowAccessStateRow>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span>{t('agentDetail.access.workflow.loadFailed')}</span>
|
||||
@@ -83,12 +86,12 @@ export function WorkflowReferencesTable({
|
||||
</div>
|
||||
</WorkflowAccessStateRow>
|
||||
)}
|
||||
{workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && (
|
||||
{enabled && workflowReferencesQuery.isSuccess && workflowReferences.length === 0 && (
|
||||
<WorkflowAccessStateRow>
|
||||
{t('agentDetail.access.workflow.empty')}
|
||||
</WorkflowAccessStateRow>
|
||||
)}
|
||||
{workflowReferencesQuery.isSuccess && workflowReferences.map(reference => (
|
||||
{enabled && workflowReferencesQuery.isSuccess && workflowReferences.map(reference => (
|
||||
<WorkflowAccessRow
|
||||
key={`${reference.app_id}:${reference.workflow_id}`}
|
||||
reference={reference}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function AgentAccessPage({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<WorkflowReferencesTable agentId={agentId} />
|
||||
<WorkflowReferencesTable agentId={agentId} enabled={agentQuery.isSuccess} />
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -54,6 +54,19 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
}))
|
||||
|
||||
const modelHooksState = vi.hoisted(() => ({
|
||||
defaultTextGenerationModel: {
|
||||
provider: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
},
|
||||
model: 'gpt-4o-mini',
|
||||
} as { provider: { provider: string }, model: string } | undefined,
|
||||
}))
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
@@ -101,6 +114,10 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
@@ -194,7 +211,7 @@ vi.mock('@/service/client', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
|
||||
useDefaultModel: () => ({ data: undefined }),
|
||||
useDefaultModel: () => ({ data: modelHooksState.defaultTextGenerationModel }),
|
||||
useTextGenerationCurrentProviderAndModelAndModelList: () => ({
|
||||
textGenerationModelList: [],
|
||||
}),
|
||||
@@ -271,7 +288,7 @@ vi.mock('../components/preview/build-chat', async () => {
|
||||
void props.onSaveDraftBeforeRun?.().then(() => {
|
||||
setMessageSent(true)
|
||||
props.onConversationIdChange?.('build-conversation-new')
|
||||
})
|
||||
}).catch(() => undefined)
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
@@ -359,6 +376,12 @@ vi.mock('../components/preview/versions-panel', () => ({
|
||||
describe('AgentConfigurePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
modelHooksState.defaultTextGenerationModel = {
|
||||
provider: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
},
|
||||
model: 'gpt-4o-mini',
|
||||
}
|
||||
mocks.refreshDebugConversation.mockResolvedValue({
|
||||
debug_conversation_has_messages: false,
|
||||
debug_conversation_id: 'debug-conversation-new',
|
||||
@@ -1036,6 +1059,50 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should block build chat checkout when no model is configured', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
modelHooksState.defaultTextGenerationModel = undefined
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: undefined as unknown,
|
||||
dataUpdatedAt: 0,
|
||||
error: new Response(null, { status: 404 }),
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
|
||||
})
|
||||
expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
})
|
||||
|
||||
it('should keep the build draft bar disabled while a build conversation is responding', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
+44
-10
@@ -91,6 +91,11 @@ function setDocumentVisibilityState(visibilityState: DocumentVisibilityState) {
|
||||
})
|
||||
}
|
||||
|
||||
const configuredModel = {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
}
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: toastMock,
|
||||
}))
|
||||
@@ -607,7 +612,9 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should publish only when publishDraft is called explicitly', async () => {
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
@@ -654,12 +661,28 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(toastMock.success).toHaveBeenCalledWith('common.api.actionSuccess')
|
||||
})
|
||||
|
||||
it('should toast and skip publish when no model is configured', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Published prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.publishDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(publishAgentMutationFn).not.toHaveBeenCalled()
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.modelProvider.selectModel')
|
||||
})
|
||||
|
||||
it('should keep default model fallback from creating unpublished changes after publish', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@@ -681,6 +704,7 @@ describe('useAgentConfigureSync', () => {
|
||||
|
||||
it('should keep base config fallback fields from creating unpublished changes after publish', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
baseConfig: {
|
||||
app_features: {
|
||||
file_upload: {
|
||||
@@ -708,7 +732,9 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should publish the current draft snapshot instead of a stale caller payload', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@@ -736,7 +762,9 @@ describe('useAgentConfigureSync', () => {
|
||||
|
||||
it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
@@ -760,7 +788,9 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should toast and skip publish when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@@ -785,7 +815,9 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
it('should toast metadata filtering model error when publishing with automatic metadata filtering and no model', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
const { result, store } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
@@ -813,7 +845,9 @@ describe('useAgentConfigureSync', () => {
|
||||
it('should expose publishing status from the publish mutation while publish is pending', async () => {
|
||||
const publishDeferred = createDeferredPromise<PublishAgentResponse>()
|
||||
publishAgentMutationFn.mockReturnValueOnce(publishDeferred.promise)
|
||||
const { result } = renderUseAgentConfigureSync()
|
||||
const { result } = renderUseAgentConfigureSync({
|
||||
currentModel: configuredModel,
|
||||
})
|
||||
let publishPromise!: Promise<void>
|
||||
act(() => {
|
||||
publishPromise = result.current.publishDraft()
|
||||
|
||||
+135
-5
@@ -16,6 +16,42 @@ const mockPromptEditor = vi.hoisted(() => vi.fn())
|
||||
const mockCopy = vi.hoisted(() => vi.fn())
|
||||
const mockReset = vi.hoisted(() => vi.fn())
|
||||
const mockUseClipboard = vi.hoisted(() => vi.fn())
|
||||
const mockConfigFiles = vi.hoisted(() => ({
|
||||
current: [] as Array<{
|
||||
id: string
|
||||
name: string
|
||||
driveKey?: string
|
||||
children?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
driveKey?: string
|
||||
}>
|
||||
}>,
|
||||
}))
|
||||
const mockLexical = vi.hoisted(() => ({
|
||||
selection: null as null | {
|
||||
__range: true
|
||||
isCollapsed: () => boolean
|
||||
anchor: {
|
||||
getNode: () => {
|
||||
__text: true
|
||||
getKey: () => string
|
||||
getTextContent: () => string
|
||||
getTextContentSize: () => number
|
||||
select: (anchorOffset: number, focusOffset: number) => void
|
||||
}
|
||||
offset: number
|
||||
}
|
||||
},
|
||||
rootChildren: [] as Array<{
|
||||
__text: true
|
||||
getKey: () => string
|
||||
getTextContent: () => string
|
||||
getTextContentSize: () => number
|
||||
select: (anchorOffset: number, focusOffset: number) => void
|
||||
}>,
|
||||
rootSelectEnd: vi.fn(),
|
||||
}))
|
||||
const mockBuiltInTools = vi.hoisted(() => [
|
||||
{
|
||||
id: 'duckduckgo',
|
||||
@@ -62,11 +98,37 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
|
||||
return (
|
||||
<div>
|
||||
<div role="textbox" aria-label={String(props.placeholder)} />
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@lexical/react/LexicalComposerContext', () => ({
|
||||
useLexicalComposerContext: () => [{
|
||||
focus: (callback: () => void) => callback(),
|
||||
getEditorState: () => ({
|
||||
read: (callback: () => void) => callback(),
|
||||
}),
|
||||
registerCommand: () => vi.fn(),
|
||||
registerUpdateListener: () => vi.fn(),
|
||||
update: (callback: () => void) => callback(),
|
||||
}],
|
||||
}))
|
||||
|
||||
vi.mock('lexical', () => ({
|
||||
$getRoot: () => ({
|
||||
getChildren: () => mockLexical.rootChildren,
|
||||
selectEnd: mockLexical.rootSelectEnd,
|
||||
}),
|
||||
$getSelection: () => mockLexical.selection,
|
||||
$isElementNode: (node: { __element?: boolean } | null | undefined) => !!node?.__element,
|
||||
$isRangeSelection: (selection: { __range?: boolean } | null | undefined) => !!selection?.__range,
|
||||
$isTextNode: (node: { __text?: boolean } | null | undefined) => !!node?.__text,
|
||||
COMMAND_PRIORITY_LOW: 1,
|
||||
SELECTION_CHANGE_COMMAND: Symbol('selection-change-command'),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/infotip', () => ({
|
||||
Infotip: ({ children }: { children: ReactNode }) => <span>{children}</span>,
|
||||
}))
|
||||
@@ -103,10 +165,11 @@ vi.mock('../orchestrate/config-context', () => ({
|
||||
{
|
||||
id: 'playwright',
|
||||
name: 'Playwright',
|
||||
skillMdKey: 'skills/playwright/SKILL.md',
|
||||
},
|
||||
],
|
||||
}),
|
||||
useAgentConfigFiles: () => ({ files: [] }),
|
||||
useAgentConfigFiles: () => ({ files: mockConfigFiles.current }),
|
||||
}))
|
||||
|
||||
const duckDuckGoSearchAction = {
|
||||
@@ -167,6 +230,10 @@ const renderAgentPromptEditor = (
|
||||
describe('AgentPromptEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockConfigFiles.current = []
|
||||
mockLexical.selection = null
|
||||
mockLexical.rootChildren = []
|
||||
mockLexical.rootSelectEnd.mockClear()
|
||||
mockUseClipboard.mockReturnValue({
|
||||
copied: false,
|
||||
copy: mockCopy,
|
||||
@@ -290,6 +357,40 @@ describe('AgentPromptEditor', () => {
|
||||
|
||||
expect(container.querySelector('.i-ri-terminal-box-line')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should warn only for prompt references missing from the current configuration', () => {
|
||||
mockConfigFiles.current = [{
|
||||
id: 'folder',
|
||||
name: 'Folder',
|
||||
children: [{
|
||||
id: 'file-1',
|
||||
name: 'Spec.md',
|
||||
driveKey: 'drive/spec.md',
|
||||
}],
|
||||
}]
|
||||
renderAgentPromptEditor('Review these tenders', {
|
||||
knowledgeRetrievals: [{ id: 'retrieval-1', name: 'Release Notes' }],
|
||||
tools: [
|
||||
duckDuckGoProviderTool,
|
||||
{ id: 'cli-1', kind: 'cli', name: 'Lark CLI' },
|
||||
],
|
||||
})
|
||||
|
||||
const promptEditorProps = mockPromptEditor.mock.calls.at(-1)?.[0] as PromptEditorProps
|
||||
const getWarning = promptEditorProps.rosterReferenceBlock?.getWarning
|
||||
expect(getWarning).toBeDefined()
|
||||
|
||||
expect(getWarning?.({ kind: 'skill', id: 'skills%2Fplaywright%2FSKILL.md', label: 'Playwright' })).toBeUndefined()
|
||||
expect(getWarning?.({ kind: 'file', id: 'drive%2Fspec.md', label: 'Spec.md' })).toBeUndefined()
|
||||
expect(getWarning?.({ kind: 'knowledge', id: 'retrieval-1', label: 'Release Notes' })).toBeUndefined()
|
||||
expect(getWarning?.({ kind: 'tool', id: 'duckduckgo/ddg_search', label: 'DuckDuckGo Search' })).toBeUndefined()
|
||||
expect(getWarning?.({ kind: 'tool-all', id: 'duckduckgo/*', label: 'DuckDuckGo' })).toBeUndefined()
|
||||
|
||||
expect(getWarning?.({ kind: 'skill', id: 'missing-skill', label: 'Missing Skill' })).toContain('agentDetail.configure.prompt.referenceMissing')
|
||||
expect(getWarning?.({ kind: 'file', id: 'missing-file', label: 'Missing File' })).toContain('agentDetail.configure.prompt.referenceMissing')
|
||||
expect(getWarning?.({ kind: 'knowledge', id: 'missing-retrieval', label: 'Missing Retrieval' })).toContain('agentDetail.configure.prompt.referenceMissing')
|
||||
expect(getWarning?.({ kind: 'tool', id: 'missing/action', label: 'Missing Tool' })).toContain('agentDetail.configure.prompt.referenceMissing')
|
||||
})
|
||||
})
|
||||
|
||||
// Prompt slash commands should use the Agent Roster category menu and replace it with submenus.
|
||||
@@ -320,6 +421,35 @@ describe('AgentPromptEditor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should replace the slash at the current lexical selection instead of appending', async () => {
|
||||
const textNode = {
|
||||
__text: true as const,
|
||||
getKey: () => 'text-node',
|
||||
getTextContent: () => 'Review / now',
|
||||
getTextContentSize: () => 'Review / now'.length,
|
||||
select: vi.fn(),
|
||||
}
|
||||
mockLexical.rootChildren = [textNode]
|
||||
mockLexical.selection = {
|
||||
__range: true,
|
||||
isCollapsed: () => true,
|
||||
anchor: {
|
||||
getNode: () => textNode,
|
||||
offset: 'Review /'.length,
|
||||
},
|
||||
}
|
||||
const { store } = renderAgentPromptEditor('Review / now')
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('textbox'), { key: '/' })
|
||||
fireEvent.click(screen.getByRole('button', { name: /agentDetail\.configure\.skills\.label/i }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Playwright/i }))
|
||||
|
||||
expect(store.get(agentComposerPromptAtom)).toBe('Review [§skill:playwright:Playwright§] now')
|
||||
await waitFor(() => {
|
||||
expect(mockLexical.rootSelectEnd).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should insert slash from the focused footer insert action', () => {
|
||||
const { store } = renderAgentPromptEditor('Review these tenders')
|
||||
|
||||
@@ -350,7 +480,7 @@ describe('AgentPromptEditor', () => {
|
||||
skills={[]}
|
||||
files={[]}
|
||||
tools={[]}
|
||||
onToolsChange={vi.fn()}
|
||||
onAddProviderTools={vi.fn()}
|
||||
onAddSkill={options => options?.onAdded?.({ id: 'skill-1', name: 'Skill One' })}
|
||||
retrievals={[]}
|
||||
onBack={vi.fn()}
|
||||
@@ -368,7 +498,7 @@ describe('AgentPromptEditor', () => {
|
||||
skills={[]}
|
||||
files={[]}
|
||||
tools={[]}
|
||||
onToolsChange={vi.fn()}
|
||||
onAddProviderTools={vi.fn()}
|
||||
onAddFile={options => options?.onAdded?.({ id: 'file-1', name: 'Guide.md', icon: 'markdown', configName: 'Guide.md' })}
|
||||
retrievals={[]}
|
||||
onBack={vi.fn()}
|
||||
@@ -386,7 +516,7 @@ describe('AgentPromptEditor', () => {
|
||||
skills={[]}
|
||||
files={[]}
|
||||
tools={[]}
|
||||
onToolsChange={vi.fn()}
|
||||
onAddProviderTools={vi.fn()}
|
||||
onAddKnowledge={options => options?.onAdded?.({ id: 'retrieval-1', name: 'Retrieval One', queryMode: 'agent' })}
|
||||
retrievals={[]}
|
||||
onBack={vi.fn()}
|
||||
@@ -404,7 +534,7 @@ describe('AgentPromptEditor', () => {
|
||||
skills={[]}
|
||||
files={[]}
|
||||
tools={[]}
|
||||
onToolsChange={vi.fn()}
|
||||
onAddProviderTools={vi.fn()}
|
||||
onAddCliTool={options => options?.onAdded?.({ id: 'cli-1', kind: 'cli', name: 'Lark CLI' })}
|
||||
retrievals={[]}
|
||||
onBack={vi.fn()}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { useAgentConfigureData } from '../hooks'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
@@ -214,6 +215,7 @@ function AgentConfigurePageComposerContent({
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const [buildDraftActionsDisabled, setBuildDraftActionsDisabled] = useState(false)
|
||||
const [clearPreviewChat, setClearPreviewChat] = useState(false)
|
||||
const [completedBuildConversationId, setCompletedBuildConversationId] = useState<string | null>(null)
|
||||
@@ -333,6 +335,7 @@ function AgentConfigurePageComposerContent({
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
buildDraftChangedKeys={buildDraft.changedKeys}
|
||||
showPublishBar={!buildDraft.isActive}
|
||||
workflowReferencesEnabled={agentQuery.isSuccess}
|
||||
bottomAction={showBuildDraftBar
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
@@ -407,6 +410,11 @@ function AgentConfigurePageComposerContent({
|
||||
}}
|
||||
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
|
||||
? async () => {
|
||||
if (!currentModel?.provider || !currentModel.model) {
|
||||
toast.error(tCommon('modelProvider.selectModel'))
|
||||
throw new Error('Agent model is required.')
|
||||
}
|
||||
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
|
||||
+27
-1
@@ -74,8 +74,9 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
referencingWorkflows: {
|
||||
get: {
|
||||
queryOptions: ({ input }: { input: { params: { agent_id: string } } }) => ({
|
||||
queryOptions: ({ enabled = true, input }: { enabled?: boolean, input: { params: { agent_id: string } } }) => ({
|
||||
queryKey: ['agent-referencing-workflows', input],
|
||||
enabled,
|
||||
queryFn: async () => ({
|
||||
data: (workflowReferences.fetchCount++, workflowReferences.data),
|
||||
}),
|
||||
@@ -162,6 +163,7 @@ function renderPublishBar({
|
||||
selectedVersionSnapshot,
|
||||
setupStore,
|
||||
usedByAppReferences = [],
|
||||
workflowReferencesEnabled,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
@@ -173,6 +175,7 @@ function renderPublishBar({
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
setupStore?: (store: ReturnType<typeof createStore>) => void
|
||||
usedByAppReferences?: AgentReferencingWorkflowResponse[]
|
||||
workflowReferencesEnabled?: boolean
|
||||
} = {}) {
|
||||
workflowReferences.data = usedByAppReferences
|
||||
const queryClient = new QueryClient({
|
||||
@@ -200,6 +203,7 @@ function renderPublishBar({
|
||||
agentName="Iris"
|
||||
isPublishing={nextProps?.isPublishing ?? isPublishing}
|
||||
selectedVersionSnapshot={selectedVersionSnapshot}
|
||||
workflowReferencesEnabled={workflowReferencesEnabled}
|
||||
onPublish={onPublish}
|
||||
onExitVersions={onExitVersions}
|
||||
onOpenVersions={vi.fn()}
|
||||
@@ -407,6 +411,28 @@ describe('AgentConfigurePublishBar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should publish without loading workflow references when references are disabled', async () => {
|
||||
const { onPublish } = renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
prompt: 'Updated system prompt',
|
||||
usedByAppReferences: publishedReferences,
|
||||
workflowReferencesEnabled: false,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(workflowReferences.fetchCount).toBe(0)
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPublish).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(workflowReferences.fetchCount).toBe(0)
|
||||
expect(screen.queryByRole('region', {
|
||||
name: /agentV2\.agentDetail\.configure\.publishImpact\.title/,
|
||||
})).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should mark non-prompt draft changes as unpublished', () => {
|
||||
renderPublishBar({
|
||||
activeConfigSnapshot,
|
||||
|
||||
+27
-27
@@ -7,10 +7,18 @@ import { Input } from '@langgenius/dify-ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentComposerEnvVariablesAtom } from '@/features/agent-v2/agent-composer/store-modules/env'
|
||||
import {
|
||||
addEnvVariableAtom,
|
||||
agentComposerEnvVariablesAtom,
|
||||
importEnvVariablesAtom,
|
||||
removeEnvVariableAtom,
|
||||
setEnvVariableKeyAtom,
|
||||
setEnvVariableScopeAtom,
|
||||
setEnvVariableValueAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/env'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
@@ -410,7 +418,13 @@ export function EnvVariablesTable({
|
||||
export function AgentEnvEditor() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const [envVariables, setEnvVariables] = useAtom(agentComposerEnvVariablesAtom)
|
||||
const envVariables = useAtomValue(agentComposerEnvVariablesAtom)
|
||||
const addEnvVariable = useSetAtom(addEnvVariableAtom)
|
||||
const importEnvVariables = useSetAtom(importEnvVariablesAtom)
|
||||
const removeEnvVariable = useSetAtom(removeEnvVariableAtom)
|
||||
const setEnvVariableKey = useSetAtom(setEnvVariableKeyAtom)
|
||||
const setEnvVariableScope = useSetAtom(setEnvVariableScopeAtom)
|
||||
const setEnvVariableValue = useSetAtom(setEnvVariableValueAtom)
|
||||
const starterVariableRef = useRef<EnvVariable | undefined>(undefined)
|
||||
if (!starterVariableRef.current)
|
||||
starterVariableRef.current = createEnvVariable()
|
||||
@@ -422,20 +436,6 @@ export function AgentEnvEditor() {
|
||||
const envEditorTableId = 'agent-configure-env-editor-table'
|
||||
const visibleEnvVariables = envVariables.length > 0 ? envVariables : [starterVariable]
|
||||
|
||||
const updateVariable = (id: string, updater: (variable: EnvVariable) => EnvVariable) => {
|
||||
const existingVariable = envVariables.find(variable => variable.id === id)
|
||||
|
||||
if (existingVariable) {
|
||||
setEnvVariables(envVariables.map(variable => (
|
||||
variable.id === id ? updater(variable) : variable
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
if (id === starterVariable.id)
|
||||
setEnvVariables([updater(starterVariable)])
|
||||
}
|
||||
|
||||
const addVariable = ({
|
||||
focusField = 'key',
|
||||
scope,
|
||||
@@ -448,13 +448,13 @@ export function AgentEnvEditor() {
|
||||
...(scope ? { scope } : {}),
|
||||
}
|
||||
|
||||
setEnvVariables([
|
||||
...(envVariables.length > 0 ? envVariables : [starterVariable]),
|
||||
addEnvVariable({
|
||||
starterVariable,
|
||||
variable,
|
||||
])
|
||||
})
|
||||
setFocusedVariable({ id: variable.id, field: focusField })
|
||||
}
|
||||
const importEnvVariables = async (file: File) => {
|
||||
const handleImportEnvVariables = async (file: File) => {
|
||||
const {
|
||||
invalidLineCount,
|
||||
variables,
|
||||
@@ -470,19 +470,19 @@ export function AgentEnvEditor() {
|
||||
if (importedVariables.length === 0)
|
||||
return
|
||||
|
||||
setEnvVariables([...envVariables, ...importedVariables])
|
||||
importEnvVariables(importedVariables)
|
||||
}
|
||||
const updateVariableKey = (id: string, key: string) => {
|
||||
updateVariable(id, variable => ({ ...variable, key }))
|
||||
setEnvVariableKey({ id, key, starterVariable })
|
||||
}
|
||||
const updateVariableScope = (id: string, scope: EnvScope) => {
|
||||
updateVariable(id, variable => ({ ...variable, scope }))
|
||||
setEnvVariableScope({ id, scope, starterVariable })
|
||||
}
|
||||
const updateVariableValue = (id: string, value: string) => {
|
||||
updateVariable(id, variable => ({ ...variable, value }))
|
||||
setEnvVariableValue({ id, starterVariable, value })
|
||||
}
|
||||
const deleteVariable = (id: string) => {
|
||||
setEnvVariables(envVariables.filter(variable => variable.id !== id))
|
||||
removeEnvVariable(id)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -508,7 +508,7 @@ export function AgentEnvEditor() {
|
||||
event.target.value = ''
|
||||
|
||||
if (file)
|
||||
void importEnvVariables(file)
|
||||
void handleImportEnvVariables(file)
|
||||
}}
|
||||
/>
|
||||
<Tooltip>
|
||||
|
||||
+111
-3
@@ -3,7 +3,7 @@ import type { AgentConfigApiContext } from '../../config-context'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -39,6 +39,8 @@ const mocks = vi.hoisted(() => ({
|
||||
deleteFileMutationFn: vi.fn(async (_input: unknown) => ({ removed_names: ['brief.md'], result: 'success' })),
|
||||
previewQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})),
|
||||
downloadQueryOptions: vi.fn((_options: ConfigFileQueryOptionsInput) => ({})),
|
||||
downloadBlob: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@@ -48,6 +50,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mocks.downloadBlob,
|
||||
downloadUrl: mocks.downloadUrl,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
@@ -368,6 +375,57 @@ describe('AgentFiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should download configured files from the row action by config name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles()
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.files\.download.*diagram\.png/,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'diagram.png',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(mocks.downloadUrl).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/diagram.png',
|
||||
fileName: 'diagram.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('should download the selected file from the preview header action', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles()
|
||||
|
||||
await user.click(screen.getByText('diagram.png').closest('button')!)
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', {
|
||||
name: /common\.operation\.download.*diagram\.png/,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'diagram.png',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(mocks.downloadUrl).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/diagram.png',
|
||||
fileName: 'diagram.png',
|
||||
})
|
||||
})
|
||||
|
||||
it('should show config note as a virtual build note file and preview its content locally', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles({
|
||||
@@ -399,15 +457,65 @@ describe('AgentFiles', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('should download the virtual build note file as markdown content', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles({
|
||||
initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }),
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.files\.download.*build_note\.md/,
|
||||
}))
|
||||
|
||||
expect(mocks.downloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'build_note.md',
|
||||
})
|
||||
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
|
||||
await expect(blob.text()).resolves.toBe('Build context from the latest build chat.')
|
||||
expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
name: 'build_note.md',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should download the virtual build note from the preview header action', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles({
|
||||
initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }),
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('build_note.md').closest('button')!)
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
|
||||
await user.click(within(dialog).getByRole('button', {
|
||||
name: /common\.operation\.download.*build_note\.md/,
|
||||
}))
|
||||
|
||||
expect(mocks.downloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'build_note.md',
|
||||
})
|
||||
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
|
||||
await expect(blob.text()).resolves.toBe('Build context from the latest build chat.')
|
||||
})
|
||||
|
||||
it('should show generated build note metadata with an explanatory infotip', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentFiles({
|
||||
initialDraft: createInitialDraft({ configNote: 'Build context from the latest build chat.' }),
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.files.buildNote.generated')).toBeInTheDocument()
|
||||
const generatedBadge = screen.getByText('agentV2.agentDetail.configure.files.buildNote.generated')
|
||||
const buildNoteRow = generatedBadge.closest('li')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.files.buildNote.tooltip' }))
|
||||
expect(generatedBadge).toBeInTheDocument()
|
||||
expect(buildNoteRow).not.toBeNull()
|
||||
|
||||
await user.click(within(buildNoteRow!).getByRole('button', { name: 'agentV2.agentDetail.configure.files.buildNote.tooltip' }))
|
||||
|
||||
expect(await screen.findByText('agentDetail.configure.files.buildNote.richTooltip')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
+107
-59
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { MouseEvent, ReactNode } from 'react'
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -15,15 +14,21 @@ import {
|
||||
FileTreeIcon,
|
||||
FileTreeLabel,
|
||||
} from '@langgenius/dify-ui/file-tree'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerFilesAtom } from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import {
|
||||
agentComposerFilesAtom,
|
||||
clearAgentConfigNoteAtom,
|
||||
removeAgentFileAtom,
|
||||
upsertAgentFileAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/files'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { downloadBlob, downloadUrl } from '@/utils/download'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { DocsLink } from '../common/docs-link'
|
||||
@@ -64,16 +69,6 @@ const findAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNod
|
||||
}
|
||||
}
|
||||
|
||||
const removeAgentFileNode = (files: AgentFileNode[], fileId: string): AgentFileNode[] => files.flatMap((file) => {
|
||||
if (file.id === fileId)
|
||||
return []
|
||||
|
||||
if (file.children)
|
||||
return [{ ...file, children: removeAgentFileNode(file.children, fileId) }]
|
||||
|
||||
return [file]
|
||||
})
|
||||
|
||||
function AgentFileItem({
|
||||
children,
|
||||
depth,
|
||||
@@ -93,6 +88,7 @@ function AgentFileItem({
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const queryClient = useQueryClient()
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>()
|
||||
const selectedFile = selectedFileId ? findAgentFileNode(files, selectedFileId) : undefined
|
||||
@@ -169,25 +165,71 @@ function AgentFileItem({
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(file.id)
|
||||
}, [file.id, onRemove])
|
||||
const downloadFile = useCallback(async (targetFile: AgentFileNode) => {
|
||||
if (targetFile.virtualContent !== undefined) {
|
||||
downloadBlob({
|
||||
data: new Blob([targetFile.virtualContent], { type: 'text/markdown;charset=utf-8' }),
|
||||
fileName: targetFile.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const fileName = getAgentFilePreviewKey(targetFile)
|
||||
if (apiContext.workflow) {
|
||||
const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.files.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
name: fileName,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
downloadUrl({ url: result.url, fileName: targetFile.name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.files.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: fileName,
|
||||
},
|
||||
query: {
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
downloadUrl({ url: result.url, fileName: targetFile.name })
|
||||
}, [apiContext, queryClient])
|
||||
const handleDownload = useCallback(async (event: MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation()
|
||||
await downloadFile(file)
|
||||
}, [downloadFile, file])
|
||||
const handlePreviewOpenChange = useCallback((open: boolean) => {
|
||||
if (open)
|
||||
setSelectedFileId(file.id)
|
||||
setIsPreviewOpen(open)
|
||||
}, [file.id])
|
||||
const canRemoveFile = !readOnly && (!file.virtualContent || isBuildNoteFile)
|
||||
|
||||
return (
|
||||
<li className="group/file-row relative min-w-0">
|
||||
<li
|
||||
data-selected={selected || undefined}
|
||||
className="group/file-row relative flex h-6 min-w-0 items-center rounded-md focus-within:bg-state-base-hover hover:bg-state-base-hover data-[selected]:bg-state-base-active"
|
||||
>
|
||||
<Dialog open={isPreviewOpen} onOpenChange={handlePreviewOpenChange}>
|
||||
<DialogTrigger
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
data-selected={selected || undefined}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
className={cn(
|
||||
'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none group-hover/file-row:bg-state-base-hover hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid data-[selected]:bg-state-base-active',
|
||||
'pr-7',
|
||||
)}
|
||||
className="group/file-tree-row relative flex h-full min-w-0 flex-1 cursor-pointer items-center rounded-md pl-2 text-left outline-hidden select-none focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
|
||||
/>
|
||||
)}
|
||||
>
|
||||
@@ -214,62 +256,73 @@ function AgentFileItem({
|
||||
isImage: isImagePreviewFile,
|
||||
isLoading: !isVirtualPreviewFile && previewQuery.isPending,
|
||||
},
|
||||
onDownloadFile: () => downloadFile(selectedPreviewFile),
|
||||
onSelectFile: selectedFile => setSelectedFileId(selectedFile.id),
|
||||
selectedFileId: selectedFileId ?? file.id,
|
||||
sections: [],
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
{isBuildNoteFile && (
|
||||
<AgentBuildNoteInfotip
|
||||
className={cn(!readOnly && 'group-focus-within/file-row:opacity-0 group-hover/file-row:opacity-0')}
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (!file.virtualContent || isBuildNoteFile) && (
|
||||
<div className="pointer-events-none absolute top-1/2 right-1 z-10 flex -translate-y-1/2 items-center justify-end gap-1 opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
data-agent-file-remove-button
|
||||
aria-label={t('agentDetail.configure.files.remove', { name: file.name })}
|
||||
onClick={handleRemove}
|
||||
className="pointer-events-none absolute top-1/2 right-1 z-10 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within/file-row:pointer-events-auto group-focus-within/file-row:opacity-100 group-hover/file-row:pointer-events-auto group-hover/file-row:opacity-100 hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
aria-label={t('agentDetail.configure.files.download', { name: file.name })}
|
||||
onClick={handleDownload}
|
||||
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
<span aria-hidden className="i-ri-download-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
{canRemoveFile && (
|
||||
<button
|
||||
type="button"
|
||||
data-agent-file-remove-button
|
||||
aria-label={t('agentDetail.configure.files.remove', { name: file.name })}
|
||||
onClick={handleRemove}
|
||||
className="flex size-5 items-center justify-center rounded-md text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive focus-visible:bg-state-destructive-hover focus-visible:text-text-destructive focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
>
|
||||
<span aria-hidden className="i-ri-delete-bin-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentBuildNoteFileRow() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel title={BUILD_NOTE_FILE_NAME}>
|
||||
<FileTreeLabel className="w-auto flex-none" title={BUILD_NOTE_FILE_NAME}>
|
||||
{BUILD_NOTE_FILE_NAME}
|
||||
</FileTreeLabel>
|
||||
<FileTreeBadge className="ml-0.5 gap-0.5 px-1 py-0.5">
|
||||
<span aria-hidden className="i-ri-sparkling-line size-3 shrink-0" />
|
||||
<span>{t('agentDetail.configure.files.buildNote.generated')}</span>
|
||||
</FileTreeBadge>
|
||||
<div className="ml-1 flex shrink-0 items-center gap-0.5">
|
||||
<AgentBuildNoteBadge />
|
||||
<AgentBuildNoteInfotip />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentBuildNoteInfotip({
|
||||
className,
|
||||
}: {
|
||||
className?: string
|
||||
}) {
|
||||
function AgentBuildNoteBadge() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
|
||||
return (
|
||||
<FileTreeBadge className="ms-0 gap-0.5 px-1 py-0.5">
|
||||
<span aria-hidden className="i-ri-sparkling-line size-3 shrink-0" />
|
||||
<span>{t('agentDetail.configure.files.buildNote.generated')}</span>
|
||||
</FileTreeBadge>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentBuildNoteInfotip() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<Infotip
|
||||
aria-label={t('agentDetail.configure.files.buildNote.tooltip')}
|
||||
className={cn('absolute top-1/2 right-1 z-10 size-5 -translate-y-1/2', className)}
|
||||
iconClassName="size-4"
|
||||
className="size-5"
|
||||
iconClassName="size-4 text-text-quaternary hover:text-text-quaternary"
|
||||
popupClassName="w-[230px] rounded-xl bg-components-tooltip-bg px-4 py-3.5 text-text-secondary shadow-lg backdrop-blur-[5px]"
|
||||
>
|
||||
<p className="body-xs-regular text-text-secondary">
|
||||
@@ -293,19 +346,17 @@ export function AgentFiles() {
|
||||
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
const draft = useAtomValue(agentComposerDraftAtom)
|
||||
const setDraft = useSetAtom(agentComposerDraftAtom)
|
||||
const files = useAtomValue(agentComposerFilesAtom)
|
||||
const setFiles = useSetAtom(agentComposerFilesAtom)
|
||||
const clearAgentConfigNote = useSetAtom(clearAgentConfigNoteAtom)
|
||||
const removeAgentFile = useSetAtom(removeAgentFileAtom)
|
||||
const upsertAgentFile = useSetAtom(upsertAgentFileAtom)
|
||||
const buildNoteFile = getBuildNoteFile(draft.configNote)
|
||||
const visibleFiles = buildNoteFile ? [buildNoteFile, ...files] : files
|
||||
const { mutate: deleteAgentFile } = useMutation(consoleQuery.agent.byAgentId.config.files.byName.delete.mutationOptions())
|
||||
const { mutate: deleteWorkflowAgentFile } = useMutation(consoleQuery.apps.byAppId.agent.config.files.byName.delete.mutationOptions())
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
if (fileId === BUILD_NOTE_FILE_ID) {
|
||||
setDraft(draft => ({
|
||||
...draft,
|
||||
configNote: '',
|
||||
}))
|
||||
clearAgentConfigNote()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,7 +367,7 @@ export function AgentFiles() {
|
||||
return
|
||||
|
||||
const onSuccess = () => {
|
||||
setFiles(files => removeAgentFileNode(files, fileId))
|
||||
removeAgentFile(fileId)
|
||||
}
|
||||
if (apiContext.workflow) {
|
||||
deleteWorkflowAgentFile({
|
||||
@@ -343,20 +394,17 @@ export function AgentFiles() {
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
}, { onSuccess })
|
||||
}, [apiContext, deleteAgentFile, deleteWorkflowAgentFile, files, setDraft, setFiles])
|
||||
}, [apiContext, clearAgentConfigNote, deleteAgentFile, deleteWorkflowAgentFile, files, removeAgentFile])
|
||||
const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => {
|
||||
promptAddCallbackRef.current = options?.onAdded
|
||||
setIsUploadOpen(true)
|
||||
}, [])
|
||||
useRegisterAgentOrchestrateAddAction('files', handleOpenUpload)
|
||||
const handleUploaded = useCallback((file: AgentFileNode) => {
|
||||
setFiles(files => [
|
||||
...removeAgentFileNode(files, file.id),
|
||||
file,
|
||||
])
|
||||
upsertAgentFile(file)
|
||||
promptAddCallbackRef.current?.(file)
|
||||
promptAddCallbackRef.current = undefined
|
||||
}, [setFiles])
|
||||
}, [upsertAgentFile])
|
||||
const handleUploadOpenChange = useCallback((open: boolean) => {
|
||||
if (!open)
|
||||
promptAddCallbackRef.current = undefined
|
||||
|
||||
@@ -182,7 +182,7 @@ export function AgentFileTree({
|
||||
label={label}
|
||||
labelledBy={labelledBy}
|
||||
slotClassNames={{
|
||||
viewport: 'max-h-[inherit] overscroll-contain',
|
||||
viewport: 'max-h-[inherit]',
|
||||
content: 'w-full max-w-full min-w-0!',
|
||||
scrollbar: 'hidden',
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type AgentOrchestrateHeaderProps = {
|
||||
@@ -15,6 +16,7 @@ export function AgentOrchestrateHeader({
|
||||
isBuildDraftActive = false,
|
||||
}: AgentOrchestrateHeaderProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const communityEditionIsolationTip = t('agentDetail.configure.communityEditionIsolationTip')
|
||||
|
||||
return (
|
||||
<div className="shrink-0 px-4 py-3">
|
||||
@@ -23,6 +25,28 @@ export function AgentOrchestrateHeader({
|
||||
<h2 id={headingId} className="truncate title-xl-semi-bold text-text-primary">
|
||||
{t('agentDetail.configure.title')}
|
||||
</h2>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionIsolationTip}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="bottom"
|
||||
popupClassName="max-w-[320px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionIsolationTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{isBuildDraftActive && (
|
||||
<span className="flex min-w-[18px] shrink-0 items-center justify-center rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1.25 py-0.75 system-2xs-medium-uppercase text-text-accent-secondary">
|
||||
{t('agentDetail.configure.buildDraft.modeBadge')}
|
||||
|
||||
@@ -41,6 +41,7 @@ type AgentOrchestratePanelProps = {
|
||||
className?: string
|
||||
readOnly?: boolean
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
workflowReferencesEnabled?: boolean
|
||||
isBuildDraftActive?: boolean
|
||||
buildDraftChangedKeys?: readonly AgentBuildDraftChangedKey[]
|
||||
showHeader?: boolean
|
||||
@@ -68,6 +69,7 @@ export function AgentOrchestratePanel({
|
||||
className,
|
||||
readOnly = false,
|
||||
selectedVersionSnapshot,
|
||||
workflowReferencesEnabled,
|
||||
isBuildDraftActive = false,
|
||||
buildDraftChangedKeys = [],
|
||||
showHeader = true,
|
||||
@@ -92,6 +94,7 @@ export function AgentOrchestratePanel({
|
||||
draftSavedAt={draftSavedAt}
|
||||
isPublishing={isPublishing}
|
||||
selectedVersionSnapshot={selectedVersionSnapshot}
|
||||
workflowReferencesEnabled={workflowReferencesEnabled}
|
||||
onPublish={onPublish}
|
||||
onExitVersions={onExitVersions}
|
||||
onOpenVersions={onOpenVersions}
|
||||
|
||||
+14
-6
@@ -2,10 +2,15 @@
|
||||
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { AgentKnowledgeRetrievalItem } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import {
|
||||
addKnowledgeRetrievalAtom,
|
||||
agentComposerKnowledgeRetrievalsAtom,
|
||||
removeKnowledgeRetrievalAtom,
|
||||
updateKnowledgeRetrievalAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionConfigurableItem } from '../common/configurable-item'
|
||||
@@ -48,7 +53,10 @@ function AgentKnowledgeRetrievalRow({
|
||||
|
||||
export function AgentKnowledgeRetrieval() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [retrievals, setRetrievals] = useAtom(agentComposerKnowledgeRetrievalsAtom)
|
||||
const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
|
||||
const addKnowledgeRetrieval = useSetAtom(addKnowledgeRetrievalAtom)
|
||||
const updateKnowledgeRetrieval = useSetAtom(updateKnowledgeRetrievalAtom)
|
||||
const removeKnowledgeRetrieval = useSetAtom(removeKnowledgeRetrievalAtom)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [addDialogName, setAddDialogName] = useState<string>()
|
||||
const [editingRetrieval, setEditingRetrieval] = useState<AgentKnowledgeRetrievalItem | null>(null)
|
||||
@@ -57,7 +65,7 @@ export function AgentKnowledgeRetrieval() {
|
||||
const retrievalListId = 'agent-configure-knowledge-retrieval-list'
|
||||
const isDialogOpen = isAddDialogOpen || !!editingRetrieval
|
||||
const updateRetrieval = (nextRetrieval: AgentKnowledgeRetrievalItem) => {
|
||||
setRetrievals(retrievals.map(retrieval => retrieval.id === nextRetrieval.id ? nextRetrieval : retrieval))
|
||||
updateKnowledgeRetrieval(nextRetrieval)
|
||||
setEditingRetrieval(nextRetrieval)
|
||||
}
|
||||
const getDefaultRetrievalName = (index: number) => {
|
||||
@@ -74,7 +82,7 @@ export function AgentKnowledgeRetrieval() {
|
||||
setIsAddDialogOpen(true)
|
||||
}
|
||||
const createRetrieval = (nextRetrieval: AgentKnowledgeRetrievalItem) => {
|
||||
setRetrievals(current => [...current, nextRetrieval])
|
||||
addKnowledgeRetrieval(nextRetrieval)
|
||||
setEditingRetrieval(nextRetrieval)
|
||||
setIsAddDialogOpen(false)
|
||||
addOptionsRef.current?.onAdded?.(nextRetrieval)
|
||||
@@ -110,7 +118,7 @@ export function AgentKnowledgeRetrieval() {
|
||||
<AgentKnowledgeRetrievalRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
onDelete={() => setRetrievals(retrievals.filter(retrieval => retrieval.id !== item.id))}
|
||||
onDelete={() => removeKnowledgeRetrieval(item.id)}
|
||||
onEdit={() => setEditingRetrieval(item)}
|
||||
/>
|
||||
))}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { insertTokenAtTextRange, replaceTrailingSlashWithToken } from '../options'
|
||||
|
||||
describe('prompt editor token replacement', () => {
|
||||
// Replacing the tracked slash range keeps insertion at the user's caret instead of appending.
|
||||
describe('insertTokenAtTextRange', () => {
|
||||
it('should replace a slash in the middle of the prompt and place the cursor after the token', () => {
|
||||
expect(insertTokenAtTextRange(
|
||||
'Review / before replying',
|
||||
{ start: 7, end: 8 },
|
||||
'[§file:file-1:Spec§]',
|
||||
)).toEqual({
|
||||
value: 'Review [§file:file-1:Spec§] before replying',
|
||||
cursorOffset: 'Review [§file:file-1:Spec§]'.length,
|
||||
})
|
||||
})
|
||||
|
||||
it('should add spacing when the slash is adjacent to text and place the cursor after the spacer', () => {
|
||||
expect(insertTokenAtTextRange(
|
||||
'Review/now',
|
||||
{ start: 6, end: 7 },
|
||||
'[§skill:analysis:Analysis§]',
|
||||
)).toEqual({
|
||||
value: 'Review [§skill:analysis:Analysis§] now',
|
||||
cursorOffset: 'Review [§skill:analysis:Analysis§] '.length,
|
||||
})
|
||||
})
|
||||
|
||||
it('should clamp out-of-bound ranges before replacing', () => {
|
||||
expect(insertTokenAtTextRange(
|
||||
'Review/',
|
||||
{ start: 6, end: 99 },
|
||||
'[§knowledge:kb-1:KB§]',
|
||||
)).toEqual({
|
||||
value: 'Review [§knowledge:kb-1:KB§]',
|
||||
cursorOffset: 'Review [§knowledge:kb-1:KB§]'.length,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Existing fallback behavior is retained for callers that only know about a trailing slash.
|
||||
describe('replaceTrailingSlashWithToken', () => {
|
||||
it('should replace a trailing slash', () => {
|
||||
expect(replaceTrailingSlashWithToken(
|
||||
'Review /',
|
||||
'[§file:file-1:Spec§]',
|
||||
)).toBe('Review [§file:file-1:Spec§]')
|
||||
})
|
||||
|
||||
it('should append when no trailing slash exists', () => {
|
||||
expect(replaceTrailingSlashWithToken(
|
||||
'Review',
|
||||
'[§file:file-1:Spec§]',
|
||||
)).toBe('Review [§file:file-1:Spec§]')
|
||||
})
|
||||
})
|
||||
})
|
||||
+336
-31
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import type { LexicalNode } from 'lexical'
|
||||
import type { KeyboardEvent, MouseEvent, PointerEvent as ReactPointerEvent } from 'react'
|
||||
import type { TextRange } from './options'
|
||||
import type { SlashMenuCategory, SlashMenuView } from './slash'
|
||||
import type { RosterReferenceToken } from '@/app/components/base/prompt-editor/plugins/roster-reference-block/utils'
|
||||
import type { AgentFileNode, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
@@ -8,9 +10,21 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Kbd } from '@langgenius/dify-ui/kbd'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { useClipboard } from 'foxact/use-clipboard'
|
||||
import { useAtom, useAtomValue } from 'jotai'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
@@ -18,14 +32,17 @@ import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
import { agentComposerToolsAtom } from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import {
|
||||
addProviderToolsAtom,
|
||||
agentComposerToolsAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
|
||||
import { useAgentOrchestrateAddActions } from '../add-actions-context'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentConfigFiles, useAgentConfigSkills } from '../config-context'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { useAgentPromptToolIconResolver } from './hooks'
|
||||
import { replaceTrailingSlashWithToken } from './options'
|
||||
import { insertTokenAtTextRange, replaceTrailingSlashWithToken } from './options'
|
||||
import { AgentPromptSlashMenu } from './slash'
|
||||
|
||||
const subscribeHydrationState = () => () => {}
|
||||
@@ -152,13 +169,232 @@ const isSelectionAfterSlash = (rootElement: HTMLElement | null, fallbackValue: s
|
||||
return previousChild ? getLastTextContent(previousChild).endsWith('/') : false
|
||||
}
|
||||
|
||||
/* v8 ignore start -- Lexical selection offsets and DOM range geometry are browser-editor integration glue; user-visible slash insertion behavior is covered by AgentPromptEditor tests. @preserve */
|
||||
const getNodeOffset = (
|
||||
node: LexicalNode,
|
||||
anchorNode: LexicalNode,
|
||||
anchorOffset: number,
|
||||
): { found: boolean, offset: number } => {
|
||||
if (node.getKey() === anchorNode.getKey())
|
||||
return { found: true, offset: anchorOffset }
|
||||
|
||||
if (!$isElementNode(node))
|
||||
return { found: false, offset: node.getTextContent().length }
|
||||
|
||||
let offset = 0
|
||||
for (const child of node.getChildren()) {
|
||||
const childOffset = getNodeOffset(child, anchorNode, anchorOffset)
|
||||
if (childOffset.found)
|
||||
return { found: true, offset: offset + childOffset.offset }
|
||||
|
||||
offset += childOffset.offset
|
||||
}
|
||||
|
||||
return { found: false, offset }
|
||||
}
|
||||
|
||||
const getSelectionTextOffset = () => {
|
||||
const selection = $getSelection()
|
||||
if (!$isRangeSelection(selection) || !selection.isCollapsed())
|
||||
return null
|
||||
|
||||
const anchor = selection.anchor
|
||||
const anchorNode = anchor.getNode()
|
||||
const root = $getRoot()
|
||||
let offset = 0
|
||||
|
||||
for (const child of root.getChildren()) {
|
||||
const childOffset = getNodeOffset(child, anchorNode, anchor.offset)
|
||||
if (childOffset.found)
|
||||
return offset + childOffset.offset
|
||||
|
||||
offset += childOffset.offset + 1
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const readSlashInsertRange = (): TextRange | null => {
|
||||
const offset = getSelectionTextOffset()
|
||||
if (!offset)
|
||||
return null
|
||||
|
||||
const value = $getRoot().getChildren().map(node => node.getTextContent()).join('\n')
|
||||
if (value[offset - 1] !== '/')
|
||||
return null
|
||||
|
||||
return {
|
||||
start: offset - 1,
|
||||
end: offset,
|
||||
}
|
||||
}
|
||||
|
||||
const selectNodeTextOffset = (node: LexicalNode, textOffset: number): boolean => {
|
||||
if ($isTextNode(node)) {
|
||||
const offset = Math.max(0, Math.min(textOffset, node.getTextContentSize()))
|
||||
node.select(offset, offset)
|
||||
return true
|
||||
}
|
||||
|
||||
if (!$isElementNode(node))
|
||||
return false
|
||||
|
||||
const children = node.getChildren()
|
||||
let currentOffset = 0
|
||||
|
||||
for (let index = 0; index < children.length; index++) {
|
||||
const child = children[index]!
|
||||
const childLength = child.getTextContent().length
|
||||
if (textOffset > currentOffset + childLength) {
|
||||
currentOffset += childLength
|
||||
continue
|
||||
}
|
||||
|
||||
if ($isElementNode(child) || $isTextNode(child))
|
||||
return selectNodeTextOffset(child, textOffset - currentOffset)
|
||||
|
||||
const childSelectionOffset = textOffset <= currentOffset ? index : index + 1
|
||||
node.select(childSelectionOffset, childSelectionOffset)
|
||||
return true
|
||||
}
|
||||
|
||||
node.select(children.length, children.length)
|
||||
return true
|
||||
}
|
||||
|
||||
const selectTextOffset = (textOffset: number) => {
|
||||
const root = $getRoot()
|
||||
let currentOffset = 0
|
||||
|
||||
for (const child of root.getChildren()) {
|
||||
const childLength = child.getTextContent().length
|
||||
if (textOffset <= currentOffset + childLength) {
|
||||
selectNodeTextOffset(child, textOffset - currentOffset)
|
||||
return
|
||||
}
|
||||
|
||||
currentOffset += childLength + 1
|
||||
}
|
||||
|
||||
root.selectEnd()
|
||||
}
|
||||
|
||||
type SelectionRestoreRequest = {
|
||||
id: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
type SlashMenuPosition = {
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
|
||||
const slashMenuViewportPadding = 8
|
||||
const slashMenuMainWidth = 200
|
||||
const slashMenuSubmenuWidth = 360
|
||||
|
||||
const getSlashMenuPosition = (editorElement: HTMLElement): SlashMenuPosition | null => {
|
||||
const selection = window.getSelection()
|
||||
if (!selection || !selection.isCollapsed || selection.rangeCount === 0)
|
||||
return null
|
||||
|
||||
const anchorNode = selection.anchorNode
|
||||
if (!anchorNode || !editorElement.contains(anchorNode))
|
||||
return null
|
||||
|
||||
const range = selection.getRangeAt(0).cloneRange()
|
||||
let rect: DOMRect | null = null
|
||||
const rects = range.getClientRects()
|
||||
if (rects.length)
|
||||
rect = rects[rects.length - 1]!
|
||||
else
|
||||
rect = range.getBoundingClientRect()
|
||||
|
||||
if (!rect || (rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
|
||||
const node = anchorNode.nodeType === Node.ELEMENT_NODE
|
||||
? anchorNode as Element
|
||||
: anchorNode.parentElement
|
||||
|
||||
rect = node?.getBoundingClientRect() ?? editorElement.getBoundingClientRect()
|
||||
}
|
||||
|
||||
const editorRect = editorElement.getBoundingClientRect()
|
||||
if (!rect || rect.bottom < editorRect.top || rect.top > editorRect.bottom)
|
||||
return null
|
||||
|
||||
return {
|
||||
left: rect.right,
|
||||
top: rect.bottom + 4,
|
||||
}
|
||||
}
|
||||
|
||||
const getSlashMenuLeft = (position: SlashMenuPosition, width: number) => {
|
||||
if (typeof window === 'undefined')
|
||||
return position.left
|
||||
|
||||
return Math.max(
|
||||
slashMenuViewportPadding,
|
||||
Math.min(position.left, window.innerWidth - width - slashMenuViewportPadding),
|
||||
)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
function AgentPromptSelectionBridge({
|
||||
restoreRequest,
|
||||
onSlashRangeChange,
|
||||
}: {
|
||||
restoreRequest: SelectionRestoreRequest | null
|
||||
onSlashRangeChange: (range: TextRange | null) => void
|
||||
}) {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
const updateSlashRange = () => {
|
||||
editor.getEditorState().read(() => {
|
||||
onSlashRangeChange(readSlashInsertRange())
|
||||
})
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
updateSlashRange()
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
updateSlashRange,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
onSlashRangeChange(readSlashInsertRange())
|
||||
})
|
||||
}),
|
||||
)
|
||||
}, [editor, onSlashRangeChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoreRequest)
|
||||
return
|
||||
|
||||
editor.focus(() => {
|
||||
editor.update(() => {
|
||||
selectTextOffset(restoreRequest.offset)
|
||||
})
|
||||
})
|
||||
}, [editor, restoreRequest])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function AgentPromptEditor() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const [value, setValue] = useAtom(agentComposerPromptAtom)
|
||||
const { skills } = useAgentConfigSkills()
|
||||
const { files } = useAgentConfigFiles()
|
||||
const [tools, setTools] = useAtom(agentComposerToolsAtom)
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
const addProviderTools = useSetAtom(addProviderToolsAtom)
|
||||
const { getConfiguredToolIcon } = useAgentPromptToolIconResolver()
|
||||
const retrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
|
||||
const addActions = useAgentOrchestrateAddActions()
|
||||
@@ -178,8 +414,12 @@ export function AgentPromptEditor() {
|
||||
})
|
||||
const [slashMenuView, setSlashMenuView] = useState<SlashMenuView>('main')
|
||||
const [isSlashMenuOpen, setIsSlashMenuOpen] = useState(false)
|
||||
const [slashMenuPosition, setSlashMenuPosition] = useState<SlashMenuPosition | null>(null)
|
||||
const [selectionRestoreRequest, setSelectionRestoreRequest] = useState<SelectionRestoreRequest | null>(null)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<HTMLDivElement>(null)
|
||||
const slashInsertRangeRef = useRef<TextRange | null>(null)
|
||||
const selectionRestoreRequestIdRef = useRef(0)
|
||||
const configuredReferenceIds = useMemo(() => {
|
||||
const skillIds = new Set<string>()
|
||||
skills.forEach((skill) => {
|
||||
@@ -215,23 +455,46 @@ export function AgentPromptEditor() {
|
||||
|
||||
const closeSlashMenu = () => {
|
||||
setIsSlashMenuOpen(false)
|
||||
setSlashMenuPosition(null)
|
||||
setSlashMenuView('main')
|
||||
}
|
||||
|
||||
const openSlashMenu = () => {
|
||||
const updateSlashMenuPosition = useCallback(() => {
|
||||
const editorElement = editorRef.current
|
||||
if (!editorElement)
|
||||
return
|
||||
|
||||
const position = getSlashMenuPosition(editorElement)
|
||||
if (!position)
|
||||
return
|
||||
|
||||
setSlashMenuPosition(position)
|
||||
}, [])
|
||||
|
||||
const openSlashMenu = useCallback(() => {
|
||||
setSlashMenuView('main')
|
||||
updateSlashMenuPosition()
|
||||
setIsSlashMenuOpen(true)
|
||||
}
|
||||
}, [updateSlashMenuPosition])
|
||||
|
||||
const syncSlashMenuWithSelection = useCallback(() => {
|
||||
if (!isHydrated || readOnly)
|
||||
return
|
||||
|
||||
if (isSelectionAfterSlash(editorRef.current, value))
|
||||
if (isSelectionAfterSlash(editorRef.current, value)) {
|
||||
updateSlashMenuPosition()
|
||||
openSlashMenu()
|
||||
else
|
||||
}
|
||||
else {
|
||||
slashInsertRangeRef.current = null
|
||||
closeSlashMenu()
|
||||
}, [isHydrated, readOnly, value])
|
||||
}
|
||||
}, [isHydrated, openSlashMenu, readOnly, updateSlashMenuPosition, value])
|
||||
|
||||
const handleSlashRangeChange = useCallback((range: TextRange | null) => {
|
||||
if (range)
|
||||
slashInsertRangeRef.current = range
|
||||
}, [])
|
||||
|
||||
const handleEditorKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isHydrated || readOnly)
|
||||
@@ -287,7 +550,25 @@ export function AgentPromptEditor() {
|
||||
}
|
||||
|
||||
const handleSlashSelect = (token: string) => {
|
||||
setValue(replaceTrailingSlashWithToken(value, token))
|
||||
const slashRange = slashInsertRangeRef.current
|
||||
let insertionResult
|
||||
if (slashRange) {
|
||||
insertionResult = insertTokenAtTextRange(value, slashRange, token)
|
||||
}
|
||||
else {
|
||||
const nextValue = replaceTrailingSlashWithToken(value, token)
|
||||
insertionResult = {
|
||||
value: nextValue,
|
||||
cursorOffset: nextValue.length,
|
||||
}
|
||||
}
|
||||
setValue(insertionResult.value)
|
||||
slashInsertRangeRef.current = null
|
||||
selectionRestoreRequestIdRef.current += 1
|
||||
setSelectionRestoreRequest({
|
||||
id: selectionRestoreRequestIdRef.current,
|
||||
offset: insertionResult.cursorOffset,
|
||||
})
|
||||
closeSlashMenu()
|
||||
}
|
||||
|
||||
@@ -343,6 +624,13 @@ export function AgentPromptEditor() {
|
||||
if (!(target instanceof Node))
|
||||
return
|
||||
|
||||
if (
|
||||
target instanceof Element
|
||||
&& target.closest('[data-agent-prompt-slash-menu]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!rootRef.current?.contains(target))
|
||||
closeSlashMenu()
|
||||
}
|
||||
@@ -375,6 +663,37 @@ export function AgentPromptEditor() {
|
||||
icon: 'i-ri-book-open-line',
|
||||
},
|
||||
]
|
||||
const slashMenuWidth = slashMenuView === 'main' ? slashMenuMainWidth : slashMenuSubmenuWidth
|
||||
const slashMenu = isHydrated && !readOnly && isSlashMenuOpen
|
||||
? createPortal(
|
||||
<div
|
||||
data-agent-prompt-slash-menu
|
||||
className="fixed z-60"
|
||||
style={{
|
||||
left: slashMenuPosition ? `${getSlashMenuLeft(slashMenuPosition, slashMenuWidth)}px` : '12px',
|
||||
top: slashMenuPosition ? `${slashMenuPosition.top}px` : '36px',
|
||||
}}
|
||||
>
|
||||
<AgentPromptSlashMenu
|
||||
view={slashMenuView}
|
||||
categories={slashMenuCategories}
|
||||
skills={skills}
|
||||
files={files}
|
||||
tools={tools}
|
||||
onAddProviderTools={addProviderTools}
|
||||
onAddCliTool={ENABLE_AGENT_CLI_TOOLS ? addActions.cli : undefined}
|
||||
onAddFile={addActions.files}
|
||||
onAddKnowledge={addActions.knowledge}
|
||||
onAddSkill={addActions.skills}
|
||||
retrievals={retrievals}
|
||||
onBack={() => setSlashMenuView('main')}
|
||||
onOpenCategory={setSlashMenuView}
|
||||
onSelect={handleSlashSelect}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-1 px-0 py-0" aria-labelledby="agent-configure-prompt-label">
|
||||
@@ -442,7 +761,12 @@ export function AgentPromptEditor() {
|
||||
}}
|
||||
disableSlashPicker
|
||||
disableBracePicker
|
||||
/>
|
||||
>
|
||||
<AgentPromptSelectionBridge
|
||||
restoreRequest={selectionRestoreRequest}
|
||||
onSlashRangeChange={handleSlashRangeChange}
|
||||
/>
|
||||
</PromptEditor>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div
|
||||
@@ -466,26 +790,7 @@ export function AgentPromptEditor() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isHydrated && !readOnly && isSlashMenuOpen && (
|
||||
<div data-agent-prompt-slash-menu className="absolute top-9 left-3 z-50">
|
||||
<AgentPromptSlashMenu
|
||||
view={slashMenuView}
|
||||
categories={slashMenuCategories}
|
||||
skills={skills}
|
||||
files={files}
|
||||
tools={tools}
|
||||
onToolsChange={setTools}
|
||||
onAddCliTool={ENABLE_AGENT_CLI_TOOLS ? addActions.cli : undefined}
|
||||
onAddFile={addActions.files}
|
||||
onAddKnowledge={addActions.knowledge}
|
||||
onAddSkill={addActions.skills}
|
||||
retrievals={retrievals}
|
||||
onBack={() => setSlashMenuView('main')}
|
||||
onOpenCategory={setSlashMenuView}
|
||||
onSelect={handleSlashSelect}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{slashMenu}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
+29
-1
@@ -71,6 +71,20 @@ const appendToken = (value: string, token: string) => {
|
||||
return `${value}${value.endsWith(' ') || value.endsWith('\n') ? '' : ' '}${token}`
|
||||
}
|
||||
|
||||
export type TextRange = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export type TokenInsertionResult = {
|
||||
value: string
|
||||
cursorOffset: number
|
||||
}
|
||||
|
||||
const hasTrailingSpace = (value: string) => value.endsWith(' ') || value.endsWith('\n')
|
||||
|
||||
const hasLeadingSpace = (value: string) => value.startsWith(' ') || value.startsWith('\n')
|
||||
|
||||
export const replaceTrailingSlashWithToken = (value: string, token: string) => {
|
||||
if (!value.endsWith('/'))
|
||||
return appendToken(value, token)
|
||||
@@ -79,5 +93,19 @@ export const replaceTrailingSlashWithToken = (value: string, token: string) => {
|
||||
if (!valueWithoutSlash)
|
||||
return token
|
||||
|
||||
return `${valueWithoutSlash}${valueWithoutSlash.endsWith(' ') || valueWithoutSlash.endsWith('\n') ? '' : ' '}${token}`
|
||||
return `${valueWithoutSlash}${hasTrailingSpace(valueWithoutSlash) ? '' : ' '}${token}`
|
||||
}
|
||||
|
||||
export const insertTokenAtTextRange = (value: string, range: TextRange, token: string): TokenInsertionResult => {
|
||||
const start = Math.max(0, Math.min(range.start, value.length))
|
||||
const end = Math.max(start, Math.min(range.end, value.length))
|
||||
const prefix = value.slice(0, start)
|
||||
const suffix = value.slice(end)
|
||||
const beforeToken = prefix && !hasTrailingSpace(prefix) ? ' ' : ''
|
||||
const afterToken = suffix && !hasLeadingSpace(suffix) ? ' ' : ''
|
||||
|
||||
return {
|
||||
value: `${prefix}${beforeToken}${token}${afterToken}${suffix}`,
|
||||
cursorOffset: prefix.length + beforeToken.length + token.length + afterToken.length,
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -2,11 +2,11 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentOrchestrateAddAction, AgentOrchestrateAddedItem } from '../add-actions-context'
|
||||
import type { AgentProviderToolDefaultValue } from '../tools/types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import type { ToolTypeEnum, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentFileNode, AgentKnowledgeRetrievalItem, AgentSkill, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { FileTreeIcon } from '@langgenius/dify-ui/file-tree'
|
||||
import { useMemo, useState } from 'react'
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { addProviderTools } from '../tools/hooks'
|
||||
import { useAgentPromptToolIconResolver } from './hooks'
|
||||
|
||||
export type SlashMenuView = 'main' | 'skills' | 'files' | 'tools' | 'knowledge'
|
||||
@@ -42,7 +41,7 @@ type AgentPromptSlashMenuProps = {
|
||||
skills: AgentSkill[]
|
||||
files: AgentFileNode[]
|
||||
tools: AgentTool[]
|
||||
onToolsChange: (tools: AgentTool[]) => void
|
||||
onAddProviderTools: (tools: AgentProviderToolDefaultValue[]) => void
|
||||
onAddCliTool?: AgentOrchestrateAddAction
|
||||
onAddFile?: AgentOrchestrateAddAction
|
||||
onAddKnowledge?: AgentOrchestrateAddAction
|
||||
@@ -83,7 +82,7 @@ export function AgentPromptSlashMenu({
|
||||
skills,
|
||||
files,
|
||||
tools,
|
||||
onToolsChange,
|
||||
onAddProviderTools,
|
||||
onAddCliTool,
|
||||
onAddFile,
|
||||
onAddKnowledge,
|
||||
@@ -167,7 +166,7 @@ export function AgentPromptSlashMenu({
|
||||
{view === 'tools' && (
|
||||
<AgentPromptToolRows
|
||||
configuredTools={tools}
|
||||
onConfiguredToolsChange={onToolsChange}
|
||||
onAddProviderTools={onAddProviderTools}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
)}
|
||||
@@ -279,11 +278,11 @@ function AgentPromptFileRows({
|
||||
|
||||
function AgentPromptToolRows({
|
||||
configuredTools,
|
||||
onConfiguredToolsChange,
|
||||
onAddProviderTools,
|
||||
onSelect,
|
||||
}: {
|
||||
configuredTools: AgentTool[]
|
||||
onConfiguredToolsChange: (tools: AgentTool[]) => void
|
||||
onAddProviderTools: (tools: AgentProviderToolDefaultValue[]) => void
|
||||
onSelect: (token: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
@@ -329,7 +328,7 @@ function AgentPromptToolRows({
|
||||
]
|
||||
|
||||
const selectTools = (tools: AgentProviderToolDefaultValue[]) => {
|
||||
onConfiguredToolsChange(addProviderTools(configuredTools, tools))
|
||||
onAddProviderTools(tools)
|
||||
}
|
||||
|
||||
const toggleProvider = (providerId: string) => {
|
||||
|
||||
+7
-5
@@ -33,6 +33,7 @@ type AgentConfigurePublishBarProps = {
|
||||
draftSavedAt?: number
|
||||
isPublishing?: boolean
|
||||
selectedVersionSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
workflowReferencesEnabled?: boolean
|
||||
onPublish?: () => void | Promise<void>
|
||||
onExitVersions?: () => void
|
||||
onOpenVersions?: () => void
|
||||
@@ -90,6 +91,7 @@ export function AgentConfigurePublishBar({
|
||||
draftSavedAt,
|
||||
isPublishing = false,
|
||||
selectedVersionSnapshot,
|
||||
workflowReferencesEnabled = true,
|
||||
onPublish,
|
||||
onExitVersions,
|
||||
onOpenVersions,
|
||||
@@ -128,11 +130,9 @@ export function AgentConfigurePublishBar({
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
enabled: workflowReferencesEnabled && publishIsAvailable && !selectedVersionSnapshot,
|
||||
})
|
||||
const workflowReferencesQuery = useQuery({
|
||||
...workflowReferencesQueryOptions,
|
||||
enabled: publishIsAvailable && !selectedVersionSnapshot,
|
||||
})
|
||||
const workflowReferencesQuery = useQuery(workflowReferencesQueryOptions)
|
||||
const restoreVersionMutation = useMutation(consoleQuery.agent.byAgentId.versions.byVersionId.restore.post.mutationOptions())
|
||||
const canPublish = publishIsAvailable
|
||||
|
||||
@@ -195,7 +195,9 @@ export function AgentConfigurePublishBar({
|
||||
}
|
||||
|
||||
const cachedReferences = queryClient.getQueryData<AgentReferencingWorkflowsResponse>(workflowReferencesQueryOptions.queryKey)
|
||||
const references = (cachedReferences ?? workflowReferencesQuery.data ?? await queryClient.ensureQueryData(workflowReferencesQueryOptions))?.data ?? []
|
||||
const references = workflowReferencesEnabled
|
||||
? (cachedReferences ?? workflowReferencesQuery.data ?? await queryClient.ensureQueryData(workflowReferencesQueryOptions))?.data ?? []
|
||||
: []
|
||||
|
||||
if (references.length > 0) {
|
||||
setPublishBarMode({ status: 'confirmingImpact', references })
|
||||
|
||||
+164
@@ -30,6 +30,14 @@ type ConfigSkillFileQueryOptionsInput = {
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigSkillDownloadQueryOptionsInput = {
|
||||
input: {
|
||||
params: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
deleteSkillMutationFn: vi.fn(async (_input: unknown) => ({ removed_names: ['Tender Analyzer'], result: 'success' })),
|
||||
uploadSkillMutationFn: vi.fn(async (_input: unknown) => ({
|
||||
@@ -44,9 +52,12 @@ const mocks = vi.hoisted(() => ({
|
||||
size: 128,
|
||||
},
|
||||
})),
|
||||
skillDownloadQueryOptions: vi.fn((_options: ConfigSkillDownloadQueryOptionsInput) => ({})),
|
||||
inspectQueryOptions: vi.fn((_options: ConfigSkillInspectQueryOptionsInput) => ({})),
|
||||
previewQueryOptions: vi.fn((_options: ConfigSkillFileQueryOptionsInput) => ({})),
|
||||
downloadQueryOptions: vi.fn((_options: ConfigSkillFileQueryOptionsInput) => ({})),
|
||||
downloadBlob: vi.fn(),
|
||||
downloadUrl: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
@@ -56,6 +67,11 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mocks.downloadBlob,
|
||||
downloadUrl: mocks.downloadUrl,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
@@ -71,6 +87,11 @@ vi.mock('@/service/client', () => ({
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }),
|
||||
},
|
||||
download: {
|
||||
get: {
|
||||
queryOptions: mocks.skillDownloadQueryOptions,
|
||||
},
|
||||
},
|
||||
inspect: {
|
||||
get: {
|
||||
queryOptions: mocks.inspectQueryOptions,
|
||||
@@ -107,6 +128,11 @@ vi.mock('@/service/client', () => ({
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.deleteSkillMutationFn }),
|
||||
},
|
||||
download: {
|
||||
get: {
|
||||
queryOptions: mocks.skillDownloadQueryOptions,
|
||||
},
|
||||
},
|
||||
inspect: {
|
||||
get: {
|
||||
queryOptions: mocks.inspectQueryOptions,
|
||||
@@ -235,6 +261,12 @@ describe('AgentSkills', () => {
|
||||
url: `https://example.com/${input.query.path}`,
|
||||
}),
|
||||
}))
|
||||
mocks.skillDownloadQueryOptions.mockImplementation(({ input }) => ({
|
||||
queryKey: ['download-skill', input],
|
||||
queryFn: async () => ({
|
||||
url: `https://example.com/${input.params.name}.skill`,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should delete a configured skill by config name', async () => {
|
||||
@@ -390,6 +422,69 @@ describe('AgentSkills', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should download a whole skill package from the row action', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: /common\.operation\.download.*Tender Analyzer/,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'Tender Analyzer',
|
||||
},
|
||||
query: {
|
||||
draft_type: 'draft',
|
||||
version_id: undefined,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(mocks.downloadUrl).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/Tender Analyzer.skill',
|
||||
fileName: 'Tender Analyzer',
|
||||
})
|
||||
})
|
||||
|
||||
it('should download a whole workflow skill package with node_id', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills({
|
||||
apiContext: {
|
||||
agentId: 'agent-1',
|
||||
draftType: 'draft',
|
||||
versionId: 'draft-1',
|
||||
workflow: {
|
||||
appId: 'app-1',
|
||||
nodeId: 'node-1',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: /common\.operation\.download.*Tender Analyzer/,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.skillDownloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
app_id: 'app-1',
|
||||
name: 'Tender Analyzer',
|
||||
},
|
||||
query: {
|
||||
draft_type: 'draft',
|
||||
node_id: 'node-1',
|
||||
version_id: 'draft-1',
|
||||
},
|
||||
}),
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
it('should inspect skills by config name and preview package members by member path', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
@@ -425,6 +520,75 @@ describe('AgentSkills', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should wrap long preview lines instead of forcing a horizontal code block', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
|
||||
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
|
||||
|
||||
const skillMdCode = await screen.findByText('# Skill')
|
||||
expect(skillMdCode.tagName).toBe('CODE')
|
||||
expect(skillMdCode).toHaveClass('[overflow-wrap:anywhere]')
|
||||
expect(skillMdCode).toHaveClass('break-words')
|
||||
expect(skillMdCode).toHaveClass('whitespace-pre-wrap')
|
||||
expect(skillMdCode).not.toHaveClass('whitespace-pre')
|
||||
expect(skillMdCode).not.toHaveClass('min-w-max')
|
||||
})
|
||||
|
||||
it('should download skill package members from the detail file tree', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
|
||||
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
|
||||
await user.click(await screen.findByText('references'))
|
||||
await user.click(screen.getByText('guide.md').closest('button')!)
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: /common\.operation\.download.*guide\.md/,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.downloadQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
name: 'Tender Analyzer',
|
||||
},
|
||||
query: expect.objectContaining({
|
||||
path: 'references/guide.md',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
expect(mocks.downloadUrl).toHaveBeenCalledWith({
|
||||
url: 'https://example.com/references/guide.md',
|
||||
fileName: 'guide.md',
|
||||
})
|
||||
})
|
||||
|
||||
it('should download inspected SKILL.md content as markdown', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills()
|
||||
|
||||
await user.click(screen.getByText('Tender Analyzer').closest('button')!)
|
||||
await user.click(await screen.findByRole('button', {
|
||||
name: /common\.operation\.download.*SKILL\.md/,
|
||||
}))
|
||||
|
||||
expect(mocks.downloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'SKILL.md',
|
||||
})
|
||||
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
|
||||
await expect(blob.text()).resolves.toBe('# Skill\n')
|
||||
expect(mocks.downloadQueryOptions).not.toHaveBeenCalledWith(expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
query: expect.objectContaining({
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should disable add and remove actions when the section is read only', () => {
|
||||
const { container } = renderAgentSkills({ readOnly: true })
|
||||
|
||||
|
||||
+52
-34
@@ -48,6 +48,7 @@ export type AgentSkillDetail = {
|
||||
}
|
||||
onFolderOpenChange?: (context: { file: AgentSkillFileNode, depth: number, open: boolean }) => void
|
||||
onFolderDoubleClick?: (context: { file: AgentSkillFileNode, depth: number }) => void
|
||||
onDownloadFile?: () => void
|
||||
onSelectFile?: (file: AgentSkillFileNode) => void
|
||||
renderFolderSuffix?: (context: { file: AgentSkillFileNode, depth: number }) => ReactNode
|
||||
selectedFileId?: string
|
||||
@@ -210,29 +211,25 @@ function AgentFilePreviewContent({
|
||||
}
|
||||
|
||||
if (binary) {
|
||||
if (downloadUrl) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 px-4">
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.unsupported')}
|
||||
</span>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1 rounded-md px-2 py-1 system-sm-medium text-text-accent outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0" />
|
||||
<span className="shrink-0">{tCommon('operation.download')}</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="px-4 system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.empty')}
|
||||
</p>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2 px-4">
|
||||
<span className="system-sm-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.files.preview.unsupported')}
|
||||
</span>
|
||||
<a
|
||||
href={downloadUrl || '#'}
|
||||
onClick={(event) => {
|
||||
if (!downloadUrl)
|
||||
event.preventDefault()
|
||||
}}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex min-w-0 items-center gap-1 rounded-md px-2 py-1 system-sm-medium text-text-accent outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0" />
|
||||
<span className="shrink-0">{tCommon('operation.download')}</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -244,19 +241,27 @@ function AgentFilePreviewContent({
|
||||
)
|
||||
}
|
||||
|
||||
const lines = content.split('\n')
|
||||
const lines = content.split('\n').map((line, index) => ({
|
||||
content: line,
|
||||
key: `${index}:${line}`,
|
||||
lineNumber: String(index + 1).padStart(2, '0'),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 overflow-auto px-2 pb-4">
|
||||
<pre
|
||||
aria-hidden="true"
|
||||
className="m-0 w-7 shrink-0 pr-2 text-right font-mono text-[13px] leading-[22px] text-text-quaternary select-none"
|
||||
>
|
||||
{lines.map((_, index) => String(index + 1).padStart(2, '0')).join('\n')}
|
||||
</pre>
|
||||
<pre className="m-0 min-w-max flex-1 font-mono text-[13px] leading-[22px] whitespace-pre text-text-primary">
|
||||
{content}
|
||||
</pre>
|
||||
<div className="min-h-0 flex-1 overflow-auto px-2 pb-4 font-mono text-[13px] leading-[22px]">
|
||||
{lines.map(line => (
|
||||
<div key={line.key} className="flex min-w-0 items-start">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="w-7 shrink-0 pr-2 text-right text-text-quaternary select-none"
|
||||
>
|
||||
{line.lineNumber}
|
||||
</span>
|
||||
<code className="block min-w-0 flex-1 [overflow-wrap:anywhere] break-words whitespace-pre-wrap text-text-primary">
|
||||
{line.content}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -269,6 +274,7 @@ export function AgentSkillDetailDialog({
|
||||
detail: AgentSkillDetail
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const previewTitle = detail.filePreview?.fileName
|
||||
|
||||
return (
|
||||
@@ -306,7 +312,19 @@ export function AgentSkillDetailDialog({
|
||||
</h2>
|
||||
)}
|
||||
</div>
|
||||
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{detail.onDownloadFile && previewTitle && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${tCommon('operation.download')} ${previewTitle}`}
|
||||
onClick={detail.onDownloadFile}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-download-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
<DialogCloseButton className="static size-7 shrink-0 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea
|
||||
className="relative min-h-0 flex-1 overflow-hidden has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid"
|
||||
|
||||
+11
-9
@@ -6,7 +6,11 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentComposerSkillsAtom } from '@/features/agent-v2/agent-composer/store-modules/skills'
|
||||
import {
|
||||
agentComposerSkillsAtom,
|
||||
removeAgentSkillAtom,
|
||||
upsertAgentSkillAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/skills'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context'
|
||||
import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
@@ -25,7 +29,8 @@ export function AgentSkills() {
|
||||
const promptAddCallbackRef = useRef<AgentOrchestrateAddActionOptions['onAdded']>(undefined)
|
||||
const apiContext = useAgentConfigApiContext()
|
||||
const skills = useAtomValue(agentComposerSkillsAtom)
|
||||
const setSkills = useSetAtom(agentComposerSkillsAtom)
|
||||
const upsertAgentSkill = useSetAtom(upsertAgentSkillAtom)
|
||||
const removeAgentSkill = useSetAtom(removeAgentSkillAtom)
|
||||
const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.config.skills.byName.delete.mutationOptions())
|
||||
const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.config.skills.byName.delete.mutationOptions())
|
||||
|
||||
@@ -36,13 +41,10 @@ export function AgentSkills() {
|
||||
useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload)
|
||||
|
||||
const handleUploaded = useCallback((skill: AgentSkill) => {
|
||||
setSkills(skills => [
|
||||
...skills.filter(item => item.id !== skill.id),
|
||||
skill,
|
||||
])
|
||||
upsertAgentSkill(skill)
|
||||
promptAddCallbackRef.current?.(skill)
|
||||
promptAddCallbackRef.current = undefined
|
||||
}, [setSkills])
|
||||
}, [upsertAgentSkill])
|
||||
|
||||
const handleUploadOpenChange = useCallback((open: boolean) => {
|
||||
if (!open)
|
||||
@@ -56,7 +58,7 @@ export function AgentSkills() {
|
||||
return
|
||||
|
||||
const onSuccess = () => {
|
||||
setSkills(skills => skills.filter(item => item.id !== skillId))
|
||||
removeAgentSkill(skillId)
|
||||
}
|
||||
if (apiContext.workflow) {
|
||||
deleteAppSkill({
|
||||
@@ -83,7 +85,7 @@ export function AgentSkills() {
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
}, { onSuccess })
|
||||
}, [apiContext, deleteAgentSkill, deleteAppSkill, setSkills, skills])
|
||||
}, [apiContext, deleteAgentSkill, deleteAppSkill, removeAgentSkill, skills])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
+50
-1
@@ -6,8 +6,11 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import {
|
||||
Dialog,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { downloadUrl } from '@/utils/download'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { AgentSkillDetailDialog } from './detail-dialog'
|
||||
import { useAgentSkillDetail } from './use-skill-detail'
|
||||
@@ -22,11 +25,46 @@ export function AgentSkillItem({
|
||||
onRemove: (skillId: string) => void
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
|
||||
const handleRemove = useCallback(() => {
|
||||
onRemove(skill.id)
|
||||
}, [onRemove, skill.id])
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (apiContext.workflow) {
|
||||
const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.skills.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
downloadUrl({ url: result.url, fileName: skill.name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.skills.byName.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
downloadUrl({ url: result.url, fileName: skill.name })
|
||||
}, [apiContext, queryClient, skill.name])
|
||||
const handleOpenPreview = useCallback(() => {
|
||||
setIsPreviewOpen(true)
|
||||
}, [])
|
||||
@@ -53,12 +91,23 @@ export function AgentSkillItem({
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 system-xs-regular text-text-tertiary',
|
||||
!readOnly && 'group-focus-within:opacity-0 group-hover:opacity-0',
|
||||
'group-focus-within:opacity-0 group-hover:opacity-0',
|
||||
)}
|
||||
>
|
||||
{t('agentDetail.configure.skills.itemType')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${tCommon('operation.download')} ${skill.name}`}
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-text-tertiary opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
readOnly ? 'right-1' : 'right-7',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-download-line size-4" />
|
||||
</button>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+55
-2
@@ -4,9 +4,10 @@ import type { AgentConfigSkillFileResponse } from '@dify/contracts/api/console/a
|
||||
import type { AgentConfigApiContext } from '../config-context'
|
||||
import type { AgentSkillDetail } from './detail-dialog'
|
||||
import type { AgentFileNode, AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { downloadBlob, downloadUrl } from '@/utils/download'
|
||||
import { getDriveFileIconType } from '../files/file-icon'
|
||||
|
||||
const isSkillFolder = (file: AgentConfigSkillFileResponse) =>
|
||||
@@ -150,6 +151,7 @@ export function useAgentSkillDetail({
|
||||
isOpen: boolean
|
||||
skill: AgentSkill
|
||||
}): AgentSkillDetail {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedFileId, setSelectedFileId] = useState<string>()
|
||||
const agentSkillInspectQuery = useQuery({
|
||||
...consoleQuery.agent.byAgentId.config.skills.byName.inspect.get.queryOptions({
|
||||
@@ -263,6 +265,56 @@ export function useAgentSkillDetail({
|
||||
enabled: shouldDownloadPreviewFile && !!apiContext.workflow,
|
||||
})
|
||||
const downloadQuery = apiContext.workflow ? workflowDownloadQuery : agentDownloadQuery
|
||||
const handleDownloadFile = useCallback(async () => {
|
||||
if (!selectedFile)
|
||||
return
|
||||
|
||||
const file = selectedFile
|
||||
const path = file.configName ?? file.id
|
||||
const isSkillMdFile = path === inspectQuery.data?.skill_md.path || file.name === 'SKILL.md'
|
||||
|
||||
if (isSkillMdFile && inspectQuery.data?.skill_md.text !== undefined) {
|
||||
downloadBlob({
|
||||
data: new Blob([inspectQuery.data.skill_md.text], { type: 'text/markdown;charset=utf-8' }),
|
||||
fileName: file.name,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (apiContext.workflow) {
|
||||
const result = await queryClient.fetchQuery(consoleQuery.apps.byAppId.agent.config.skills.byName.files.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
app_id: apiContext.workflow.appId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
node_id: apiContext.workflow.nodeId,
|
||||
path,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
downloadUrl({ url: result.url, fileName: file.name })
|
||||
return
|
||||
}
|
||||
|
||||
const result = await queryClient.fetchQuery(consoleQuery.agent.byAgentId.config.skills.byName.files.download.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: apiContext.agentId,
|
||||
name: skill.name,
|
||||
},
|
||||
query: {
|
||||
path,
|
||||
draft_type: apiContext.draftType,
|
||||
version_id: apiContext.versionId,
|
||||
},
|
||||
},
|
||||
}))
|
||||
downloadUrl({ url: result.url, fileName: file.name })
|
||||
}, [apiContext, inspectQuery.data?.skill_md.path, inspectQuery.data?.skill_md.text, queryClient, selectedFile, skill.name])
|
||||
|
||||
return {
|
||||
description,
|
||||
@@ -279,6 +331,7 @@ export function useAgentSkillDetail({
|
||||
isImage: isImagePreviewFile,
|
||||
isLoading: isSkillMdSelected ? inspectQuery.isPending : !!selectedPreviewPath && previewQuery.isPending,
|
||||
},
|
||||
onDownloadFile: handleDownloadFile,
|
||||
onSelectFile: file => setSelectedFileId(file.id),
|
||||
selectedFileId: previewFileId,
|
||||
sections: [],
|
||||
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
import type { AgentProviderToolDefaultValue } from '../types'
|
||||
import { addProviderTools } from '../hooks'
|
||||
|
||||
const noCredentialTool = {
|
||||
provider_id: 'duckduckgo',
|
||||
provider_type: 'builtin',
|
||||
provider_name: 'DuckDuckGo',
|
||||
provider_show_name: 'DuckDuckGo',
|
||||
tool_name: 'ddg_search',
|
||||
tool_label: 'DuckDuckGo Search',
|
||||
tool_description: 'Search the web.',
|
||||
title: 'DuckDuckGo Search',
|
||||
is_team_authorization: true,
|
||||
params: {},
|
||||
paramSchemas: [],
|
||||
allowDelete: false,
|
||||
credentialRequired: false,
|
||||
} satisfies AgentProviderToolDefaultValue
|
||||
|
||||
const unauthorizedCredentialTool = {
|
||||
...noCredentialTool,
|
||||
provider_id: 'google',
|
||||
provider_name: 'google',
|
||||
provider_show_name: 'Google',
|
||||
tool_name: 'search',
|
||||
tool_label: 'Google Search',
|
||||
title: 'Google Search',
|
||||
is_team_authorization: false,
|
||||
credentialRequired: true,
|
||||
} satisfies AgentProviderToolDefaultValue
|
||||
|
||||
const unauthorizedOAuthTool = {
|
||||
...unauthorizedCredentialTool,
|
||||
provider_id: 'slack',
|
||||
provider_name: 'slack',
|
||||
provider_show_name: 'Slack',
|
||||
credentialType: 'oauth2',
|
||||
} satisfies AgentProviderToolDefaultValue
|
||||
|
||||
describe('addProviderTools', () => {
|
||||
it('should not mark tools that do not need credentials as unauthorized', () => {
|
||||
const nextTools = addProviderTools([], [noCredentialTool])
|
||||
|
||||
expect(nextTools).toEqual([
|
||||
expect.objectContaining({
|
||||
credentialId: undefined,
|
||||
credentialType: undefined,
|
||||
credentialVariant: 'none',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should mark credential-required tools without credentials as unauthorized', () => {
|
||||
const nextTools = addProviderTools([], [unauthorizedCredentialTool])
|
||||
|
||||
expect(nextTools).toEqual([
|
||||
expect.objectContaining({
|
||||
credentialId: undefined,
|
||||
credentialType: 'unauthorized',
|
||||
credentialVariant: 'unauthorized',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve oauth credential type for credential-required OAuth tools', () => {
|
||||
const nextTools = addProviderTools([], [unauthorizedOAuthTool])
|
||||
|
||||
expect(nextTools).toEqual([
|
||||
expect.objectContaining({
|
||||
credentialId: undefined,
|
||||
credentialType: 'oauth2',
|
||||
credentialVariant: 'unauthorized',
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
+30
-6
@@ -2,7 +2,7 @@ import type { AddOAuthButtonProps } from '@/app/components/plugins/plugin-auth/t
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -369,21 +369,21 @@ describe('AgentTools', () => {
|
||||
expect(screen.queryByText('Lark CLI')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the add trigger mounted while the tool picker is open', async () => {
|
||||
it('should open the tool picker directly from the add trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentTools()
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.tools.add',
|
||||
}))
|
||||
|
||||
expect(screen.getByText('Mock tool picker')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.cliTool\.label/,
|
||||
})).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {
|
||||
expect(screen.queryByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.tools\.addMenu\.tool\.label/,
|
||||
}))
|
||||
|
||||
expect(screen.getByText('Mock tool picker')).toBeInTheDocument()
|
||||
})).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.tools.add',
|
||||
})).toBeInTheDocument()
|
||||
@@ -508,5 +508,29 @@ describe('AgentTools', () => {
|
||||
expect(screen.getByTestId('tool-setting-form')).toBeInTheDocument()
|
||||
expect(screen.getByText('Search Query')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close provider tool settings when the configured action leaves the draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
toolProviderState.builtInTools = [duckDuckGoProvider]
|
||||
const { store } = renderAgentToolsWithStore(agentToolsDraft)
|
||||
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: 'DuckDuckGo',
|
||||
}))
|
||||
await user.click(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.tools.editAction:{"name":"DuckDuckGo Search"}',
|
||||
}))
|
||||
|
||||
expect(screen.getByTestId('tool-setting-form')).toBeInTheDocument()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...agentToolsDraft,
|
||||
tools: [],
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('tool-setting-form')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+54
-135
@@ -1,11 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentProviderToolDefaultValue, ToolSettingTarget } from './types'
|
||||
import type { ToolSettingTarget } from './types'
|
||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { AgentCliTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { agentComposerToolsAtom, useRemoveProviderTool, useRemoveProviderToolAction } from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import {
|
||||
agentComposerToolsAtom,
|
||||
removeCliToolAtom,
|
||||
removeProviderToolActionAtom,
|
||||
removeProviderToolAtom,
|
||||
saveCliToolAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
|
||||
const toSelectedToolValue = (tool: AgentTool): ToolValue[] => {
|
||||
if (tool.kind !== 'provider')
|
||||
@@ -19,135 +25,22 @@ const toSelectedToolValue = (tool: AgentTool): ToolValue[] => {
|
||||
}))
|
||||
}
|
||||
|
||||
const toProviderToolAction = (tool: AgentProviderToolDefaultValue) => ({
|
||||
id: `${tool.provider_id}:${tool.tool_name}`,
|
||||
name: tool.tool_label || tool.title || tool.tool_name,
|
||||
toolName: tool.tool_name,
|
||||
description: tool.tool_description || '',
|
||||
})
|
||||
export function useSelectedProviderTools() {
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
|
||||
const getCredentialVariant = (tool: AgentProviderToolDefaultValue) => {
|
||||
if (!tool.credentialRequired)
|
||||
return 'none' as const
|
||||
|
||||
if (!tool.allowDelete)
|
||||
return tool.credential_id ? 'authorized' as const : 'unauthorized' as const
|
||||
|
||||
return tool.is_team_authorization ? 'authorized' as const : 'unauthorized' as const
|
||||
return useMemo(() => tools.flatMap(toSelectedToolValue), [tools])
|
||||
}
|
||||
|
||||
const getCredentialType = (tool: AgentProviderToolDefaultValue) => {
|
||||
if (!tool.credentialRequired)
|
||||
return undefined
|
||||
|
||||
if (tool.credentialType === 'oauth2')
|
||||
return 'oauth2' as const
|
||||
|
||||
if (!tool.allowDelete)
|
||||
return tool.credential_id ? 'api-key' as const : 'unauthorized' as const
|
||||
|
||||
return tool.is_team_authorization ? 'api-key' as const : 'unauthorized' as const
|
||||
}
|
||||
|
||||
export const addProviderTools = (
|
||||
currentTools: AgentTool[],
|
||||
selectedTools: AgentProviderToolDefaultValue[],
|
||||
): AgentTool[] => {
|
||||
if (selectedTools.length === 0)
|
||||
return currentTools
|
||||
|
||||
const nextTools = [...currentTools]
|
||||
|
||||
selectedTools.forEach((selectedTool) => {
|
||||
const action = toProviderToolAction(selectedTool)
|
||||
const existingToolIndex = nextTools.findIndex(tool => tool.kind === 'provider' && tool.id === selectedTool.provider_id)
|
||||
const existingTool = nextTools[existingToolIndex]
|
||||
|
||||
if (existingTool?.kind === 'provider') {
|
||||
if (existingTool.actions.some(existingAction => existingAction.toolName === action.toolName))
|
||||
return
|
||||
|
||||
nextTools[existingToolIndex] = {
|
||||
...existingTool,
|
||||
displayName: existingTool.displayName ?? selectedTool.provider_show_name,
|
||||
icon: existingTool.icon ?? selectedTool.provider_icon,
|
||||
iconDark: existingTool.iconDark ?? selectedTool.provider_icon_dark,
|
||||
allowDelete: existingTool.allowDelete ?? selectedTool.allowDelete,
|
||||
actions: [...existingTool.actions, action],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
nextTools.push({
|
||||
id: selectedTool.provider_id,
|
||||
name: selectedTool.provider_name,
|
||||
kind: 'provider',
|
||||
displayName: selectedTool.provider_show_name,
|
||||
iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary',
|
||||
icon: selectedTool.provider_icon,
|
||||
iconDark: selectedTool.provider_icon_dark,
|
||||
providerType: selectedTool.provider_type,
|
||||
allowDelete: selectedTool.allowDelete,
|
||||
credentialId: selectedTool.credential_id,
|
||||
credentialKey: selectedTool.is_team_authorization
|
||||
? 'agentDetail.configure.tools.credential.authOne'
|
||||
: undefined,
|
||||
credentialType: getCredentialType(selectedTool),
|
||||
credentialVariant: getCredentialVariant(selectedTool),
|
||||
actions: [action],
|
||||
})
|
||||
})
|
||||
|
||||
return nextTools
|
||||
}
|
||||
|
||||
export function useAgentToolsOperations() {
|
||||
const [tools, setTools] = useAtom(agentComposerToolsAtom)
|
||||
const removeProviderTool = useRemoveProviderTool()
|
||||
const removeProviderToolAction = useRemoveProviderToolAction()
|
||||
export function useProviderToolSettingsSurface() {
|
||||
const removeProviderTool = useSetAtom(removeProviderToolAtom)
|
||||
const removeProviderToolAction = useSetAtom(removeProviderToolActionAtom)
|
||||
const [settingTarget, setSettingTarget] = useState<ToolSettingTarget | null>(null)
|
||||
const [isCliToolDialogOpen, setIsCliToolDialogOpen] = useState(false)
|
||||
const [editingCliTool, setEditingCliTool] = useState<AgentCliTool | null>(null)
|
||||
|
||||
const addTools = useCallback((selectedTools: AgentProviderToolDefaultValue[]) => {
|
||||
setTools(addProviderTools(tools, selectedTools))
|
||||
}, [setTools, tools])
|
||||
|
||||
const deleteCliTool = useCallback((toolId: string) => {
|
||||
setTools(tools.filter(tool => tool.id !== toolId))
|
||||
}, [setTools, tools])
|
||||
|
||||
const openCliToolDialog = useCallback(() => {
|
||||
setEditingCliTool(null)
|
||||
setIsCliToolDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const editCliTool = useCallback((tool: AgentCliTool) => {
|
||||
setEditingCliTool(tool)
|
||||
setIsCliToolDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCliDialogSave = useCallback((tool: AgentCliTool) => {
|
||||
if (editingCliTool)
|
||||
setTools(tools.map(currentTool => currentTool.id === tool.id ? tool : currentTool))
|
||||
else
|
||||
setTools([...tools, tool])
|
||||
|
||||
setEditingCliTool(null)
|
||||
}, [editingCliTool, setTools, tools])
|
||||
|
||||
const handleCliDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open)
|
||||
setEditingCliTool(null)
|
||||
|
||||
setIsCliToolDialogOpen(open)
|
||||
}, [])
|
||||
|
||||
const closeSettingTargetIfRemoved = useCallback((toolId: string, actionId?: string) => {
|
||||
setSettingTarget((target) => {
|
||||
if (!target || target.tool.id !== toolId)
|
||||
if (!target || target.toolId !== toolId)
|
||||
return target
|
||||
if (actionId && target.action.id !== actionId)
|
||||
if (actionId && target.actionId !== actionId)
|
||||
return target
|
||||
|
||||
return null
|
||||
@@ -161,31 +54,57 @@ export function useAgentToolsOperations() {
|
||||
|
||||
const deleteProviderToolAction = useCallback((toolId: string, actionId: string) => {
|
||||
closeSettingTargetIfRemoved(toolId, actionId)
|
||||
removeProviderToolAction(toolId, actionId)
|
||||
removeProviderToolAction({ toolId, actionId })
|
||||
}, [closeSettingTargetIfRemoved, removeProviderToolAction])
|
||||
|
||||
const closeProviderSettingsDialog = useCallback(() => {
|
||||
setSettingTarget(null)
|
||||
}, [])
|
||||
|
||||
const selectedTools = useMemo(() => tools.flatMap(toSelectedToolValue), [tools])
|
||||
|
||||
return {
|
||||
tools,
|
||||
selectedTools,
|
||||
settingTarget,
|
||||
isCliToolDialogOpen,
|
||||
editingCliTool,
|
||||
setTools,
|
||||
setSettingTarget,
|
||||
addTools,
|
||||
deleteCliTool,
|
||||
deleteProviderTool,
|
||||
deleteProviderToolAction,
|
||||
closeProviderSettingsDialog,
|
||||
}
|
||||
}
|
||||
|
||||
export function useCliToolDialogSurface() {
|
||||
const saveCliTool = useSetAtom(saveCliToolAtom)
|
||||
const removeCliTool = useSetAtom(removeCliToolAtom)
|
||||
const [isCliToolDialogOpen, setIsCliToolDialogOpen] = useState(false)
|
||||
const [editingCliTool, setEditingCliTool] = useState<AgentCliTool | null>(null)
|
||||
|
||||
const openCliToolDialog = useCallback(() => {
|
||||
setEditingCliTool(null)
|
||||
setIsCliToolDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const editCliTool = useCallback((tool: AgentCliTool) => {
|
||||
setEditingCliTool(tool)
|
||||
setIsCliToolDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleCliDialogSave = useCallback((tool: AgentCliTool) => {
|
||||
saveCliTool(tool)
|
||||
setEditingCliTool(null)
|
||||
}, [saveCliTool])
|
||||
|
||||
const handleCliDialogOpenChange = useCallback((open: boolean) => {
|
||||
if (!open)
|
||||
setEditingCliTool(null)
|
||||
|
||||
setIsCliToolDialogOpen(open)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isCliToolDialogOpen,
|
||||
editingCliTool,
|
||||
deleteCliTool: removeCliTool,
|
||||
openCliToolDialog,
|
||||
editCliTool,
|
||||
handleCliDialogSave,
|
||||
handleCliDialogOpenChange,
|
||||
closeProviderSettingsDialog,
|
||||
}
|
||||
}
|
||||
|
||||
+41
-17
@@ -1,18 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
|
||||
import type { AgentProviderToolDefaultValue, ToolSettingTarget } from './types'
|
||||
import type { ToolSettingTarget } from './types'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentCliTool, AgentProviderTool, AgentTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import type { AgentProviderToolDefaultValue } from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { ToolPickerContent } from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { useSetProviderToolCredential } from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import {
|
||||
addProviderToolsAtom,
|
||||
agentComposerToolsAtom,
|
||||
setProviderToolCredentialAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import { ENABLE_AGENT_CLI_TOOLS } from '@/features/agent-v2/agent-detail/configure/feature-flags'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
@@ -28,7 +34,11 @@ import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { CliToolDialog } from './cli-tool/dialog'
|
||||
import { AgentCliToolItem } from './cli-tool/item'
|
||||
import { useAgentToolsOperations } from './hooks'
|
||||
import {
|
||||
useCliToolDialogSurface,
|
||||
useProviderToolSettingsSurface,
|
||||
useSelectedProviderTools,
|
||||
} from './hooks'
|
||||
import { ProviderToolSettingsDialog } from './provider-tool/dialog'
|
||||
import { AgentProviderToolItem } from './provider-tool/item'
|
||||
|
||||
@@ -264,6 +274,12 @@ function AddToolMenuItem({
|
||||
)
|
||||
}
|
||||
|
||||
type AddToolMenuView = 'menu' | 'tool-picker'
|
||||
|
||||
// CLI tools are not available yet, so open the tool picker directly for now.
|
||||
// Switch this back to 'menu' when the CLI tool entry returns.
|
||||
const addToolDefaultView = 'tool-picker' satisfies AddToolMenuView
|
||||
|
||||
function AddToolMenu({
|
||||
onAddCliTool,
|
||||
onAddTools,
|
||||
@@ -275,7 +291,7 @@ function AddToolMenu({
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<'menu' | 'tool-picker'>('menu')
|
||||
const [view, setView] = useState<AddToolMenuView>(addToolDefaultView)
|
||||
const providerById = useAgentToolProviderMap()
|
||||
|
||||
const openToolPicker = useCallback(() => {
|
||||
@@ -291,7 +307,7 @@ function AddToolMenu({
|
||||
setOpen(nextOpen)
|
||||
|
||||
if (nextOpen)
|
||||
setView('menu')
|
||||
setView(addToolDefaultView)
|
||||
}, [])
|
||||
|
||||
const toAgentToolDefaultValue = useCallback((tool: ToolDefaultValue): AgentProviderToolDefaultValue => ({
|
||||
@@ -367,25 +383,30 @@ function AddToolMenu({
|
||||
export function AgentTools() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const setProviderToolCredential = useSetProviderToolCredential()
|
||||
const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom)
|
||||
const providerById = useAgentToolProviderMap()
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
const selectedTools = useSelectedProviderTools()
|
||||
const addTools = useSetAtom(addProviderToolsAtom)
|
||||
const {
|
||||
tools,
|
||||
selectedTools,
|
||||
settingTarget,
|
||||
isCliToolDialogOpen,
|
||||
editingCliTool,
|
||||
setSettingTarget,
|
||||
addTools,
|
||||
deleteCliTool,
|
||||
deleteProviderTool,
|
||||
deleteProviderToolAction,
|
||||
closeProviderSettingsDialog,
|
||||
} = useProviderToolSettingsSurface()
|
||||
const {
|
||||
isCliToolDialogOpen,
|
||||
editingCliTool,
|
||||
deleteCliTool,
|
||||
openCliToolDialog,
|
||||
editCliTool,
|
||||
handleCliDialogSave,
|
||||
handleCliDialogOpenChange,
|
||||
closeProviderSettingsDialog,
|
||||
} = useAgentToolsOperations()
|
||||
} = useCliToolDialogSurface()
|
||||
const handleProviderCredentialChange = useCallback((toolId: string, credentialId?: string, credentialType?: AgentProviderTool['credentialType']) => {
|
||||
setProviderToolCredential({ toolId, credentialId, credentialType })
|
||||
}, [setProviderToolCredential])
|
||||
const visibleTools = useMemo(
|
||||
() => ENABLE_AGENT_CLI_TOOLS ? tools : tools.filter(tool => tool.kind !== 'cli'),
|
||||
[tools],
|
||||
@@ -460,9 +481,12 @@ export function AgentTools() {
|
||||
)
|
||||
const toolsTip = t('agentDetail.configure.tools.tip')
|
||||
const toolsListId = 'agent-configure-tools-list'
|
||||
const settingTargetTool = settingTarget
|
||||
? tools.find(tool => tool.kind === 'provider' && tool.id === settingTarget.toolId)
|
||||
: undefined
|
||||
const settingTargetCollection = settingTarget
|
||||
? providerById.get(settingTarget.tool.id)
|
||||
?? providerById.get(settingTarget.tool.name)
|
||||
? providerById.get(settingTarget.toolId)
|
||||
?? providerById.get(settingTargetTool?.name ?? settingTarget.toolId)
|
||||
: undefined
|
||||
|
||||
return (
|
||||
@@ -501,7 +525,7 @@ export function AgentTools() {
|
||||
onDeleteProviderTool={deleteProviderTool}
|
||||
onDeleteProviderToolAction={deleteProviderToolAction}
|
||||
onEditCliTool={editCliTool}
|
||||
onCredentialChange={setProviderToolCredential}
|
||||
onCredentialChange={handleProviderCredentialChange}
|
||||
/>
|
||||
))}
|
||||
</ConfigureSection>
|
||||
|
||||
+35
-15
@@ -4,11 +4,15 @@ import type { ToolSettingTarget } from '../types'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { AgentProviderTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import SettingBuiltInTool from '@/app/components/app/configuration/config/agent/agent-tools/setting-built-in-tool'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { agentComposerToolSettingsAtom } from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
import {
|
||||
agentComposerToolsAtom,
|
||||
agentComposerToolSettingsAtom,
|
||||
saveProviderToolActionSettingsAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store-modules/tools'
|
||||
|
||||
const localize = (value: string) => ({
|
||||
en_US: value,
|
||||
@@ -51,34 +55,50 @@ export function ProviderToolSettingsDialog({
|
||||
collection?: ToolWithProvider
|
||||
onClose: () => void
|
||||
}) {
|
||||
const [toolSettings, setToolSettings] = useAtom(agentComposerToolSettingsAtom)
|
||||
const toolCollection = useMemo(() => {
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
const toolSettings = useAtomValue(agentComposerToolSettingsAtom)
|
||||
const saveProviderToolActionSettings = useSetAtom(saveProviderToolActionSettingsAtom)
|
||||
const currentTarget = useMemo(() => {
|
||||
if (!settingTarget)
|
||||
return null
|
||||
|
||||
return collection ?? createFallbackToolCollection(settingTarget.tool)
|
||||
}, [collection, settingTarget])
|
||||
const tool = tools.find(tool => tool.kind === 'provider' && tool.id === settingTarget.toolId)
|
||||
if (tool?.kind !== 'provider')
|
||||
return null
|
||||
|
||||
const action = tool.actions.find(action => action.id === settingTarget.actionId)
|
||||
if (!action)
|
||||
return null
|
||||
|
||||
return { action, tool }
|
||||
}, [settingTarget, tools])
|
||||
const toolCollection = useMemo(() => {
|
||||
if (!currentTarget)
|
||||
return null
|
||||
|
||||
return collection ?? createFallbackToolCollection(currentTarget.tool)
|
||||
}, [collection, currentTarget])
|
||||
const handleSave = useCallback((value: Record<string, unknown>) => {
|
||||
if (!settingTarget)
|
||||
if (!currentTarget)
|
||||
return
|
||||
|
||||
setToolSettings({
|
||||
...toolSettings,
|
||||
[settingTarget.action.id]: value,
|
||||
saveProviderToolActionSettings({
|
||||
actionId: currentTarget.action.id,
|
||||
value,
|
||||
})
|
||||
onClose()
|
||||
}, [onClose, setToolSettings, settingTarget, toolSettings])
|
||||
}, [currentTarget, onClose, saveProviderToolActionSettings])
|
||||
|
||||
if (!settingTarget || !toolCollection)
|
||||
if (!currentTarget || !toolCollection)
|
||||
return null
|
||||
|
||||
return (
|
||||
<SettingBuiltInTool
|
||||
toolName={settingTarget.action.toolName}
|
||||
setting={toolSettings[settingTarget.action.id]}
|
||||
toolName={currentTarget.action.toolName}
|
||||
setting={toolSettings[currentTarget.action.id]}
|
||||
collection={toolCollection}
|
||||
isModel={false}
|
||||
credentialId={settingTarget.tool.credentialId}
|
||||
credentialId={currentTarget.tool.credentialId}
|
||||
onSave={handleSave}
|
||||
onHide={onClose}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -186,8 +186,8 @@ const ProviderToolActionItem = memo(({
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const handleConfigureAction = useCallback(() => {
|
||||
onConfigureAction({ action, tool })
|
||||
}, [action, onConfigureAction, tool])
|
||||
onConfigureAction({ actionId: action.id, toolId: tool.id })
|
||||
}, [action.id, onConfigureAction, tool.id])
|
||||
const handleRemoveAction = useCallback(() => {
|
||||
onRemoveAction(action.id)
|
||||
}, [action.id, onRemoveAction])
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type {
|
||||
AgentProviderTool,
|
||||
AgentToolAction,
|
||||
} from '@/features/agent-v2/agent-composer/form-state'
|
||||
|
||||
export type AgentProviderToolDefaultValue = ToolDefaultValue & {
|
||||
allowDelete?: boolean
|
||||
credentialType?: AgentProviderTool['credentialType']
|
||||
credentialRequired?: boolean
|
||||
}
|
||||
import type { AgentProviderTool } from '@/features/agent-v2/agent-composer/form-state'
|
||||
|
||||
export type ToolSettingTarget = {
|
||||
action: AgentToolAction
|
||||
tool: AgentProviderTool
|
||||
actionId: string
|
||||
toolId: AgentProviderTool['id']
|
||||
}
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentWorkingDirectoryPanel } from '../working-directory-panel'
|
||||
|
||||
type QueryOptionsInput = {
|
||||
input: {
|
||||
query?: {
|
||||
path?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
sandboxInfoQueryOptions: vi.fn(),
|
||||
sandboxFilesQueryOptions: vi.fn(),
|
||||
sandboxFileReadQueryOptions: vi.fn(),
|
||||
workflowSandboxFilesQueryOptions: vi.fn(),
|
||||
workflowSandboxFileReadQueryOptions: vi.fn(),
|
||||
downloadBlob: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: mocks.downloadBlob,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
sandbox: {
|
||||
get: {
|
||||
queryOptions: mocks.sandboxInfoQueryOptions,
|
||||
},
|
||||
files: {
|
||||
get: {
|
||||
queryOptions: mocks.sandboxFilesQueryOptions,
|
||||
},
|
||||
read: {
|
||||
get: {
|
||||
queryOptions: mocks.sandboxFileReadQueryOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
apps: {
|
||||
byAppId: {
|
||||
workflowRuns: {
|
||||
byWorkflowRunId: {
|
||||
agentNodes: {
|
||||
byNodeId: {
|
||||
sandbox: {
|
||||
files: {
|
||||
get: {
|
||||
queryOptions: mocks.workflowSandboxFilesQueryOptions,
|
||||
},
|
||||
read: {
|
||||
get: {
|
||||
queryOptions: mocks.workflowSandboxFileReadQueryOptions,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function renderWorkingDirectoryPanel() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentWorkingDirectoryPanel
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
source={{
|
||||
type: 'agent',
|
||||
agentId: 'agent-1',
|
||||
conversationId: 'conversation-1',
|
||||
}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AgentWorkingDirectoryPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.sandboxInfoQueryOptions.mockImplementation(() => ({
|
||||
queryKey: ['sandbox-info'],
|
||||
queryFn: async () => ({
|
||||
workspace_cwd: 'workspace',
|
||||
}),
|
||||
}))
|
||||
mocks.sandboxFilesQueryOptions.mockImplementation(({ input }: QueryOptionsInput) => ({
|
||||
queryKey: ['sandbox-files', input],
|
||||
queryFn: async () => ({
|
||||
path: input.query?.path ?? '~/workspace',
|
||||
entries: [
|
||||
{ name: 'workspace/report.md', type: 'file' },
|
||||
{ name: 'workspace/notes.md', type: 'file' },
|
||||
{ name: 'workspace/chart.png', type: 'file' },
|
||||
],
|
||||
}),
|
||||
}))
|
||||
mocks.sandboxFileReadQueryOptions.mockImplementation(({ input }: QueryOptionsInput) => ({
|
||||
queryKey: ['sandbox-file-read', input],
|
||||
queryFn: async () => ({
|
||||
binary: input.query?.path?.endsWith('chart.png') ?? false,
|
||||
path: input.query?.path ?? '',
|
||||
text: input.query?.path?.endsWith('chart.png') ? null : `Content for ${input.query?.path}`,
|
||||
truncated: false,
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should download the selected working directory file from the preview header action', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkingDirectoryPanel()
|
||||
|
||||
await user.click(await screen.findByText('notes.md'))
|
||||
await user.click(await screen.findByRole('button', {
|
||||
name: /common\.operation\.download.*notes\.md/i,
|
||||
}))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.downloadBlob).toHaveBeenCalledWith({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'notes.md',
|
||||
})
|
||||
})
|
||||
const blob = mocks.downloadBlob.mock.calls[0]?.[0].data as Blob
|
||||
await expect(blob.text()).resolves.toBe('Content for ~/workspace/notes.md')
|
||||
})
|
||||
|
||||
it('should show an unsupported preview download placeholder for binary working directory files', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWorkingDirectoryPanel()
|
||||
|
||||
await user.click(await screen.findByText('chart.png'))
|
||||
|
||||
expect(await screen.findByText('agentV2.agentDetail.configure.files.preview.unsupported')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /common\.operation\.download/i })).toHaveAttribute('href', '#')
|
||||
expect(screen.queryByRole('button', { name: /common\.operation\.download.*chart\.png/i })).not.toBeInTheDocument()
|
||||
expect(mocks.downloadBlob).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentChatRuntimeEmptyStateProps, AgentChatRuntimeProps } from './chat-runtime'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentChatRuntime } from './chat-runtime'
|
||||
|
||||
@@ -26,6 +27,7 @@ function AgentBuildChatEmptyState({
|
||||
inputNode,
|
||||
}: AgentChatRuntimeEmptyStateProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const communityEditionBuildModeTip = t('agentDetail.configure.build.empty.communityEditionTip')
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -42,8 +44,32 @@ function AgentBuildChatEmptyState({
|
||||
</div>
|
||||
<span aria-hidden className="absolute i-ri-hammer-line size-5 text-saas-dify-blue-inverted" />
|
||||
</div>
|
||||
<div className="mt-3 max-w-full truncate system-md-medium text-text-secondary">
|
||||
{t('agentDetail.configure.build.empty.title')}
|
||||
<div className="mt-3 flex max-w-full items-center gap-1.5">
|
||||
<div className="min-w-0 truncate system-md-medium text-text-secondary">
|
||||
{t('agentDetail.configure.build.empty.title')}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
openOnHover
|
||||
delay={300}
|
||||
closeDelay={200}
|
||||
aria-label={communityEditionBuildModeTip}
|
||||
render={(
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-custom-vender-line-alertsAndFeedback-alert-triangle size-4 text-text-warning-secondary" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
placement="top"
|
||||
popupClassName="max-w-[340px] px-3 py-2 system-xs-regular text-text-tertiary"
|
||||
>
|
||||
{communityEditionBuildModeTip}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="mt-1 max-w-full body-md-regular text-text-tertiary">
|
||||
{t('agentDetail.configure.build.empty.description')}
|
||||
|
||||
+37
-2
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulAppFeaturesConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulAppFeaturesConfig, FileTransferMethod, FileType } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { Features } from '@/app/components/base/features/types'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -37,6 +37,41 @@ const defaultFeatureState: Features = {
|
||||
annotationReply: { enabled: false },
|
||||
}
|
||||
|
||||
const agentFileTypes = new Set<string>(['audio', 'custom', 'document', 'image', 'video'])
|
||||
const agentFileTransferMethods = new Set<string>(['datasource_file', 'local_file', 'remote_url', 'tool_file'])
|
||||
|
||||
function isAgentFileType(value: string): value is FileType {
|
||||
return agentFileTypes.has(value)
|
||||
}
|
||||
|
||||
function isAgentFileTransferMethod(value: string): value is FileTransferMethod {
|
||||
return agentFileTransferMethods.has(value)
|
||||
}
|
||||
|
||||
function toAgentFileTransferMethods(values?: readonly string[]): FileTransferMethod[] | undefined {
|
||||
return values?.filter(isAgentFileTransferMethod)
|
||||
}
|
||||
|
||||
function toAgentFileUploadFeatureConfig(file: Features['file']): AgentSoulAppFeaturesConfig['file_upload'] {
|
||||
if (!file)
|
||||
return undefined
|
||||
|
||||
const { allowed_file_types, allowed_file_upload_methods } = file
|
||||
const fileUpload: Record<string, unknown> = { ...file }
|
||||
delete fileUpload.allowed_file_types
|
||||
delete fileUpload.allowed_file_upload_methods
|
||||
|
||||
return {
|
||||
...fileUpload,
|
||||
...(allowed_file_types
|
||||
? { allowed_file_types: allowed_file_types.filter(isAgentFileType) }
|
||||
: {}),
|
||||
...(allowed_file_upload_methods
|
||||
? { allowed_file_upload_methods: toAgentFileTransferMethods(allowed_file_upload_methods) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function toPanelFeatures(appFeatures?: AgentSoulAppFeaturesConfig): Features {
|
||||
return {
|
||||
...defaultFeatureState,
|
||||
@@ -65,7 +100,7 @@ function toAppFeatures(features: Features, appFeatures?: AgentSoulAppFeaturesCon
|
||||
speech_to_text: features.speech2text,
|
||||
retriever_resource: features.citation,
|
||||
sensitive_word_avoidance: features.moderation as AgentSoulAppFeaturesConfig['sensitive_word_avoidance'],
|
||||
file_upload: features.file,
|
||||
file_upload: toAgentFileUploadFeatureConfig(features.file),
|
||||
annotation_reply: features.annotationReply,
|
||||
}
|
||||
}
|
||||
|
||||
+19
-1
@@ -6,9 +6,10 @@ import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-stat
|
||||
import { Dialog } from '@langgenius/dify-ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { skipToken, useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { downloadBlob } from '@/utils/download'
|
||||
import { getFileIconType } from '../orchestrate/files/file-icon'
|
||||
import { AgentSkillDetailDialog } from '../orchestrate/skills/detail-dialog'
|
||||
import { AgentWorkingDirectoryBreadcrumb } from './working-directory-breadcrumb'
|
||||
@@ -431,6 +432,20 @@ export function AgentWorkingDirectoryPanel({
|
||||
retry: false,
|
||||
})
|
||||
const isFileReadLoading = !!selectedWorkingDirectoryFile && fileReadQuery.isPending
|
||||
const { data: fileReadData, refetch: refetchFileRead } = fileReadQuery
|
||||
const handleDownloadFile = useCallback(async () => {
|
||||
if (!selectedWorkingDirectoryFile)
|
||||
return
|
||||
|
||||
const readResult = fileReadData ?? (await refetchFileRead()).data
|
||||
if (readResult?.binary || readResult?.text === undefined || readResult.text === null)
|
||||
return
|
||||
|
||||
downloadBlob({
|
||||
data: new Blob([readResult.text], { type: 'text/plain;charset=utf-8' }),
|
||||
fileName: selectedWorkingDirectoryFile.name,
|
||||
})
|
||||
}, [fileReadData, refetchFileRead, selectedWorkingDirectoryFile])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -485,6 +500,9 @@ export function AgentWorkingDirectoryPanel({
|
||||
isError: fileListQuery.isError || fileReadQuery.isError,
|
||||
isLoading: isFileListLoading || isFileReadLoading,
|
||||
},
|
||||
onDownloadFile: selectedWorkingDirectoryFile && !fileReadQuery.data?.binary
|
||||
? handleDownloadFile
|
||||
: undefined,
|
||||
folderOpenState: ({ file }) => {
|
||||
const queryIndex = loadedFolderPathIndexes.get(file.id)
|
||||
const folderLoaded = queryIndex !== undefined && expandedFolderQueries[queryIndex]?.isSuccess
|
||||
|
||||
@@ -237,6 +237,16 @@ export function useAgentConfigureSync({
|
||||
return
|
||||
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
const configSnapshot = formStateToAgentSoulConfig({
|
||||
baseConfig: baseConfigRef.current,
|
||||
formState: draft,
|
||||
currentModel: currentModelRef.current,
|
||||
})
|
||||
if (!configSnapshot.model?.model_provider || !configSnapshot.model.model) {
|
||||
toast.error(tCommon('modelProvider.selectModel'))
|
||||
return
|
||||
}
|
||||
|
||||
const knowledgeValidation = validateKnowledgeRetrievals(draft.knowledgeRetrievals)
|
||||
if (!knowledgeValidation.isValid) {
|
||||
toast.error(getKnowledgeValidationMessage(knowledgeValidation.firstIssue?.code) ?? tCommon('api.actionFailed'))
|
||||
@@ -247,11 +257,6 @@ export function useAgentConfigureSync({
|
||||
setIsPublishInFlight(true)
|
||||
try {
|
||||
debouncedSaveDraft.cancel?.()
|
||||
const configSnapshot = formStateToAgentSoulConfig({
|
||||
baseConfig: baseConfigRef.current,
|
||||
formState: draft,
|
||||
currentModel: currentModelRef.current,
|
||||
})
|
||||
const saved = await saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
type AgentDetailLayoutProps = {
|
||||
@@ -11,11 +13,14 @@ type AgentDetailLayoutProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404
|
||||
|
||||
export function AgentDetailLayout({
|
||||
agentId,
|
||||
children,
|
||||
}: AgentDetailLayoutProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const router = useRouter()
|
||||
const agentQuery = useQuery(consoleQuery.agent.byAgentId.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
@@ -23,9 +28,18 @@ export function AgentDetailLayout({
|
||||
},
|
||||
},
|
||||
}))
|
||||
const shouldRedirectToRoster = isNotFoundResponse(agentQuery.error)
|
||||
|
||||
useDocumentTitle(agentQuery.data?.name ?? t('agentDetail.documentTitle'))
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectToRoster)
|
||||
router.replace('/roster')
|
||||
}, [router, shouldRedirectToRoster])
|
||||
|
||||
if (shouldRedirectToRoster)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">
|
||||
|
||||
Reference in New Issue
Block a user