Explorar o código

feat: 添加节点运行效果

jiaxing.liao hai 1 mes
pai
achega
d67ea775f9

+ 1 - 1
apps/web/src/features/setter/index.vue

@@ -29,7 +29,7 @@ const props = withDefaults(defineProps<Props>(), {
 const emit = defineEmits<{
 	'update:visible': [value: boolean]
 	// 更新节点数据
-	'update:node:data': [data: Record<string, unknown>]
+	'update:node:data': [data: IWorkflowNode]
 	// 运行节点
 	'run-node': [id: string]
 }>()

+ 1 - 1
apps/web/src/nodes/src/condition/index.ts

@@ -37,7 +37,7 @@ export const conditionNode: INodeType = {
 		})
 
 		ports.push({
-			id: data?.id || 'source',
+			id: data?.id ? `${data.id}-else` : 'source',
 			label: 'ELSE',
 			type: 'port'
 		})

+ 53 - 5
apps/web/src/views/Editor.vue

@@ -52,7 +52,7 @@
 					<Workflow
 						ref="workflowRef"
 						:id="workflow?.id"
-						:workflow="workflow"
+						:workflow="workflowWithExecutionState"
 						:nodeMap="nodeMap"
 						@click:node="handleSelectNode"
 						@dblclick:node="handleNodeClick"
@@ -81,7 +81,7 @@
 				<Setter
 					:id="nodeID"
 					:workflow="workflow!"
-					@update:node:data="hangleUpdateNodeData"
+					@update:node:data="handleUpdateNodeData"
 					@run-node="handleRunNode"
 					v-model:visible="setterVisible"
 				/>
@@ -105,7 +105,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, inject, type CSSProperties, onBeforeUnmount, watch, nextTick } from 'vue'
+import { computed, ref, inject, type CSSProperties, onBeforeUnmount, watch, nextTick } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { agent } from '@repo/api-service'
 
@@ -127,7 +127,8 @@ import type {
 	XYPosition,
 	Connection,
 	ConnectStartEvent,
-	IWorkflowNode
+	IWorkflowNode,
+	CanvasExecutionStatus
 } from '@repo/workflow'
 
 const layout = inject<{ setMainStyle: (style: CSSProperties) => void }>('layout')
@@ -168,6 +169,45 @@ const workflow = ref<IWorkflow>(
 				edges: []
 			}
 )
+
+const mapNodeExecutionStatus = (status?: string): CanvasExecutionStatus => {
+	if (status === 'running') {
+		return 'running'
+	}
+
+	if (status === 'success') {
+		return 'success'
+	}
+
+	if (status === 'failed') {
+		return 'warning'
+	}
+
+	return 'idle'
+}
+
+const workflowWithExecutionState = computed<IWorkflow>(() => {
+	const baseWorkflow = workflow.value
+	const runnerNodeStatusMap = new Map(
+		runnerStore.nodes.map((item) => [item.nodeId, mapNodeExecutionStatus(item.status)])
+	)
+
+	return {
+		...baseWorkflow,
+		nodes: (baseWorkflow.nodes || []).map((node) => {
+			const executionStatus = runnerNodeStatusMap.get(node.id) || 'idle'
+
+			return {
+				...node,
+				executionStatus,
+				data: {
+					...(node.data || {}),
+					executionStatus
+				}
+			}
+		})
+	}
+})
 const inputRef = ref<InstanceType<typeof Input>>()
 const saveAgentTimer = ref<number | undefined>(undefined)
 const saveVarsTimer = ref<number | undefined>(undefined)
@@ -556,12 +596,16 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
 		// 需要连接前一个节点
 		if (peddingHandlePayload.value) {
 			const { position, handle, parentId } = peddingHandlePayload.value
+
 			newNodeParam.position = position
 			newNodeParam.prevNodeId = handle.nodeId
 			newNodeParam.parentId = parentId
 			if (handle.handleId?.includes('_')) {
 				newNodeParam.nodeHandleId = handle.handleId
 			}
+			if (handle.handleId?.endsWith('-else')) {
+				newNodeParam.nodeHandleId = handle.nodeId
+			}
 		}
 
 		if (!newNodeParam.position) {
@@ -613,7 +657,6 @@ const handleDelete = () => {
 		cancelButtonText: '取消',
 		type: 'warning'
 	}).then(() => {
-		console.log('删除成功')
 		localStorage.removeItem(`project_${id}`)
 		router.push('/')
 	})
@@ -648,6 +691,11 @@ const onCreateConnection = async (connection: Connection) => {
 		params.sourceHandle = sourceHandle
 	}
 
+	// 如果是source条件节点else的sourceHandle模式就是当前节点id
+	if (sourceHandle && sourceHandle.endsWith('-else')) {
+		params.sourceHandle = source
+	}
+
 	if (!workflow.value?.edges.some((edge) => edge.source === source && edge.target === target)) {
 		const response = await agent.postAgentDoNewEdge(params)
 

+ 4 - 0
packages/workflow/src/Interface.ts

@@ -20,6 +20,8 @@ export interface CanvasElementPortWithRenderData extends CanvasConnectionPort {
 	position: Position
 	offset?: { top?: string; left?: string }
 }
+
+export type CanvasExecutionStatus = 'idle' | 'running' | 'success' | 'warning'
 export interface IWorkflowNode extends Node {
 	appAgentId: string
 	name?: string
@@ -27,6 +29,7 @@ export interface IWorkflowNode extends Node {
 	nodeType: string
 	parentId?: string
 	selected?: boolean
+	executionStatus?: CanvasExecutionStatus
 	data: {
 		id: string
 		// 位置
@@ -39,6 +42,7 @@ export interface IWorkflowNode extends Node {
 		zIndex?: number
 		// 节点类型
 		nodeType: string
+		executionStatus?: CanvasExecutionStatus
 		// 定义节点数据
 		[key: string]: any
 	}

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

@@ -19,6 +19,7 @@ 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'
+import { getEdgeExecutionStatus } from '../utils/execution-status'
 
 defineOptions({
 	name: 'workflow-canvas',
@@ -137,22 +138,37 @@ const normalizedEdges = computed(() =>
 		const targetNode = nodeById.value[edge.target]
 		const sourceParentId = sourceNode?.parentId || ''
 		const targetParentId = targetNode?.parentId || ''
+		const executionStatus = getEdgeExecutionStatus(sourceNode, targetNode)
 
 		if (sourceParentId && sourceParentId !== targetParentId) {
 			return {
 				...edge,
-				source: sourceParentId
+				source: sourceParentId,
+				data: {
+					...(edge.data || {}),
+					executionStatus
+				}
 			}
 		}
 
 		if (targetParentId && targetParentId !== sourceParentId) {
 			return {
 				...edge,
-				target: targetParentId
+				target: targetParentId,
+				data: {
+					...(edge.data || {}),
+					executionStatus
+				}
 			}
 		}
 
-		return edge
+		return {
+			...edge,
+			data: {
+				...(edge.data || {}),
+				executionStatus
+			}
+		}
 	})
 )
 
@@ -170,12 +186,12 @@ onEdgeMouseEnter(({ edge }) => {
 
 onEdgeMouseMove(
 	useThrottleFn(({ edge, event }) => {
-		const type = edge.data.source.type
+		const type = edge.data?.source?.type
 		if (type !== 'ai_tool') {
 			return
 		}
 
-		if (!edge.data.maxConnections || edge.data.maxConnections > 1) {
+		if (!edge.data?.maxConnections || edge.data.maxConnections > 1) {
 			const projectedPosition = getProjectedPosition(event)
 			const yDiff = projectedPosition.y - edge.targetY
 			if (yDiff < 4 * 16) {

+ 17 - 1
packages/workflow/src/components/elements/edges/CanvasEdge.vue

@@ -8,6 +8,8 @@ import {
 	type Connection
 } from '@vue-flow/core'
 import { IconButton } from '@repo/ui'
+import type { CanvasExecutionStatus } from '../../../Interface'
+import { getExecutionStatusColor } from '../../../utils/execution-status'
 
 defineOptions({
 	inheritAttrs: false
@@ -26,6 +28,20 @@ type CanvasEdgeProps = EdgeProps & {
 
 const props = defineProps<CanvasEdgeProps>()
 const path = computed(() => getBezierPath(props))
+const executionStatus = computed<CanvasExecutionStatus>(
+	() => (props.data?.executionStatus as CanvasExecutionStatus) || 'idle'
+)
+const edgeStyle = computed(() => {
+	const status = executionStatus.value
+	const color = getExecutionStatusColor(status)
+
+	return {
+		stroke: color,
+		strokeWidth: status === 'idle' ? 1.6 : 2.5,
+		filter: status === 'idle' ? 'none' : `drop-shadow(0 0 8px ${color}55)`,
+		transition: 'stroke 180ms ease, stroke-width 180ms ease, filter 180ms ease'
+	}
+})
 
 const delayedHovered = ref(props.hovered)
 const delayedHoveredSetTimeoutRef = ref<ReturnType<typeof setTimeout> | null>(null)
@@ -66,7 +82,7 @@ const onDelete = () => {
 </script>
 
 <template>
-	<BaseEdge :path="path[0]" :interaction-width="40" :marker-end="markerEnd" />
+	<BaseEdge :path="path[0]" :interaction-width="40" :marker-end="markerEnd" :style="edgeStyle" />
 
 	<EdgeLabelRenderer>
 		<div

+ 99 - 0
packages/workflow/src/components/elements/nodes/CanvasNode.vue

@@ -187,3 +187,102 @@ provideCanvasNodeContext({
 		<CanvasNodeToolBar v-if="!hideToolBar" @delete="onDelete" @run="onRun" :hovered="hovered" />
 	</div>
 </template>
+
+<style>
+.canvas-node-status-shell {
+	transition:
+		border-color 180ms ease,
+		box-shadow 180ms ease,
+		filter 180ms ease;
+	isolation: isolate;
+}
+
+.canvas-node-status-shell[data-execution-status='running'] {
+	border-color: #3b82f6 !important;
+	box-shadow:
+		0 0 0 1px rgb(59 130 246 / 20%),
+		0 0 18px rgb(59 130 246 / 22%);
+	animation: canvas-node-status-pulse 1.8s ease-in-out infinite;
+}
+
+.canvas-node-status-shell[data-execution-status='running']::before {
+	content: '';
+	position: absolute;
+	top: 0;
+	left: 8%;
+	width: 28%;
+	height: 3px;
+	border-radius: 999px;
+	background: linear-gradient(
+		90deg,
+		rgb(59 130 246 / 0%),
+		rgb(147 197 253 / 95%),
+		rgb(59 130 246 / 0%)
+	);
+	box-shadow: 0 0 10px rgb(59 130 246 / 45%);
+	pointer-events: none;
+	animation: canvas-node-status-scan 1.6s ease-in-out infinite;
+}
+
+.canvas-node-status-shell[data-execution-status='running']::after {
+	content: '';
+	position: absolute;
+	inset: 0;
+	border-radius: inherit;
+	background: linear-gradient(
+		120deg,
+		rgb(255 255 255 / 0%),
+		rgb(255 255 255 / 10%),
+		rgb(255 255 255 / 0%)
+	);
+	opacity: 0.65;
+	pointer-events: none;
+	animation: canvas-node-status-sheen 2.2s ease-in-out infinite;
+}
+
+.canvas-node-status-shell[data-execution-status='success'] {
+	border-color: #22c55e !important;
+}
+
+.canvas-node-status-shell[data-execution-status='warning'] {
+	border-color: #f59e0b !important;
+}
+
+@keyframes canvas-node-status-pulse {
+	0%,
+	100% {
+		box-shadow:
+			0 0 0 1px rgb(59 130 246 / 18%),
+			0 0 12px rgb(59 130 246 / 14%);
+	}
+
+	50% {
+		box-shadow:
+			0 0 0 1px rgb(59 130 246 / 26%),
+			0 0 22px rgb(59 130 246 / 26%);
+	}
+}
+
+@keyframes canvas-node-status-scan {
+	from {
+		transform: translateX(-10%);
+		opacity: 0.35;
+	}
+
+	to {
+		transform: translateX(250%);
+		opacity: 1;
+	}
+}
+
+@keyframes canvas-node-status-sheen {
+	0%,
+	100% {
+		opacity: 0.2;
+	}
+
+	50% {
+		opacity: 0.7;
+	}
+}
+</style>

+ 5 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeDefault.vue

@@ -4,6 +4,7 @@ import { Icon } from '@repo/ui'
 
 import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
 import { useVueFlowContext } from '../../../../hooks/useVueFlowContext'
+import { getNodeExecutionStatus } from '../../../../utils/execution-status'
 
 const node = useCanvasNodeContext()
 const { nodeMap } = useVueFlowContext()
@@ -34,6 +35,8 @@ const nodeSubtitle = computed(() => {
 	return ''
 })
 
+const executionStatus = computed(() => getNodeExecutionStatus(node.props.value.node))
+
 const warningInfo = computed(() => {
 	const validate = nodeType.value?.validate
 	return validate && validate(nodeData.value?.data)
@@ -42,8 +45,9 @@ const warningInfo = computed(() => {
 
 <template>
 	<div
+		:data-execution-status="executionStatus"
 		:class="nodeClass"
-		class="default-node w-full h-full bg-#fff box-border border-1.5px border-solid border-#dcdcdc rounded-8px relative"
+		class="canvas-node-status-shell default-node w-full h-full bg-#fff box-border border-1.5px border-solid border-#dcdcdc rounded-8px relative"
 	>
 		<div className="w-full h-full relative flex items-center justify-center">
 			<div

+ 7 - 1
packages/workflow/src/components/elements/nodes/render-types/NodeIcon.vue

@@ -4,6 +4,7 @@ import { Icon } from '@repo/ui'
 
 import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
 import { useVueFlowContext } from '../../../../hooks/useVueFlowContext'
+import { getNodeExecutionStatus } from '../../../../utils/execution-status'
 
 const node = useCanvasNodeContext()
 const { nodeMap } = useVueFlowContext()
@@ -19,11 +20,16 @@ const nodeClass = computed(() => {
 const nodeData = computed(() => node.props.value.data)
 
 const nodeType = computed(() => nodeMap.value[nodeData.value?.nodeType!])
+const executionStatus = computed(() => getNodeExecutionStatus(node.props.value.node))
 </script>
 
 <template>
 	<el-tooltip :content="nodeType?.displayName || '节点标题'">
-		<div :class="nodeClass" class="bg-#fff rounded-8px relative">
+		<div
+			:data-execution-status="executionStatus"
+			:class="nodeClass"
+			class="canvas-node-status-shell bg-#fff rounded-8px relative border-1.5px border-solid border-transparent"
+		>
 			<div className="w-full h-full relative flex items-center justify-center">
 				<div
 					class="relative flex-shrink-0 flex items-center justify-center w-10 h-10 rounded-lg"

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

@@ -9,6 +9,7 @@ import type { XYPosition, Connection } from '@vue-flow/core'
 import type { CanvasNodeMoveEvent, ConnectStartEvent } from '../../../../Interface'
 import { useCanvasNodeContext } from '../../../../hooks/useCanvasNodeContext'
 import { useVueFlowContext } from '../../../../hooks/useVueFlowContext'
+import { getNodeExecutionStatus } from '../../../../utils/execution-status'
 
 import '@vue-flow/node-resizer/dist/style.css'
 
@@ -55,6 +56,7 @@ 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 executionStatus = computed(() => getNodeExecutionStatus(node.props.value.node))
 
 const nodeClass = computed(() => {
 	const classes: string[] = []
@@ -99,8 +101,9 @@ function onAddEdge(connection: Connection) {
 	/>
 
 	<div
+		:data-execution-status="executionStatus"
 		:class="nodeClass"
-		class="w-full h-full box-border node-loop rounded-12px border-2 border-solid border-#dcdcdc bg-#fff overflow-hidden flex flex-col"
+		class="canvas-node-status-shell w-full h-full box-border node-loop rounded-12px border-2 border-solid border-#dcdcdc bg-#fff overflow-hidden flex flex-col"
 	>
 		<!-- 标题栏 -->
 		<div

+ 48 - 0
packages/workflow/src/utils/execution-status.ts

@@ -0,0 +1,48 @@
+import type { CanvasExecutionStatus, IWorkflowNode } from '../Interface'
+
+export const getNodeExecutionStatus = (node?: IWorkflowNode | null): CanvasExecutionStatus => {
+	return node?.data?.executionStatus ?? node?.executionStatus ?? 'idle'
+}
+
+export const getEdgeExecutionStatus = (
+	sourceNode?: IWorkflowNode | null,
+	targetNode?: IWorkflowNode | null
+): CanvasExecutionStatus => {
+	const sourceStatus = getNodeExecutionStatus(sourceNode)
+	const targetStatus = getNodeExecutionStatus(targetNode)
+	const bothExecuted = sourceStatus !== 'idle' && targetStatus !== 'idle'
+
+	if (!bothExecuted) {
+		return 'idle'
+	}
+
+	if (sourceStatus === 'running' || targetStatus === 'running') {
+		return 'running'
+	}
+
+	if (sourceStatus === 'warning' || targetStatus === 'warning') {
+		return 'warning'
+	}
+
+	if (sourceStatus === 'success' && targetStatus === 'success') {
+		return 'success'
+	}
+
+	return 'idle'
+}
+
+export const getExecutionStatusColor = (status: CanvasExecutionStatus): string => {
+	if (status === 'running') {
+		return '#3b82f6'
+	}
+
+	if (status === 'success') {
+		return '#22c55e'
+	}
+
+	if (status === 'warning') {
+		return '#f59e0b'
+	}
+
+	return '#b1b1b7'
+}