+ {!file.isMissing && (
+
+ )}
{canRemoveFile && (
+ )}
{!readOnly && (
$['agentDetail.configure.skills.remove'], { name: skill.name })}
onClick={handleRemove}
- className="pointer-events-none absolute top-1/2 right-1 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-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"
+ 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-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',
+ skill.isMissing ? 'right-7' : 'right-1',
+ )}
>
diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx
index c28d475643a..f5f6a7fe237 100644
--- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx
+++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx
@@ -9,6 +9,16 @@ import { AgentRosterList } from '../agent-roster-list'
const { duplicateAgentMutationFn } = vi.hoisted(() => ({
duplicateAgentMutationFn: vi.fn(),
}))
+const exportAppConfigMock = vi.hoisted(() => vi.fn())
+const downloadBlobMock = vi.hoisted(() => vi.fn())
+
+vi.mock('@/service/apps', () => ({
+ exportAppConfig: exportAppConfigMock,
+}))
+
+vi.mock('@/utils/download', () => ({
+ downloadBlob: downloadBlobMock,
+}))
vi.mock('@/hooks/use-timestamp', () => ({
default: () => ({
@@ -50,6 +60,7 @@ vi.mock('@/service/client', () => ({
const createAgent = (overrides: Partial
= {}): AgentAppPartial => ({
active_config_is_published: false,
+ app_id: 'app-1',
description: 'Find and summarize market materials.',
id: 'agent-1',
icon: '🧸',
@@ -104,6 +115,7 @@ describe('AgentRosterList', () => {
name: 'Research Agent copy',
}),
)
+ exportAppConfigMock.mockResolvedValue({ data: 'kind: app\napp:\n mode: agent\n' })
})
afterEach(() => {
@@ -254,6 +266,27 @@ describe('AgentRosterList', () => {
expect(duplicateAgentMutationFn).not.toHaveBeenCalled()
})
+ it('exports the Agent App DSL with the backing App id', async () => {
+ const user = userEvent.setup()
+ renderList([createAgent()])
+
+ await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ }))
+ await user.click(screen.getByRole('menuitem', { name: 'app.export' }))
+
+ await waitFor(() => {
+ expect(exportAppConfigMock).toHaveBeenCalledWith({
+ appID: 'app-1',
+ include: false,
+ })
+ })
+ expect(downloadBlobMock).toHaveBeenCalledWith({
+ data: expect.any(Blob),
+ fileName: 'Research Agent.yml',
+ })
+ const [{ data }] = downloadBlobMock.mock.calls[0] as [{ data: Blob }]
+ expect(await data.text()).toBe('kind: app\napp:\n mode: agent\n')
+ })
+
it('uses the latest cached agent detail when opening the duplicate dialog', async () => {
const user = userEvent.setup()
const { queryClient } = renderList([
diff --git a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx
index b3366f6bc65..628b2a30547 100644
--- a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx
+++ b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx
@@ -4,6 +4,15 @@ import userEvent from '@testing-library/user-event'
import { renderWithNuqs } from '@/test/nuqs-testing'
import { RosterToolbar } from '../roster-toolbar'
+vi.mock('@/app/components/app/create-from-dsl-modal', () => ({
+ default: ({ show, onSuccess }: { show: boolean; onSuccess?: () => void }) =>
+ show ? (
+
+ Complete agent import
+
+ ) : null,
+}))
+
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
@@ -17,15 +26,38 @@ const renderToolbar = ({
} = {}) => {
const queryClient = new QueryClient()
- return renderWithNuqs(
+ const result = renderWithNuqs(
,
{ searchParams },
)
+
+ return { ...result, queryClient }
}
describe('RosterToolbar', () => {
+ it('opens the shared create menu for blank Agent creation and DSL import', async () => {
+ const user = userEvent.setup()
+ const { queryClient } = renderToolbar()
+ const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
+
+ await user.click(screen.getByRole('button', { name: 'common.operation.create' }))
+
+ expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank' })).toBeInTheDocument()
+ expect(screen.getByRole('menuitem', { name: /app\.importDSL/ })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('menuitem', { name: /app\.importDSL/ }))
+
+ expect(
+ await screen.findByRole('dialog', { name: 'agentV2.roster.importDSL' }),
+ ).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Complete agent import' }))
+
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
+ })
+
it('enables roster filters and emits the selected filter', async () => {
const user = userEvent.setup()
const { onUrlUpdate } = renderToolbar()
diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx
index 94641ca2e79..7915bb98b0f 100644
--- a/web/features/agent-v2/roster/components/agent-roster-list.tsx
+++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx
@@ -9,12 +9,15 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
+import { toast } from '@langgenius/dify-ui/toast'
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { SkeletonRectangle } from '@/app/components/base/skeleton'
import useTimestamp from '@/hooks/use-timestamp'
import Link from '@/next/link'
+import { exportAppConfig } from '@/service/apps'
+import { downloadBlob } from '@/utils/download'
import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown'
import { DeleteAgentDialog } from './delete-agent-dialog'
import { DuplicateAgentDialog } from './duplicate-agent-dialog'
@@ -103,6 +106,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) {
function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
+ const { t: tApp } = useTranslation('app')
const { formatTime } = useTimestamp()
const nameId = useId()
const descriptionId = useId()
@@ -136,6 +140,26 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
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 (
@@ -228,6 +252,10 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
/>
{tCommon(($) => $['operation.duplicate'])}
+
+
+ {tApp(($) => $.export)}
+
void
+}
+
+export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) {
const { t } = useTranslation('agentV2')
const { t: tCommon } = useTranslation('common')
const router = useRouter()
- const [open, setOpen] = useState(false)
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(false)
const [formKey, setFormKey] = useState(0)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
@@ -46,7 +51,8 @@ export function CreateAgentDialog() {
}
const handleOpenChange = (nextOpen: boolean) => {
- setOpen(nextOpen)
+ if (open === undefined) setUncontrolledOpen(nextOpen)
+ onOpenChange?.(nextOpen)
if (!nextOpen) resetForm()
}
@@ -80,11 +86,17 @@ export function CreateAgentDialog() {
return (
<>
-