mirror of
https://github.com/langgenius/dify.git
synced 2026-07-15 01:17:04 +08:00
Merge branch 'feat/agent-v2' of https://github.com/langgenius/dify into feat/agent-v2
This commit is contained in:
@ -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()
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user