From 48d23cd74429fbd3672f09b7830f981e0d0d42cd Mon Sep 17 00:00:00 2001 From: Joel Date: Tue, 21 Apr 2026 15:57:16 +0800 Subject: [PATCH] feat: support slash variable filtering in prompt editor (#35460) --- .../prompt-editor/__tests__/hooks.spec.tsx | 10 +-- .../components/base/prompt-editor/hooks.ts | 7 +- .../__tests__/index.spec.tsx | 78 +++++++++++++++++++ .../plugins/component-picker-block/index.tsx | 21 +++-- .../var-reference-vars.helpers.spec.ts | 23 ++++++ .../__tests__/var-reference-vars.spec.tsx | 28 +++++++ .../variable/var-reference-vars.helpers.ts | 50 +++++++++++- .../variable/var-reference-vars.tsx | 13 ++-- 8 files changed, 210 insertions(+), 20 deletions(-) diff --git a/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx b/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx index 9917918628..13c0c05803 100644 --- a/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/hooks.spec.tsx @@ -423,11 +423,11 @@ describe('prompt-editor/hooks', () => { maxLength: 5, })) - const match = result.current('prefix @..', {} as LexicalEditor) + const match = result.current('prefix @ab', {} as LexicalEditor) expect(match).toEqual({ leadOffset: 7, - matchingString: '..', - replaceableString: '@..', + matchingString: 'ab', + replaceableString: '@ab', }) }) @@ -437,7 +437,7 @@ describe('prompt-editor/hooks', () => { maxLength: 5, })) - expect(result.current('prefix @.', {} as LexicalEditor)).toBeNull() + expect(result.current('prefix @a', {} as LexicalEditor)).toBeNull() }) it('should return null when matching text exceeds maxLength', () => { @@ -445,7 +445,7 @@ describe('prompt-editor/hooks', () => { minLength: 1, maxLength: 2, })) - expect(result.current('prefix @...', {} as LexicalEditor)).toBeNull() + expect(result.current('prefix @abc', {} as LexicalEditor)).toBeNull() }) it('should return null when text has no trigger character', () => { diff --git a/web/app/components/base/prompt-editor/hooks.ts b/web/app/components/base/prompt-editor/hooks.ts index dd6c6295a6..70869d6a17 100644 --- a/web/app/components/base/prompt-editor/hooks.ts +++ b/web/app/components/base/prompt-editor/hooks.ts @@ -154,17 +154,18 @@ type TriggerFn = ( text: string, editor: LexicalEditor, ) => MenuTextMatch | null -const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;' +const escapeForCharacterClass = (value: string) => value.replace(/[[\]\\^-]/g, '\\$&') export function useBasicTypeaheadTriggerMatch( trigger: string, { minLength = 1, maxLength = 75 }: { minLength?: number, maxLength?: number }, ): TriggerFn { return useCallback( (text: string) => { - const validChars = `[${PUNCTUATION}\\s]` + const escapedTrigger = escapeForCharacterClass(trigger) + const validChars = `[^${escapedTrigger}\\n\\r]` const TypeaheadTriggerRegex = new RegExp( '(.*)(' - + `[${trigger}]` + + `[${escapedTrigger}]` + `((?:${validChars}){0,${maxLength}})` + ')$', ) 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 6cc38ad78f..3c734700a7 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 @@ -521,6 +521,84 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { await waitFor(() => expect(readEditorText(editor)).not.toContain('{')) }) + it('filters workflow variables from slash input and matches child paths', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + const user = userEvent.setup() + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('payload', VarType.object, [makeWorkflowNodeVar('child_name', VarType.string)]), + makeWorkflowNodeVar('other_value', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + await setEditorText(editor, '/child', true) + await flushNextTick() + + expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument() + expect(await screen.findByText('payload')).toBeInTheDocument() + expect(screen.queryByText('other_value')).not.toBeInTheDocument() + + const label = document.querySelector('[title="payload"]') + expect(label).not.toBeNull() + const row = (label as HTMLElement).parentElement?.parentElement + expect(row).not.toBeNull() + + await user.hover(row as HTMLElement) + const childField = await screen.findByText('child_name') + fireEvent.mouseDown(childField) + await user.unhover(row as HTMLElement) + + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'payload', 'child_name']) + await waitFor(() => expect(readEditorText(editor)).not.toContain('/child')) + }) + + it('filters workflow variables on the first character after slash and does not highlight context by default', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('child_value', VarType.string), + makeWorkflowNodeVar('other_value', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + await setEditorText(editor, '/c', true) + await flushNextTick() + + expect(await screen.findByText('child_value')).toBeInTheDocument() + expect(screen.queryByText('other_value')).not.toBeInTheDocument() + + const contextTitle = screen.getByText('common.promptEditor.context.item.title') + expect(contextTitle.closest('[tabindex="-1"]')).not.toHaveClass('bg-state-base-hover!') + + await waitFor(() => { + expect(readEditorText(editor)).toContain('/c') + }) + }) + 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 5903060911..5e983ed09a 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 @@ -1,5 +1,5 @@ import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin' -import type { TextNode } from 'lexical' +import type { LexicalEditor, TextNode } from 'lexical' import type { ContextBlockType, CurrentBlockType, @@ -89,10 +89,16 @@ const ComponentPicker = ({ ], }) const [editor] = useLexicalComposerContext() - const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { + const triggerMatchRef = useRef(null) + const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { minLength: 0, - maxLength: 0, + maxLength: 75, }) + const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => { + const match = baseCheckForTriggerMatch(text, editor) + triggerMatchRef.current = match?.matchingString ?? null + return match + }, [baseCheckForTriggerMatch]) const [queryString, setQueryString] = useState(null) const [blurHidden, setBlurHidden] = useState(false) @@ -155,6 +161,7 @@ const ComponentPicker = ({ currentBlock, errorMessageBlock, lastRunBlock, + queryString || undefined, ) const onSelectOption = useCallback( @@ -207,6 +214,8 @@ const ComponentPicker = ({ anchorElementRef, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, ) => { + const effectiveQueryString = triggerMatchRef.current ?? queryString + if (blurHidden) return null if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show))) @@ -237,6 +246,8 @@ const ComponentPicker = ({ workflowVariableBlock?.show && (
{ @@ -270,8 +281,8 @@ const ComponentPicker = ({ ) } {option.renderMenuOption({ - queryString, - isSelected: selectedIndex === index, + queryString: effectiveQueryString, + isSelected: workflowVariableBlock?.show ? false : selectedIndex === index, onSelect: () => { selectOptionAndCleanUp(option) }, diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts index 773ecacc39..13b7879bad 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.helpers.spec.ts @@ -81,4 +81,27 @@ describe('var-reference-vars helpers', () => { expect(vars[0]!.title).toBe('Node B') expect(vars[0]!.vars).toEqual([expect.objectContaining({ variable: 'another_value' })]) }) + + it('should keep parent vars when search text matches a child variable', () => { + const vars = filterReferenceVars([ + { + title: 'Node A', + nodeId: 'node-a', + vars: [{ + variable: 'payload', + type: VarType.object, + children: [{ variable: 'child_name', type: VarType.string }], + }], + }, + { + title: 'Node B', + nodeId: 'node-b', + vars: [{ variable: 'other_value', type: VarType.string }], + }, + ] as NodeOutPutVar[], 'child') + + expect(vars).toHaveLength(1) + expect(vars[0]!.title).toBe('Node A') + expect(vars[0]!.vars).toEqual([expect.objectContaining({ variable: 'payload' })]) + }) }) 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 93c857223a..372fcb3508 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 @@ -199,6 +199,34 @@ describe('VarReferenceVars', () => { })) }) + it('should filter by externally controlled search text and match child variables', () => { + render( + , + ) + + expect(screen.queryByPlaceholderText('workflow.common.searchVar')).not.toBeInTheDocument() + expect(screen.getByText('payload')).toBeInTheDocument() + expect(screen.queryByText('other_value')).not.toBeInTheDocument() + }) + it('should ignore file vars when file support is disabled and forward blur-sm events', () => { const onChange = vi.fn() const onBlur = vi.fn() diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts index d36dc807a8..a9941bf72c 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.helpers.ts @@ -1,3 +1,4 @@ +import type { Field, StructuredOutput } from '@/app/components/workflow/nodes/llm/types' import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { VAR_SHOW_NAME_MAP } from '@/app/components/workflow/constants' import { checkKeys } from '@/utils/var' @@ -76,6 +77,51 @@ const getVisibleChildren = (vars: Var[]) => { return vars.filter(variable => checkKeys([variable.variable], false).isValid || isSpecialVar(variable.variable.split('.')[0]!)) } +const includesSearchText = (value: string | undefined, searchTextLower: string) => { + if (!value) + return false + + return value.toLowerCase().includes(searchTextLower) +} + +const isStructuredOutputChildren = (children: Var['children']): children is StructuredOutput => { + return !!children && !Array.isArray(children) && 'schema' in children +} + +const matchesStructuredField = (fieldName: string, field: Field, searchTextLower: string): boolean => { + if (includesSearchText(fieldName, searchTextLower)) + return true + + if (field.properties) + return Object.entries(field.properties).some(([childName, childField]) => matchesStructuredField(childName, childField, searchTextLower)) + + if (field.items) + return matchesStructuredField(field.items.type, field.items, searchTextLower) + + return false +} + +const matchesVariableSearch = (variable: Var, searchTextLower: string): boolean => { + if ( + includesSearchText(variable.variable, searchTextLower) + || includesSearchText(variable.des, searchTextLower) + || includesSearchText(variable.schemaType, searchTextLower) + ) { + return true + } + + if (!variable.children) + return false + + if (Array.isArray(variable.children)) + return getVisibleChildren(variable.children).some(child => matchesVariableSearch(child, searchTextLower)) + + if (isStructuredOutputChildren(variable.children)) + return Object.entries(variable.children.schema.properties).some(([fieldName, field]) => matchesStructuredField(fieldName, field, searchTextLower)) + + return false +} + export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) => { const searchTextLower = searchText.toLowerCase() @@ -85,7 +131,7 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) = .filter((node) => { if (!searchText) return true - return node.vars.some(variable => variable.variable.toLowerCase().includes(searchTextLower)) + return node.vars.some(variable => matchesVariableSearch(variable, searchTextLower)) || node.title.toLowerCase().includes(searchTextLower) }) .map((node) => { @@ -94,7 +140,7 @@ export const filterReferenceVars = (vars: NodeOutPutVar[], searchText: string) = return { ...node, - vars: node.vars.filter(variable => variable.variable.toLowerCase().includes(searchTextLower)), + vars: node.vars.filter(variable => matchesVariableSearch(variable, searchTextLower)), } }) } 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 5c67723d78..648e795dcc 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 @@ -241,6 +241,7 @@ const Item: FC = ({ type Props = { hideSearch?: boolean + searchText?: string searchBoxClassName?: string vars: NodeOutPutVar[] isSupportFileVar?: boolean @@ -258,6 +259,7 @@ type Props = { } const VarReferenceVars: FC = ({ hideSearch, + searchText, searchBoxClassName, vars, isSupportFileVar, @@ -274,7 +276,8 @@ const VarReferenceVars: FC = ({ preferSchemaType, }) => { const { t } = useTranslation() - const [searchText, setSearchText] = useState('') + const [internalSearchValue, setInternalSearchValue] = useState('') + const searchValue = searchText ?? internalSearchValue const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { @@ -283,7 +286,7 @@ const VarReferenceVars: FC = ({ } } - const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText]) + const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue]) return ( <> @@ -295,11 +298,11 @@ const VarReferenceVars: FC = ({ className="var-search-input" showLeftIcon showClearIcon - value={searchText} + value={searchValue} placeholder={t('common.searchVar', { ns: 'workflow' }) || ''} - onChange={e => setSearchText(e.target.value)} + onChange={e => setInternalSearchValue(e.target.value)} onKeyDown={handleKeyDown} - onClear={() => setSearchText('')} + onClear={() => setInternalSearchValue('')} onBlur={onBlur} autoFocus={autoFocus} />