工作流动画效果

This commit is contained in:
rjb
2026-01-19 17:52:29 +08:00
parent 6674060f2f
commit e4aa6cdb79
12 changed files with 2835 additions and 102 deletions

View File

@@ -143,7 +143,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import {
UserFilled,
@@ -170,10 +170,15 @@ const props = defineProps<{
nodeTestResult?: any
}>()
const emit = defineEmits<{
'execution-status': [status: any]
}>()
const messages = ref<Message[]>([])
const inputMessage = ref('')
const loading = ref(false)
const messagesContainer = ref<HTMLElement>()
let pollingInterval: any = null
// 发送消息
const handleSendMessage = async () => {
@@ -208,8 +213,16 @@ const handleSendMessage = async () => {
// 轮询执行状态
const checkStatus = async () => {
try {
const statusResponse = await api.get(`/api/v1/executions/${execution.id}`)
const exec = statusResponse.data
// 获取详细执行状态(包含节点执行信息)
const statusResponse = await api.get(`/api/v1/executions/${execution.id}/status`)
const status = statusResponse.data
// 将执行状态传递给父组件,用于显示工作流动画
emit('execution-status', status)
// 获取执行详情(用于提取输出结果)
const execResponse = await api.get(`/api/v1/executions/${execution.id}`)
const exec = execResponse.data
if (exec.status === 'completed') {
// 提取Agent回复
@@ -244,6 +257,16 @@ const handleSendMessage = async () => {
loading.value = false
scrollToBottom()
// 延迟清除执行状态,让用户能看到最终的执行结果
setTimeout(() => {
emit('execution-status', null)
}, 3000) // 3秒后清除
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
} else if (exec.status === 'failed') {
messages.value.push({
role: 'agent',
@@ -252,9 +275,19 @@ const handleSendMessage = async () => {
})
loading.value = false
scrollToBottom()
// 延迟清除执行状态,让用户能看到失败节点的状态
setTimeout(() => {
emit('execution-status', null)
}, 5000) // 5秒后清除
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
} else {
// 继续轮询
setTimeout(checkStatus, 1000)
// 继续轮询pending 或 running 状态)
// 不需要做任何操作,等待下次轮询
}
} catch (error: any) {
messages.value.push({
@@ -264,11 +297,21 @@ const handleSendMessage = async () => {
})
loading.value = false
scrollToBottom()
// 清除执行状态
emit('execution-status', null)
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
}
// 开始轮询
setTimeout(checkStatus, 1000)
// 使用 setInterval 进行轮询每500毫秒检查一次更频繁能捕获快速执行的节点
pollingInterval = setInterval(checkStatus, 500)
// 立即执行一次
checkStatus()
} catch (error: any) {
console.error('发送消息失败:', error)
@@ -292,6 +335,13 @@ const handlePresetQuestion = (question: string) => {
// 清空对话
const handleClearChat = () => {
messages.value = []
// 清除执行状态
emit('execution-status', null)
// 清除轮询
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
}
// 滚动到底部
@@ -341,6 +391,16 @@ const handleCloseNodeTest = () => {
watch(messages, () => {
scrollToBottom()
}, { deep: true })
// 组件卸载时清理轮询
onUnmounted(() => {
if (pollingInterval) {
clearInterval(pollingInterval)
pollingInterval = null
}
// 清除执行状态
emit('execution-status', null)
})
</script>
<style scoped>

View File

@@ -78,21 +78,82 @@ export const StartNode = defineComponent({
height: '8px'
}
}),
// 状态指示器(右上角)
isExecuting ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#409eff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(64, 158, 255, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
}
}, [
h('div', {
class: 'node-loading-spinner',
style: {
width: '12px',
height: '12px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}
})
]) : null,
isExecuted ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#67c23a',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(103, 194, 58, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✓') : null,
isFailed ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#f56c6c',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(245, 108, 108, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✕') : null,
h('div', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px'
gap: '4px',
position: 'relative',
zIndex: 1
}
}, [
props.data.label || '开始',
isFailed ? h('span', {
style: {
fontSize: '12px',
marginLeft: '4px'
}
}, '❌') : null
props.data.label || '开始'
])
])
}
@@ -171,21 +232,82 @@ export const LLMNode = defineComponent({
height: '8px'
}
}),
// 状态指示器(右上角)
isExecuting ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#409eff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(64, 158, 255, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
}
}, [
h('div', {
class: 'node-loading-spinner',
style: {
width: '12px',
height: '12px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}
})
]) : null,
isExecuted ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#67c23a',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(103, 194, 58, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✓') : null,
isFailed ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#f56c6c',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(245, 108, 108, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✕') : null,
h('div', {
style: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px'
gap: '4px',
position: 'relative',
zIndex: 1
}
}, [
props.data.label || 'LLM',
isFailed ? h('span', {
style: {
fontSize: '12px',
marginLeft: '4px'
}
}, '❌') : null
props.data.label || 'LLM'
]),
h(Handle, {
type: 'source',
@@ -282,7 +404,77 @@ export const ConditionNode = defineComponent({
height: '8px'
}
}),
props.data.label || '条件',
// 状态指示器(右上角)
isExecuting ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#409eff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(64, 158, 255, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
}
}, [
h('div', {
class: 'node-loading-spinner',
style: {
width: '12px',
height: '12px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}
})
]) : null,
isExecuted ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#67c23a',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(103, 194, 58, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✓') : null,
isFailed ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#f56c6c',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(245, 108, 108, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✕') : null,
h('div', {
style: {
position: 'relative',
zIndex: 1
}
}, props.data.label || '条件'),
h(Handle, {
type: 'source',
position: Position.Bottom,
@@ -390,7 +582,77 @@ export const EndNode = defineComponent({
height: '8px'
}
}),
props.data.label || '结束'
// 状态指示器(右上角)
isExecuting ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#409eff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(64, 158, 255, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
}
}, [
h('div', {
class: 'node-loading-spinner',
style: {
width: '12px',
height: '12px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}
})
]) : null,
isExecuted ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#67c23a',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(103, 194, 58, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✓') : null,
isFailed ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#f56c6c',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(245, 108, 108, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✕') : null,
h('div', {
style: {
position: 'relative',
zIndex: 1
}
}, props.data.label || '结束')
])
}
}
@@ -466,7 +728,77 @@ export const DefaultNode = defineComponent({
height: '8px'
}
}),
props.data.label || '节点',
// 状态指示器(右上角)
isExecuting ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#409eff',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(64, 158, 255, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
}
}, [
h('div', {
class: 'node-loading-spinner',
style: {
width: '12px',
height: '12px',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid white',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite'
}
})
]) : null,
isExecuted ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#67c23a',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(103, 194, 58, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✓') : null,
isFailed ? h('div', {
style: {
position: 'absolute',
top: '-8px',
right: '-8px',
width: '20px',
height: '20px',
background: '#f56c6c',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(245, 108, 108, 0.6)',
zIndex: 3001, // 确保在模态框之上Element Plus 模态框 z-index 通常是 2000-2100
fontSize: '12px',
color: 'white'
}
}, '✕') : null,
h('div', {
style: {
position: 'relative',
zIndex: 1
}
}, props.data.label || '节点'),
h(Handle, {
type: 'source',
position: Position.Bottom,

View File

@@ -12,6 +12,10 @@
</el-button>
<el-button @click="handleRun">运行</el-button>
<el-button @click="handleClear">清空</el-button>
<el-button type="warning" @click="handleTestAnimation" :loading="testingAnimation">
<el-icon><VideoPlay /></el-icon>
{{ testingAnimation ? '测试中...' : '测试动画' }}
</el-button>
<el-button v-if="copiedNode" @click="handlePasteNodeFromButton" type="success">
<el-icon><DocumentCopy /></el-icon>
粘贴节点 (Ctrl+V)
@@ -1003,6 +1007,37 @@
</el-form-item>
</template>
<!-- 结束节点配置 -->
<template v-if="selectedNode.type === 'end' || selectedNode.type === 'output'">
<el-form-item label="输出格式">
<el-select v-model="selectedNode.data.output_format" placeholder="选择输出格式">
<el-option label="纯文本(适合对话)" value="text" />
<el-option label="JSON格式" value="json" />
</el-select>
<el-alert
type="info"
:closable="false"
show-icon
style="margin-top: 5px;"
>
<template #title>
<div style="font-size: 12px;">
<strong>纯文本</strong>自动提取文本内容适合对话场景<br/>
<strong>JSON格式</strong>保留完整数据结构适合API调用
</div>
</template>
</el-alert>
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="selectedNode.data.description"
type="textarea"
:rows="2"
placeholder="节点描述(可选)"
/>
</el-form-item>
</template>
<!-- 定时任务节点配置 -->
<template v-if="isScheduleNodeSelected">
<el-form-item label="延迟类型">
@@ -1314,6 +1349,8 @@ const handleManageTemplates = () => {
const executedNodeIds = ref<Set<string>>(new Set())
const runningNodeId = ref<string | null>(null)
const failedNodeIds = ref<Set<string>>(new Set())
const testingAnimation = ref(false)
const testAnimationTimer = ref<number | null>(null)
// 运行对话框
const runDialogVisible = ref(false)
@@ -1331,7 +1368,8 @@ const {
addEdges,
removeNodes,
removeEdges,
updateNode,
updateNode,
updateEdge,
screenToFlowCoordinate,
zoomIn: vueFlowZoomIn,
zoomOut: vueFlowZoomOut,
@@ -1558,6 +1596,10 @@ const handleDrop = (event: DragEvent) => {
body_type: 'text',
attachments: []
} : {}),
// 结束节点默认配置
...(nodeType === 'end' || nodeType === 'output' ? {
output_format: 'text' // 默认纯文本格式,适合对话场景
} : {}),
// 消息队列节点默认配置
...(isMQNode ? {
queue_type: 'rabbitmq',
@@ -2385,6 +2427,211 @@ const handleClear = () => {
ElMessage.success('画布已清空')
}
// 测试动画 - 依次执行所有节点,展示完整的工作流动画效果
const handleTestAnimation = () => {
// 清除之前的测试状态
if (testAnimationTimer.value) {
clearTimeout(testAnimationTimer.value)
testAnimationTimer.value = null
}
// 找到第一个开始节点
const startNode = nodes.value.find(n => n.type === 'start' || n.data?.type === 'start' || n.id.startsWith('start'))
if (!startNode) {
ElMessage.warning('未找到开始节点,请先添加一个开始节点')
return
}
console.log('[rjb] 🧪 开始测试动画节点ID:', startNode.id)
// 清除所有节点的执行状态
nodes.value.forEach(node => {
const nodeClass = (node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim()
if (nodeClass !== node.class) {
updateNode(node.id, {
class: nodeClass,
data: { ...node.data, executionStatus: undefined, executionClass: undefined }
})
}
})
// 清除所有边的执行状态
edges.value.forEach(edge => {
const edgeClass = (edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim()
if (edgeClass !== edge.class) {
if (updateEdge) {
updateEdge(edge.id, {
class: edgeClass,
style: {
...edge.style,
stroke: '#409eff',
strokeWidth: 2.5
},
animated: false
})
} else {
edge.class = edgeClass
edge.animated = false
}
}
})
// 使用广度优先搜索BFS找到所有节点的执行顺序
const getExecutionOrder = (startNodeId: string): string[] => {
const visited = new Set<string>()
const order: string[] = []
const queue: string[] = [startNodeId]
visited.add(startNodeId)
while (queue.length > 0) {
const currentNodeId = queue.shift()!
order.push(currentNodeId)
// 找到所有从当前节点出发的边
const outgoingEdges = edges.value.filter(e => e.source === currentNodeId)
for (const edge of outgoingEdges) {
if (!visited.has(edge.target)) {
visited.add(edge.target)
queue.push(edge.target)
}
}
}
// 如果还有未访问的节点(可能是孤立的),也加入顺序
nodes.value.forEach(node => {
if (!visited.has(node.id)) {
order.push(node.id)
}
})
return order
}
const executionOrder = getExecutionOrder(startNode.id)
console.log('[rjb] 🧪 节点执行顺序:', executionOrder)
if (executionOrder.length === 0) {
ElMessage.warning('未找到可执行的节点')
return
}
testingAnimation.value = true
// 依次执行每个节点
const executeNodeAnimation = (nodeIndex: number) => {
if (nodeIndex >= executionOrder.length) {
// 所有节点执行完成
testingAnimation.value = false
testAnimationTimer.value = null
ElMessage.success(`动画测试完成!共执行了 ${executionOrder.length} 个节点`)
return
}
const nodeId = executionOrder[nodeIndex]
const node = nodes.value.find(n => n.id === nodeId)
if (!node) {
// 如果节点不存在,跳过
executeNodeAnimation(nodeIndex + 1)
return
}
console.log(`[rjb] 🧪 [${nodeIndex + 1}/${executionOrder.length}] 开始执行节点:`, nodeId)
// 第一步:标记为执行中
const nodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' executing').trim()
const nodeData = { ...node.data, executionStatus: 'executing', executionClass: 'executing' }
updateNode(node.id, {
class: nodeClass,
data: nodeData
})
console.log('[rjb] 🧪 ✅ 标记节点为执行中:', nodeId)
// 高亮连接到当前节点的边(从上游节点来的边)
edges.value.forEach(edge => {
if (edge.target === nodeId) {
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executing').trim()
try {
if (updateEdge) {
updateEdge(edge.id, {
class: edgeClass,
style: {
...edge.style,
stroke: '#409eff',
strokeWidth: 3.5,
strokeDasharray: '8,4'
},
animated: true
})
} else {
edge.class = edgeClass
edge.style = {
...edge.style,
stroke: '#409eff',
strokeWidth: 3.5,
strokeDasharray: '8,4'
}
edge.animated = true
}
} catch (e) {
console.warn('[rjb] Failed to update edge:', e)
}
}
})
// 第二步1.5秒后标记为已完成,然后执行下一个节点
testAnimationTimer.value = window.setTimeout(() => {
const executedNodeClass = ((node.class || '').replace(/\b(executing|executed|failed)\b/g, '').trim() + ' executed').trim()
const executedNodeData = { ...node.data, executionStatus: 'executed', executionClass: 'executed' }
updateNode(node.id, {
class: executedNodeClass,
data: executedNodeData
})
console.log('[rjb] 🧪 ✅ 标记节点为已完成:', nodeId)
// 更新连接到当前节点的边为已完成状态
edges.value.forEach(edge => {
if (edge.target === nodeId) {
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executed').trim()
try {
if (updateEdge) {
updateEdge(edge.id, {
class: edgeClass,
style: {
...edge.style,
stroke: '#67c23a',
strokeWidth: 3,
strokeDasharray: '0'
},
animated: false
})
} else {
edge.class = edgeClass
edge.style = {
...edge.style,
stroke: '#67c23a',
strokeWidth: 3,
strokeDasharray: '0'
}
edge.animated = false
}
} catch (e) {
console.warn('[rjb] Failed to update edge:', e)
}
}
})
// 继续执行下一个节点
executeNodeAnimation(nodeIndex + 1)
}, 1500) // 每个节点执行1.5秒
}
// 开始执行第一个节点
executeNodeAnimation(0)
}
// 监听节点和边的变化,检查是否有变更
watch([nodes, edges], () => {
if (lastSavedData.value) {
@@ -2475,6 +2722,17 @@ watch(() => props.executionStatus, (newStatus, oldStatus) => {
const { current_node, executed_nodes, failed_nodes } = newStatus
console.log('[rjb] Execution status - current:', current_node, 'executed:', executed_nodes, 'failed:', failed_nodes)
console.log('[rjb] Execution status full:', JSON.stringify(newStatus, null, 2))
// 清除所有边的执行状态
edges.value.forEach(edge => {
if (edge.data) {
delete edge.data.executionStatus
}
if (edge.class) {
edge.class = edge.class.replace(/\b(edge-executing|edge-executed)\b/g, '').trim()
}
})
// 更新正在执行的节点
if (current_node && current_node.node_id) {
@@ -2486,7 +2744,49 @@ watch(() => props.executionStatus, (newStatus, oldStatus) => {
class: nodeClass,
data: nodeData
})
console.log('[rjb] ✅ Marking node as executing:', current_node.node_id, 'class:', nodeClass)
console.log('[rjb] ✅ Marking node as executing:', current_node.node_id, 'class:', nodeClass, 'nodeData:', nodeData)
// 高亮连接到正在执行节点的边
edges.value.forEach(edge => {
if (edge.target === current_node.node_id) {
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executing').trim()
try {
if (updateEdge) {
updateEdge(edge.id, {
class: edgeClass,
style: {
...edge.style,
stroke: '#409eff',
strokeWidth: 3.5,
strokeDasharray: '8,4'
},
animated: true
})
} else {
// 如果updateEdge不存在直接修改兼容旧版本
edge.class = edgeClass
edge.style = {
...edge.style,
stroke: '#409eff',
strokeWidth: 3.5,
strokeDasharray: '8,4'
}
edge.animated = true
}
} catch (e) {
console.warn('[rjb] Failed to update edge:', e)
// 直接修改作为后备方案
edge.class = edgeClass
edge.style = {
...edge.style,
stroke: '#409eff',
strokeWidth: 3.5,
strokeDasharray: '8,4'
}
edge.animated = true
}
}
})
} else {
console.warn('[rjb] ❌ Node not found:', current_node.node_id, 'available nodes:', nodes.value.map(n => n.id))
}
@@ -2505,6 +2805,36 @@ watch(() => props.executionStatus, (newStatus, oldStatus) => {
data: nodeData
})
console.log('[rjb] ✅ Marking node as executed:', executedNode.node_id, 'class:', nodeClass)
// 高亮连接到已执行节点的边
edges.value.forEach(edge => {
if (edge.target === executedNode.node_id) {
const edgeClass = ((edge.class || '').replace(/\b(edge-executing|edge-executed)\b/g, '').trim() + ' edge-executed').trim()
try {
updateEdge(edge.id, {
class: edgeClass,
style: {
...edge.style,
stroke: '#67c23a',
strokeWidth: 3,
strokeDasharray: '0'
},
animated: false
})
} catch (e) {
console.warn('[rjb] Failed to update edge:', e)
// 如果updateEdge不存在直接修改兼容旧版本
edge.class = edgeClass
edge.style = {
...edge.style,
stroke: '#67c23a',
strokeWidth: 3,
strokeDasharray: '0'
}
edge.animated = false
}
}
})
} else {
console.warn('[rjb] ❌ Executed node not found:', executedNode.node_id)
}
@@ -2719,6 +3049,12 @@ onUnmounted(() => {
// 禁用自动保存
disableAutoSave()
// 清理测试动画定时器
if (testAnimationTimer.value) {
clearTimeout(testAnimationTimer.value)
testAnimationTimer.value = null
}
// 断开协作连接
if (collaboration) {
collaboration.disconnect()
@@ -2987,19 +3323,26 @@ onUnmounted(() => {
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.5), 0 0 20px rgba(64, 158, 255, 0.8) !important;
animation: pulse-blue 1.5s infinite !important;
transform: scale(1.05) !important;
z-index: 10 !important;
z-index: 3000 !important; /* Element Plus 模态框 z-index 通常是 2000-2100我们设置更高 */
position: relative !important;
}
.custom-node.executed {
.custom-node.executed,
.vue-flow__node.executed .custom-node,
.vue-flow__node .custom-node.executed {
border: 3px solid #67c23a !important;
box-shadow: 0 0 0 3px rgba(103, 194, 58, 0.5), 0 2px 8px rgba(103, 194, 58, 0.3) !important;
z-index: 5 !important;
z-index: 2999 !important; /* 比执行中的节点稍低,但仍在模态框之上 */
position: relative !important;
}
.custom-node.failed {
.custom-node.failed,
.vue-flow__node.failed .custom-node,
.vue-flow__node .custom-node.failed {
border: 3px solid #f56c6c !important;
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.5), 0 2px 8px rgba(245, 108, 108, 0.3) !important;
z-index: 5 !important;
z-index: 2999 !important; /* 比执行中的节点稍低,但仍在模态框之上 */
position: relative !important;
}
/* 确保 Vue Flow 节点容器也能应用样式 */
@@ -3013,18 +3356,24 @@ onUnmounted(() => {
.vue-flow__node .custom-node.executing {
border-color: #409eff !important;
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.5), 0 0 20px rgba(64, 158, 255, 0.8) !important;
animation: pulse-blue 1.5s infinite !important;
animation: pulse-blue 1.5s infinite, node-pulse 2s ease-in-out infinite !important;
transform: scale(1.05) !important;
z-index: 3000 !important;
position: relative !important;
}
.vue-flow__node .custom-node.executed {
border-color: #67c23a !important;
box-shadow: 0 0 0 3px rgba(103, 194, 58, 0.5), 0 2px 8px rgba(103, 194, 58, 0.3) !important;
z-index: 2999 !important;
position: relative !important;
}
.vue-flow__node .custom-node.failed {
border-color: #f56c6c !important;
box-shadow: 0 0 0 3px rgba(245, 108, 108, 0.5), 0 2px 8px rgba(245, 108, 108, 0.3) !important;
z-index: 2999 !important;
position: relative !important;
}
@keyframes pulse-blue {
@@ -3039,6 +3388,86 @@ onUnmounted(() => {
}
}
/* 旋转动画 - 用于加载指示器 */
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 增强执行状态的动画效果 */
.custom-node.executing {
animation: pulse-blue 1.5s infinite, node-pulse 2s ease-in-out infinite !important;
}
@keyframes node-pulse {
0%, 100% {
transform: scale(1.05);
}
50% {
transform: scale(1.08);
}
}
/* 执行成功时的闪烁效果 */
.custom-node.executed {
animation: success-flash 0.5s ease-out !important;
}
@keyframes success-flash {
0% {
box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.5), 0 0 20px rgba(103, 194, 58, 0.8);
}
50% {
box-shadow: 0 0 0 8px rgba(103, 194, 58, 0.3), 0 0 30px rgba(103, 194, 58, 1);
}
100% {
box-shadow: 0 0 0 3px rgba(103, 194, 58, 0.5), 0 2px 8px rgba(103, 194, 58, 0.3);
}
}
/* 执行失败时的闪烁效果 */
.custom-node.failed {
animation: error-shake 0.5s ease-out !important;
}
@keyframes error-shake {
0%, 100% {
transform: translateX(0);
}
10%, 30%, 50%, 70%, 90% {
transform: translateX(-3px);
}
20%, 40%, 60%, 80% {
transform: translateX(3px);
}
}
/* 边的执行状态样式 */
.vue-flow__edge.edge-executing .vue-flow__edge-path {
stroke: #409eff !important;
stroke-width: 3.5 !important;
stroke-dasharray: 8,4 !important;
animation: edge-flow 1.5s linear infinite !important;
}
.vue-flow__edge.edge-executed .vue-flow__edge-path {
stroke: #67c23a !important;
stroke-width: 3 !important;
}
@keyframes edge-flow {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 12;
}
}
/* 全局样式 - 优化边的选中效果(类似 Dify */
.vue-flow__edge.selected .vue-flow__edge-path {
stroke: #67c23a !important;

View File

@@ -148,6 +148,19 @@ export const useAgentStore = defineStore('agent', () => {
}
}
// 复制Agent
const duplicateAgent = async (id: string, name?: string) => {
loading.value = true
try {
const requestData = name ? { name } : {}
const response = await api.post(`/api/v1/agents/${id}/duplicate`, requestData)
agents.value.unshift(response.data) // 添加到列表开头
return response.data
} finally {
loading.value = false
}
}
// 设置当前Agent
const setCurrentAgent = (agent: Agent | null) => {
currentAgent.value = agent
@@ -164,6 +177,7 @@ export const useAgentStore = defineStore('agent', () => {
deleteAgent,
deployAgent,
stopAgent,
duplicateAgent,
setCurrentAgent
}
})

View File

@@ -70,7 +70,7 @@
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<el-table-column label="操作" width="350" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">
<el-icon><Edit /></el-icon>
@@ -80,6 +80,10 @@
<el-icon><Setting /></el-icon>
设计
</el-button>
<el-button link type="info" @click="handleDuplicate(row)">
<el-icon><CopyDocument /></el-icon>
复制
</el-button>
<el-button
v-if="row.status === 'draft' || row.status === 'stopped'"
link
@@ -177,7 +181,8 @@ import {
Delete,
Setting,
VideoPlay,
VideoPause
VideoPause,
CopyDocument
} from '@element-plus/icons-vue'
import { useAgentStore } from '@/stores/agent'
import type { Agent } from '@/stores/agent'
@@ -374,6 +379,34 @@ const handleStop = async (agent: Agent) => {
}
}
// 复制
const handleDuplicate = async (agent: Agent) => {
try {
const { value } = await ElMessageBox.prompt(
`请输入新Agent的名称留空将自动生成`,
'复制Agent',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPlaceholder: `留空将使用: ${agent.name} (副本)`,
inputValidator: (val: string) => {
if (val && val.length > 100) {
return '名称长度不能超过100个字符'
}
return true
}
}
)
await agentStore.duplicateAgent(agent.id, value || undefined)
ElMessage.success('复制成功')
await loadAgents()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error(error.response?.data?.detail || '复制失败')
}
}
}
// 删除
const handleDelete = async (agent: Agent) => {
try {

View File

@@ -25,6 +25,7 @@
:workflow-id="undefined"
:initial-nodes="initialNodes"
:initial-edges="initialEdges"
:execution-status="executionStatus"
@save="handleSaveWorkflow"
@node-test="handleNodeTest"
/>
@@ -37,6 +38,7 @@
:opening-message="openingMessage"
:preset-questions="presetQuestions"
:node-test-result="nodeTestResult"
@execution-status="handleChatExecutionStatus"
/>
</div>
</div>
@@ -200,6 +202,12 @@ const loadWorkflowData = async () => {
}
}
// 处理聊天界面的执行状态更新
const handleChatExecutionStatus = (status: any) => {
console.log('[rjb] WorkflowDesigner received execution status from chat:', JSON.stringify(status, null, 2))
executionStatus.value = status
}
// 测试Agent
const handleTestAgent = () => {
testInput.value = '{}'
@@ -214,8 +222,8 @@ const handleRunTest = async () => {
testing.value = true
testResult.value = null
// 轮询超时时间(60秒
const maxPollingTime = 60000
// 轮询超时时间(5分钟复杂工作流可能需要更长时间
const maxPollingTime = 5 * 60 * 1000 // 5分钟 = 300000毫秒
const startTime = Date.now()
let pollingInterval: any = null