From 0862641533739e9e31a6e3f98ec31fdcad99102b Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 20 Jul 2026 18:41:26 +0800 Subject: [PATCH] feat: support export agent dsl in sidebar (#39299) --- .../__tests__/navigation.spec.tsx | 43 +++++++++++++++++-- .../agent-v2/agent-detail/sidebar-actions.tsx | 32 ++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx index a6875e96ec4..6f864447879 100644 --- a/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx +++ b/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx @@ -1,14 +1,24 @@ import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { AgentDetailSection, AgentDetailTop } from '../navigation' const mocks = vi.hoisted(() => ({ + downloadBlob: vi.fn(), + exportAppConfig: vi.fn(), pathname: '/agents/agent-1/configure', queryData: undefined as AgentAppDetailWithSite | undefined, })) +vi.mock('@/service/apps', () => ({ + exportAppConfig: mocks.exportAppConfig, +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: mocks.downloadBlob, +})) + vi.mock('@tanstack/react-query', async (importOriginal) => { const actual = await importOriginal() @@ -70,6 +80,7 @@ vi.mock('@/service/client', () => ({ })) const createAgent = (overrides: Partial = {}): AgentAppDetailWithSite => ({ + app_id: 'app-1', description: 'Find and summarize market materials.', enable_api: true, enable_site: true, @@ -96,6 +107,8 @@ function renderAgentDetailSection(expand = true) { describe('AgentDetailSection', () => { beforeEach(() => { + vi.clearAllMocks() + mocks.exportAppConfig.mockResolvedValue({ data: 'kind: app\napp:\n mode: agent\n' }) mocks.pathname = '/agents/agent-1/configure' mocks.queryData = createAgent() }) @@ -131,9 +144,31 @@ describe('AgentDetailSection', () => { await user.click(trigger) - expect(screen.getByRole('menuitem', { name: 'agentV2.roster.editInfo' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'common.operation.duplicate' })).toBeInTheDocument() - expect(screen.getByRole('menuitem', { name: 'common.operation.delete' })).toBeInTheDocument() + expect(screen.getAllByRole('menuitem').map((item) => item.textContent)).toEqual([ + 'agentV2.roster.editInfo', + 'common.operation.duplicate', + 'app.export', + 'common.operation.delete', + ]) + }) + + it('exports the Agent App DSL from the detail action menu', async () => { + const user = userEvent.setup() + renderAgentDetailSection() + + await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) + await user.click(screen.getByRole('menuitem', { name: 'app.export' })) + + await waitFor(() => { + expect(mocks.exportAppConfig).toHaveBeenCalledWith({ + appID: 'app-1', + include: false, + }) + }) + expect(mocks.downloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'Research Agent.yml', + }) }) it('does not render more actions in collapsed sidebar mode', () => { diff --git a/web/features/agent-v2/agent-detail/sidebar-actions.tsx b/web/features/agent-v2/agent-detail/sidebar-actions.tsx index 2eccb188036..2a3dee7e6f4 100644 --- a/web/features/agent-v2/agent-detail/sidebar-actions.tsx +++ b/web/features/agent-v2/agent-detail/sidebar-actions.tsx @@ -8,14 +8,18 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { DeleteAgentDialog } from '@/features/agent-v2/roster/components/delete-agent-dialog' import { DuplicateAgentDialog } from '@/features/agent-v2/roster/components/duplicate-agent-dialog' import { EditAgentDialog } from '@/features/agent-v2/roster/components/edit-agent-dialog' +import { exportAppConfig } from '@/service/apps' +import { downloadBlob } from '@/utils/download' type AgentDetailSidebarActionAgent = Pick< AgentAppPartial, + | 'app_id' | 'description' | 'icon' | 'icon_background' @@ -30,6 +34,7 @@ type AgentDetailSidebarActionAgent = Pick< export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebarActionAgent }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') + const { t: tApp } = useTranslation('app') const [isEditOpen, setIsEditOpen] = useState(false) const [editSessionKey, setEditSessionKey] = useState(0) const [isDuplicateOpen, setIsDuplicateOpen] = useState(false) @@ -57,6 +62,26 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar setIsDuplicateOpen(true) } + const handleExport = async () => { + if (!agent.app_id) { + toast.error(tApp(($) => $.exportFailed)) + return + } + + try { + const { data } = await exportAppConfig({ + appID: agent.app_id, + include: false, + }) + downloadBlob({ + data: new Blob([data], { type: 'application/yaml' }), + fileName: `${agent.name}.yml`, + }) + } catch { + toast.error(tApp(($) => $.exportFailed)) + } + } + return ( <> @@ -75,6 +100,13 @@ export function AgentDetailSidebarActions({ agent }: { agent: AgentDetailSidebar {tCommon(($) => $['operation.duplicate'])} + + + {tApp(($) => $.export)} +