|
|
@@ -1,5 +1,10 @@
|
|
|
<template>
|
|
|
- <div class="h-full w-full" ref="workflowWrapperRef" @drop="onDrop">
|
|
|
+ <div
|
|
|
+ class="h-full w-full"
|
|
|
+ ref="workflowWrapperRef"
|
|
|
+ @drop="onDrop"
|
|
|
+ @contextmenu="handleWorkflowContextMenu"
|
|
|
+ >
|
|
|
<Workflow
|
|
|
ref="workflowRef"
|
|
|
:id="workflow?.id"
|
|
|
@@ -61,17 +66,44 @@
|
|
|
>
|
|
|
<div ref="nodeLibraryPanelRef">
|
|
|
<NodeLibary
|
|
|
- @add-node="handleNodeCreate"
|
|
|
+ @add-node="handleNodeCreateFromLibrary"
|
|
|
:parent-node-type="nodeLibaryParentType"
|
|
|
hide-start
|
|
|
ignore-drag
|
|
|
/>
|
|
|
</div>
|
|
|
</el-popover>
|
|
|
+ <Teleport to="body">
|
|
|
+ <div
|
|
|
+ v-if="contextMenuVisible"
|
|
|
+ ref="contextMenuRef"
|
|
|
+ class="workflow-context-menu"
|
|
|
+ :style="contextMenuStyle"
|
|
|
+ @contextmenu.prevent
|
|
|
+ >
|
|
|
+ <button type="button" class="workflow-context-menu__item" @click="handleContextAddNode">
|
|
|
+ <Icon icon="lucide:plus" class="workflow-context-menu__icon">+</Icon>
|
|
|
+ <span>添加节点</span>
|
|
|
+ </button>
|
|
|
+ <button type="button" class="workflow-context-menu__item" @click="handleContextAddStickyNote">
|
|
|
+ <Icon icon="lucide:file-plus-corner" class="workflow-context-menu__icon"></Icon>
|
|
|
+ <span>添加注释</span>
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ class="workflow-context-menu__item"
|
|
|
+ :class="{ 'is-disabled': !selectedNodeId }"
|
|
|
+ @click="handleContextRunNode"
|
|
|
+ >
|
|
|
+ <Icon icon="lucide:play" class="workflow-context-menu__icon"></Icon>
|
|
|
+ <span>测试运行</span>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </Teleport>
|
|
|
</template>
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
-import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
|
import { agent } from '@repo/api-service'
|
|
|
|
|
|
@@ -86,6 +118,7 @@ import { useRunnerStore } from '@/store/modules/runner.store'
|
|
|
import { Workflow, useDragAndDrop } from '@repo/workflow'
|
|
|
import { useDebounceFn } from '@vueuse/core'
|
|
|
import { isEqual } from 'lodash-es'
|
|
|
+import { Icon } from '@repo/ui'
|
|
|
|
|
|
import type {
|
|
|
CanvasExecutionStatus,
|
|
|
@@ -133,6 +166,10 @@ const showNodeLibary = ref(false)
|
|
|
const libaryRefferenceRef = ref<HTMLElement>()
|
|
|
const nodeLibaryPopoverAnchorRef = ref<HTMLElement>()
|
|
|
const nodeLibraryPanelRef = ref<HTMLElement>()
|
|
|
+const contextMenuRef = ref<HTMLElement>()
|
|
|
+const contextMenuVisible = ref(false)
|
|
|
+const contextMenuPosition = ref({ x: 0, y: 0 })
|
|
|
+const contextMenuFlowPosition = ref<XYPosition>({ x: 0, y: 0 })
|
|
|
const runVisible = ref(false)
|
|
|
const closeRunWorkflowOnSubmit = ref(false)
|
|
|
const runWorkflowInputOnly = ref(false)
|
|
|
@@ -154,11 +191,22 @@ const peddingHandlePayload = ref<{
|
|
|
|
|
|
const runningStatusStartedAt = new Map<string, number>()
|
|
|
const pendingNodeStatusTimers = new Map<string, number>()
|
|
|
+let hideNodeLibaryTimer: number | undefined
|
|
|
const pendingEdges = ref<
|
|
|
Array<Connection & { id: string; type?: string; data?: Record<string, unknown> }>
|
|
|
>([])
|
|
|
const pendingNodes = ref<IWorkflowNode[]>([])
|
|
|
|
|
|
+const selectedNodeId = computed(() => {
|
|
|
+ const selectedNode = props.workflow?.nodes?.find((node) => node.selected)
|
|
|
+ return selectedNode?.id || ''
|
|
|
+})
|
|
|
+
|
|
|
+const contextMenuStyle = computed(() => ({
|
|
|
+ left: `${contextMenuPosition.value.x}px`,
|
|
|
+ top: `${contextMenuPosition.value.y}px`
|
|
|
+}))
|
|
|
+
|
|
|
const removeNodeLibaryPopoverAnchor = () => {
|
|
|
nodeLibaryPopoverAnchorRef.value?.remove()
|
|
|
nodeLibaryPopoverAnchorRef.value = undefined
|
|
|
@@ -184,6 +232,31 @@ const createNodeLibaryPopoverAnchor = (position?: { x: number; y: number }) => {
|
|
|
return anchor
|
|
|
}
|
|
|
|
|
|
+const closeContextMenu = () => {
|
|
|
+ contextMenuVisible.value = false
|
|
|
+}
|
|
|
+
|
|
|
+const getFlowPositionFromMouseEvent = (event: MouseEvent): XYPosition => {
|
|
|
+ const screenToFlowCoordinate = workflowRef.value?.getVueFlow()?.screenToFlowCoordinate
|
|
|
+ if (screenToFlowCoordinate) {
|
|
|
+ return screenToFlowCoordinate({
|
|
|
+ x: event.clientX,
|
|
|
+ y: event.clientY
|
|
|
+ })
|
|
|
+ }
|
|
|
+
|
|
|
+ const viewport = workflowRef.value?.getVueFlow()?.viewport?.value
|
|
|
+ const bounds = workflowWrapperRef.value?.getBoundingClientRect()
|
|
|
+ if (viewport && bounds) {
|
|
|
+ return {
|
|
|
+ x: (event.clientX - bounds.left - viewport.x) / viewport.zoom,
|
|
|
+ y: (event.clientY - bounds.top - viewport.y) / viewport.zoom
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { x: 0, y: 0 }
|
|
|
+}
|
|
|
+
|
|
|
// 在为节点安排新的状态切换前,先清理旧的延时任务。
|
|
|
const clearPendingNodeStatusTimer = (nodeId: string) => {
|
|
|
const timer = pendingNodeStatusTimers.get(nodeId)
|
|
|
@@ -538,6 +611,31 @@ const handleRunAgent = (id?: string) => {
|
|
|
handleRunNode(targetNode.id)
|
|
|
}
|
|
|
|
|
|
+const createStickyNoteNode = (position: XYPosition = { x: 600, y: 300 }) => {
|
|
|
+ props.workflow?.nodes.push({
|
|
|
+ appAgentId: props.workflow.id,
|
|
|
+ type: 'canvas-node',
|
|
|
+ zIndex: -1,
|
|
|
+ nodeType: 'stickyNote',
|
|
|
+ position,
|
|
|
+ id: `stickyNote_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
|
|
+ name: t('pages.nodeView.stickyNote.name'),
|
|
|
+ remark: '',
|
|
|
+ data: {
|
|
|
+ id: '',
|
|
|
+ version: ['1.0.0'],
|
|
|
+ inputs: [],
|
|
|
+ outputs: [],
|
|
|
+ position,
|
|
|
+ nodeType: 'stickyNote',
|
|
|
+ content: t('pages.nodeView.stickyNote.content'),
|
|
|
+ width: 400,
|
|
|
+ height: 200,
|
|
|
+ color: '#fff5d6'
|
|
|
+ }
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
watch(
|
|
|
() => props.workflow?.nodes,
|
|
|
async (nodes) => {
|
|
|
@@ -559,32 +657,16 @@ watch(
|
|
|
const handleNodeCreate = (value: { type: string; position?: XYPosition } | string) => {
|
|
|
if (typeof value === 'string') {
|
|
|
if (value === 'stickyNote') {
|
|
|
- props.workflow?.nodes.push({
|
|
|
- appAgentId: props.workflow.id,
|
|
|
- type: 'canvas-node',
|
|
|
- zIndex: -1,
|
|
|
- nodeType: 'stickyNote',
|
|
|
- position: { x: 600, y: 300 },
|
|
|
- id: 'stickyNote',
|
|
|
- name: t('pages.nodeView.stickyNote.name'),
|
|
|
- remark: '',
|
|
|
- data: {
|
|
|
- id: '',
|
|
|
- version: ['1.0.0'],
|
|
|
- inputs: [],
|
|
|
- outputs: [],
|
|
|
- position: { x: 600, y: 300 },
|
|
|
- nodeType: 'stickyNote',
|
|
|
- content: t('pages.nodeView.stickyNote.content'),
|
|
|
- width: 400,
|
|
|
- height: 200,
|
|
|
- color: '#fff5d6'
|
|
|
- }
|
|
|
- })
|
|
|
+ createStickyNoteNode()
|
|
|
}
|
|
|
return
|
|
|
}
|
|
|
|
|
|
+ if (value.type === 'stickyNote') {
|
|
|
+ createStickyNoteNode(value.position)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
const nodeToAdd = nodeMap[value.type]?.schema
|
|
|
const viewport = workflowRef.value?.getVueFlow()?.viewport
|
|
|
const parentNodeType = getNodeTypeById(peddingHandlePayload.value?.parentId)
|
|
|
@@ -716,6 +798,77 @@ const handleNodeCreate = (value: { type: string; position?: XYPosition } | strin
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const handleNodeCreateFromLibrary = (value: { type: string; position?: XYPosition } | string) => {
|
|
|
+ if (typeof value === 'string') {
|
|
|
+ handleNodeCreate(value)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ handleNodeCreate({
|
|
|
+ ...value,
|
|
|
+ position: value.position || contextMenuFlowPosition.value
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const openNodeLibraryAtContextMenu = async () => {
|
|
|
+ await nextTick()
|
|
|
+ if (hideNodeLibaryTimer) {
|
|
|
+ window.clearTimeout(hideNodeLibaryTimer)
|
|
|
+ hideNodeLibaryTimer = undefined
|
|
|
+ }
|
|
|
+ libaryRefferenceRef.value = createNodeLibaryPopoverAnchor(contextMenuPosition.value)
|
|
|
+ peddingHandlePayload.value = {
|
|
|
+ position: contextMenuFlowPosition.value
|
|
|
+ }
|
|
|
+ showNodeLibary.value = true
|
|
|
+}
|
|
|
+
|
|
|
+const handleContextAddNode = () => {
|
|
|
+ closeContextMenu()
|
|
|
+ void openNodeLibraryAtContextMenu()
|
|
|
+}
|
|
|
+
|
|
|
+const handleContextAddStickyNote = () => {
|
|
|
+ closeContextMenu()
|
|
|
+ handleNodeCreate({
|
|
|
+ type: 'stickyNote',
|
|
|
+ position: contextMenuFlowPosition.value
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+const handleContextRunNode = () => {
|
|
|
+ if (!selectedNodeId.value) {
|
|
|
+ ElMessage.warning(t('pages.nodeView.messages.selectNodeFirst'))
|
|
|
+ closeContextMenu()
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ closeContextMenu()
|
|
|
+ void handleRunNode(selectedNodeId.value)
|
|
|
+}
|
|
|
+
|
|
|
+const handleWorkflowContextMenu = (event: MouseEvent) => {
|
|
|
+ const target = event.target as HTMLElement | null
|
|
|
+ if (target?.closest('.workflow-context-menu')) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const wrapperEl = workflowWrapperRef.value
|
|
|
+ if (!wrapperEl?.contains(target)) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ event.preventDefault()
|
|
|
+ onHideNodeLibary()
|
|
|
+
|
|
|
+ contextMenuFlowPosition.value = getFlowPositionFromMouseEvent(event)
|
|
|
+ contextMenuPosition.value = {
|
|
|
+ x: event.clientX,
|
|
|
+ y: event.clientY
|
|
|
+ }
|
|
|
+ contextMenuVisible.value = true
|
|
|
+}
|
|
|
+
|
|
|
// 双击节点时打开对应的配置面板。
|
|
|
const handleNodeClick = (id: string, _position: XYPosition) => {
|
|
|
openSetter(id, 'setting')
|
|
|
@@ -1005,14 +1158,26 @@ const onConnectionOpenNodeLibary = async (payload: {
|
|
|
const onHideNodeLibary = () => {
|
|
|
showNodeLibary.value = false
|
|
|
|
|
|
- setTimeout(() => {
|
|
|
+ if (hideNodeLibaryTimer) {
|
|
|
+ window.clearTimeout(hideNodeLibaryTimer)
|
|
|
+ }
|
|
|
+
|
|
|
+ hideNodeLibaryTimer = window.setTimeout(() => {
|
|
|
peddingHandlePayload.value = undefined
|
|
|
libaryRefferenceRef.value = undefined
|
|
|
removeNodeLibaryPopoverAnchor()
|
|
|
+ hideNodeLibaryTimer = undefined
|
|
|
}, 500)
|
|
|
}
|
|
|
|
|
|
const onGlobalPointerDown = (event: PointerEvent) => {
|
|
|
+ if (contextMenuVisible.value) {
|
|
|
+ const target = event.target as Node | null
|
|
|
+ if (!target || !contextMenuRef.value?.contains(target)) {
|
|
|
+ closeContextMenu()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
if (!showNodeLibary.value) {
|
|
|
return
|
|
|
}
|
|
|
@@ -1037,15 +1202,18 @@ const onGlobalPointerDown = (event: PointerEvent) => {
|
|
|
}
|
|
|
|
|
|
const onGlobalKeyDown = (event: KeyboardEvent) => {
|
|
|
- if (!showNodeLibary.value) {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
if (event.key === 'Escape') {
|
|
|
- onHideNodeLibary()
|
|
|
+ closeContextMenu()
|
|
|
+ if (showNodeLibary.value) {
|
|
|
+ onHideNodeLibary()
|
|
|
+ }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+const onGlobalScroll = () => {
|
|
|
+ closeContextMenu()
|
|
|
+}
|
|
|
+
|
|
|
// 根据边上加号按钮的位置打开节点库弹层。
|
|
|
const handleClickConectionAdd = (connection: Connection & { id: string }, parentId?: string) => {
|
|
|
const el = document.querySelector(`[edge-add-btn="${connection.id}"]`) as HTMLElement
|
|
|
@@ -1087,24 +1255,69 @@ const handleViewportChange = useDebounceFn((viewport: { x: number; y: number; zo
|
|
|
}, 1000)
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
+ if (hideNodeLibaryTimer) {
|
|
|
+ window.clearTimeout(hideNodeLibaryTimer)
|
|
|
+ }
|
|
|
removeNodeLibaryPopoverAnchor()
|
|
|
resetDisplayedNodeStatuses()
|
|
|
document.removeEventListener('pointerdown', onGlobalPointerDown, true)
|
|
|
document.removeEventListener('keydown', onGlobalKeyDown)
|
|
|
+ window.removeEventListener('scroll', onGlobalScroll, true)
|
|
|
})
|
|
|
|
|
|
-watch(
|
|
|
- showNodeLibary,
|
|
|
- (visible) => {
|
|
|
- if (visible) {
|
|
|
- document.addEventListener('pointerdown', onGlobalPointerDown, true)
|
|
|
- document.addEventListener('keydown', onGlobalKeyDown)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- document.removeEventListener('pointerdown', onGlobalPointerDown, true)
|
|
|
- document.removeEventListener('keydown', onGlobalKeyDown)
|
|
|
- },
|
|
|
- { immediate: true }
|
|
|
-)
|
|
|
+onMounted(() => {
|
|
|
+ document.addEventListener('pointerdown', onGlobalPointerDown, true)
|
|
|
+ document.addEventListener('keydown', onGlobalKeyDown)
|
|
|
+ window.addEventListener('scroll', onGlobalScroll, true)
|
|
|
+})
|
|
|
</script>
|
|
|
+
|
|
|
+<style scoped lang="less">
|
|
|
+.workflow-context-menu {
|
|
|
+ position: fixed;
|
|
|
+ z-index: 3000;
|
|
|
+ min-width: 148px;
|
|
|
+ padding: 6px;
|
|
|
+ border: 1px solid #eaecf0;
|
|
|
+ border-radius: 8px;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 12px 28px rgba(16, 24, 40, 0.14);
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-context-menu__item {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ width: 100%;
|
|
|
+ height: 34px;
|
|
|
+ padding: 0 10px;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 6px;
|
|
|
+ background: transparent;
|
|
|
+ color: #344054;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1;
|
|
|
+ text-align: left;
|
|
|
+ cursor: pointer;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-context-menu__item:hover:not(.is-disabled) {
|
|
|
+ background: #f2f4f7;
|
|
|
+ color: #296dff;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-context-menu__item.is-disabled {
|
|
|
+ color: #98a2b3;
|
|
|
+ cursor: not-allowed;
|
|
|
+}
|
|
|
+
|
|
|
+.workflow-context-menu__icon {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 16px;
|
|
|
+ height: 16px;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 700;
|
|
|
+}
|
|
|
+</style>
|