From 9e6a2cd19c957d422284880c3ddde01fdc02bd67 Mon Sep 17 00:00:00 2001 From: Joel Date: Wed, 15 Jul 2026 17:54:09 +0800 Subject: [PATCH] feat: new agent support import and export in frontend (#39012) --- .../components/app/create-app-dropdown.tsx | 95 ++++++ .../__tests__/index.spec.tsx | 92 +++++- .../app/create-from-dsl-modal/index.tsx | 42 ++- .../apps/app-list-header-filters.tsx | 82 +---- .../hooks/__tests__/use-DSL.spec.ts | 29 ++ .../update-dsl-modal.helpers.spec.ts | 38 +++ .../__tests__/update-dsl-modal.spec.tsx | 73 +++++ .../nodes/agent-v2/__tests__/hooks.spec.tsx | 292 +++++++++++++++++- .../nodes/agent-v2/__tests__/panel.spec.tsx | 131 +++++++- .../nodes/agent-v2/agent-soul-config.ts | 79 +++-- .../agent-orchestrate-panel-content.spec.tsx | 7 +- ...ave-inline-agent-to-roster-dialog.spec.tsx | 63 +++- .../agent-orchestrate-panel-content.tsx | 73 +++-- .../save-inline-agent-to-roster-dialog.tsx | 94 ++++-- .../workflow/nodes/agent-v2/hooks.ts | 141 +++++++-- .../workflow/nodes/agent-v2/panel.tsx | 175 +++++++---- .../workflow/update-dsl-modal.helpers.ts | 6 +- .../components/workflow/update-dsl-modal.tsx | 13 +- .../workflow-preview/__tests__/index.spec.tsx | 37 ++- .../agent-composer/__tests__/store.spec.ts | 34 ++ .../agent-v2/agent-composer/conversions.ts | 5 + .../agent-v2/agent-composer/form-state.ts | 2 + .../missing-reference-warning.spec.tsx | 20 ++ .../common/missing-reference-warning.tsx | 32 ++ .../files/__tests__/index.spec.tsx | 47 +++ .../components/orchestrate/files/index.tsx | 48 ++- .../skills/__tests__/index.spec.tsx | 43 +++ .../components/orchestrate/skills/item.tsx | 63 ++-- .../__tests__/agent-roster-list.spec.tsx | 33 ++ .../__tests__/roster-toolbar.spec.tsx | 34 +- .../roster/components/agent-roster-list.tsx | 28 ++ .../roster/components/create-agent-dialog.tsx | 28 +- .../roster/components/roster-create-menu.tsx | 33 ++ .../roster/components/roster-toolbar.tsx | 4 +- web/hooks/use-import-dsl.ts | 15 +- web/i18n/ar-TN/agent-v-2.json | 2 + web/i18n/de-DE/agent-v-2.json | 2 + web/i18n/en-US/agent-v-2.json | 2 + web/i18n/es-ES/agent-v-2.json | 2 + web/i18n/fa-IR/agent-v-2.json | 2 + web/i18n/fr-FR/agent-v-2.json | 2 + web/i18n/hi-IN/agent-v-2.json | 2 + web/i18n/id-ID/agent-v-2.json | 2 + web/i18n/it-IT/agent-v-2.json | 2 + web/i18n/ja-JP/agent-v-2.json | 2 + web/i18n/ko-KR/agent-v-2.json | 2 + web/i18n/nl-NL/agent-v-2.json | 2 + web/i18n/pl-PL/agent-v-2.json | 2 + web/i18n/pt-BR/agent-v-2.json | 2 + web/i18n/ro-RO/agent-v-2.json | 2 + web/i18n/ru-RU/agent-v-2.json | 2 + web/i18n/sl-SI/agent-v-2.json | 2 + web/i18n/th-TH/agent-v-2.json | 2 + web/i18n/tr-TR/agent-v-2.json | 2 + web/i18n/uk-UA/agent-v-2.json | 2 + web/i18n/vi-VN/agent-v-2.json | 2 + web/i18n/zh-Hans/agent-v-2.json | 2 + web/i18n/zh-Hant/agent-v-2.json | 2 + web/models/app.ts | 8 + web/service/client.spec.ts | 106 +++++++ web/service/client.ts | 88 ++++++ web/types/app.ts | 9 +- web/utils/app-redirection.spec.ts | 21 ++ web/utils/app-redirection.ts | 6 +- web/utils/dsl-import-warning.ts | 12 + web/utils/imported-app-redirection.spec.ts | 50 +++ web/utils/imported-app-redirection.ts | 22 ++ 67 files changed, 2071 insertions(+), 328 deletions(-) create mode 100644 web/app/components/app/create-app-dropdown.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/missing-reference-warning.spec.tsx create mode 100644 web/features/agent-v2/agent-detail/configure/components/orchestrate/common/missing-reference-warning.tsx create mode 100644 web/features/agent-v2/roster/components/roster-create-menu.tsx create mode 100644 web/utils/dsl-import-warning.ts create mode 100644 web/utils/imported-app-redirection.spec.ts create mode 100644 web/utils/imported-app-redirection.ts diff --git a/web/app/components/app/create-app-dropdown.tsx b/web/app/components/app/create-app-dropdown.tsx new file mode 100644 index 00000000000..99be41f1c4c --- /dev/null +++ b/web/app/components/app/create-app-dropdown.tsx @@ -0,0 +1,95 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { useTranslation } from 'react-i18next' + +type CreateAppDropdownProps = { + onCreateBlank: () => void + onCreateTemplate?: () => void + onImportDSL: () => void +} + +export function CreateAppDropdown({ + onCreateBlank, + onCreateTemplate, + onImportDSL, +}: CreateAppDropdownProps) { + const { t } = useTranslation() + + return ( + + + + {t(($) => $['operation.create'], { ns: 'common' })} + + + } + /> + +
+ + + + {t(($) => $['newApp.startFromBlank'], { ns: 'app' })} + + + {onCreateTemplate && ( + + + + {t(($) => $['newApp.startFromTemplate'], { ns: 'app' })} + + + )} +
+
+
+ + + + + + + {t(($) => $.importDSL, { ns: 'app' })} + + + {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} + + + +
+ + + ) +} diff --git a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx index ab8f378d558..5e65dec9adc 100644 --- a/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx +++ b/web/app/components/app/create-from-dsl-modal/__tests__/index.spec.tsx @@ -13,6 +13,9 @@ const mockImportDSLConfirm = vi.fn() const mockTrackCreateApp = vi.fn() const mockHandleCheckPluginDependencies = vi.fn() const mockGetRedirection = vi.fn() +const mockResolveImportedAppRedirectionTarget = vi.fn( + async (target: Record) => target, +) const mockInvalidateAppList = vi.hoisted(() => vi.fn()) const toastMocks = vi.hoisted(() => ({ call: vi.fn(), @@ -139,6 +142,11 @@ vi.mock('@/utils/app-redirection', () => ({ getRedirection: (...args: unknown[]) => mockGetRedirection(...args), })) +vi.mock('@/utils/imported-app-redirection', () => ({ + resolveImportedAppRedirectionTarget: (target: Record) => + mockResolveImportedAppRedirectionTarget(target), +})) + vi.mock('@langgenius/dify-ui/toast', () => ({ toast: Object.assign((...args: unknown[]) => toastMocks.call(...args), { success: (...args: unknown[]) => toastMocks.success(...args), @@ -320,8 +328,16 @@ describe('CreateFromDSLModal', () => { id: 'import-2', status: DSLImportStatus.COMPLETED_WITH_WARNINGS, app_id: 'app-2', - app_mode: AppModeEnum.CHAT, + app_mode: AppModeEnum.AGENT, permission_keys: ['app.acl.view_layout'], + warnings: [ + { + code: 'agent_secret_required', + path: 'agent_packages.agent_1.soul.env.secret_refs', + message: "Agent secret 'SEARCH_TOKEN' must be configured.", + details: { name: 'SEARCH_TOKEN' }, + }, + ], }) render( @@ -344,6 +360,13 @@ describe('CreateFromDSLModal', () => { mode: DSLImportMode.YAML_CONTENT, yaml_content: 'app: demo', }) + expect(toastMocks.call).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\.)newApp\.caution(?=$|:)/), + { + type: 'warning', + description: "Agent secret 'SEARCH_TOKEN' must be configured.", + }, + ) }) it('should remove the current file and keep the create shortcut guarded', async () => { @@ -437,6 +460,73 @@ describe('CreateFromDSLModal', () => { ) }) + it('should surface Agent warnings after confirming a pending import', async () => { + vi.useFakeTimers() + mockImportDSL.mockResolvedValue({ + id: 'agent-import-pending', + status: DSLImportStatus.PENDING, + imported_dsl_version: '1.0.0', + current_dsl_version: '2.0.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + app_id: 'agent-app-1', + app_mode: AppModeEnum.AGENT, + warnings: [ + { + code: 'agent_tool_authorization_required', + path: 'agent_packages.agent_1.soul.tools.dify_tools.0', + message: "Agent tool 'web_search' requires authorization.", + details: { tool_name: 'web_search' }, + }, + ], + }) + mockResolveImportedAppRedirectionTarget.mockResolvedValueOnce({ + id: 'agent-app-1', + mode: AppModeEnum.AGENT, + permission_keys: undefined, + bound_agent_id: 'agent-1', + }) + + render( + , + ) + + await act(async () => { + fireEvent.click(getCreateButton()) + }) + await act(async () => { + vi.advanceTimersByTime(300) + }) + await act(async () => { + fireEvent.click(screen.getAllByRole('button', { name: /newApp\.Confirm/ })[0]!) + }) + + expect(toastMocks.call).toHaveBeenCalledWith(expect.stringMatching(/newApp\.caution/), { + type: 'warning', + description: "Agent tool 'web_search' requires authorization.", + }) + expect(mockResolveImportedAppRedirectionTarget).toHaveBeenCalledWith({ + id: 'agent-app-1', + mode: AppModeEnum.AGENT, + permission_keys: undefined, + }) + expect(mockGetRedirection).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent-app-1', + mode: AppModeEnum.AGENT, + bound_agent_id: 'agent-1', + }), + mockPush, + expect.any(Object), + ) + }) + it('should close the DSL mismatch modal when dialog requests close', async () => { vi.useFakeTimers() mockImportDSL.mockResolvedValue({ diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index f5cd1db9b56..60b8c6bb94d 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -27,6 +27,8 @@ import { importDSL, importDSLConfirm } from '@/service/apps' import { useInvalidateAppList } from '@/service/use-apps' import { getRedirection } from '@/utils/app-redirection' import { trackCreateApp } from '@/utils/create-app-tracking' +import { getDSLImportWarningDescription } from '@/utils/dsl-import-warning' +import { resolveImportedAppRedirectionTarget } from '@/utils/imported-app-redirection' import { CreateFromDSLModalTab } from './types' import Uploader from './uploader' @@ -123,6 +125,7 @@ const CreateFromDSLModal = ({ imported_dsl_version, current_dsl_version, permission_keys, + warnings, } = response if ( status === DSLImportStatus.COMPLETED || @@ -142,7 +145,8 @@ const CreateFromDSLModal = ({ type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', description: status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + ? getDSLImportWarningDescription(warnings) || + t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) : undefined, }, ) @@ -150,7 +154,12 @@ const CreateFromDSLModal = ({ invalidateAppList() if (app_id) { await handleCheckPluginDependencies(app_id) - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { + const redirectionTarget = await resolveImportedAppRedirectionTarget({ + id: app_id, + mode: app_mode, + permission_keys, + }) + getRedirection(redirectionTarget, push, { currentUserId, resourceMaintainer: currentUserId, workspacePermissionKeys, @@ -199,19 +208,40 @@ const CreateFromDSLModal = ({ import_id: importId, }) - const { status, app_id, app_mode, permission_keys } = response + const { status, app_id, app_mode, permission_keys, warnings } = response - if (status === DSLImportStatus.COMPLETED) { + if ( + status === DSLImportStatus.COMPLETED || + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ) { trackCreateApp({ source: 'studio_upload', appMode: app_mode }) if (onSuccess) onSuccess() if (onClose) onClose() - toast.success(t(($) => $['newApp.appCreated'], { ns: 'app' })) + toast( + t( + ($) => $[status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution'], + { ns: 'app' }, + ), + { + type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', + description: + status === DSLImportStatus.COMPLETED_WITH_WARNINGS + ? getDSLImportWarningDescription(warnings) || + t(($) => $['newApp.appCreateDSLWarning'], { ns: 'app' }) + : undefined, + }, + ) if (app_id) await handleCheckPluginDependencies(app_id) setNeedRefresh('1') invalidateAppList() if (app_id) { - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { + const redirectionTarget = await resolveImportedAppRedirectionTarget({ + id: app_id, + mode: app_mode, + permission_keys, + }) + getRedirection(redirectionTarget, push, { currentUserId, resourceMaintainer: currentUserId, workspacePermissionKeys, diff --git a/web/app/components/apps/app-list-header-filters.tsx b/web/app/components/apps/app-list-header-filters.tsx index 7fcd42d5674..c8b09257959 100644 --- a/web/app/components/apps/app-list-header-filters.tsx +++ b/web/app/components/apps/app-list-header-filters.tsx @@ -2,15 +2,8 @@ import type { GetAppsData } from '@dify/contracts/api/console/apps/types.gen' import type { AppListCategory } from './app-type-filter-shared' -import { Button } from '@langgenius/dify-ui/button' -import { cn } from '@langgenius/dify-ui/cn' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@langgenius/dify-ui/dropdown-menu' import { useTranslation } from 'react-i18next' +import { CreateAppDropdown } from '@/app/components/app/create-app-dropdown' import { SearchInput } from '@/app/components/base/search-input' import { TagFilter } from '@/features/tag-management/components/tag-filter' import Link from '@/next/link' @@ -88,74 +81,11 @@ export function AppListHeaderFilters({ {t(($) => $['studio.viewSnippets'], { ns: 'app' })} {showCreateButton && ( - - - - {t(($) => $['operation.create'], { ns: 'common' })} - - - } - /> - -
- - - - {t(($) => $['newApp.startFromBlank'], { ns: 'app' })} - - - - - - {t(($) => $['newApp.startFromTemplate'], { ns: 'app' })} - - -
-
-
- - - - - - - {t(($) => $.importDSL, { ns: 'app' })} - - - {t(($) => $['newApp.dropDSLToCreateApp'], { ns: 'app' })} - - - -
- - + )}
diff --git a/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts b/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts index a9bc05b31da..88a0b0e1b35 100644 --- a/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts +++ b/web/app/components/workflow-app/hooks/__tests__/use-DSL.spec.ts @@ -115,6 +115,35 @@ describe('useDSLByCanEdit', () => { ) }) + it('should download workflow DSL containing portable Agent packages unchanged', async () => { + const agentDSL = `version: 0.7.0 +agent_packages: + agent_1: + schema_version: 1 +workflow: + graph: + nodes: + - data: + type: agent + version: '2' + agent_binding: + binding_type: inline_agent + package_ref: agent_1 +` + mockExportAppConfig.mockResolvedValue({ data: agentDSL }) + const { result } = renderHook(() => useDSLByCanEdit(true)) + + await act(async () => { + await result.current.handleExportDSL() + }) + + const [{ data, fileName }] = mockDownloadBlob.mock.calls[0] as [ + { data: Blob; fileName: string }, + ] + expect(await data.text()).toBe(agentDSL) + expect(fileName).toBe('Workflow App.yml') + }) + it('should forward include and workflow id arguments when exporting dsl directly', async () => { const { result } = renderHook(() => useDSLByCanEdit(true)) diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts index b6c3d613455..1f21bdfc250 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts +++ b/web/app/components/workflow/__tests__/update-dsl-modal.helpers.spec.ts @@ -2,6 +2,7 @@ import { DSLImportStatus } from '@/models/app' import { AppModeEnum } from '@/types/app' import { BlockEnum } from '../types' import { + getImportNotificationPayload, getInvalidNodeTypes, isImportCompleted, normalizeWorkflowFeatures, @@ -94,6 +95,43 @@ workflow: expect(isImportCompleted(DSLImportStatus.PENDING)).toBe(false) }) + it('should use distinct Agent warning messages in the import notification', () => { + const t = ((key: (selector: Record) => string) => + key({ + 'common.importSuccess': 'Import succeeded', + 'common.importWarning': 'Caution', + 'common.importWarningDetails': 'Some configuration may need attention', + })) as never + + const payload = getImportNotificationPayload(DSLImportStatus.COMPLETED_WITH_WARNINGS, t, [ + { + code: 'agent_file_omitted', + path: 'agent_packages.agent_1.omitted_assets', + message: "Agent file 'brief.pdf' was not included.", + details: {}, + }, + { + code: 'agent_file_omitted', + path: 'agent_packages.agent_1.omitted_assets', + message: "Agent file 'brief.pdf' was not included.", + details: {}, + }, + { + code: 'agent_tool_authorization_required', + path: 'agent_packages.agent_1.soul.tools.dify_tools.0', + message: "Agent tool 'web_search' requires authorization.", + details: {}, + }, + ]) + + expect(payload).toEqual({ + type: 'warning', + message: 'Caution', + children: + "Agent file 'brief.pdf' was not included. · Agent tool 'web_search' requires authorization.", + }) + }) + it('should normalize workflow features with defaults', () => { const features = normalizeWorkflowFeatures({ file_upload: { diff --git a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx index ec746bd1efb..265155acea9 100644 --- a/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx +++ b/web/app/components/workflow/__tests__/update-dsl-modal.spec.tsx @@ -177,6 +177,42 @@ describe('UpdateDSLModal', () => { expect(defaultProps.onCancel).toHaveBeenCalledTimes(1) }) + it('should show Agent package warnings returned by a completed import', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-with-agent-warnings', + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + app_id: 'app-1', + warnings: [ + { + code: 'agent_file_omitted', + path: 'agent_packages.agent_1.omitted_assets', + message: "Agent file 'brief.pdf' was not included in the portable package.", + details: { kind: 'file', name: 'brief.pdf' }, + }, + { + code: 'agent_tool_authorization_required', + path: 'agent_packages.agent_1.soul.tools.dify_tools.0', + message: "Agent tool 'web_search' requires authorization.", + details: { tool_name: 'web_search' }, + }, + ], + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledWith('workflow.common.importWarning', { + description: + "Agent file 'brief.pdf' was not included in the portable package. · Agent tool 'web_search' requires authorization.", + }) + }) + }) + it('should show an error notification when import fails', async () => { mockImportDSL.mockResolvedValue({ id: 'import-1', @@ -225,6 +261,43 @@ describe('UpdateDSLModal', () => { expect(mockEmitWorkflowUpdate).toHaveBeenCalledWith('app-1') }) + it('should show Agent package warnings returned after confirming a pending import', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-pending-agent', + status: DSLImportStatus.PENDING, + imported_dsl_version: '0.8.0', + current_dsl_version: '0.7.0', + }) + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + app_id: 'app-1', + warnings: [ + { + code: 'agent_secret_required', + path: 'agent_packages.agent_1.soul.env.secret_refs', + message: "Agent secret 'SEARCH_TOKEN' must be configured.", + details: { name: 'SEARCH_TOKEN' }, + }, + ], + }) + + renderModal() + + fireEvent.change(screen.getByTestId('dsl-file-input'), { + target: { files: [new File(['workflow'], 'workflow.yml', { type: 'text/yaml' })] }, + }) + fireEvent.click(screen.getByRole('button', { name: 'workflow.common.overwriteAndImport' })) + + const confirmButton = await screen.findByRole('button', { name: 'app.newApp.Confirm' }) + fireEvent.click(confirmButton) + + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledWith('workflow.common.importWarning', { + description: "Agent secret 'SEARCH_TOKEN' must be configured.", + }) + }) + }) + it('should open the pending modal after the timeout and allow dismissing it', async () => { mockImportDSL.mockResolvedValue({ id: 'import-5', diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx index 00d13ea45ee..6988574d113 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/hooks.spec.tsx @@ -10,7 +10,7 @@ import { import { FlowType } from '@/types/common' import { renderWorkflowHook } from '../../../__tests__/workflow-test-env' import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config' -import { useCreateInlineAgentBinding } from '../hooks' +import { useCreateInlineAgentBinding, useWorkflowInlineAgentDetail } from '../hooks' const mockDefaultModel = vi.hoisted(() => ({ value: { @@ -47,6 +47,68 @@ const mockComposerMutationOptions = vi.hoisted(() => mutationFn: mockComposerMutationFn, })), ) +const mockSnippetComposerMutationFn = vi.hoisted(() => + vi.fn(async (variables: unknown) => ({ + agent_soul: ( + variables as { + body?: { + agent_soul?: unknown + } + } + ).body?.agent_soul, + binding: { + binding_type: 'inline_agent', + agent_id: 'snippet-inline-agent-1', + current_snapshot_id: 'snippet-inline-snapshot-1', + }, + variables, + })), +) +const mockSnippetComposerMutationOptions = vi.hoisted(() => + vi.fn(() => ({ + mutationFn: mockSnippetComposerMutationFn, + })), +) +const mockAppComposerQueryFn = vi.hoisted(() => + vi.fn(async (): Promise<{ agent?: { id: string } }> => ({ agent: { id: 'app-agent' } })), +) +const mockSnippetComposerQueryFn = vi.hoisted(() => + vi.fn(async () => ({ agent: { id: 'snippet-agent' } })), +) +const mockAppComposerQueryOptions = vi.hoisted(() => + vi.fn( + (options: { + input: symbol | { params: { app_id: string; node_id: string } } + refetchInterval?: (query: { + state: { + data?: { + agent?: unknown + } + } + }) => number | false + }) => { + const { input } = options + + return { + queryKey: + typeof input === 'symbol' + ? ['workflow-agent-composer-disabled'] + : ['workflow-agent-composer', input.params.app_id, input.params.node_id], + queryFn: typeof input === 'symbol' ? input : mockAppComposerQueryFn, + refetchInterval: options.refetchInterval, + } + }, + ), +) +const mockSnippetComposerQueryOptions = vi.hoisted(() => + vi.fn(({ input }: { input: symbol | { params: { snippet_id: string; node_id: string } } }) => ({ + queryKey: + typeof input === 'symbol' + ? ['snippet-agent-composer-disabled'] + : ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], + queryFn: typeof input === 'symbol' ? input : mockSnippetComposerQueryFn, + })), +) vi.mock('@langgenius/dify-ui/toast', () => ({ toast: { @@ -77,6 +139,7 @@ vi.mock('@/service/client', () => ({ byNodeId: { agentComposer: { get: { + queryOptions: mockAppComposerQueryOptions, queryKey: ({ input, }: { @@ -93,9 +156,107 @@ vi.mock('@/service/client', () => ({ }, }, }, + snippets: { + bySnippetId: { + workflows: { + draft: { + nodes: { + byNodeId: { + agentComposer: { + get: { + queryOptions: mockSnippetComposerQueryOptions, + queryKey: ({ + input, + }: { + input: { params: { snippet_id: string; node_id: string } } + }) => ['snippet-agent-composer', input.params.snippet_id, input.params.node_id], + }, + put: { + mutationOptions: mockSnippetComposerMutationOptions, + }, + }, + }, + }, + }, + }, + }, + }, }, })) +describe('useWorkflowInlineAgentDetail', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads inline agent detail through the snippet composer API', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const { result } = renderWorkflowHook( + () => useWorkflowInlineAgentDetail('node-1', 'snippet-agent'), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'snippet-1', + flowType: FlowType.snippet, + fileSettings: {} as never, + }, + }, + }, + ) + + await waitFor(() => expect(result.current.data).toEqual({ agent: { id: 'snippet-agent' } })) + expect(mockSnippetComposerQueryOptions).toHaveBeenCalledWith({ + input: { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + }, + }) + expect(mockSnippetComposerQueryFn).toHaveBeenCalled() + expect(mockAppComposerQueryFn).not.toHaveBeenCalled() + }) + + it('polls until a copied inline agent composer is created for the new node', async () => { + mockAppComposerQueryFn.mockResolvedValueOnce({ agent: undefined }) + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentDetail('copied-node', 'source-inline-agent', { + pollUntilReady: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'app-1', + flowType: FlowType.appFlow, + fileSettings: {} as never, + }, + }, + }, + ) + + await waitFor(() => expect(mockAppComposerQueryFn).toHaveBeenCalledTimes(2), { + timeout: 1500, + }) + expect(result.current.data).toEqual({ agent: { id: 'app-agent' } }) + }) +}) + describe('useCreateInlineAgentBinding', () => { beforeEach(() => { vi.clearAllMocks() @@ -188,6 +349,64 @@ describe('useCreateInlineAgentBinding', () => { ) }) + it('creates inline agent through the snippet composer API', async () => { + const onSuccess = vi.fn() + const queryClient = new QueryClient({ + defaultOptions: { + mutations: { + retry: false, + }, + }, + }) + const { result } = renderWorkflowHook(() => useCreateInlineAgentBinding(), { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'snippet-1', + flowType: FlowType.snippet, + fileSettings: {} as never, + }, + }, + }) + + act(() => { + result.current.createInlineAgentBinding('node-1', { onSuccess }) + }) + + await waitFor(() => expect(mockSnippetComposerMutationFn).toHaveBeenCalled()) + expect(mockSnippetComposerMutationFn).toHaveBeenCalledWith( + { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + body: expect.objectContaining({ + variant: 'workflow', + save_strategy: 'node_job_only', + binding: { + binding_type: 'inline_agent', + }, + }), + }, + expect.any(Object), + ) + await waitFor(() => + expect(onSuccess).toHaveBeenCalledWith({ + binding_type: 'inline_agent', + agent_id: 'snippet-inline-agent-1', + current_snapshot_id: 'snippet-inline-snapshot-1', + }), + ) + expect(queryClient.getQueryData(['snippet-agent-composer', 'snippet-1', 'node-1'])).toEqual( + expect.objectContaining({ + binding: expect.objectContaining({ + agent_id: 'snippet-inline-agent-1', + }), + }), + ) + expect(mockComposerMutationFn).not.toHaveBeenCalled() + }) + it('creates inline agent with a model-less initial soul before the default model loads', async () => { mockDefaultModel.value = undefined as never const onSuccess = vi.fn() @@ -399,6 +618,77 @@ describe('useWorkflowInlineAgentConfigureSync', () => { ) }) + it('saves inline agent composer changes through the snippet composer API', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }) + const { result } = renderWorkflowHook( + () => + useWorkflowInlineAgentConfigureSync({ + nodeId: 'node-1', + baseConfig: { + schema_version: 1, + }, + enabled: true, + }), + { + queryClient, + hooksStoreProps: { + configsMap: { + flowId: 'snippet-1', + flowType: FlowType.snippet, + fileSettings: {} as never, + }, + }, + }, + ) + + act(() => { + getDefaultStore().set(agentComposerDraftAtom, { + ...defaultAgentSoulConfigFormState, + prompt: 'Snippet inline prompt', + }) + }) + + await act(async () => { + await result.current.saveDraft() + }) + + expect(mockSnippetComposerMutationFn).toHaveBeenCalledWith( + { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + body: expect.objectContaining({ + variant: 'workflow', + save_strategy: 'node_job_only', + agent_soul: expect.objectContaining({ + prompt: expect.objectContaining({ + system_prompt: 'Snippet inline prompt', + }), + }), + }), + }, + expect.any(Object), + ) + expect(queryClient.getQueryData(['snippet-agent-composer', 'snippet-1', 'node-1'])).toEqual( + expect.objectContaining({ + agent_soul: expect.objectContaining({ + schema_version: 1, + }), + }), + ) + expect(mockComposerMutationFn).not.toHaveBeenCalled() + }) + it('still saves manually when inline agent autosave is disabled', async () => { const queryClient = new QueryClient({ defaultOptions: { diff --git a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx index c9e5986e0f1..5829a6e34af 100644 --- a/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/__tests__/panel.spec.tsx @@ -4,6 +4,7 @@ import type { PromptEditorProps } from '@/app/components/base/prompt-editor' import type { NodePanelProps } from '@/app/components/workflow/types' import { act, fireEvent, render, screen, within } from '@testing-library/react' import { BlockEnum } from '@/app/components/workflow/types' +import { FlowType } from '@/types/common' import { AgentV2Panel } from '../panel' const { @@ -17,6 +18,7 @@ const { mockPromptEditorProps, mockCopyFromRosterMutate, mockCopyFromRosterState, + mockConfigsMap, mockCreateInlineAgentBinding, mockSetInputs, mockStoreState, @@ -48,6 +50,10 @@ const { mockCopyFromRosterState: { isPending: false, }, + mockConfigsMap: { + flowId: 'app-1', + flowType: 'appFlow' as FlowType, + }, mockCreateInlineAgentBinding: vi.fn(), mockSetInputs: vi.fn(), mockStoreState: { @@ -307,6 +313,11 @@ vi.mock('@/app/components/workflow/hooks', () => ({ useWorkflowVariableType: () => vi.fn(), })) +vi.mock('@/app/components/workflow/hooks-store', () => ({ + useHooksStore: (selector: (state: { configsMap: typeof mockConfigsMap }) => unknown) => + selector({ configsMap: mockConfigsMap }), +})) + vi.mock('@/app/components/workflow/store', () => ({ useStore: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState), })) @@ -335,6 +346,8 @@ describe('agent/panel', () => { mockStoreState.appId = 'app-1' mockStoreState.openInlineAgentPanelNodeId = undefined mockCopyFromRosterState.isPending = false + mockConfigsMap.flowId = 'app-1' + mockConfigsMap.flowType = FlowType.appFlow mockCopyFromRosterMutate.mockImplementation( ( _variables, @@ -502,6 +515,33 @@ describe('agent/panel', () => { ) }) + it('copies a roster agent through the snippet composer API', () => { + mockConfigsMap.flowId = 'snippet-1' + mockConfigsMap.flowType = FlowType.snippet + mockStoreState.appId = undefined as never + render() + + fireEvent.click( + screen.getByRole('button', { name: /^workflow\.nodes\.agent\.roster\.openPanel/ }), + ) + fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.roster.makeCopy' })) + + expect(mockCopyFromRosterMutate).toHaveBeenCalledWith( + { + params: { + snippet_id: 'snippet-1', + node_id: 'agent-node', + }, + body: { + source_agent_id: 'agent-1', + }, + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + it('renders a required roster state when no roster agent is selected', () => { render( { expect(screen.getByText('workflow.nodes.agent.task.label')).toBeInTheDocument() }) + it('does not open copied inline agent configuration before its composer is created', () => { + mockStoreState.openInlineAgentPanelNodeId = 'agent-node' + mockUseWorkflowInlineAgentDetail.mockReturnValue({ + data: undefined, + isFetching: false, + refetch: mockWorkflowInlineAgentDetailRefetch, + }) + + const { container, rerender } = render( + , + ) + + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() + expect( + screen.queryByRole('button', { + name: /^workflow\.nodes\.agent\.roster\.openPanel/, + }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + + mockUseWorkflowInlineAgentDetail.mockReturnValue({ + data: { + agent: { + id: 'cloned-inline-agent', + name: 'Cloned Workflow Agent', + description: '', + scope: 'workflow_only', + status: 'active', + }, + binding: { + binding_type: 'inline_agent', + agent_id: 'cloned-inline-agent', + current_snapshot_id: 'cloned-inline-snapshot', + }, + }, + isFetching: false, + refetch: mockWorkflowInlineAgentDetailRefetch, + }) + rerender( + , + ) + + expect(screen.getByRole('dialog', { name: 'Cloned Workflow Agent' })).toBeInTheDocument() + expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({ + agentId: 'cloned-inline-agent', + nodeId: 'agent-node', + open: true, + }) + }) + it('uses the detail header when opening an existing inline agent from the roster trigger', () => { const { rerender } = render( { expect(screen.queryByRole('button', { name: 'Start from Scratch' })).not.toBeInTheDocument() }) - it('opens the inline panel while workflow composer state is still loading', () => { + it('keeps the inline panel closed while workflow composer state is still loading', () => { mockStoreState.openInlineAgentPanelNodeId = 'agent-node' mockUseWorkflowInlineAgentDetail.mockReturnValue({ data: undefined, @@ -902,20 +1012,15 @@ describe('agent/panel', () => { expect(mockUseWorkflowInlineAgentDetail).toHaveBeenCalledWith('agent-node', 'inline-agent-1') expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument() - const panel = screen.getByRole('dialog', { - name: 'workflow.nodes.agent.roster.inlineSetup.name', - }) - expect(panel).toBeInTheDocument() expect( - within(panel).queryByRole('button', { name: 'workflow.nodes.agent.roster.more' }), + screen.queryByRole('button', { + name: /^workflow\.nodes\.agent\.roster\.openPanel/, + }), + ).not.toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect( + screen.queryByRole('region', { name: 'inline-orchestrate-panel' }), ).not.toBeInTheDocument() - expect(screen.getByRole('region', { name: 'inline-orchestrate-panel' })).toBeInTheDocument() - expect(mockOrchestratePanelContentProps.at(-1)).toMatchObject({ - agentId: 'inline-agent-1', - inlineComposerState: undefined, - nodeId: 'agent-node', - open: true, - }) }) it('recovers the inline setup panel open state from the node open marker', () => { diff --git a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts index 972c326d17e..4360a719a59 100644 --- a/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts +++ b/web/app/components/workflow/nodes/agent-v2/agent-soul-config.ts @@ -85,9 +85,12 @@ export function useWorkflowInlineAgentConfigureSync({ const enabledRef = useRef(enabled) const onDraftSavedRef = useRef(onDraftSaved) const lastAutosavedDraftKeyRef = useRef(undefined) - const saveComposerMutation = useMutation( + const { mutateAsync: saveAppComposer } = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(), ) + const { mutateAsync: saveSnippetComposer } = useMutation( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(), + ) baseConfigRef.current = baseConfig currentModelRef.current = currentModel @@ -106,32 +109,62 @@ export function useWorkflowInlineAgentConfigureSync({ const saveComposer = useSerialAsyncCallback( async (configSnapshot: AgentSoulConfig): Promise => { - if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) return + if ( + !configsMap?.flowId || + (configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet) + ) + return const savedDraftKey = JSON.stringify(configSnapshot) - const composerState = await saveComposerMutation.mutateAsync({ - params: { - app_id: configsMap.flowId, - node_id: nodeId, - }, - body: { - variant: 'workflow', - save_strategy: 'node_job_only', - agent_soul: configSnapshot, - }, - }) + const body = { + variant: 'workflow' as const, + save_strategy: 'node_job_only' as const, + agent_soul: configSnapshot, + } + const composerState = + configsMap.flowType === FlowType.snippet + ? await saveSnippetComposer({ + params: { + snippet_id: configsMap.flowId, + node_id: nodeId, + }, + body, + }) + : await saveAppComposer({ + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + body, + }) - queryClient.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: configsMap.flowId, - node_id: nodeId, + if (configsMap.flowType === FlowType.snippet) { + queryClient.setQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: { + snippet_id: configsMap.flowId, + node_id: nodeId, + }, + }, }, - }, - }), - composerState, - ) + ), + composerState, + ) + } else { + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + }, + }), + composerState, + ) + } setOriginalConfig(composerState.agent_soul) setOriginalDraft(agentSoulConfigToFormState(composerState.agent_soul)) setDraftSavedAt(Date.now()) diff --git a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx index 794f89349bd..7c9841c423b 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/__tests__/agent-orchestrate-panel-content.spec.tsx @@ -5,6 +5,7 @@ import type { import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { FlowType } from '@/types/common' import { WorkflowInlineAgentConfigureWorkspace } from '../agent-orchestrate-panel-content' const mocks = vi.hoisted(() => ({ @@ -464,7 +465,8 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => { { ({ @@ -68,6 +69,25 @@ vi.mock('@/service/client', () => ({ }, }, }, + snippets: { + bySnippetId: { + workflows: { + draft: { + nodes: { + byNodeId: { + agentComposer: { + saveToRoster: { + post: { + mutationOptions: vi.fn(() => ({})), + }, + }, + }, + }, + }, + }, + }, + }, + }, }, })) @@ -90,7 +110,8 @@ const renderDialog = (agent: AgentComposerAgentResponse = inlineAgent) => { render( { expect(mutationOptions).not.toHaveProperty('onError') }) + it('saves the inline agent to roster through the snippet composer API', async () => { + const user = userEvent.setup() + render( + , + ) + + const dialog = screen.getByRole('dialog', { name: 'agentV2.roster.saveToRosterDialog.title' }) + await user.type( + within(dialog).getByRole('textbox', { name: 'agentV2.roster.createForm.nameLabel' }), + 'Snippet Agent', + ) + await user.click(within(dialog).getByRole('button', { name: 'common.operation.save' })) + + expect(mutationMock.mutate).toHaveBeenCalledWith( + { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + body: expect.objectContaining({ + variant: 'workflow', + save_strategy: 'save_to_roster', + new_agent_name: 'Snippet Agent', + }), + }, + expect.objectContaining({ + onSuccess: expect.any(Function), + }), + ) + }) + it('submits the visible default icon when the inline agent has no icon metadata', async () => { const user = userEvent.setup() renderDialog({ diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx index aa664af9f8c..c7f081c904e 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-orchestrate-panel-content.tsx @@ -63,6 +63,7 @@ import { useAgentConfigureBuildDraftData, } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft' import { consoleQuery } from '@/service/client' +import { FlowType } from '@/types/common' import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config' type WorkflowRosterAgentOrchestratePanelContentProps = { @@ -73,7 +74,8 @@ type WorkflowRosterAgentOrchestratePanelContentProps = { type WorkflowInlineAgentConfigureWorkspaceProps = { agentId?: string - appId?: string + flowId?: string + flowType?: FlowType inlineComposerState?: WorkflowAgentComposerResponse nodeId: string onClose?: () => void @@ -277,8 +279,9 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ activeConfigSnapshot, agentId, agentSoulConfig, - appId, buildDraft, + flowId, + flowType, inlineComposerState, nodeId, onClose, @@ -301,6 +304,7 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ null, ) const [workflowRunId, setWorkflowRunId] = useState(null) + const appId = flowType === FlowType.appFlow ? flowId : undefined const conversationIds = useAtomValue(agentConfigureConversationIdsAtom) const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom) const workingDirectoryPanel = useAgentWorkingDirectoryPanel({ @@ -354,27 +358,54 @@ function WorkflowInlineAgentConfigureWorkspaceContent({ } }, ) - if (!appId) return + if (!flowId) return - queryClient.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: appId, - node_id: nodeId, + if (flowType === FlowType.snippet) { + queryClient.setQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: { + snippet_id: flowId, + node_id: nodeId, + }, + }, }, - }, - }), - (composerState) => - composerState - ? { - ...composerState, - debug_conversation_has_messages, - debug_conversation_id, - debug_conversation_message_count, - } - : composerState, - ) + ), + (composerState) => + composerState + ? { + ...composerState, + debug_conversation_has_messages, + debug_conversation_id, + debug_conversation_message_count, + } + : composerState, + ) + return + } + + if (flowType === FlowType.appFlow) { + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: flowId, + node_id: nodeId, + }, + }, + }), + (composerState) => + composerState + ? { + ...composerState, + debug_conversation_has_messages, + debug_conversation_id, + debug_conversation_message_count, + } + : composerState, + ) + } }, }), ) diff --git a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx index 5029be8c907..43d84cf26b6 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/save-inline-agent-to-roster-dialog.tsx @@ -3,6 +3,7 @@ import type { AgentComposerAgentResponse, AgentComposerBindingResponse, + WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' import type { AgentFormValues, @@ -28,9 +29,11 @@ import { } from '@/features/agent-v2/roster/components/agent-form' import { AgentFormFields } from '@/features/agent-v2/roster/components/agent-form-fields' import { consoleQuery } from '@/service/client' +import { FlowType } from '@/types/common' type SaveInlineAgentToRosterDialogProps = { - appId?: string + flowId?: string + flowType?: FlowType formKey: number initialAgent?: AgentComposerAgentResponse | null nodeId: string @@ -40,7 +43,8 @@ type SaveInlineAgentToRosterDialogProps = { } export function SaveInlineAgentToRosterDialog({ - appId, + flowId, + flowType, formKey, initialAgent, nodeId, @@ -57,9 +61,14 @@ export function SaveInlineAgentToRosterDialog({ const [agentIcon, setAgentIcon] = useState(() => initialAgent ? createAgentIconSelection(initialAgent) : defaultAgentIcon, ) - const saveToRosterMutation = useMutation( + const appSaveToRosterMutation = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(), ) + const snippetSaveToRosterMutation = useMutation( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions(), + ) + const isSavingToRoster = + appSaveToRosterMutation.isPending || snippetSaveToRosterMutation.isPending const handleOpenChange = (nextOpen: boolean) => { if (nextOpen) { @@ -74,41 +83,60 @@ export function SaveInlineAgentToRosterDialog({ } const handleSubmit = (formValues: AgentFormValues) => { - if (saveToRosterMutation.isPending) return + if (isSavingToRoster) return - if (!appId) return + if (!flowId) return const trimmedName = formValues.name?.trim() ?? '' const trimmedRole = formValues.role?.trim() ?? '' - saveToRosterMutation.mutate( - { - params: { - app_id: appId, - node_id: nodeId, - }, - body: { - variant: 'workflow', - save_strategy: 'save_to_roster', - new_agent_name: trimmedName, - description: formValues.description?.trim() ?? '', - role: trimmedRole, - icon_type: agentIcon.type, - icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, - icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, - }, - }, - { - onSuccess: (composerState) => { - const binding = composerState.binding - if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return + const body = { + variant: 'workflow' as const, + save_strategy: 'save_to_roster' as const, + new_agent_name: trimmedName, + description: formValues.description?.trim() ?? '', + role: trimmedRole, + icon_type: agentIcon.type, + icon: agentIcon.type === 'image' ? agentIcon.fileId : agentIcon.icon, + icon_background: agentIcon.type === 'emoji' ? agentIcon.background : undefined, + } + const options = { + onSuccess: (composerState: WorkflowAgentComposerResponse) => { + const binding = composerState.binding + if (binding?.binding_type !== 'roster_agent' || !binding.agent_id) return - toast.success(t(($) => $['roster.saveToRosterSuccess'])) - onSaved(binding) - handleOpenChange(false) - }, + toast.success(t(($) => $['roster.saveToRosterSuccess'])) + onSaved(binding) + handleOpenChange(false) }, - ) + } + + if (flowType === FlowType.snippet) { + snippetSaveToRosterMutation.mutate( + { + params: { + snippet_id: flowId, + node_id: nodeId, + }, + body, + }, + options, + ) + return + } + + if (flowType === FlowType.appFlow) { + appSaveToRosterMutation.mutate( + { + params: { + app_id: flowId, + node_id: nodeId, + }, + body, + }, + options, + ) + } } return ( @@ -145,7 +173,7 @@ export function SaveInlineAgentToRosterDialog({ type="button" className="min-w-18" onClick={() => handleOpenChange(false)} - disabled={saveToRosterMutation.isPending} + disabled={isSavingToRoster} > {tCommon(($) => $['operation.cancel'])} @@ -153,7 +181,7 @@ export function SaveInlineAgentToRosterDialog({ type="submit" variant="primary" className="min-w-18" - loading={saveToRosterMutation.isPending} + loading={isSavingToRoster} > {tCommon(($) => $['operation.save'])} diff --git a/web/app/components/workflow/nodes/agent-v2/hooks.ts b/web/app/components/workflow/nodes/agent-v2/hooks.ts index 3067aea18b5..605a77f6cb5 100644 --- a/web/app/components/workflow/nodes/agent-v2/hooks.ts +++ b/web/app/components/workflow/nodes/agent-v2/hooks.ts @@ -20,6 +20,8 @@ type CreateInlineAgentBindingOptions = { onSuccess?: (binding: CreatedInlineAgentBinding) => void } +const INLINE_AGENT_CREATION_REFETCH_INTERVAL = 1000 + export function useAgentRosterDetail(agentId?: string) { return useQuery( consoleQuery.agent.byAgentId.get.queryOptions({ @@ -34,10 +36,27 @@ export function useAgentRosterDetail(agentId?: string) { ) } -export function useWorkflowInlineAgentDetail(nodeId?: string, agentId?: string | null) { +export function useWorkflowInlineAgentDetail( + nodeId?: string, + agentId?: string | null, + options?: { + pollUntilReady?: boolean + }, +) { const configsMap = useHooksStore((state) => state.configsMap) + const refetchUntilReady = options?.pollUntilReady + ? { + refetchInterval: (query: { + state: { + data?: { + agent?: unknown + } + } + }) => (query.state.data?.agent ? false : INLINE_AGENT_CREATION_REFETCH_INTERVAL), + } + : {} - return useQuery( + const appComposerQuery = useQuery( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions({ input: configsMap?.flowId && configsMap.flowType === FlowType.appFlow && nodeId && agentId @@ -48,8 +67,27 @@ export function useWorkflowInlineAgentDetail(nodeId?: string, agentId?: string | }, } : skipToken, + ...refetchUntilReady, }), ) + const snippetComposerQuery = useQuery( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions( + { + input: + configsMap?.flowId && configsMap.flowType === FlowType.snippet && nodeId && agentId + ? { + params: { + snippet_id: configsMap.flowId, + node_id: nodeId, + }, + } + : skipToken, + ...refetchUntilReady, + }, + ), + ) + + return configsMap?.flowType === FlowType.snippet ? snippetComposerQuery : appComposerQuery } export function useCreateInlineAgentBinding() { @@ -57,36 +95,53 @@ export function useCreateInlineAgentBinding() { const configsMap = useHooksStore((state) => state.configsMap) const { data: defaultModel } = useDefaultModel(ModelTypeEnum.textGeneration) const queryClient = useQueryClient() - const { isPending, mutateAsync } = useMutation( + const { isPending: isAppComposerPending, mutateAsync: mutateAppComposerAsync } = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(), ) + const { isPending: isSnippetComposerPending, mutateAsync: mutateSnippetComposerAsync } = + useMutation( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions(), + ) const createInlineAgentBinding = useCallback( async (nodeId: string, options?: CreateInlineAgentBindingOptions) => { - if (!configsMap?.flowId || configsMap.flowType !== FlowType.appFlow) { + if ( + !configsMap?.flowId || + (configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet) + ) { toast.error(t(($) => $['roster.nodeSelector.createInlineFailed'])) options?.onError?.() return } try { - const composerState = await mutateAsync({ - params: { - app_id: configsMap.flowId, - node_id: nodeId, + const body = { + variant: 'workflow' as const, + save_strategy: 'node_job_only' as const, + binding: { + binding_type: 'inline_agent' as const, }, - body: { - variant: 'workflow', - save_strategy: 'node_job_only', - binding: { - binding_type: 'inline_agent', - }, - soul_lock: { - locked: false, - }, - agent_soul: getDefaultAgentSoul(defaultModel), + soul_lock: { + locked: false, }, - }) + agent_soul: getDefaultAgentSoul(defaultModel), + } + const composerState = + configsMap.flowType === FlowType.snippet + ? await mutateSnippetComposerAsync({ + params: { + snippet_id: configsMap.flowId, + node_id: nodeId, + }, + body, + }) + : await mutateAppComposerAsync({ + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + body, + }) const binding = composerState.binding if ( @@ -99,17 +154,33 @@ export function useCreateInlineAgentBinding() { return } - queryClient.setQueryData( - consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ - input: { - params: { - app_id: configsMap.flowId, - node_id: nodeId, + if (configsMap.flowType === FlowType.snippet) { + queryClient.setQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: { + snippet_id: configsMap.flowId, + node_id: nodeId, + }, + }, }, - }, - }), - composerState, - ) + ), + composerState, + ) + } else { + queryClient.setQueryData( + consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({ + input: { + params: { + app_id: configsMap.flowId, + node_id: nodeId, + }, + }, + }), + composerState, + ) + } options?.onSuccess?.({ binding_type: 'inline_agent', agent_id: binding.agent_id, @@ -119,11 +190,19 @@ export function useCreateInlineAgentBinding() { options?.onError?.() } }, - [configsMap?.flowId, configsMap?.flowType, defaultModel, mutateAsync, queryClient, t], + [ + configsMap?.flowId, + configsMap?.flowType, + defaultModel, + mutateAppComposerAsync, + mutateSnippetComposerAsync, + queryClient, + t, + ], ) return { createInlineAgentBinding, - isCreatingInlineAgent: isPending, + isCreatingInlineAgent: isAppComposerPending || isSnippetComposerPending, } } diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index f487d89e162..6941b1125ba 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -1,6 +1,7 @@ import type { AgentComposerBindingResponse, DeclaredOutputConfig, + WorkflowAgentComposerResponse, } from '@dify/contracts/api/console/apps/types.gen' import type { AgentRosterNodeData } from '../../block-selector/types' import type { NodePanelProps } from '../../types' @@ -17,8 +18,10 @@ import { replaceAgentOutputName, } from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils' import { useNodeDataUpdate } from '@/app/components/workflow/hooks' +import { useHooksStore } from '@/app/components/workflow/hooks-store' import { useStore } from '@/app/components/workflow/store' import { consoleQuery } from '@/service/client' +import { FlowType } from '@/types/common' import useNodeCrud from '../_base/hooks/use-node-crud' import { AgentAdvancedSettings } from './components/agent-advanced-settings' import { @@ -127,7 +130,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { const { handleNodeDataUpdate, handleNodeDataUpdateWithSyncDraft } = useNodeDataUpdate() const openInlineAgentPanelNodeId = useStore((state) => state.openInlineAgentPanelNodeId) const setOpenInlineAgentPanelNodeId = useStore((state) => state.setOpenInlineAgentPanelNodeId) - const appId = useStore((state) => state.appId) + const configsMap = useHooksStore((state) => state.configsMap) const drawerPortalContainerRef = useRef(null) const [localDeclaredOutputs, setLocalDeclaredOutputs] = useState( null, @@ -137,26 +140,40 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { inputs.agent_binding?.binding_type === 'roster_agent' ? inputs.agent_binding.agent_id : undefined - const inlineAgentId = + const sourceInlineAgentId = inputs.agent_binding?.binding_type === 'inline_agent' ? inputs.agent_binding.agent_id : undefined const isInlineAgentReady = hasValidInlineAgentBinding(inputs) const isInlineAgentPending = inputs.agent_binding?.binding_type === 'inline_agent' && !isInlineAgentReady - const isInlineAgentPanelOpen = - (isInlineAgentReady || isInlineAgentPending) && openInlineAgentPanelNodeId === id const rosterAgentQuery = useAgentRosterDetail(rosterAgentId) - const inlineAgentQuery = useWorkflowInlineAgentDetail(id, inlineAgentId) + const inlineAgentQuery = useWorkflowInlineAgentDetail(id, sourceInlineAgentId, { + pollUntilReady: isInlineAgentReady, + }) const { createInlineAgentBinding, isCreatingInlineAgent } = useCreateInlineAgentBinding() const inlineAgent = inlineAgentQuery.data?.agent - const { isPending: isCopyingFromRoster, mutate: copyFromRoster } = useMutation( + const inlineAgentBinding = inlineAgentQuery.data?.binding + const inlineAgentId = + inlineAgentBinding?.binding_type === 'inline_agent' && inlineAgentBinding.agent_id + ? inlineAgentBinding.agent_id + : sourceInlineAgentId + const isInlineAgentCreated = isInlineAgentReady && !!inlineAgent + const isInlineAgentWaitingForCreation = isInlineAgentReady && !isInlineAgentCreated + const isInlineAgentPanelOpen = + (isInlineAgentCreated || isInlineAgentPending) && openInlineAgentPanelNodeId === id + const { isPending: isAppCopyingFromRoster, mutate: copyFromRosterApp } = useMutation( consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(), ) + const { isPending: isSnippetCopyingFromRoster, mutate: copyFromRosterSnippet } = useMutation( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.copyFromRoster.post.mutationOptions(), + ) + const isCopyingFromRoster = isAppCopyingFromRoster || isSnippetCopyingFromRoster const isAgentPanelOpen = - isInlineAgentReady || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen - const isInlineAgentLoading = isInlineAgentPending || (isInlineAgentReady && !inlineAgent) - const isAgentBindingPending = isInlineAgentPending || isCreatingInlineAgent + isInlineAgentCreated || isInlineAgentPending ? isInlineAgentPanelOpen : isRosterAgentPanelOpen + const isInlineAgentLoading = isInlineAgentPending || isInlineAgentWaitingForCreation + const isAgentBindingPending = + isInlineAgentPending || isInlineAgentWaitingForCreation || isCreatingInlineAgent const canStartFromScratch = inputs.agent_binding?.binding_type !== 'inline_agent' const canSaveInlineToRoster = isInlineAgentReady && !!inlineAgent const inlineComposerStateForPanel = inlineAgentQuery.data @@ -256,65 +273,90 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { [handleNodeDataUpdateWithSyncDraft, id, inputs, setOpenInlineAgentPanelNodeId], ) + const handleRosterCopySuccess = useCallback( + (composerState: WorkflowAgentComposerResponse) => { + const binding = composerState.binding + if ( + binding?.binding_type !== 'inline_agent' || + !binding.agent_id || + !binding.current_snapshot_id + ) { + return + } + + setIsRosterAgentPanelOpen(false) + setIsInlineAgentPanelOpenedFromTrigger(true) + setOpenInlineAgentPanelNodeId(id) + + const newInputs = produce(inputsRef.current, (draft) => { + delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster + draft.agent_binding = { + binding_type: 'inline_agent', + agent_id: binding.agent_id, + current_snapshot_id: binding.current_snapshot_id, + } + draft._openInlineAgentPanel = true + }) + inputsRef.current = newInputs + handleNodeDataUpdateWithSyncDraft( + { + id, + data: newInputs, + }, + { + sync: true, + notRefreshWhenSyncError: true, + }, + ) + }, + [handleNodeDataUpdateWithSyncDraft, id, setOpenInlineAgentPanelNodeId], + ) + const handleMakeRosterCopy = useCallback(() => { - if (!appId || !rosterAgentId || isCopyingFromRoster) return + if (!configsMap?.flowId || !rosterAgentId || isCopyingFromRoster) return - copyFromRoster( - { - params: { - app_id: appId, - node_id: id, - }, - body: { - source_agent_id: rosterAgentId, - }, - }, - { - onSuccess: (composerState) => { - const binding = composerState.binding - if ( - binding?.binding_type !== 'inline_agent' || - !binding.agent_id || - !binding.current_snapshot_id - ) { - return - } + const body = { + source_agent_id: rosterAgentId, + } + const options = { + onSuccess: handleRosterCopySuccess, + } - setIsRosterAgentPanelOpen(false) - setIsInlineAgentPanelOpenedFromTrigger(true) - setOpenInlineAgentPanelNodeId(id) - - const newInputs = produce(inputsRef.current, (draft) => { - delete (draft as AgentV2NodeType & { agent_roster?: unknown }).agent_roster - draft.agent_binding = { - binding_type: 'inline_agent', - agent_id: binding.agent_id, - current_snapshot_id: binding.current_snapshot_id, - } - draft._openInlineAgentPanel = true - }) - inputsRef.current = newInputs - handleNodeDataUpdateWithSyncDraft( - { - id, - data: newInputs, - }, - { - sync: true, - notRefreshWhenSyncError: true, - }, - ) + if (configsMap.flowType === FlowType.snippet) { + copyFromRosterSnippet( + { + params: { + snippet_id: configsMap.flowId, + node_id: id, + }, + body, }, - }, - ) + options, + ) + return + } + + if (configsMap.flowType === FlowType.appFlow) { + copyFromRosterApp( + { + params: { + app_id: configsMap.flowId, + node_id: id, + }, + body, + }, + options, + ) + } }, [ - appId, - copyFromRoster, - handleNodeDataUpdateWithSyncDraft, + configsMap?.flowId, + configsMap?.flowType, + copyFromRosterApp, + copyFromRosterSnippet, + handleRosterCopySuccess, id, isCopyingFromRoster, rosterAgentId, - setOpenInlineAgentPanelNodeId, ]) const handleSaveInlineToRosterOpen = useCallback(() => { @@ -446,6 +488,8 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { const handleAgentPanelOpenChange = useCallback( (open: boolean) => { + if (open && isInlineAgentWaitingForCreation) return + if (isInlineAgentReady || isInlineAgentPending) { if (open) setIsInlineAgentPanelOpenedFromTrigger(true) @@ -470,6 +514,7 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { isCreatingInlineAgent, isInlineAgentPending, isInlineAgentReady, + isInlineAgentWaitingForCreation, setOpenInlineAgentPanelNodeId, ], ) @@ -574,9 +619,9 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { />
) { isInlineAgentReady || isInlineAgentPending ? ( handleAgentPanelOpenChange(false)} @@ -621,7 +667,8 @@ export function AgentV2Panel({ id, data }: NodePanelProps) { /> { export const getImportNotificationPayload = ( status: DSLImportStatus, t: TFunction, + warnings: DSLImportWarning[] = [], ): ImportNotificationPayload => { return { type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', @@ -84,7 +87,8 @@ export const getImportNotificationPayload = ( : t(($) => $['common.importWarning'], { ns: 'workflow' }), children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS - ? t(($) => $['common.importWarningDetails'], { ns: 'workflow' }) + ? getDSLImportWarningDescription(warnings) || + t(($) => $['common.importWarningDetails'], { ns: 'workflow' }) : undefined, } } diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index 3537c84afc3..97e1af895d9 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -1,6 +1,7 @@ 'use client' import type { MouseEventHandler } from 'react' +import type { DSLImportWarning } from '@/models/app' import { Button } from '@langgenius/dify-ui/button' import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' @@ -82,7 +83,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) = const isCreatingRef = useRef(false) const handleCompletedImport = useCallback( - async (status: DSLImportStatus, appId?: string) => { + async (status: DSLImportStatus, appId?: string, warnings: DSLImportWarning[] = []) => { if (!appId) { toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) return @@ -91,7 +92,7 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) = await handleWorkflowUpdate(appId) collaborationManager.emitWorkflowUpdate(appId) onImport?.() - const payload = getImportNotificationPayload(status, t) + const payload = getImportNotificationPayload(status, t, warnings) toast[payload.type]( payload.message, payload.children ? { description: payload.children } : undefined, @@ -133,10 +134,10 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) = yaml_content: fileContent, app_id: appDetail.id, }) - const { id, status, app_id, imported_dsl_version, current_dsl_version } = response + const { id, status, app_id, imported_dsl_version, current_dsl_version, warnings } = response if (isImportCompleted(status)) { - await handleCompletedImport(status, app_id) + await handleCompletedImport(status, app_id, warnings) } else if (status === DSLImportStatus.PENDING) { handlePendingImport(id, imported_dsl_version, current_dsl_version) } else { @@ -160,10 +161,10 @@ const UpdateDSLModal = ({ onCancel, onBackup, onImport }: UpdateDSLModalProps) = import_id: importId, }) - const { status, app_id } = response + const { status, app_id, warnings } = response if (isImportCompleted(status)) { - await handleCompletedImport(status, app_id) + await handleCompletedImport(status, app_id, warnings) } else if (status === DSLImportStatus.FAILED) { setLoading(false) toast.error(t(($) => $['common.importFailure'], { ns: 'workflow' })) diff --git a/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx index e02af88579f..d1410f8c3a3 100644 --- a/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-preview/__tests__/index.spec.tsx @@ -1,4 +1,5 @@ -import { render, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' +import { BlockEnum } from '@/app/components/workflow/types' import WorkflowPreview from '../index' const defaultViewport = { @@ -39,4 +40,38 @@ describe('WorkflowPreview', () => { expect(container.querySelector('.react-flow__minimap')).toHaveClass('right-4!') expect(container.querySelector('.react-flow__minimap')).not.toHaveClass('left-4!') }) + + it('should render a portable Agent v2 node without failing the preview', async () => { + render( +
+ +
, + ) + + expect(await screen.findByText('Research Agent')).toBeInTheDocument() + expect(screen.getByText('Handles research tasks')).toBeInTheDocument() + }) }) diff --git a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts index 46e56169d7b..3351b7924e0 100644 --- a/web/features/agent-v2/agent-composer/__tests__/store.spec.ts +++ b/web/features/agent-v2/agent-composer/__tests__/store.spec.ts @@ -12,6 +12,40 @@ import { } from '../store' describe('agent composer store conversions', () => { + it('should hydrate missing file and skill references from API config', () => { + const formState = agentSoulConfigToFormState({ + config_files: [ + { + file_id: 'missing-file-id', + file_kind: 'upload_file', + is_missing: true, + name: 'missing.pdf', + }, + ], + config_skills: [ + { + file_id: 'missing-skill-id', + file_kind: 'tool_file', + is_missing: true, + name: 'Missing Skill', + }, + ], + } as unknown as AgentSoulConfig) + + expect(formState.files).toEqual([ + expect.objectContaining({ + isMissing: true, + name: 'missing.pdf', + }), + ]) + expect(formState.skills).toEqual([ + expect.objectContaining({ + isMissing: true, + name: 'Missing Skill', + }), + ]) + }) + it('rebases draft baselines through the composer state action', () => { const store = createStore() const nextDraft = { diff --git a/web/features/agent-v2/agent-composer/conversions.ts b/web/features/agent-v2/agent-composer/conversions.ts index dad3a9d1073..d7bf769579d 100644 --- a/web/features/agent-v2/agent-composer/conversions.ts +++ b/web/features/agent-v2/agent-composer/conversions.ts @@ -495,12 +495,16 @@ const toConfigFileConfigs = ( }) } +const isConfigReferenceMissing = (reference: object) => + 'is_missing' in reference && reference.is_missing === true + const toSkillFormState = (config?: AgentSoulConfig): AgentSkill[] => { return (config?.config_skills ?? []).map((skill) => ({ id: skill.name, name: skill.name, description: skill.description ?? undefined, fileId: skill.file_id, + isMissing: isConfigReferenceMissing(skill) || undefined, size: skill.size ?? undefined, hash: skill.hash ?? undefined, mimeType: skill.mime_type ?? undefined, @@ -513,6 +517,7 @@ const toFileFormState = (config?: AgentSoulConfig): AgentFileNode[] => { name: file.name, icon: getFileIconType(file.name, file.mime_type ?? undefined), fileId: file.file_id, + isMissing: isConfigReferenceMissing(file) || undefined, configName: file.name, size: file.size ?? undefined, hash: file.hash ?? undefined, diff --git a/web/features/agent-v2/agent-composer/form-state.ts b/web/features/agent-v2/agent-composer/form-state.ts index ee72fbf2815..adaec226a0f 100644 --- a/web/features/agent-v2/agent-composer/form-state.ts +++ b/web/features/agent-v2/agent-composer/form-state.ts @@ -36,6 +36,7 @@ export type AgentSkill = { fileId?: string hash?: string id: string + isMissing?: boolean mimeType?: string name: string size?: number @@ -47,6 +48,7 @@ export type AgentFileNode = { hash?: string id: string icon: FileTreeIconType + isMissing?: boolean fileId?: string configName?: string children?: AgentFileNode[] diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/missing-reference-warning.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/missing-reference-warning.spec.tsx new file mode 100644 index 00000000000..84eed63377e --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/__tests__/missing-reference-warning.spec.tsx @@ -0,0 +1,20 @@ +import type { ReactElement, ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { vi } from 'vitest' +import { MissingReferenceWarning } from '../missing-reference-warning' + +vi.mock('@langgenius/dify-ui/tooltip', () => ({ + Tooltip: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ render }: { render: ReactElement }) => render, +})) + +describe('MissingReferenceWarning', () => { + it('should render a warning icon with matching accessible and tooltip labels', () => { + render() + + const warning = screen.getByRole('button', { name: 'File not found' }) + expect(warning.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + expect(screen.getByRole('tooltip')).toHaveTextContent('File not found') + }) +}) diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/missing-reference-warning.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/missing-reference-warning.tsx new file mode 100644 index 00000000000..42d008c55ed --- /dev/null +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/common/missing-reference-warning.tsx @@ -0,0 +1,32 @@ +'use client' + +import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' + +export function MissingReferenceWarning({ + className, + label, +}: { + className?: string + label: string +}) { + return ( + + + + + } + /> + {label} + + ) +} diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx index 6001c8dc257..c2ad0732343 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/__tests__/index.spec.tsx @@ -211,6 +211,53 @@ describe('AgentFiles', () => { })) }) + it('should prevent missing files from being previewed or downloaded', async () => { + const user = userEvent.setup() + renderAgentFiles({ + initialDraft: createInitialDraft({ + files: [ + { + id: 'missing.pdf', + name: 'missing.pdf', + icon: 'pdf', + fileId: 'missing-file-id', + configName: 'missing.pdf', + isMissing: true, + }, + { + id: 'available.pdf', + name: 'available.pdf', + icon: 'pdf', + fileId: 'available-file-id', + configName: 'available.pdf', + }, + ], + }), + }) + + const warning = screen.getByRole('button', { + name: 'agentV2.agentDetail.configure.files.missing', + }) + expect(warning.querySelector('.i-ri-alert-fill')).toBeInTheDocument() + expect( + screen.getAllByRole('button', { + name: 'agentV2.agentDetail.configure.files.missing', + }), + ).toHaveLength(1) + + expect(screen.getByRole('button', { name: 'missing.pdf' })).toBeDisabled() + expect( + screen.queryByRole('button', { + name: /agentV2\.agentDetail\.configure\.files\.download.*missing\.pdf/, + }), + ).not.toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'available.pdf' })) + const dialog = await screen.findByRole('dialog') + expect(within(dialog).queryByText('missing.pdf')).not.toBeInTheDocument() + expect(within(dialog).getAllByText('available.pdf').length).toBeGreaterThan(0) + }) + it('should delete configured files by config name', async () => { const { container } = renderAgentFiles() diff --git a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx index 4205da11d2d..08a9878c3e9 100644 --- a/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx +++ b/web/features/agent-v2/agent-detail/configure/components/orchestrate/files/index.tsx @@ -4,6 +4,7 @@ import type { MouseEvent, ReactNode } from 'react' import type { AgentOrchestrateAddActionOptions } from '../add-actions-context' import type { AgentConfigApiContext } from '../config-context' import type { AgentFileNode } from '@/features/agent-v2/agent-composer/form-state' +import { cn } from '@langgenius/dify-ui/cn' import { Dialog, DialogTrigger } from '@langgenius/dify-ui/dialog' import { FileTreeBadge, @@ -30,6 +31,7 @@ import { useRegisterAgentOrchestrateAddAction } from '../add-actions-context' import { ConfigureSectionAddButton } from '../common/add-button' import { DocsLink } from '../common/docs-link' import { ConfigureSectionEmpty } from '../common/empty' +import { MissingReferenceWarning } from '../common/missing-reference-warning' import { ConfigureSection } from '../common/section' import { AgentConfigureTipContent } from '../common/tip-content' import { useAgentConfigApiContext } from '../config-context' @@ -216,6 +218,8 @@ function AgentFileItem({ ) const handleDownload = useCallback( async (event: MouseEvent) => { + if (file.isMissing) return + event.stopPropagation() await downloadFile(file) }, @@ -223,10 +227,12 @@ function AgentFileItem({ ) const handlePreviewOpenChange = useCallback( (open: boolean) => { + if (open && file.isMissing) return + if (open) setSelectedFileId(file.id) setIsPreviewOpen(open) }, - [file.id], + [file.id, file.isMissing], ) const canRemoveFile = !readOnly && (!file.virtualContent || isBuildNoteFile) @@ -241,7 +247,11 @@ function AgentFileItem({ + {file.isMissing && ( + $['agentDetail.configure.files.missing'])} + /> + )} +
+ {!file.isMissing && ( + + )} {canRemoveFile && ( + {skill.isMissing && ( + $['agentDetail.configure.skills.missing'])} + /> + )} + {!skill.isMissing && ( + - + + + )} {!readOnly && ( diff --git a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx index c28d475643a..f5f6a7fe237 100644 --- a/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/agent-roster-list.spec.tsx @@ -9,6 +9,16 @@ import { AgentRosterList } from '../agent-roster-list' const { duplicateAgentMutationFn } = vi.hoisted(() => ({ duplicateAgentMutationFn: vi.fn(), })) +const exportAppConfigMock = vi.hoisted(() => vi.fn()) +const downloadBlobMock = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/apps', () => ({ + exportAppConfig: exportAppConfigMock, +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: downloadBlobMock, +})) vi.mock('@/hooks/use-timestamp', () => ({ default: () => ({ @@ -50,6 +60,7 @@ vi.mock('@/service/client', () => ({ const createAgent = (overrides: Partial = {}): AgentAppPartial => ({ active_config_is_published: false, + app_id: 'app-1', description: 'Find and summarize market materials.', id: 'agent-1', icon: '🧸', @@ -104,6 +115,7 @@ describe('AgentRosterList', () => { name: 'Research Agent copy', }), ) + exportAppConfigMock.mockResolvedValue({ data: 'kind: app\napp:\n mode: agent\n' }) }) afterEach(() => { @@ -254,6 +266,27 @@ describe('AgentRosterList', () => { expect(duplicateAgentMutationFn).not.toHaveBeenCalled() }) + it('exports the Agent App DSL with the backing App id', async () => { + const user = userEvent.setup() + renderList([createAgent()]) + + await user.click(screen.getByRole('button', { name: /agentV2\.roster\.moreActions/ })) + await user.click(screen.getByRole('menuitem', { name: 'app.export' })) + + await waitFor(() => { + expect(exportAppConfigMock).toHaveBeenCalledWith({ + appID: 'app-1', + include: false, + }) + }) + expect(downloadBlobMock).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: 'Research Agent.yml', + }) + const [{ data }] = downloadBlobMock.mock.calls[0] as [{ data: Blob }] + expect(await data.text()).toBe('kind: app\napp:\n mode: agent\n') + }) + it('uses the latest cached agent detail when opening the duplicate dialog', async () => { const user = userEvent.setup() const { queryClient } = renderList([ diff --git a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx index b3366f6bc65..628b2a30547 100644 --- a/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx +++ b/web/features/agent-v2/roster/components/__tests__/roster-toolbar.spec.tsx @@ -4,6 +4,15 @@ import userEvent from '@testing-library/user-event' import { renderWithNuqs } from '@/test/nuqs-testing' import { RosterToolbar } from '../roster-toolbar' +vi.mock('@/app/components/app/create-from-dsl-modal', () => ({ + default: ({ show, onSuccess }: { show: boolean; onSuccess?: () => void }) => + show ? ( +
+ +
+ ) : null, +})) + vi.mock('@/next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), @@ -17,15 +26,38 @@ const renderToolbar = ({ } = {}) => { const queryClient = new QueryClient() - return renderWithNuqs( + const result = renderWithNuqs( , { searchParams }, ) + + return { ...result, queryClient } } describe('RosterToolbar', () => { + it('opens the shared create menu for blank Agent creation and DSL import', async () => { + const user = userEvent.setup() + const { queryClient } = renderToolbar() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + + await user.click(screen.getByRole('button', { name: 'common.operation.create' })) + + expect(screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank' })).toBeInTheDocument() + expect(screen.getByRole('menuitem', { name: /app\.importDSL/ })).toBeInTheDocument() + + await user.click(screen.getByRole('menuitem', { name: /app\.importDSL/ })) + + expect( + await screen.findByRole('dialog', { name: 'agentV2.roster.importDSL' }), + ).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'Complete agent import' })) + + expect(invalidateQueries).toHaveBeenCalledTimes(1) + }) + it('enables roster filters and emits the selected filter', async () => { const user = userEvent.setup() const { onUrlUpdate } = renderToolbar() diff --git a/web/features/agent-v2/roster/components/agent-roster-list.tsx b/web/features/agent-v2/roster/components/agent-roster-list.tsx index 94641ca2e79..7915bb98b0f 100644 --- a/web/features/agent-v2/roster/components/agent-roster-list.tsx +++ b/web/features/agent-v2/roster/components/agent-roster-list.tsx @@ -9,12 +9,15 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' import { useId, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { SkeletonRectangle } from '@/app/components/base/skeleton' import useTimestamp from '@/hooks/use-timestamp' import Link from '@/next/link' +import { exportAppConfig } from '@/service/apps' +import { downloadBlob } from '@/utils/download' import { AgentWorkflowReferencesDropdown } from './agent-workflow-references-dropdown' import { DeleteAgentDialog } from './delete-agent-dialog' import { DuplicateAgentDialog } from './duplicate-agent-dialog' @@ -103,6 +106,7 @@ function AgentRosterPlaceholderState({ title }: { title: string }) { function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') + const { t: tApp } = useTranslation('app') const { formatTime } = useTimestamp() const nameId = useId() const descriptionId = useId() @@ -136,6 +140,26 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { setIsDuplicateOpen(true) } + const handleExport = async () => { + if (!agent.app_id) { + toast.error(tApp(($) => $.exportFailed)) + return + } + + try { + const { data } = await exportAppConfig({ + appID: agent.app_id, + include: false, + }) + downloadBlob({ + data: new Blob([data], { type: 'application/yaml' }), + fileName: `${agent.name}.yml`, + }) + } catch { + toast.error(tApp(($) => $.exportFailed)) + } + } + return (
@@ -228,6 +252,10 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) { /> {tCommon(($) => $['operation.duplicate'])} + + + {tApp(($) => $.export)} + void +} + +export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps = {}) { const { t } = useTranslation('agentV2') const { t: tCommon } = useTranslation('common') const router = useRouter() - const [open, setOpen] = useState(false) + const [uncontrolledOpen, setUncontrolledOpen] = useState(false) const [formKey, setFormKey] = useState(0) const [name, setName] = useState('') const [description, setDescription] = useState('') @@ -46,7 +51,8 @@ export function CreateAgentDialog() { } const handleOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen) + if (open === undefined) setUncontrolledOpen(nextOpen) + onOpenChange?.(nextOpen) if (!nextOpen) resetForm() } @@ -80,11 +86,17 @@ export function CreateAgentDialog() { return ( <> - - }> - - {t(($) => $['roster.createAgent'])} - + + {open === undefined && ( + }> + + {t(($) => $['roster.createAgent'])} + + )}
diff --git a/web/features/agent-v2/roster/components/roster-create-menu.tsx b/web/features/agent-v2/roster/components/roster-create-menu.tsx new file mode 100644 index 00000000000..f827ae5b44b --- /dev/null +++ b/web/features/agent-v2/roster/components/roster-create-menu.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { CreateAppDropdown } from '@/app/components/app/create-app-dropdown' +import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal' +import { consoleQuery } from '@/service/client' +import { CreateAgentDialog } from './create-agent-dialog' + +export function RosterCreateMenu() { + const queryClient = useQueryClient() + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [importDialogOpen, setImportDialogOpen] = useState(false) + + return ( + <> + setCreateDialogOpen(true)} + onImportDSL={() => setImportDialogOpen(true)} + /> + + {importDialogOpen && ( + setImportDialogOpen(false)} + onSuccess={() => { + void queryClient.invalidateQueries({ queryKey: consoleQuery.agent.get.key() }) + }} + /> + )} + + ) +} diff --git a/web/features/agent-v2/roster/components/roster-toolbar.tsx b/web/features/agent-v2/roster/components/roster-toolbar.tsx index ea537abf545..f17709cc822 100644 --- a/web/features/agent-v2/roster/components/roster-toolbar.tsx +++ b/web/features/agent-v2/roster/components/roster-toolbar.tsx @@ -12,7 +12,7 @@ import { rosterKeywordQueryParser, rosterQueryParamNames, } from '../query-params' -import { CreateAgentDialog } from './create-agent-dialog' +import { RosterCreateMenu } from './roster-create-menu' import { RosterSortSelect } from './roster-sort-select' type RosterToolbarProps = { @@ -122,7 +122,7 @@ export function RosterToolbar({ draftAgents, publishedAgents }: RosterToolbarPro
- +
) diff --git a/web/hooks/use-import-dsl.ts b/web/hooks/use-import-dsl.ts index 64a41a5aa6c..a6936e55b98 100644 --- a/web/hooks/use-import-dsl.ts +++ b/web/hooks/use-import-dsl.ts @@ -15,6 +15,7 @@ import { useRouter } from '@/next/navigation' import { importDSL, importDSLConfirm } from '@/service/apps' import { useInvalidateAppList } from '@/service/use-apps' import { getRedirection } from '@/utils/app-redirection' +import { resolveImportedAppRedirectionTarget } from '@/utils/imported-app-redirection' type DSLPayload = { mode: DSLImportMode @@ -86,7 +87,12 @@ export const useImportDSL = () => { setNeedRefresh('1') invalidateAppList() await handleCheckPluginDependencies(app_id) - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { + const redirectionTarget = await resolveImportedAppRedirectionTarget({ + id: app_id, + mode: app_mode, + permission_keys, + }) + getRedirection(redirectionTarget, push, { currentUserId, resourceMaintainer: currentUserId, workspacePermissionKeys, @@ -143,7 +149,12 @@ export const useImportDSL = () => { await handleCheckPluginDependencies(app_id) setNeedRefresh('1') invalidateAppList() - getRedirection({ id: app_id, mode: app_mode, permission_keys }, push, { + const redirectionTarget = await resolveImportedAppRedirectionTarget({ + id: app_id, + mode: app_mode, + permission_keys, + }) + getRedirection(redirectionTarget, push, { currentUserId, resourceMaintainer: currentUserId, workspacePermissionKeys, diff --git a/web/i18n/ar-TN/agent-v-2.json b/web/i18n/ar-TN/agent-v-2.json index ac180a47d4e..b496b16635c 100644 --- a/web/i18n/ar-TN/agent-v-2.json +++ b/web/i18n/ar-TN/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "قم بتحميل المستندات التي يمكن للوكيل قراءتها، مثل المواصفات أو القوالب أو الإرشادات", "agentDetail.configure.files.empty.title": "لا توجد ملفات بعد", "agentDetail.configure.files.label": "الملفات", + "agentDetail.configure.files.missing": "الملف غير موجود", "agentDetail.configure.files.preview.empty": "لا يوجد محتوى معاينة.", "agentDetail.configure.files.preview.failed": "فشل تحميل المعاينة.", "agentDetail.configure.files.preview.unsupported": "هذا الملف لا يدعم المعاينة.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "لا توجد مهارات بعد", "agentDetail.configure.skills.itemType": "مهارة", "agentDetail.configure.skills.label": "المهارات", + "agentDetail.configure.skills.missing": "المهارة غير موجودة", "agentDetail.configure.skills.remove": "إزالة {{name}}", "agentDetail.configure.skills.richTip": "اجمع التعليمات والملفات والبرامج النصية لمهمة متكررة في Skill. أشر إليها باستخدام / في Prompt. معرفة المزيد\n\nفي وضع Build، يمكن للوكيل إعدادها لك.", "agentDetail.configure.skills.tip": "اجمع التعليمات والملفات والبرامج النصية لمهمة متكررة في Skill. أشر إليها باستخدام / في Prompt. معرفة المزيد\n\nفي وضع Build، يمكن للوكيل إعدادها لك.", diff --git a/web/i18n/de-DE/agent-v-2.json b/web/i18n/de-DE/agent-v-2.json index 5f0adec66fc..1d7b048ef47 100644 --- a/web/i18n/de-DE/agent-v-2.json +++ b/web/i18n/de-DE/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Laden Sie Dokumente hoch, die der Agent lesen kann, z. B. Spezifikationen, Vorlagen oder Richtlinien", "agentDetail.configure.files.empty.title": "Noch keine Dateien", "agentDetail.configure.files.label": "Dateien", + "agentDetail.configure.files.missing": "Datei nicht gefunden", "agentDetail.configure.files.preview.empty": "Kein Vorschauinhalt.", "agentDetail.configure.files.preview.failed": "Vorschau konnte nicht geladen werden.", "agentDetail.configure.files.preview.unsupported": "Für diese Datei wird keine Vorschau unterstützt.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Noch keine Skills", "agentDetail.configure.skills.itemType": "Skill", "agentDetail.configure.skills.label": "Skills", + "agentDetail.configure.skills.missing": "Skill nicht gefunden", "agentDetail.configure.skills.remove": "{{name}} entfernen", "agentDetail.configure.skills.richTip": "Bündeln Sie Anweisungen, Dateien und Skripte für eine wiederkehrende Aufgabe in einer Skill. Verweisen Sie im Prompt mit / darauf. Mehr erfahren\n\nIm Build-Modus kann der Agent diese Einrichtung für Sie übernehmen.", "agentDetail.configure.skills.tip": "Bündeln Sie Anweisungen, Dateien und Skripte für eine wiederkehrende Aufgabe in einer Skill. Verweisen Sie im Prompt mit / darauf. Mehr erfahren\n\nIm Build-Modus kann der Agent diese Einrichtung für Sie übernehmen.", diff --git a/web/i18n/en-US/agent-v-2.json b/web/i18n/en-US/agent-v-2.json index 186ce131be0..a62d2f5315d 100644 --- a/web/i18n/en-US/agent-v-2.json +++ b/web/i18n/en-US/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Upload docs the agent can read, like specs, templates, or guidelines", "agentDetail.configure.files.empty.title": "No files yet", "agentDetail.configure.files.label": "Files", + "agentDetail.configure.files.missing": "File not found", "agentDetail.configure.files.preview.empty": "No preview content.", "agentDetail.configure.files.preview.failed": "Failed to load preview.", "agentDetail.configure.files.preview.unsupported": "Preview is not supported for this file.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "No skills yet", "agentDetail.configure.skills.itemType": "Skill", "agentDetail.configure.skills.label": "Skills", + "agentDetail.configure.skills.missing": "Skill not found", "agentDetail.configure.skills.remove": "Remove {{name}}", "agentDetail.configure.skills.richTip": "Package the instructions, files, and scripts for a recurring task into a skill. Reference with / in the Prompt. Learn more\n\nIn Build mode, the agent can set these up for you.", "agentDetail.configure.skills.tip": "Package the instructions, files, and scripts for a recurring task into a skill. Reference with / in the Prompt. Learn more\n\nIn Build mode, the agent can set these up for you.", diff --git a/web/i18n/es-ES/agent-v-2.json b/web/i18n/es-ES/agent-v-2.json index fe1b31e3e36..29b22f7489f 100644 --- a/web/i18n/es-ES/agent-v-2.json +++ b/web/i18n/es-ES/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Sube documentos que el agente pueda leer, como especificaciones, plantillas o guías", "agentDetail.configure.files.empty.title": "Aún no hay archivos", "agentDetail.configure.files.label": "Archivos", + "agentDetail.configure.files.missing": "Archivo no encontrado", "agentDetail.configure.files.preview.empty": "Sin contenido de vista previa.", "agentDetail.configure.files.preview.failed": "Error al cargar la vista previa.", "agentDetail.configure.files.preview.unsupported": "Este archivo no admite vista previa.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Aún no hay habilidades", "agentDetail.configure.skills.itemType": "Habilidad", "agentDetail.configure.skills.label": "Habilidades", + "agentDetail.configure.skills.missing": "Habilidad no encontrada", "agentDetail.configure.skills.remove": "Eliminar {{name}}", "agentDetail.configure.skills.richTip": "Empaqueta las instrucciones, archivos y scripts de una tarea recurrente en una skill. Haz referencia con / en el Prompt. Más información\n\nEn modo Build, el agente puede configurarlas por ti.", "agentDetail.configure.skills.tip": "Empaqueta las instrucciones, archivos y scripts de una tarea recurrente en una skill. Haz referencia con / en el Prompt. Más información\n\nEn modo Build, el agente puede configurarlas por ti.", diff --git a/web/i18n/fa-IR/agent-v-2.json b/web/i18n/fa-IR/agent-v-2.json index ce6298c5755..89719d0aee4 100644 --- a/web/i18n/fa-IR/agent-v-2.json +++ b/web/i18n/fa-IR/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "اسنادی را که عامل می‌تواند بخواند بارگذاری کنید، مانند مشخصات، قالب‌ها یا دستورالعمل‌ها", "agentDetail.configure.files.empty.title": "هنوز فایلی وجود ندارد", "agentDetail.configure.files.label": "فایل‌ها", + "agentDetail.configure.files.missing": "فایل یافت نشد", "agentDetail.configure.files.preview.empty": "محتوای پیش‌نمایش وجود ندارد.", "agentDetail.configure.files.preview.failed": "بارگذاری پیش‌نمایش ناموفق بود.", "agentDetail.configure.files.preview.unsupported": "این فایل از پیش‌نمایش پشتیبانی نمی‌کند.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "هنوز مهارتی وجود ندارد", "agentDetail.configure.skills.itemType": "مهارت", "agentDetail.configure.skills.label": "مهارت‌ها", + "agentDetail.configure.skills.missing": "مهارت یافت نشد", "agentDetail.configure.skills.remove": "حذف {{name}}", "agentDetail.configure.skills.richTip": "دستورالعمل‌ها، فایل‌ها و اسکریپت‌های یک کار تکرارشونده را در یک Skill بسته‌بندی کنید. در Prompt با / به آن ارجاع دهید. بیشتر بدانید\n\nدر حالت Build، عامل می‌تواند این موارد را برای شما تنظیم کند.", "agentDetail.configure.skills.tip": "دستورالعمل‌ها، فایل‌ها و اسکریپت‌های یک کار تکرارشونده را در یک Skill بسته‌بندی کنید. در Prompt با / به آن ارجاع دهید. بیشتر بدانید\n\nدر حالت Build، عامل می‌تواند این موارد را برای شما تنظیم کند.", diff --git a/web/i18n/fr-FR/agent-v-2.json b/web/i18n/fr-FR/agent-v-2.json index f02b2b17454..08638a872d8 100644 --- a/web/i18n/fr-FR/agent-v-2.json +++ b/web/i18n/fr-FR/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Téléchargez des documents que l’agent peut lire, comme des spécifications, des modèles ou des directives", "agentDetail.configure.files.empty.title": "Pas encore de fichiers", "agentDetail.configure.files.label": "Fichiers", + "agentDetail.configure.files.missing": "Fichier introuvable", "agentDetail.configure.files.preview.empty": "Aucun contenu d’aperçu.", "agentDetail.configure.files.preview.failed": "Échec du chargement de l’aperçu.", "agentDetail.configure.files.preview.unsupported": "Ce fichier ne prend pas en charge l’aperçu.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Pas encore de compétences", "agentDetail.configure.skills.itemType": "Compétence", "agentDetail.configure.skills.label": "Compétences", + "agentDetail.configure.skills.missing": "Compétence introuvable", "agentDetail.configure.skills.remove": "Supprimer {{name}}", "agentDetail.configure.skills.richTip": "Regroupez les instructions, fichiers et scripts d'une tâche récurrente dans une skill. Référencez-la avec / dans le Prompt. En savoir plus\n\nEn mode Build, l'agent peut les configurer pour vous.", "agentDetail.configure.skills.tip": "Regroupez les instructions, fichiers et scripts d'une tâche récurrente dans une skill. Référencez-la avec / dans le Prompt. En savoir plus\n\nEn mode Build, l'agent peut les configurer pour vous.", diff --git a/web/i18n/hi-IN/agent-v-2.json b/web/i18n/hi-IN/agent-v-2.json index 8599c7a5c30..2063feced8b 100644 --- a/web/i18n/hi-IN/agent-v-2.json +++ b/web/i18n/hi-IN/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "ऐसे दस्तावेज़ अपलोड करें जिन्हें एजेंट पढ़ सके, जैसे विनिर्देश, टेम्पलेट या दिशानिर्देश", "agentDetail.configure.files.empty.title": "अभी तक कोई फ़ाइल नहीं", "agentDetail.configure.files.label": "फ़ाइलें", + "agentDetail.configure.files.missing": "फ़ाइल नहीं मिली", "agentDetail.configure.files.preview.empty": "कोई पूर्वावलोकन सामग्री नहीं।", "agentDetail.configure.files.preview.failed": "पूर्वावलोकन लोड करने में विफल।", "agentDetail.configure.files.preview.unsupported": "यह फ़ाइल पूर्वावलोकन का समर्थन नहीं करती।", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "अभी तक कोई कौशल नहीं", "agentDetail.configure.skills.itemType": "कौशल", "agentDetail.configure.skills.label": "कौशल", + "agentDetail.configure.skills.missing": "कौशल नहीं मिला", "agentDetail.configure.skills.remove": "{{name}} हटाएँ", "agentDetail.configure.skills.richTip": "किसी दोहराए जाने वाले कार्य के निर्देशों, फ़ाइलों और स्क्रिप्ट को एक Skill में पैकेज करें। Prompt में / से संदर्भ दें। और जानें\n\nBuild mode में, एजेंट इन्हें आपके लिए सेट कर सकता है।", "agentDetail.configure.skills.tip": "किसी दोहराए जाने वाले कार्य के निर्देशों, फ़ाइलों और स्क्रिप्ट को एक Skill में पैकेज करें। Prompt में / से संदर्भ दें। और जानें\n\nBuild mode में, एजेंट इन्हें आपके लिए सेट कर सकता है।", diff --git a/web/i18n/id-ID/agent-v-2.json b/web/i18n/id-ID/agent-v-2.json index e371cc8f24a..d625f3ea07d 100644 --- a/web/i18n/id-ID/agent-v-2.json +++ b/web/i18n/id-ID/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Unggah dokumen yang dapat dibaca agen, seperti spesifikasi, templat, atau pedoman", "agentDetail.configure.files.empty.title": "Belum ada file", "agentDetail.configure.files.label": "File", + "agentDetail.configure.files.missing": "File tidak ditemukan", "agentDetail.configure.files.preview.empty": "Tidak ada konten pratinjau.", "agentDetail.configure.files.preview.failed": "Gagal memuat pratinjau.", "agentDetail.configure.files.preview.unsupported": "File ini tidak mendukung pratinjau.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Belum ada keterampilan", "agentDetail.configure.skills.itemType": "Keterampilan", "agentDetail.configure.skills.label": "Keterampilan", + "agentDetail.configure.skills.missing": "Keterampilan tidak ditemukan", "agentDetail.configure.skills.remove": "Hapus {{name}}", "agentDetail.configure.skills.richTip": "Kemas instruksi, file, dan skrip untuk tugas berulang menjadi skill. Referensikan dengan / di Prompt. Pelajari selengkapnya\n\nDalam mode Build, agen dapat menyiapkannya untuk Anda.", "agentDetail.configure.skills.tip": "Kemas instruksi, file, dan skrip untuk tugas berulang menjadi skill. Referensikan dengan / di Prompt. Pelajari selengkapnya\n\nDalam mode Build, agen dapat menyiapkannya untuk Anda.", diff --git a/web/i18n/it-IT/agent-v-2.json b/web/i18n/it-IT/agent-v-2.json index 1a926b697d9..68d22d4614c 100644 --- a/web/i18n/it-IT/agent-v-2.json +++ b/web/i18n/it-IT/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Carica documenti che l’agente possa leggere, come specifiche, modelli o linee guida", "agentDetail.configure.files.empty.title": "Nessun file al momento", "agentDetail.configure.files.label": "File", + "agentDetail.configure.files.missing": "File non trovato", "agentDetail.configure.files.preview.empty": "Nessun contenuto in anteprima.", "agentDetail.configure.files.preview.failed": "Impossibile caricare l’anteprima.", "agentDetail.configure.files.preview.unsupported": "Questo file non supporta l’anteprima.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Nessuna abilità al momento", "agentDetail.configure.skills.itemType": "Abilità", "agentDetail.configure.skills.label": "Abilità", + "agentDetail.configure.skills.missing": "Abilità non trovata", "agentDetail.configure.skills.remove": "Rimuovi {{name}}", "agentDetail.configure.skills.richTip": "Raggruppa istruzioni, file e script per un'attività ricorrente in una skill. Fai riferimento con / nel Prompt. Scopri di più\n\nIn modalità Build, l'agente può configurarli per te.", "agentDetail.configure.skills.tip": "Raggruppa istruzioni, file e script per un'attività ricorrente in una skill. Fai riferimento con / nel Prompt. Scopri di più\n\nIn modalità Build, l'agente può configurarli per te.", diff --git a/web/i18n/ja-JP/agent-v-2.json b/web/i18n/ja-JP/agent-v-2.json index ecd92caed6c..6d1429519f2 100644 --- a/web/i18n/ja-JP/agent-v-2.json +++ b/web/i18n/ja-JP/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "仕様、テンプレート、ガイドラインなど、エージェントが読めるドキュメントをアップロード", "agentDetail.configure.files.empty.title": "ファイルはまだありません", "agentDetail.configure.files.label": "ファイル", + "agentDetail.configure.files.missing": "ファイルが見つかりません", "agentDetail.configure.files.preview.empty": "プレビュー内容はありません。", "agentDetail.configure.files.preview.failed": "プレビューの読み込みに失敗しました。", "agentDetail.configure.files.preview.unsupported": "ファイルはプレビューに対応していません。", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "スキルはまだありません", "agentDetail.configure.skills.itemType": "スキル", "agentDetail.configure.skills.label": "スキル", + "agentDetail.configure.skills.missing": "スキルが見つかりません", "agentDetail.configure.skills.remove": "{{name}} を削除", "agentDetail.configure.skills.richTip": "繰り返し行うタスクに必要な指示、ファイル、スクリプトを Skill にまとめます。Prompt で / を使って参照します。詳しく見る\n\nBuild モードでは、エージェントがこれらを設定できます。", "agentDetail.configure.skills.tip": "繰り返し行うタスクに必要な指示、ファイル、スクリプトを Skill にまとめます。Prompt で / を使って参照します。詳しく見る\n\nBuild モードでは、エージェントがこれらを設定できます。", diff --git a/web/i18n/ko-KR/agent-v-2.json b/web/i18n/ko-KR/agent-v-2.json index a1d7327e76b..593ee20fb4e 100644 --- a/web/i18n/ko-KR/agent-v-2.json +++ b/web/i18n/ko-KR/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "사양, 템플릿, 가이드라인 등 에이전트가 읽을 수 있는 문서를 업로드하세요", "agentDetail.configure.files.empty.title": "아직 파일이 없습니다", "agentDetail.configure.files.label": "파일", + "agentDetail.configure.files.missing": "파일을 찾을 수 없음", "agentDetail.configure.files.preview.empty": "미리보기 내용이 없습니다.", "agentDetail.configure.files.preview.failed": "미리보기를 불러오지 못했습니다.", "agentDetail.configure.files.preview.unsupported": "이 파일은 미리보기를 지원하지 않습니다.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "아직 스킬이 없습니다", "agentDetail.configure.skills.itemType": "스킬", "agentDetail.configure.skills.label": "스킬", + "agentDetail.configure.skills.missing": "스킬을 찾을 수 없음", "agentDetail.configure.skills.remove": "{{name}} 제거", "agentDetail.configure.skills.richTip": "반복 작업에 필요한 지침, 파일, 스크립트를 Skill로 패키징하세요. Prompt에서 /로 참조하세요. 자세히 알아보기\n\nBuild mode에서는 에이전트가 이를 대신 설정할 수 있습니다.", "agentDetail.configure.skills.tip": "반복 작업에 필요한 지침, 파일, 스크립트를 Skill로 패키징하세요. Prompt에서 /로 참조하세요. 자세히 알아보기\n\nBuild mode에서는 에이전트가 이를 대신 설정할 수 있습니다.", diff --git a/web/i18n/nl-NL/agent-v-2.json b/web/i18n/nl-NL/agent-v-2.json index e21dfc68be5..f9e0f6744f4 100644 --- a/web/i18n/nl-NL/agent-v-2.json +++ b/web/i18n/nl-NL/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Upload documenten die de agent kan lezen, zoals specificaties, sjablonen of richtlijnen", "agentDetail.configure.files.empty.title": "Nog geen bestanden", "agentDetail.configure.files.label": "Bestanden", + "agentDetail.configure.files.missing": "Bestand niet gevonden", "agentDetail.configure.files.preview.empty": "Geen voorbeeldinhoud.", "agentDetail.configure.files.preview.failed": "Laden van voorbeeld mislukt.", "agentDetail.configure.files.preview.unsupported": "Dit bestand ondersteunt geen voorbeeldweergave.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Nog geen skills", "agentDetail.configure.skills.itemType": "Skill", "agentDetail.configure.skills.label": "Skills", + "agentDetail.configure.skills.missing": "Skill niet gevonden", "agentDetail.configure.skills.remove": "{{name}} verwijderen", "agentDetail.configure.skills.richTip": "Bundel de instructies, bestanden en scripts voor een terugkerende taak in een skill. Verwijs ernaar met / in de Prompt. Meer informatie\n\nIn de Build-modus kan de agent dit voor u instellen.", "agentDetail.configure.skills.tip": "Bundel de instructies, bestanden en scripts voor een terugkerende taak in een skill. Verwijs ernaar met / in de Prompt. Meer informatie\n\nIn de Build-modus kan de agent dit voor u instellen.", diff --git a/web/i18n/pl-PL/agent-v-2.json b/web/i18n/pl-PL/agent-v-2.json index 3967666dd35..00b2fc8781a 100644 --- a/web/i18n/pl-PL/agent-v-2.json +++ b/web/i18n/pl-PL/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Prześlij dokumenty, które agent może czytać, np. specyfikacje, szablony lub wytyczne", "agentDetail.configure.files.empty.title": "Brak plików", "agentDetail.configure.files.label": "Pliki", + "agentDetail.configure.files.missing": "Nie znaleziono pliku", "agentDetail.configure.files.preview.empty": "Brak treści podglądu.", "agentDetail.configure.files.preview.failed": "Nie udało się załadować podglądu.", "agentDetail.configure.files.preview.unsupported": "Ten plik nie obsługuje podglądu.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Brak umiejętności", "agentDetail.configure.skills.itemType": "Umiejętność", "agentDetail.configure.skills.label": "Umiejętności", + "agentDetail.configure.skills.missing": "Nie znaleziono umiejętności", "agentDetail.configure.skills.remove": "Usuń {{name}}", "agentDetail.configure.skills.richTip": "Spakuj instrukcje, pliki i skrypty dla powtarzalnego zadania w skill. Odwołuj się za pomocą / w Prompt. Dowiedz się więcej\n\nW trybie Build agent może skonfigurować je za Ciebie.", "agentDetail.configure.skills.tip": "Spakuj instrukcje, pliki i skrypty dla powtarzalnego zadania w skill. Odwołuj się za pomocą / w Prompt. Dowiedz się więcej\n\nW trybie Build agent może skonfigurować je za Ciebie.", diff --git a/web/i18n/pt-BR/agent-v-2.json b/web/i18n/pt-BR/agent-v-2.json index d0b4042975e..06a61c8ec2b 100644 --- a/web/i18n/pt-BR/agent-v-2.json +++ b/web/i18n/pt-BR/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Envie documentos que o agente possa ler, como especificações, modelos ou diretrizes", "agentDetail.configure.files.empty.title": "Ainda não há arquivos", "agentDetail.configure.files.label": "Arquivos", + "agentDetail.configure.files.missing": "Arquivo não encontrado", "agentDetail.configure.files.preview.empty": "Sem conteúdo de pré-visualização.", "agentDetail.configure.files.preview.failed": "Falha ao carregar a pré-visualização.", "agentDetail.configure.files.preview.unsupported": "Este arquivo não oferece suporte à pré-visualização.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Ainda não há habilidades", "agentDetail.configure.skills.itemType": "Habilidade", "agentDetail.configure.skills.label": "Habilidades", + "agentDetail.configure.skills.missing": "Habilidade não encontrada", "agentDetail.configure.skills.remove": "Remover {{name}}", "agentDetail.configure.skills.richTip": "Empacote as instruções, arquivos e scripts de uma tarefa recorrente em uma skill. Referencie com / no Prompt. Saiba mais\n\nNo modo Build, o agente pode configurar isso para você.", "agentDetail.configure.skills.tip": "Empacote as instruções, arquivos e scripts de uma tarefa recorrente em uma skill. Referencie com / no Prompt. Saiba mais\n\nNo modo Build, o agente pode configurar isso para você.", diff --git a/web/i18n/ro-RO/agent-v-2.json b/web/i18n/ro-RO/agent-v-2.json index 1228cb2d3d7..07e4135cf35 100644 --- a/web/i18n/ro-RO/agent-v-2.json +++ b/web/i18n/ro-RO/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Încarcă documente pe care agentul le poate citi, precum specificații, șabloane sau ghiduri", "agentDetail.configure.files.empty.title": "Niciun fișier încă", "agentDetail.configure.files.label": "Fișiere", + "agentDetail.configure.files.missing": "Fișierul nu a fost găsit", "agentDetail.configure.files.preview.empty": "Niciun conținut de previzualizat.", "agentDetail.configure.files.preview.failed": "Încărcarea previzualizării a eșuat.", "agentDetail.configure.files.preview.unsupported": "Acest fișier nu acceptă previzualizarea.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Nicio abilitate încă", "agentDetail.configure.skills.itemType": "Abilitate", "agentDetail.configure.skills.label": "Abilități", + "agentDetail.configure.skills.missing": "Abilitatea nu a fost găsită", "agentDetail.configure.skills.remove": "Elimină {{name}}", "agentDetail.configure.skills.richTip": "Împachetați instrucțiunile, fișierele și scripturile pentru o sarcină recurentă într-un skill. Faceți referire cu / în Prompt. Aflați mai multe\n\nÎn modul Build, agentul le poate configura pentru dvs.", "agentDetail.configure.skills.tip": "Împachetați instrucțiunile, fișierele și scripturile pentru o sarcină recurentă într-un skill. Faceți referire cu / în Prompt. Aflați mai multe\n\nÎn modul Build, agentul le poate configura pentru dvs.", diff --git a/web/i18n/ru-RU/agent-v-2.json b/web/i18n/ru-RU/agent-v-2.json index 391d73eb7ca..70460ce89ed 100644 --- a/web/i18n/ru-RU/agent-v-2.json +++ b/web/i18n/ru-RU/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Загрузите документы, которые может прочитать агент, например спецификации, шаблоны или руководства", "agentDetail.configure.files.empty.title": "Пока нет файлов", "agentDetail.configure.files.label": "Файлы", + "agentDetail.configure.files.missing": "Файл не найден", "agentDetail.configure.files.preview.empty": "Нет содержимого для предпросмотра.", "agentDetail.configure.files.preview.failed": "Не удалось загрузить предпросмотр.", "agentDetail.configure.files.preview.unsupported": "Этот файл не поддерживает предварительный просмотр.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Пока нет навыков", "agentDetail.configure.skills.itemType": "Навык", "agentDetail.configure.skills.label": "Навыки", + "agentDetail.configure.skills.missing": "Навык не найден", "agentDetail.configure.skills.remove": "Удалить {{name}}", "agentDetail.configure.skills.richTip": "Упакуйте инструкции, файлы и скрипты для повторяющейся задачи в Skill. Ссылайтесь на него через / в Prompt. Подробнее\n\nВ режиме Build агент может настроить это за вас.", "agentDetail.configure.skills.tip": "Упакуйте инструкции, файлы и скрипты для повторяющейся задачи в Skill. Ссылайтесь на него через / в Prompt. Подробнее\n\nВ режиме Build агент может настроить это за вас.", diff --git a/web/i18n/sl-SI/agent-v-2.json b/web/i18n/sl-SI/agent-v-2.json index e215bdee067..d056821cc53 100644 --- a/web/i18n/sl-SI/agent-v-2.json +++ b/web/i18n/sl-SI/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Naložite dokumente, ki jih lahko agent bere, npr. specifikacije, predloge ali smernice", "agentDetail.configure.files.empty.title": "Še ni datotek", "agentDetail.configure.files.label": "Datoteke", + "agentDetail.configure.files.missing": "Datoteka ni bila najdena", "agentDetail.configure.files.preview.empty": "Ni vsebine za predogled.", "agentDetail.configure.files.preview.failed": "Predogleda ni bilo mogoče naložiti.", "agentDetail.configure.files.preview.unsupported": "Ta datoteka ne podpira predogleda.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Še ni veščin", "agentDetail.configure.skills.itemType": "Veščina", "agentDetail.configure.skills.label": "Veščine", + "agentDetail.configure.skills.missing": "Veščina ni bila najdena", "agentDetail.configure.skills.remove": "Odstrani {{name}}", "agentDetail.configure.skills.richTip": "Navodila, datoteke in skripte za ponavljajočo se nalogo zapakirajte v skill. Sklicujte se z / v Promptu. Več informacij\n\nV načinu Build jih lahko agent nastavi namesto vas.", "agentDetail.configure.skills.tip": "Navodila, datoteke in skripte za ponavljajočo se nalogo zapakirajte v skill. Sklicujte se z / v Promptu. Več informacij\n\nV načinu Build jih lahko agent nastavi namesto vas.", diff --git a/web/i18n/th-TH/agent-v-2.json b/web/i18n/th-TH/agent-v-2.json index f90adf7f7d4..10c5f1ac59c 100644 --- a/web/i18n/th-TH/agent-v-2.json +++ b/web/i18n/th-TH/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "อัปโหลดเอกสารที่ตัวแทนสามารถอ่านได้ เช่น ข้อกำหนด เทมเพลต หรือแนวทาง", "agentDetail.configure.files.empty.title": "ยังไม่มีไฟล์", "agentDetail.configure.files.label": "ไฟล์", + "agentDetail.configure.files.missing": "ไม่พบไฟล์", "agentDetail.configure.files.preview.empty": "ไม่มีเนื้อหาสำหรับแสดงตัวอย่าง", "agentDetail.configure.files.preview.failed": "โหลดตัวอย่างไม่สำเร็จ", "agentDetail.configure.files.preview.unsupported": "ไฟล์นี้ไม่รองรับการแสดงตัวอย่าง", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "ยังไม่มีทักษะ", "agentDetail.configure.skills.itemType": "ทักษะ", "agentDetail.configure.skills.label": "ทักษะ", + "agentDetail.configure.skills.missing": "ไม่พบทักษะ", "agentDetail.configure.skills.remove": "ลบ {{name}}", "agentDetail.configure.skills.richTip": "แพ็กเกจคำสั่ง ไฟล์ และสคริปต์สำหรับงานที่ทำซ้ำเป็น Skill อ้างอิงด้วย / ใน Prompt เรียนรู้เพิ่มเติม\n\nในโหมด Build agent สามารถตั้งค่าสิ่งเหล่านี้ให้คุณได้", "agentDetail.configure.skills.tip": "แพ็กเกจคำสั่ง ไฟล์ และสคริปต์สำหรับงานที่ทำซ้ำเป็น Skill อ้างอิงด้วย / ใน Prompt เรียนรู้เพิ่มเติม\n\nในโหมด Build agent สามารถตั้งค่าสิ่งเหล่านี้ให้คุณได้", diff --git a/web/i18n/tr-TR/agent-v-2.json b/web/i18n/tr-TR/agent-v-2.json index 3bda42a44dc..f4884b2b24a 100644 --- a/web/i18n/tr-TR/agent-v-2.json +++ b/web/i18n/tr-TR/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Ajanın okuyabileceği belgeleri yükleyin, örneğin spesifikasyonlar, şablonlar veya yönergeler", "agentDetail.configure.files.empty.title": "Henüz dosya yok", "agentDetail.configure.files.label": "Dosyalar", + "agentDetail.configure.files.missing": "Dosya bulunamadı", "agentDetail.configure.files.preview.empty": "Önizleme içeriği yok.", "agentDetail.configure.files.preview.failed": "Önizleme yüklenemedi.", "agentDetail.configure.files.preview.unsupported": "Bu dosya önizlemeyi desteklemiyor.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Henüz beceri yok", "agentDetail.configure.skills.itemType": "Beceri", "agentDetail.configure.skills.label": "Beceriler", + "agentDetail.configure.skills.missing": "Beceri bulunamadı", "agentDetail.configure.skills.remove": "{{name}} kaldır", "agentDetail.configure.skills.richTip": "Tekrarlanan bir görev için gereken talimatları, dosyaları ve betikleri bir Skill olarak paketleyin. Prompt içinde / ile referans verin. Daha fazla bilgi\n\nBuild mode'da agent bunları sizin için ayarlayabilir.", "agentDetail.configure.skills.tip": "Tekrarlanan bir görev için gereken talimatları, dosyaları ve betikleri bir Skill olarak paketleyin. Prompt içinde / ile referans verin. Daha fazla bilgi\n\nBuild mode'da agent bunları sizin için ayarlayabilir.", diff --git a/web/i18n/uk-UA/agent-v-2.json b/web/i18n/uk-UA/agent-v-2.json index b9191507c0e..aeb9f469317 100644 --- a/web/i18n/uk-UA/agent-v-2.json +++ b/web/i18n/uk-UA/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Завантажте документи, які може читати агент, наприклад специфікації, шаблони чи інструкції", "agentDetail.configure.files.empty.title": "Файлів ще немає", "agentDetail.configure.files.label": "Файли", + "agentDetail.configure.files.missing": "Файл не знайдено", "agentDetail.configure.files.preview.empty": "Немає вмісту для перегляду.", "agentDetail.configure.files.preview.failed": "Не вдалося завантажити перегляд.", "agentDetail.configure.files.preview.unsupported": "Попередній перегляд цього файлу не підтримується.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Поки що немає навичок", "agentDetail.configure.skills.itemType": "Навичка", "agentDetail.configure.skills.label": "Навички", + "agentDetail.configure.skills.missing": "Навичку не знайдено", "agentDetail.configure.skills.remove": "Видалити {{name}}", "agentDetail.configure.skills.richTip": "Запакуйте інструкції, файли та скрипти для повторюваного завдання в Skill. Посилайтеся на нього через / у Prompt. Докладніше\n\nУ режимі Build агент може налаштувати це за вас.", "agentDetail.configure.skills.tip": "Запакуйте інструкції, файли та скрипти для повторюваного завдання в Skill. Посилайтеся на нього через / у Prompt. Докладніше\n\nУ режимі Build агент може налаштувати це за вас.", diff --git a/web/i18n/vi-VN/agent-v-2.json b/web/i18n/vi-VN/agent-v-2.json index 5c9b4b9c51e..69848faba20 100644 --- a/web/i18n/vi-VN/agent-v-2.json +++ b/web/i18n/vi-VN/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "Tải lên tài liệu mà tác nhân có thể đọc, như đặc tả, mẫu hoặc hướng dẫn", "agentDetail.configure.files.empty.title": "Chưa có tệp nào", "agentDetail.configure.files.label": "Tệp", + "agentDetail.configure.files.missing": "Không tìm thấy tệp", "agentDetail.configure.files.preview.empty": "Không có nội dung xem trước.", "agentDetail.configure.files.preview.failed": "Tải xem trước thất bại.", "agentDetail.configure.files.preview.unsupported": "Tệp này không hỗ trợ xem trước.", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "Chưa có kỹ năng nào", "agentDetail.configure.skills.itemType": "Kỹ năng", "agentDetail.configure.skills.label": "Kỹ năng", + "agentDetail.configure.skills.missing": "Không tìm thấy kỹ năng", "agentDetail.configure.skills.remove": "Xóa {{name}}", "agentDetail.configure.skills.richTip": "Đóng gói hướng dẫn, tệp và script cho một tác vụ lặp lại thành skill. Tham chiếu bằng / trong Prompt. Tìm hiểu thêm\n\nỞ chế độ Build, tác nhân có thể thiết lập những mục này cho bạn.", "agentDetail.configure.skills.tip": "Đóng gói hướng dẫn, tệp và script cho một tác vụ lặp lại thành skill. Tham chiếu bằng / trong Prompt. Tìm hiểu thêm\n\nỞ chế độ Build, tác nhân có thể thiết lập những mục này cho bạn.", diff --git a/web/i18n/zh-Hans/agent-v-2.json b/web/i18n/zh-Hans/agent-v-2.json index 6c29caac01a..2e78c30cb47 100644 --- a/web/i18n/zh-Hans/agent-v-2.json +++ b/web/i18n/zh-Hans/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "上传 Agent 可读取的文档,例如规格、模板或指南", "agentDetail.configure.files.empty.title": "暂无文件", "agentDetail.configure.files.label": "文件", + "agentDetail.configure.files.missing": "未找到文件", "agentDetail.configure.files.preview.empty": "暂无预览内容。", "agentDetail.configure.files.preview.failed": "预览加载失败。", "agentDetail.configure.files.preview.unsupported": "该文件不支持预览。", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "暂无 Skill", "agentDetail.configure.skills.itemType": "Skill", "agentDetail.configure.skills.label": "Skill", + "agentDetail.configure.skills.missing": "未找到 Skill", "agentDetail.configure.skills.remove": "移除 {{name}}", "agentDetail.configure.skills.richTip": "将重复任务所需的指令、文件和脚本打包成 Skill。在提示词中用 / 引用。了解更多\n\n在构建模式中,Agent 可帮你完成这些设置。", "agentDetail.configure.skills.tip": "将重复任务所需的指令、文件和脚本打包成 Skill。在提示词中用 / 引用。了解更多\n\n在构建模式中,Agent 可帮你完成这些设置。", diff --git a/web/i18n/zh-Hant/agent-v-2.json b/web/i18n/zh-Hant/agent-v-2.json index d0de3ea9efd..2a61a4dec6b 100644 --- a/web/i18n/zh-Hant/agent-v-2.json +++ b/web/i18n/zh-Hant/agent-v-2.json @@ -98,6 +98,7 @@ "agentDetail.configure.files.empty.description": "上傳 Agent 可讀取的文件,例如規格、範本或指南", "agentDetail.configure.files.empty.title": "暫無檔案", "agentDetail.configure.files.label": "檔案", + "agentDetail.configure.files.missing": "找不到檔案", "agentDetail.configure.files.preview.empty": "暫無預覽內容。", "agentDetail.configure.files.preview.failed": "預覽載入失敗。", "agentDetail.configure.files.preview.unsupported": "此檔案不支援預覽。", @@ -208,6 +209,7 @@ "agentDetail.configure.skills.empty.title": "暫無 Skill", "agentDetail.configure.skills.itemType": "Skill", "agentDetail.configure.skills.label": "Skill", + "agentDetail.configure.skills.missing": "找不到 Skill", "agentDetail.configure.skills.remove": "移除 {{name}}", "agentDetail.configure.skills.richTip": "將重複任務所需的指令、檔案和指令碼打包成 Skill。在提示詞中用 / 引用。了解更多\n\n在建置模式中,Agent 可幫你完成這些設定。", "agentDetail.configure.skills.tip": "將重複任務所需的指令、檔案和指令碼打包成 Skill。在提示詞中用 / 引用。了解更多\n\n在建置模式中,Agent 可幫你完成這些設定。", diff --git a/web/models/app.ts b/web/models/app.ts index 544e1abb067..84fea16e1e0 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -26,6 +26,13 @@ export enum DSLImportStatus { FAILED = 'failed', } +export type DSLImportWarning = { + code: string + path: string + message: string + details: Record +} + export type AppListResponse = { data: App[] has_more: boolean @@ -46,6 +53,7 @@ export type DSLImportResponse = { error: string leaked_dependencies: Dependency[] permission_keys: string[] + warnings?: DSLImportWarning[] } export type UpdateAppSiteCodeResponse = { app_id: string } & SiteConfig diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index 1ca0beaafff..97d5e1ed59c 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -610,6 +610,54 @@ describe('consoleQuery agent mutation defaults', () => { ).toEqual(composerState) }) + it('should cache snippet composer state after saving the inline agent', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const composerState = createWorkflowComposerState({ + binding: { + agent_id: 'snippet-inline-agent-1', + binding_type: 'inline_agent', + current_snapshot_id: 'snippet-inline-snapshot-1', + id: 'binding-1', + node_id: 'node-1', + workflow_id: 'workflow-1', + }, + }) + + const mutationOptions = + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.put.mutationOptions() + await mutationOptions.onSuccess?.( + composerState, + { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + body: { + variant: 'workflow', + save_strategy: 'node_job_only', + }, + }, + undefined, + createMutationContext(queryClient), + ) + + expect( + queryClient.getQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + }, + }, + ), + ), + ).toEqual(composerState) + }) + it('should cache workflow composer state and invalidate roster lists after saving inline agent to roster', async () => { const consoleQuery = await loadConsoleQuery() const queryClient = new QueryClient() @@ -666,6 +714,64 @@ describe('consoleQuery agent mutation defaults', () => { }) }) + it('should cache snippet composer state and invalidate roster lists after saving inline agent to roster', async () => { + const consoleQuery = await loadConsoleQuery() + const queryClient = new QueryClient() + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const composerState = createWorkflowComposerState() + + const mutationOptions = + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.saveToRoster.post.mutationOptions() + await mutationOptions.onSuccess?.( + composerState, + { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + body: { + variant: 'workflow', + save_strategy: 'save_to_roster', + new_agent_name: 'Saved Agent', + description: 'Agent description', + role: 'Assistant', + }, + }, + undefined, + createMutationContext(queryClient), + ) + + expect( + queryClient.getQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: { + snippet_id: 'snippet-1', + node_id: 'node-1', + }, + }, + }, + ), + ), + ).toEqual(composerState) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.agent.get.key(), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: 'agent-1', + }, + }, + }), + }) + }) + it('should invalidate invite option lists after updating an agent', async () => { const consoleQuery = await loadConsoleQuery() const queryClient = new QueryClient() diff --git a/web/service/client.ts b/web/service/client.ts index d811305537e..3443010c1c5 100644 --- a/web/service/client.ts +++ b/web/service/client.ts @@ -461,6 +461,94 @@ export const consoleQuery: RouterUtils = createTanstackQue }, }, }, + snippets: { + bySnippetId: { + workflows: { + draft: { + nodes: { + byNodeId: { + agentComposer: { + put: { + mutationOptions: { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: variables.params, + }, + }, + ), + composerState, + ) + }, + }, + }, + copyFromRoster: { + post: { + mutationOptions: { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: variables.params, + }, + }, + ), + composerState, + ) + }, + }, + }, + }, + saveToRoster: { + post: { + mutationOptions: { + onSuccess: (composerState, variables, _onMutateResult, context) => { + context.client.setQueryData( + consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey( + { + input: { + params: variables.params, + }, + }, + ), + composerState, + ) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.get.key(), + }) + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.inviteOptions.get.key(), + }) + + const agentId = + composerState.binding?.binding_type === 'roster_agent' + ? composerState.binding.agent_id + : undefined + if (agentId) { + context.client.invalidateQueries({ + queryKey: consoleQuery.agent.byAgentId.get.queryKey({ + input: { + params: { + agent_id: agentId, + }, + }, + }), + }) + } + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, agent: { post: { mutationOptions: { diff --git a/web/types/app.ts b/web/types/app.ts index 0a30b962856..81de2bcec0d 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -51,13 +51,20 @@ export const RETRIEVE_METHOD = { /** * App modes */ -export type AppModeEnum = 'completion' | 'workflow' | 'chat' | 'advanced-chat' | 'agent-chat' +export type AppModeEnum = + | 'completion' + | 'workflow' + | 'chat' + | 'advanced-chat' + | 'agent-chat' + | 'agent' export const AppModeEnum = { COMPLETION: 'completion' as AppModeEnum, WORKFLOW: 'workflow' as AppModeEnum, CHAT: 'chat' as AppModeEnum, ADVANCED_CHAT: 'advanced-chat' as AppModeEnum, AGENT_CHAT: 'agent-chat' as AppModeEnum, + AGENT: 'agent' as AppModeEnum, } as const export const AppModes = [ AppModeEnum.COMPLETION, diff --git a/web/utils/app-redirection.spec.ts b/web/utils/app-redirection.spec.ts index d263f974a73..a2a6a7ba690 100644 --- a/web/utils/app-redirection.spec.ts +++ b/web/utils/app-redirection.spec.ts @@ -70,6 +70,27 @@ describe('app-redirection', () => { expect(result).toBe('/app/app-456/configuration') }) + it('returns the Agent configure path when the backing Agent ID is available', () => { + const app = { + id: 'app-1', + mode: AppModeEnum.AGENT, + permission_keys: [AppACLPermission.ViewLayout], + bound_agent_id: 'agent-1', + } + + expect(getRedirectionPath(app)).toBe('/agents/agent-1/configure') + }) + + it('falls back to the Agent roster when the backing Agent ID is unavailable', () => { + const app = { + id: 'app-1', + mode: AppModeEnum.AGENT, + permission_keys: [AppACLPermission.ViewLayout], + } + + expect(getRedirectionPath(app)).toBe('/agents') + }) + it('handles different app IDs', () => { const app1 = { id: 'abc-123', mode: AppModeEnum.CHAT, permission_keys: [] } const app2 = { diff --git a/web/utils/app-redirection.ts b/web/utils/app-redirection.ts index e17d64db8ac..24ef81e6bf1 100644 --- a/web/utils/app-redirection.ts +++ b/web/utils/app-redirection.ts @@ -2,16 +2,20 @@ import type { ResourceMaintainerPermissionOptions } from '@/utils/permission' import { AppModeEnum } from '@/types/app' import { getAppACLCapabilities } from '@/utils/permission' -type AppRedirectionTarget = { +export type AppRedirectionTarget = { id: string mode: AppModeEnum permission_keys?: string[] + bound_agent_id?: string | null } export const getRedirectionPath = ( app: AppRedirectionTarget, maintainerPermissionOptions?: ResourceMaintainerPermissionOptions, ) => { + if (app.mode === AppModeEnum.AGENT) + return app.bound_agent_id ? `/agents/${app.bound_agent_id}/configure` : '/agents' + const appACLCapabilities = getAppACLCapabilities(app.permission_keys, maintainerPermissionOptions) if (appACLCapabilities.canAccessLayout) { diff --git a/web/utils/dsl-import-warning.ts b/web/utils/dsl-import-warning.ts new file mode 100644 index 00000000000..ed85658052b --- /dev/null +++ b/web/utils/dsl-import-warning.ts @@ -0,0 +1,12 @@ +import type { DSLImportWarning } from '@/models/app' + +const MAX_VISIBLE_IMPORT_WARNINGS = 3 + +export const getDSLImportWarningDescription = (warnings: DSLImportWarning[] = []) => { + const messages = [...new Set(warnings.map((warning) => warning.message.trim()).filter(Boolean))] + if (!messages.length) return + + const visibleMessages = messages.slice(0, MAX_VISIBLE_IMPORT_WARNINGS) + if (messages.length > MAX_VISIBLE_IMPORT_WARNINGS) visibleMessages.push('…') + return visibleMessages.join(' · ') +} diff --git a/web/utils/imported-app-redirection.spec.ts b/web/utils/imported-app-redirection.spec.ts new file mode 100644 index 00000000000..29feca53795 --- /dev/null +++ b/web/utils/imported-app-redirection.spec.ts @@ -0,0 +1,50 @@ +import { AppModeEnum } from '@/types/app' +import { resolveImportedAppRedirectionTarget } from './imported-app-redirection' + +const mockFetchAppDetail = vi.hoisted(() => vi.fn()) + +vi.mock('@/service/client', () => ({ + consoleClient: { + apps: { + byAppId: { + get: (...args: unknown[]) => mockFetchAppDetail(...args), + }, + }, + }, +})) + +describe('resolveImportedAppRedirectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves the backing Agent ID for an imported Agent App', async () => { + mockFetchAppDetail.mockResolvedValue({ bound_agent_id: 'agent-1' }) + + await expect( + resolveImportedAppRedirectionTarget({ + id: 'app-1', + mode: AppModeEnum.AGENT, + permission_keys: ['app.acl.view_layout'], + }), + ).resolves.toEqual({ + id: 'app-1', + mode: AppModeEnum.AGENT, + permission_keys: ['app.acl.view_layout'], + bound_agent_id: 'agent-1', + }) + expect(mockFetchAppDetail).toHaveBeenCalledWith({ + params: { app_id: 'app-1' }, + }) + }) + + it('keeps the roster fallback when resolving the imported Agent App fails', async () => { + mockFetchAppDetail.mockRejectedValue(new Error('Failed to fetch App detail')) + const target = { + id: 'app-1', + mode: AppModeEnum.AGENT, + } + + await expect(resolveImportedAppRedirectionTarget(target)).resolves.toEqual(target) + }) +}) diff --git a/web/utils/imported-app-redirection.ts b/web/utils/imported-app-redirection.ts new file mode 100644 index 00000000000..7b77cd39d38 --- /dev/null +++ b/web/utils/imported-app-redirection.ts @@ -0,0 +1,22 @@ +import type { AppRedirectionTarget } from '@/utils/app-redirection' +import { consoleClient } from '@/service/client' +import { AppModeEnum } from '@/types/app' + +export const resolveImportedAppRedirectionTarget = async ( + app: AppRedirectionTarget, +): Promise => { + if (app.mode !== AppModeEnum.AGENT) return app + + try { + const importedApp = await consoleClient.apps.byAppId.get({ + params: { app_id: app.id }, + }) + + return { + ...app, + bound_agent_id: importedApp.bound_agent_id, + } + } catch { + return app + } +}