mirror of
https://github.com/langgenius/dify.git
synced 2026-08-28 22:36:52 +08:00
feat: support nested agent output children
This commit is contained in:
parent
c9a3fa9a45
commit
53b59eea8b
@ -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<typeof userEvent.setup>) {
|
||||
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<typeof userEvent.setup>, 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(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
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(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
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(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
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(<AgentOutputVariables outputs={outputs} onChange={onChange} />)
|
||||
|
||||
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',
|
||||
}],
|
||||
}],
|
||||
}])
|
||||
})
|
||||
})
|
||||
@ -6,6 +6,7 @@ import {
|
||||
} from '../utils'
|
||||
|
||||
const createDraft = (overrides: Partial<OutputDraft> = {}): 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,
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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<HTMLDivElement>(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({
|
||||
/>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="border-t border-divider-subtle">
|
||||
<div className="px-3 py-2">
|
||||
<FieldRoot name="defaultValue" className="gap-1">
|
||||
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
|
||||
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={draft.defaultValue}
|
||||
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
|
||||
className="mt-1 min-h-6"
|
||||
onValueChange={defaultValue => updateDraft({ defaultValue })}
|
||||
/>
|
||||
{defaultValueErrorKey && (
|
||||
<FieldError match className="py-0 system-xs-regular text-text-destructive">
|
||||
{t(defaultValueErrorKey, { ns: 'workflow' })}
|
||||
</FieldError>
|
||||
)}
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
{allowDefaultValue && (
|
||||
<CollapsibleRoot>
|
||||
<CollapsibleTrigger className="h-8 min-h-8 justify-start gap-x-0.5 rounded-none border-y border-divider-subtle pr-2 pl-2.5 system-xs-regular text-text-tertiary hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-tertiary focus-visible:bg-state-base-hover focus-visible:ring-inset data-panel-open:text-text-tertiary">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="i-ri-arrow-down-double-line size-3 transition-transform duration-100 ease-out group-data-panel-open:rotate-180 motion-reduce:transition-none"
|
||||
/>
|
||||
{t('nodes.agent.outputVars.showAdvancedOptions', { ns: 'workflow' })}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsiblePanel className="border-t border-divider-subtle">
|
||||
<div className="px-3 py-2">
|
||||
<FieldRoot name="defaultValue" className="gap-1">
|
||||
<FieldLabel className="py-0 system-xs-medium text-text-secondary">
|
||||
{t('nodes.agent.outputVars.defaultValueLabel', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={draft.defaultValue}
|
||||
placeholder={t('nodes.agent.outputVars.defaultValuePlaceholder', { ns: 'workflow' })}
|
||||
className="mt-1 min-h-6"
|
||||
onValueChange={defaultValue => updateDraft({ defaultValue })}
|
||||
/>
|
||||
{defaultValueErrorKey && (
|
||||
<FieldError match className="py-0 system-xs-regular text-text-destructive">
|
||||
{t(defaultValueErrorKey, { ns: 'workflow' })}
|
||||
</FieldError>
|
||||
)}
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</CollapsiblePanel>
|
||||
</CollapsibleRoot>
|
||||
)}
|
||||
<div className="flex h-12 items-center justify-end gap-x-2 px-3">
|
||||
<Button type="button" size="small" variant="secondary" onClick={onCancel}>
|
||||
{t('operation.cancel', { ns: 'common' })}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentOutputVariablesProps, EditingState } from './utils'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentOutputVariablesProps, DeclaredOutputChildConfig, EditableOutputConfig, EditingState } from './utils'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -7,21 +8,30 @@ import Divider from '@/app/components/base/divider'
|
||||
import OutputVars from '../../../_base/components/output-vars'
|
||||
import { OutputEditCard } from './edit-card'
|
||||
import {
|
||||
canOutputHaveChildren,
|
||||
createDraft,
|
||||
deleteOutputChildAtPath,
|
||||
getOutputChildren,
|
||||
getOutputChildrenAtPath,
|
||||
getOutputDescription,
|
||||
getOutputDisplayType,
|
||||
getOutputTypeOptionValue,
|
||||
insertOutputChildAtPath,
|
||||
isDefaultOutput,
|
||||
toDeclaredOutputChild,
|
||||
updateOutputChildAtPath,
|
||||
} from './utils'
|
||||
|
||||
function OutputRow({
|
||||
output,
|
||||
editable,
|
||||
onAddChild,
|
||||
onDelete,
|
||||
onEdit,
|
||||
}: {
|
||||
output: DeclaredOutputConfig
|
||||
output: EditableOutputConfig
|
||||
editable: boolean
|
||||
onAddChild?: () => void
|
||||
onDelete: () => void
|
||||
onEdit: () => void
|
||||
}) {
|
||||
@ -43,6 +53,16 @@ function OutputRow({
|
||||
</div>
|
||||
{editable && (
|
||||
<div className="pointer-events-none flex shrink-0 items-center gap-x-0.5 opacity-0 group-focus-within:pointer-events-auto group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
{onAddChild && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('operation.add', { ns: 'common' })} ${output.name}`}
|
||||
className="flex size-6 items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
onClick={onAddChild}
|
||||
>
|
||||
<span aria-hidden="true" className="i-ri-add-circle-line size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('nodes.agent.outputVars.edit', { ns: 'workflow', name: output.name })}
|
||||
@ -70,6 +90,22 @@ function OutputRow({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChildOutputFrame({ children, depth }: { children: ReactNode, depth: number }) {
|
||||
return (
|
||||
<div className="flex items-stretch">
|
||||
{Array.from({ length: depth }, (_, index) => (
|
||||
<div key={index} aria-hidden="true" className="flex w-5 shrink-0 justify-center">
|
||||
<div className="w-px bg-divider-subtle" />
|
||||
</div>
|
||||
))}
|
||||
<div className="min-w-0 flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentOutputVariables({
|
||||
outputs,
|
||||
onChange,
|
||||
@ -80,49 +116,151 @@ export function AgentOutputVariables({
|
||||
setEditingState({ draft: createDraft() })
|
||||
}
|
||||
function handleEditOutput(index: number) {
|
||||
setEditingState({ index, draft: createDraft(outputs[index]) })
|
||||
setEditingState({ outputIndex: index, draft: createDraft(outputs[index]) })
|
||||
}
|
||||
function handleNewChild(outputIndex: number, parentPath: number[]) {
|
||||
setEditingState({ outputIndex, parentPath, draft: createDraft() })
|
||||
}
|
||||
function handleEditChild(outputIndex: number, childPath: number[], child: DeclaredOutputChildConfig) {
|
||||
setEditingState({ outputIndex, childPath, draft: createDraft(child) })
|
||||
}
|
||||
function handleDeleteOutput(index: number) {
|
||||
onChange(outputs.filter((_, outputIndex) => outputIndex !== index))
|
||||
}
|
||||
function handleConfirm(output: DeclaredOutputConfig, index?: number) {
|
||||
if (typeof index === 'number') {
|
||||
onChange(outputs.map((item, outputIndex) => outputIndex === index ? output : item))
|
||||
function handleDeleteChild(outputIndex: number, childPath: number[]) {
|
||||
const parent = outputs[outputIndex]
|
||||
if (!parent)
|
||||
return
|
||||
|
||||
onChange(outputs.map((item, itemIndex) => (
|
||||
itemIndex === outputIndex ? deleteOutputChildAtPath(item, childPath) : item
|
||||
)))
|
||||
}
|
||||
function handleConfirm(output: DeclaredOutputConfig, state: EditingState) {
|
||||
if (typeof state.outputIndex === 'number' && state.parentPath) {
|
||||
const parent = outputs[state.outputIndex]
|
||||
if (!parent)
|
||||
return
|
||||
|
||||
const childOutput = toDeclaredOutputChild(output)
|
||||
onChange(outputs.map((item, outputIndex) => (
|
||||
outputIndex === state.outputIndex ? insertOutputChildAtPath(item, state.parentPath!, childOutput) : item
|
||||
)))
|
||||
}
|
||||
else if (typeof state.outputIndex === 'number' && state.childPath) {
|
||||
const childOutput = toDeclaredOutputChild(output)
|
||||
onChange(outputs.map((item, outputIndex) => (
|
||||
outputIndex === state.outputIndex ? updateOutputChildAtPath(item, state.childPath!, childOutput) : item
|
||||
)))
|
||||
}
|
||||
else if (typeof state.outputIndex === 'number') {
|
||||
onChange(outputs.map((item, outputIndex) => outputIndex === state.outputIndex ? output : item))
|
||||
}
|
||||
else {
|
||||
onChange([...outputs, output])
|
||||
}
|
||||
setEditingState(null)
|
||||
}
|
||||
return (
|
||||
<OutputVars>
|
||||
<div className="pb-2">
|
||||
<div className="flex flex-col">
|
||||
{outputs.map((output, index) => (
|
||||
editingState?.index === index
|
||||
? (
|
||||
function renderChildren(output: DeclaredOutputConfig, outputIndex: number, depth: number, children: DeclaredOutputChildConfig[], editable: boolean, parentPath: number[] = []) {
|
||||
return (
|
||||
<>
|
||||
{children.map((child, childIndex) => {
|
||||
const childPath = [...parentPath, childIndex]
|
||||
const nestedChildren = getOutputChildren(child)
|
||||
const isEditingChild = editingState?.outputIndex === outputIndex && pathsEqual(editingState.childPath, childPath)
|
||||
return (
|
||||
<div key={`${childPath.join('.')}-${child.name}-${getOutputTypeOptionValue(child)}`} className="flex flex-col">
|
||||
{isEditingChild
|
||||
? (
|
||||
<ChildOutputFrame depth={depth}>
|
||||
<OutputEditCard
|
||||
allowDefaultValue={false}
|
||||
editingIndex={childIndex}
|
||||
existingOutputs={getOutputChildrenAtPath(output, parentPath)}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)
|
||||
: (
|
||||
<ChildOutputFrame depth={depth}>
|
||||
<OutputRow
|
||||
output={child}
|
||||
editable={editable}
|
||||
onAddChild={editable && canOutputHaveChildren(child) ? () => handleNewChild(outputIndex, childPath) : undefined}
|
||||
onDelete={() => handleDeleteChild(outputIndex, childPath)}
|
||||
onEdit={() => handleEditChild(outputIndex, childPath, child)}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
{renderChildren(output, outputIndex, depth + 1, nestedChildren, editable, childPath)}
|
||||
{editingState?.outputIndex === outputIndex && pathsEqual(editingState.parentPath, childPath) && (
|
||||
<ChildOutputFrame depth={depth + 1}>
|
||||
<OutputEditCard
|
||||
key={`${output.name}-editing`}
|
||||
existingOutputs={outputs}
|
||||
allowDefaultValue={false}
|
||||
existingOutputs={nestedChildren}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<OutputRow
|
||||
key={`${output.name}-${getOutputTypeOptionValue(output)}`}
|
||||
output={output}
|
||||
editable={!isDefaultOutput(output)}
|
||||
onDelete={() => handleDeleteOutput(index)}
|
||||
onEdit={() => handleEditOutput(index)}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<OutputVars>
|
||||
<div className="pb-2">
|
||||
<div className="flex flex-col">
|
||||
{outputs.map((output, index) => {
|
||||
const editable = !isDefaultOutput(output)
|
||||
const children = getOutputChildren(output)
|
||||
const isEditingOutput = editingState?.outputIndex === index && !editingState.childPath && !editingState.parentPath
|
||||
return (
|
||||
<div key={`${output.name}-${getOutputTypeOptionValue(output)}`} className="flex flex-col">
|
||||
{isEditingOutput
|
||||
? (
|
||||
<OutputEditCard
|
||||
key={`${output.name}-editing`}
|
||||
editingIndex={index}
|
||||
existingOutputs={outputs}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<OutputRow
|
||||
output={output}
|
||||
editable={editable}
|
||||
onAddChild={editable && canOutputHaveChildren(output) ? () => handleNewChild(index, []) : undefined}
|
||||
onDelete={() => handleDeleteOutput(index)}
|
||||
onEdit={() => handleEditOutput(index)}
|
||||
/>
|
||||
)}
|
||||
{renderChildren(output, index, 1, children, editable)}
|
||||
{editingState?.outputIndex === index && editingState.parentPath && !editingState.parentPath.length && (
|
||||
<ChildOutputFrame depth={1}>
|
||||
<OutputEditCard
|
||||
allowDefaultValue={false}
|
||||
existingOutputs={children}
|
||||
state={editingState}
|
||||
onCancel={() => setEditingState(null)}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</ChildOutputFrame>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="py-1">
|
||||
<Divider type="horizontal" className="h-px bg-divider-subtle" />
|
||||
</div>
|
||||
{editingState && editingState.index == null
|
||||
{editingState && editingState.outputIndex == null
|
||||
? (
|
||||
<OutputEditCard
|
||||
existingOutputs={outputs}
|
||||
@ -149,3 +287,10 @@ export function AgentOutputVariables({
|
||||
</OutputVars>
|
||||
)
|
||||
}
|
||||
|
||||
function pathsEqual(left?: number[], right?: number[]) {
|
||||
if (!left || !right || left.length !== right.length)
|
||||
return false
|
||||
|
||||
return left.every((item, index) => item === right[index])
|
||||
}
|
||||
|
||||
@ -2,6 +2,10 @@ import type { DeclaredOutputConfig, DeclaredOutputType } from '@dify/contracts/a
|
||||
import type { TFunction } from 'i18next'
|
||||
import { defaultAgentV2DeclaredOutputs } from '../../output-variables'
|
||||
|
||||
export type DeclaredOutputChildConfig = NonNullable<DeclaredOutputConfig['children']>[number]
|
||||
|
||||
export type EditableOutputConfig = DeclaredOutputConfig | DeclaredOutputChildConfig
|
||||
|
||||
export type OutputTypeOptionValue
|
||||
= DeclaredOutputType
|
||||
| 'array[boolean]'
|
||||
@ -23,11 +27,14 @@ export type OutputDraft = {
|
||||
name: string
|
||||
required: boolean
|
||||
type: OutputTypeOptionValue
|
||||
children: DeclaredOutputChildConfig[]
|
||||
}
|
||||
|
||||
export type EditingState = {
|
||||
draft: OutputDraft
|
||||
index?: number
|
||||
outputIndex?: number
|
||||
childPath?: number[]
|
||||
parentPath?: number[]
|
||||
}
|
||||
|
||||
export type AgentOutputVariablesProps = {
|
||||
@ -51,7 +58,7 @@ export const OUTPUT_TYPE_OPTIONS: OutputTypeOption[] = [
|
||||
{ value: 'array[file]', label: 'array[file]', type: 'array', arrayItemType: 'file' },
|
||||
]
|
||||
|
||||
export function getOutputTypeOptionValue(output: DeclaredOutputConfig): OutputTypeOptionValue {
|
||||
export function getOutputTypeOptionValue(output: EditableOutputConfig): OutputTypeOptionValue {
|
||||
if (output.type !== 'array')
|
||||
return output.type
|
||||
|
||||
@ -62,7 +69,7 @@ export function getOutputTypeOption(value: OutputTypeOptionValue) {
|
||||
return OUTPUT_TYPE_OPTIONS.find(option => option.value === value) || OUTPUT_TYPE_OPTIONS[0]!
|
||||
}
|
||||
|
||||
export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
export function createDraft(output?: EditableOutputConfig): OutputDraft {
|
||||
if (!output) {
|
||||
return {
|
||||
defaultValue: '',
|
||||
@ -70,6 +77,7 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
name: '',
|
||||
required: false,
|
||||
type: 'string',
|
||||
children: [],
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,10 +87,14 @@ export function createDraft(output?: DeclaredOutputConfig): OutputDraft {
|
||||
name: output.name,
|
||||
required: output.required ?? true,
|
||||
type: getOutputTypeOptionValue(output),
|
||||
children: getOutputChildren(output),
|
||||
}
|
||||
}
|
||||
|
||||
export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig {
|
||||
export function createOutputFromDraft(
|
||||
draft: OutputDraft,
|
||||
{ includeDefaultValue = true }: { includeDefaultValue?: boolean } = {},
|
||||
): DeclaredOutputConfig {
|
||||
const option = getOutputTypeOption(draft.type)
|
||||
const output: DeclaredOutputConfig = {
|
||||
name: draft.name.trim(),
|
||||
@ -99,6 +111,16 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.children.length && draft.type === 'object')
|
||||
output.children = draft.children
|
||||
|
||||
if (draft.children.length && draft.type === 'array[object]') {
|
||||
output.array_item = {
|
||||
type: 'object',
|
||||
children: draft.children,
|
||||
}
|
||||
}
|
||||
|
||||
if (option.type === 'file') {
|
||||
output.file = {
|
||||
extensions: [],
|
||||
@ -106,7 +128,7 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (draft.defaultValue.trim()) {
|
||||
if (includeDefaultValue && draft.defaultValue.trim()) {
|
||||
output.failure_strategy = {
|
||||
on_failure: 'default_value',
|
||||
default_value: coerceDefaultValue(draft.defaultValue, option),
|
||||
@ -116,6 +138,113 @@ export function createOutputFromDraft(draft: OutputDraft): DeclaredOutputConfig
|
||||
return output
|
||||
}
|
||||
|
||||
export function toDeclaredOutputChild(output: DeclaredOutputConfig): DeclaredOutputChildConfig {
|
||||
return {
|
||||
name: output.name,
|
||||
type: output.type,
|
||||
required: output.required,
|
||||
...(output.description ? { description: output.description } : {}),
|
||||
...(output.file ? { file: output.file } : {}),
|
||||
...(output.children ? { children: output.children } : {}),
|
||||
...(output.array_item ? { array_item: output.array_item } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function getOutputChildren(output: EditableOutputConfig): DeclaredOutputChildConfig[] {
|
||||
if (getOutputTypeOptionValue(output) === 'array[object]')
|
||||
return readOutputChildren(output.array_item?.children)
|
||||
|
||||
if (output.type === 'object')
|
||||
return readOutputChildren(output.children)
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export function canOutputHaveChildren(output: EditableOutputConfig) {
|
||||
const type = getOutputTypeOptionValue(output)
|
||||
return type === 'object' || type === 'array[object]'
|
||||
}
|
||||
|
||||
export function updateOutputChildren(
|
||||
output: DeclaredOutputConfig,
|
||||
children: DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputConfig {
|
||||
if (getOutputTypeOptionValue(output) === 'array[object]') {
|
||||
return {
|
||||
...output,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
...output.array_item,
|
||||
children: children.length ? children : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (output.type === 'object') {
|
||||
return {
|
||||
...output,
|
||||
children: children.length ? children : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
export function getOutputChildrenAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
path: number[],
|
||||
): DeclaredOutputChildConfig[] {
|
||||
const target = getOutputChildAtPath(output, path)
|
||||
return target ? getOutputChildren(target) : getOutputChildren(output)
|
||||
}
|
||||
|
||||
export function getOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
path: number[],
|
||||
): DeclaredOutputChildConfig | undefined {
|
||||
let current: DeclaredOutputChildConfig | undefined
|
||||
let children = getOutputChildren(output)
|
||||
for (const index of path) {
|
||||
current = children[index]
|
||||
if (!current)
|
||||
return undefined
|
||||
children = getOutputChildren(current)
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
export function insertOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
parentPath: number[],
|
||||
child: DeclaredOutputChildConfig,
|
||||
) {
|
||||
return updateOutputChildrenAtPath(output, parentPath, children => [...children, child])
|
||||
}
|
||||
|
||||
export function updateOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
childPath: number[],
|
||||
child: DeclaredOutputChildConfig,
|
||||
) {
|
||||
const childIndex = childPath.at(-1)
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.map((item, index) => index === childIndex ? child : item))
|
||||
}
|
||||
|
||||
export function deleteOutputChildAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
childPath: number[],
|
||||
) {
|
||||
const childIndex = childPath.at(-1)
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
return updateOutputChildrenAtPath(output, childPath.slice(0, -1), children => children.filter((_, index) => index !== childIndex))
|
||||
}
|
||||
|
||||
export function getDefaultValueErrorKey(draft: OutputDraft) {
|
||||
const trimmed = draft.defaultValue.trim()
|
||||
if (!trimmed)
|
||||
@ -157,7 +286,7 @@ export function isDefaultOutput(output: DeclaredOutputConfig) {
|
||||
)
|
||||
}
|
||||
|
||||
export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction) {
|
||||
export function getOutputDescription(output: EditableOutputConfig, t: TFunction) {
|
||||
if (output.name === 'text')
|
||||
return t('nodes.agent.outputVars.text', { ns: 'workflow' })
|
||||
if (output.name === 'files')
|
||||
@ -167,11 +296,83 @@ export function getOutputDescription(output: DeclaredOutputConfig, t: TFunction)
|
||||
return output.description || ''
|
||||
}
|
||||
|
||||
export function getOutputDisplayType(output: DeclaredOutputConfig) {
|
||||
export function getOutputDisplayType(output: EditableOutputConfig) {
|
||||
return getOutputTypeOption(getOutputTypeOptionValue(output)).label
|
||||
}
|
||||
|
||||
function getOutputDefaultValue(output: DeclaredOutputConfig) {
|
||||
function readOutputChildren(children: EditableOutputConfig['children']) {
|
||||
return (children ?? []) as DeclaredOutputChildConfig[]
|
||||
}
|
||||
|
||||
function updateOutputChildrenAtPath(
|
||||
output: DeclaredOutputConfig,
|
||||
parentPath: number[],
|
||||
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputConfig {
|
||||
if (!parentPath.length)
|
||||
return updateOutputChildren(output, updater(getOutputChildren(output)))
|
||||
|
||||
const [childIndex, ...restPath] = parentPath
|
||||
if (childIndex == null)
|
||||
return output
|
||||
|
||||
const children = getOutputChildren(output)
|
||||
const nextChildren = children.map((child, index) => (
|
||||
index === childIndex ? updateChildChildrenAtPath(child, restPath, updater) : child
|
||||
))
|
||||
|
||||
return updateOutputChildren(output, nextChildren)
|
||||
}
|
||||
|
||||
function updateChildChildrenAtPath(
|
||||
child: DeclaredOutputChildConfig,
|
||||
parentPath: number[],
|
||||
updater: (children: DeclaredOutputChildConfig[]) => DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputChildConfig {
|
||||
if (!parentPath.length)
|
||||
return updateChildChildren(child, updater(getOutputChildren(child)))
|
||||
|
||||
const [childIndex, ...restPath] = parentPath
|
||||
if (childIndex == null)
|
||||
return child
|
||||
|
||||
const children = getOutputChildren(child)
|
||||
const nextChildren = children.map((nestedChild, index) => (
|
||||
index === childIndex ? updateChildChildrenAtPath(nestedChild, restPath, updater) : nestedChild
|
||||
))
|
||||
|
||||
return updateChildChildren(child, nextChildren)
|
||||
}
|
||||
|
||||
function updateChildChildren(
|
||||
child: DeclaredOutputChildConfig,
|
||||
children: DeclaredOutputChildConfig[],
|
||||
): DeclaredOutputChildConfig {
|
||||
if (getOutputTypeOptionValue(child) === 'array[object]') {
|
||||
return {
|
||||
...child,
|
||||
array_item: {
|
||||
type: 'object',
|
||||
...child.array_item,
|
||||
children: children.length ? children : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (child.type === 'object') {
|
||||
return {
|
||||
...child,
|
||||
children: children.length ? children : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return child
|
||||
}
|
||||
|
||||
function getOutputDefaultValue(output: EditableOutputConfig) {
|
||||
if (!('failure_strategy' in output))
|
||||
return ''
|
||||
|
||||
const defaultValue = output.failure_strategy?.default_value
|
||||
if (defaultValue == null)
|
||||
return ''
|
||||
|
||||
Loading…
Reference in New Issue
Block a user