feat: support slash variable filtering in prompt editor (#35460)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled

This commit is contained in:
Joel
2026-04-21 15:57:16 +08:00
committed by GitHub
parent 0b60bf6ef0
commit 48d23cd744
8 changed files with 210 additions and 20 deletions

View File

@@ -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', () => {

View File

@@ -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}})`
+ ')$',
)

View File

@@ -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((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
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((
<MinimalEditor
triggerString="/"
contextBlock={makeContextBlock()}
workflowVariableBlock={workflowVariableBlock}
captures={captures}
/>
))
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 }

View File

@@ -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<string | null>(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<string | null>(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 && (
<div className="p-1">
<VarReferenceVars
hideSearch={triggerString === '/'}
searchText={triggerString === '/' ? (effectiveQueryString || '') : undefined}
searchBoxClassName="mt-1"
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
@@ -270,8 +281,8 @@ const ComponentPicker = ({
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
queryString: effectiveQueryString,
isSelected: workflowVariableBlock?.show ? false : selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},

View File

@@ -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' })])
})
})

View File

@@ -199,6 +199,34 @@ describe('VarReferenceVars', () => {
}))
})
it('should filter by externally controlled search text and match child variables', () => {
render(
<VarReferenceVars
hideSearch
searchText="child"
vars={createVars([
{
title: 'Object vars',
nodeId: 'node-obj',
vars: [{
variable: 'payload',
type: VarType.object,
children: [{ variable: 'child_name', type: VarType.string }],
}, {
variable: 'other_value',
type: VarType.string,
}],
},
])}
onChange={vi.fn()}
/>,
)
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()

View File

@@ -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)),
}
})
}

View File

@@ -241,6 +241,7 @@ const Item: FC<ItemProps> = ({
type Props = {
hideSearch?: boolean
searchText?: string
searchBoxClassName?: string
vars: NodeOutPutVar[]
isSupportFileVar?: boolean
@@ -258,6 +259,7 @@ type Props = {
}
const VarReferenceVars: FC<Props> = ({
hideSearch,
searchText,
searchBoxClassName,
vars,
isSupportFileVar,
@@ -274,7 +276,8 @@ const VarReferenceVars: FC<Props> = ({
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<Props> = ({
}
}
const filteredVars = useMemo(() => filterReferenceVars(vars, searchText), [vars, searchText])
const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue])
return (
<>
@@ -295,11 +298,11 @@ const VarReferenceVars: FC<Props> = ({
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}
/>