) => {
- event.preventDefault()
- await handlePublishRequest()
- },
- })
-
- return (
-
-
-
-
-
-
- {t('agentDetail.configure.publishImpact.title', {
- action: actionLabel,
- name: agentName || t('agentDetail.configure.publishImpact.fallbackAgentName'),
- })}
-
-
- {t('agentDetail.configure.publishImpact.descriptionPrefix')}
- {' '}
-
- {t('agentDetail.configure.publishImpact.workflowCount', { count: publishedReferences.length })}
-
- {t('agentDetail.configure.publishImpact.descriptionSuffix')}
-
-
-
-
-
- {t('agentDetail.configure.publishImpact.affectedWorkflows')}
-
-
- {publishedReferences.map(reference => (
-
- ))}
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-function ReferenceLink({
- reference,
-}: {
- reference: AgentReferencingWorkflowResponse
-}) {
- const imageUrl = (reference.app_icon_type === 'image' || reference.app_icon_type === 'link') ? reference.app_icon : undefined
- const iconType = (imageUrl ? 'image' : reference.app_icon_type) as AgentIconType | null | undefined
-
- return (
-
-
-
-
- {reference.app_name}
-
-
- )
-}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx
index c54d4c86d0f..7b08b75080e 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx
@@ -6,13 +6,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
import { useAgentComposerConfigSnapshot } from '@/features/agent-v2/agent-composer/store'
+import { AgentDriveApiContextProvider } from '../../drive-context'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import { AgentSkills } from '../index'
const mocks = vi.hoisted(() => ({
+ driveSkillsQueryOptions: vi.fn(),
+ driveSkillInspectQueryOptions: vi.fn(),
driveFilesQueryOptions: vi.fn(),
driveFileDownloadQueryOptions: vi.fn(),
driveFilePreviewQueryOptions: vi.fn(),
+ deleteSkillMutationOptions: vi.fn(),
uploadSkillMutationOptions: vi.fn(),
}))
@@ -28,6 +32,18 @@ vi.mock('@/service/client', () => ({
agent: {
byAgentId: {
drive: {
+ skills: {
+ get: {
+ queryOptions: mocks.driveSkillsQueryOptions,
+ },
+ bySkillPath: {
+ inspect: {
+ get: {
+ queryOptions: mocks.driveSkillInspectQueryOptions,
+ },
+ },
+ },
+ },
files: {
get: {
queryOptions: mocks.driveFilesQueryOptions,
@@ -45,6 +61,11 @@ vi.mock('@/service/client', () => ({
},
},
skills: {
+ bySlug: {
+ delete: {
+ mutationOptions: mocks.deleteSkillMutationOptions,
+ },
+ },
upload: {
post: {
mutationOptions: mocks.uploadSkillMutationOptions,
@@ -53,26 +74,58 @@ vi.mock('@/service/client', () => ({
},
},
},
+ apps: {
+ byAppId: {
+ agent: {
+ drive: {
+ skills: {
+ get: {
+ queryOptions: mocks.driveSkillsQueryOptions,
+ },
+ bySkillPath: {
+ inspect: {
+ get: {
+ queryOptions: mocks.driveSkillInspectQueryOptions,
+ },
+ },
+ },
+ },
+ files: {
+ get: {
+ queryOptions: mocks.driveFilesQueryOptions,
+ },
+ download: {
+ get: {
+ queryOptions: mocks.driveFileDownloadQueryOptions,
+ },
+ },
+ preview: {
+ get: {
+ queryOptions: mocks.driveFilePreviewQueryOptions,
+ },
+ },
+ },
+ },
+ skills: {
+ bySlug: {
+ delete: {
+ mutationOptions: mocks.deleteSkillMutationOptions,
+ },
+ },
+ upload: {
+ post: {
+ mutationOptions: mocks.uploadSkillMutationOptions,
+ },
+ },
+ },
+ },
+ },
+ },
},
}))
const agentSkillsDraft = {
...defaultAgentSoulConfigFormState,
- skills: [
- {
- id: 'tender-analyzer',
- name: 'Tender Analyzer',
- description: 'Extracts tender requirements and scoring criteria.',
- files: ['__MACOSX/._hatch-pet', 'SKILL.md', 'schema.json'],
- path: 'tender-analyzer',
- skillMdKey: 'tender-analyzer/SKILL.md',
- },
- {
- id: 'meeting-brief',
- name: 'Meeting Brief',
- path: 'meeting-brief',
- },
- ],
} satisfies typeof defaultAgentSoulConfigFormState
function renderAgentSkills() {
@@ -86,9 +139,31 @@ function renderAgentSkills() {
return render(
-
-
-
+
+
+
+
+
+ ,
+ )
+}
+
+function renderWorkflowAgentSkills() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ })
+
+ return render(
+
+
+
+
+
+
,
)
}
@@ -104,11 +179,13 @@ function renderReadonlyAgentSkills() {
return render(
-
-
-
-
-
+
+
+
+
+
+
+
,
)
}
@@ -118,7 +195,7 @@ function ConfigSnapshotProbe() {
return (
- {JSON.stringify(configSnapshot.skills_files?.skills ?? [])}
+ {JSON.stringify(configSnapshot)}
)
}
@@ -126,6 +203,76 @@ function ConfigSnapshotProbe() {
describe('AgentSkills', () => {
beforeEach(() => {
vi.clearAllMocks()
+ const skillItems = [
+ {
+ archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ description: 'Extracts tender requirements and scoring criteria.',
+ name: 'Tender Analyzer',
+ path: 'tender-analyzer',
+ skill_md_key: 'tender-analyzer/SKILL.md',
+ },
+ {
+ description: '',
+ name: 'Meeting Brief',
+ path: 'meeting-brief',
+ skill_md_key: 'meeting-brief/SKILL.md',
+ },
+ ]
+ mocks.driveSkillsQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-skills', input],
+ initialData: { items: skillItems },
+ queryFn: async () => ({
+ items: skillItems,
+ }),
+ }))
+ mocks.driveSkillInspectQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-skill-inspect', input],
+ queryFn: async () => ({
+ archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ description: 'Extracts tender requirements and scoring criteria.',
+ files: [
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/SKILL.md',
+ name: 'SKILL.md',
+ path: 'SKILL.md',
+ type: 'file',
+ },
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/references/guide.md',
+ name: 'guide.md',
+ path: 'references/guide.md',
+ type: 'file',
+ },
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/scripts/extract.py',
+ name: 'extract.py',
+ path: 'scripts/extract.py',
+ type: 'file',
+ },
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ name: '.DIFY-SKILL-FULL.zip',
+ path: '.DIFY-SKILL-FULL.zip',
+ type: 'file',
+ },
+ ],
+ name: 'Tender Analyzer',
+ path: 'tender-analyzer',
+ skill_md: {
+ binary: false,
+ key: 'tender-analyzer/SKILL.md',
+ text: 'Skill markdown content',
+ truncated: false,
+ },
+ skill_md_key: 'tender-analyzer/SKILL.md',
+ source: 'drive',
+ warnings: [],
+ }),
+ }))
mocks.driveFilesQueryOptions.mockImplementation(({ input }) => ({
queryKey: ['agent-drive-files', input],
queryFn: async () => ({
@@ -157,9 +304,12 @@ describe('AgentSkills', () => {
mutationFn: vi.fn(),
mutationKey: ['upload-skill'],
})
+ mocks.deleteSkillMutationOptions.mockReturnValue({
+ mutationFn: vi.fn(),
+ mutationKey: ['delete-skill'],
+ })
})
- // Skill rows load their preview from the agent drive and render the shared detail dialog.
it('should fetch drive files and open the skill detail dialog when the skill row is clicked', async () => {
renderAgentSkills()
@@ -170,81 +320,281 @@ describe('AgentSkills', () => {
const dialog = screen.getByRole('dialog')
expect(dialog).toBeInTheDocument()
- expect(mocks.driveFilesQueryOptions).toHaveBeenCalledWith({
- input: {
- params: {
- agent_id: 'agent-1',
+ await waitFor(() => {
+ expect(mocks.driveSkillInspectQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ agent_id: 'agent-1',
+ skill_path: 'tender-analyzer',
+ },
},
- query: {
- prefix: 'tender-analyzer/',
- },
- },
+ })
})
+ expect(mocks.driveFilesQueryOptions).not.toHaveBeenCalled()
expect(within(dialog).getByText('Tender Analyzer')).toBeInTheDocument()
expect(within(dialog).getByText('Extracts tender requirements and scoring criteria.')).toBeInTheDocument()
- expect(within(dialog).queryByText('__MACOSX/._hatch-pet')).not.toBeInTheDocument()
- expect(await within(dialog).findByText('scripts/extract.py')).toBeInTheDocument()
- expect(within(dialog).getByText('SKILL.md')).toBeInTheDocument()
- expect(await within(dialog).findByText('Preview content for tender-analyzer/SKILL.md')).toBeInTheDocument()
- expect(mocks.driveFilePreviewQueryOptions).toHaveBeenCalledWith({
- input: {
- params: {
- agent_id: 'agent-1',
- },
- query: {
- key: 'tender-analyzer/SKILL.md',
- },
- },
- })
- expect(mocks.driveFilePreviewQueryOptions).not.toHaveBeenCalledWith({
- input: {
- params: {
- agent_id: 'agent-1',
- },
- query: {
- key: 'tender-analyzer/__MACOSX/._hatch-pet',
- },
- },
- })
+ expect(await within(dialog).findByText('references')).toBeInTheDocument()
+ expect(within(dialog).getByText('guide.md')).toBeInTheDocument()
+ expect(within(dialog).getByText('scripts')).toBeInTheDocument()
+ expect(within(dialog).getByText('extract.py')).toBeInTheDocument()
+ expect(within(dialog).queryByText('.DIFY-SKILL-FULL.zip')).not.toBeInTheDocument()
})
- it('should preview the selected skill file from the detail file tree', async () => {
+ it('should keep the detail dialog open after selecting a skill', async () => {
renderAgentSkills()
fireEvent.click(screen.getByRole('button', {
name: 'Tender Analyzer',
}))
- const dialog = screen.getByRole('dialog')
- const scriptFile = await within(dialog).findByRole('button', { name: 'scripts/extract.py' })
- fireEvent.click(scriptFile)
+ expect(screen.getByRole('dialog')).toBeInTheDocument()
+ })
- expect(await within(dialog).findByText('Preview content for tender-analyzer/scripts/extract.py')).toBeInTheDocument()
+ it('should preview selected package file content in the skill detail dialog', async () => {
+ const user = userEvent.setup()
+ renderAgentSkills()
+
+ await user.click(screen.getByRole('button', {
+ name: 'Tender Analyzer',
+ }))
+
+ const dialog = screen.getByRole('dialog')
+ await user.click(await within(dialog).findByText('guide.md'))
+
+ expect(await within(dialog).findByText('Preview content for tender-analyzer/references/guide.md')).toBeInTheDocument()
expect(mocks.driveFilePreviewQueryOptions).toHaveBeenCalledWith({
input: {
params: {
agent_id: 'agent-1',
},
query: {
- key: 'tender-analyzer/scripts/extract.py',
+ key: 'tender-analyzer/references/guide.md',
},
},
})
})
- // The hover/focus remove action updates the composer draft without opening preview.
- it('should remove the skill without opening the detail dialog when the remove action is clicked', () => {
+ it('should not request preview for package files without a drive entry', async () => {
+ const user = userEvent.setup()
+ mocks.driveSkillInspectQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-skill-inspect', input],
+ queryFn: async () => ({
+ archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ description: 'Extracts tender requirements and scoring criteria.',
+ files: [
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/SKILL.md',
+ name: 'SKILL.md',
+ path: 'SKILL.md',
+ type: 'file',
+ },
+ {
+ available_in_drive: false,
+ drive_key: null,
+ name: 'animation-rows.md',
+ path: 'references/animation-rows.md',
+ type: 'file',
+ },
+ ],
+ name: 'Tender Analyzer',
+ path: 'tender-analyzer',
+ skill_md: {
+ binary: false,
+ key: 'tender-analyzer/SKILL.md',
+ text: 'Skill markdown content',
+ truncated: false,
+ },
+ skill_md_key: 'tender-analyzer/SKILL.md',
+ source: 'drive',
+ warnings: [],
+ }),
+ }))
+
+ renderAgentSkills()
+
+ await user.click(screen.getByRole('button', {
+ name: 'Tender Analyzer',
+ }))
+
+ const dialog = screen.getByRole('dialog')
+ await user.click(await within(dialog).findByText('animation-rows.md'))
+
+ expect(mocks.driveFilePreviewQueryOptions).not.toHaveBeenCalledWith({
+ input: {
+ params: {
+ agent_id: 'agent-1',
+ },
+ query: {
+ key: 'tender-analyzer/references/animation-rows.md',
+ },
+ },
+ })
+ expect(within(dialog).getByText('agentV2.agentDetail.configure.files.preview.empty')).toBeInTheDocument()
+ })
+
+ it('should deduplicate package files by path in the skill detail dialog', async () => {
+ const user = userEvent.setup()
+ mocks.driveSkillInspectQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-skill-inspect', input],
+ queryFn: async () => ({
+ archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ description: 'Extracts tender requirements and scoring criteria.',
+ files: [
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/SKILL.md',
+ name: 'SKILL.md',
+ path: 'SKILL.md',
+ type: 'file',
+ },
+ {
+ available_in_drive: false,
+ drive_key: null,
+ name: 'SKILL.md',
+ path: 'tender-analyzer/SKILL.md',
+ type: 'file',
+ },
+ {
+ available_in_drive: true,
+ drive_key: 'tender-analyzer/scripts/extract.py',
+ name: 'extract.py',
+ path: 'scripts/extract.py',
+ type: 'file',
+ },
+ ],
+ name: 'Tender Analyzer',
+ path: 'tender-analyzer',
+ skill_md: {
+ binary: false,
+ key: 'tender-analyzer/SKILL.md',
+ text: 'Skill markdown content',
+ truncated: false,
+ },
+ skill_md_key: 'tender-analyzer/SKILL.md',
+ source: 'drive',
+ warnings: [],
+ }),
+ }))
+
+ renderAgentSkills()
+
+ await user.click(screen.getByRole('button', {
+ name: 'Tender Analyzer',
+ }))
+
+ const dialog = screen.getByRole('dialog')
+
+ expect(await within(dialog).findByText('SKILL.md')).toBeInTheDocument()
+ expect(within(dialog).getAllByText('SKILL.md')).toHaveLength(1)
+ expect(within(dialog).getByText('agentV2.agentDetail.configure.skills.detail.fileCount:{"count":2}')).toBeInTheDocument()
+ })
+
+ it('should use workflow node drive routes for skill list and preview in inline workflow mode', async () => {
+ renderWorkflowAgentSkills()
+
+ expect(mocks.driveSkillsQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ app_id: 'app-1',
+ },
+ query: {
+ node_id: 'node-1',
+ },
+ },
+ })
+
+ fireEvent.click(screen.getByRole('button', {
+ name: 'Tender Analyzer',
+ }))
+
+ await waitFor(() => {
+ expect(mocks.driveSkillInspectQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ app_id: 'app-1',
+ skill_path: 'tender-analyzer',
+ },
+ query: {
+ node_id: 'node-1',
+ },
+ },
+ })
+ })
+ fireEvent.click(await screen.findByText('guide.md'))
+
+ await waitFor(() => {
+ expect(mocks.driveFilePreviewQueryOptions).toHaveBeenCalledWith({
+ input: {
+ params: {
+ app_id: 'app-1',
+ },
+ query: {
+ node_id: 'node-1',
+ key: 'tender-analyzer/references/guide.md',
+ },
+ },
+ })
+ })
+ })
+
+ it('should delete the skill without opening the detail dialog when the remove action is clicked', () => {
renderAgentSkills()
fireEvent.click(screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
}))
- expect(screen.queryByText('Tender Analyzer')).not.toBeInTheDocument()
- expect(screen.getByText('Meeting Brief')).toBeInTheDocument()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
+ it('should route Agent App skill delete through agent and slug identifiers', async () => {
+ const deleteSkill = vi.fn().mockResolvedValue({ result: 'success', removed_keys: ['tender-analyzer/SKILL.md'] })
+ mocks.deleteSkillMutationOptions.mockReturnValue({
+ mutationFn: deleteSkill,
+ mutationKey: ['delete-skill'],
+ })
+ renderAgentSkills()
+
+ fireEvent.click(screen.getByRole('button', {
+ name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
+ }))
+
+ await waitFor(() => {
+ expect(deleteSkill.mock.calls[0]?.[0]).toEqual({
+ params: {
+ agent_id: 'agent-1',
+ slug: 'tender-analyzer',
+ },
+ })
+ })
+ })
+
+ it('should route workflow skill delete through app and node identifiers', async () => {
+ const deleteSkill = vi.fn().mockResolvedValue({ result: 'success', removed_keys: ['tender-analyzer/SKILL.md'] })
+ mocks.deleteSkillMutationOptions.mockReturnValue({
+ mutationFn: deleteSkill,
+ mutationKey: ['delete-skill'],
+ })
+ renderWorkflowAgentSkills()
+
+ fireEvent.click(screen.getByRole('button', {
+ name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
+ }))
+
+ await waitFor(() => {
+ expect(deleteSkill.mock.calls[0]?.[0]).toEqual({
+ params: {
+ app_id: 'app-1',
+ slug: 'tender-analyzer',
+ },
+ query: {
+ node_id: 'node-1',
+ },
+ })
+ })
+ })
+
it('should hide add and remove actions when readonly', () => {
renderReadonlyAgentSkills()
@@ -255,21 +605,49 @@ describe('AgentSkills', () => {
})).not.toBeInTheDocument()
})
- // Upload uses the drive-backed response so the added skill can be reloaded from agent drive paths.
- it('should add an uploaded skill with drive-backed keys when the upload succeeds', async () => {
+ it('should upload a skill through the drive-backed endpoint', async () => {
const user = userEvent.setup()
- const uploadSkill = vi.fn().mockResolvedValue({
- manifest: {
- files: ['SKILL.md', 'scripts/run.py'],
- name: 'Invoice Helper',
+ const driveSkills = [
+ {
+ archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ description: 'Extracts tender requirements and scoring criteria.',
+ name: 'Tender Analyzer',
+ path: 'tender-analyzer',
+ skill_md_key: 'tender-analyzer/SKILL.md',
},
- skill: {
- id: 'skill-hash',
- manifest_files: ['SKILL.md', 'scripts/run.py'],
+ {
+ description: '',
+ name: 'Meeting Brief',
+ path: 'meeting-brief',
+ skill_md_key: 'meeting-brief/SKILL.md',
+ },
+ ]
+ mocks.driveSkillsQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-skills', input],
+ initialData: { items: [...driveSkills] },
+ queryFn: async () => ({ items: [...driveSkills] }),
+ }))
+ const uploadSkill = vi.fn().mockImplementation(async () => {
+ driveSkills.push({
+ archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
+ description: '',
name: 'Invoice Helper',
path: 'invoice-helper',
skill_md_key: 'invoice-helper/SKILL.md',
- },
+ })
+ return {
+ manifest: {
+ files: ['SKILL.md', 'scripts/run.py'],
+ name: 'Invoice Helper',
+ },
+ skill: {
+ manifest_files: ['SKILL.md', 'scripts/run.py'],
+ name: 'Invoice Helper',
+ path: 'invoice-helper',
+ skill_md_key: 'invoice-helper/SKILL.md',
+ archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
+ },
+ }
})
mocks.uploadSkillMutationOptions.mockReturnValue({
mutationFn: uploadSkill,
@@ -296,24 +674,94 @@ describe('AgentSkills', () => {
})
})
expect(await screen.findByRole('button', { name: 'Invoice Helper' })).toBeInTheDocument()
-
- await user.click(screen.getByRole('button', { name: 'Invoice Helper' }))
-
- expect(mocks.driveFilesQueryOptions).toHaveBeenCalledWith({
- input: {
- params: {
- agent_id: 'agent-1',
- },
- query: {
- prefix: 'invoice-helper/',
- },
- },
- })
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('agentV2.agentDetail.configure.skills.upload.success')
})
- // Uploaded drive-backed refs must survive insertion into draft and serialization back to config.
- it('should preserve archive and skill file ids from the upload response in the serialized draft', async () => {
+ it('should refresh the rendered skill list after delete succeeds', async () => {
+ const driveSkills = [
+ {
+ archive_key: 'tender-analyzer/.DIFY-SKILL-FULL.zip',
+ description: 'Extracts tender requirements and scoring criteria.',
+ name: 'Tender Analyzer',
+ path: 'tender-analyzer',
+ skill_md_key: 'tender-analyzer/SKILL.md',
+ },
+ {
+ description: '',
+ name: 'Meeting Brief',
+ path: 'meeting-brief',
+ skill_md_key: 'meeting-brief/SKILL.md',
+ },
+ ]
+ mocks.driveSkillsQueryOptions.mockImplementation(({ input }) => ({
+ queryKey: ['agent-drive-skills', input],
+ initialData: { items: [...driveSkills] },
+ queryFn: async () => ({ items: [...driveSkills] }),
+ }))
+ const deleteSkill = vi.fn().mockImplementation(async () => {
+ driveSkills.splice(0, 1)
+ return { result: 'success', removed_keys: ['tender-analyzer/SKILL.md'] }
+ })
+ mocks.deleteSkillMutationOptions.mockReturnValue({
+ mutationFn: deleteSkill,
+ mutationKey: ['delete-skill'],
+ })
+
+ renderAgentSkills()
+
+ fireEvent.click(screen.getByRole('button', {
+ name: /agentV2\.agentDetail\.configure\.skills\.remove.*Tender Analyzer/,
+ }))
+
+ await waitFor(() => {
+ expect(screen.queryByRole('button', { name: 'Tender Analyzer' })).not.toBeInTheDocument()
+ })
+ expect(screen.getByRole('button', { name: 'Meeting Brief' })).toBeInTheDocument()
+ })
+
+ it('should route workflow skill uploads through app and node identifiers', async () => {
+ const user = userEvent.setup()
+ const uploadSkill = vi.fn().mockResolvedValue({
+ manifest: {
+ files: ['SKILL.md'],
+ name: 'Invoice Helper',
+ },
+ skill: {
+ name: 'Invoice Helper',
+ path: 'invoice-helper',
+ skill_md_key: 'invoice-helper/SKILL.md',
+ },
+ })
+ mocks.uploadSkillMutationOptions.mockReturnValue({
+ mutationFn: uploadSkill,
+ mutationKey: ['upload-skill'],
+ })
+
+ renderWorkflowAgentSkills()
+
+ await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.skills.add' }))
+
+ const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement
+ const file = new File(['skill'], 'invoice-helper.skill', { type: 'application/zip' })
+ await user.upload(fileInput, file)
+ await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.skills.upload.action' }))
+
+ await waitFor(() => {
+ expect(uploadSkill.mock.calls[0]?.[0]).toEqual({
+ params: {
+ app_id: 'app-1',
+ },
+ query: {
+ node_id: 'node-1',
+ },
+ body: {
+ file,
+ },
+ })
+ })
+ })
+
+ it('should not persist skills_files when the draft is serialized', async () => {
const user = userEvent.setup()
const uploadSkill = vi.fn().mockResolvedValue({
manifest: {
@@ -321,14 +769,9 @@ describe('AgentSkills', () => {
name: 'Invoice Helper',
},
skill: {
- file_id: 'archive-upload-file-id',
- full_archive_file_id: 'archive-tool-file-id',
- full_archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
- id: 'skill-hash',
- manifest_files: ['SKILL.md', 'scripts/run.py'],
+ archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
name: 'Invoice Helper',
path: 'invoice-helper',
- skill_md_file_id: 'skill-md-tool-file-id',
skill_md_key: 'invoice-helper/SKILL.md',
},
})
@@ -347,10 +790,12 @@ describe('AgentSkills', () => {
render(
-
-
-
-
+
+
+
+
+
+
,
)
@@ -362,18 +807,8 @@ describe('AgentSkills', () => {
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.skills.upload.action' }))
await waitFor(() => {
- const serializedSkills = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '[]')
- expect(serializedSkills).toEqual(expect.arrayContaining([
- expect.objectContaining({
- file_id: 'archive-upload-file-id',
- full_archive_file_id: 'archive-tool-file-id',
- full_archive_key: 'invoice-helper/.DIFY-SKILL-FULL.zip',
- name: 'Invoice Helper',
- path: 'invoice-helper',
- skill_md_file_id: 'skill-md-tool-file-id',
- skill_md_key: 'invoice-helper/SKILL.md',
- }),
- ]))
+ const serializedConfig = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '{}')
+ expect(serializedConfig).not.toHaveProperty('skills_files')
})
})
})
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx
index c7fd8c3f6ff..409adff5d79 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/detail-dialog.tsx
@@ -200,7 +200,7 @@ function AgentFilePreviewContent({
}
return (
-
+
{content}
)
@@ -217,7 +217,7 @@ export function AgentSkillDetailDialog({
const fileCount = detail.fileCount ?? countAgentFileNodes(detail.files)
return (
-
+
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
index 5ecf687020a..0d4e49eda33 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
@@ -2,47 +2,77 @@
import type { AgentOrchestrateAddActionOptions } from '../add-actions-context'
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
-import { useAtom } from 'jotai'
+import { useMutation } from '@tanstack/react-query'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { agentComposerSkillsAtom, useRemoveSkill } 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'
import { ConfigureSectionEmpty } from '../common/empty'
import { ConfigureSection } from '../common/section'
+import { useAgentDriveApiContext, useAgentDriveSkills } from '../drive-context'
import { AgentSkillItem } from './item'
import { AgentSkillUploadDialog } from './upload-dialog'
-export function AgentSkills({
- agentId,
-}: {
- agentId: string
-}) {
+export function AgentSkills() {
const { t } = useTranslation('agentV2')
- const [skills, setSkills] = useAtom(agentComposerSkillsAtom)
- const removeSkill = useRemoveSkill()
const skillsTip = t('agentDetail.configure.skills.tip')
const skillsListId = 'agent-configure-skills-list'
const [isUploadOpen, setIsUploadOpen] = useState(false)
const promptAddCallbackRef = useRef(undefined)
+ const apiContext = useAgentDriveApiContext()
+ const { query: skillsQuery, skills } = useAgentDriveSkills()
+ const { mutate: deleteAgentSkill } = useMutation(consoleQuery.agent.byAgentId.skills.bySlug.delete.mutationOptions())
+ const { mutate: deleteAppSkill } = useMutation(consoleQuery.apps.byAppId.agent.skills.bySlug.delete.mutationOptions())
+
const handleOpenUpload = useCallback((options?: AgentOrchestrateAddActionOptions) => {
promptAddCallbackRef.current = options?.onAdded
setIsUploadOpen(true)
}, [])
useRegisterAgentOrchestrateAddAction('skills', handleOpenUpload)
+
const handleUploaded = useCallback((skill: AgentSkill) => {
- setSkills(skills.some(currentSkill => currentSkill.id === skill.id)
- ? skills
- : [...skills, skill])
+ void skillsQuery.refetch()
promptAddCallbackRef.current?.(skill)
promptAddCallbackRef.current = undefined
- }, [setSkills, skills])
+ }, [skillsQuery])
+
const handleUploadOpenChange = useCallback((open: boolean) => {
if (!open)
promptAddCallbackRef.current = undefined
setIsUploadOpen(open)
}, [])
+ const handleRemoveSkill = useCallback((skillId: string) => {
+ const skill = skills.find(item => item.id === skillId)
+ const skillSlug = skill?.path ?? skill?.skillMdKey?.split('/', 1)[0]
+ if (!skillSlug)
+ return
+
+ const onSuccess = () => {
+ void skillsQuery.refetch()
+ }
+ if (apiContext.workflow) {
+ deleteAppSkill({
+ params: {
+ app_id: apiContext.workflow.appId,
+ slug: skillSlug,
+ },
+ query: {
+ node_id: apiContext.workflow.nodeId,
+ },
+ }, { onSuccess })
+ return
+ }
+
+ deleteAgentSkill({
+ params: {
+ agent_id: apiContext.agentId,
+ slug: skillSlug,
+ },
+ }, { onSuccess })
+ }, [apiContext, deleteAgentSkill, deleteAppSkill, skills, skillsQuery])
+
return (
<>
)
: skills.map(skill => (
-
+
))}
{
- const skillMdKeySlug = skill.skillMdKey?.split('/', 1)[0]
- return skill.path ?? skillMdKeySlug ?? skill.id
-}
-
-const getSkillFileName = (key: string, skillDrivePath: string) => key.startsWith(`${skillDrivePath}/`)
- ? key.slice(skillDrivePath.length + 1)
- : key
-
-const toSkillFileNode = (item: AgentDriveItemResponse, skillDrivePath: string) => ({
- icon: getDriveFileIconType({
- fileKind: item.file_kind,
- fileName: getSkillFileName(item.key, skillDrivePath),
- mimeType: item.mime_type,
- }),
- id: item.key,
- name: getSkillFileName(item.key, skillDrivePath),
-})
-
-const getSkillMdFileId = (files: AgentFileNode[]): string | undefined => {
- for (const file of files) {
- if (file.icon !== 'folder' && file.name === 'SKILL.md')
- return file.id
-
- const childFileId = file.children ? getSkillMdFileId(file.children) : undefined
- if (childFileId)
- return childFileId
- }
-}
-
-const getFirstSkillFileId = (files: AgentFileNode[]): string | undefined => {
- for (const file of files) {
- if (file.icon !== 'folder')
- return file.id
-
- const childFileId = file.children ? getFirstSkillFileId(file.children) : undefined
- if (childFileId)
- return childFileId
- }
-}
+import { useAgentSkillDetail } from './use-skill-detail'
export function AgentSkillItem({
- agentId,
+ apiContext,
skill,
onRemove,
}: {
- agentId: string
+ apiContext: AgentDriveApiContext
skill: AgentSkill
onRemove: (skillId: string) => void
}) {
const { t } = useTranslation('agentV2')
const readOnly = useAgentOrchestrateReadOnly()
const [isPreviewOpen, setIsPreviewOpen] = useState(false)
- const [selectedFileId, setSelectedFileId] = useState()
const handleRemove = useCallback(() => {
onRemove(skill.id)
}, [onRemove, skill.id])
const handleOpenPreview = useCallback(() => {
- setSelectedFileId(undefined)
setIsPreviewOpen(true)
}, [])
- const skillDrivePath = getSkillDrivePath(skill)
- const driveFilesQuery = useQuery({
- ...consoleQuery.agent.byAgentId.drive.files.get.queryOptions({
- input: {
- params: {
- agent_id: agentId,
- },
- query: {
- prefix: `${skillDrivePath}/`,
- },
- },
- }),
- enabled: isPreviewOpen,
- })
- const detailFiles = driveFilesQuery.isSuccess
- ? (driveFilesQuery.data.items ?? []).map(item => toSkillFileNode(item, skillDrivePath))
- : []
- const previewFileId = selectedFileId
- ?? skill.skillMdKey
- ?? (driveFilesQuery.isSuccess ? getSkillMdFileId(detailFiles) ?? getFirstSkillFileId(detailFiles) : undefined)
- const previewQuery = useQuery({
- ...consoleQuery.agent.byAgentId.drive.files.preview.get.queryOptions({
- input: {
- params: {
- agent_id: agentId,
- },
- query: {
- key: previewFileId ?? '',
- },
- },
- }),
- enabled: isPreviewOpen && !!previewFileId,
- })
- const selectedFile = detailFiles.find(file => file.id === previewFileId)
- const isImagePreviewFile = selectedFile?.icon === 'image'
- const downloadQuery = useQuery({
- ...consoleQuery.agent.byAgentId.drive.files.download.get.queryOptions({
- input: {
- params: {
- agent_id: agentId,
- },
- query: {
- key: previewFileId ?? '',
- },
- },
- }),
- enabled: isPreviewOpen && !!previewFileId && (isImagePreviewFile || !!previewQuery.data?.binary),
+ const detail = useAgentSkillDetail({
+ apiContext,
+ description: skill.description ?? t('agentDetail.configure.skills.tip'),
+ isOpen: isPreviewOpen,
+ skill,
})
return (
@@ -158,24 +71,7 @@ export function AgentSkillItem({
{isPreviewOpen && (
setSelectedFileId(file.id),
- selectedFileId: previewFileId,
- sections: [],
- }}
+ detail={detail}
/>
)}
diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx
index d95bb162b3e..94c66e4b951 100644
--- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx
+++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/upload-dialog.tsx
@@ -1,6 +1,8 @@
'use client'
import type { PostAgentByAgentIdSkillsUploadResponse } from '@dify/contracts/api/console/agent/types.gen'
+import type { PostAppsByAppIdAgentSkillsUploadResponse } from '@dify/contracts/api/console/apps/types.gen'
+import type { AgentDriveApiContext } from '../drive-context'
import type { AgentSkill } from '@/features/agent-v2/agent-composer/form-state'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
@@ -18,25 +20,23 @@ const skillPackageExtensions = ['.zip', '.skill']
const getSkillNameFromFile = (file: File) => file.name.replace(/\.(?:skill|zip)$/iu, '') || file.name
-const toUploadedSkill = (response: PostAgentByAgentIdSkillsUploadResponse, file: File): AgentSkill => {
+const toUploadedSkill = (
+ response: PostAgentByAgentIdSkillsUploadResponse | PostAppsByAppIdAgentSkillsUploadResponse,
+ file: File,
+): AgentSkill => {
const name = response.skill?.name
?? response.manifest?.name
?? getSkillNameFromFile(file)
- const id = response.skill?.id
- ?? response.skill?.skill_md_key
+ const id = response.skill?.skill_md_key
?? response.skill?.path
?? name
return {
- description: response.skill?.description ?? response.manifest?.description,
- files: response.skill?.manifest_files ?? response.manifest?.files,
- fileId: response.skill?.file_id ?? undefined,
- fullArchiveFileId: response.skill?.full_archive_file_id ?? undefined,
- fullArchiveKey: response.skill?.full_archive_key ?? undefined,
+ description: response.skill?.description ?? response.manifest?.description ?? undefined,
+ archiveKey: response.skill?.archive_key ?? undefined,
id,
name,
path: response.skill?.path ?? undefined,
- skillMdFileId: response.skill?.skill_md_file_id ?? undefined,
skillMdKey: response.skill?.skill_md_key ?? undefined,
}
}
@@ -146,12 +146,12 @@ function AgentSkillPackageUploader({
}
export function AgentSkillUploadDialog({
- agentId,
+ apiContext,
onUploaded,
open,
onOpenChange,
}: {
- agentId: string
+ apiContext: AgentDriveApiContext
onUploaded?: (skill: AgentSkill) => void
open: boolean
onOpenChange: (open: boolean) => void
@@ -159,21 +159,16 @@ export function AgentSkillUploadDialog({
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const [file, setFile] = useState()
- const uploadSkillMutation = useMutation(consoleQuery.agent.byAgentId.skills.upload.post.mutationOptions())
+ const uploadAgentSkillMutation = useMutation(consoleQuery.agent.byAgentId.skills.upload.post.mutationOptions())
+ const uploadWorkflowSkillMutation = useMutation(consoleQuery.apps.byAppId.agent.skills.upload.post.mutationOptions())
+ const uploadSkillMutation = apiContext.workflow ? uploadWorkflowSkillMutation : uploadAgentSkillMutation
const handleUpload = () => {
if (!file || uploadSkillMutation.isPending)
return
- uploadSkillMutation.mutate({
- params: {
- agent_id: agentId,
- },
- body: {
- file,
- },
- }, {
- onSuccess: (response) => {
+ const options = {
+ onSuccess: (response: PostAgentByAgentIdSkillsUploadResponse | PostAppsByAppIdAgentSkillsUploadResponse) => {
toast.success(t('agentDetail.configure.skills.upload.success'))
onUploaded?.(toUploadedSkill(response, file))
setFile(undefined)
@@ -182,20 +177,45 @@ export function AgentSkillUploadDialog({
onError: () => {
toast.error(t('agentDetail.configure.skills.upload.failed'))
},
- })
+ }
+
+ if (apiContext.workflow) {
+ uploadWorkflowSkillMutation.mutate({
+ params: {
+ app_id: apiContext.workflow.appId,
+ },
+ query: {
+ node_id: apiContext.workflow.nodeId,
+ },
+ body: {
+ file,
+ },
+ }, options)
+ return
+ }
+
+ uploadAgentSkillMutation.mutate({
+ params: {
+ agent_id: apiContext.agentId,
+ },
+ body: {
+ file,
+ },
+ }, options)
}
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
- uploadSkillMutation.reset()
+ uploadAgentSkillMutation.reset()
+ uploadWorkflowSkillMutation.reset()
setFile(undefined)
}
onOpenChange(nextOpen)
}
return (
-
{}}
+ onSelect={(nextSortValue) => {
+ setPage(1)
+ setSort(parseSortValue(nextSortValue))
+ }}
/>