From 12be211a6d98098febcc36f5eaf5a2769024533f Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Thu, 26 Mar 2026 10:29:07 +0800 Subject: [PATCH] feat(workflow): merge tool agent insertions into slash menu --- .../__tests__/index.spec.tsx | 73 ++++++++++ .../plugins/component-picker-block/index.tsx | 14 +- .../components/base/prompt-editor/types.ts | 2 + .../__tests__/var-reference-vars.spec.tsx | 79 ++++++++++ .../variable/var-reference-vars.tsx | 137 +++++++++++++++--- .../hooks/use-context-generate.ts | 3 +- .../components/__tests__/placeholder.spec.tsx | 86 +++++++++++ .../components/placeholder.tsx | 17 --- .../mixed-variable-text-input/index.tsx | 9 +- 9 files changed, 371 insertions(+), 49 deletions(-) create mode 100644 web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/components/__tests__/placeholder.spec.tsx diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index 6cc6c3a67f..e2dfe09778 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -631,4 +631,77 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { // With a single option group, the only divider should be the workflow-var/options separator. expect(document.querySelectorAll('.bg-divider-subtle')).toHaveLength(1) }) + + it('renders agent entries in the slash menu and routes selection through workflowVariableBlock.onSelectAgent', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const onSelectAgent = vi.fn() + + const workflowVariableBlock = makeWorkflowVariableBlock({ + agentNodes: [{ id: 'agent-1', title: 'Agent One' }], + onSelectAgent, + showAssembleVariables: true, + onAssembleVariables: vi.fn(() => ['tool-ext', 'result']), + }, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('output', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + await setEditorText(editor, '/', true) + await flushNextTick() + + const agentButton = await screen.findByRole('button', { name: 'Agent One' }) + const assembleButton = await screen.findByRole('button', { name: 'workflow.nodes.tool.assembleVariables' }) + + expect(agentButton.compareDocumentPosition(assembleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + fireEvent.click(agentButton) + + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['agent-1', 'context']) + expect(onSelectAgent).toHaveBeenCalledWith({ id: 'agent-1', title: 'Agent One' }) + + await waitFor(() => { + expect(readEditorText(editor)).not.toContain('/') + }) + }) + + it('does not render an at-menu when triggerString is @ but agentBlock is not provided', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({ + agentNodes: [{ id: 'agent-1', title: 'Agent One' }], + onSelectAgent: vi.fn(), + }, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('output', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + await setEditorText(editor, '@', true) + + await waitFor(() => { + expect(screen.queryByText('Agent One')).not.toBeInTheDocument() + expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument() + }) + }) }) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 8cef22a27c..094a524b78 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -254,13 +254,14 @@ const ComponentPicker = ({ root.selectStart() }) editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [agent.id, 'context']) + workflowVariableBlock?.onSelectAgent?.(agent) agentBlock?.onSelect?.(agent) editor.update(() => { const root = $getRoot() root.selectEnd() }) handleClose() - }, [editor, getMatchFromSelection, agentBlock, handleClose]) + }, [editor, getMatchFromSelection, workflowVariableBlock, agentBlock, handleClose]) const handleSelectContext = useCallback(() => { if (!contextBlock?.selectable) @@ -279,7 +280,9 @@ const ComponentPicker = ({ const isAgentTrigger = triggerString === '@' && agentBlock?.show const showAssembleVariables = triggerString === '/' && workflowVariableBlock?.showAssembleVariables && !!workflowVariableBlock?.onAssembleVariables - const agentNodes: AgentNode[] = useMemo(() => agentBlock?.agentNodes || [], [agentBlock?.agentNodes]) + const agentNodes: AgentNode[] = useMemo(() => { + return workflowVariableBlock?.agentNodes || agentBlock?.agentNodes || [] + }, [workflowVariableBlock?.agentNodes, agentBlock?.agentNodes]) const handleOpen = useCallback(() => { if (isSupportSandbox && triggerString === '/') setActiveTab('variables') @@ -289,6 +292,9 @@ const ComponentPicker = ({ anchorElementRef, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, ) => { + if (triggerString === '@' && !agentBlock?.show) + return null + if (isAgentTrigger) { if (!(anchorElementRef.current && agentNodes.length)) return null @@ -462,6 +468,8 @@ const ComponentPicker = ({ onBlur={handleClose} showManageInputField={workflowVariableBlock.showManageInputField} onManageInputField={workflowVariableBlock.onManageInputField} + agentNodes={triggerString === '/' ? workflowVariableBlock.agentNodes : undefined} + onSelectAgent={triggerString === '/' && workflowVariableBlock.agentNodes?.length ? handleSelectAgent : undefined} showAssembleVariables={showAssembleVariables} onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined} autoFocus={false} @@ -510,7 +518,7 @@ const ComponentPicker = ({ } ) - }, [isAgentTrigger, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext]) + }, [isAgentTrigger, isSupportSandbox, triggerString, allFlattenOptions.length, workflowVariableBlock?.show, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, workflowVariableBlock?.agentNodes, floatingStyles, isPositioned, refs, agentNodes, handleSelectAgent, handleClose, useExternalSearch, queryString, workflowVariableOptions, isSupportFileVar, showAssembleVariables, handleSelectAssembleVariables, currentBlock?.generatorType, t, activeTab, handleSelectWorkflowVariable, handleSelectFileReference, contextBlock?.show, contextBlock?.selectable, handleSelectContext, agentBlock?.show]) return ( <> diff --git a/web/app/components/base/prompt-editor/types.ts b/web/app/components/base/prompt-editor/types.ts index fe041d6c18..b832e1abfb 100644 --- a/web/app/components/base/prompt-editor/types.ts +++ b/web/app/components/base/prompt-editor/types.ts @@ -79,6 +79,8 @@ export type WorkflowVariableBlockType = { getVarType?: GetVarType showManageInputField?: boolean onManageInputField?: () => void + agentNodes?: AgentNode[] + onSelectAgent?: (agent: AgentNode) => void showAssembleVariables?: boolean onAssembleVariables?: () => ValueSelector | null } diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx index eca09b88f6..29515aa9ae 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx @@ -223,4 +223,83 @@ describe('VarReferenceVars', () => { fireEvent.click(screen.getByText('asset')) expect(onChange).not.toHaveBeenCalled() }) + + it('should render agent entries before assemble variables and normal variables', () => { + const onSelectAgent = vi.fn() + + render( + null} + />, + ) + + const agentButton = screen.getByRole('button', { name: 'Agent One' }) + const assembleButton = screen.getByRole('button', { name: 'workflow.nodes.tool.assembleVariables' }) + const variableLabel = screen.getByText('valid_name') + + expect(agentButton.compareDocumentPosition(assembleButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + expect(assembleButton.compareDocumentPosition(variableLabel) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + + fireEvent.click(agentButton) + expect(onSelectAgent).toHaveBeenCalledWith({ id: 'agent-1', title: 'Agent One' }) + }) + + it('should filter agent entries and variables with the shared search box', () => { + render( + , + ) + + const searchInput = screen.getByPlaceholderText('workflow.common.searchVar') + + fireEvent.change(searchInput, { target: { value: 'agent' } }) + expect(screen.getByRole('button', { name: 'Agent One' })).toBeInTheDocument() + expect(screen.queryByText('valid_name')).not.toBeInTheDocument() + + fireEvent.change(searchInput, { target: { value: 'valid' } }) + expect(screen.getByText('valid_name')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Agent One' })).not.toBeInTheDocument() + }) + + it('should include agents, assemble variables, and variables in keyboard navigation order', () => { + const onSelectAgent = vi.fn() + const onAssembleVariables = vi.fn(() => null) + const onChange = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'Enter' }) + expect(onSelectAgent).toHaveBeenCalledWith({ id: 'agent-1', title: 'Agent One' }) + + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + expect(onAssembleVariables).toHaveBeenCalledTimes(1) + + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + expect(onChange).toHaveBeenCalledWith(['node-a', 'valid_name'], expect.objectContaining({ + variable: 'valid_name', + })) + }) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 3878933a02..e7f8143e12 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -1,6 +1,7 @@ 'use client' import type { FC } from 'react' import type { StructuredOutput } from '../../../llm/types' +import type { AgentNode } from '@/app/components/base/prompt-editor/types' import type { Field } from '@/app/components/workflow/nodes/llm/types' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { useHover, useLatest } from 'ahooks' @@ -11,6 +12,7 @@ import { useTranslation } from 'react-i18next' import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' import { AssembleVariables, CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' +import { Agent } from '@/app/components/base/icons/src/vender/workflow' import Input from '@/app/components/base/input' import { PortalToFollowElem, @@ -102,6 +104,11 @@ const matchesNestedVar = (itemData: Var, query: string): boolean => { return false } +type KeyboardItem + = | { type: 'agent', agent: AgentNode } + | { type: 'assemble' } + | { type: 'variable', node: NodeOutPutVar, itemData: Var } + type ItemProps = { nodeId: string title: string @@ -339,6 +346,8 @@ type Props = { isInCodeGeneratorInstructionEditor?: boolean showManageInputField?: boolean onManageInputField?: () => void + agentNodes?: AgentNode[] + onSelectAgent?: (agent: AgentNode) => void showAssembleVariables?: boolean onAssembleVariables?: () => ValueSelector | null autoFocus?: boolean @@ -360,6 +369,8 @@ const VarReferenceVars: FC = ({ isInCodeGeneratorInstructionEditor, showManageInputField, onManageInputField, + agentNodes, + onSelectAgent, showAssembleVariables, onAssembleVariables, autoFocus = true, @@ -388,6 +399,14 @@ const VarReferenceVars: FC = ({ onClose?.() } + const filteredAgentNodes = useMemo(() => { + if (!agentNodes?.length || !onSelectAgent) + return [] + if (!normalizedSearchTextTrimmed) + return agentNodes + return agentNodes.filter(node => node.title.toLowerCase().includes(normalizedSearchTextLower)) + }, [agentNodes, normalizedSearchTextLower, normalizedSearchTextTrimmed, onSelectAgent]) + const validatedVars = useMemo(() => { const result: NodeOutPutVar[] = [] vars.forEach((node) => { @@ -435,23 +454,33 @@ const VarReferenceVars: FC = ({ }) return items }, [filteredVars]) + const showAgentSection = filteredAgentNodes.length > 0 + const showAssembleEntry = !!(showAssembleVariables && onAssembleVariables) + const keyboardItems = useMemo(() => { + const items: KeyboardItem[] = [] + filteredAgentNodes.forEach(agent => items.push({ type: 'agent', agent })) + if (showAssembleEntry) + items.push({ type: 'assemble' }) + flatItems.forEach(item => items.push({ type: 'variable', ...item })) + return items + }, [filteredAgentNodes, flatItems, showAssembleEntry]) const [activeIndex, setActiveIndex] = useState(-1) - const itemRefs = useRef>([]) + const itemRefsRef = useRef>([]) const lastInteractionRef = useRef<'keyboard' | 'mouse' | 'filter' | null>(null) const resolvedActiveIndex = useMemo(() => { - if (!enableKeyboardNavigation || flatItems.length === 0) + if (!enableKeyboardNavigation || keyboardItems.length === 0) return -1 - if (activeIndex < 0 || activeIndex >= flatItems.length) + if (activeIndex < 0 || activeIndex >= keyboardItems.length) return 0 return activeIndex - }, [activeIndex, enableKeyboardNavigation, flatItems.length]) - const flatItemsRef = useLatest(flatItems) + }, [activeIndex, enableKeyboardNavigation, keyboardItems.length]) + const keyboardItemsRef = useLatest(keyboardItems) const activeIndexRef = useLatest(resolvedActiveIndex) const onCloseRef = useLatest(onClose) useEffect(() => { - itemRefs.current = [] - }, [flatItems.length]) + itemRefsRef.current = [] + }, [keyboardItems.length]) const handleHighlightIndex = useCallback((index: number, source: 'keyboard' | 'mouse' | 'filter') => { lastInteractionRef.current = source @@ -459,26 +488,38 @@ const VarReferenceVars: FC = ({ }, []) useEffect(() => { - if (!enableKeyboardNavigation || flatItems.length === 0) { + if (!enableKeyboardNavigation || keyboardItems.length === 0) { lastInteractionRef.current = 'filter' return } - if (activeIndex < 0 || activeIndex >= flatItems.length) + if (activeIndex < 0 || activeIndex >= keyboardItems.length) lastInteractionRef.current = 'filter' - }, [activeIndex, enableKeyboardNavigation, flatItems.length]) + }, [activeIndex, enableKeyboardNavigation, keyboardItems.length]) useEffect(() => { if (!enableKeyboardNavigation || resolvedActiveIndex < 0) return if (lastInteractionRef.current !== 'keyboard') return - const target = itemRefs.current[resolvedActiveIndex] + const target = itemRefsRef.current[resolvedActiveIndex] if (target) target.scrollIntoView({ block: 'nearest' }) lastInteractionRef.current = null - }, [enableKeyboardNavigation, flatItems.length, resolvedActiveIndex]) + }, [enableKeyboardNavigation, keyboardItems.length, resolvedActiveIndex]) + + const handleSelectItem = useCallback((item: KeyboardItem) => { + if (item.type === 'agent') { + onSelectAgent?.(item.agent) + onClose?.() + return + } + + if (item.type === 'assemble') { + onAssembleVariables?.() + onClose?.() + return + } - const handleSelectItem = useCallback((item: { node: NodeOutPutVar, itemData: Var }) => { const isStructureOutput = item.itemData.type === VarType.object && (item.itemData.children as StructuredOutput | undefined)?.schema?.properties const isFile = item.itemData.type === VarType.file && !isStructureOutput @@ -500,13 +541,13 @@ const VarReferenceVars: FC = ({ onChange(valueSelector, item.itemData) onClose?.() - }, [isSupportFileVar, onChange, onClose]) + }, [isSupportFileVar, onChange, onClose, onSelectAgent, onAssembleVariables]) useEffect(() => { if (!enableKeyboardNavigation) return const handleDocumentKeyDown = (event: KeyboardEvent) => { - const items = flatItemsRef.current + const items = keyboardItemsRef.current if (!items.length) return if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) @@ -538,9 +579,10 @@ const VarReferenceVars: FC = ({ return () => { document.removeEventListener('keydown', handleDocumentKeyDown, true) } - }, [activeIndexRef, enableKeyboardNavigation, flatItemsRef, handleHighlightIndex, handleSelectItem, onCloseRef]) + }, [activeIndexRef, enableKeyboardNavigation, keyboardItemsRef, handleHighlightIndex, handleSelectItem, onCloseRef]) - let runningIndex = -1 + const assembleIndex = filteredAgentNodes.length + let runningIndex = filteredAgentNodes.length + (showAssembleEntry ? 1 : 0) - 1 return ( <> @@ -572,13 +614,66 @@ const VarReferenceVars: FC = ({ ) } { - showAssembleVariables && onAssembleVariables && ( + showAgentSection && ( +
+
+ {t('nodes.tool.agentPopupHeader', { ns: 'workflow' })} +
+ {filteredAgentNodes.map((agent) => { + runningIndex += 1 + const itemIndex = runningIndex + return ( + + ) + })} +
+ ) + } + { + showAssembleEntry && (
) - :
{t('common.noVar', { ns: 'workflow' })}
} + : !showAgentSection && !showAssembleEntry &&
{t('common.noVar', { ns: 'workflow' })}
} { showManageInputField && ( void) => callback()) +const mockDispatchCommand = vi.fn() +const mockInsertNodes = vi.fn() +const mockTextNode = vi.fn() + +const mockEditor = { + update: mockEditorUpdate, + dispatchCommand: mockDispatchCommand, +} as unknown as LexicalEditor + +const lexicalContextValue: LexicalComposerContextWithEditor = [ + mockEditor, + { getTheme: () => undefined }, +] + +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: vi.fn(), +})) + +vi.mock('lexical', () => ({ + $insertNodes: vi.fn(), + FOCUS_COMMAND: 'focus-command', +})) + +vi.mock('@/app/components/base/prompt-editor/plugins/custom-text/node', () => ({ + CustomTextNode: class MockCustomTextNode { + value: string + + constructor(value: string) { + this.value = value + mockTextNode(value) + } + }, +})) + +describe('Tool mixed variable placeholder', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(useLexicalComposerContext).mockReturnValue(lexicalContextValue) + vi.mocked($insertNodes).mockImplementation(nodes => mockInsertNodes(nodes)) + }) + + it('should insert an empty text node and focus the editor when the placeholder background is clicked', () => { + const parentClick = vi.fn() + + render( +
+ +
, + ) + + fireEvent.click(screen.getByText('workflow.nodes.tool.insertPlaceholder1')) + + expect(parentClick).not.toHaveBeenCalled() + expect(mockTextNode).toHaveBeenCalledWith('') + expect(mockInsertNodes).toHaveBeenCalledTimes(1) + expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, expect.any(FocusEvent)) + }) + + it('should render only the slash insertion hint', () => { + render() + + expect(screen.getByText('workflow.nodes.tool.insertPlaceholder2')).toBeInTheDocument() + expect(screen.queryByText('workflow.nodes.tool.insertPlaceholder3')).not.toBeInTheDocument() + expect(screen.queryByText('@')).not.toBeInTheDocument() + }) + + it('should insert a slash shortcut from the highlighted action and prevent the native mouse down behavior', () => { + render() + + const shortcut = screen.getByText('workflow.nodes.tool.insertPlaceholder2') + const event = createEvent.mouseDown(shortcut) + fireEvent(shortcut, event) + + expect(event.defaultPrevented).toBe(true) + expect(mockTextNode).toHaveBeenCalledWith('/') + expect(mockDispatchCommand).toHaveBeenCalledWith(FOCUS_COMMAND, expect.any(FocusEvent)) + }) +}) diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/components/placeholder.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/components/placeholder.tsx index 7d78c58464..df2e528ac6 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/components/placeholder.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/components/placeholder.tsx @@ -8,13 +8,11 @@ import { cn } from '@/utils/classnames' type PlaceholderProps = { disableVariableInsertion?: boolean - hasSelectedAgent?: boolean hideBadge?: boolean } const Placeholder = ({ disableVariableInsertion = false, - hasSelectedAgent = false, hideBadge = false, }: PlaceholderProps) => { const { t } = useTranslation() @@ -54,21 +52,6 @@ const Placeholder = ({ > {t('nodes.tool.insertPlaceholder2', { ns: 'workflow' })} - {!hasSelectedAgent && ( - <> -
@
-
{ - e.preventDefault() - e.stopPropagation() - handleInsert('@') - })} - > - {t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })} -
- - )} )} diff --git a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx index db26b35145..9b1c11ce99 100644 --- a/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx +++ b/web/app/components/workflow/nodes/tool/components/mixed-variable-text-input/index.tsx @@ -414,15 +414,12 @@ const MixedVariableTextInput = ({ workflowNodesMap, showManageInputField, onManageInputField, + agentNodes, + onSelectAgent: handleAgentSelect, showAssembleVariables: !disableVariableInsertion && !!toolNodeId && !!paramKey, onAssembleVariables: handleAssembleSelect, }} - agentBlock={{ - show: agentNodes.length > 0 && !detectedAgentFromValue, - agentNodes, - onSelect: handleAgentSelect, - }} - placeholder={} + placeholder={} onChange={(text) => { const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text) if (hasPlaceholder)