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:
yyh
2026-07-06 13:51:33 +00:00
committed by GitHub
co-authored by Joel zyssyz123 autofix-ci[bot] 林玮 盐粒 Yanli
parent bdb3469ca0
commit d0ea5a5e0d
180 changed files with 4929 additions and 1512 deletions
@@ -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()
})
})
@@ -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()
@@ -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()
@@ -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()
@@ -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,
@@ -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>
@@ -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()
})
@@ -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}
@@ -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)}
/>
))}
@@ -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§]')
})
})
})
@@ -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>
)
@@ -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,
}
}
@@ -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) => {
@@ -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 })
@@ -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 })
@@ -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"
@@ -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 (
<>
@@ -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"
@@ -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: [],
@@ -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',
}),
])
})
})
@@ -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()
})
})
})
@@ -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,
}
}
@@ -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>
@@ -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}
/>
@@ -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']
}
@@ -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')}
@@ -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,
}
}
@@ -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">