Bladeren bron

fix: 修改节点之间的问题

jiaxing.liao 1 dag geleden
bovenliggende
commit
a47d7ebcfc

+ 51 - 10
apps/web/src/features/setter/index.vue

@@ -127,16 +127,20 @@ provide('nodeVars', nodeVars)
 			<div class="content">
 				<el-tabs>
 					<el-tab-pane label="设置">
-						<component
-							:is="setter"
-							:key="node?.id"
-							:id="node?.id"
-							:data="node?.data"
-							@update="onUpdate"
-						></component>
+						<div class="tab-pane tab-pane--fill">
+							<component
+								:is="setter"
+								:key="node?.id"
+								:id="node?.id"
+								:data="node?.data"
+								@update="onUpdate"
+							></component>
+						</div>
 					</el-tab-pane>
 					<el-tab-pane label="上次运行">
-						<NodeLog :node="node" />
+						<div class="tab-pane tab-pane--scroll">
+							<NodeLog :node="node" />
+						</div>
 					</el-tab-pane>
 				</el-tabs>
 			</div>
@@ -184,8 +188,8 @@ provide('nodeVars', nodeVars)
 	/* 内容区 */
 	.drawer .content {
 		flex: 1;
-		// padding: 16px;
-		overflow-y: auto;
+		min-height: 0;
+		overflow: hidden;
 	}
 
 	:deep(.el-collapse-item__header) {
@@ -199,5 +203,42 @@ provide('nodeVars', nodeVars)
 	:deep(.el-tabs__nav-scroll) {
 		padding-left: 20px;
 	}
+	:deep(.el-tabs) {
+		height: 100%;
+		display: flex;
+		flex-direction: column;
+	}
+	:deep(.el-tabs__header) {
+		flex-shrink: 0;
+		margin-bottom: 0;
+	}
+	:deep(.el-tabs__content) {
+		flex: 1;
+		min-height: 0;
+		overflow: hidden;
+	}
+	:deep(.el-tab-pane) {
+		height: 100%;
+	}
+
+	.tab-pane {
+		height: 100%;
+		min-height: 0;
+	}
+
+	.tab-pane--fill {
+		display: flex;
+		flex-direction: column;
+		overflow-x: hidden;
+		overflow-y: auto;
+	}
+
+	:deep(.tab-pane--fill > .el-scrollbar) {
+		height: 100%;
+	}
+
+	.tab-pane--scroll {
+		overflow-y: auto;
+	}
 }
 </style>

+ 71 - 47
apps/web/src/views/Editor.vue

@@ -66,6 +66,7 @@
 						@delete:connection="handleDeleteEdge"
 						@dragover="onDragOver"
 						@dragleave="onDragLeave"
+						@create:connection:cancelled="onConnectionOpenNodeLibary"
 						class="bg-#f5f5f5"
 					>
 						<Toolbar
@@ -99,6 +100,7 @@ import { agent } from '@repo/api-service'
 
 import RunWorkflow from '@/components/RunWorkflow/index.vue'
 import EditorFooter from '@/features/editorFooter/index.vue'
+import NodeLibary from '@/features/nodeLibary/index.vue'
 import Toolbar from '@/features/toolbar/index.vue'
 import Setter from '@/features/setter/index.vue'
 
@@ -108,7 +110,7 @@ import { Workflow, useDragAndDrop } from '@repo/workflow'
 
 import { dayjs, ElMessage, ElMessageBox } from 'element-plus'
 
-import type { IWorkflow, XYPosition, Connection } from '@repo/workflow'
+import type { IWorkflow, XYPosition, Connection, ConnectStartEvent } from '@repo/workflow'
 import { useRunnerStore } from '@/store/modules/runner.store'
 
 const layout = inject<{ setMainStyle: (style: CSSProperties) => void }>('layout')
@@ -209,7 +211,7 @@ const toWorkflowNode = (node: any) => {
 		width,
 		height,
 		zIndex: node?.zIndex ?? schema?.zIndex ?? 1,
-		selected: !!node?.selected,
+		selected: false,
 		data: {
 			...(schema?.data || {}),
 			...(node?.data || {}),
@@ -222,48 +224,50 @@ const toWorkflowNode = (node: any) => {
 	}
 }
 
-const resolveOverlapPositions = (nodes: any[]) => {
-	const positionCountMap = new Map<string, number>()
-	const gapX = 220
-	const gapY = 140
-
-	return nodes.map((node) => {
-		const x = Number(node?.position?.x ?? 20)
-		const y = Number(node?.position?.y ?? 30)
-		const key = `${x},${y}`
-		const currentCount = positionCountMap.get(key) ?? 0
-		positionCountMap.set(key, currentCount + 1)
+const normalizeEdgeEndpoints = (
+	edge: { source: string; target: string },
+	nodes: Array<{ id: string; parentId?: string }>
+) => {
+	const sourceNode = nodes.find((node) => node.id === edge.source)
+	const targetNode = nodes.find((node) => node.id === edge.target)
 
-		if (currentCount === 0) {
-			return node
-		}
+	const sourceParentId = sourceNode?.parentId || ''
+	const targetParentId = targetNode?.parentId || ''
 
-		const row = Math.floor(currentCount / 4)
-		const col = currentCount % 4
-		const nextPosition = {
-			x: x + col * gapX,
-			y: y + row * gapY
+	if (sourceParentId && sourceParentId !== targetParentId) {
+		return {
+			source: sourceParentId,
+			target: edge.target
 		}
+	}
 
+	if (targetParentId && targetParentId !== sourceParentId) {
 		return {
-			...node,
-			position: nextPosition,
-			data: {
-				...(node?.data || {}),
-				position: nextPosition
-			}
+			source: edge.source,
+			target: targetParentId
 		}
-	})
+	}
+
+	return edge
 }
 
-const toWorkflowEdge = (edge: any, index: number) => {
+const toWorkflowEdge = (
+	edge: any,
+	index: number,
+	nodes: Array<{ id: string; parentId?: string }> = []
+) => {
 	if (!edge || typeof edge !== 'object' || !edge.source || !edge.target) {
 		return null
 	}
 
+	const normalizedEdge = normalizeEdgeEndpoints(edge, nodes)
+
 	return {
 		...edge,
-		id: edge.id || `edge-${edge.source}-${edge.target}-${index}`,
+		...normalizedEdge,
+		sourceHandle: edge.sourceHandle === 'source' ? `${edge.source}-source` : edge.sourceHandle,
+		targetHandle: edge.targetHandle === 'target' ? `${edge.target}-target` : edge.targetHandle,
+		id: edge.id || `edge-${normalizedEdge.source}-${normalizedEdge.target}-${index}`,
 		type: 'canvas-edge',
 		data: edge.data || {}
 	}
@@ -284,20 +288,14 @@ const loadAgentWorkflow = async (agentId: string) => {
 			throw new Error('获取智能体信息失败')
 		}
 
-		const mappedNodes = (result.nodes || []).map(toWorkflowNode)
-		const positionedNodes = resolveOverlapPositions(mappedNodes)
+		const normalizedNodes = (result.nodes || []).map(toWorkflowNode)
 
 		workflow.value = {
-			id: result.id || agentId,
-			name: result.name || 'workflow_1',
-			created: dayjs().format('MM 月 DD 日'),
-			nodes: positionedNodes,
-			edges: (result.edges || []).map(toWorkflowEdge).filter(Boolean),
-			tags: workflow.value?.tags || [],
-			conversation_variables: result.conversation_variables || [],
-			env_variables: result.env_variables || [],
-			profilePhoto: result.profilePhoto,
-			viewPort: result.viewPort
+			...(result as unknown as IWorkflow),
+			nodes: normalizedNodes,
+			edges: (result.edges || []).map((edge: any, index: number) =>
+				toWorkflowEdge(edge, index, normalizedNodes)
+			)
 		}
 		await nextTick()
 	} catch (error) {
@@ -436,7 +434,8 @@ const handleRunSelectedNode = async () => {
 		const response = await agent.postAgentDoExecute({
 			appAgentId: workflow.value.id,
 			start_node_id: nodeID.value,
-			is_debugger: true
+			is_debugger: true,
+			params: {}
 		})
 		const agentRunnerKey = response?.result
 		if (agentRunnerKey) {
@@ -460,7 +459,8 @@ const handleRunNode = async (id: string) => {
 		const response = await agent.postAgentDoExecute({
 			appAgentId: workflow.value.id,
 			start_node_id: id,
-			is_debugger: true
+			is_debugger: true,
+			params: {}
 		})
 		const agentRunnerKey = response?.result
 		if (agentRunnerKey) {
@@ -548,7 +548,6 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 				ElMessage.error('新增节点失败')
 			})
 	}
-	console.log(workflow.value?.nodes, 'workflow.nodes')
 }
 const handleNodeClick = (id: string, _position: XYPosition) => {
 	nodeID.value = id
@@ -589,8 +588,13 @@ const handleDelete = () => {
 /**
  * 创建连线
  */
+const normalizeConnectionEndpoints = (connection: Connection) => {
+	return normalizeEdgeEndpoints(connection, workflow.value?.nodes || [])
+}
+
 const onCreateConnection = async (connection: Connection) => {
-	const { source, target, sourceHandle } = connection
+	const { sourceHandle } = connection
+	const { source, target } = normalizeConnectionEndpoints(connection)
 
 	const params: {
 		appAgentId: string
@@ -605,7 +609,8 @@ const onCreateConnection = async (connection: Connection) => {
 		zIndex: 1
 	}
 
-	if (sourceHandle && sourceHandle !== 'source' && sourceHandle !== 'target') {
+	// 需要传sourceHandle的情况是中间带"_"
+	if (sourceHandle && sourceHandle.includes('_')) {
 		params.sourceHandle = sourceHandle
 	}
 
@@ -707,6 +712,14 @@ const handleUpdateNodeProps = (id: string, attrs: Record<string, unknown>) => {
 		} else {
 			Object.assign(node, attrs)
 		}
+		agent
+			.postAgentDoUpdateAgentNode(buildUpdateNodePayload(node))
+			.then((response) => {
+				handleApiResult(response, undefined, '更新节点失败')
+			})
+			.catch((error) => {
+				console.error('postDoUpdateAgentNode error', error)
+			})
 	}
 }
 
@@ -754,6 +767,17 @@ const handleChangeEnvVars = async (
 	await loadAgentWorkflow(workflow.value.id)
 }
 
+/**
+ * 连线取消时
+ */
+const onConnectionOpenNodeLibary = (event: {
+	handle: ConnectStartEvent
+	position: XYPosition
+	e: MouseEvent
+}) => {
+	// TODO: 在这个对应位置(可以需要转化成实际坐标)打开节点弹窗,选中节点在对应位置添加节点
+}
+
 onBeforeUnmount(() => {
 	if (saveAgentTimer.value) window.clearTimeout(saveAgentTimer.value)
 	if (saveVarsTimer.value) window.clearTimeout(saveVarsTimer.value)

+ 4 - 1
packages/workflow/index.ts

@@ -1,5 +1,8 @@
 import Workflow from './src/Workflow.vue'
 import useDragAndDrop from './src/hooks/useDragAndDrop'
+import { useWorkflowContext } from './src/hooks/useWorkflowContext'
+import { useVueFlowContext } from './src/hooks/useVueFlowContext'
+import { useCanvasNodeContext } from './src/hooks/useCanvasNodeContext'
 
-export { Workflow, useDragAndDrop }
+export { Workflow, useDragAndDrop, useWorkflowContext, useVueFlowContext, useCanvasNodeContext }
 export * from './src/Interface'

+ 3 - 3
packages/workflow/src/Workflow.vue

@@ -15,9 +15,10 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, provide } from 'vue'
+import { ref, computed, toRef } from 'vue'
 import Canvas from './components/Canvas.vue'
 import type { IWorkflow } from './Interface'
+import { provideWorkflowContext } from './hooks/useWorkflowContext'
 
 defineOptions({
 	inheritAttrs: false
@@ -49,6 +50,5 @@ defineExpose({
 	}
 })
 
-provide('workflow', props.workflow)
-provide('vueflow', vueflowRef.value)
+provideWorkflowContext(toRef(props, 'workflow'))
 </script>

+ 73 - 21
packages/workflow/src/components/Canvas.vue

@@ -8,7 +8,7 @@ import type {
 } from '../Interface'
 import type { NodeMouseEvent, Connection, NodeDragEvent } from '@vue-flow/core'
 
-import { ref, onMounted, computed, provide } from 'vue'
+import { ref, computed, toRef } from 'vue'
 import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core'
 import { MiniMap } from '@vue-flow/minimap'
 import { useThrottleFn } from '@vueuse/core'
@@ -18,9 +18,11 @@ import CanvasEdge from './elements/edges/CanvasEdge.vue'
 import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'
 import CanvasBackground from './elements/background/CanvasBackground.vue'
 import CanvasControlBar from './elements/control-bar/CanvasControlBar.vue'
+import { provideVueFlowContext } from '../hooks/useVueFlowContext'
 
 defineOptions({
-	name: 'workflow-canvas'
+	name: 'workflow-canvas',
+	inheritAttrs: false
 })
 
 const emit = defineEmits<{
@@ -42,7 +44,9 @@ const emit = defineEmits<{
 	'update:has-range-selection': [isActive: boolean]
 	'click:node': [id: string, position: XYPosition]
 	'dblclick:node': [id: string, position: XYPosition]
-	'click:node:add': [id: string, handle: string]
+	'click:node:add': [
+		payload: { nodeId: string; handle: string; position: XYPosition; event?: MouseEvent }
+	]
 	'initialized:nodes': []
 	'run:node': [id: string]
 	'copy:production:url': [id: string]
@@ -64,9 +68,7 @@ const emit = defineEmits<{
 	'create:connection': [connection: Connection]
 	'create:connection:end': [connection: Connection, event?: MouseEvent]
 	'create:connection:cancelled': [
-		handle: ConnectStartEvent,
-		position: XYPosition,
-		event?: MouseEvent
+		payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }
 	]
 	'click:connection:add': [connection: Connection]
 }>()
@@ -96,6 +98,7 @@ const props = withDefaults(
 const getNodes = computed(() =>
 	props.hideChildNode ? props.nodes.filter((node) => !node?.parentId) : props.nodes
 )
+const defaultViewport = { x: 0, y: 0, zoom: 1 }
 
 const showMinimap = ref(false)
 const vueFlow = useVueFlow(props.id)
@@ -107,6 +110,7 @@ const {
 	zoomIn,
 	zoomOut,
 	fitView,
+	setViewport,
 	zoomTo,
 	onNodeMouseEnter,
 	onNodeMouseLeave,
@@ -122,6 +126,31 @@ const nodeById = computed((): Record<string, IWorkflowNode> => {
 	}, {})
 })
 
+const normalizedEdges = computed(() =>
+	props.edges.map((edge) => {
+		const sourceNode = nodeById.value[edge.source]
+		const targetNode = nodeById.value[edge.target]
+		const sourceParentId = sourceNode?.parentId || ''
+		const targetParentId = targetNode?.parentId || ''
+
+		if (sourceParentId && sourceParentId !== targetParentId) {
+			return {
+				...edge,
+				source: sourceParentId
+			}
+		}
+
+		if (targetParentId && targetParentId !== sourceParentId) {
+			return {
+				...edge,
+				target: targetParentId
+			}
+		}
+
+		return edge
+	})
+)
+
 /**
  * Edge and Nodes Hovering
  */
@@ -283,7 +312,11 @@ function onConnectEnd(event?: MouseEvent) {
 	if (connectedHandle.value) {
 		emit('create:connection:end', connectedHandle.value, event)
 	} else if (connectingHandle.value) {
-		emit('create:connection:cancelled', connectingHandle.value, getProjectedPosition(event), event)
+		emit('create:connection:cancelled', {
+			handle: connectingHandle.value,
+			position: getProjectedPosition(event),
+			event
+		})
 	}
 
 	connectedHandle.value = undefined
@@ -307,25 +340,26 @@ function onRunNode(id: string) {
 }
 
 let loaded = false
-function onNodesInitialized() {
+async function onNodesInitialized() {
 	if (!loaded) {
-		props.zoomToFit && onZoomToFit()
-		onResetZoom()
+		if (props.zoomToFit) {
+			onZoomToFit()
+			onResetZoom()
+		} else {
+			await setViewport(defaultViewport)
+		}
 		loaded = true
 		emit('initialized:nodes')
 	}
 }
 
-onMounted(() => {
-	fitView()
-})
-
-provide('vueflow', {
-	id: props.id,
-	nodes: props.nodes,
-	edges: props.edges,
+provideVueFlowContext({
+	id: toRef(props, 'id'),
+	nodes: toRef(props, 'nodes'),
+	edges: toRef(props, 'edges'),
 	vueFlow,
-	nodeMap: props.nodeMap
+	nodeMap: toRef(props, 'nodeMap'),
+	connectingHandle
 })
 
 defineExpose({
@@ -337,7 +371,9 @@ defineExpose({
 	<VueFlow
 		:id="id"
 		:nodes="getNodes"
-		:edges="edges"
+		:edges="normalizedEdges"
+		:default-viewport="defaultViewport"
+		:fit-view-on-init="zoomToFit"
 		:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
 		:connection-radius="60"
 		snap-to-grid
@@ -363,8 +399,24 @@ defineExpose({
 					:hovered="nodesHoveredById[nodeProps.id]"
 					@move="onUpdateNodePosition"
 					@update="onUpdateNodeAttrs"
-					@add-inner-node="emit('click:node:add', nodeProps.id, 'inner')"
+					@add-node="
+						emit('click:node:add', {
+							nodeId: $event.nodeId,
+							handle: $event.handleId,
+							position: getProjectedPosition($event.event),
+							event: $event.event
+						})
+					"
+					@add-inner-node="
+						emit('click:node:add', {
+							nodeId: nodeProps.id,
+							handle: 'inner',
+							position: nodeProps.position,
+							event: undefined
+						})
+					"
 					@add-inner-edge="emit('create:connection:end', $event)"
+					@create-connection-cancelled="emit('create:connection:cancelled', $event)"
 					@delete="onDeleteNode"
 					@run="onRunNode"
 				/>

+ 15 - 2
packages/workflow/src/components/elements/handles/CanvasHandle.vue

@@ -4,6 +4,7 @@ import { Handle, type Position, type ValidConnectionFunc } from '@vue-flow/core'
 import HandlePort from './HandlePort.vue'
 
 const props = defineProps<{
+	nodeId?: string
 	handleId: string
 	handleClasses?: string | string[]
 	position: Position
@@ -15,6 +16,10 @@ const props = defineProps<{
 	connectionsCount: number
 }>()
 
+const emit = defineEmits<{
+	add: [payload: { handleId: string; event: MouseEvent }]
+}>()
+
 const connectionsLimitReached = computed(() => {
 	return props.maxConnections && props.connectionsCount >= props.maxConnections
 })
@@ -44,7 +49,14 @@ const isConnectableEnd = computed(() => {
 		:connectable-end="isConnectableEnd"
 		:is-valid-connection="isValidConnection"
 	>
-		<HandlePort :position="position" :type="type" :label="label" />
+		<HandlePort
+			:node-id="nodeId"
+			:position="position"
+			:type="type"
+			:label="label"
+			:show-add-action="type === 'source' && connectionsCount === 0"
+			@add="emit('add', { handleId, event: $event })"
+		/>
 	</Handle>
 </template>
 
@@ -61,9 +73,10 @@ const isConnectableEnd = computed(() => {
 	justify-content: center;
 	align-items: center;
 	border: 0;
-	z-index: 1;
+	z-index: 20;
 	background: transparent;
 	border-radius: 0;
+	overflow: visible;
 
 	&.inputs.main {
 		cursor: default;

+ 131 - 8
packages/workflow/src/components/elements/handles/HandlePort.vue

@@ -1,18 +1,80 @@
-<template>
-	<div :class="[position, type]" class="renderType flex items-center">
-		<div :class="type" class="handlePort transition-transform duration-150 relative"></div>
-		<div v-if="label" class="w-max ml-8px text-12px text-#666">{{ label }}</div>
-	</div>
-</template>
-
 <script setup lang="ts">
-defineProps<{
+import { computed, ref } from 'vue'
+import { useVueFlowContext } from '../../../hooks/useVueFlowContext'
+
+const props = defineProps<{
+	nodeId?: string
 	position: string
 	type: 'source' | 'target'
 	label?: string
+	showAddAction?: boolean
+}>()
+
+const emit = defineEmits<{
+	add: [event: MouseEvent]
 }>()
+
+const vueFlowContext = useVueFlowContext()
+const shouldShowAddAction = computed(() => props.type === 'source' && props.showAddAction !== false)
+const isConnecting = computed(
+	() =>
+		vueFlowContext.connectingHandle.value &&
+		vueFlowContext.connectingHandle.value?.nodeId === props.nodeId
+)
+const isHovered = ref(false)
+
+const isHandlePlusVisible = computed(() => !isConnecting.value || isHovered.value)
+
+function onMouseEnter() {
+	isHovered.value = true
+}
+
+function onMouseLeave() {
+	isHovered.value = false
+}
+
+const onAdd = (event: MouseEvent) => {
+	emit('add', event)
+}
 </script>
 
+<template>
+	<div :class="[position, type]" class="renderType flex items-center">
+		<div :class="type" class="handlePort transition-transform duration-150 relative"></div>
+		<Transition name="canvas-node-handle-main-output">
+			<svg
+				v-if="shouldShowAddAction"
+				v-show="isHandlePlusVisible"
+				data-test-id="canvas-handle-plus-wrapper"
+				class="handleAddAction"
+				viewBox="0 0 44 24"
+				aria-hidden="true"
+				@mouseenter="onMouseEnter"
+				@mouseleave="onMouseLeave"
+			>
+				<line x1="0" y1="12" x2="18" y2="12" class="handleAddAction__line" stroke-width="2" />
+				<g
+					data-test-id="canvas-handle-plus"
+					class="handleAddAction__plus clickable"
+					transform="translate(20, 0)"
+					@click.stop="onAdd"
+				>
+					<rect x="2" y="2" width="20" height="20" rx="4" class="handleAddAction__rect clickable" />
+					<path
+						d="M8 12h8m-4-4v8"
+						class="handleAddAction__icon clickable"
+						stroke-width="1.5"
+						stroke-linecap="round"
+						stroke-linejoin="round"
+					/>
+				</g>
+			</svg>
+		</Transition>
+
+		<div v-if="label" class="w-max ml-8px text-12px text-#666">{{ label }}</div>
+	</div>
+</template>
+
 <style lang="less" scoped>
 .handlePort {
 	width: 12px;
@@ -24,6 +86,51 @@ defineProps<{
 	flex-shrink: 0;
 }
 
+.handleAddAction {
+	width: 44px;
+	height: 24px;
+	margin-left: 4px;
+	flex-shrink: 0;
+	overflow: visible;
+	pointer-events: none;
+}
+
+.handleAddAction__line {
+	stroke: #d1d5db;
+	pointer-events: none;
+}
+
+.handleAddAction__plus {
+	color: #4b5563;
+	transform-origin: center;
+	transition:
+		transform 0.15s ease,
+		color 0.15s ease;
+	pointer-events: auto;
+}
+
+.handleAddAction__rect {
+	fill: #ececec;
+	stroke: #d6d6d6;
+	transition:
+		fill 0.15s ease,
+		stroke 0.15s ease;
+}
+
+.handleAddAction__icon {
+	stroke: currentColor;
+}
+
+.handleAddAction__plus:hover {
+	color: #111827;
+	transform: translate(20px, 0) scale(1.04);
+}
+
+.handleAddAction__plus:hover .handleAddAction__rect {
+	fill: #e5e7eb;
+	stroke: #cfd4dc;
+}
+
 .source:hover {
 	transform: scale(1.2);
 	border-width: 1.2px;
@@ -31,6 +138,8 @@ defineProps<{
 }
 
 .renderType {
+	overflow: visible;
+
 	&.top {
 		margin-bottom: -16px;
 		transform: translate(0%, -50%);
@@ -51,4 +160,18 @@ defineProps<{
 		transform: translate(0%, 50%);
 	}
 }
+
+.canvas-node-handle-main-output-enter-active,
+.canvas-node-handle-main-output-leave-active {
+	transform-origin: 0 center;
+	transition-property: transform, opacity;
+	transition-duration: 0.2s;
+	transition-timing-function: ease;
+}
+
+.canvas-node-handle-main-output-enter-from,
+.canvas-node-handle-main-output-leave-to {
+	transform: scale(0);
+	opacity: 0;
+}
 </style>

+ 58 - 12
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, provide, inject } from 'vue'
+import { computed } from 'vue'
 import { Position, type Connection } from '@vue-flow/core'
 
 import CanvasHandle from '../handles/CanvasHandle.vue'
@@ -8,10 +8,15 @@ import CanvasNodeToolBar from './CanvasNodeToolBar.vue'
 
 import type { NodeProps } from '@vue-flow/core'
 import type {
+	ConnectStartEvent,
 	IWorkflowNode,
+	IWorkflowEdge,
+	XYPosition,
 	CanvasConnectionPort,
 	CanvasElementPortWithRenderData
 } from '../../../Interface'
+import { provideCanvasNodeContext } from '../../../hooks/useCanvasNodeContext'
+import { useVueFlowContext } from '../../../hooks/useVueFlowContext'
 
 type Props = NodeProps<IWorkflowNode> & {
 	readOnly?: boolean
@@ -20,15 +25,19 @@ type Props = NodeProps<IWorkflowNode> & {
 }
 
 const props = defineProps<Props>()
-const nodeMap = inject<{ nodeMap: Record<string, any> }>('vueflow')?.nodeMap
+const { nodeMap, edges } = useVueFlowContext()
 
 const emit = defineEmits<{
 	update: [id: string, parameters: Record<string, unknown>]
 	move: [id: string, position: { x: number; y: number }]
 	delete: [id: string]
 	run: [id: string]
+	'add-node': [payload: { nodeId: string; handleId: string; event: MouseEvent }]
 	'add-inner-node': []
 	'add-inner-edge': [connection: Connection]
+	'create-connection-cancelled': [
+		payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }
+	]
 }>()
 
 /**
@@ -38,17 +47,18 @@ const createEndpoint = (data: {
 	port: CanvasConnectionPort
 	index: number
 	count: number
+	connectionsCount: number
 	offsetAxis: 'top' | 'left'
 	position: Position
 	type: 'source' | 'target'
 }): CanvasElementPortWithRenderData => {
-	const { port, index, count, offsetAxis, position, type } = data
+	const { port, index, count, connectionsCount, offsetAxis, position, type } = data
 
 	return {
 		...port,
-		handleId: port?.id || type,
+		handleId: port?.id || props.node.id + '-' + type,
 		position,
-		connectionsCount: count,
+		connectionsCount,
 		isConnecting: false,
 		offset: {
 			[offsetAxis]: `${(100 / (count + 1)) * (index + 1)}%`
@@ -56,11 +66,27 @@ const createEndpoint = (data: {
 	}
 }
 
+const getConnectionCount = (
+	connectionType: 'source' | 'target',
+	handleId: string,
+	nodeEdges: IWorkflowEdge[]
+) => {
+	return nodeEdges.filter((edge) => {
+		if (connectionType === 'source') {
+			const edgeHandleId = edge.sourceHandle || 'source'
+			return edge.source === props.id && edgeHandleId === handleId
+		}
+
+		const edgeHandleId = edge.targetHandle || 'target'
+		return edge.target === props.id && edgeHandleId === handleId
+	}).length
+}
+
 /**
  * Inputs
  */
 const inputs = computed(() => {
-	const getInputs = nodeMap?.[props.node?.data.nodeType]?.inputs
+	const getInputs = nodeMap.value?.[props.node?.data.nodeType]?.inputs
 	const inputs = typeof getInputs === 'function' ? getInputs(props.node?.data) : getInputs || []
 
 	return (inputs as CanvasConnectionPort[]).map((target, index) =>
@@ -68,6 +94,11 @@ const inputs = computed(() => {
 			port: target,
 			index,
 			count: inputs.length,
+			connectionsCount: getConnectionCount(
+				'target',
+				target?.id || `${props.node?.id}-target`,
+				edges.value
+			),
 			offsetAxis: 'top',
 			position: Position.Left,
 			type: 'target'
@@ -79,13 +110,19 @@ const inputs = computed(() => {
  * Outputs
  */
 const outputs = computed(() => {
-	const getOutputs = nodeMap?.[props.node?.data.nodeType]?.outputs
+	const getOutputs = nodeMap.value?.[props.node?.data.nodeType]?.outputs
 	const outputs = typeof getOutputs === 'function' ? getOutputs(props.node?.data) : getOutputs || []
+
 	return (outputs as CanvasConnectionPort[]).map((target, index) =>
 		createEndpoint({
 			port: target,
 			index,
 			count: outputs.length,
+			connectionsCount: getConnectionCount(
+				'source',
+				target?.id || `${props.node?.id}-source`,
+				edges.value
+			),
 			offsetAxis: 'top',
 			position: Position.Right,
 			type: 'source'
@@ -93,7 +130,7 @@ const outputs = computed(() => {
 	)
 })
 
-const hideToolBar = computed(() => nodeMap?.[props.node?.data.nodeType]?.hideToolBar)
+const hideToolBar = computed(() => nodeMap.value?.[props.node?.data.nodeType]?.hideToolBar)
 
 const onUpdate = (prop: Record<string, unknown>) => {
 	emit('update', props.id, prop)
@@ -107,11 +144,11 @@ const onRun = () => {
 	emit('run', props.id)
 }
 
-provide('canvas-node-data', {
-	props: {
+provideCanvasNodeContext({
+	props: computed(() => ({
 		...props,
 		data: props.node?.data
-	},
+	})),
 	inputs,
 	outputs
 })
@@ -122,8 +159,10 @@ provide('canvas-node-data', {
 		<NodeRenderer
 			v-bind="$attrs"
 			@update="onUpdate"
+			@click:node:add="emit('add-node', $event)"
 			@add-inner-node="emit('add-inner-node')"
 			@add-inner-edge="emit('add-inner-edge', $event)"
+			@create:connection:cancelled="emit('create-connection-cancelled', $event)"
 		/>
 
 		<template v-for="target in inputs" :key="'handle-inputs-port' + target.index">
@@ -131,7 +170,14 @@ provide('canvas-node-data', {
 		</template>
 
 		<template v-for="source in outputs" :key="'handle-outputs-port' + source.index">
-			<CanvasHandle v-bind="source" type="source" />
+			<CanvasHandle
+				v-bind="source"
+				:node-id="props.id"
+				type="source"
+				@add="
+					emit('add-node', { nodeId: props.id, handleId: $event.handleId, event: $event.event })
+				"
+			/>
 		</template>
 
 		<CanvasNodeToolBar v-if="!hideToolBar" @delete="onDelete" @run="onRun" :hovered="hovered" />

+ 4 - 12
packages/workflow/src/components/elements/nodes/CanvasNodeToolBar.vue

@@ -1,10 +1,9 @@
 <script setup lang="ts">
-import { ref, watch, inject, computed, type Ref } from 'vue'
+import { ref, watch, computed } from 'vue'
 
 import { Icon } from '@repo/ui'
 
-import type { IWorkflowNode, CanvasConnectionPort } from '../../../Interface'
-import type { NodeProps } from '@vue-flow/core'
+import { useCanvasNodeContext } from '../../../hooks/useCanvasNodeContext'
 
 const emit = defineEmits<{
 	delete: []
@@ -15,14 +14,7 @@ const props = defineProps<{
 	hovered?: boolean
 }>()
 
-const node = inject<{
-	props?: NodeProps<IWorkflowNode['data']> & {
-		readOnly?: boolean
-		hovered?: boolean
-	}
-	inputs?: Ref<CanvasConnectionPort[]>
-	outputs?: Ref<CanvasConnectionPort[]>
-}>('canvas-node-data')
+const node = useCanvasNodeContext()
 
 const barState = ref(false)
 const more = () => {
@@ -59,7 +51,7 @@ watch(
 	{ immediate: true }
 )
 
-const renderToolbar = computed(() => delayedHovered.value && !node?.props?.readOnly)
+const renderToolbar = computed(() => delayedHovered.value && !node.props.value.readOnly)
 </script>
 
 <template>

+ 10 - 19
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -1,39 +1,30 @@
 <script setup lang="ts">
-import { inject, computed, type Ref } from 'vue'
+import { computed } from 'vue'
 import { Icon } from '@repo/ui'
 
-import type { NodeProps } from '@vue-flow/core'
-import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
+import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
+import { useVueFlowContext } from '../../../../hooks/useVueFlowContext'
 
-const node = inject<{
-	props?: NodeProps<IWorkflowNode['data']> & {
-		readOnly?: boolean
-		hovered?: boolean
-		node: IWorkflowNode
-	}
-	inputs?: Ref<CanvasConnectionPort[]>
-	outputs?: Ref<CanvasConnectionPort[]>
-}>('canvas-node-data')
-
-const nodeMap = inject<{ nodeMap: Record<string, any> }>('vueflow')?.nodeMap
+const node = useCanvasNodeContext()
+const { nodeMap } = useVueFlowContext()
 
 const nodeClass = computed(() => {
 	let classes: string[] = []
-	if (node?.props?.selected) {
+	if (node.props.value.selected) {
 		classes.push('ring-6px', 'ring-#e0e2e7')
 	}
-	if (node?.inputs?.value?.length === 0) {
+	if (node.inputs.value.length === 0) {
 		classes.push('rounded-l-36px')
 	}
-	if (node?.outputs?.value?.length === 0) {
+	if (node.outputs.value.length === 0) {
 		classes.push('rounded-r-36px')
 	}
 
 	return classes
 })
-const nodeData = computed(() => node?.props?.node)
+const nodeData = computed(() => node.props.value.node)
 
-const nodeType = computed(() => nodeMap?.[nodeData.value?.nodeType!])
+const nodeType = computed(() => nodeMap.value[nodeData.value?.nodeType!])
 
 const nodeSubtitle = computed(() => {
 	const getSubtitle = nodeType.value?.getSubtitle

+ 8 - 23
packages/workflow/src/components/elements/nodes/render-types/NodeIcon.vue

@@ -1,39 +1,24 @@
 <script setup lang="ts">
-import { inject, computed, type Ref } from 'vue'
+import { computed } from 'vue'
 import { Icon } from '@repo/ui'
 
-import type { NodeProps } from '@vue-flow/core'
-import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
+import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
+import { useVueFlowContext } from '../../../../hooks/useVueFlowContext'
 
-const node = inject<{
-	props?: NodeProps<IWorkflowNode['data']> & {
-		readOnly?: boolean
-		hovered?: boolean
-		node: IWorkflowNode
-	}
-	inputs?: Ref<CanvasConnectionPort[]>
-	outputs?: Ref<CanvasConnectionPort[]>
-}>('canvas-node-data')
-
-const nodeMap = inject<{ nodeMap: Record<string, any> }>('vueflow')?.nodeMap
+const node = useCanvasNodeContext()
+const { nodeMap } = useVueFlowContext()
 
 const nodeClass = computed(() => {
 	let classes: string[] = []
-	if (node?.props?.selected) {
+	if (node.props.value.selected) {
 		classes.push('ring-6px', 'ring-#e0e2e7')
 	}
-	if (node?.inputs?.value?.length === 0) {
-		classes.push('rounded-l-36px')
-	}
-	if (node?.outputs?.value?.length === 0) {
-		classes.push('rounded-r-36px')
-	}
 
 	return classes
 })
-const nodeData = computed(() => node?.props?.data)
+const nodeData = computed(() => node.props.value.data)
 
-const nodeType = computed(() => nodeMap?.[nodeData.value?.nodeType!])
+const nodeType = computed(() => nodeMap.value[nodeData.value?.nodeType!])
 </script>
 
 <template>

+ 31 - 31
packages/workflow/src/components/elements/nodes/render-types/NodeLoop.vue

@@ -1,12 +1,14 @@
 <script setup lang="ts">
-import { inject, computed } from 'vue'
+import { computed } from 'vue'
 import { Icon } from '@repo/ui'
 import { NodeResizer } from '@vue-flow/node-resizer'
 import type { OnResize } from '@vue-flow/node-resizer'
 import Canvas from '../../../Canvas.vue'
 
-import type { NodeProps, XYPosition, Connection } from '@vue-flow/core'
-import type { IWorkflowNode, CanvasConnectionPort, IWorkflowEdge } from '../../../../Interface'
+import type { XYPosition, Connection } from '@vue-flow/core'
+import type { ConnectStartEvent } from '../../../../Interface'
+import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
+import { useVueFlowContext } from '../../../../hooks/useVueFlowContext'
 
 import '@vue-flow/node-resizer/dist/style.css'
 
@@ -14,45 +16,41 @@ defineOptions({
 	inheritAttrs: false
 })
 
-const node = inject<{
-	props?: NodeProps<IWorkflowNode> & {
-		readOnly?: boolean
-		hovered?: boolean
-		node: IWorkflowNode
-	}
-	inputs?: { value: CanvasConnectionPort[] }
-	outputs?: { value: CanvasConnectionPort[] }
-}>('canvas-node-data')
-
-const nodeMap = inject<{ nodeMap: Record<string, any> }>('vueflow')?.nodeMap
-
-const nodes = inject<{ nodes: IWorkflowNode[] }>('vueflow')?.nodes
-const edges = inject<{ edges: IWorkflowEdge[] }>('vueflow')?.edges
+const node = useCanvasNodeContext()
+const { nodeMap, nodes, edges } = useVueFlowContext()
 
 const childrenNodes = computed(
-	() => nodes?.filter((item) => item?.parentId === node?.props?.id) || []
+	() => nodes.value.filter((item) => item?.parentId === node.props.value.id) || []
 )
 
+const childNodeIds = computed(() => new Set(childrenNodes.value.map((item) => item.id)))
+const childrenEdges = computed(() =>
+	edges.value.filter(
+		(edge) => childNodeIds.value.has(edge.source) && childNodeIds.value.has(edge.target)
+	)
+)
 const emit = defineEmits<{
 	update: [parameters: Record<string, unknown>]
 	move: [position: XYPosition]
+	'click:node:add': [payload: { nodeId: string; handle: string; position: XYPosition; event?: MouseEvent }]
 	'add-inner-node': [parentId: string]
 	'add-inner-edge': [connection: Connection]
+	'create:connection:cancelled': [payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }]
 }>()
 
-const nodeData = computed(() => node?.props?.node?.data ?? node?.props?.data)
+const nodeData = computed(() => node.props.value.node?.data ?? node.props.value.data)
 const nodeType = computed(() => {
 	const type = nodeData.value?.nodeType
-	return type ? nodeMap?.[type] : undefined
+	return type ? nodeMap.value[type] : undefined
 })
-const isReadOnly = computed(() => node?.props?.readOnly ?? false)
+const isReadOnly = computed(() => node.props.value.readOnly ?? false)
 
 const width = computed(() => Number(nodeData.value?.width) || 424)
 const height = computed(() => Number(nodeData.value?.height) || 244)
 
 const nodeClass = computed(() => {
 	const classes: string[] = []
-	if (node?.props?.selected) {
+	if (node.props.value.selected) {
 		classes.push('ring-2', 'ring-#06aed4')
 	}
 	return classes
@@ -71,7 +69,7 @@ function onResize(event: OnResize) {
 
 function onAddNode() {
 	if (!isReadOnly.value) {
-		emit('add-inner-node', node?.props?.id!)
+		emit('add-inner-node', node.props.value.id)
 	}
 }
 
@@ -86,7 +84,7 @@ function onAddEdge(connection: Connection) {
 		:min-height="180"
 		:width="width"
 		:height="height"
-		:is-visible="!isReadOnly && node?.props?.selected"
+		:is-visible="!isReadOnly && node.props.value.selected"
 		handle-class-name="bg-#06aed4! border-white! w-8px h-8px"
 		line-class-name="border-#06aed4!"
 		@resize="onResize"
@@ -94,7 +92,7 @@ function onAddEdge(connection: Connection) {
 
 	<div
 		:class="nodeClass"
-		class="node-loop rounded-12px border-2 border-solid border-#dcdcdc bg-#fafafa overflow-hidden flex flex-col"
+		class="w-full h-full box-border node-loop rounded-12px border-2 border-solid border-#dcdcdc bg-#fafafa overflow-hidden flex flex-col"
 	>
 		<!-- 标题栏 -->
 		<div
@@ -110,24 +108,26 @@ function onAddEdge(connection: Connection) {
 		</div>
 
 		<!-- 内部画布区域:虚线网格 + 占位内容 -->
-		<div class="loop-body flex-1 min-h-0 relative p-16px">
+		<div class="loop-body flex-1 min-h-0 relative">
 			<div
-				class="absolute inset-16px rounded-8px border-1 border-dashed border-#d9d9d9 bg-[repeating-linear-gradient(to_right,#e8e8e8_0,transparent_1px),repeating-linear-gradient(to_bottom,#e8e8e8_0,transparent_1px)] bg-[length:12px_12px]"
+				class="absolute top-16px right-40px bottom-16px left-16px rounded-8px border-1 border-dashed border-#d9d9d9 bg-[repeating-linear-gradient(to_right,#e8e8e8_0,transparent_1px),repeating-linear-gradient(to_bottom,#e8e8e8_0,transparent_1px)] bg-[length:12px_12px]"
 			/>
-			<div class="w-full h-full relative z-1 flex items-center gap-12px pt-8px">
+			<div class="absolute top-16px right-40px bottom-16px left-16px z-1">
 				<Canvas
-					:id="node?.props?.id"
+					:id="node.props.value.id"
 					:nodes="childrenNodes"
-					:edges="edges || []"
+					:edges="childrenEdges"
 					:read-only="isReadOnly"
-					:node-map="nodeMap!"
+					:node-map="nodeMap"
 					:show-control-bar="false"
 					:hide-child-node="false"
 					:zoom-to-fit="false"
 					:max-zoom="1"
 					:min-zoom="1"
+					@click:node:add="emit('click:node:add', $event)"
 					@create:node="onAddNode"
 					@create:connection:end="onAddEdge"
+					@create:connection:cancelled="emit('create:connection:cancelled', $event)"
 				/>
 			</div>
 		</div>

+ 10 - 9
packages/workflow/src/components/elements/nodes/render-types/NodeRenderer.vue

@@ -1,24 +1,23 @@
 <script setup lang="ts">
-import { inject, computed, type Ref } from 'vue'
+import { computed } from 'vue'
 import NodeDefault from './NodeDefault.vue'
 import NodeStickyNote from './NodeStickyNote.vue'
 import NodeLoop from './NodeLoop.vue'
 import NodeIcon from './NodeIcon.vue'
 
-import type { NodeProps, Connection } from '@vue-flow/core'
-import type { IWorkflowNode, CanvasConnectionPort } from '../../../../Interface'
+import type { Connection } from '@vue-flow/core'
+import type { XYPosition, ConnectStartEvent } from '../../../../Interface'
+import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
 
-const node = inject<{
-	props?: NodeProps<IWorkflowNode['data']>
-	inputs?: Ref<CanvasConnectionPort[]>
-	outputs?: Ref<CanvasConnectionPort[]>
-}>('canvas-node-data')
+const node = useCanvasNodeContext()
 
-const nodeType = computed(() => node?.props?.data?.nodeType)
+const nodeType = computed(() => node.props.value.data?.nodeType)
 
 defineEmits<{
+	'click:node:add': [payload: { nodeId: string; handleId: string; event?: MouseEvent }]
 	'add-inner-node': []
 	'add-inner-edge': [connection: Connection]
+	'create:connection:cancelled': [payload: { handle: ConnectStartEvent; position: XYPosition; event?: MouseEvent }]
 }>()
 </script>
 
@@ -27,8 +26,10 @@ defineEmits<{
 	<NodeLoop
 		v-else-if="nodeType === 'loop' || nodeType === 'iteration'"
 		v-bind="$attrs"
+		@click:node:add="$emit('click:node:add', $event)"
 		@add-inner-node="$emit('add-inner-node')"
 		@add-inner-edge="$emit('add-inner-edge', $event)"
+		@create:connection:cancelled="$emit('create:connection:cancelled', $event)"
 	/>
 	<NodeIcon v-else-if="nodeType?.includes('-start')" v-bind="$attrs" />
 	<NodeDefault v-else v-bind="$attrs"> </NodeDefault>

+ 9 - 21
packages/workflow/src/components/elements/nodes/render-types/NodeStickyNote.vue

@@ -1,11 +1,11 @@
 <script setup lang="ts">
-import { ref, inject, computed } from 'vue'
+import { ref, computed } from 'vue'
 import { StickyNote } from '@repo/ui'
 import { NodeResizer } from '@vue-flow/node-resizer'
 import type { OnResize } from '@vue-flow/node-resizer'
 
-import type { NodeProps, XYPosition } from '@vue-flow/core'
-import type { IWorkflowNode } from '../../../../Interface'
+import type { XYPosition } from '@vue-flow/core'
+import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
 
 // make sure to include the necessary styles!
 import '@vue-flow/node-resizer/dist/style.css'
@@ -14,19 +14,7 @@ defineOptions({
 	inheritAttrs: false
 })
 
-const node = inject<{
-	props?: NodeProps<
-		IWorkflowNode['data'] & {
-			content?: string
-			width?: number
-			height?: number
-			color?: string
-		}
-	> & {
-		readOnly?: boolean
-		hovered?: boolean
-	}
-}>('canvas-node-data')
+const node = useCanvasNodeContext()
 
 const emit = defineEmits<{
 	update: [parameters: Record<string, unknown>]
@@ -36,8 +24,8 @@ const emit = defineEmits<{
 	'open:contextmenu': [event: MouseEvent]
 }>()
 
-const data = computed(() => node?.props?.data)
-const isReadOnly = computed(() => node?.props?.readOnly ?? false)
+const data = computed(() => node.props.value.data)
+const isReadOnly = computed(() => node.props.value.readOnly ?? false)
 
 const modelValue = computed({
 	get() {
@@ -54,7 +42,7 @@ const editMode = ref(false)
 
 const nodeClass = computed(() => {
 	let classes: string[] = []
-	if (node?.props?.selected) {
+	if (node.props.value.selected) {
 		classes.push('ring-6px', 'ring-#e0e2e7')
 	}
 
@@ -90,7 +78,7 @@ function onResize(event: OnResize) {
 		:min-width="150"
 		:height="data?.height"
 		:width="data?.width"
-		:is-visible="!isReadOnly && node?.props?.selected"
+		:is-visible="!isReadOnly && node.props.value.selected"
 		handleClassName="bg-transparent! border-transparent!"
 		lineClassName="border-transparent!"
 		@resize="onResize"
@@ -104,7 +92,7 @@ function onResize(event: OnResize) {
 			:width="data?.width"
 			:height="data?.height"
 			:backgroundColor="data?.color"
-			:readOnly="node?.props?.readOnly"
+			:readOnly="node.props.value.readOnly"
 			:editMode="editMode"
 			@dblclick.stop="handleSetEditMode"
 			@edit="handleSetEditMode"

+ 52 - 0
packages/workflow/src/hooks/useCanvasNodeContext.ts

@@ -0,0 +1,52 @@
+import { computed, inject, provide, unref, type ComputedRef, type InjectionKey, type MaybeRefOrGetter } from 'vue'
+import type { NodeProps } from '@vue-flow/core'
+
+import type { CanvasElementPortWithRenderData, IWorkflowNode } from '../Interface'
+
+export interface CanvasNodeContext {
+	props: ComputedRef<
+		NodeProps<IWorkflowNode> & {
+			readOnly?: boolean
+			hovered?: boolean
+			node: IWorkflowNode
+			data: IWorkflowNode['data']
+		}
+	>
+	inputs: ComputedRef<CanvasElementPortWithRenderData[]>
+	outputs: ComputedRef<CanvasElementPortWithRenderData[]>
+}
+
+const canvasNodeContextKey: InjectionKey<CanvasNodeContext> = Symbol('canvas-node-context')
+
+export function provideCanvasNodeContext(options: {
+	props: MaybeRefOrGetter<
+		NodeProps<IWorkflowNode> & {
+			readOnly?: boolean
+			hovered?: boolean
+			node: IWorkflowNode
+			data: IWorkflowNode['data']
+		}
+	>
+	inputs: MaybeRefOrGetter<CanvasElementPortWithRenderData[]>
+	outputs: MaybeRefOrGetter<CanvasElementPortWithRenderData[]>
+}): CanvasNodeContext {
+	const context: CanvasNodeContext = {
+		props: computed(() => unref(options.props)),
+		inputs: computed(() => unref(options.inputs)),
+		outputs: computed(() => unref(options.outputs))
+	}
+
+	provide(canvasNodeContextKey, context)
+
+	return context
+}
+
+export function useCanvasNodeContext(): CanvasNodeContext {
+	const context = inject(canvasNodeContextKey, null)
+
+	if (!context) {
+		throw new Error('useCanvasNodeContext must be used within CanvasNode')
+	}
+
+	return context
+}

+ 56 - 0
packages/workflow/src/hooks/useVueFlowContext.ts

@@ -0,0 +1,56 @@
+import {
+	computed,
+	inject,
+	provide,
+	unref,
+	type ComputedRef,
+	type InjectionKey,
+	type MaybeRefOrGetter,
+	type Ref
+} from 'vue'
+import type { VueFlowStore } from '@vue-flow/core'
+
+import type { IWorkflowEdge, IWorkflowNode, ConnectStartEvent } from '../Interface'
+
+export interface VueFlowContext {
+	id: ComputedRef<string>
+	nodes: ComputedRef<IWorkflowNode[]>
+	edges: ComputedRef<IWorkflowEdge[]>
+	vueFlow: VueFlowStore
+	nodeMap: ComputedRef<Record<string, any>>
+	connectingHandle: Ref<ConnectStartEvent | undefined>
+}
+
+const vueFlowContextKey: InjectionKey<VueFlowContext> = Symbol('vueflow-context')
+
+export function provideVueFlowContext(options: {
+	id: MaybeRefOrGetter<string | undefined>
+	nodes: MaybeRefOrGetter<IWorkflowNode[] | undefined>
+	edges: MaybeRefOrGetter<IWorkflowEdge[] | undefined>
+	vueFlow: VueFlowStore
+	nodeMap: MaybeRefOrGetter<Record<string, any> | undefined>
+	connectingHandle: Ref<ConnectStartEvent | undefined>
+}): VueFlowContext {
+	const context: VueFlowContext = {
+		id: computed(() => unref(options.id) || 'canvas'),
+		nodes: computed(() => unref(options.nodes) || []),
+		edges: computed(() => unref(options.edges) || []),
+		vueFlow: options.vueFlow,
+		nodeMap: computed(() => unref(options.nodeMap) || {}),
+		connectingHandle: options.connectingHandle
+	}
+
+	provide(vueFlowContextKey, context)
+
+	return context
+}
+
+export function useVueFlowContext(): VueFlowContext {
+	const context = inject(vueFlowContextKey, null)
+
+	if (!context) {
+		throw new Error('useVueFlowContext must be used within Canvas')
+	}
+
+	return context
+}

+ 29 - 0
packages/workflow/src/hooks/useWorkflowContext.ts

@@ -0,0 +1,29 @@
+import { computed, inject, provide, unref, type ComputedRef, type InjectionKey, type MaybeRef } from 'vue'
+
+import type { IWorkflow } from '../Interface'
+
+export interface WorkflowContext {
+	workflow: ComputedRef<IWorkflow | undefined>
+}
+
+const workflowContextKey: InjectionKey<WorkflowContext> = Symbol('workflow-context')
+
+export function provideWorkflowContext(workflow: MaybeRef<IWorkflow | undefined>): WorkflowContext {
+	const context: WorkflowContext = {
+		workflow: computed(() => unref(workflow))
+	}
+
+	provide(workflowContextKey, context)
+
+	return context
+}
+
+export function useWorkflowContext(): WorkflowContext {
+	const context = inject(workflowContextKey, null)
+
+	if (!context) {
+		throw new Error('useWorkflowContext must be used within Workflow')
+	}
+
+	return context
+}