mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
feat(workflow): merge tool agent insertions into slash menu
This commit is contained in:
@ -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',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user