Merge branch 'feat/agent-v2' of https://github.com/langgenius/dify into feat/agent-v2

This commit is contained in:
yyh
2026-06-17 17:59:11 +08:00
7 changed files with 205 additions and 26 deletions

View File

@ -270,6 +270,53 @@ describe('PromptEditorContent', () => {
})
})
it('should update rendered output block type when declared output config changes', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const outputBlock = {
show: true,
outputs: [
{ name: 'summary', type: 'string' as const },
],
}
const { rerender } = render(
<PromptEditorContentHarness
captures={captures}
initialText="[§output:summary:summary§]"
shortcutPopups={[]}
floatingAnchorElem={document.createElement('div')}
onEditorChange={vi.fn()}
agentOutputBlock={outputBlock}
/>,
)
await waitFor(() => {
expect(screen.getByText('summary')).toBeInTheDocument()
expect(screen.getByText('string')).toBeInTheDocument()
})
rerender(
<PromptEditorContentHarness
captures={captures}
initialText="[§output:summary:summary§]"
shortcutPopups={[]}
floatingAnchorElem={document.createElement('div')}
onEditorChange={vi.fn()}
agentOutputBlock={{
show: true,
outputs: [
{ name: 'summary', type: 'file' },
],
}}
/>,
)
await waitFor(() => {
expect(screen.getByText('file')).toBeInTheDocument()
})
expect(screen.queryByText('string')).not.toBeInTheDocument()
})
it('should render optional blocks and open shortcut popups with the real editor runtime', async () => {
const captures: Captures = { editor: null, eventEmitter: null }
const onEditorChange = vi.fn()

View File

@ -14,6 +14,7 @@ import {
getAgentOutputToken,
inferAgentOutputType,
parseAgentOutputToken,
replaceAgentOutputName,
} from '../utils'
describe('AgentOutputBlockNode', () => {
@ -82,6 +83,14 @@ describe('AgentOutputBlockNode', () => {
])
})
it('should replace only matching output token names', () => {
expect(replaceAgentOutputName(
'Use [§output:summary:summary§] and §output:other:other§',
'summary',
'final_summary',
)).toBe('Use [§output:final_summary:final_summary§] and §output:other:other§')
})
it('should create node with helper and support type guard checks', () => {
runInEditor(() => {
const node = $createAgentOutputBlockNode('result', 'object')

View File

@ -1,4 +1,4 @@
import type { TextNode } from 'lexical'
import type { ElementNode, TextNode } from 'lexical'
import type { AgentOutputBlockType } from '../../types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
@ -6,6 +6,7 @@ import {
$applyNodeReplacement,
$getRoot,
$insertNodes,
$isElementNode,
COMMAND_PRIORITY_EDITOR,
} from 'lexical'
import { memo, useCallback, useEffect } from 'react'
@ -21,6 +22,12 @@ import {
parseAgentOutputToken,
} from './utils'
function getAgentOutputBlockNodeType(name: string, outputs: NonNullable<AgentOutputBlockType['outputs']>) {
const output = outputs.find(item => item.name === name)
return inferAgentOutputType(name, output ? getAgentOutputTypeOptionValue(output) : 'string')
}
const AgentOutputBlock = memo(({
outputs = [],
onChange,
@ -69,8 +76,7 @@ const AgentOutputBlockReplacementBlock = memo(({
const createAgentOutputBlockNode = useCallback((textNode: TextNode): AgentOutputBlockNode => {
const match = parseAgentOutputToken(textNode.getTextContent())
const name = match?.name || ''
const output = outputs.find(item => item.name === name)
const outputType = inferAgentOutputType(name, output ? getAgentOutputTypeOptionValue(output) : 'string')
const outputType = getAgentOutputBlockNodeType(name, outputs)
return $applyNodeReplacement($createAgentOutputBlockNode(name, outputType, false, outputs, onChange))
}, [onChange, outputs])
@ -99,6 +105,32 @@ const AgentOutputBlockReplacementBlock = memo(({
)
}, [editor, transformListener])
useEffect(() => {
editor.update(() => {
const visitNode = (node: ElementNode) => {
node.getChildren().forEach((child) => {
if (child instanceof AgentOutputBlockNode) {
const name = child.getName()
const outputType = getAgentOutputBlockNodeType(name, outputs)
if (
child.getOutputType() !== outputType
|| child.getOutputs() !== outputs
|| child.getOnChange() !== onChange
) {
child.replace($createAgentOutputBlockNode(name, outputType, child.isEditing(), outputs, onChange))
}
return
}
if ($isElementNode(child))
visitNode(child)
})
}
visitNode($getRoot())
})
}, [editor, onChange, outputs])
return null
})
AgentOutputBlockReplacementBlock.displayName = 'AgentOutputBlockReplacementBlock'

View File

@ -84,6 +84,19 @@ export function extractAgentOutputNames(text: string) {
return names
}
export function replaceAgentOutputName(text: string, oldName: string, nextName: string) {
const replaceTokenName = (match: string, name: string, mirrorName: string) => {
if (name !== oldName)
return match
return match.replace(`${name}:${mirrorName}`, `${nextName}:${nextName}`)
}
return text
.replace(new RegExp(AGENT_OUTPUT_TOKEN_REGEX.source, 'g'), replaceTokenName)
.replace(new RegExp(LEGACY_AGENT_OUTPUT_TOKEN_REGEX.source, 'g'), replaceTokenName)
}
export const AGENT_OUTPUT_TYPE_OPTIONS: AgentOutputTypeOption[] = [
{ value: 'string', label: 'string', type: 'string' },
{ value: 'number', label: 'number', type: 'number' },

View File

@ -62,6 +62,8 @@ vi.mock('@/app/components/base/prompt-editor', () => ({
placeholder={typeof props.placeholder === 'string' ? props.placeholder : undefined}
value={props.value}
onChange={event => props.onChange?.(event.currentTarget.value)}
onBlur={props.onBlur}
onFocus={props.onFocus}
/>
{props.children}
</>
@ -547,12 +549,15 @@ describe('agent/panel', () => {
})
expect(mockPromptEditorProps[0]?.contextBlock).toBeUndefined()
expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.task.insert' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.task.mention' })).not.toBeInTheDocument()
fireEvent.focus(editor)
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.task.insert' }))
expect(mockEditorFocus).toHaveBeenCalled()
expect(mockInsertNodes.mock.calls[0]?.[0]?.[0]?.getTextContent()).toBe('/')
fireEvent.click(screen.getByRole('button', { name: 'workflow.nodes.agent.task.mention' }))
expect(mockInsertNodes.mock.calls[1]?.[0]?.[0]?.getTextContent()).toBe('{')
expect(screen.queryByRole('button', { name: 'workflow.nodes.agent.task.mention' })).not.toBeInTheDocument()
})
it('syncs declared outputs created from the agent task editor', () => {
@ -732,6 +737,64 @@ describe('agent/panel', () => {
)
})
it('renames prompt output tokens when a referenced declared output is renamed from the output list', () => {
render(
<AgentV2Panel
id="agent-node"
data={createData({
agent_task: 'Use [§output:summary:summary§] and §output:other:other§',
agent_declared_outputs: [
{
name: 'summary',
type: 'string',
required: false,
},
{
name: 'other',
type: 'string',
required: false,
},
],
})}
panelProps={panelProps}
/>,
)
mockPromptEditorProps[0]?.agentOutputBlock?.onChange?.([
{
name: 'final_summary',
type: 'string',
required: false,
},
{
name: 'other',
type: 'string',
required: false,
},
])
expect(mockHandleNodeDataUpdateWithSyncDraft).toHaveBeenCalledWith(
{
id: 'agent-node',
data: expect.objectContaining({
agent_task: 'Use [§output:final_summary:final_summary§] and §output:other:other§',
agent_declared_outputs: [
expect.objectContaining({
name: 'final_summary',
}),
expect.objectContaining({
name: 'other',
}),
],
}),
},
expect.objectContaining({
sync: true,
notRefreshWhenSyncError: true,
}),
)
})
it('renders declared outputs from workflow draft graph data', () => {
render(
<AgentV2Panel

View File

@ -27,11 +27,11 @@ function AgentTaskToolbar({
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const handleInsert = useCallback((text: '/' | '{') => {
const handleInsert = useCallback(() => {
onInsert()
editor.focus()
editor.update(() => {
$insertNodes([$createCustomTextNode(text)])
$insertNodes([$createCustomTextNode('/')])
})
}, [editor, onInsert])
@ -41,19 +41,11 @@ function AgentTaskToolbar({
<button
type="button"
className="flex items-center gap-1 system-xs-medium hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => handleInsert('/')}
onClick={handleInsert}
>
<span aria-hidden className="i-ri-slash-commands-2 size-3.5" />
{t(`${i18nPrefix}.task.insert`, { ns: 'workflow' })}
</button>
<button
type="button"
className="flex items-center gap-1 system-xs-medium hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => handleInsert('{')}
>
<span aria-hidden className="i-ri-at-line size-3.5" />
{t(`${i18nPrefix}.task.mention`, { ns: 'workflow' })}
</button>
</div>
<div className="rounded-sm border border-divider-regular bg-background-default px-1 system-2xs-regular text-text-tertiary">
{taskLength}
@ -146,10 +138,12 @@ export function AgentTaskField({
onChange: onOutputsChange,
}}
>
<AgentTaskToolbar
taskLength={(data.agent_task || '').length}
onInsert={setFocus}
/>
{isFocus && (
<AgentTaskToolbar
taskLength={(data.agent_task || '').length}
onInsert={setFocus}
/>
)}
</PromptEditor>
</div>
</div>

View File

@ -4,7 +4,10 @@ import type { AgentV2NodeType } from './types'
import { produce } from 'immer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { extractAgentOutputNames } from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
import {
extractAgentOutputNames,
replaceAgentOutputName,
} from '@/app/components/base/prompt-editor/plugins/agent-output-block/utils'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import useNodeCrud from '../_base/hooks/use-node-crud'
@ -119,14 +122,32 @@ export function AgentV2Panel({
}, [id, isInlineAgentReady, setOpenInlineAgentPanelNodeId])
const handleDeclaredOutputsChange = useCallback((outputs: ReturnType<typeof getAgentV2DeclaredOutputs>, agentTask?: string) => {
const previousOutputs = getAgentV2DeclaredOutputs(inputsRef.current)
let nextAgentTask = agentTask
if (nextAgentTask === undefined && previousOutputs.length === outputs.length) {
const renamedOutputs = previousOutputs
.map((previousOutput, index) => ({
oldName: previousOutput.name,
nextName: outputs[index]?.name,
}))
.filter(({ oldName, nextName }) => nextName && oldName !== nextName)
if (renamedOutputs.length === 1) {
const { oldName, nextName } = renamedOutputs[0]!
const currentAgentTask = inputsRef.current.agent_task || ''
if (extractAgentOutputNames(currentAgentTask).has(oldName))
nextAgentTask = replaceAgentOutputName(currentAgentTask, oldName, nextName!)
}
}
const newInputs = produce(inputsRef.current, (draft) => {
draft.agent_declared_outputs = outputs
if (agentTask !== undefined)
draft.agent_task = agentTask
if (nextAgentTask !== undefined)
draft.agent_task = nextAgentTask
})
inputsRef.current = newInputs
if (agentTask !== undefined)
promptOutputNamesRef.current = extractAgentOutputNames(agentTask)
if (nextAgentTask !== undefined)
promptOutputNamesRef.current = extractAgentOutputNames(nextAgentTask)
handleNodeDataUpdateWithSyncDraft(
{
id,