mirror of
https://github.com/langgenius/dify.git
synced 2026-04-30 23:48:04 +08:00
feat(workflow): merge tool agent insertions into slash menu
This commit is contained in:
@ -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((
|
||||
<MinimalEditor
|
||||
triggerString="/"
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
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((
|
||||
<MinimalEditor
|
||||
triggerString="@"
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
captures={captures}
|
||||
/>
|
||||
))
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
<>
|
||||
|
||||
@ -79,6 +79,8 @@ export type WorkflowVariableBlockType = {
|
||||
getVarType?: GetVarType
|
||||
showManageInputField?: boolean
|
||||
onManageInputField?: () => void
|
||||
agentNodes?: AgentNode[]
|
||||
onSelectAgent?: (agent: AgentNode) => void
|
||||
showAssembleVariables?: boolean
|
||||
onAssembleVariables?: () => ValueSelector | null
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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))
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user