From f2417f3ec0f7a04e4e674fa8d0b211861df6aa36 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 11 Nov 2026 18:07:32 +0800 Subject: [PATCH] feat: add external agent workflow selection and settings --- oxlint-suppressions.json | 13 - .../__tests__/use-nodes-sync-draft.spec.ts | 25 ++ .../block-selector/__tests__/blocks.spec.tsx | 15 +- .../workflow-agent-selector.browser.spec.tsx | 250 ++++++++++++++ .../block-selector/agent-selector.tsx | 48 ++- .../workflow/block-selector/blocks.tsx | 4 + .../workflow/block-selector/types.ts | 6 +- .../workflow-agent-selector-preview.ts | 26 ++ .../workflow-agent-selector.tsx | 250 ++++++++++++++ .../hooks/__tests__/use-checklist.spec.tsx | 45 +++ .../use-collaborative-workflow.spec.ts | 47 +++ .../__tests__/use-nodes-interactions.spec.ts | 54 +++ .../workflow/hooks/use-checklist.ts | 26 +- .../hooks/use-collaborative-workflow.ts | 32 +- .../workflow/hooks/use-nodes-interactions.ts | 33 ++ .../use-node-actions-menu-model.ts | 10 +- .../error-handle/fail-branch-card.tsx | 30 +- .../nodes/_base/components/node-control.tsx | 70 ++-- .../agent-output-variables/index.tsx | 44 ++- .../agent-output-variables/utils.ts | 2 + .../components/workflow/nodes/components.ts | 7 +- web/app/components/workflow/nodes/panel.tsx | 5 + web/app/components/workflow/utils/node.ts | 2 + .../__tests__/preview.browser.spec.tsx | 325 ++++++++++++++++++ .../workflow-preview/agent-field.tsx | 126 +++++++ .../workflow-preview/external-badge.tsx | 10 + .../workflow-preview/external-settings.tsx | 220 ++++++++++++ .../external-agent/workflow-preview/node.tsx | 40 +++ .../external-agent/workflow-preview/panel.tsx | 227 ++++++++++++ .../workflow-preview/preview.ts | 36 ++ .../workflow-preview/task-field.tsx | 154 +++++++++ .../external-agent/workflow-preview/types.ts | 38 ++ web/i18n/locales/ar-TN/agent-roster.json | 15 + web/i18n/locales/az-AZ/agent-roster.json | 15 + web/i18n/locales/de-DE/agent-roster.json | 15 + web/i18n/locales/en-US/agent-roster.json | 15 + web/i18n/locales/es-ES/agent-roster.json | 15 + web/i18n/locales/fa-IR/agent-roster.json | 15 + web/i18n/locales/fr-FR/agent-roster.json | 15 + web/i18n/locales/hi-IN/agent-roster.json | 15 + web/i18n/locales/id-ID/agent-roster.json | 15 + web/i18n/locales/it-IT/agent-roster.json | 15 + web/i18n/locales/ja-JP/agent-roster.json | 15 + web/i18n/locales/ko-KR/agent-roster.json | 15 + web/i18n/locales/lo-LA/agent-roster.json | 15 + web/i18n/locales/nl-NL/agent-roster.json | 15 + web/i18n/locales/pl-PL/agent-roster.json | 15 + web/i18n/locales/pt-BR/agent-roster.json | 15 + web/i18n/locales/ro-RO/agent-roster.json | 15 + web/i18n/locales/ru-RU/agent-roster.json | 15 + web/i18n/locales/sl-SI/agent-roster.json | 15 + web/i18n/locales/th-TH/agent-roster.json | 15 + web/i18n/locales/tr-TR/agent-roster.json | 15 + web/i18n/locales/uk-UA/agent-roster.json | 15 + web/i18n/locales/vi-VN/agent-roster.json | 15 + web/i18n/locales/zh-Hans/agent-roster.json | 15 + web/i18n/locales/zh-Hant/agent-roster.json | 15 + web/i18n/resources.generated.d.ts | 14 + 58 files changed, 2509 insertions(+), 100 deletions(-) create mode 100644 web/app/components/workflow/block-selector/__tests__/workflow-agent-selector.browser.spec.tsx create mode 100644 web/app/components/workflow/block-selector/workflow-agent-selector-preview.ts create mode 100644 web/app/components/workflow/block-selector/workflow-agent-selector.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/__tests__/preview.browser.spec.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/agent-field.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/external-badge.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/external-settings.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/node.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/panel.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/preview.ts create mode 100644 web/features/agent-v2/external-agent/workflow-preview/task-field.tsx create mode 100644 web/features/agent-v2/external-agent/workflow-preview/types.ts diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c9ce9bab601..e717f6227a4 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3658,11 +3658,6 @@ "count": 2 } }, - "web/app/components/workflow/block-selector/agent-selector.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 7 - } - }, "web/app/components/workflow/block-selector/all-start-blocks.tsx": { "better-tailwindcss/enforce-logical-properties": { "count": 1 @@ -4133,9 +4128,6 @@ } }, "web/app/components/workflow/nodes/_base/components/node-control.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 1 - }, "jsx-a11y/click-events-have-key-events": { "count": 1 }, @@ -4386,11 +4378,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx": { - "better-tailwindcss/enforce-logical-properties": { - "count": 2 - } - }, "web/app/components/workflow/nodes/agent/components/model-bar.tsx": { "better-tailwindcss/enforce-logical-properties": { "count": 2 diff --git a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts index 13acd200629..d66d70df686 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-nodes-sync-draft.spec.ts @@ -12,6 +12,8 @@ import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixt import { renderWorkflowCanvasHook } from '@/app/components/workflow/__tests__/workflow-test-env' import { ChatVarType } from '@/app/components/workflow/panel/chat-variable-panel/type' import { BlockEnum } from '@/app/components/workflow/types' +import { createExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/preview' +import { createExternalRosterPreview } from '@/features/agent-v2/roster/external-roster-preview' import { markAppDeletionFailed, markAppDeletionStarted } from '@/service/app-deletion' import { createHttpError } from '@/service/http-error' import { createConsoleQueryWrapper, seedAppDetail } from '@/test/console/query-data' @@ -439,6 +441,29 @@ describe('useNodesSyncDraft', () => { ) }) + it('excludes external agent previews and their connected edges from backend draft payloads', async () => { + const previewData = createExternalAgentWorkflowPreviewData(createExternalRosterPreview()[1]!) + const { result } = renderUseNodesSyncDraft({ + nodes: [ + startNode, + createNode({ id: 'external-preview', data: { type: BlockEnum.Agent, ...previewData } }), + ], + edges: [createEdge({ source: startNode.id, target: 'external-preview' })], + }) + + await act(async () => { + await result.current.doSyncWorkflowDraft() + }) + + expect(mockSyncWorkflowDraft).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + params: expect.objectContaining({ + graph: expect.objectContaining({ nodes: [savedStartNode], edges: [] }), + }), + }), + ) + }) + it('keeps pending inline Agent v2 nodes in draft without incomplete bindings', async () => { const agentData = { type: BlockEnum.Agent, diff --git a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx index 36d012167c5..312df5e1428 100644 --- a/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/blocks.spec.tsx @@ -389,7 +389,7 @@ describe('Blocks', () => { expect(await screen.findByText('Nadia')).toBeInTheDocument() expect(screen.getByText('Researcher')).toBeInTheDocument() - await user.click(screen.getByRole('menuitem', { name: 'Nadia Researcher' })) + await user.click(screen.getByRole('button', { name: /Nadia.*Researcher/ })) expect(onSelect).toHaveBeenCalledWith(BlockEnum.AgentV2, { agent_binding: { @@ -511,11 +511,14 @@ describe('Blocks', () => { await user.click(screen.getByRole('button', { name: /Agent/ })) expect(await screen.findByText('Nadia')).toBeInTheDocument() - await user.click(screen.getByRole('menuitem', { name: 'Nadia Researcher' })) + await user.click(screen.getByRole('button', { name: /Nadia.*Researcher/ })) - await waitFor(() => - expect(queryMocks.toastError).toHaveBeenCalledWith('workflow.nodes.agent.modelNotSelected'), + expect(screen.getByRole('button', { name: /Nadia.*Researcher/ })).toHaveAttribute( + 'aria-disabled', + 'true', ) + expect(screen.getByText('workflow.nodes.agent.modelNotSelected')).toBeInTheDocument() + expect(queryMocks.toastError).not.toHaveBeenCalled() expect(onSelect).not.toHaveBeenCalled() }) @@ -546,14 +549,14 @@ describe('Blocks', () => { ) await user.click(screen.getByRole('button', { name: /Agent/ })) - const consoleLink = await screen.findByRole('menuitem', { + const consoleLink = await screen.findByRole('link', { name: 'agentRoster.roster.nodeSelector.manageInAgentConsole', }) expect(consoleLink).toHaveAttribute('href', '/agents') expect(consoleLink).toHaveAttribute('target', '_blank') expect(consoleLink).toHaveAttribute('rel', 'noopener noreferrer') await user.click( - await screen.findByRole('menuitem', { + await screen.findByRole('button', { name: 'agentRoster.roster.nodeSelector.startFromScratch', }), ) diff --git a/web/app/components/workflow/block-selector/__tests__/workflow-agent-selector.browser.spec.tsx b/web/app/components/workflow/block-selector/__tests__/workflow-agent-selector.browser.spec.tsx new file mode 100644 index 00000000000..bc8011e533e --- /dev/null +++ b/web/app/components/workflow/block-selector/__tests__/workflow-agent-selector.browser.spec.tsx @@ -0,0 +1,250 @@ +import type { AgentInviteOptionResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { ComponentProps } from 'react' +import type { EditorResource } from '@/app/components/workflow/editor-resource/resource' +import type { NodeDefault } from '@/app/components/workflow/types' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' +import { page, userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { EditorResourceScope } from '@/app/components/workflow/editor-resource/provider' +import { BlockEnum } from '@/app/components/workflow/types' +import agentRoster from '@/i18n/locales/en-US/agent-roster.json' +import common from '@/i18n/locales/en-US/common.json' +import navigation from '@/i18n/locales/en-US/navigation.json' +import workflow from '@/i18n/locales/en-US/workflow.json' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' +import { AgentBlockItem } from '../agent-selector' +import { BlockClassification } from '../types' + +const difyAgent: AgentInviteOptionResponse = { + id: 'dify-agent-1', + name: 'Research Agent', + description: 'Researches public information.', + role: 'Researcher', + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFFFFF', + active_config_snapshot_id: 'snapshot-1', + agent_kind: 'dify_agent', + scope: 'roster', + source: 'workflow', + status: 'active', +} + +const mocks = vi.hoisted(() => ({ fetchAgents: vi.fn() })) +vi.mock('react-i18next', async (importOriginal) => importOriginal()) +// boundary: Dify invite options are server data; source filtering and selection remain real. +vi.mock('@/service/console', async (importOriginal) => { + const actual = await importOriginal() + const { withConsoleResponses } = await import('@/test/console/service-responses') + return { + ...actual, + consoleQuery: withConsoleResponses(actual.consoleQuery, { + 'agent.inviteOptions.get': ( + input: Parameters[0], + ) => { + mocks.fetchAgents(input) + const keyword = input?.query?.keyword?.toLowerCase() ?? '' + const data = [difyAgent].filter((agent) => agent.name.toLowerCase().includes(keyword)) + return { data, has_more: false, limit: 8, page: 1, total: data.length } + }, + }), + } +}) + +const block: NodeDefault = { + metaData: { + classification: BlockClassification.Default, + sort: 3, + type: BlockEnum.AgentV2, + title: 'Agent', + author: 'Dify', + description: 'Call an agent.', + }, + defaultValue: {}, + checkValid: () => ({ isValid: true }), +} + +type AgentBlockProps = ComponentProps + +async function setup( + resource: EditorResource = { + kind: 'app', + collaborationEnabled: false, + appId: 'workflow-app', + mode: 'workflow', + }, +) { + await page.viewport(1100, 750) + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { agentRoster, common, navigation, workflow } }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + onTestFinished(() => queryClient.clear()) + const onSelect = vi.fn() + const onSelectExternal = vi.fn>() + const onStartFromScratch = vi.fn() + await render( + + + +
+ +
+
+
+
, + ) + const trigger = page.getByRole('button', { name: /^Agent/ }) + await trigger.click() + const picker = page.getByRole('dialog', { name: 'Select an agent', exact: true }) + await expect.element(picker).toBeVisible() + return { picker, trigger, onSelect, onSelectExternal, onStartFromScratch } +} + +beforeEach(({ onTestFinished }) => { + mocks.fetchAgents.mockClear() + onTestFinished(guardUnexpectedFetch()) +}) + +it('filters the active source while sharing the search and avoiding Dify requests on External', async () => { + const { picker } = await setup() + const search = picker.getByRole('searchbox', { name: 'Search agents', exact: true }) + await expect + .element(picker.getByRole('tab', { name: 'All', exact: true })) + .toHaveAttribute('aria-selected', 'true') + await expect.element(picker.getByRole('button', { name: /Research Agent/ })).toBeVisible() + await expect.element(picker.getByRole('button', { name: /Contract Review Agent/ })).toBeVisible() + await picker.getByRole('tab', { name: 'External', exact: true }).click() + await expect + .element(picker.getByRole('button', { name: /Research Agent/ })) + .not.toBeInTheDocument() + await expect + .element(picker.getByRole('button', { name: 'Start from Scratch', exact: true })) + .not.toBeInTheDocument() + const requests = mocks.fetchAgents.mock.calls.length + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + onTestFinished(() => { + vi.useRealTimers() + }) + await search.fill('document') + await expect.element(search).toHaveValue('document') + await vi.advanceTimersByTimeAsync(300) + await expect.element(picker.getByRole('button', { name: /Document Review Agent/ })).toBeVisible() + await expect + .element(picker.getByRole('button', { name: /Contract Review Agent/ })) + .not.toBeInTheDocument() + expect(mocks.fetchAgents).toHaveBeenCalledTimes(requests) + vi.useRealTimers() + + await picker.getByRole('tab', { name: 'Dify', exact: true }).click() + await expect + .poll(() => mocks.fetchAgents.mock.calls.at(-1)) + .toEqual([{ query: { app_id: 'workflow-app', limit: 8, page: 1, keyword: 'document' } }]) + await expect.element(search).toHaveValue('document') + await expect.element(picker.getByText('No matching agents', { exact: true })).toBeVisible() + await picker.getByRole('tab', { name: 'All', exact: true }).click() + await expect.element(picker.getByRole('button', { name: /Document Review Agent/ })).toBeVisible() +}) + +it('skips disabled external agents during keyboard selection and closes with the external callback', async () => { + const { picker, onSelect, onSelectExternal } = await setup() + await picker.getByRole('tab', { name: 'External', exact: true }).click() + const contract = picker.getByRole('button', { name: /Contract Review Agent/ }) + const document = picker.getByRole('button', { name: /Document Review Agent/ }) + await expect.element(picker.getByRole('button', { name: /Invoice Audit Agent/ })).toBeDisabled() + await userEvent.tab() + await expect + .element(picker.getByRole('tabpanel', { name: 'External', exact: true })) + .toHaveFocus() + await userEvent.tab() + await expect.element(contract).toHaveFocus() + await userEvent.tab() + await expect.element(document).toHaveFocus() + await userEvent.tab() + const manage = picker.getByRole('link', { name: 'Manage in Agents', exact: true }) + await expect.element(manage).toHaveFocus() + await expect.element(manage).toHaveAttribute('href', '/agents') + await userEvent.tab({ shift: true }) + await expect.element(document).toHaveFocus() + await userEvent.keyboard('{Enter}') + await expect + .poll(() => onSelectExternal.mock.calls) + .toEqual([[expect.objectContaining({ key: 'document-review', name: 'Document Review Agent' })]]) + expect(onSelect).not.toHaveBeenCalled() + await expect.element(picker).not.toBeInTheDocument() +}) + +it('keeps Dify selection and starting from scratch as distinct actions', async () => { + const { picker, trigger, onSelect, onSelectExternal, onStartFromScratch } = await setup() + await picker.getByRole('button', { name: /Research Agent/ }).click() + await expect + .poll(() => onSelect.mock.calls) + .toEqual([ + [ + { + description: difyAgent.description, + icon: difyAgent.icon, + icon_background: difyAgent.icon_background, + icon_type: difyAgent.icon_type, + id: difyAgent.id, + name: difyAgent.name, + role: difyAgent.role, + }, + ], + ]) + expect(onSelectExternal).not.toHaveBeenCalled() + await expect.element(picker).not.toBeInTheDocument() + await trigger.click() + await picker.getByRole('button', { name: 'Start from Scratch', exact: true }).click() + expect(onStartFromScratch).toHaveBeenCalledOnce() + expect(onSelect).toHaveBeenCalledOnce() + await expect.element(picker).not.toBeInTheDocument() +}) + +it('resets the source and search when closing and reopening the picker', async () => { + const { picker, trigger } = await setup() + await picker.getByRole('tab', { name: 'External', exact: true }).click() + await picker.getByRole('searchbox', { name: 'Search agents', exact: true }).fill('contract') + await expect + .element(picker.getByRole('button', { name: /Document Review Agent/ })) + .not.toBeInTheDocument() + await userEvent.keyboard('{Escape}') + await expect + .element(picker.getByRole('searchbox', { name: 'Search agents', exact: true })) + .toHaveValue('') + await userEvent.keyboard('{Escape}') + await expect.element(picker).not.toBeInTheDocument() + await trigger.click() + await expect + .element(picker.getByRole('tab', { name: 'All', exact: true })) + .toHaveAttribute('aria-selected', 'true') + await expect + .element(picker.getByRole('searchbox', { name: 'Search agents', exact: true })) + .toHaveValue('') + await expect.element(picker.getByRole('button', { name: /Research Agent/ })).toBeVisible() + await expect.element(picker.getByRole('button', { name: /Document Review Agent/ })).toBeVisible() +}) + +it('keeps external previews out of snippet selection and omits the workflow app filter', async () => { + const { picker } = await setup({ kind: 'snippet', snippetId: 'snippet-1' }) + await expect + .element(picker.getByRole('tab', { name: 'External', exact: true })) + .not.toBeInTheDocument() + await expect + .element(picker.getByRole('button', { name: /Contract Review Agent/ })) + .not.toBeInTheDocument() + await expect + .poll(() => mocks.fetchAgents.mock.calls.at(-1)) + .toEqual([{ query: { limit: 8, page: 1 } }]) +}) diff --git a/web/app/components/workflow/block-selector/agent-selector.tsx b/web/app/components/workflow/block-selector/agent-selector.tsx index 5082a7ac51c..0bee9708bf1 100644 --- a/web/app/components/workflow/block-selector/agent-selector.tsx +++ b/web/app/components/workflow/block-selector/agent-selector.tsx @@ -3,6 +3,7 @@ import type { MenuContentProps } from '@langgenius/dify-ui/menu' import type { ReactNode } from 'react' import type { NodeDefault } from '../types' import type { AgentRosterNodeData } from './types' +import type { ExternalAgentWorkflowPreviewAgent } from '@/features/agent-v2/external-agent/workflow-preview/types' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' import { @@ -17,6 +18,7 @@ import { MenuSeparator, MenuTrigger, } from '@langgenius/dify-ui/menu' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' import { keepPreviousData, useQuery } from '@tanstack/react-query' import { useDebounce } from 'ahooks' import { useAtomValue } from 'jotai' @@ -24,11 +26,15 @@ import { createContext, use, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import Badge from '@/app/components/base/badge' -import { editorAppIdAtom } from '@/app/components/workflow/editor-resource/state' +import { + editorAppIdAtom, + editorResourceKindAtom, +} from '@/app/components/workflow/editor-resource/state' import { toast } from '@/app/notifications' import Link from '@/next/link' import { consoleQuery } from '@/service/console' import BlockIcon from '../block-icon' +import { WorkflowAgentSelectorContent } from './workflow-agent-selector' const AGENT_SELECTOR_PAGE_SIZE = 8 @@ -163,7 +169,7 @@ function AgentSelectorList({ handleSelect(agent)} > @@ -215,7 +221,7 @@ function AgentSelectorLoadingSkeleton() {
@@ -247,27 +253,32 @@ function toAgentRosterNodeData(agent: AgentInviteOptionResponse): AgentRosterNod export function AgentBlockItem({ block, onSelect, + onSelectExternal, onStartFromScratch, }: { block: NodeDefault onSelect: (agent: AgentRosterNodeData) => void + onSelectExternal?: (agent: ExternalAgentWorkflowPreviewAgent) => void onStartFromScratch: () => void }) { const { t } = useTranslation(['navigation']) + const { t: tAgentRoster } = useTranslation(['agentRoster']) + const resourceKind = useAtomValue(editorResourceKindAtom) + const [open, setOpen] = useState(false) return ( - - + } > - + {block.metaData.title} @@ -275,19 +286,28 @@ export function AgentBlockItem({ size="xs" variant="dimm" text={t(($) => $['menus.status'], { ns: 'navigation' })} - className="ml-2 shrink-0" + className="ms-2 shrink-0" /> - - + - + className="w-100 max-w-[calc(100vw-1rem)] overflow-hidden" + > + + {tAgentRoster(($) => $['roster.nodeSelector.dialogLabel'])} + + setOpen(false)} + /> + + ) } diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 236ed89425a..1c58e436505 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/web/app/components/workflow/block-selector/blocks.tsx @@ -11,6 +11,7 @@ import { Fragment, memo, useCallback, useId, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useStoreApi } from 'reactflow' import Badge from '@/app/components/base/badge' +import { createExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/preview' import BlockIcon from '../block-icon' import { useHumanInputMigration } from '../nodes/human-input-v2/migration/context' import { getHumanInputCreationPolicy } from '../nodes/human-input-v2/migration/policy' @@ -142,6 +143,9 @@ const Blocks = ({ version: '2', }) } + onSelectExternal={(agent) => + onSelect(BlockEnum.AgentV2, createExternalAgentWorkflowPreviewData(agent)) + } /> ) } diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index 8ce50e47346..20fb2c4741c 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -9,6 +9,7 @@ import type { } from '../../plugins/types' import type { Collection, CollectionProviderType, Event } from '../../tools/types' import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ExternalAgentWorkflowPreviewDefaultValue } from '@/features/agent-v2/external-agent/workflow-preview/types' export const TabType = { Start: 'start', @@ -123,7 +124,10 @@ type AgentDefaultValue = { export type PluginDefaultValue = ToolDefaultValue | DataSourceDefaultValue | TriggerDefaultValue -export type BlockDefaultValue = PluginDefaultValue | AgentDefaultValue +export type BlockDefaultValue = + | PluginDefaultValue + | AgentDefaultValue + | ExternalAgentWorkflowPreviewDefaultValue export type ToolValue = { provider_name: string diff --git a/web/app/components/workflow/block-selector/workflow-agent-selector-preview.ts b/web/app/components/workflow/block-selector/workflow-agent-selector-preview.ts new file mode 100644 index 00000000000..44bec6eb26b --- /dev/null +++ b/web/app/components/workflow/block-selector/workflow-agent-selector-preview.ts @@ -0,0 +1,26 @@ +import type { ExternalAgentWorkflowPreviewAgent } from '@/features/agent-v2/external-agent/workflow-preview/types' + +// Local examples for the external Agent picker until its API is available. +export const externalWorkflowAgentExamples: readonly ExternalAgentWorkflowPreviewAgent[] = [ + { + key: 'contract-review', + name: 'Contract Review Agent', + role: 'Legal & compliance', + description: 'Reviews contract clauses against your company’s policies.', + enabled: true, + }, + { + key: 'document-review', + name: 'Document Review Agent', + role: 'Document review', + description: 'Reviews documents and highlights information that needs attention.', + enabled: true, + }, + { + key: 'invoice-audit', + name: 'Invoice Audit Agent', + role: 'Finance', + description: 'Checks invoices for discrepancies and missing information.', + enabled: false, + }, +] diff --git a/web/app/components/workflow/block-selector/workflow-agent-selector.tsx b/web/app/components/workflow/block-selector/workflow-agent-selector.tsx new file mode 100644 index 00000000000..c48e3219bd8 --- /dev/null +++ b/web/app/components/workflow/block-selector/workflow-agent-selector.tsx @@ -0,0 +1,250 @@ +import type { AgentInviteOptionResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { AgentRosterNodeData } from './types' +import type { ExternalAgentWorkflowPreviewAgent } from '@/features/agent-v2/external-agent/workflow-preview/types' +import { Button } from '@langgenius/dify-ui/button' +import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { useDebounce } from 'ahooks' +import { useAtomValue } from 'jotai' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import { SearchInput } from '@/app/components/base/search-input' +import { editorAppIdAtom } from '@/app/components/workflow/editor-resource/state' +import Link from '@/next/link' +import { consoleQuery } from '@/service/console' +import { externalWorkflowAgentExamples } from './workflow-agent-selector-preview' + +type AgentSource = 'all' | 'dify' | 'external' + +type WorkflowAgentSelectorContentProps = { + onSelect: (agent: AgentRosterNodeData) => void + onSelectExternal?: (agent: ExternalAgentWorkflowPreviewAgent) => void + onStartFromScratch: () => void + onRequestClose: () => void +} + +export function WorkflowAgentSelectorContent({ + onSelect, + onSelectExternal, + onStartFromScratch, + onRequestClose, +}: WorkflowAgentSelectorContentProps) { + const { t } = useTranslation(['agentRoster', 'workflow', 'common']) + const appId = useAtomValue(editorAppIdAtom) + const [source, setSource] = useState('all') + const [search, setSearch] = useState('') + const keyword = useDebounce(search.trim(), { wait: 300 }) + const agentsQuery = useQuery({ + ...consoleQuery.agent.inviteOptions.get.queryOptions({ + input: { + query: { + limit: 8, + page: 1, + ...(appId ? { app_id: appId } : {}), + ...(keyword ? { keyword } : {}), + }, + }, + }), + enabled: source !== 'external', + staleTime: 0, + placeholderData: keepPreviousData, + }) + const difyAgents = source === 'external' ? [] : (agentsQuery.data?.data ?? []) + const externalAgents = + source === 'dify' || !onSelectExternal + ? [] + : externalWorkflowAgentExamples.filter((agent) => + `${agent.name} ${agent.role} ${agent.description}` + .toLocaleLowerCase() + .includes(search.trim().toLocaleLowerCase()), + ) + const isLoading = source !== 'external' && agentsQuery.isPending + const hasDifyError = source !== 'external' && agentsQuery.isError + const isEmpty = + !isLoading && !hasDifyError && difyAgents.length === 0 && externalAgents.length === 0 + const sources: AgentSource[] = onSelectExternal ? ['all', 'dify', 'external'] : ['all', 'dify'] + + const selectDifyAgent = (agent: AgentInviteOptionResponse) => { + if (!agent.active_config_snapshot_id) return + onRequestClose() + onSelect({ + description: agent.description, + icon: agent.icon, + icon_background: agent.icon_background, + icon_type: agent.icon_type, + id: agent.id, + name: agent.name, + role: agent.role, + }) + } + + return ( + <> +
+ $['roster.searchLabel'])} + placeholder={t(($) => $['roster.nodeSelector.searchAgentsPlaceholder'])} + /> +
+ { + if (value === 'all' || value === 'dify' || value === 'external') setSource(value) + }} + > + $['roster.nodeSelector.dialogLabel'])} + className="h-8 gap-0.5 border-b border-divider-subtle px-3" + > + {sources.map((tab) => ( + + {t(($) => $[`roster.nodeSelector.tabs.${tab}`])} + + ))} + + {sources.map((tab) => ( + + {source === tab && ( + <> + {source !== 'external' && !search.trim() && ( + + )} + {isLoading && } + {hasDifyError && ( +

+ {t(($) => $['roster.loadingError'])} +

+ )} + {!hasDifyError && + difyAgents.map((agent) => ( + + ))} + {externalAgents.map((agent) => ( + + ))} + {isEmpty && ( +

+ {search.trim() + ? t(($) => $['roster.emptySearch']) + : t(($) => $['roster.empty'])} +

+ )} + + )} +
+ ))} +
+
+ + {t(($) => $['roster.nodeSelector.manageInAgentConsole'])} + + +
+ + ) +} + +function DifyAgentSelectorSkeleton() { + return ( +
+ {[0, 1, 2].map((item) => ( +
+ + +
+ ))} +
+ ) +} diff --git a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx index dd8d588c0b7..a0376bad39c 100644 --- a/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx +++ b/web/app/components/workflow/hooks/__tests__/use-checklist.spec.tsx @@ -17,6 +17,8 @@ import ReactFlow, { ReactFlowProvider, useEdges, useNodes } from 'reactflow' import { CollectionType } from '@/app/components/tools/types' import { toast } from '@/app/notifications' import { AppToaster } from '@/app/notifications/app-toaster' +import { createExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/preview' +import { createExternalRosterPreview } from '@/features/agent-v2/roster/external-roster-preview' import { consoleQuery } from '@/service/console' import { seedModelProviderQueries } from '@/test/console/model-provider' import { createConsoleQueryWrapper } from '@/test/console/query-data' @@ -687,6 +689,49 @@ describe('useChecklistBeforePublish', () => { }) describe('useWorkflowRunValidation', () => { + it('blocks running and publishing a graph containing a local external agent preview', async () => { + const graph = buildConnectedGraph() + const preview = createNode({ + id: 'external-preview', + data: { + type: BlockEnum.Agent, + title: 'External Agent', + ...createExternalAgentWorkflowPreviewData(createExternalRosterPreview()[1]!), + }, + }) + const { wrapper } = createChecklistWrapper({ + nodes: [...graph.nodes, preview], + edges: [ + createEdge({ source: 'start', target: preview.id }), + createEdge({ source: preview.id, target: 'code' }), + createEdge({ source: 'code', target: 'end' }), + ], + }) + const { result } = renderHook( + () => ({ + nodes: useNodes(), + run: useWorkflowRunValidation(), + publish: useChecklistBeforePublish(), + }), + { wrapper }, + ) + + expect(result.current.run.warningNodes).toContainEqual( + expect.objectContaining({ + id: preview.id, + errorMessages: ['agentRoster.external.workflow.runUnavailable'], + canNavigate: true, + }), + ) + act(() => { + expect(result.current.run.validateBeforeRun()).toBe(false) + }) + await act(async () => { + expect(await result.current.publish.handleCheckBeforePublish()).toBe(false) + }) + expect(result.current.nodes.find((node) => node.id === preview.id)?.data.selected).toBe(true) + }) + it('blocks a run with disconnected nodes and explains why', async () => { const { nodes } = buildConnectedGraph() const { wrapper } = createChecklistWrapper({ nodes, edges: [] }) diff --git a/web/app/components/workflow/hooks/__tests__/use-collaborative-workflow.spec.ts b/web/app/components/workflow/hooks/__tests__/use-collaborative-workflow.spec.ts index 7a9450ab4db..dadf5284961 100644 --- a/web/app/components/workflow/hooks/__tests__/use-collaborative-workflow.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-collaborative-workflow.spec.ts @@ -1,6 +1,9 @@ import { act, waitFor } from '@testing-library/react' +import { createExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/preview' +import { createExternalRosterPreview } from '@/features/agent-v2/roster/external-roster-preview' import { createEdge, createNode } from '../../__tests__/fixtures' import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env' +import { BlockEnum } from '../../types' import { useCollaborativeWorkflow } from '../use-collaborative-workflow' const mocks = vi.hoisted(() => ({ @@ -76,3 +79,47 @@ it('updates the local graph while stripping selection state from broadcasts', as expect(previousEdges[0].data).not.toHaveProperty('_connectedNodeIsSelected') expect(nextEdges[0].data).not.toHaveProperty('_connectedNodeIsSelected') }) + +it('keeps external agent previews and their edges local through edits and deletion', async () => { + mocks.canApplyLocalGraphMutation.mockReturnValue(true) + const { result } = setup() + await waitFor(() => expect(result.current.getState().nodes).toHaveLength(2)) + const originalNodes = result.current.getState().nodes + const preview = createNode({ + id: 'external-preview', + data: { + type: BlockEnum.Agent, + ...createExternalAgentWorkflowPreviewData(createExternalRosterPreview()[1]!), + }, + }) + const previewEdge = createEdge({ id: 'preview-edge', source: oldNode.id, target: preview.id }) + act(() => { + result.current.setNodes([...originalNodes, preview], false) + result.current.setEdges([oldEdge, previewEdge], false) + }) + expect(mocks.setNodes).not.toHaveBeenCalled() + expect(mocks.setEdges).not.toHaveBeenCalled() + + act(() => { + result.current.setNodes([newNode, originalNodes[1]!, preview]) + result.current.setEdges([newEdge, previewEdge]) + }) + expect(result.current.getState().nodes).toHaveLength(3) + expect(result.current.getState().edges).toHaveLength(2) + const [oldBroadcastNodes, newBroadcastNodes] = mocks.setNodes.mock.lastCall! + expect(oldBroadcastNodes.map((node: { id: string }) => node.id)).toEqual(['old-node', 'target']) + expect(newBroadcastNodes.map((node: { id: string }) => node.id)).toEqual(['new-node', 'target']) + expect(mocks.setEdges).toHaveBeenLastCalledWith( + [expect.objectContaining({ id: oldEdge.id })], + [expect.objectContaining({ id: newEdge.id })], + ) + + act(() => { + result.current.setNodes([newNode, originalNodes[1]!]) + result.current.setEdges([newEdge]) + }) + expect(mocks.setEdges).toHaveBeenLastCalledWith( + [expect.objectContaining({ id: newEdge.id })], + [expect.objectContaining({ id: newEdge.id })], + ) +}) diff --git a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts index af6c058f0f0..7dbfe4e57c8 100644 --- a/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts +++ b/web/app/components/workflow/hooks/__tests__/use-nodes-interactions.spec.ts @@ -6,9 +6,13 @@ import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps import { act, waitFor } from '@testing-library/react' import { use, useLayoutEffect } from 'react' import { useEdges, useNodes } from 'reactflow' +import { createExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/preview' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' +import { createExternalRosterPreview } from '@/features/agent-v2/roster/external-roster-preview' import { seedModelProviderQueries } from '@/test/console/model-provider' import { createConsoleQueryWrapper, seedAppDslVersion } from '@/test/console/query-data' import { createDeferred } from '@/test/deferred' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' import { createEdge, createNode } from '../../__tests__/fixtures' import { renderWorkflowFlowHook } from '../../__tests__/workflow-test-env' import { WORKFLOW_COMMON_NODES } from '../../constants/node' @@ -543,6 +547,56 @@ it.each(['add', 'replace'] as const)( }, ) +it('adds an external agent preview beside an existing connection without changing or syncing the workflow graph', async ({ + onTestFinished, +}) => { + onTestFinished(guardUnexpectedFetch()) + const originalNodes = [codeNode('a'), codeNode('b')] + const originalEdge = createEdge({ id: 'original-connection', source: 'a', target: 'b' }) + const { result, sync } = setup({ nodes: originalNodes, edges: [originalEdge] }) + const originalConfiguration = structuredClone( + result.current.nodes.map((node) => ({ + id: node.id, + position: node.position, + data: { ...node.data, selected: false }, + })), + ) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + onTestFinished(() => { + vi.useRealTimers() + }) + + act(() => + result.current.handleNodeAdd( + { + nodeType: BlockEnum.AgentV2, + pluginDefaultValue: createExternalAgentWorkflowPreviewData( + createExternalRosterPreview()[1]!, + ), + }, + { prevNodeId: 'a', nextNodeId: 'b' }, + ), + ) + await act(async () => vi.advanceTimersByTimeAsync(2000)) + + const previews = result.current.nodes.filter((node) => + isExternalAgentWorkflowPreviewData(node.data), + ) + expect(previews).toHaveLength(1) + expect(result.current.edges).toEqual([originalEdge]) + expect( + result.current.nodes + .filter((node) => !isExternalAgentWorkflowPreviewData(node.data)) + .map((node) => ({ + id: node.id, + position: node.position, + data: { ...node.data, selected: false }, + })), + ).toEqual(originalConfiguration) + expect(sync).not.toHaveBeenCalled() + expect(createAgent).not.toHaveBeenCalled() +}) + it('creates an inline Agent through the service boundary and resolves the pending graph node', async () => { const request = createDeferred>>() createAgent.mockReturnValue(request.promise) diff --git a/web/app/components/workflow/hooks/use-checklist.ts b/web/app/components/workflow/hooks/use-checklist.ts index 2204cac5992..35b4c1dfad8 100644 --- a/web/app/components/workflow/hooks/use-checklist.ts +++ b/web/app/components/workflow/hooks/use-checklist.ts @@ -43,6 +43,7 @@ import { getAgentToolPublishIssues, useAgentToolPresentation, } from '@/features/agent-v2/agent-detail/configure/tool-provider-catalog' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { consoleQuery } from '@/service/console' import { fetchDatasets } from '@/service/datasets' import { useAllTriggerPlugins } from '@/service/use-triggers' @@ -181,7 +182,7 @@ const getDuplicateEndOutputMessages = ( } export const useChecklist = (nodes: Node[], edges: Edge[]) => { - const { t } = useTranslation(['agentV2', 'common', 'workflow', 'modelProvider']) + const { t } = useTranslation(['agentV2', 'common', 'workflow', 'modelProvider', 'agentRoster']) const language = useGetLanguage() const workspaceId = useAtomValue(currentWorkspaceIdAtom) const contactIds = useMemo(() => getHumanInputContactIds(nodes), [nodes]) @@ -410,6 +411,16 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => { for (let i = 0; i < filteredNodes.length; i++) { const node = filteredNodes[i] + if (node && isExternalAgentWorkflowPreviewData(node.data)) { + list.push({ + id: node.id, + type: BlockEnum.AgentV2, + title: node.data.title, + errorMessages: [t(($) => $['external.workflow.runUnavailable'], { ns: 'agentRoster' })], + canNavigate: true, + }) + continue + } let moreDataForCheckValid: CheckValidExtraData let usedVars: ValueSelector[] = [] @@ -756,6 +767,19 @@ export const useChecklistBeforePublish = () => { const { dataSourceList, environmentVariables = [] } = workflowStore.getState() const nodes = getNodes() const filteredNodes = nodes.filter((node) => node.type === CUSTOM_NODE) + const externalAgentPreview = filteredNodes.find((node) => + isExternalAgentWorkflowPreviewData(node.data), + ) + if (externalAgentPreview) { + store.getState().setNodes( + nodes.map((node) => ({ + ...node, + selected: node.id === externalAgentPreview.id, + data: { ...node.data, selected: node.id === externalAgentPreview.id }, + })), + ) + return false + } const duplicateEndOutputMessages = getDuplicateEndOutputMessages(filteredNodes, t) const { validNodes, maxDepth } = getValidTreeNodes(filteredNodes, edges) diff --git a/web/app/components/workflow/hooks/use-collaborative-workflow.ts b/web/app/components/workflow/hooks/use-collaborative-workflow.ts index 96e377394e8..4c076ac49df 100644 --- a/web/app/components/workflow/hooks/use-collaborative-workflow.ts +++ b/web/app/components/workflow/hooks/use-collaborative-workflow.ts @@ -1,6 +1,8 @@ import type { Edge, Node } from '../types' +import { useRefWithInit } from '@langgenius/dify-ui/use-ref-with-init' import { useCallback, useMemo } from 'react' import { useStoreApi } from 'reactflow' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { collaborationManager } from '../collaboration/core/collaboration-manager' const sanitizeNodeForBroadcast = (node: Node): Node => { @@ -33,6 +35,7 @@ const sanitizeEdgeForBroadcast = (edge: Edge): Edge => { export const useCollaborativeWorkflow = () => { const store = useStoreApi() + const previewNodeIds = useRefWithInit(() => new Set()).current const { setNodes: collabSetNodes, setEdges: collabSetEdges } = collaborationManager const setNodes = useCallback( @@ -42,33 +45,48 @@ export const useCollaborativeWorkflow = () => { source = 'use-collaborative-workflow:setNodes', ) => { const { getNodes, setNodes: reactFlowSetNodes } = store.getState() + const oldNodes = getNodes() + for (const node of [...oldNodes, ...newNodes]) { + if (isExternalAgentWorkflowPreviewData(node.data)) previewNodeIds.add(node.id) + } if (shouldBroadcast) { if (!collaborationManager.canApplyLocalGraphMutation()) return - const oldNodes = getNodes() collabSetNodes( - oldNodes.map(sanitizeNodeForBroadcast), - newNodes.map(sanitizeNodeForBroadcast), + oldNodes + .filter((node) => !isExternalAgentWorkflowPreviewData(node.data)) + .map(sanitizeNodeForBroadcast), + newNodes + .filter((node) => !isExternalAgentWorkflowPreviewData(node.data)) + .map(sanitizeNodeForBroadcast), source, ) } reactFlowSetNodes(newNodes) }, - [store, collabSetNodes], + [store, collabSetNodes, previewNodeIds], ) const setEdges = useCallback( (newEdges: Edge[], shouldBroadcast: boolean = true) => { - const { edges, setEdges: reactFlowSetEdges } = store.getState() + const { edges, getNodes, setEdges: reactFlowSetEdges } = store.getState() + for (const node of getNodes()) { + if (isExternalAgentWorkflowPreviewData(node.data)) previewNodeIds.add(node.id) + } if (shouldBroadcast) { if (!collaborationManager.canApplyLocalGraphMutation()) return - collabSetEdges(edges.map(sanitizeEdgeForBroadcast), newEdges.map(sanitizeEdgeForBroadcast)) + const canBroadcastEdge = (edge: Edge) => + !previewNodeIds.has(edge.source) && !previewNodeIds.has(edge.target) + collabSetEdges( + edges.filter(canBroadcastEdge).map(sanitizeEdgeForBroadcast), + newEdges.filter(canBroadcastEdge).map(sanitizeEdgeForBroadcast), + ) } reactFlowSetEdges(newEdges) }, - [store, collabSetEdges], + [store, collabSetEdges, previewNodeIds], ) const collaborativeStore = useCallback(() => { diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 956e439918a..5e032f0f079 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next' import { getConnectedEdges, getOutgoers, useReactFlow } from 'reactflow' import { isChatflowAtom } from '@/app/components/workflow/editor-resource/state' import { toast } from '@/app/notifications' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { consoleQuery } from '@/service/console' import { collaborationManager } from '../collaboration/core/collaboration-manager' import { @@ -884,6 +885,27 @@ export const useNodesInteractions = () => { y: 0, }, }) + if (isExternalAgentWorkflowPreviewData(newNode.data)) { + const anchor = nodes.find((node) => node.id === (nextNodeId ?? prevNodeId)) + const anchorPosition = anchor?.positionAbsolute ?? anchor?.position + const mousePosition = workflowStore.getState().mousePosition + newNode.position = anchorPosition + ? { x: anchorPosition.x + NODE_WIDTH_X_OFFSET, y: anchorPosition.y + Y_OFFSET } + : reactflow.screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) + setNodes( + [ + ...nodes.map((node) => ({ + ...node, + selected: false, + data: { ...node.data, selected: false }, + })), + newNode, + ], + false, + ) + saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: newNode.id }) + return + } if (prevNodeId && !nextNodeId) { const prevNodeIndex = nodes.findIndex((node) => node.id === prevNodeId) const prevNode = nodes[prevNodeIndex] @@ -1356,6 +1378,7 @@ export const useNodesInteractions = () => { createInlineAgentBindingForNode, saveStateToHistory, workflowStore, + reactflow, getAfterNodesInSameBranch, nodesMetaDataMap, t, @@ -1375,6 +1398,15 @@ export const useNodesInteractions = () => { const nodeMetaData = nodesMetaDataMap?.[nodeType] if (!nodeMetaData) return const { defaultValue } = nodeMetaData + if ( + isExternalAgentWorkflowPreviewData({ + ...defaultValue, + ...pluginDefaultValue, + } as Node['data']) + ) { + handleNodeAdd({ nodeType, sourceHandle, pluginDefaultValue }, { prevNodeId: currentNodeId }) + return + } const insertionBlocker = getHumanInputInsertionBlocker(nodes, nodeType, { ...(defaultValue as Node['data']), ...pluginDefaultValue, @@ -1576,6 +1608,7 @@ export const useNodesInteractions = () => { saveStateToHistory, nodesMetaDataMap, autoGenerateWebhookUrl, + handleNodeAdd, t, ], ) diff --git a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts index 4110a996fb5..a26fde8621e 100644 --- a/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts +++ b/web/app/components/workflow/node-actions-menu/use-node-actions-menu-model.ts @@ -8,6 +8,7 @@ import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useWorkflowStore } from '@/app/components/workflow/store' import { BlockEnum, NodeRunningStatus } from '@/app/components/workflow/types' import { canRunBySingle } from '@/app/components/workflow/utils' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { consoleQuery } from '@/service/console' import { matchesProviderReference } from '@/utils/provider-reference' import { useNodesInteractions } from '../hooks/use-nodes-interactions' @@ -41,7 +42,11 @@ export function useNodeActionsMenuModel({ ) const isChildNode = !!(data.isInIteration || data.isInLoop) - const canRun = canRunWorkflow && !nodesReadOnly && canRunBySingle(data.type, isChildNode) + const canRun = + canRunWorkflow && + !nodesReadOnly && + !isExternalAgentWorkflowPreviewData(data) && + canRunBySingle(data.type, isChildNode) const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running const canChangeBlock = !nodeMetaData.isTypeFixed && !nodeMetaData.isUndeletable && !nodesReadOnly const sourceHandle = useMemo(() => { @@ -62,6 +67,7 @@ export function useNodeActionsMenuModel({ }, [data.provider_id, data.provider_type, data.type, workflowTools]) const handleRun = useCallback(() => { + if (!canRun) return const store = workflowStore.getState() store.setInitShowLastRunTab(true) store.setPendingSingleRun({ @@ -70,7 +76,7 @@ export function useNodeActionsMenuModel({ }) handleNodeSelect(id) onClose() - }, [handleNodeSelect, id, isSingleRunning, onClose, workflowStore]) + }, [canRun, handleNodeSelect, id, isSingleRunning, onClose, workflowStore]) const handleCopy = useCallback(() => { onClose() diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx index c8cab8aab5e..c5bd9c0397c 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card.tsx @@ -2,9 +2,26 @@ import { RiMindMap } from '@remixicon/react' import { useTranslation } from 'react-i18next' import { useDocLink } from '@/context/i18n' -const FailBranchCard = () => { +const FailBranchCard = ({ + learnMorePlacement = 'inline', +}: { + learnMorePlacement?: 'inline' | 'below' +}) => { const { t } = useTranslation(['workflow']) const docLink = useDocLink() + const learnMore = ( + + {t(($) => $['common.learnMore'], { ns: 'workflow' })} + {learnMorePlacement === 'below' && ( + + )} + + ) return (
@@ -17,17 +34,10 @@ const FailBranchCard = () => {
{t(($) => $['nodes.common.errorHandle.failBranch.customizeTip'], { ns: 'workflow' })} -   - - {t(($) => $['common.learnMore'], { ns: 'workflow' })} - + {learnMorePlacement === 'inline' && <> {learnMore}}
+ {learnMorePlacement === 'below' &&
{learnMore}
} ) } diff --git a/web/app/components/workflow/nodes/_base/components/node-control.tsx b/web/app/components/workflow/nodes/_base/components/node-control.tsx index c2b5eb96285..7f5929a90d5 100644 --- a/web/app/components/workflow/nodes/_base/components/node-control.tsx +++ b/web/app/components/workflow/nodes/_base/components/node-control.tsx @@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next' import { useHooksStore } from '@/app/components/workflow/hooks-store' import { NodeActionsMenu } from '@/app/components/workflow/node-actions-menu' import { useWorkflowStore } from '@/app/components/workflow/store' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { useNodesInteractions } from '../../../hooks/use-nodes-interactions' import { useNodesReadOnly } from '../../../hooks/use-workflow' import { NodeRunningStatus } from '../../../types' @@ -27,7 +28,7 @@ const NodeControl: FC = ({ id, data, pluginInstallLocked }) => return (
= ({ id, data, pluginInstallLocked }) => onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > - {canRun && !nodesReadOnly && canRunBySingle(data.type, isChildNode) && ( - - )} + const store = workflowStore.getState() + store.setInitShowLastRunTab(true) + store.setPendingSingleRun({ + nodeId: id, + action, + }) + handleNodeSelect(id) + }} + > + {isSingleRunning ? ( + + ) : ( + + } /> + + {t(($) => $['panel.runThisStep'], { ns: 'workflow' })} + + + )} + + )}
diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx index 9831c4e6d7a..97bd3bb80fb 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/index.tsx @@ -25,7 +25,6 @@ import { getOutputDisplayType, getOutputTypeOptionValue, insertOutputChildAtPath, - isDefaultOutput, toDeclaredOutputChild, updateOutputChildAtPath, } from './utils' @@ -44,10 +43,10 @@ function OutputRow({ onEdit: () => void }) { const { t } = useTranslation(['workflow', 'common', 'workflowAgent']) - const description = getOutputDescription(output, t) + const description = editable ? output.description || '' : getOutputDescription(output, t) return (
-
+
@@ -144,6 +143,8 @@ export function AgentOutputVariables({ onChange, collapsed, onCollapse, + compact = false, + reservedOutputNames = AGENT_V2_RESERVED_OUTPUT_NAMES, }: AgentOutputVariablesProps) { const { t } = useTranslation(['workflowAgent']) const [editingState, setEditingState] = useState(null) @@ -277,11 +278,28 @@ export function AgentOutputVariables({ ) } return ( - + $['nodes.agent.outputVars.newOutput'], { ns: 'workflowAgent' })} + className="me-4" + onClick={() => { + handleCollapseChange(false) + handleNewOutput() + }} + > + + + ) : undefined + } + >
{outputs.map((output, index) => { - const editable = !isDefaultOutput(output) + const editable = !reservedOutputNames.has(output.name) const children = getOutputChildren(output) const isEditingOutput = editingState?.outputIndex === index && @@ -297,7 +315,7 @@ export function AgentOutputVariables({ key={`${output.name}-editing`} editingIndex={index} existingOutputs={outputs} - reservedNames={AGENT_V2_RESERVED_OUTPUT_NAMES} + reservedNames={reservedOutputNames} state={editingState} onCancel={() => setEditingState(null)} onConfirm={handleConfirm} @@ -332,18 +350,20 @@ export function AgentOutputVariables({
) })} -
- -
+ {!compact && ( +
+ +
+ )} {editingState && editingState.outputIndex == null ? ( setEditingState(null)} onConfirm={handleConfirm} /> - ) : ( + ) : !compact ? (
- )} + ) : null}
diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts index a9739a7d5c1..3b1fd5615f8 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/utils.ts @@ -45,6 +45,8 @@ export type AgentOutputVariablesProps = { onChange: (outputs: DeclaredOutputConfig[]) => void collapsed?: boolean onCollapse?: (collapsed: boolean) => void + compact?: boolean + reservedOutputNames?: ReadonlySet } export const OUTPUT_NAME_PATTERN = /^[a-z_]\w*$/i diff --git a/web/app/components/workflow/nodes/components.ts b/web/app/components/workflow/nodes/components.ts index f7582b3b572..cf9c59e4212 100644 --- a/web/app/components/workflow/nodes/components.ts +++ b/web/app/components/workflow/nodes/components.ts @@ -1,5 +1,7 @@ import type { ComponentProps, ComponentType } from 'react' import { createElement } from 'react' +import { ExternalAgentWorkflowPreviewNode } from '@/features/agent-v2/external-agent/workflow-preview/node' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { BlockEnum } from '../types' import { AgentV2Node } from './agent-v2/node' import { isAgentV2NodeData } from './agent-v2/types' @@ -36,6 +38,9 @@ type WorkflowAgentNodeProps = ComponentProps | ComponentProps< type WorkflowComponentMap = Record>> function WorkflowAgentNode(props: WorkflowAgentNodeProps) { + if (isExternalAgentWorkflowPreviewData(props.data)) + return createElement(ExternalAgentWorkflowPreviewNode, { data: props.data }) + if (isAgentV2NodeData(props.data)) return createElement(AgentV2Node, props as ComponentProps) @@ -65,7 +70,7 @@ export const NodeComponentMap = { [BlockEnum.DocExtractor]: DocExtractorNode, [BlockEnum.ListFilter]: ListFilterNode, [BlockEnum.Agent]: WorkflowAgentNode, - [BlockEnum.AgentV2]: AgentV2Node, + [BlockEnum.AgentV2]: WorkflowAgentNode, [BlockEnum.DataSource]: DataSourceNode, [BlockEnum.KnowledgeBase]: KnowledgeBaseNode, [BlockEnum.HumanInput]: WorkflowHumanInputNode, diff --git a/web/app/components/workflow/nodes/panel.tsx b/web/app/components/workflow/nodes/panel.tsx index 99d02aae25d..ad2fa9ee389 100644 --- a/web/app/components/workflow/nodes/panel.tsx +++ b/web/app/components/workflow/nodes/panel.tsx @@ -1,5 +1,7 @@ import type { Node } from '../types' import { memo } from 'react' +import { ExternalAgentWorkflowPreviewPanel } from '@/features/agent-v2/external-agent/workflow-preview/panel' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { CUSTOM_NODE } from '../constants' import { getNodeCatalogType } from '../utils/node' import BasePanel from './_base/components/workflow-panel' @@ -15,6 +17,9 @@ const Panel = memo((props: PanelProps) => { const nodeData = props.data if (nodeClass !== CUSTOM_NODE) return null + if (isExternalAgentWorkflowPreviewData(nodeData)) + return + const PanelComponent = PanelComponentMap[nodeData.type]! return ( diff --git a/web/app/components/workflow/utils/node.ts b/web/app/components/workflow/utils/node.ts index cf632f49349..5a3a6fa4166 100644 --- a/web/app/components/workflow/utils/node.ts +++ b/web/app/components/workflow/utils/node.ts @@ -3,6 +3,7 @@ import type { LoopNodeType } from '../nodes/loop/types' import type { CommonNodeType, Node } from '../types' import { Position } from 'reactflow' import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants' +import { isExternalAgentWorkflowPreviewData } from '@/features/agent-v2/external-agent/workflow-preview/types' import { CUSTOM_NODE, NESTED_ELEMENT_Z_INDEX } from '../constants' import { isAgentV2NodeData } from '../nodes/agent-v2/types' import { isHumanInputV2NodeData } from '../nodes/human-input-v2/types' @@ -11,6 +12,7 @@ import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants' import { BlockEnum } from '../types' export function getNodeCatalogType(data: CommonNodeType): BlockEnum { + if (isExternalAgentWorkflowPreviewData(data)) return BlockEnum.AgentV2 if (isAgentV2NodeData(data)) return BlockEnum.AgentV2 if (isHumanInputV2NodeData(data)) return BlockEnum.HumanInputV2 return data.type diff --git a/web/features/agent-v2/external-agent/workflow-preview/__tests__/preview.browser.spec.tsx b/web/features/agent-v2/external-agent/workflow-preview/__tests__/preview.browser.spec.tsx new file mode 100644 index 00000000000..8e834f6487e --- /dev/null +++ b/web/features/agent-v2/external-agent/workflow-preview/__tests__/preview.browser.spec.tsx @@ -0,0 +1,325 @@ +import type { ComponentType } from 'react' +import type { NodeProps } from 'reactflow' +import type { ExternalAgentWorkflowPreviewNodeData } from '../types' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { createInstance } from 'i18next' +import { I18nextProvider } from 'react-i18next' +import ReactFlow, { Handle, Position, ReactFlowProvider, useNodes } from 'reactflow' +import { page, userEvent } from 'vite-plus/test/browser' +import { render } from 'vitest-browser-react' +import { createEdge, createNode } from '@/app/components/workflow/__tests__/fixtures' +import { WorkflowContext } from '@/app/components/workflow/context' +import { EditorResourceScope } from '@/app/components/workflow/editor-resource/provider' +import { HooksStoreContext } from '@/app/components/workflow/hooks-store/provider' +import { createHooksStore } from '@/app/components/workflow/hooks-store/store' +import { NodeComponentMap } from '@/app/components/workflow/nodes/components' +import Panel from '@/app/components/workflow/nodes/panel' +import { createWorkflowStore } from '@/app/components/workflow/store/workflow' +import { BlockEnum, InputVarType, VarType } from '@/app/components/workflow/types' +import { EventEmitterContextProvider } from '@/context/event-emitter-provider' +import { createExternalRosterPreview } from '@/features/agent-v2/roster/external-roster-preview' +import agentRoster from '@/i18n/locales/en-US/agent-roster.json' +import common from '@/i18n/locales/en-US/common.json' +import workflowAgent from '@/i18n/locales/en-US/workflow-agent.json' +import workflowDebug from '@/i18n/locales/en-US/workflow-debug.json' +import workflow from '@/i18n/locales/en-US/workflow.json' +import { consoleQuery } from '@/service/console' +import { seedModelProviderQueries } from '@/test/console/model-provider' +import { createConsoleQueryWrapper } from '@/test/console/query-data' +import { seedToolProviders } from '@/test/console/tool-providers' +import { guardUnexpectedFetch } from '@/test/unexpected-fetch' +import { createExternalAgentWorkflowPreviewData } from '../preview' +import { isExternalAgentWorkflowPreviewData } from '../types' +import 'reactflow/dist/style.css' + +vi.mock('react-i18next', async (importOriginal) => importOriginal()) + +// The Agent catalog entry routes external preview data before mounting Dify API owners. +const AgentNode = NodeComponentMap[BlockEnum.Agent] as unknown as ComponentType<{ + id: string + data: ExternalAgentWorkflowPreviewNodeData +}> + +function CanvasNode({ id, data }: NodeProps) { + return ( + <> + + {isExternalAgentWorkflowPreviewData(data) ? ( + + ) : ( +
{data.title}
+ )} + + + ) +} + +const nodeTypes = { custom: CanvasNode } + +function SelectedPanel() { + const node = useNodes().find((node) => node.selected || node.data.selected) + return node ? : null +} + +async function setup() { + await page.viewport(1280, 1000) + const i18n = createInstance() + await i18n.init({ + lng: 'en-US', + resources: { 'en-US': { agentRoster, common, workflowAgent, workflowDebug, workflow } }, + keySeparator: false, + interpolation: { escapeValue: false }, + }) + const { queryClient, wrapper: QueryProvider } = createConsoleQueryWrapper({ + currentWorkspace: { role: 'owner' }, + }) + onTestFinished(() => queryClient.clear()) + seedModelProviderQueries(queryClient) + seedToolProviders(queryClient, { + builtInTools: [], + customTools: [], + workflowTools: [], + mcpTools: [], + }) + queryClient.setQueryData(consoleQuery.spec.schemaDefinitions.get.queryKey(), []) + const syncDraft = vi.fn(async () => {}) + const hooks = createHooksStore({ doSyncWorkflowDraft: syncDraft }) + const store = createWorkflowStore({}) + const agent = createExternalRosterPreview()[1]! + const data: ExternalAgentWorkflowPreviewNodeData = { + type: BlockEnum.Agent, + title: 'Agent', + desc: '', + selected: true, + ...createExternalAgentWorkflowPreviewData(agent), + } + const nodes = [ + createNode({ + id: 'input', + position: { x: 0, y: 0 }, + data: { + type: BlockEnum.Start, + title: 'Input', + variables: [ + { + variable: 'contract_text', + type: InputVarType.paragraph, + label: 'Contract text', + required: false, + }, + ], + }, + }), + createNode({ + id: 'policy', + position: { x: 0, y: 100 }, + data: { + type: BlockEnum.Code, + title: 'Policy', + outputs: { result: { type: VarType.string } }, + }, + }), + createNode({ id: 'external-agent', position: { x: 160, y: 50 }, selected: true, data }), + ] + const edges = [ + createEdge({ source: 'input', target: 'policy' }), + createEdge({ source: 'policy', target: 'external-agent' }), + ] + await render( + + + + + + + +
+
+ +
+ +
+
+
+
+
+
+
+
, + ) + onTestFinished(() => { + expect(syncDraft).not.toHaveBeenCalled() + }) + const panel = page.getByRole('region', { name: 'Contract Review Agent', exact: true }) + await expect.element(panel).toBeVisible() + return { panel, canvas: page.getByRole('region', { name: 'Agent canvas', exact: true }) } +} + +async function closeAndReopen( + panel: ReturnType, + name = 'Contract Review Agent', +) { + await panel.getByRole('button', { name: 'Close', exact: true }).click() + await expect.element(panel).not.toBeInTheDocument() + await page + .getByRole('region', { name: 'Agent canvas', exact: true }) + .getByText(name, { exact: true }) + .click() + const reopened = page.getByRole('region', { name, exact: true }) + await expect.element(reopened).toBeVisible() + return reopened +} + +async function replaceTask(task: ReturnType, value: string) { + await task.click() + await userEvent.keyboard( + /Mac/.test(navigator.userAgent) ? '{Meta>}a{/Meta}' : '{Control>}a{/Control}', + ) + await userEvent.keyboard('{Backspace}') + await expect.element(task).toHaveTextContent('') + await task.fill(value) +} + +beforeEach(({ onTestFinished }) => { + onTestFinished(guardUnexpectedFetch()) +}) + +it('shows the external badge and updates the canvas agent through the change picker without Dify APIs', async () => { + const { panel, canvas } = await setup() + await expect.element(panel.getByText('External', { exact: true })).toBeVisible() + await expect.element(canvas.getByText('External', { exact: true })).toBeVisible() + await expect + .element(panel.getByRole('tab', { name: 'Settings', exact: true })) + .toHaveAttribute('aria-selected', 'true') + await expect + .element(panel.getByRole('button', { name: /Run this step/i })) + .toHaveAttribute('aria-disabled', 'true') + await panel.getByRole('button', { name: 'Change', exact: true }).click() + await page.getByRole('button', { name: /Document Review Agent/ }).click() + const changedPanel = page.getByRole('region', { name: 'Document Review Agent', exact: true }) + await expect.element(changedPanel).toBeVisible() + await expect.element(canvas.getByText('Document Review Agent', { exact: true })).toBeVisible() + await expect + .element(canvas.getByText('Contract Review Agent', { exact: true })) + .not.toBeInTheDocument() + await closeAndReopen(changedPanel, 'Document Review Agent') +}) + +it('keeps edits made in the real task editor when closing and reopening the node', async () => { + const { panel } = await setup() + const task = panel.getByRole('textbox', { name: 'Agent task', exact: true }) + await expect.element(task).toMatchTextContent(/Policy/) + await expect.element(task).toMatchTextContent(/Contract/) + await replaceTask(task, 'Review the renewal terms and highlight unusual liability clauses.') + await userEvent.tab() + const reopened = await closeAndReopen(panel) + await expect + .element(reopened.getByRole('textbox', { name: 'Agent task', exact: true })) + .toHaveTextContent('Review the renewal terms and highlight unusual liability clauses.') +}) + +it('retains the wait limit and error strategy through panel reopening', async () => { + const { panel } = await setup() + const enabled = panel.getByRole('switch', { name: 'External agent wait limit', exact: true }) + const minutes = panel.getByRole('textbox', { name: 'Wait limit', exact: true }) + await expect.element(enabled).toBeChecked() + await expect.element(minutes).toHaveValue('10') + await minutes.fill('25') + await userEvent.tab() + await expect + .element(panel.getByRole('slider', { name: 'Wait limit', exact: true })) + .toHaveAttribute('aria-valuenow', '25') + await enabled.click() + await expect.element(enabled).not.toBeChecked() + await enabled.click() + await expect.element(minutes).toHaveValue('25') + const errorHandling = panel.getByRole('button', { name: 'Fail Branch', exact: true }) + await expect.element(errorHandling).toBeVisible() + await errorHandling.click() + await page.getByRole('menuitemradio', { name: /^None/ }).click() + const reopened = await closeAndReopen(panel) + await expect + .element(reopened.getByRole('textbox', { name: 'Wait limit', exact: true })) + .toHaveValue('25') + await expect.element(reopened.getByRole('button', { name: 'None', exact: true })).toBeVisible() + await expect + .element(reopened.getByRole('combobox', { name: 'On timeout', exact: true })) + .toMatchTextContent('End waiting') +}) + +it('creates an output token from the task editor and retains its output definition', async () => { + const { panel } = await setup() + await replaceTask(panel.getByRole('textbox', { name: 'Agent task', exact: true }), 'Return ') + await userEvent.keyboard('/') + await page + .getByRole('listbox', { name: 'Typeahead menu', exact: true }) + .getByRole('button', { name: 'New output', exact: true }) + .click() + const name = page.getByRole('textbox', { name: 'Field name', exact: true }) + await expect.element(name).toHaveFocus() + await name.fill('review_notes') + await userEvent.keyboard('{Enter}') + await expect.element(name).not.toBeInTheDocument() + await expect + .element(panel.getByRole('textbox', { name: 'Agent task', exact: true })) + .toMatchTextContent(/review_notes/) + const reopened = await closeAndReopen(panel) + await expect + .element(reopened.getByRole('textbox', { name: 'Agent task', exact: true })) + .toMatchTextContent(/review_notes/) + await expect + .element(reopened.getByRole('button', { name: 'Delete review_notes', exact: true })) + .toBeInTheDocument() +}) + +it('allows the default text output to be edited and deleted while retaining a newly added output', async () => { + const { panel } = await setup() + await expect.element(panel.getByText('Text returned by the agent', { exact: true })).toBeVisible() + await panel.getByText('text', { exact: true }).last().hover() + await panel.getByRole('button', { name: 'Edit text', exact: true }).click() + await panel + .getByRole('form', { name: 'Output variable editor', exact: true }) + .getByRole('textbox', { name: 'Description', exact: true }) + .fill('Contract review result') + await panel.getByRole('button', { name: 'Confirm', exact: true }).click() + await expect.element(panel.getByText('Contract review result', { exact: true })).toBeVisible() + await panel.getByText('text', { exact: true }).last().hover() + await panel.getByRole('button', { name: 'Edit text', exact: true }).click() + const name = panel.getByRole('textbox', { name: 'Field name', exact: true }) + await name.fill('summary') + await panel.getByRole('button', { name: 'Confirm', exact: true }).click() + await expect.element(panel.getByText('summary', { exact: true })).toBeVisible() + await panel.getByRole('button', { name: 'New output', exact: true }).click() + await name.fill('review_notes') + await panel.getByRole('button', { name: 'Confirm', exact: true }).click() + await panel.getByText('summary', { exact: true }).hover() + await panel.getByRole('button', { name: 'Delete summary', exact: true }).click() + await expect.element(panel.getByText('summary', { exact: true })).not.toBeInTheDocument() + const reopened = await closeAndReopen(panel) + await expect.element(reopened.getByText('review_notes', { exact: true })).toBeVisible() + await expect.element(reopened.getByText('text', { exact: true })).not.toBeInTheDocument() +}) + +it('shows the empty Last Run tab without requesting an execution record', async () => { + const { panel } = await setup() + await panel.getByRole('tab', { name: 'Last Run', exact: true }).click() + await expect + .element(panel.getByText('The results of the last run will be displayed here', { exact: true })) + .toBeVisible() + await panel.getByRole('tab', { name: 'Settings', exact: true }).click() + await expect + .element(panel.getByRole('textbox', { name: 'Agent task', exact: true })) + .toBeVisible() +}) diff --git a/web/features/agent-v2/external-agent/workflow-preview/agent-field.tsx b/web/features/agent-v2/external-agent/workflow-preview/agent-field.tsx new file mode 100644 index 00000000000..5e543239a05 --- /dev/null +++ b/web/features/agent-v2/external-agent/workflow-preview/agent-field.tsx @@ -0,0 +1,126 @@ +import type { ExternalAgentWorkflowPreviewAgent } from './types' +import { Button } from '@langgenius/dify-ui/button' +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useId, useState } from 'react' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import { SearchInput } from '@/app/components/base/search-input' +import { externalWorkflowAgentExamples } from '@/app/components/workflow/block-selector/workflow-agent-selector-preview' +import { ExternalAgentBadge } from './external-badge' + +export function ExternalAgentField({ + agent, + onChange, +}: { + agent: ExternalAgentWorkflowPreviewAgent + onChange: (agent: ExternalAgentWorkflowPreviewAgent) => void +}) { + const { t } = useTranslation(['workflow', 'workflowAgent', 'agentRoster']) + const labelId = useId() + const [open, setOpen] = useState(false) + return ( + +
+
+ + {t(($) => $['nodes.agent.roster.label'], { ns: 'workflow' })} + + } + > + {t(($) => $['nodes.agent.roster.change'], { ns: 'workflowAgent' })} + +
+ + } + > + + + ), + background: 'var(--color-background-default)', + }} + /> + + + {agent.name} + + + + +
+ + + {t(($) => $['roster.nodeSelector.dialogLabel'], { ns: 'agentRoster' })} + + { + onChange(next) + setOpen(false) + }} + /> + +
+ ) +} + +function ExternalAgentOptions({ + onSelect, +}: { + onSelect: (agent: ExternalAgentWorkflowPreviewAgent) => void +}) { + const { t } = useTranslation(['agentRoster']) + const [search, setSearch] = useState('') + const agents = externalWorkflowAgentExamples.filter((agent) => + agent.name.toLowerCase().includes(search.trim().toLowerCase()), + ) + return ( +
+
+ $['roster.searchLabel'])} + placeholder={t(($) => $['roster.nodeSelector.searchAgentsPlaceholder'])} + /> +
+
+ {agents.map((agent) => ( + + ))} + {agents.length === 0 && ( +

+ {t(($) => $['roster.emptySearch'])} +

+ )} +
+
+ ) +} diff --git a/web/features/agent-v2/external-agent/workflow-preview/external-badge.tsx b/web/features/agent-v2/external-agent/workflow-preview/external-badge.tsx new file mode 100644 index 00000000000..039d20f4df3 --- /dev/null +++ b/web/features/agent-v2/external-agent/workflow-preview/external-badge.tsx @@ -0,0 +1,10 @@ +import { useTranslation } from 'react-i18next' + +export function ExternalAgentBadge() { + const { t } = useTranslation(['agentRoster']) + return ( + + {t(($) => $['roster.nodeSelector.source.external'])} + + ) +} diff --git a/web/features/agent-v2/external-agent/workflow-preview/external-settings.tsx b/web/features/agent-v2/external-agent/workflow-preview/external-settings.tsx new file mode 100644 index 00000000000..7ea56a54f89 --- /dev/null +++ b/web/features/agent-v2/external-agent/workflow-preview/external-settings.tsx @@ -0,0 +1,220 @@ +import { Field, FieldLabel } from '@langgenius/dify-ui/field' +import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip' +import { + NumberField, + NumberFieldGroup, + NumberFieldInput, + NumberFieldUnit, +} from '@langgenius/dify-ui/number-field' +import { + Select, + SelectContent, + SelectItem, + SelectItemIndicator, + SelectItemText, + SelectLabel, + SelectTrigger, +} from '@langgenius/dify-ui/select' +import { Separator } from '@langgenius/dify-ui/separator' +import { + Slider, + SliderControl, + SliderIndicator, + SliderLabel, + SliderThumb, + SliderTrack, +} from '@langgenius/dify-ui/slider' +import { Switch } from '@langgenius/dify-ui/switch' +import { useId, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Collapse, + CollapseActions, + CollapseContent, + CollapseHeader, + CollapseIndicator, + CollapseTitle, + CollapseTrigger, +} from '@/app/components/workflow/nodes/_base/components/collapse' +import ErrorHandleTypeSelector from '@/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector' +import FailBranchCard from '@/app/components/workflow/nodes/_base/components/error-handle/fail-branch-card' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' + +type ExternalAgentWorkflowSettingsProps = { + waitLimitEnabled: boolean + waitLimitMinutes: number + onWaitLimitEnabledChange: (enabled: boolean) => void + onWaitLimitMinutesChange: (minutes: number) => void + errorStrategy: ErrorHandleTypeEnum + onErrorStrategyChange: (strategy: ErrorHandleTypeEnum) => void +} + +export function ExternalAgentWorkflowSettings({ + waitLimitEnabled, + waitLimitMinutes, + onWaitLimitEnabledChange, + onWaitLimitMinutesChange, + errorStrategy, + onErrorStrategyChange, +}: ExternalAgentWorkflowSettingsProps) { + const { t } = useTranslation(['agentRoster', 'workflow']) + const waitLimitTitleId = useId() + const waitLimitLabelId = useId() + const timeoutHelpId = useId() + const errorHandlingTitleId = useId() + const [errorHandlingCollapsed, setErrorHandlingCollapsed] = useState(false) + const waitLimitLabel = t(($) => $['external.workflow.waitLimit.label']) + const waitLimitHelp = t(($) => $['external.workflow.waitLimit.help']) + const hasErrorStrategy = errorStrategy !== ErrorHandleTypeEnum.none + + const changeWaitLimit = (minutes: number | null) => { + if (minutes !== null && Number.isFinite(minutes) && minutes > 0) + onWaitLimitMinutesChange(minutes) + } + + return ( + <> +
+ +
+ + {t(($) => $['external.workflow.waitLimit.title'])} + + + + {waitLimitHelp} + + +
+
+ {waitLimitEnabled && ( +
+
+ + {waitLimitLabel} + +
+ + value={waitLimitMinutes} + min={Math.min(1, waitLimitMinutes)} + max={Math.max(30, waitLimitMinutes)} + step={1} + onValueChange={changeWaitLimit} + className="w-27" + > + {waitLimitLabel} + + + + + + + + + {waitLimitLabel} + + + + + {t(($) => $['external.workflow.waitLimit.unit'])} + + + + +
+
+ value="end-waiting"> +
+ + {t(($) => $['external.workflow.waitLimit.onTimeout'])} + + + {t(($) => $['external.workflow.waitLimit.endWaiting'])} + +
+ + + + {t(($) => $['external.workflow.waitLimit.endWaiting'])} + + + + + +

+ {waitLimitHelp} +

+
+ )} +
+ +
+ + + + + {t(($) => $['nodes.common.errorHandle.title'], { ns: 'workflow' })} + + + + + + {t(($) => $['nodes.common.errorHandle.tip'], { ns: 'workflow' })} + + + {hasErrorStrategy && ( + + + + )} + +
+ { + setErrorHandlingCollapsed(false) + onErrorStrategyChange(strategy) + }} + /> +
+
+
+ + {errorStrategy === ErrorHandleTypeEnum.failBranch && ( + + )} + {errorStrategy === ErrorHandleTypeEnum.defaultValue && ( +

+ {t(($) => $['nodes.common.errorHandle.defaultValue.desc'], { ns: 'workflow' })} +

+ )} +
+
+
+ + ) +} diff --git a/web/features/agent-v2/external-agent/workflow-preview/node.tsx b/web/features/agent-v2/external-agent/workflow-preview/node.tsx new file mode 100644 index 00000000000..aa38e75ec8f --- /dev/null +++ b/web/features/agent-v2/external-agent/workflow-preview/node.tsx @@ -0,0 +1,40 @@ +import type { ExternalAgentWorkflowPreviewNodeData } from './types' +import { useTranslation } from 'react-i18next' +import AppIcon from '@/app/components/base/app-icon' +import { ExternalAgentBadge } from './external-badge' + +export function ExternalAgentWorkflowPreviewNode({ + data, +}: { + data: ExternalAgentWorkflowPreviewNodeData +}) { + const { t } = useTranslation(['workflow']) + const agent = data._externalAgentPreview + + return ( +
+
+ {t(($) => $['nodes.agent.roster.label'])} +
+
+ , + background: 'var(--color-components-icon-bg-midnight-soft)', + }} + /> +
+
+ {agent.name} + +
+ {agent.role && ( +
{agent.role}
+ )} +
+
+
+ ) +} diff --git a/web/features/agent-v2/external-agent/workflow-preview/panel.tsx b/web/features/agent-v2/external-agent/workflow-preview/panel.tsx new file mode 100644 index 00000000000..f479d6b9c35 --- /dev/null +++ b/web/features/agent-v2/external-agent/workflow-preview/panel.tsx @@ -0,0 +1,227 @@ +import type { ExternalAgentWorkflowPreviewNodeData } from './types' +import type { CommonNodeType } from '@/app/components/workflow/types' +import { IconButton } from '@langgenius/dify-ui/icon-button' +import { Menu, MenuContent, MenuItem, MenuTrigger } from '@langgenius/dify-ui/menu' +import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' +import { useCallback, useId, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useReactFlow } from 'reactflow' +import BlockIcon from '@/app/components/workflow/block-icon' +import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' +import { TitleInput } from '@/app/components/workflow/nodes/_base/components/title-description-input' +import NoLastRunData from '@/app/components/workflow/nodes/_base/components/workflow-panel/last-run/no-data' +import { AgentOutputVariables } from '@/app/components/workflow/nodes/agent-v2/components/agent-output-variables' +import { BlockEnum } from '@/app/components/workflow/types' +import { ExternalAgentField } from './agent-field' +import { ExternalAgentWorkflowSettings } from './external-settings' +import { externalAgentDefaultOutputs, externalAgentExampleTask } from './preview' +import { ExternalAgentTaskField } from './task-field' +import { isExternalAgentWorkflowPreviewData } from './types' + +const externalReservedOutputNames: ReadonlySet = new Set() + +type PreviewUpdate = + | Partial + | (( + current: ExternalAgentWorkflowPreviewNodeData, + ) => Partial) + +export function ExternalAgentWorkflowPreviewPanel({ + id, + data, +}: { + id: string + data: ExternalAgentWorkflowPreviewNodeData +}) { + const { t } = useTranslation(['agentRoster', 'common', 'workflow', 'workflowDebug']) + const { setNodes, setEdges } = useReactFlow() + const [tab, setTab] = useState<'settings' | 'lastRun'>('settings') + const [outputsCollapsed, setOutputsCollapsed] = useState(false) + const titleContainerRef = useRef(null) + const renameRequestedRef = useRef(false) + const descriptionId = useId() + const agent = data._externalAgentPreview + const task = data.agent_task ?? externalAgentExampleTask + const outputs = data.agent_declared_outputs ?? externalAgentDefaultOutputs + const waitLimit = data._externalAgentWaitLimit ?? { enabled: true, minutes: 10 } + const update = useCallback( + (patch: PreviewUpdate) => { + // Preview configuration belongs to this local canvas node. It does not enter draft sync + // or the collaboration writer; those boundaries also exclude temporary external nodes. + setNodes((nodes) => + nodes.map((node) => { + if (node.id !== id || !isExternalAgentWorkflowPreviewData(node.data)) return node + const changes = typeof patch === 'function' ? patch(node.data) : patch + return { + ...node, + ...(typeof changes.selected === 'boolean' ? { selected: changes.selected } : {}), + data: { ...node.data, ...changes }, + } + }), + ) + }, + [id, setNodes], + ) + const close = () => update({ selected: false }) + const remove = () => { + setNodes((nodes) => nodes.filter((node) => node.id !== id)) + setEdges((edges) => edges.filter((edge) => edge.source !== id && edge.target !== id)) + } + const runLabel = t(($) => $['panel.runThisStep'], { ns: 'workflow' }) + + return ( +
+ { + if (value === 'settings' || value === 'lastRun') setTab(value) + }} + className="flex min-h-0 flex-1 flex-col" + > +
+
+ +
+ update({ title })} /> +
+ + + + + } + /> + {t(($) => $['external.workflow.runUnavailable'])} + + { + if (open) renameRequestedRef.current = false + }} + > + $['operation.more'], { ns: 'common' })}> + + + } + /> + + renameRequestedRef.current + ? (titleContainerRef.current?.querySelector('input') ?? true) + : true + } + > + { + renameRequestedRef.current = true + }} + > + + {t(($) => $['operation.rename'], { ns: 'common' })} + + + + {t(($) => $['operation.delete'], { ns: 'common' })} + + + + + $['operation.close'], { ns: 'common' })} + onClick={close} + > + + +
+ +