feat: enhance agent integration in prompt editor and mixed-variable text input

This commit is contained in:
zhsama 2026-01-08 17:02:35 +08:00
parent 831eba8b1c
commit 1aed585a19
9 changed files with 176 additions and 79 deletions

View File

@ -5,6 +5,7 @@ import type {
} from 'lexical'
import type { FC } from 'react'
import type {
AgentBlockType,
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
@ -103,6 +104,7 @@ export type PromptEditorProps = {
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
agentBlock?: AgentBlockType
isSupportFileVar?: boolean
}
@ -128,6 +130,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
currentBlock,
errorMessageBlock,
lastRunBlock,
agentBlock,
isSupportFileVar,
}) => {
const { eventEmitter } = useEventEmitterContextContext()
@ -139,6 +142,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
{
replace: TextNode,
with: (node: TextNode) => new CustomTextNode(node.__text),
withKlass: CustomTextNode,
},
ContextBlockNode,
HistoryBlockNode,
@ -212,19 +216,22 @@ const PromptEditor: FC<PromptEditorProps> = ({
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
/>
<ComponentPickerBlock
triggerString="@"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
isSupportFileVar={isSupportFileVar}
/>
{(!agentBlock || agentBlock.show) && (
<ComponentPickerBlock
triggerString="@"
contextBlock={contextBlock}
historyBlock={historyBlock}
queryBlock={queryBlock}
variableBlock={variableBlock}
externalToolBlock={externalToolBlock}
workflowVariableBlock={workflowVariableBlock}
currentBlock={currentBlock}
errorMessageBlock={errorMessageBlock}
lastRunBlock={lastRunBlock}
agentBlock={agentBlock}
isSupportFileVar={isSupportFileVar}
/>
)}
<ComponentPickerBlock
triggerString="{"
contextBlock={contextBlock}

View File

@ -1,6 +1,7 @@
import type { MenuRenderFn } from '@lexical/react/LexicalTypeaheadMenuPlugin'
import type { TextNode } from 'lexical'
import type {
AgentBlockType,
ContextBlockType,
CurrentBlockType,
ErrorMessageBlockType,
@ -29,7 +30,9 @@ import {
} from 'react'
import ReactDOM from 'react-dom'
import { GeneratorType } from '@/app/components/app/configuration/config/automatic/types'
import AgentNodeList from '@/app/components/workflow/nodes/_base/components/agent-node-list'
import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars'
import { BlockEnum } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useBasicTypeaheadTriggerMatch } from '../../hooks'
import { $splitNodeContainingQuery } from '../../utils'
@ -51,6 +54,7 @@ type ComponentPickerProps = {
currentBlock?: CurrentBlockType
errorMessageBlock?: ErrorMessageBlockType
lastRunBlock?: LastRunBlockType
agentBlock?: AgentBlockType
isSupportFileVar?: boolean
}
const ComponentPicker = ({
@ -64,6 +68,7 @@ const ComponentPicker = ({
currentBlock,
errorMessageBlock,
lastRunBlock,
agentBlock,
isSupportFileVar,
}: ComponentPickerProps) => {
const { eventEmitter } = useEventEmitterContextContext()
@ -151,12 +156,31 @@ const ComponentPicker = ({
editor.dispatchCommand(KEY_ESCAPE_COMMAND, escapeEvent)
}, [editor])
const handleSelectAgent = useCallback((agent: { id: string, title: string }) => {
editor.update(() => {
const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!)
if (needRemove)
needRemove.remove()
})
agentBlock?.onSelect?.(agent)
handleClose()
}, [editor, checkForTriggerMatch, triggerString, agentBlock, handleClose])
const isAgentTrigger = triggerString === '@' && agentBlock?.show
const agentNodes = agentBlock?.agentNodes || []
const renderMenu = useCallback<MenuRenderFn<PickerBlockMenuOption>>((
anchorElementRef,
{ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex },
) => {
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
return null
if (isAgentTrigger) {
if (!(anchorElementRef.current && agentNodes.length))
return null
}
else {
if (!(anchorElementRef.current && (allFlattenOptions.length || workflowVariableBlock?.show)))
return null
}
setTimeout(() => {
if (anchorElementRef.current)
@ -167,9 +191,6 @@ const ComponentPicker = ({
<>
{
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"
@ -179,56 +200,73 @@ const ComponentPicker = ({
}}
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}
{isAgentTrigger
? (
<AgentNodeList
nodes={agentNodes.map(node => ({
...node,
type: BlockEnum.Agent,
}))}
onSelect={handleSelectAgent}
onClose={handleClose}
onBlur={handleClose}
showManageInputField={workflowVariableBlock.showManageInputField}
onManageInputField={workflowVariableBlock.onManageInputField}
maxHeightClass="max-h-[34vh]"
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 && (
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>
)
}
{option.renderMenuOption({
queryString,
isSelected: selectedIndex === index,
onSelect: () => {
selectOptionAndCleanUp(option)
},
onSetHighlight: () => {
setHighlightedIndex(index)
},
})}
</Fragment>
))
}
</div>
<div>
{
options.map((option, index) => (
<Fragment key={option.key}>
{
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,
@ -236,7 +274,7 @@ const ComponentPicker = ({
}
</>
)
}, [allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, workflowVariableOptions, isSupportFileVar, handleClose, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
}, [isAgentTrigger, agentNodes, allFlattenOptions.length, workflowVariableBlock?.show, floatingStyles, isPositioned, refs, handleSelectAgent, handleClose, workflowVariableOptions, isSupportFileVar, currentBlock?.generatorType, handleSelectWorkflowVariable, queryString, workflowVariableBlock?.showManageInputField, workflowVariableBlock?.onManageInputField])
return (
<LexicalTypeaheadMenuPlugin

View File

@ -73,6 +73,17 @@ export type WorkflowVariableBlockType = {
onManageInputField?: () => void
}
export type AgentNode = {
id: string
title: string
}
export type AgentBlockType = {
show?: boolean
agentNodes?: AgentNode[]
onSelect?: (agent: AgentNode) => void
}
export type MenuTextMatch = {
leadOffset: number
matchingString: string

View File

@ -1,3 +1,4 @@
import type { AgentBlockType } from '@/app/components/base/prompt-editor/types'
import type {
Node,
NodeOutPutVar,
@ -6,6 +7,7 @@ import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import PromptEditor from '@/app/components/base/prompt-editor'
@ -74,19 +76,44 @@ const MixedVariableTextInput = ({
return null
}, [value, nodesByIdMap])
const [selectedAgent, setSelectedAgent] = useState<{ id: string, title: string } | null>(null)
const agentNodes = useMemo(() => {
return availableNodes
.filter(node => node.data.type === BlockEnum.Agent)
.map(node => ({
id: node.id,
title: node.data.title,
}))
}, [availableNodes])
const handleAgentSelect = useCallback((agent: { id: string, title: string }) => {
setSelectedAgent(agent)
if (onChange) {
const agentVar = `{{#${agent.id}.text#}}`
const newValue = value ? `${agentVar}${value}` : agentVar
onChange(newValue)
setControlPromptEditorRerenderKey(Date.now())
}
}, [onChange, value, setControlPromptEditorRerenderKey])
const handleAgentRemove = useCallback(() => {
if (!detectedAgentFromValue || !onChange)
const agentNodeId = detectedAgentFromValue?.nodeId || selectedAgent?.id
if (!agentNodeId || !onChange)
return
const pattern = /\{\{#([^#]+)#\}\}/g
const valueWithoutAgentVars = value.replace(pattern, (match, variablePath) => {
const nodeId = variablePath.split('.')[0]
return nodeId === detectedAgentFromValue.nodeId ? '' : match
return nodeId === agentNodeId ? '' : match
}).trim()
onChange(valueWithoutAgentVars)
setSelectedAgent(null)
setControlPromptEditorRerenderKey(Date.now())
}, [detectedAgentFromValue, value, onChange, setControlPromptEditorRerenderKey])
}, [detectedAgentFromValue?.nodeId, selectedAgent?.id, value, onChange, setControlPromptEditorRerenderKey])
const displayedAgent = detectedAgentFromValue || (selectedAgent ? { nodeId: selectedAgent.id, name: selectedAgent.title } : null)
return (
<div className={cn(
@ -95,9 +122,9 @@ const MixedVariableTextInput = ({
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
)}
>
{detectedAgentFromValue && (
{displayedAgent && (
<AgentHeaderBar
agentName={detectedAgentFromValue.name}
agentName={displayedAgent.name}
onRemove={handleAgentRemove}
onViewInternals={onViewInternals}
/>
@ -127,7 +154,12 @@ const MixedVariableTextInput = ({
showManageInputField,
onManageInputField,
}}
placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} />}
agentBlock={{
show: agentNodes.length > 0 && !displayedAgent,
agentNodes,
onSelect: handleAgentSelect,
} as AgentBlockType}
placeholder={<Placeholder disableVariableInsertion={disableVariableInsertion} hasSelectedAgent={!!displayedAgent} />}
onChange={onChange}
/>
</div>

View File

@ -7,9 +7,10 @@ import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/cust
type PlaceholderProps = {
disableVariableInsertion?: boolean
hasSelectedAgent?: boolean
}
const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) => {
const Placeholder = ({ disableVariableInsertion = false, hasSelectedAgent = false }: PlaceholderProps) => {
const { t } = useTranslation()
const [editor] = useLexicalComposerContext()
@ -44,17 +45,21 @@ const Placeholder = ({ disableVariableInsertion = false }: PlaceholderProps) =>
>
{t('nodes.tool.insertPlaceholder2', { ns: 'workflow' })}
</div>
<div className="system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder">@</div>
<div
className="system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
onMouseDown={((e) => {
e.preventDefault()
e.stopPropagation()
handleInsert('@')
})}
>
{t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })}
</div>
{!hasSelectedAgent && (
<>
<div className="system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder">@</div>
<div
className="system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary"
onMouseDown={((e) => {
e.preventDefault()
e.stopPropagation()
handleInsert('@')
})}
>
{t('nodes.tool.insertPlaceholder3', { ns: 'workflow' })}
</div>
</>
)}
</>
)}
</div>

View File

@ -173,6 +173,7 @@
"common.needConnectTip": "This step is not connected to anything",
"common.needOutputNode": "The Output node must be added",
"common.needStartNode": "At least one start node must be added",
"common.noAgentNodes": "No agent nodes available",
"common.noHistory": "No History",
"common.noVar": "No variable",
"common.notRunning": "Not running yet",

View File

@ -171,6 +171,7 @@
"common.needConnectTip": "接続されていないステップがあります",
"common.needOutputNode": "出力ノードを追加する必要があります",
"common.needStartNode": "少なくとも1つのスタートードを追加する必要があります",
"common.noAgentNodes": "利用可能なエージェントノードがありません",
"common.noHistory": "履歴がありません",
"common.noVar": "変数がありません",
"common.notRunning": "まだ実行されていません",

View File

@ -171,6 +171,7 @@
"common.needConnectTip": "此节点尚未连接到其他节点",
"common.needOutputNode": "必须添加输出节点",
"common.needStartNode": "必须添加至少一个开始节点",
"common.noAgentNodes": "没有可用的代理节点",
"common.noHistory": "没有历史版本",
"common.noVar": "没有变量",
"common.notRunning": "尚未运行",

View File

@ -171,6 +171,7 @@
"common.needConnectTip": "此節點尚未連接到其他節點",
"common.needOutputNode": "必須新增輸出節點",
"common.needStartNode": "至少必須新增一個起始節點",
"common.noAgentNodes": "沒有可用的代理節點",
"common.noHistory": "無歷史記錄",
"common.noVar": "沒有變數",
"common.notRunning": "尚未運行",