diff --git a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx index 828a16b5e87..3d6766b0024 100644 --- a/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/prompt-editor-content.spec.tsx @@ -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( + , + ) + + await waitFor(() => { + expect(screen.getByText('summary')).toBeInTheDocument() + expect(screen.getByText('string')).toBeInTheDocument() + }) + + rerender( + , + ) + + 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() diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx index 50c6b1f98b4..95f71afc67a 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/__tests__/node.spec.tsx @@ -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') diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx b/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx index 72d8450e85f..aeafc69c5f4 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/index.tsx @@ -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) { + 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' diff --git a/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts b/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts index 149522c6576..1b2fcbf3772 100644 --- a/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts +++ b/web/app/components/base/prompt-editor/plugins/agent-output-block/utils.ts @@ -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' }, 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 4100257d76a..6212068fe96 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 @@ -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( + , + ) + + 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( { + const handleInsert = useCallback(() => { onInsert() editor.focus() editor.update(() => { - $insertNodes([$createCustomTextNode(text)]) + $insertNodes([$createCustomTextNode('/')]) }) }, [editor, onInsert]) @@ -41,19 +41,11 @@ function AgentTaskToolbar({ -
{taskLength} @@ -146,10 +138,12 @@ export function AgentTaskField({ onChange: onOutputsChange, }} > - + {isFocus && ( + + )}
diff --git a/web/app/components/workflow/nodes/agent-v2/panel.tsx b/web/app/components/workflow/nodes/agent-v2/panel.tsx index 7b2d5b1f6c1..371b2d847ee 100644 --- a/web/app/components/workflow/nodes/agent-v2/panel.tsx +++ b/web/app/components/workflow/nodes/agent-v2/panel.tsx @@ -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, 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,