diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts index 10578e0004..2e69f0669c 100644 --- a/web/app/components/base/prompt-editor/hooks.ts +++ b/web/app/components/base/prompt-editor/hooks.ts @@ -155,14 +155,13 @@ export type TriggerFn = ( text: string, editor: LexicalEditor, ) => MenuTextMatch | null -export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;' export function useBasicTypeaheadTriggerMatch( trigger: string, { minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number }, ): TriggerFn { return useCallback( (text: string) => { - const validChars = `[${PUNCTUATION}\\s]` + const validChars = '[^\\n]' const TypeaheadTriggerRegex = new RegExp( '(.*)(' + `[${trigger}]` diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx index 8855d948df..f2d6f3c0cf 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/hooks.tsx @@ -32,6 +32,8 @@ import { PickerBlockMenuOption } from './menu' import { PromptMenuItem } from './prompt-option' import { VariableMenuItem } from './variable-option' +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + export const usePromptOptions = ( contextBlock?: ContextBlockType, queryBlock?: QueryBlockType, @@ -154,7 +156,7 @@ export const useVariableOptions = ( if (!queryString) return baseOptions - const regex = new RegExp(queryString, 'i') + const regex = new RegExp(escapeRegExp(queryString), 'i') return baseOptions.filter(option => regex.test(option.key)) }, [editor, queryString, variableBlock]) @@ -232,7 +234,7 @@ export const useExternalToolOptions = ( if (!queryString) return baseToolOptions - const regex = new RegExp(queryString, 'i') + const regex = new RegExp(escapeRegExp(queryString), 'i') return baseToolOptions.filter(option => regex.test(option.key)) }, [editor, queryString, externalToolBlockType]) 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 8ff0ed5a02..d36c8e571f 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 @@ -91,9 +91,10 @@ const ComponentPicker = ({ ], }) const [editor] = useLexicalComposerContext() + const useExternalSearch = triggerString === '/' || triggerString === '@' const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { minLength: 0, - maxLength: 0, + maxLength: useExternalSearch ? 75 : 0, }) const [queryString, setQueryString] = useState(null) @@ -116,6 +117,7 @@ const ComponentPicker = ({ currentBlock, errorMessageBlock, lastRunBlock, + useExternalSearch ? (queryString ?? undefined) : undefined, ) const onSelectOption = useCallback( @@ -247,6 +249,9 @@ const ComponentPicker = ({ onBlur={handleClose} maxHeightClass="max-h-[34vh]" autoFocus={false} + hideSearch={useExternalSearch} + externalSearchText={useExternalSearch ? (queryString ?? '') : undefined} + enableKeyboardNavigation={useExternalSearch} /> ) : ( @@ -270,6 +275,9 @@ const ComponentPicker = ({ onAssembleVariables={showAssembleVariables ? handleSelectAssembleVariables : undefined} autoFocus={false} isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code} + hideSearch={useExternalSearch} + externalSearchText={useExternalSearch ? (queryString ?? '') : undefined} + enableKeyboardNavigation={useExternalSearch} /> ) @@ -311,7 +319,7 @@ const ComponentPicker = ({ } ) - }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables]) + }, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField, showAssembleVariables, handleSelectAssembleVariables, useExternalSearch]) return ( void + isHighlighted?: boolean + onSetHighlight?: () => void + registerRef?: (element: HTMLButtonElement | null) => void } -const Item: FC = ({ node, onSelect }) => { +const Item: FC = ({ node, onSelect, isHighlighted, onSetHighlight, registerRef }) => { const [isHovering, setIsHovering] = useState(false) return ( @@ -27,10 +30,15 @@ const Item: FC = ({ node, onSelect }) => { type="button" className={cn( 'relative flex h-6 w-full cursor-pointer items-center rounded-md border-none bg-transparent px-3 text-left', - isHovering && 'bg-state-base-hover', + (isHovering || isHighlighted) && 'bg-state-base-hover', )} - onMouseEnter={() => setIsHovering(true)} + ref={registerRef} + onMouseEnter={() => { + setIsHovering(true) + onSetHighlight?.() + }} onMouseLeave={() => setIsHovering(false)} + onFocus={onSetHighlight} onClick={() => onSelect(node)} onMouseDown={e => e.preventDefault()} > @@ -58,6 +66,8 @@ type Props = { searchBoxClassName?: string maxHeightClass?: string autoFocus?: boolean + externalSearchText?: string + enableKeyboardNavigation?: boolean } const AgentNodeList: FC = ({ @@ -69,9 +79,13 @@ const AgentNodeList: FC = ({ searchBoxClassName, maxHeightClass, autoFocus = true, + externalSearchText, + enableKeyboardNavigation = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') + const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText.trim() + const shouldShowSearchInput = !hideSearch && externalSearchText === undefined const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -80,15 +94,79 @@ const AgentNodeList: FC = ({ } } - const filteredNodes = nodes.filter((node) => { - if (!searchText) + const filteredNodes = useMemo(() => nodes.filter((node) => { + if (!normalizedSearchText) return true - return node.title.toLowerCase().includes(searchText.toLowerCase()) - }) + return node.title.toLowerCase().includes(normalizedSearchText.toLowerCase()) + }), [nodes, normalizedSearchText]) + + const [activeIndex, setActiveIndex] = useState(-1) + const itemRefs = useRef>([]) + + useEffect(() => { + itemRefs.current = [] + }, [filteredNodes.length]) + + useEffect(() => { + if (!enableKeyboardNavigation) { + setActiveIndex(-1) + return + } + if (filteredNodes.length === 0) { + setActiveIndex(-1) + return + } + setActiveIndex(0) + }, [enableKeyboardNavigation, filteredNodes.length, normalizedSearchText]) + + useEffect(() => { + if (!enableKeyboardNavigation || activeIndex < 0) + return + const target = itemRefs.current[activeIndex] + if (target) + target.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, enableKeyboardNavigation, filteredNodes.length]) + + const handleSelectItem = useCallback((node: AgentNode) => { + onSelect(node) + }, [onSelect]) + + useEffect(() => { + if (!enableKeyboardNavigation) + return + const handleKeyDown = (event: KeyboardEvent) => { + if (filteredNodes.length === 0) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape') { + onClose?.() + return + } + if (event.key === 'Enter') { + if (activeIndex < 0 || activeIndex >= filteredNodes.length) + return + handleSelectItem(filteredNodes[activeIndex]) + return + } + const delta = event.key === 'ArrowDown' ? 1 : -1 + setActiveIndex((prev) => { + const baseIndex = prev < 0 ? 0 : prev + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), filteredNodes.length - 1) + return nextIndex + }) + } + document.addEventListener('keydown', handleKeyDown, true) + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [activeIndex, enableKeyboardNavigation, filteredNodes, handleSelectItem, onClose]) return ( <> - {!hideSearch && ( + {shouldShowSearchInput && ( <>
= ({ {filteredNodes.length > 0 ? (
- {filteredNodes.map(node => ( + {filteredNodes.map((node, index) => ( setActiveIndex(index) : undefined} + registerRef={enableKeyboardNavigation ? (element) => { + itemRefs.current[index] = element + } : undefined} /> ))}
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 745b383305..57b31094bd 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 @@ -6,7 +6,7 @@ import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflo 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 { AssembleVariables, CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' @@ -43,6 +43,31 @@ type ItemProps = { zIndex?: number className?: string preferSchemaType?: boolean + isHighlighted?: boolean + onSetHighlight?: () => void + registerRef?: (element: HTMLDivElement | null) => void +} + +const buildValueSelector = ({ + nodeId, + objPath, + itemData, + isFlat, +}: { + nodeId: string + objPath: string[] + itemData: Var + isFlat?: boolean +}): ValueSelector => { + if (isFlat) + return [itemData.variable] + const isSys = itemData.variable.startsWith('sys.') + const isEnv = itemData.variable.startsWith('env.') + const isChatVar = itemData.variable.startsWith('conversation.') + const isRagVariable = itemData.isRagVariable + if (isSys || isEnv || isChatVar || isRagVariable) + return [...objPath, ...itemData.variable.split('.')] + return [nodeId, ...objPath, itemData.variable] } const Item: FC = ({ @@ -60,6 +85,9 @@ const Item: FC = ({ zIndex, className, preferSchemaType, + isHighlighted, + onSetHighlight, + registerRef, }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties const isFile = itemData.type === VarType.file && !isStructureOutput @@ -123,6 +151,10 @@ const Item: FC = ({ })() const itemRef = useRef(null) + const setItemRef = useCallback((element: HTMLDivElement | null) => { + itemRef.current = element + registerRef?.(element) + }, [registerRef]) const [isItemHovering, setIsItemHovering] = useState(false) useHover(itemRef, { onChange: (hovering) => { @@ -152,15 +184,12 @@ const Item: FC = ({ if (!isSupportFileVar && isFile) return - if (isFlat) { - onChange([itemData.variable], itemData) - } - else if (isSys || isEnv || isChatVar || isRagVariable) { // system variable | environment variable | conversation variable - onChange([...objPath, ...itemData.variable.split('.')], itemData) - } - else { - onChange([nodeId, ...objPath, itemData.variable], itemData) - } + onChange(buildValueSelector({ + nodeId, + objPath, + itemData, + isFlat, + }), itemData) } const variableCategory = useMemo(() => { if (isEnv) @@ -181,14 +210,15 @@ const Item: FC = ({ >
e.preventDefault()} >
@@ -259,6 +289,8 @@ type Props = { onAssembleVariables?: () => ValueSelector | null autoFocus?: boolean preferSchemaType?: boolean + externalSearchText?: string + enableKeyboardNavigation?: boolean } const VarReferenceVars: FC = ({ hideSearch, @@ -278,9 +310,13 @@ const VarReferenceVars: FC = ({ onAssembleVariables, autoFocus = true, preferSchemaType, + externalSearchText, + enableKeyboardNavigation = false, }) => { const { t } = useTranslation() const [searchText, setSearchText] = useState('') + const normalizedSearchText = externalSearchText === undefined ? searchText : externalSearchText.trim() + const shouldShowSearchInput = !hideSearch && externalSearchText === undefined const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -296,35 +332,124 @@ const VarReferenceVars: FC = ({ onClose?.() } - const filteredVars = vars.filter((v) => { - const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) - return children.length > 0 - }).filter((node) => { - if (!searchText) - return node - const children = node.vars.filter((v) => { - const searchTextLower = searchText.toLowerCase() - return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower) - }) - return children.length > 0 - }).map((node) => { - let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) - if (searchText) { - const searchTextLower = searchText.toLowerCase() - if (!node.title.toLowerCase().includes(searchTextLower)) - vars = vars.filter(v => v.variable.toLowerCase().includes(searchText.toLowerCase())) - } + const filteredVars = useMemo(() => { + return vars.filter((v) => { + const children = v.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) + return children.length > 0 + }).filter((node) => { + if (!normalizedSearchText) + return node + const searchTextLower = normalizedSearchText.toLowerCase() + const children = node.vars.filter((v) => { + return v.variable.toLowerCase().includes(searchTextLower) || node.title.toLowerCase().includes(searchTextLower) + }) + return children.length > 0 + }).map((node) => { + let vars = node.vars.filter(v => checkKeys([v.variable], false).isValid || isSpecialVar(v.variable.split('.')[0])) + if (normalizedSearchText) { + const searchTextLower = normalizedSearchText.toLowerCase() + if (!node.title.toLowerCase().includes(searchTextLower)) + vars = vars.filter(v => v.variable.toLowerCase().includes(searchTextLower)) + } - return { - ...node, - vars, + return { + ...node, + vars, + } + }) + }, [normalizedSearchText, vars]) + + const flatItems = useMemo(() => { + const items: Array<{ node: NodeOutPutVar, itemData: Var }> = [] + filteredVars.forEach((node) => { + node.vars.forEach((itemData) => { + items.push({ node, itemData }) + }) + }) + return items + }, [filteredVars]) + const [activeIndex, setActiveIndex] = useState(-1) + const itemRefs = useRef>([]) + + useEffect(() => { + itemRefs.current = [] + }, [flatItems.length]) + + useEffect(() => { + if (!enableKeyboardNavigation) { + setActiveIndex(-1) + return } - }) + if (flatItems.length === 0) { + setActiveIndex(-1) + return + } + setActiveIndex(0) + }, [enableKeyboardNavigation, flatItems.length, normalizedSearchText]) + + useEffect(() => { + if (!enableKeyboardNavigation || activeIndex < 0) + return + const target = itemRefs.current[activeIndex] + if (target) + target.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, enableKeyboardNavigation, flatItems.length]) + + 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 + if (!isSupportFileVar && isFile) + return + const valueSelector = buildValueSelector({ + nodeId: item.node.nodeId, + objPath: [], + itemData: item.itemData, + isFlat: item.node.isFlat, + }) + onChange(valueSelector, item.itemData) + onClose?.() + }, [onChange, onClose, isSupportFileVar]) + + useEffect(() => { + if (!enableKeyboardNavigation) + return + const handleKeyDown = (event: KeyboardEvent) => { + if (flatItems.length === 0) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + event.preventDefault() + event.stopPropagation() + if (event.key === 'Escape') { + onClose?.() + return + } + if (event.key === 'Enter') { + if (activeIndex < 0 || activeIndex >= flatItems.length) + return + handleSelectItem(flatItems[activeIndex]) + return + } + const delta = event.key === 'ArrowDown' ? 1 : -1 + setActiveIndex((prev) => { + const baseIndex = prev < 0 ? 0 : prev + const nextIndex = Math.min(Math.max(baseIndex + delta, 0), flatItems.length - 1) + return nextIndex + }) + } + document.addEventListener('keydown', handleKeyDown, true) + return () => { + document.removeEventListener('keydown', handleKeyDown, true) + } + }, [activeIndex, enableKeyboardNavigation, flatItems, handleSelectItem, onClose]) + + let runningIndex = -1 return ( <> { - !hideSearch && ( + shouldShowSearchInput && ( <>
e.stopPropagation()}> = ({ {item.title}
)} - {item.vars.map((v, j) => ( - - ))} + {item.vars.map((v, j) => { + runningIndex += 1 + const itemIndex = runningIndex + return ( + setActiveIndex(itemIndex) : undefined} + registerRef={enableKeyboardNavigation ? (element) => { + itemRefs.current[itemIndex] = element + } : undefined} + /> + ) + })} {item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && (