mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-30 17:50:29 +08:00
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
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:
@@ -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', () => {
|
||||
|
||||
@@ -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}})`
|
||||
+ ')$',
|
||||
)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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' })])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user