From 53b59eea8ba894f8b7453660f8586698739879bc Mon Sep 17 00:00:00 2001 From: yyh Date: Tue, 23 Jun 2026 18:14:34 +0800 Subject: [PATCH] feat: support nested agent output children --- .../__tests__/index.spec.tsx | 184 +++++++++++++++ .../__tests__/utils.spec.ts | 46 ++++ .../agent-output-variables/edit-card.tsx | 76 +++--- .../agent-output-variables/index.tsx | 199 +++++++++++++--- .../agent-output-variables/utils.ts | 217 +++++++++++++++++- 5 files changed, 652 insertions(+), 70 deletions(-) create mode 100644 web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx new file mode 100644 index 00000000000..2772d850f06 --- /dev/null +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/index.spec.tsx @@ -0,0 +1,184 @@ +import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { AgentOutputVariables } from '../index' + +const editorLabel = 'workflow.nodes.agent.outputVars.editorLabel' +const nameLabel = 'workflow.nodes.agent.outputVars.nameLabel' +const confirmLabel = 'workflow.nodes.agent.outputVars.confirm' + +async function expandOutputVars(user: ReturnType) { + await user.click(screen.getByRole('button', { name: 'workflow.nodes.common.outputVars' })) +} + +function getAddButton(name: string) { + return screen.getByRole('button', { name: `common.operation.add ${name}` }) +} + +function getEditButton(name: string) { + return screen.getByRole('button', { + name: `workflow.nodes.agent.outputVars.edit:{"name":"${name}"}`, + }) +} + +async function confirmEditorName(user: ReturnType, name: string) { + const editor = screen.getByRole('form', { name: editorLabel }) + const nameInput = within(editor).getByLabelText(nameLabel) + + await user.clear(nameInput) + await user.type(nameInput, name) + await user.click(within(editor).getByRole('button', { name: confirmLabel })) +} + +describe('AgentOutputVariables', () => { + it('should add an object child without opening the parent editor', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const outputs: DeclaredOutputConfig[] = [{ + name: 'profile', + type: 'object', + required: true, + description: 'User profile', + }] + + render() + + await expandOutputVars(user) + await user.click(getAddButton('profile')) + + expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1) + expect(screen.getByText('profile')).toBeInTheDocument() + + await confirmEditorName(user, 'email') + + expect(onChange).toHaveBeenCalledWith([{ + name: 'profile', + type: 'object', + required: true, + description: 'User profile', + children: [{ + name: 'email', + type: 'string', + required: false, + }], + }]) + }) + + it('should append children to array object items', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const outputs: DeclaredOutputConfig[] = [{ + name: 'addresses', + type: 'array', + required: false, + array_item: { + type: 'object', + }, + }] + + render() + + await expandOutputVars(user) + await user.click(getAddButton('addresses')) + await confirmEditorName(user, 'city') + + expect(onChange).toHaveBeenCalledWith([{ + name: 'addresses', + type: 'array', + required: false, + array_item: { + type: 'object', + children: [{ + name: 'city', + type: 'string', + required: false, + }], + }, + }]) + }) + + it('should add nested children under the selected object child', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const outputs: DeclaredOutputConfig[] = [{ + name: 'profile', + type: 'object', + required: true, + children: [{ + name: 'contact', + type: 'object', + required: true, + }], + }] + + render() + + await expandOutputVars(user) + await user.click(getAddButton('contact')) + await confirmEditorName(user, 'email') + + expect(onChange).toHaveBeenCalledWith([{ + name: 'profile', + type: 'object', + required: true, + children: [{ + name: 'contact', + type: 'object', + required: true, + children: [{ + name: 'email', + type: 'string', + required: false, + }], + }], + }]) + }) + + it('should edit only the selected nested child', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + const outputs: DeclaredOutputConfig[] = [{ + name: 'profile', + type: 'object', + required: true, + children: [{ + name: 'contact', + type: 'object', + required: true, + children: [{ + name: 'email', + type: 'string', + required: true, + description: 'Primary email', + }], + }], + }] + + render() + + await expandOutputVars(user) + await user.click(getEditButton('email')) + + expect(screen.getAllByRole('form', { name: editorLabel })).toHaveLength(1) + + await confirmEditorName(user, 'work_email') + + expect(onChange).toHaveBeenCalledWith([{ + name: 'profile', + type: 'object', + required: true, + children: [{ + name: 'contact', + type: 'object', + required: true, + children: [{ + name: 'work_email', + type: 'string', + required: true, + description: 'Primary email', + }], + }], + }]) + }) +}) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts index 4648f90c238..50382fcd8f9 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/__tests__/utils.spec.ts @@ -6,6 +6,7 @@ import { } from '../utils' const createDraft = (overrides: Partial = {}): OutputDraft => ({ + children: [], defaultValue: '', description: '', name: 'summary', @@ -54,4 +55,49 @@ describe('agent output variables utils', () => { type: 'array[file]', }))).toBe('nodes.agent.outputVars.defaultValueFileUnsupported') }) + + it('should preserve object children when building an output', () => { + expect(createOutputFromDraft(createDraft({ + children: [{ + name: 'email', + type: 'string', + required: true, + description: 'User email', + }], + name: 'profile', + type: 'object', + }))).toMatchObject({ + name: 'profile', + type: 'object', + children: [{ + name: 'email', + type: 'string', + required: true, + description: 'User email', + }], + }) + }) + + it('should preserve array object item children when building an output', () => { + expect(createOutputFromDraft(createDraft({ + children: [{ + name: 'city', + type: 'string', + required: false, + }], + name: 'addresses', + type: 'array[object]', + }))).toMatchObject({ + name: 'addresses', + type: 'array', + array_item: { + type: 'object', + children: [{ + name: 'city', + type: 'string', + required: false, + }], + }, + }) + }) }) diff --git a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx index 3e66f868ad9..4db6f83d4df 100644 --- a/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx +++ b/web/app/components/workflow/nodes/agent-v2/components/agent-output-variables/edit-card.tsx @@ -1,5 +1,5 @@ import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen' -import type { EditingState, OutputDraft } from './utils' +import type { EditableOutputConfig, EditingState, OutputDraft } from './utils' import { Button } from '@langgenius/dify-ui/button' import { CollapsiblePanel, CollapsibleRoot, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible' import { FieldControl, FieldError, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field' @@ -38,21 +38,25 @@ function ConfirmHotkeyHint() { export function OutputEditCard({ existingOutputs, + editingIndex, + allowDefaultValue = true, state, onCancel, onConfirm, }: { - existingOutputs: DeclaredOutputConfig[] + existingOutputs: EditableOutputConfig[] + editingIndex?: number + allowDefaultValue?: boolean state: EditingState onCancel: () => void - onConfirm: (output: DeclaredOutputConfig, index?: number) => void + onConfirm: (output: DeclaredOutputConfig, state: EditingState) => void }) { const { t } = useTranslation() const nameErrorId = useId() const editorRef = useRef(null) const [draft, setDraft] = useState(state.draft) const trimmedName = draft.name.trim() - const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== state.index) + const duplicateName = existingOutputs.some((output, index) => output.name === trimmedName && index !== editingIndex) const nameInvalid = !!trimmedName && !OUTPUT_NAME_PATTERN.test(trimmedName) const hasNameError = duplicateName || nameInvalid const defaultValueErrorKey = getDefaultValueErrorKey(draft) @@ -63,7 +67,7 @@ export function OutputEditCard({ function handleConfirm() { if (confirmDisabled) return - onConfirm(createOutputFromDraft(draft), state.index) + onConfirm(createOutputFromDraft(draft, { includeDefaultValue: allowDefaultValue }), state) } useHotkey(CONFIRM_HOTKEY, handleConfirm, { target: editorRef, ignoreInputs: false }) useHotkey('Escape', onCancel, { target: editorRef, ignoreInputs: false }) @@ -134,36 +138,38 @@ export function OutputEditCard({ /> - - - - -
- - - {t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })} - -