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 3c734700a7..a09e25f6e9 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 @@ -599,6 +599,48 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { }) }) + it('defaults to the first workflow variable and removes the full slash query when selecting by keyboard', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('first_value', VarType.string), + makeWorkflowNodeVar('second_value', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + await setEditorText(editor, '/e', true) + await flushNextTick() + + const firstItem = screen.getByText('first_value').closest('[data-selected]') + const secondItem = screen.getByText('second_value').closest('[data-selected]') + + expect(firstItem).toHaveAttribute('data-selected', 'true') + expect(secondItem).toHaveAttribute('data-selected', 'false') + + fireEvent.keyDown(document, { key: 'ArrowDown' }) + + expect(firstItem).toHaveAttribute('data-selected', 'false') + expect(secondItem).toHaveAttribute('data-selected', 'true') + + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'second_value']) + await waitFor(() => expect(readEditorText(editor)).not.toContain('/e')) + }) + it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => { const captures: Captures = { editor: null, eventEmitter: null } 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 5e983ed09a..503af4077d 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 @@ -7,6 +7,7 @@ import type { ExternalToolBlockType, HistoryBlockType, LastRunBlockType, + MenuTextMatch, QueryBlockType, RequestURLBlockType, VariableBlockType, @@ -89,14 +90,14 @@ const ComponentPicker = ({ ], }) const [editor] = useLexicalComposerContext() - const triggerMatchRef = useRef(null) + const triggerMatchRef = useRef(null) const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { minLength: 0, maxLength: 75, }) const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => { const match = baseCheckForTriggerMatch(text, editor) - triggerMatchRef.current = match?.matchingString ?? null + triggerMatchRef.current = match return match }, [baseCheckForTriggerMatch]) @@ -183,7 +184,8 @@ const ComponentPicker = ({ const handleSelectWorkflowVariable = useCallback((variables: string[]) => { editor.update(() => { - const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) + const currentTriggerMatch = triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor) + const needRemove = currentTriggerMatch ? $splitNodeContainingQuery(currentTriggerMatch) : null if (needRemove) needRemove.remove() }) @@ -214,7 +216,7 @@ const ComponentPicker = ({ anchorElementRef, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, ) => { - const effectiveQueryString = triggerMatchRef.current ?? queryString + const effectiveQueryString = triggerMatchRef.current?.matchingString ?? queryString if (blurHidden) return 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 372fcb3508..b8d1013db9 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 @@ -52,6 +52,42 @@ describe('VarReferenceVars', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('should select the first visible variable by default and support arrow navigation in slash mode', () => { + const onChange = vi.fn() + + render( + , + ) + + const firstItem = screen.getByText('first_value').closest('[data-selected]') + const secondItem = screen.getByText('second_value').closest('[data-selected]') + + expect(firstItem).toHaveAttribute('data-selected', 'true') + expect(secondItem).toHaveAttribute('data-selected', 'false') + + fireEvent.keyDown(document, { key: 'ArrowDown' }) + + expect(firstItem).toHaveAttribute('data-selected', 'false') + expect(secondItem).toHaveAttribute('data-selected', 'true') + + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(onChange).toHaveBeenCalledWith(['node-a', 'second_value'], expect.objectContaining({ + variable: 'second_value', + })) + }) + it('should call onChange when a variable item is chosen', () => { const onChange = vi.fn() @@ -172,6 +208,43 @@ describe('VarReferenceVars', () => { expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' })) }) + it('should resolve selectors for special variables and file support from keyboard selection', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' })) + expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' })) + expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' })) + expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' })) + }) + it('should render object vars and select them by node path', () => { const onChange = vi.fn() @@ -251,4 +324,26 @@ describe('VarReferenceVars', () => { fireEvent.click(screen.getByText('asset')) expect(onChange).not.toHaveBeenCalled() }) + + it('should ignore file vars when file support is disabled during keyboard selection', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(onChange).not.toHaveBeenCalled() + }) }) 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 28ad104ed7..38fef9016d 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 @@ -12,11 +12,8 @@ import { import { useHover } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' -import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' -import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import Input from '@/app/components/base/input' import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' @@ -31,6 +28,42 @@ import { getVariableDisplayName, } from './var-reference-vars.helpers' +const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input' + +const resolveValueSelector = ({ + itemData, + isFlat, + isSupportFileVar, + nodeId, + objPath, +}: { + itemData: Var + isFlat?: boolean + isSupportFileVar?: boolean + nodeId: string + objPath: string[] +}) => { + const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties + const isFile = itemData.type === VarType.file && !isStructureOutput + const isSys = itemData.variable.startsWith('sys.') + const isEnv = itemData.variable.startsWith('env.') + const isChatVar = itemData.variable.startsWith('conversation.') + const isRagVariable = itemData.isRagVariable + + return getValueSelector({ + itemData, + isFlat, + isSupportFileVar, + isFile, + isSys, + isEnv, + isChatVar, + isRagVariable, + nodeId, + objPath, + }) +} + type ItemProps = { nodeId: string title: string @@ -47,6 +80,8 @@ type ItemProps = { zIndex?: number className?: string preferSchemaType?: boolean + isSelected?: boolean + onActivate?: () => void } const Item: FC = ({ @@ -64,11 +99,11 @@ const Item: FC = ({ zIndex, className, preferSchemaType, + isSelected, + onActivate, }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties - const isFile = itemData.type === VarType.file && !isStructureOutput const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0) - const isSys = itemData.variable.startsWith('sys.') const isEnv = itemData.variable.startsWith('env.') const isChatVar = itemData.variable.startsWith('conversation.') const isRagVariable = itemData.isRagVariable @@ -76,15 +111,21 @@ const Item: FC = ({ if (!isFlat) return null const variable = itemData.variable - let Icon switch (variable) { case 'current': - Icon = isInCodeGeneratorInstructionEditor ? CodeAssistant : MagicEdit - return + return ( + + ) case 'error_message': - return + return default: - return + return } }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) @@ -147,15 +188,10 @@ const Item: FC = ({ const handleChosen = (e: React.MouseEvent) => { e.stopPropagation() e.nativeEvent.stopImmediatePropagation() - const valueSelector = getValueSelector({ + const valueSelector = resolveValueSelector({ itemData, isFlat, isSupportFileVar, - isFile, - isSys, - isEnv, - isChatVar, - isRagVariable, nodeId, objPath, }) @@ -173,11 +209,13 @@ const Item: FC = ({ ref={itemRef} className={cn( (isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]', - isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'), + (isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'), 'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3', className, )} + data-selected={isSelected ? 'true' : 'false'} onClick={handleChosen} + onMouseEnter={onActivate} onMouseDown={(e) => { e.preventDefault() e.stopPropagation() @@ -210,7 +248,7 @@ const Item: FC = ({
{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}
{ (isObj || isStructureOutput) && ( - + ) } @@ -221,7 +259,7 @@ const Item: FC = ({ open={open} onOpenChange={noop} > - + = ({ }) => { const { t } = useTranslation() const [internalSearchValue, setInternalSearchValue] = useState('') + const listRef = useRef(null) const searchValue = searchText ?? internalSearchValue - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onClose?.() - } - } - const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue]) + const selectableItems = useMemo(() => { + return filteredVars.flatMap(node => node.vars.map(item => ({ + nodeId: node.nodeId, + isFlat: node.isFlat, + itemData: item, + }))) + }, [filteredVars]) + const indexedFilteredVars = useMemo(() => { + let optionIndex = 0 + + return filteredVars.map(node => ({ + ...node, + vars: node.vars.map(variable => ({ + variable, + optionIndex: optionIndex++, + })), + })) + }, [filteredVars]) + const [selectedIndex, setSelectedIndex] = useState(-1) + const effectiveSelectedIndex = selectableItems.length ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) : -1 + + useEffect(() => { + const listElement = listRef.current + const selectedElement = listElement?.querySelector('[data-selected="true"]') as HTMLElement | null + if (!listElement || !selectedElement) + return + + const selectedTop = selectedElement.offsetTop + const selectedBottom = selectedTop + selectedElement.offsetHeight + const visibleTop = listElement.scrollTop + const visibleBottom = visibleTop + listElement.clientHeight + + if (selectedTop < visibleTop) + listElement.scrollTop = selectedTop + else if (selectedBottom > visibleBottom) + listElement.scrollTop = selectedBottom - listElement.clientHeight + }, [effectiveSelectedIndex]) + + const selectItem = useCallback((index: number) => { + const selectedItem = selectableItems[index] + if (!selectedItem) + return + + const { itemData, nodeId, isFlat } = selectedItem + const valueSelector = resolveValueSelector({ + itemData, + isFlat, + isSupportFileVar, + nodeId, + objPath: [], + }) + + if (valueSelector) + onChange(valueSelector, itemData) + }, [isSupportFileVar, onChange, selectableItems]) + + const handleKeyboardEvent = useCallback((event: Pick) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose?.() + return + } + + if (!selectableItems.length) + return + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + setSelectedIndex( + event.key === 'ArrowDown' + ? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1) + : Math.max(effectiveSelectedIndex - 1, 0), + ) + return + } + + if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + selectItem(effectiveSelectedIndex) + } + }, [effectiveSelectedIndex, onClose, selectableItems.length, selectItem]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + handleKeyboardEvent(e) + }, [handleKeyboardEvent]) + + useEffect(() => { + if (!hideSearch) + return + + const handleDocumentKeyDown = (event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + + handleKeyboardEvent(event) + } + + document.addEventListener('keydown', handleDocumentKeyDown, true) + return () => document.removeEventListener('keydown', handleDocumentKeyDown, true) + }, [handleKeyboardEvent, hideSearch]) return ( <> { !hideSearch && ( <> -
e.stopPropagation()}> +
e.stopPropagation()}> = ({ {filteredVars.length > 0 ? ( -
- +
{ - filteredVars.map((item, i) => ( -
+ indexedFilteredVars.map((item, i) => ( +
{!item.isFlat && (
= ({ {item.title}
)} - {item.vars.map((v, j) => ( + {item.vars.map(({ variable, optionIndex }) => ( setSelectedIndex(optionIndex)} /> ))} - {item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && ( + {item.isFlat && !indexedFilteredVars[i + 1]?.isFlat && !!indexedFilteredVars.find(item => !item.isFlat) && (
{t('debug.lastOutput', { ns: 'workflow' })}