Merge branch 'zhsama/panel-var-popup' into feat/pull-a-variable

This commit is contained in:
zhsama
2026-01-19 23:15:01 +08:00
5 changed files with 295 additions and 69 deletions

View File

@ -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}]`

View File

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

View File

@ -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<string | null>(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}
/>
</div>
)
@ -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 (
<LexicalTypeaheadMenuPlugin

View File

@ -2,7 +2,7 @@
import type { FC } from 'react'
import type { BlockEnum } from '@/app/components/workflow/types'
import * as React from 'react'
import { useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import BlockIcon from '@/app/components/workflow/block-icon'
@ -17,9 +17,12 @@ export type AgentNode = {
type ItemProps = {
node: AgentNode
onSelect: (node: AgentNode) => void
isHighlighted?: boolean
onSetHighlight?: () => void
registerRef?: (element: HTMLButtonElement | null) => void
}
const Item: FC<ItemProps> = ({ node, onSelect }) => {
const Item: FC<ItemProps> = ({ node, onSelect, isHighlighted, onSetHighlight, registerRef }) => {
const [isHovering, setIsHovering] = useState(false)
return (
@ -27,10 +30,15 @@ const Item: FC<ItemProps> = ({ 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<Props> = ({
@ -69,9 +79,13 @@ const AgentNodeList: FC<Props> = ({
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<Props> = ({
}
}
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<Array<HTMLButtonElement | null>>([])
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 && (
<>
<div className={cn('mx-2 mb-2 mt-2', searchBoxClassName)}>
<Input
@ -114,11 +192,16 @@ const AgentNodeList: FC<Props> = ({
{filteredNodes.length > 0
? (
<div className={cn('max-h-[85vh] overflow-y-auto py-1', maxHeightClass)}>
{filteredNodes.map(node => (
{filteredNodes.map((node, index) => (
<Item
key={node.id}
node={node}
onSelect={onSelect}
isHighlighted={enableKeyboardNavigation && index === activeIndex}
onSetHighlight={enableKeyboardNavigation ? () => setActiveIndex(index) : undefined}
registerRef={enableKeyboardNavigation ? (element) => {
itemRefs.current[index] = element
} : undefined}
/>
))}
</div>

View File

@ -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<ItemProps> = ({
@ -60,6 +85,9 @@ const Item: FC<ItemProps> = ({
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<ItemProps> = ({
})()
const itemRef = useRef<HTMLDivElement>(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<ItemProps> = ({
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<ItemProps> = ({
>
<PortalToFollowElemTrigger className="w-full">
<div
ref={itemRef}
ref={setItemRef}
className={cn(
(isObj || isStructureOutput) ? ' pr-1' : 'pr-[18px]',
isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
(isHovering || isHighlighted) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'),
'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3',
className,
)}
onClick={handleChosen}
onMouseEnter={onSetHighlight}
onMouseDown={e => e.preventDefault()}
>
<div className="flex w-0 grow items-center">
@ -259,6 +289,8 @@ type Props = {
onAssembleVariables?: () => ValueSelector | null
autoFocus?: boolean
preferSchemaType?: boolean
externalSearchText?: string
enableKeyboardNavigation?: boolean
}
const VarReferenceVars: FC<Props> = ({
hideSearch,
@ -278,9 +310,13 @@ const VarReferenceVars: FC<Props> = ({
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<Props> = ({
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<Array<HTMLDivElement | null>>([])
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 && (
<>
<div className={cn('var-search-input-wrapper mx-2 mb-2 mt-2', searchBoxClassName)} onClick={e => e.stopPropagation()}>
<Input
@ -385,24 +510,33 @@ const VarReferenceVars: FC<Props> = ({
{item.title}
</div>
)}
{item.vars.map((v, j) => (
<Item
key={j}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={v}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
isLoopVar={item.isLoop}
isFlat={item.isFlat}
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
zIndex={zIndex}
preferSchemaType={preferSchemaType}
/>
))}
{item.vars.map((v, j) => {
runningIndex += 1
const itemIndex = runningIndex
return (
<Item
key={j}
title={item.title}
nodeId={item.nodeId}
objPath={[]}
itemData={v}
onChange={onChange}
itemWidth={itemWidth}
isSupportFileVar={isSupportFileVar}
isException={v.isException}
isLoopVar={item.isLoop}
isFlat={item.isFlat}
isInCodeGeneratorInstructionEditor={isInCodeGeneratorInstructionEditor}
zIndex={zIndex}
preferSchemaType={preferSchemaType}
isHighlighted={enableKeyboardNavigation && itemIndex === activeIndex}
onSetHighlight={enableKeyboardNavigation ? () => setActiveIndex(itemIndex) : undefined}
registerRef={enableKeyboardNavigation ? (element) => {
itemRefs.current[itemIndex] = element
} : undefined}
/>
)
})}
{item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && (
<div className="relative mt-[14px] flex items-center space-x-1">
<div className="h-0 w-3 shrink-0 border border-divider-subtle"></div>