Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/loop-body-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar class="ai-avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 74%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/loop-break-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar class="ai-avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 74%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/loop-continue-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar class="ai-avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 74%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/loop-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar class="ai-avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 74%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
6 changes: 6 additions & 0 deletions ui/src/workflow-canvas/icons/loop-start-node-icon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<template>
<el-avatar class="ai-avatar-gradient" shape="square">
<img src="@/assets/workflow/icon_ai_chat.svg" style="width: 74%" alt="" />
</el-avatar>
</template>
<script setup lang="ts"></script>
3 changes: 2 additions & 1 deletion ui/src/workflow-canvas/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cloneDeep } from 'lodash'
import LogicFlow, { type GraphModel } from '@logicflow/core'
import { SelectionSelect } from '@logicflow/extension'
import AppEdge from '@/workflow-canvas/core/edge/index'
import LoopEdge from '@/workflow-canvas/core/edge/loop-edge'
import { initDefaultShortcut } from '@/workflow-canvas/core/shortcut'
import { disconnectAll, getTeleport } from '@/workflow-canvas/core/teleport'
import type { WorkflowNodeModel } from '@/workflow-canvas/core/workflow-node'
Expand Down Expand Up @@ -66,7 +67,7 @@ function renderGraphData(data: LogicFlow.GraphConfigData = props.data ?? {}) {
initDefaultShortcut(lf.value, lf.value.graphModel)
// 节点按需读取页面传入的已保存配置,保持与保存响应一致。
lf.value.graphModel.getDefaultModelSettings = () => props.defaultModelSettings
lf.value.batchRegister([...Object.values(nodeModules).map(({ default: node }) => node), AppEdge])
lf.value.batchRegister([...Object.values(nodeModules).map(({ default: node }) => node), AppEdge, LoopEdge])
lf.value.setDefaultEdgeType('app-edge')
lf.value.render(data ? data : {})

Expand Down
2 changes: 1 addition & 1 deletion ui/src/workflow-canvas/node-menu/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function handleDragStart(node: NodeMenuItem, event: PointerEvent) {
<el-tabs v-model="activeTab" class="small p-4 pb-0">
<el-tab-pane label="基础组件" name="basic" />
<el-tab-pane label="工具" name="tool" />
<el-tab-pane label="智能体" name="application" v-if="workflowMode === 'application'" />
<el-tab-pane label="智能体" name="application" v-if="workflowMode === 'application' || workflowMode === 'application-loop'" />
</el-tabs>

<KeepAlive>
Expand Down
76 changes: 76 additions & 0 deletions ui/src/workflow-canvas/nodes/loop-body-node/LoopBodyContainer.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<template>
<div class="workflow-loop-body relative overflow-visible p-4">
<div class="step-container" :class="{ isSelected: nodeSelected, error: nodeStatus !== 200 }">
<div class="flex-between">
<div class="flex min-w-0 items-center gap-2">
<component :is="iconComponent(`${model.type}-icon`)" class="mr-1" :size="24" :item="model.properties.node_data" />
<h4 class="truncate break-all" :title="String(model.properties.stepName ?? '')">{{ model.properties.stepName }}</h4>
</div>
<div class="flex items-center gap-1" @mousedown.stop @keydown.stop @click.stop>
<el-button text @click="layout">
<MkIcon name="icon_magnify_outlined" />
</el-button>
<el-button text @click="showNode = !showNode">
<MkIcon name="icon_down_outlined" />
</el-button>
</div>
</div>
<el-collapse-transition>
<div v-show="showNode" class="mt-2">
<div :style="`height:${canvasHeight}px`"><slot /></div>
</div>
</el-collapse-transition>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { set } from 'lodash'
import type { BaseNodeModel } from '@logicflow/core'
import { iconComponent } from '@/workflow-canvas/icons/utils'

defineOptions({ name: 'WorkflowLoopBodyContainer' })
const props = defineProps<{ nodeModel: BaseNodeModel }>()
const model = computed(() => props.nodeModel)

const nodeSelected = ref(model.value.isSelected)
watch(() => model.value.isSelected, (value) => (nodeSelected.value = value))

const nodeStatus = computed(() => (model.value.properties.status as number | undefined) ?? 200)

const showNode = computed({
get: () => {
if (model.value.properties.showNode !== undefined) return model.value.properties.showNode
set(model.value.properties, 'showNode', true)
return true
},
set: (v) => set(model.value.properties, 'showNode', v),
})

const canvasHeight = ref(1000)

function layout() {
model.value.loopLayout?.()
}
</script>
<style lang="scss" scoped>
.workflow-loop-body {
.step-container {
box-sizing: border-box;
border-radius: 8px;
border: 2px solid #fff;
background: #fff;
box-shadow: 0 2px 4px 0 rgb(var(--el-text-color-primary-rgb) / 12%);
&:hover {
box-shadow: 0 6px 24px 0 rgb(var(--el-text-color-primary-rgb) / 8%);
}
&.isSelected {
border-color: var(--mk-primary);
}
&.error {
border-color: var(--mk-danger);
border-width: 1px;
}
}
}
</style>
29 changes: 29 additions & 0 deletions ui/src/workflow-canvas/nodes/loop-body-node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import LoopBodyNodeVue from './index.vue'
import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node'
import { WorkflowNodeType } from '@/workflow-canvas/types'
import type { Model } from '@logicflow/core'

class LoopBodyModel extends WorkflowNodeModel {
setLoopBody?: () => void
loopLayout?: () => void

getDefaultAnchor(): Model.AnchorConfig[] {
return [{ x: this.x, y: this.y - this.height / 2 + 10, id: `${this.id}_children`, type: 'children', edgeAddable: false }]
}

refreshBranch() {
;[...this.incoming.edges, ...this.outgoing.edges].forEach((edge) => (edge as { updatePathByAnchor?: () => void }).updatePathByAnchor?.())
}
}

class LoopBodyNodeView extends WorkflowNodeView {
constructor(props: ConstructorParameters<typeof WorkflowNodeView>[0]) {
super(props, LoopBodyNodeVue)
}
}

export default {
type: WorkflowNodeType.LoopBodyNode,
model: LoopBodyModel,
view: LoopBodyNodeView,
}
190 changes: 190 additions & 0 deletions ui/src/workflow-canvas/nodes/loop-body-node/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<template>
<LoopBodyContainer :node-model="model">
<div
ref="containerRef"
class="h-full w-full"
@wheel.stop
@mousedown.stop
@click.stop
@dblclick.stop
@contextmenu.stop
@pointerdown.stop
@keydown.stop
@keyup.stop
/>
</LoopBodyContainer>
</template>
<script setup lang="ts">
import { inject, onMounted, onUnmounted, shallowRef, useTemplateRef } from 'vue'
import { cloneDeep, set } from 'lodash'
import LogicFlow from '@logicflow/core'
import { SelectionSelect } from '@logicflow/extension'
import type { BaseNodeModel, GraphModel } from '@logicflow/core'
import LoopBodyContainer from './LoopBodyContainer.vue'
import Dagre from '@/workflow-canvas/plugins/dagre'
import AppEdge from '@/workflow-canvas/core/edge/index'
import LoopEdge from '@/workflow-canvas/core/edge/loop-edge'
import { initDefaultShortcut } from '@/workflow-canvas/core/shortcut'
import { disconnectByFlow } from '@/workflow-canvas/core/teleport'
import { KnowledgeWorkFlowInstance, WorkFlowInstance } from '@/workflow-canvas/core/validate'
import { WorkflowMode, type ShapeItem } from '@/workflow-canvas/types'

defineOptions({ name: 'WorkflowLoopBodyNode' })
const getModel = inject('getModel') as () => BaseNodeModel
const model = getModel()

const parentLoopWorkflowMode = inject<WorkflowMode>('loopWorkflowMode') || WorkflowMode.ApplicationLoop
const apiType = (inject('apiType') as string) || 'workspace'
const currentResource = inject<unknown>('currentResource')
const selectModelList = inject<(params: { model_type: string }) => unknown>('getSelectModelList')
const modelParamsForm = inject<(modelId: string) => unknown>('getModelParamsForm')

const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const lf = shallowRef<LogicFlow | null>(null)

const nodeModules = import.meta.glob<{ default: LogicFlow.RegisterConfig }>('../*/index.ts', { eager: true })

function nestedStartDrag(shapeItem: ShapeItem, event?: PointerEvent) {
const lfInstance = lf.value
if (!lfInstance) return
if (shapeItem.type) {
lfInstance.dnd.startDrag({ type: shapeItem.type as string, properties: cloneDeep(shapeItem.properties ?? {}) })
if (event) {
const containerRect = lfInstance.container.getBoundingClientRect()
const isInsideCanvas =
event.clientX >= containerRect.left &&
event.clientX <= containerRect.right &&
event.clientY >= containerRect.top &&
event.clientY <= containerRect.bottom
if (isInsideCanvas) lfInstance.dnd.dragEnter(event)
}
}
shapeItem.callback?.(lfInstance)
}

const setLoopBody = () => {
const loopNode = model.graphModel.getNodeModelById(String(model.properties.loop_node_id))
if (loopNode) {
if (!loopNode.properties.node_data) set(loopNode.properties, 'node_data', { loop_type: 'ARRAY', array: [], number: 1 })
loopNode.properties.node_data.loop = { x: model.x, y: model.y }
loopNode.properties.node_data.loop_body = lf.value?.getGraphData()
}
}

const validate = () => {
const lfInstance = lf.value
if (!lfInstance) return Promise.resolve({})
const graphData = (lfInstance.getGraphData() ?? { nodes: [], edges: [] }) as unknown as ConstructorParameters<typeof WorkFlowInstance>[0]
const workflow =
parentLoopWorkflowMode === WorkflowMode.KnowledgeLoop
? new KnowledgeWorkFlowInstance(graphData, parentLoopWorkflowMode)
: new WorkFlowInstance(graphData, parentLoopWorkflowMode)

return Promise.all(lfInstance.graphModel.nodes.map((element) => (element as { validate?: () => Promise<unknown> }).validate?.()))
.then(() => {
const loopNode = model.graphModel.getNodeModelById(String(model.properties.loop_node_id))
try {
workflow.is_loop_valid()
const nodeData = loopNode?.properties.node_data as { loop_type?: string } | undefined
if (nodeData?.loop_type === 'LOOP' && !workflow.exist_break_node()) {
return Promise.reject({ node: loopNode, errMessage: '循环节点需要包含Break节点' })
}
return Promise.resolve({})
} catch (error) {
return Promise.reject({ node: loopNode, errMessage: error })
}
})
.catch((error) => {
model.graphModel.selectNodeById(model.id)
model.graphModel.transformModel.focusOn(model.x, model.y, model.width, model.height)
throw error
})
}

const loopLayout = () => {
const extension = lf.value?.extension as { dagre?: { layout?: () => unknown } } | undefined
extension?.dagre?.layout?.()
}

const renderGraphData = (data?: LogicFlow.GraphConfigData) => {
const container = containerRef.value
if (!container) return

lf.value = new LogicFlow({
plugins: [Dagre, SelectionSelect],
textEdit: false,
adjustEdge: false,
adjustEdgeStartAndEnd: false,
background: { backgroundColor: '#f5f6f7' },
grid: { size: 20, type: 'dot', config: { color: '#DEE0E3', thickness: 1 } },
keyboard: { enabled: true },
isSilentMode: false,
container,
})
const lfInstance = lf.value
lfInstance.setTheme({ bezier: { stroke: '#afafaf', strokeWidth: 1 } })

// 嵌套画布位于主画布 foreignObject 内,会随主画布缩放/平移而放大。
// 覆写 HtmlPointToCanvasPoint,把主画布的 scale/translate 乘进去,使鼠标坐标、节点拖动距离、连线位置与真实位置一致。
const nestedTransform = lfInstance.graphModel.transformModel
const parentTransform = model.graphModel.transformModel
lfInstance.graphModel.transformModel.HtmlPointToCanvasPoint = (point: LogicFlow.PointTuple): LogicFlow.PointTuple => {
let scaleX = nestedTransform.SCALE_X
let scaleY = nestedTransform.SCALE_Y
let translateX = nestedTransform.TRANSLATE_X
let translateY = nestedTransform.TRANSLATE_Y
const [x, y] = point
scaleX *= parentTransform.SCALE_X
scaleY *= parentTransform.SCALE_Y
translateX *= parentTransform.SCALE_X
translateY *= parentTransform.SCALE_Y
return [(x - translateX) / scaleX, (y - translateY) / scaleY]
}

initDefaultShortcut(lfInstance, lfInstance.graphModel)
lfInstance.graphModel.get_provide = (node: LogicFlow.NodeData | null, graph: GraphModel | null) => ({
getModel: () => node,
getGraph: () => graph,
workflowMode: parentLoopWorkflowMode,
loopWorkflowMode: parentLoopWorkflowMode,
currentResource,
apiType,
getSelectModelList: selectModelList,
getModelParamsForm: modelParamsForm,
startDragNode: nestedStartDrag,
})
lfInstance.graphModel.refresh_loop_fields = (fields: Array<{ label: string; value: string }>) => {
const loopNode = model.graphModel.getNodeModelById(String(model.properties.loop_node_id))
if (loopNode) {
loopNode.properties.config.fields = fields
loopNode.clearNextNodeField?.(true)
}
}
lfInstance.graphModel.getUpNodeFieldList = (containSelf: boolean, useCache: boolean) => model.getUpNodeFieldList(containSelf, useCache)
lfInstance.graphModel.get_parent_nodes = () => model.graphModel.nodes

lfInstance.batchRegister([...Object.values(nodeModules).map(({ default: node }) => node), AppEdge, LoopEdge])
lfInstance.setDefaultEdgeType('app-edge')
lfInstance.graphModel.eventCenter.on('delete_edge', (edgeIds: string[]) => {
edgeIds.forEach((edgeId) => lfInstance.deleteEdge(edgeId))
})
lfInstance.graphModel.eventCenter.on('anchor:drop', (event) => {
const nodeModel = event.nodeModel as BaseNodeModel
;(nodeModel as { clearNextNodeField?: (containSelf: boolean) => void }).clearNextNodeField?.(false)
})
lfInstance.render(data ?? {})
}

onMounted(() => {
renderGraphData(cloneDeep(model.properties.workflow as LogicFlow.GraphConfigData))
set(model, 'setLoopBody', setLoopBody)
set(model, 'set_loop_body', setLoopBody)
set(model, 'validate', validate)
set(model, 'loopLayout', loopLayout)
})

onUnmounted(() => {
disconnectByFlow(lf.value?.graphModel.flowId ?? '')
lf.value = null
})
</script>
15 changes: 15 additions & 0 deletions ui/src/workflow-canvas/nodes/loop-break-node/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import LoopBreakNodeVue from './index.vue'
import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node'
import { WorkflowNodeType } from '@/workflow-canvas/types'

class LoopBreakNodeView extends WorkflowNodeView {
constructor(props: ConstructorParameters<typeof WorkflowNodeView>[0]) {
super(props, LoopBreakNodeVue)
}
}

export default {
type: WorkflowNodeType.LoopBreakNode,
model: WorkflowNodeModel,
view: LoopBreakNodeView,
}
21 changes: 21 additions & 0 deletions ui/src/workflow-canvas/nodes/loop-break-node/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<template>
<NodeContainer :node-model="model">
<div class="text-sm text-N600">终止当前循环,跳出循环体</div>
</NodeContainer>
</template>
<script setup lang="ts">
import { inject, onMounted } from 'vue'
import { set } from 'lodash'
import type { BaseNodeModel } from '@logicflow/core'
import NodeContainer from '@/workflow-canvas/core/node-container/index.vue'

defineOptions({ name: 'WorkflowLoopBreakNode' })
const getModel = inject('getModel') as () => BaseNodeModel
const model = getModel()

onMounted(() => {
if (!model.properties.node_data) {
set(model.properties, 'node_data', { condition: 'and', condition_list: [] })
}
})
</script>
Loading
Loading