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}
/>