feat(workflow): merge tool agent insertions into slash menu

This commit is contained in:
CodingOnStar
2026-03-26 10:29:07 +08:00
parent d66b0d2d11
commit 12be211a6d
9 changed files with 371 additions and 49 deletions

View File

@ -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(
<VarReferenceVars
hideSearch
vars={baseVars}
onChange={vi.fn()}
agentNodes={[{ id: 'agent-1', title: 'Agent One' }]}
onSelectAgent={onSelectAgent}
showAssembleVariables
onAssembleVariables={() => 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(
<VarReferenceVars
vars={baseVars}
onChange={vi.fn()}
agentNodes={[{ id: 'agent-1', title: 'Agent One' }]}
onSelectAgent={vi.fn()}
/>,
)
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(
<VarReferenceVars
hideSearch
enableKeyboardNavigation
vars={baseVars}
onChange={onChange}
agentNodes={[{ id: 'agent-1', title: 'Agent One' }]}
onSelectAgent={onSelectAgent}
showAssembleVariables
onAssembleVariables={onAssembleVariables}
/>,
)
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',
}))
})
})

View File

@ -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<Props> = ({
isInCodeGeneratorInstructionEditor,
showManageInputField,
onManageInputField,
agentNodes,
onSelectAgent,
showAssembleVariables,
onAssembleVariables,
autoFocus = true,
@ -388,6 +399,14 @@ const VarReferenceVars: FC<Props> = ({
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<Props> = ({
})
return items
}, [filteredVars])
const showAgentSection = filteredAgentNodes.length > 0
const showAssembleEntry = !!(showAssembleVariables && onAssembleVariables)
const keyboardItems = useMemo<KeyboardItem[]>(() => {
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<Array<HTMLDivElement | null>>([])
const itemRefsRef = useRef<Array<HTMLElement | null>>([])
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<Props> = ({
}, [])
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<Props> = ({
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<Props> = ({
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<Props> = ({
)
}
{
showAssembleVariables && onAssembleVariables && (
showAgentSection && (
<div className="border-t border-divider-subtle pt-1">
<div className="px-3 pb-1 text-text-tertiary system-xs-medium-uppercase">
{t('nodes.tool.agentPopupHeader', { ns: 'workflow' })}
</div>
{filteredAgentNodes.map((agent) => {
runningIndex += 1
const itemIndex = runningIndex
return (
<button
key={agent.id}
type="button"
ref={enableKeyboardNavigation
? (element) => {
itemRefsRef.current[itemIndex] = element
}
: undefined}
className={cn(
'flex h-6 w-full items-center rounded-md pl-3 pr-[18px] text-text-secondary hover:bg-state-base-hover',
enableKeyboardNavigation && itemIndex === resolvedActiveIndex && 'bg-state-base-hover',
)}
onClick={() => handleSelectItem({ type: 'agent', agent })}
onFocus={enableKeyboardNavigation ? () => handleHighlightIndex(itemIndex, 'mouse') : undefined}
onMouseDown={e => e.preventDefault()}
onMouseEnter={enableKeyboardNavigation ? () => handleHighlightIndex(itemIndex, 'mouse') : undefined}
>
<span className="mr-1 flex h-4 w-4 items-center justify-center rounded bg-util-colors-indigo-indigo-500">
<Agent className="h-3 w-3 text-text-primary-on-surface" />
</span>
<span className="truncate system-sm-medium" title={agent.title}>
{agent.title}
</span>
</button>
)
})}
</div>
)
}
{
showAssembleEntry && (
<div className="flex items-center border-t border-divider-subtle pt-1">
<button
type="button"
className="flex h-6 w-full items-center rounded-md pl-3 pr-[18px] text-text-secondary hover:bg-state-base-hover"
ref={enableKeyboardNavigation
? (element) => {
itemRefsRef.current[assembleIndex] = element
}
: undefined}
className={cn(
'flex h-6 w-full items-center rounded-md pl-3 pr-[18px] text-text-secondary hover:bg-state-base-hover',
enableKeyboardNavigation && assembleIndex === resolvedActiveIndex && 'bg-state-base-hover',
)}
onClick={handleAssembleVariables}
onFocus={enableKeyboardNavigation
? () => handleHighlightIndex(assembleIndex, 'mouse')
: undefined}
onMouseDown={e => e.preventDefault()}
onMouseEnter={enableKeyboardNavigation
? () => handleHighlightIndex(assembleIndex, 'mouse')
: undefined}
>
<span className="mr-1 flex h-4 w-4 items-center justify-center rounded bg-util-colors-blue-blue-500">
<AssembleVariables className="h-3 w-3 text-text-primary-on-surface" />
@ -628,7 +723,7 @@ const VarReferenceVars: FC<Props> = ({
onSetHighlight={enableKeyboardNavigation ? () => handleHighlightIndex(itemIndex, 'mouse') : undefined}
registerRef={enableKeyboardNavigation
? (element) => {
itemRefs.current[itemIndex] = element
itemRefsRef.current[itemIndex] = element
}
: undefined}
/>
@ -646,7 +741,7 @@ const VarReferenceVars: FC<Props> = ({
}
</div>
)
: <div className="mt-2 pl-3 text-xs font-medium uppercase leading-[18px] text-gray-500">{t('common.noVar', { ns: 'workflow' })}</div>}
: !showAgentSection && !showAssembleEntry && <div className="mt-2 pl-3 text-xs font-medium uppercase leading-[18px] text-gray-500">{t('common.noVar', { ns: 'workflow' })}</div>}
{
showManageInputField && (
<ManageInputField

View File

@ -12,8 +12,7 @@ import type {
ContextGenerateResponse,
} from '@/service/debug'
import type { CompletionParams, Model, ModelModeType } from '@/types/app'
import { useSessionStorageState } from 'ahooks'
import useBoolean from 'ahooks/lib/useBoolean'
import { useBoolean, useSessionStorageState } from 'ahooks'
import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'

View File

@ -0,0 +1,86 @@
import type { LexicalComposerContextWithEditor } from '@lexical/react/LexicalComposerContext'
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { createEvent, fireEvent, render, screen } from '@testing-library/react'
import { $insertNodes, FOCUS_COMMAND } from 'lexical'
import Placeholder from '../placeholder'
const mockEditorUpdate = vi.fn((callback: () => 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(
<div onClick={parentClick}>
<Placeholder />
</div>,
)
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(<Placeholder />)
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(<Placeholder />)
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))
})
})

View File

@ -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' })}
</div>
{!hasSelectedAgent && (
<>
<div className="mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder system-kbd">@</div>
<div
className="cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto system-sm-regular hover:text-text-tertiary"
onMouseDown={((e) => {
e.preventDefault()
e.stopPropagation()
handleInsert('@')
})}
>
{t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })}
</div>
</>
)}
</>
)}
</div>

View File

@ -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 disableVariableInsertion={disableVariableInsertion} hasSelectedAgent={!!detectedAgentFromValue} />}
placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} />}
onChange={(text) => {
const hasPlaceholder = new RegExp(AGENT_CONTEXT_VAR_PATTERN.source).test(text)
if (hasPlaceholder)