feat: sub-graph to use dynamic node generation

This commit is contained in:
zhsama
2026-01-13 22:28:30 +08:00
parent f57d2ef31f
commit 96ec176b83
16 changed files with 351 additions and 134 deletions

View File

@ -4,6 +4,7 @@ import { memo, useMemo } from 'react'
import { useStore as useReactFlowStore } from 'reactflow'
import { useShallow } from 'zustand/react/shallow'
import { Panel as NodePanel } from '@/app/components/workflow/nodes'
import { BlockEnum } from '@/app/components/workflow/types'
type SubGraphChildrenProps = {
toolNodeId: string
@ -20,7 +21,7 @@ const SubGraphChildren: FC<SubGraphChildrenProps> = ({
const nodes = s.getNodes()
const currentNode = nodes.find(node => node.data.selected)
if (currentNode) {
if (currentNode?.data.type === BlockEnum.LLM) {
return {
id: currentNode.id,
type: currentNode.type,

View File

@ -1,8 +1,7 @@
import type { FC } from 'react'
import type { Viewport } from 'reactflow'
import type { SubGraphConfig } from '../types'
import type { Edge, Node } from '@/app/components/workflow/types'
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import { WorkflowWithInnerContext } from '@/app/components/workflow'
import { useAvailableNodesMetaData, useSubGraphPersistence } from '../hooks'
import SubGraphChildren from './sub-graph-children'
@ -23,37 +22,14 @@ const SubGraphMain: FC<SubGraphMainProps> = ({
paramKey,
}) => {
const availableNodesMetaData = useAvailableNodesMetaData()
const {
saveSubGraphData,
loadSubGraphData,
updateSubGraphConfig,
} = useSubGraphPersistence({ toolNodeId, paramKey })
const handleNodesChange = useCallback((updatedNodes: Node[]) => {
const existingData = loadSubGraphData()
const defaultConfig: SubGraphConfig = {
enabled: true,
startNodeId: updatedNodes[0]?.id || '',
selectedOutputVar: [],
whenOutputNone: 'default',
}
saveSubGraphData({
nodes: updatedNodes,
edges,
config: existingData?.config || defaultConfig,
})
}, [edges, loadSubGraphData, saveSubGraphData])
const { updateSubGraphConfig } = useSubGraphPersistence({ toolNodeId, paramKey })
const hooksStore = useMemo(() => {
return {
interactionMode: 'subgraph',
availableNodesMetaData,
doSyncWorkflowDraft: async () => {
handleNodesChange(nodes)
},
syncWorkflowDraftWhenPageClose: () => {
handleNodesChange(nodes)
},
doSyncWorkflowDraft: async () => {},
syncWorkflowDraftWhenPageClose: () => {},
handleRefreshWorkflowDraft: () => {},
handleBackupDraft: () => {},
handleLoadBackupDraft: () => {},
@ -86,7 +62,7 @@ const SubGraphMain: FC<SubGraphMainProps> = ({
resetConversationVar: async () => {},
invalidateConversationVarValues: () => {},
}
}, [availableNodesMetaData, handleNodesChange, nodes])
}, [availableNodesMetaData])
return (
<WorkflowWithInnerContext
@ -94,6 +70,9 @@ const SubGraphMain: FC<SubGraphMainProps> = ({
edges={edges}
viewport={viewport}
hooksStore={hooksStore as any}
allowSelectionWhenReadOnly
canvasReadOnly
interactionMode="subgraph"
>
<SubGraphChildren
toolNodeId={toolNodeId}

View File

@ -2,11 +2,13 @@ import type { FC } from 'react'
import type { Viewport } from 'reactflow'
import type { SubGraphProps } from './types'
import type { InjectWorkflowStoreSliceFn } from '@/app/components/workflow/store'
import type { PromptItem } from '@/app/components/workflow/types'
import { memo, useMemo } from 'react'
import WorkflowWithDefaultContext from '@/app/components/workflow'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
import { BlockEnum, PromptRole } from '@/app/components/workflow/types'
import SubGraphMain from './components/sub-graph-main'
import { useSubGraphInit, useSubGraphNodes, useSubGraphPersistence } from './hooks'
import { useSubGraphNodes } from './hooks'
import { createSubGraphSlice } from './store'
const defaultViewport: Viewport = {
@ -16,15 +18,106 @@ const defaultViewport: Viewport = {
}
const SubGraph: FC<SubGraphProps> = (props) => {
const { toolNodeId, paramKey } = props
const {
toolNodeId,
paramKey,
agentName,
agentNodeId,
extractorNode,
toolParamValue,
} = props
const { loadSubGraphData } = useSubGraphPersistence({ toolNodeId, paramKey })
const savedData = useMemo(() => loadSubGraphData(), [loadSubGraphData])
const promptText = useMemo(() => {
if (!toolParamValue)
return ''
// Reason: escape agent id before building a regex pattern.
const escapedAgentId = agentNodeId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const leadingPattern = new RegExp(`^\\{\\{[@#]${escapedAgentId}\\.context[@#]\\}\\}`)
return toolParamValue.replace(leadingPattern, '')
}, [agentNodeId, toolParamValue])
const { initialNodes, initialEdges } = useSubGraphInit(props)
const startNode = useMemo(() => {
return {
id: 'subgraph-source',
type: 'custom',
position: { x: 100, y: 150 },
data: {
type: BlockEnum.Start,
title: agentName,
desc: '',
_connectedSourceHandleIds: ['source'],
_connectedTargetHandleIds: [],
variables: [],
},
selectable: false,
draggable: false,
connectable: false,
focusable: false,
deletable: false,
}
}, [agentName])
const nodesSource = savedData?.nodes || initialNodes
const edgesSource = savedData?.edges || initialEdges
const extractorDisplayNode = useMemo(() => {
if (!extractorNode)
return null
const nextPromptTemplate = Array.isArray(extractorNode.data.prompt_template)
? extractorNode.data.prompt_template.map((item: PromptItem) => {
if (item.role === PromptRole.system)
return { ...item, text: promptText }
return item
})
: {
...extractorNode.data.prompt_template,
text: promptText,
}
const hasSystemPrompt = Array.isArray(nextPromptTemplate)
&& nextPromptTemplate.some((item: PromptItem) => item.role === PromptRole.system)
const normalizedPromptTemplate = Array.isArray(nextPromptTemplate)
? (hasSystemPrompt ? nextPromptTemplate : [{ role: PromptRole.system, text: promptText }, ...nextPromptTemplate])
: nextPromptTemplate
return {
...extractorNode,
hidden: false,
position: { x: 450, y: 150 },
data: {
...extractorNode.data,
prompt_template: normalizedPromptTemplate,
},
}
}, [extractorNode, promptText])
const nodesSource = useMemo(() => {
if (!extractorDisplayNode)
return [startNode]
return [startNode, extractorDisplayNode]
}, [extractorDisplayNode, startNode])
const edgesSource = useMemo(() => {
if (!extractorDisplayNode)
return []
return [
{
id: `${startNode.id}-${extractorDisplayNode.id}`,
source: startNode.id,
sourceHandle: 'source',
target: extractorDisplayNode.id,
targetHandle: 'target',
type: 'custom',
selectable: false,
data: {
sourceType: BlockEnum.Start,
targetType: BlockEnum.LLM,
_isTemp: true,
_isSubGraphTemp: true,
},
},
]
}, [extractorDisplayNode, startNode])
const { nodes, edges } = useSubGraphNodes(nodesSource, edgesSource)

View File

@ -5,6 +5,7 @@ const initialState: Omit<SubGraphSliceShape, 'setSubGraphContext' | 'setSubGraph
parameterKey: '',
sourceAgentNodeId: '',
sourceVariable: [],
subGraphReadOnly: true,
subGraphNodes: [],
subGraphEdges: [],

View File

@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Edge, Node, NodeOutPutVar, ValueSelector, VarType } from '@/app/components/workflow/types'
export type WhenOutputNoneOption = 'error' | 'default'
@ -25,6 +26,9 @@ export type SubGraphProps = {
sourceVariable: ValueSelector
agentNodeId: string
agentName: string
extractorNode?: Node<LLMNodeType>
toolParamValue?: string
onSave?: (nodes: Node[], edges: Edge[]) => void
}
export type SubGraphSliceShape = {
@ -32,6 +36,7 @@ export type SubGraphSliceShape = {
parameterKey: string
sourceAgentNodeId: string
sourceVariable: ValueSelector
subGraphReadOnly: boolean
subGraphNodes: Node[]
subGraphEdges: Edge[]

View File

@ -25,6 +25,7 @@ import {
useAvailableBlocks,
useNodesInteractions,
} from './hooks'
import { useHooksStore } from './hooks-store'
import { BlockEnum, NodeRunningStatus } from './types'
import { getEdgeColor } from './utils'
@ -56,6 +57,8 @@ const CustomEdge = ({
})
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const interactionMode = useHooksStore(s => s.interactionMode)
const allowGraphActions = interactionMode !== 'subgraph'
const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop)
const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop)
const {
@ -136,35 +139,37 @@ const CustomEdge = ({
stroke,
strokeWidth: 2,
opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1),
strokeDasharray: (data._isTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined,
strokeDasharray: (data._isTemp && !data._isSubGraphTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined,
}}
/>
<EdgeLabelRenderer>
<div
className={cn(
'nopan nodrag hover:scale-125',
data?._hovering ? 'block' : 'hidden',
open && '!block',
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
)}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
opacity: data._waitingRun ? 0.7 : 1,
}}
>
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
asChild
onSelect={handleInsert}
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)}
triggerClassName={() => 'hover:scale-150 transition-all'}
/>
</div>
</EdgeLabelRenderer>
{allowGraphActions && (
<EdgeLabelRenderer>
<div
className={cn(
'nopan nodrag hover:scale-125',
data?._hovering ? 'block' : 'hidden',
open && '!block',
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
)}
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
opacity: data._waitingRun ? 0.7 : 1,
}}
>
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
asChild
onSelect={handleInsert}
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)}
triggerClassName={() => 'hover:scale-150 transition-all'}
/>
</div>
</EdgeLabelRenderer>
)}
</>
)
}

View File

@ -23,6 +23,7 @@ export type AvailableNodesMetaData = {
nodesMap?: Record<BlockEnum, NodeDefault<any>>
}
export type CommonHooksFnMap = {
interactionMode?: 'default' | 'subgraph'
doSyncWorkflowDraft: (
notRefreshWhenSyncError?: boolean,
callback?: {
@ -76,6 +77,7 @@ export type Shape = {
} & CommonHooksFnMap
export const createHooksStore = ({
interactionMode = 'default',
doSyncWorkflowDraft = async () => noop(),
syncWorkflowDraftWhenPageClose = noop,
handleRefreshWorkflowDraft = noop,
@ -118,6 +120,7 @@ export const createHooksStore = ({
}: Partial<Shape>) => {
return createStore<Shape>(set => ({
refreshAll: props => set(state => ({ ...state, ...props })),
interactionMode,
doSyncWorkflowDraft,
syncWorkflowDraftWhenPageClose,
handleRefreshWorkflowDraft,

View File

@ -17,7 +17,7 @@ import {
} from '../utils'
import { useWorkflowHistoryStore } from '../workflow-history-store'
export const useShortcuts = (): void => {
export const useShortcuts = (enabled = true): void => {
const {
handleNodesCopy,
handleNodesPaste,
@ -66,13 +66,17 @@ export const useShortcuts = (): void => {
}
const shouldHandleShortcut = useCallback((e: KeyboardEvent) => {
if (!enabled)
return false
return !isEventTargetInputArea(e.target as HTMLElement)
}, [])
}, [enabled])
const shouldHandleCopy = useCallback(() => {
if (!enabled)
return false
const selection = document.getSelection()
return !selection || selection.isCollapsed
}, [])
}, [enabled])
useKeyPress(['delete', 'backspace'], (e) => {
if (shouldHandleShortcut(e)) {
@ -282,6 +286,8 @@ export const useShortcuts = (): void => {
// Listen for zen toggle event from /zen command
useEffect(() => {
if (!enabled)
return
const handleZenToggle = () => {
handleToggleMaximizeCanvas()
}
@ -290,5 +296,5 @@ export const useShortcuts = (): void => {
return () => {
window.removeEventListener(ZEN_TOGGLE_EVENT, handleZenToggle)
}
}, [handleToggleMaximizeCanvas])
}, [enabled, handleToggleMaximizeCanvas])
}

View File

@ -498,13 +498,9 @@ export const useNodesReadOnly = () => {
const isRestoring = useStore(s => s.isRestoring)
const getNodesReadOnly = useCallback((): boolean => {
const {
workflowRunningData,
historyWorkflowData,
isRestoring,
} = workflowStore.getState()
const state = workflowStore.getState()
return !!(workflowRunningData?.result.status === WorkflowRunningStatus.Running || historyWorkflowData || isRestoring)
return !!(state.workflowRunningData?.result.status === WorkflowRunningStatus.Running || state.historyWorkflowData || state.isRestoring)
}, [workflowStore])
return {

View File

@ -2,6 +2,7 @@
import type { FC } from 'react'
import type {
NodeMouseHandler,
Viewport,
} from 'reactflow'
import type { Shape as HooksStoreShape } from './hooks-store'
@ -102,6 +103,7 @@ import {
} from './store'
import SyncingDataModal from './syncing-data-modal'
import {
BlockEnum,
ControlMode,
} from './types'
import { setupScrollToNodeListener } from './utils/node-navigation'
@ -134,6 +136,9 @@ export type WorkflowProps = {
viewport?: Viewport
children?: React.ReactNode
onWorkflowDataUpdate?: (v: any) => void
allowSelectionWhenReadOnly?: boolean
canvasReadOnly?: boolean
interactionMode?: 'default' | 'subgraph'
}
export const Workflow: FC<WorkflowProps> = memo(({
nodes: originalNodes,
@ -141,6 +146,9 @@ export const Workflow: FC<WorkflowProps> = memo(({
viewport,
children,
onWorkflowDataUpdate,
allowSelectionWhenReadOnly = false,
canvasReadOnly = false,
interactionMode = 'default',
}) => {
const workflowContainerRef = useRef<HTMLDivElement>(null)
const workflowStore = useWorkflowStore()
@ -196,7 +204,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
if (!isEqual(oldData, nodesData)) {
setNodesInStore(nodes)
}
}, [setNodesInStore, workflowStore])
}, [setNodesInStore])
useEffect(() => {
setNodesOnlyChangeWithData(currentNodes as Node[])
}, [currentNodes, setNodesOnlyChangeWithData])
@ -328,7 +336,8 @@ export const Workflow: FC<WorkflowProps> = memo(({
},
})
useShortcuts()
const isSubGraph = interactionMode === 'subgraph'
useShortcuts(!isSubGraph)
// Initialize workflow node search functionality
useWorkflowSearch()
@ -382,6 +391,16 @@ export const Workflow: FC<WorkflowProps> = memo(({
}
}
const handleNodeClickInMode = useCallback<NodeMouseHandler>(
(event, node) => {
if (isSubGraph && node.data.type !== BlockEnum.LLM)
return
handleNodeClick(event, node)
},
[handleNodeClick, isSubGraph],
)
return (
<div
id="workflow-container"
@ -393,18 +412,18 @@ export const Workflow: FC<WorkflowProps> = memo(({
ref={workflowContainerRef}
>
<SyncingDataModal />
<CandidateNode />
{!isSubGraph && <CandidateNode />}
<div
className="pointer-events-none absolute left-0 top-0 z-10 flex w-12 items-center justify-center p-1 pl-2"
style={{ height: controlHeight }}
>
<Control />
{!isSubGraph && <Control />}
</div>
<Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />
<PanelContextmenu />
<NodeContextmenu />
<SelectionContextmenu />
<HelpLine />
{!isSubGraph && <Operator handleRedo={handleHistoryForward} handleUndo={handleHistoryBack} />}
{!isSubGraph && <PanelContextmenu />}
{!isSubGraph && <NodeContextmenu />}
{!isSubGraph && <SelectionContextmenu />}
{!isSubGraph && <HelpLine />}
{
!!showConfirm && (
<Confirm
@ -427,38 +446,38 @@ export const Workflow: FC<WorkflowProps> = memo(({
onNodeDragStop={handleNodeDragStop}
onNodeMouseEnter={handleNodeEnter}
onNodeMouseLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
onConnect={handleNodeConnect}
onConnectStart={handleNodeConnectStart}
onConnectEnd={handleNodeConnectEnd}
onNodeClick={handleNodeClickInMode}
onNodeContextMenu={isSubGraph ? undefined : handleNodeContextMenu}
onConnect={isSubGraph ? undefined : handleNodeConnect}
onConnectStart={isSubGraph ? undefined : handleNodeConnectStart}
onConnectEnd={isSubGraph ? undefined : handleNodeConnectEnd}
onEdgeMouseEnter={handleEdgeEnter}
onEdgeMouseLeave={handleEdgeLeave}
onEdgesChange={handleEdgesChange}
onSelectionStart={handleSelectionStart}
onSelectionChange={handleSelectionChange}
onSelectionDrag={handleSelectionDrag}
onPaneContextMenu={handlePaneContextMenu}
onSelectionContextMenu={handleSelectionContextMenu}
onSelectionStart={isSubGraph ? undefined : handleSelectionStart}
onSelectionChange={isSubGraph ? undefined : handleSelectionChange}
onSelectionDrag={isSubGraph ? undefined : handleSelectionDrag}
onPaneContextMenu={isSubGraph ? undefined : handlePaneContextMenu}
onSelectionContextMenu={isSubGraph ? undefined : handleSelectionContextMenu}
connectionLineComponent={CustomConnectionLine}
// NOTE: For LOOP node, how to distinguish between ITERATION and LOOP here? Maybe both are the same?
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable={!nodesReadOnly}
nodesConnectable={!nodesReadOnly}
nodesFocusable={!nodesReadOnly}
edgesFocusable={!nodesReadOnly}
panOnScroll={controlMode === ControlMode.Pointer && !workflowReadOnly}
panOnDrag={controlMode === ControlMode.Hand || [1]}
zoomOnPinch={true}
zoomOnScroll={true}
zoomOnDoubleClick={true}
nodesDraggable={!(nodesReadOnly || canvasReadOnly || isSubGraph)}
nodesConnectable={!(nodesReadOnly || canvasReadOnly || isSubGraph)}
nodesFocusable={allowSelectionWhenReadOnly ? true : !nodesReadOnly}
edgesFocusable={isSubGraph ? false : (allowSelectionWhenReadOnly ? true : !nodesReadOnly)}
panOnScroll={!isSubGraph && controlMode === ControlMode.Pointer && !workflowReadOnly}
panOnDrag={!isSubGraph && (controlMode === ControlMode.Hand || [1])}
selectionOnDrag={!isSubGraph && controlMode === ControlMode.Pointer && !workflowReadOnly && !canvasReadOnly}
zoomOnPinch={!isSubGraph}
zoomOnScroll={!isSubGraph}
zoomOnDoubleClick={!isSubGraph}
isValidConnection={isValidConnection}
selectionKeyCode={null}
selectionMode={SelectionMode.Partial}
selectionOnDrag={controlMode === ControlMode.Pointer && !workflowReadOnly}
minZoom={0.25}
>
<Background

View File

@ -13,6 +13,7 @@ import {
Stop,
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import Tooltip from '@/app/components/base/tooltip'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { useWorkflowStore } from '@/app/components/workflow/store'
import {
useNodesInteractions,
@ -30,12 +31,18 @@ const NodeControl: FC<NodeControlProps> = ({
const [open, setOpen] = useState(false)
const { handleNodeSelect } = useNodesInteractions()
const workflowStore = useWorkflowStore()
const interactionMode = useHooksStore(s => s.interactionMode)
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)
}, [])
const isChildNode = !!(data.isInIteration || data.isInLoop)
const allowNodeMenu = interactionMode !== 'subgraph'
const canSingleRun = canRunBySingle(data.type, isChildNode)
if (!allowNodeMenu && !canSingleRun)
return null
return (
<div
className={`
@ -50,7 +57,7 @@ const NodeControl: FC<NodeControlProps> = ({
onClick={e => e.stopPropagation()}
>
{
canRunBySingle(data.type, isChildNode) && (
canSingleRun && (
<div
className={`flex h-5 w-5 items-center justify-center rounded-md ${isSingleRunning && 'cursor-pointer hover:bg-state-base-hover'}`}
onClick={() => {
@ -80,13 +87,15 @@ const NodeControl: FC<NodeControlProps> = ({
</div>
)
}
<PanelOperator
id={id}
data={data}
offset={0}
onOpenChange={handleOpenChange}
triggerClassName="!w-5 !h-5"
/>
{allowNodeMenu && (
<PanelOperator
id={id}
data={data}
offset={0}
onOpenChange={handleOpenChange}
triggerClassName="!w-5 !h-5"
/>
)}
</div>
</div>
)

View File

@ -12,6 +12,7 @@ import {
Handle,
Position,
} from 'reactflow'
import { useHooksStore } from '@/app/components/workflow/hooks-store'
import { cn } from '@/utils/classnames'
import BlockSelector from '../../../block-selector'
import {
@ -46,6 +47,8 @@ export const NodeTargetHandle = memo(({
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const interactionMode = useHooksStore(s => s.interactionMode)
const allowGraphActions = interactionMode !== 'subgraph'
const connected = data._connectedTargetHandleIds?.includes(handleId)
const { availablePrevBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const isConnectable = !!availablePrevBlocks.length
@ -55,9 +58,9 @@ export const NodeTargetHandle = memo(({
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!connected)
if (!connected && allowGraphActions)
setOpen(v => !v)
}, [connected])
}, [allowGraphActions, connected])
const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => {
handleNodeAdd(
{
@ -91,11 +94,11 @@ export const NodeTargetHandle = memo(({
|| data.type === BlockEnum.TriggerPlugin) && 'opacity-0',
handleClassName,
)}
isConnectable={isConnectable}
onClick={handleHandleClick}
isConnectable={allowGraphActions && isConnectable}
onClick={allowGraphActions ? handleHandleClick : undefined}
>
{
!connected && isConnectable && !getNodesReadOnly() && (
allowGraphActions && !connected && isConnectable && !getNodesReadOnly() && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
@ -135,6 +138,8 @@ export const NodeSourceHandle = memo(({
const [open, setOpen] = useState(false)
const { handleNodeAdd } = useNodesInteractions()
const { getNodesReadOnly } = useNodesReadOnly()
const interactionMode = useHooksStore(s => s.interactionMode)
const allowGraphActions = interactionMode !== 'subgraph'
const { availableNextBlocks } = useAvailableBlocks(data.type, data.isInIteration || data.isInLoop)
const isConnectable = !!availableNextBlocks.length
const isChatMode = useIsChatMode()
@ -145,8 +150,9 @@ export const NodeSourceHandle = memo(({
}, [])
const handleHandleClick = useCallback((e: MouseEvent) => {
e.stopPropagation()
setOpen(v => !v)
}, [])
if (allowGraphActions)
setOpen(v => !v)
}, [allowGraphActions])
const handleSelect = useCallback((type: BlockEnum, pluginDefaultValue?: PluginDefaultValue) => {
handleNodeAdd(
{
@ -161,7 +167,7 @@ export const NodeSourceHandle = memo(({
}, [handleNodeAdd, id, handleId])
useEffect(() => {
if (!shouldAutoOpenStartNodeSelector)
if (!shouldAutoOpenStartNodeSelector || !allowGraphActions)
return
if (isChatMode) {
@ -198,8 +204,8 @@ export const NodeSourceHandle = memo(({
!connected && 'after:opacity-0',
handleClassName,
)}
isConnectable={isConnectable}
onClick={handleHandleClick}
isConnectable={allowGraphActions && isConnectable}
onClick={allowGraphActions ? handleHandleClick : undefined}
>
<div className="absolute -top-1 left-1/2 hidden -translate-x-1/2 -translate-y-full rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg p-1.5 shadow-lg group-hover/handle:block">
<div className="system-xs-regular text-text-tertiary">
@ -214,7 +220,7 @@ export const NodeSourceHandle = memo(({
</div>
</div>
{
isConnectable && !getNodesReadOnly() && (
allowGraphActions && isConnectable && !getNodesReadOnly() && (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}

View File

@ -231,6 +231,8 @@ const BasePanel: FC<BasePanelProps> = ({
} = useNodesMetaData()
const configsMap = useHooksStore(s => s.configsMap)
const interactionMode = useHooksStore(s => s.interactionMode)
const allowGraphActions = interactionMode !== 'subgraph'
const {
isShowSingleRun,
hideSingleRun,
@ -514,9 +516,9 @@ const BasePanel: FC<BasePanelProps> = ({
</Tooltip>
)
}
<HelpLink nodeType={data.type} />
<PanelOperator id={id} data={data} showHelpLink={false} />
<div className="mx-3 h-3.5 w-[1px] bg-divider-regular" />
{allowGraphActions && <HelpLink nodeType={data.type} />}
{allowGraphActions && <PanelOperator id={id} data={data} showHelpLink={false} />}
{allowGraphActions && <div className="mx-3 h-3.5 w-[1px] bg-divider-regular" />}
<div
className="flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={() => handleNodeSelect(id, true)}
@ -623,7 +625,7 @@ const BasePanel: FC<BasePanelProps> = ({
</div>
<Split />
{
hasRetryNode(data.type) && (
allowGraphActions && hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
@ -631,7 +633,7 @@ const BasePanel: FC<BasePanelProps> = ({
)
}
{
hasErrorHandleNode(data.type) && (
allowGraphActions && hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
@ -639,7 +641,7 @@ const BasePanel: FC<BasePanelProps> = ({
)
}
{
!!availableNextBlocks.length && (
allowGraphActions && !!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="system-sm-semibold-uppercase mb-1 flex items-center text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
@ -651,7 +653,7 @@ const BasePanel: FC<BasePanelProps> = ({
</div>
)
}
{readmeEntranceComponent}
{allowGraphActions ? readmeEntranceComponent : null}
</div>
)}

View File

@ -1,12 +1,19 @@
'use client'
import type { FC } from 'react'
import type { SubGraphModalProps } from './types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types'
import type { Node, PromptItem } from '@/app/components/workflow/types'
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react'
import { RiCloseLine } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { Fragment, memo } from 'react'
import { Fragment, memo, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useStoreApi } from 'reactflow'
import { Agent } from '@/app/components/base/icons/src/vender/workflow'
import { useNodesSyncDraft } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import { PromptRole } from '@/app/components/workflow/types'
import SubGraphCanvas from './sub-graph-canvas'
const SubGraphModal: FC<SubGraphModalProps> = ({
@ -19,6 +26,76 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
agentNodeId,
}) => {
const { t } = useTranslation()
const reactflowStore = useStoreApi()
const workflowNodes = useStore(state => state.nodes)
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const extractorNodeId = `${toolNodeId}_ext_${paramKey}`
const extractorNode = useMemo(() => {
return workflowNodes.find(node => node.id === extractorNodeId) as Node<LLMNodeType> | undefined
}, [extractorNodeId, workflowNodes])
const toolNode = useMemo(() => {
return workflowNodes.find(node => node.id === toolNodeId)
}, [toolNodeId, workflowNodes])
const toolParamValue = (toolNode?.data as ToolNodeType | undefined)?.tool_parameters?.[paramKey]?.value as string | undefined
const getSystemPromptText = useCallback((promptTemplate?: PromptItem[] | PromptItem) => {
if (!promptTemplate)
return ''
if (Array.isArray(promptTemplate)) {
const systemPrompt = promptTemplate.find(item => item.role === PromptRole.system)
return systemPrompt?.text || ''
}
return promptTemplate.text || ''
}, [])
const handleSave = useCallback((subGraphNodes: any[], _edges: any[]) => {
const extractorNodeData = subGraphNodes.find(node => node.id === extractorNodeId)
if (!extractorNodeData)
return
const systemPromptText = getSystemPromptText(extractorNodeData.data?.prompt_template)
const placeholder = `{{@${agentNodeId}.context@}}`
const nextValue = `${placeholder}${systemPromptText}`
const { getNodes, setNodes } = reactflowStore.getState()
const nextNodes = getNodes().map((node) => {
if (node.id === extractorNodeId) {
return {
...node,
hidden: true,
data: {
...node.data,
...extractorNodeData.data,
parent_node_id: toolNodeId,
},
}
}
if (node.id === toolNodeId) {
const toolData = node.data as ToolNodeType
if (!toolData.tool_parameters?.[paramKey])
return node
return {
...node,
data: {
...toolData,
tool_parameters: {
...toolData.tool_parameters,
[paramKey]: {
...toolData.tool_parameters[paramKey],
value: nextValue,
},
},
},
}
}
return node
})
setNodes(nextNodes)
// Trigger main graph draft sync to persist changes to backend
handleSyncWorkflowDraft()
}, [agentNodeId, extractorNodeId, getSystemPromptText, handleSyncWorkflowDraft, paramKey, reactflowStore, toolNodeId])
return (
<Transition appear show={isOpen} as={Fragment}>
@ -58,6 +135,9 @@ const SubGraphModal: FC<SubGraphModalProps> = ({
sourceVariable={sourceVariable}
agentNodeId={agentNodeId}
agentName={agentName}
extractorNode={extractorNode}
toolParamValue={toolParamValue}
onSave={handleSave}
/>
</div>
</DialogPanel>

View File

@ -10,6 +10,9 @@ const SubGraphCanvas: FC<SubGraphCanvasProps> = ({
sourceVariable,
agentNodeId,
agentName,
extractorNode,
toolParamValue,
onSave,
}) => {
return (
<div className="h-full w-full">
@ -19,6 +22,9 @@ const SubGraphCanvas: FC<SubGraphCanvasProps> = ({
sourceVariable={sourceVariable}
agentNodeId={agentNodeId}
agentName={agentName}
extractorNode={extractorNode}
toolParamValue={toolParamValue}
onSave={onSave}
/>
</div>
)

View File

@ -1,11 +1,14 @@
import type { ValueSelector } from '@/app/components/workflow/types'
import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types'
import type { Edge as WorkflowEdge, Node as WorkflowNode } from '@/app/components/workflow/types'
type WorkflowValueSelector = string[]
export type SubGraphModalProps = {
isOpen: boolean
onClose: () => void
toolNodeId: string
paramKey: string
sourceVariable: ValueSelector
sourceVariable: WorkflowValueSelector
agentName: string
agentNodeId: string
}
@ -13,7 +16,10 @@ export type SubGraphModalProps = {
export type SubGraphCanvasProps = {
toolNodeId: string
paramKey: string
sourceVariable: ValueSelector
sourceVariable: WorkflowValueSelector
agentNodeId: string
agentName: string
extractorNode?: WorkflowNode<LLMNodeType>
toolParamValue?: string
onSave?: (nodes: WorkflowNode[], edges: WorkflowEdge[]) => void
}