diff --git a/web/features/agent-v2/__tests__/agent-detail-page.spec.tsx b/web/features/agent-v2/__tests__/agent-detail-page.spec.tsx deleted file mode 100644 index 53dbf5214d5..00000000000 --- a/web/features/agent-v2/__tests__/agent-detail-page.spec.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { AgentDetailPage } from '../pages/agent-detail-page' - -describe('AgentDetailPage', () => { - it('renders configurable memory settings', () => { - render() - - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.configure' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'agentV2.agentDetail.memorySettings.title' })).toBeInTheDocument() - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.memorySettings\.strategies\.medium\.label/ })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.memorySettings\.isolation\.perApp\.label/ })).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.memorySettings\.export\.download/ })).toBeInTheDocument() - }) - - it('switches memory strategy and isolation choices', () => { - render() - - const economyStrategy = screen.getByRole('button', { - name: /agentV2\.agentDetail\.memorySettings\.strategies\.economy\.label/, - }) - const perRunIsolation = screen.getByRole('button', { - name: /agentV2\.agentDetail\.memorySettings\.isolation\.perRun\.label/, - }) - - fireEvent.click(economyStrategy) - fireEvent.click(perRunIsolation) - - expect(economyStrategy).toHaveAttribute('aria-pressed', 'true') - expect(perRunIsolation).toHaveAttribute('aria-pressed', 'true') - expect(screen.getByRole('button', { - name: /agentV2\.agentDetail\.memorySettings\.strategies\.medium\.label/, - })).toHaveAttribute('aria-pressed', 'false') - expect(screen.getByRole('button', { - name: /agentV2\.agentDetail\.memorySettings\.isolation\.perApp\.label/, - })).toHaveAttribute('aria-pressed', 'false') - }) - - it('renders the logs skeleton with filters and table rows', () => { - render() - - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.logs' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'agentV2.agentDetail.logs.title' })).toBeInTheDocument() - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.logs.filters.status.label', - })).toHaveTextContent('agentV2.agentDetail.logs.filters.status.all') - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.logs.filters.period.label', - })).toHaveTextContent('agentV2.agentDetail.logs.filters.period.last7days') - expect(screen.getByRole('textbox', { - name: 'agentV2.agentDetail.logs.filters.search.label', - })).toBeInTheDocument() - expect(screen.getByRole('columnheader', { name: 'agentV2.agentDetail.logs.table.startTime' })).toBeInTheDocument() - expect(screen.getByText('run_8f4e21')).toBeInTheDocument() - expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument() - }) - - it('renders the monitoring layout with metric cards', () => { - render() - - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.monitoring' })).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.totalRuns.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.activeUsers.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.tokenUsage.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.avgInteractions.title')).toBeInTheDocument() - - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.monitoring.timeRangeLabel', - })).toHaveTextContent('agentV2.agentDetail.monitoring.timeRanges.last7days') - expect(screen.getAllByLabelText(/agentV2\.agentDetail\.monitoring\.metrics\..*\.explanation/)).toHaveLength(4) - }) -}) diff --git a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx deleted file mode 100644 index cccd36fd2c1..00000000000 --- a/web/features/agent-v2/agent-detail/__tests__/layout.spec.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { toast } from '@langgenius/dify-ui/toast' -import { fireEvent, render, screen } from '@testing-library/react' -import { AgentDetailLayout } from '../layout' - -vi.mock('@langgenius/dify-ui/toast', () => ({ - toast: { - success: vi.fn(), - }, -})) - -vi.mock('@/hooks/use-document-title', () => ({ - default: vi.fn(), -})) - -describe('AgentDetailLayout', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('renders the detail shell without feature data', () => { - render( - -
- , - ) - - expect(screen.getByRole('heading', { name: 'agentV2.agentDetail.title' })).toBeInTheDocument() - expect(screen.getByText(/agentV2\.agentDetail\.subtitle/)).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'agentV2.agentDetail.publish' })).toBeEnabled() - expect(screen.getByLabelText('content')).toBeInTheDocument() - }) - - it('shows publish menu actions from the publish button', async () => { - render( - -
- , - ) - - fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.publish' })) - - expect(await screen.findByText('agentV2.agentDetail.publishMenu.publishUpdate')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.publishMenu.publishUpdateDescription')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.publishMenu.saveAsNewAgent')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.publishMenu.saveAsNewAgentDescription')).toBeInTheDocument() - }) - - it('shows success toast when publish menu actions are clicked', async () => { - render( - -
- , - ) - - fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.publish' })) - fireEvent.click(await screen.findByText('agentV2.agentDetail.publishMenu.publishUpdate')) - - expect(toast.success).toHaveBeenCalledWith('common.api.success') - - fireEvent.click(screen.getByRole('button', { name: 'agentV2.agentDetail.publish' })) - fireEvent.click(await screen.findByText('agentV2.agentDetail.publishMenu.saveAsNewAgent')) - - expect(toast.success).toHaveBeenCalledTimes(2) - expect(toast.success).toHaveBeenLastCalledWith('common.api.success') - }) - - it('opens mock version history from the history button', () => { - render( - -
- , - ) - - fireEvent.click(screen.getByRole('button', { name: 'workflow.common.versionHistory' })) - - expect(screen.getByText('workflow.versionHistory.title')).toBeInTheDocument() - expect(screen.getByText('workflow.versionHistory.currentDraft')).toBeInTheDocument() - expect(screen.getByText('v1.4.0 Handoff rules')).toBeInTheDocument() - expect(screen.getByText('Aligned escalation handoff rules and response boundaries.')).toBeInTheDocument() - expect(screen.getByText('workflow.versionHistory.latest')).toBeInTheDocument() - - fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) - - expect(screen.queryByText('workflow.versionHistory.title')).not.toBeInTheDocument() - }) -}) diff --git a/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx deleted file mode 100644 index 1bdd1070329..00000000000 --- a/web/features/agent-v2/agent-detail/__tests__/navigation.spec.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { ReactNode } from 'react' -import type { Mock } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' -import { createStore, Provider as JotaiProvider } from 'jotai' -import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms' -import { usePathname, useRouter } from '@/next/navigation' -import { AgentDetailSection, AgentDetailTop } from '../navigation' - -vi.mock('@/next/navigation', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - usePathname: vi.fn(), - useRouter: vi.fn(), - } -}) - -function GotoAnythingOpenProbe() { - const open = useGotoAnythingOpen() - - return
{String(open)}
-} - -const renderWithGotoAnythingStore = (ui: ReactNode) => { - const store = createStore() - - return render( - - {ui} - , - ) -} - -describe('Agent detail navigation', () => { - beforeEach(() => { - vi.clearAllMocks() - ;(useRouter as Mock).mockReturnValue({ back: vi.fn() }) - }) - - it('renders roster breadcrumb controls', () => { - renderWithGotoAnythingStore() - - expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/') - expect(screen.getByRole('link', { name: 'common.menus.roster' })).toHaveAttribute('href', '/roster') - expect(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' })).toBeInTheDocument() - }) - - it('opens goto anything through atom state', () => { - renderWithGotoAnythingStore( - <> - - - , - ) - - expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false') - - fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' })) - - expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true') - }) - - it('renders agent detail tabs from the route agent id', () => { - ;(usePathname as Mock).mockReturnValue('/roster/agent-1/logs') - - render() - - expect(screen.getByRole('navigation', { name: 'agentV2.agentDetail.navigationLabel' })).toBeInTheDocument() - expect(screen.getByRole('link', { name: 'agentV2.agentDetail.sections.configure' })).toHaveAttribute('href', '/roster/agent-1/configure') - expect(screen.getByRole('link', { name: 'agentV2.agentDetail.sections.access' })).toHaveAttribute('href', '/roster/agent-1/access') - expect(screen.getByRole('link', { name: 'agentV2.agentDetail.sections.logs' })).toHaveAttribute('href', '/roster/agent-1/logs') - expect(screen.queryByRole('link', { name: 'agentV2.agentDetail.sections.annotation' })).not.toBeInTheDocument() - expect(screen.getByRole('link', { name: 'agentV2.agentDetail.sections.monitoring' })).toHaveAttribute('href', '/roster/agent-1/monitoring') - }) -}) diff --git a/web/features/agent-v2/agent-detail/__tests__/page.spec.tsx b/web/features/agent-v2/agent-detail/__tests__/page.spec.tsx deleted file mode 100644 index a0691123a72..00000000000 --- a/web/features/agent-v2/agent-detail/__tests__/page.spec.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import type { Mock } from 'vitest' -import { fireEvent, render, screen } from '@testing-library/react' -import { usePathname, useRouter } from '@/next/navigation' -import { AgentDetailPage } from '../page' - -vi.mock('echarts-for-react', () => ({ - default: () =>
, -})) - -vi.mock('@/next/navigation', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - usePathname: vi.fn(), - useRouter: vi.fn(), - } -}) - -describe('AgentDetailPage', () => { - const push = vi.fn() - - beforeEach(() => { - vi.clearAllMocks() - ;(usePathname as Mock).mockReturnValue('/roster/agent-1/access') - ;(useRouter as Mock).mockReturnValue({ push }) - }) - - it('renders the logs skeleton with filters and table rows', () => { - render() - - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.logs' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'agentV2.agentDetail.logs.title' })).toBeInTheDocument() - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.logs.filters.status.label', - })).toHaveTextContent('agentV2.agentDetail.logs.filters.status.all') - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.logs.filters.period.label', - })).toHaveTextContent('agentV2.agentDetail.logs.filters.period.last7days') - expect(screen.getByRole('textbox', { - name: 'agentV2.agentDetail.logs.filters.search.label', - })).toBeInTheDocument() - expect(screen.getByRole('columnheader', { name: 'agentV2.agentDetail.logs.table.startTime' })).toBeInTheDocument() - expect(screen.getByText('run_8f4e21')).toBeInTheDocument() - expect(screen.getByRole('navigation', { name: 'Pagination' })).toBeInTheDocument() - }) - - it('renders the monitoring layout with metric cards', () => { - render() - - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.monitoring' })).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.title')).toBeInTheDocument() - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.monitoring.timeRangeLabel', - })).toHaveTextContent('agentV2.agentDetail.monitoring.timeRanges.today') - expect(screen.getByRole('combobox', { - name: 'agentV2.agentDetail.monitoring.sourceLabel', - })).toHaveTextContent('agentV2.agentDetail.access.entries.webapp.name') - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.totalConversations.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.activeUsers.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.avgSessionInteractions.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.tokenOutputSpeed.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.userSatisfactionRate.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.tokenUsage.title')).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.monitoring.metrics.totalMessages.title')).toBeInTheDocument() - expect(screen.getAllByLabelText(/agentV2\.agentDetail\.monitoring\.metrics\..*\.explanation/)).toHaveLength(7) - expect(screen.getAllByTestId('agent-monitoring-chart')).toHaveLength(7) - }) - - it('routes access actions to the target tab', async () => { - render() - - expect(screen.getByRole('region', { name: 'agentV2.agentDetail.sections.access' })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'agentV2.agentDetail.access.title' })).toBeInTheDocument() - expect(screen.getByText('agentV2.agentDetail.access.entries.webapp.name')).toBeInTheDocument() - - fireEvent.click(screen.getAllByRole('button', { - name: /agentV2\.agentDetail\.access\.moreActions/, - })[0]!) - fireEvent.click(await screen.findByText('agentV2.agentDetail.sections.logs')) - - expect(push).toHaveBeenCalledWith('/roster/agent-1/logs') - }) -}) diff --git a/web/features/agent-v2/roster/__tests__/page.spec.tsx b/web/features/agent-v2/roster/__tests__/page.spec.tsx deleted file mode 100644 index 0437c25881c..00000000000 --- a/web/features/agent-v2/roster/__tests__/page.spec.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { render, screen } from '@testing-library/react' -import RosterPage from '../page' - -vi.mock('@/hooks/use-document-title', () => ({ - default: vi.fn(), -})) - -describe('RosterPage', () => { - it('renders the agent roster shell without fetching feature data', () => { - render() - - expect(screen.getByRole('heading', { name: 'common.menus.roster', level: 1 })).toBeInTheDocument() - expect(screen.getByRole('heading', { name: 'agentV2.roster.title', level: 2 })).toBeInTheDocument() - expect(screen.getByRole('navigation', { name: 'agentV2.roster.sidebarLabel' })).toBeInTheDocument() - expect(screen.getByPlaceholderText('agentV2.roster.searchPlaceholder')).toBeInTheDocument() - expect(screen.getByText('Iris - Clarification Drafter')).toBeInTheDocument() - expect(screen.getByText('Aiko - Document Translator')).toBeInTheDocument() - expect(screen.getAllByRole('link', { name: /agentV2\.roster\.editAgent/ })[0]).toHaveAttribute('href', '/roster/iris/configure') - }) -}) diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx new file mode 100644 index 00000000000..827c6818ecb --- /dev/null +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -0,0 +1,163 @@ +'use client' + +import type { AgentRosterResponse } from '@dify/contracts/api/console/agents/types.gen' +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { useTranslation } from 'react-i18next' +import Link from '@/next/link' +import { ArchiveAgentButton } from './archive-agent-button' +import { EditAgentDialog } from './edit-agent-dialog' + +type AgentRosterListProps = { + agents: AgentRosterResponse[] + hasMore: boolean + isEmptySearch: boolean + isError: boolean + isFetching: boolean + isFetchingNextPage: boolean + isPending: boolean + onLoadMore: () => void +} + +const getSourceLabelKey = (source: AgentRosterResponse['source']) => `roster.sources.${source}` as const + +const skeletonRows = ['primary', 'secondary', 'tertiary'] as const + +function AgentRosterSkeleton() { + return ( + <> + {skeletonRows.map(row => ( +
+
+
+
+
+
+
+
+ ))} + + ) +} + +function AgentRosterEmptyState({ isSearch }: { isSearch: boolean }) { + const { t } = useTranslation('agentV2') + + return ( +
+
+ +
+

+ {isSearch ? t('roster.emptySearch') : t('roster.empty')} +

+

+ {isSearch ? t('roster.emptySearchDescription') : t('roster.emptyDescription')} +

+
+ ) +} + +function AgentRosterItem({ + agent, +}: { + agent: AgentRosterResponse +}) { + const { t: tAgentV2 } = useTranslation('agentV2') + const version = agent.active_config_snapshot?.version + + return ( +
+ +
+ {agent.icon_type === 'emoji' && agent.icon + ? {agent.icon} + : } +
+
+
+

+ {agent.name} +

+ {version != null && ( + + {version} + + )} +
+ {agent.description && ( +

+ {agent.description} +

+ )} +
+
+ + + {tAgentV2(getSourceLabelKey(agent.source))} + +
+
+ + {tAgentV2(`roster.status.${agent.status}`)} + + {agent.updated_at &&
{agent.updated_at}
} +
+ +
+ + +
+
+ ) +} + +export function AgentRosterList({ + agents, + hasMore, + isEmptySearch, + isError, + isFetching, + isFetchingNextPage, + isPending, + onLoadMore, +}: AgentRosterListProps) { + const { t } = useTranslation('agentV2') + + return ( +
+ {isPending && } + {!isPending && isError && ( +
+

{t('roster.loadingError')}

+
+ )} + {!isPending && !isError && agents.length === 0 && ( + + )} + {!isPending && !isError && agents.map(agent => ( + + ))} + {!isPending && !isError && hasMore && ( +
+ +
+ )} +
+ ) +} diff --git a/web/features/agent-v2/roster/components/archive-agent-button.tsx b/web/features/agent-v2/roster/components/archive-agent-button.tsx new file mode 100644 index 00000000000..18ce1560581 --- /dev/null +++ b/web/features/agent-v2/roster/components/archive-agent-button.tsx @@ -0,0 +1,92 @@ +'use client' + +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, + AlertDialogTrigger, +} from '@langgenius/dify-ui/alert-dialog' +import { Button } from '@langgenius/dify-ui/button' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' + +type ArchiveAgentButtonProps = { + agentId: string + agentName: string +} + +export function ArchiveAgentButton({ + agentId, + agentName, +}: ArchiveAgentButtonProps) { + const { t } = useTranslation() + const { t: tAgentV2 } = useTranslation('agentV2') + const [open, setOpen] = useState(false) + const archiveAgentMutation = useMutation(consoleQuery.agents.byAgentId.delete.mutationOptions()) + + const handleArchive = () => { + if (archiveAgentMutation.isPending) + return + + archiveAgentMutation.mutate({ + params: { + agent_id: agentId, + }, + }, { + onSuccess: () => { + toast.success(tAgentV2('roster.archiveSuccess')) + setOpen(false) + }, + onError: () => { + toast.error(tAgentV2('roster.archiveFailed')) + }, + }) + } + + return ( + + + )} + > + + {tAgentV2('roster.archive')} + + +
+ + {tAgentV2('roster.archiveDialog.title', { name: agentName })} + + + {tAgentV2('roster.archiveDialog.description')} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('operation.confirm', { ns: 'common' })} + + +
+
+ ) +} diff --git a/web/features/agent-v2/roster/components/create-agent-dialog.tsx b/web/features/agent-v2/roster/components/create-agent-dialog.tsx new file mode 100644 index 00000000000..5c89326081d --- /dev/null +++ b/web/features/agent-v2/roster/components/create-agent-dialog.tsx @@ -0,0 +1,103 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from '@langgenius/dify-ui/dialog' +import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' +import { Form } from '@langgenius/dify-ui/form' +import { Textarea } from '@langgenius/dify-ui/textarea' +import { toast } from '@langgenius/dify-ui/toast' +import { useMutation } from '@tanstack/react-query' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { consoleQuery } from '@/service/client' + +type AgentFormValues = { + description?: string + name?: string +} + +export function CreateAgentDialog() { + const { t } = useTranslation() + const { t: tAgentV2 } = useTranslation('agentV2') + const [open, setOpen] = useState(false) + const createAgentMutation = useMutation(consoleQuery.agents.post.mutationOptions()) + + const handleSubmit = (formValues: AgentFormValues) => { + const trimmedName = formValues.name?.trim() ?? '' + if (!trimmedName || createAgentMutation.isPending) + return + + createAgentMutation.mutate({ + body: { + name: trimmedName, + description: formValues.description?.trim() ?? '', + }, + }, { + onSuccess: () => { + toast.success(tAgentV2('roster.createSuccess')) + setOpen(false) + }, + onError: () => { + toast.error(tAgentV2('roster.createFailed')) + }, + }) + } + + return ( + + }> + + {tAgentV2('roster.createAgent')} + + + + + {tAgentV2('roster.createDialog.title')} + + + {tAgentV2('roster.createDialog.description')} + + + className="mt-5 space-y-4" + onFormSubmit={handleSubmit} + > + + + {tAgentV2('roster.createForm.nameLabel')} + + + + {tAgentV2('roster.createForm.nameRequired')} + + + + + {tAgentV2('roster.createForm.descriptionLabel')} + +