This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { $insertNodes } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
ExternalToolBlockType,
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { $createCustomTextNode } from '../custom-text/node'
import { PromptMenuItem } from './prompt-option'
import { VariableMenuItem } from './variable-option'
import { PickerBlockMenuOption } from './menu'
import { File05 } from '@/app/components/base/icons/src/vender/solid/files'
import {
MessageClockCircle,
Tool03,
} from '@/app/components/base/icons/src/vender/solid/general'
import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
import { UserEdit02 } from '@/app/components/base/icons/src/vender/solid/users'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import AppIcon from '@/app/components/base/app-icon'
import { VarType } from '@/app/components/workflow/types'
export const usePromptOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const promptOptions: PickerBlockMenuOption[] = []
if (contextBlock?.show) {
promptOptions.push(new PickerBlockMenuOption({
key: t('common.promptEditor.context.item.title'),
group: 'prompt context',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return <PromptMenuItem
title={t('common.promptEditor.context.item.title')}
icon={<File05 className='h-4 w-4 text-[#6938EF]' />}
disabled={!contextBlock.selectable}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
},
onSelect: () => {
if (!contextBlock?.selectable)
return
editor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined)
},
}))
}
if (queryBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
key: t('common.promptEditor.query.item.title'),
group: 'prompt query',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return (
<PromptMenuItem
title={t('common.promptEditor.query.item.title')}
icon={<UserEdit02 className='h-4 w-4 text-[#FD853A]' />}
disabled={!queryBlock.selectable}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
if (!queryBlock?.selectable)
return
editor.dispatchCommand(INSERT_QUERY_BLOCK_COMMAND, undefined)
},
}),
)
}
if (historyBlock?.show) {
promptOptions.push(
new PickerBlockMenuOption({
key: t('common.promptEditor.history.item.title'),
group: 'prompt history',
render: ({ isSelected, onSelect, onSetHighlight }) => {
return (
<PromptMenuItem
title={t('common.promptEditor.history.item.title')}
icon={<MessageClockCircle className='h-4 w-4 text-[#DD2590]' />}
disabled={!historyBlock.selectable
}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
if (!historyBlock?.selectable)
return
editor.dispatchCommand(INSERT_HISTORY_BLOCK_COMMAND, undefined)
},
}),
)
}
return promptOptions
}
export const useVariableOptions = (
variableBlock?: VariableBlockType,
queryString?: string,
): PickerBlockMenuOption[] => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const options = useMemo(() => {
if (!variableBlock?.variables)
return []
const baseOptions = (variableBlock.variables).map((item) => {
return new PickerBlockMenuOption({
key: item.value,
group: 'prompt variable',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={item.value}
icon={<BracketsX className='h-[14px] w-[14px] text-text-accent' />}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.value}}}`)
},
})
})
if (!queryString)
return baseOptions
const regex = new RegExp(queryString, 'i')
return baseOptions.filter(option => regex.test(option.key))
}, [editor, queryString, variableBlock])
const addOption = useMemo(() => {
return new PickerBlockMenuOption({
key: t('common.promptEditor.variable.modal.add'),
group: 'prompt variable',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={t('common.promptEditor.variable.modal.add')}
icon={<BracketsX className='h-[14px] w-[14px] text-text-accent' />}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
editor.update(() => {
const prefixNode = $createCustomTextNode('{{')
const suffixNode = $createCustomTextNode('}}')
$insertNodes([prefixNode, suffixNode])
prefixNode.select()
})
},
})
}, [editor, t])
return useMemo(() => {
return variableBlock?.show ? [...options, addOption] : []
}, [options, addOption, variableBlock?.show])
}
export const useExternalToolOptions = (
externalToolBlockType?: ExternalToolBlockType,
queryString?: string,
) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
const options = useMemo(() => {
if (!externalToolBlockType?.externalTools)
return []
const baseToolOptions = (externalToolBlockType.externalTools).map((item) => {
return new PickerBlockMenuOption({
key: item.name,
group: 'external tool',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={item.name}
icon={
<AppIcon
className='!h-[14px] !w-[14px]'
icon={item.icon}
background={item.icon_background}
/>
}
extraElement={<div className='text-xs text-text-tertiary'>{item.variableName}</div>}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${item.variableName}}}`)
},
})
})
if (!queryString)
return baseToolOptions
const regex = new RegExp(queryString, 'i')
return baseToolOptions.filter(option => regex.test(option.key))
}, [editor, queryString, externalToolBlockType])
const addOption = useMemo(() => {
return new PickerBlockMenuOption({
key: t('common.promptEditor.variable.modal.addTool'),
group: 'external tool',
render: ({ queryString, isSelected, onSelect, onSetHighlight }) => {
return (
<VariableMenuItem
title={t('common.promptEditor.variable.modal.addTool')}
icon={<Tool03 className='h-[14px] w-[14px] text-text-accent' />}
extraElement={< ArrowUpRight className='h-3 w-3 text-text-tertiary' />}
queryString={queryString}
isSelected={isSelected}
onClick={onSelect}
onMouseEnter={onSetHighlight}
/>
)
},
onSelect: () => {
externalToolBlockType?.onAddExternalTool?.()
},
})
}, [externalToolBlockType, t])
return useMemo(() => {
return externalToolBlockType?.show ? [...options, addOption] : []
}, [options, addOption, externalToolBlockType?.show])
}
export const useOptions = (
contextBlock?: ContextBlockType,
queryBlock?: QueryBlockType,
historyBlock?: HistoryBlockType,
variableBlock?: VariableBlockType,
externalToolBlockType?: ExternalToolBlockType,
workflowVariableBlockType?: WorkflowVariableBlockType,
currentBlockType?: CurrentBlockType,
errorMessageBlockType?: ErrorMessageBlockType,
lastRunBlockType?: LastRunBlockType,
queryString?: string,
) => {
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
const variableOptions = useVariableOptions(variableBlock, queryString)
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)
const workflowVariableOptions = useMemo(() => {
if (!workflowVariableBlockType?.show)
return []
const res = workflowVariableBlockType.variables || []
if(errorMessageBlockType?.show && res.findIndex(v => v.nodeId === 'error_message') === -1) {
res.unshift({
nodeId: 'error_message',
title: 'error_message',
isFlat: true,
vars: [
{
variable: 'error_message',
type: VarType.string,
},
],
})
}
if(lastRunBlockType?.show && res.findIndex(v => v.nodeId === 'last_run') === -1) {
res.unshift({
nodeId: 'last_run',
title: 'last_run',
isFlat: true,
vars: [
{
variable: 'last_run',
type: VarType.object,
},
],
})
}
if(currentBlockType?.show && res.findIndex(v => v.nodeId === 'current') === -1) {
const title = currentBlockType.generatorType === 'prompt' ? 'current_prompt' : 'current_code'
res.unshift({
nodeId: 'current',
title,
isFlat: true,
vars: [
{
variable: 'current',
type: VarType.string,
},
],
})
}
return res
}, [workflowVariableBlockType?.show, workflowVariableBlockType?.variables, errorMessageBlockType?.show, lastRunBlockType?.show, currentBlockType?.show, currentBlockType?.generatorType])
return useMemo(() => {
return {
workflowVariableOptions,
allFlattenOptions: [...promptOptions, ...variableOptions, ...externalToolOptions],
}
}, [promptOptions, variableOptions, externalToolOptions, workflowVariableOptions])
}

View File

@@ -0,0 +1,258 @@
import {
Fragment,
memo,
useCallback,
useState,
} from 'react'
import ReactDOM from 'react-dom'
import {
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react'
import type { TextNode } from 'lexical'
import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { LexicalTypeaheadMenuPlugin } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import type {
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
ExternalToolBlockType,
HistoryBlockType,
LastRunBlockType,
QueryBlockType,
VariableBlockType,
WorkflowVariableBlockType,
} from '../../types'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
import { INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND } from '../workflow-variable-block'
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
import { $splitNodeContainingQuery } from '../../utils'
import { useOptions } from './hooks'
import type { PickerBlockMenuOption } from './menu'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { KEY_ESCAPE_COMMAND } from 'lexical'
import { INSERT_CURRENT_BLOCK_COMMAND } from '../current-block'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import { INSERT_ERROR_MESSAGE_BLOCK_COMMAND } from '../error-message-block'
import { INSERT_LAST_RUN_BLOCK_COMMAND } from '../last-run-block'
type ComponentPickerProps = {
triggerString: string
contextBlock?: ContextBlockType
queryBlock?: QueryBlockType
historyBlock?: HistoryBlockType
variableBlock?: VariableBlockType
externalToolBlock?: ExternalToolBlockType
workflowVariableBlock?: WorkflowVariableBlockType
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
isSupportFileVar?: boolean
}
const ComponentPicker = ({
triggerString,
contextBlock,
queryBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
isSupportFileVar,
}: ComponentPickerProps) => {
const { eventEmitter } = useEventEmitterContextContext()
const { refs, floatingStyles, isPositioned } = useFloating({
placement: 'bottom-start',
middleware: [
offset(0), // fix hide cursor
shift({
padding: 8,
}),
flip(),
],
})
const [editor] = useLexicalComposerContext()
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, {
minLength: 0,
maxLength: 0,
})
const [queryString, setQueryString] = useState<string | null>(null)
eventEmitter?.useSubscription((v: any) => {
if (v.type === INSERT_VARIABLE_VALUE_BLOCK_COMMAND)
editor.dispatchCommand(INSERT_VARIABLE_VALUE_BLOCK_COMMAND, `{{${v.payload}}}`)
})
const {
allFlattenOptions,
workflowVariableOptions,
} = useOptions(
contextBlock,
queryBlock,
historyBlock,
variableBlock,
externalToolBlock,
workflowVariableBlock,
currentBlock,
errorMessageBlock,
lastRunBlock,
)
const onSelectOption = useCallback(
(
selectedOption: PickerBlockMenuOption,
nodeToRemove: TextNode | null,
closeMenu: () => void,
) => {
editor.update(() => {
if (nodeToRemove && selectedOption?.key)
nodeToRemove.remove()
selectedOption.onSelectMenuOption()
closeMenu()
})
},
[editor],
)
const handleSelectWorkflowVariable = useCallback((variables: string[]) => {
editor.update(() => {
const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
if (needRemove)
needRemove.remove()
})
const isFlat = variables.length === 1
if (isFlat) {
const varName = variables[0]
if (varName === 'current')
editor.dispatchCommand(INSERT_CURRENT_BLOCK_COMMAND, currentBlock?.generatorType)
else if (varName === 'error_message')
editor.dispatchCommand(INSERT_ERROR_MESSAGE_BLOCK_COMMAND, null)
else if (varName === 'last_run')
editor.dispatchCommand(INSERT_LAST_RUN_BLOCK_COMMAND, null)
}
else if (variables[1] === 'sys.query' || variables[1] === 'sys.files') {
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, [variables[1]])
}
else {
editor.dispatchCommand(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, variables)
}
}, [editor, currentBlock?.generatorType, checkForTriggerMatch, triggerString])
const handleClose = useCallback(() => {
const escapeEvent = new KeyboardEvent('keydown', { key: 'Escape' })
editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent)
}, [editor])
const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
return null
setTimeout(() => {
if (anchorElementRef.current)
refs.setReference(anchorElementRef.current)
}, 0)
return (
<>
{
ReactDOM.createPortal(
// The `LexicalMenu` will try to calculate the position of the floating menu based on the first child.
// Since we use floating ui, we need to wrap it with a div to prevent the position calculation being affected.
// See https://github.com/facebook/lexical/blob/ac97dfa9e14a73ea2d6934ff566282d7f758e8bb/packages/lexical-react/src/shared/LexicalMenu.ts#L493
<div className='h-0 w-0'>
<div
className='w-[260px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
ref={refs.setFloating}
>
{
workflowVariableBlock?.show && (
<div className='p-1'>
<VarReferenceVars
searchBoxClassName='mt-1'
vars={workflowVariableOptions}
onChange={(variables: string[]) => {
handleSelectWorkflowVariable(variables)
}}
maxHeightClass='max-h-[34vh]'
isSupportFileVar={isSupportFileVar}
onClose={handleClose}
onBlur={handleClose}
showManageInputField={workflowVariableBlock.showManageInputField}
onManageInputField={workflowVariableBlock.onManageInputField}
autoFocus={false}
isInCodeGeneratorInstructionEditor={currentBlock?.generatorType === GeneratorType.code}
/>
</div>
)
}
{
workflowVariableBlock?.show && !!options.length && (
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
)
}
<div>
{
options.map((option, index) => (
<Fragment key={option.key}>
{
// Divider
index !== 0 && options.at(index - 1)?.group !== option.group && (
<div className='my-1 h-px w-full -translate-x-1 bg-divider-subtle'></div>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
</div>
</div>
</div>,
anchorElementRef.current,
)
}
</>
)
}, [allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
return (
<LexicalTypeaheadMenuPlugin
options={allFlattenOptions}
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
// The `translate` class is used to workaround the issue that the `typeahead-menu` menu is not positioned as expected.
// See also https://github.com/facebook/lexical/blob/772520509308e8ba7e4a82b6cd1996a78b3298d0/packages/lexical-react/src/shared/LexicalMenu.ts#L498
//
// We no need the position function of the `LexicalTypeaheadMenuPlugin`,
// so the reference anchor should be positioned based on the range of the trigger string, and the menu will be positioned by the floating ui.
anchorClassName='z-[999999] translate-y-[calc(-100%-3px)]'
menuRenderFn={renderMenu}
triggerFn={checkForTriggerMatch}
/>
)
}
export default memo(ComponentPicker)

View File

@@ -0,0 +1,31 @@
import { MenuOption } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import { Fragment } from 'react'
/**
* Corresponds to the `MenuRenderFn` type from `@lexical/react/LexicalTypeaheadMenuPlugin`.
*/
type MenuOptionRenderProps = {
isSelected: boolean
onSelect: () => void
onSetHighlight: () => void
queryString: string | null
}
export class PickerBlockMenuOption extends MenuOption {
public group?: string
constructor(
private data: {
key: string
group?: string
onSelect?: () => void
render: (menuRenderProps: MenuOptionRenderProps) => React.JSX.Element
},
) {
super(data.key)
this.group = data.group
}
public onSelectMenuOption = () => this.data.onSelect?.()
public renderMenuOption = (menuRenderProps: MenuOptionRenderProps) => <Fragment key={this.data.key}>{this.data.render(menuRenderProps)}</Fragment>
}

View File

@@ -0,0 +1,49 @@
import { memo } from 'react'
type PromptMenuItemMenuItemProps = {
icon: React.JSX.Element
title: string
disabled?: boolean
isSelected: boolean
onClick: () => void
onMouseEnter: () => void
setRefElement?: (element: HTMLDivElement) => void
}
export const PromptMenuItem = memo(({
icon,
title,
disabled,
isSelected,
onClick,
onMouseEnter,
setRefElement,
}: PromptMenuItemMenuItemProps) => {
return (
<div
className={`
flex h-6 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover
${isSelected && !disabled && '!bg-state-base-hover'}
${disabled ? 'cursor-not-allowed opacity-30' : 'cursor-pointer hover:bg-state-base-hover'}
`}
tabIndex={-1}
ref={setRefElement}
onMouseEnter={() => {
if (disabled)
return
onMouseEnter()
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={() => {
if (disabled)
return
onClick()
}}>
{icon}
<div className='ml-1 text-[13px] text-text-secondary'>{title}</div>
</div>
)
})
PromptMenuItem.displayName = 'PromptMenuItem'

View File

@@ -0,0 +1,64 @@
import { memo } from 'react'
type VariableMenuItemProps = {
title: string
icon?: React.JSX.Element
extraElement?: React.JSX.Element
isSelected: boolean
queryString: string | null
onClick: () => void
onMouseEnter: () => void
setRefElement?: (element: HTMLDivElement) => void
}
export const VariableMenuItem = memo(({
title,
icon,
extraElement,
isSelected,
queryString,
onClick,
onMouseEnter,
setRefElement,
}: VariableMenuItemProps) => {
let before = title
let middle = ''
let after = ''
if (queryString) {
const regex = new RegExp(queryString, 'i')
const match = regex.exec(title)
if (match) {
before = title.substring(0, match.index)
middle = match[0]
after = title.substring(match.index + match[0].length)
}
}
return (
<div
className={`
flex h-6 cursor-pointer items-center rounded-md px-3 hover:bg-state-base-hover
${isSelected && 'bg-state-base-hover'}
`}
tabIndex={-1}
ref={setRefElement}
onMouseEnter={onMouseEnter}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={onClick}>
<div className='mr-2'>
{icon}
</div>
<div className='grow truncate text-[13px] text-text-secondary' title={title}>
{before}
<span className='text-text-accent'>{middle}</span>
{after}
</div>
{extraElement}
</div>
)
})
VariableMenuItem.displayName = 'VariableMenuItem'