feat: loopNode

This commit is contained in:
shaohuzhang1 2025-03-13 14:39:50 +08:00
parent e11c550fc2
commit 5837650e35
24 changed files with 1006 additions and 28 deletions

View File

@ -15,7 +15,7 @@ from .function_lib_node import *
from .function_node import *
from .question_node import *
from .reranker_node import *
from .loop_node import *
from .document_extract_node import *
from .image_understand_step_node import *
from .image_generate_step_node import *
@ -31,7 +31,7 @@ node_list = [BaseStartStepNode, BaseChatNode, BaseSearchDatasetNode, BaseQuestio
BaseFunctionNodeNode, BaseFunctionLibNodeNode, BaseRerankerNode, BaseApplicationNode,
BaseDocumentExtractNode,
BaseImageUnderstandNode, BaseFormNode, BaseSpeechToTextNode, BaseTextToSpeechNode,
BaseImageGenerateNode, BaseVariableAssignNode]
BaseImageGenerateNode, BaseVariableAssignNode, BaseLoopNode]
def get_node(node_type):

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file __init__.py
@date2025/3/11 18:24
@desc:
"""
from .impl import *

View File

@ -0,0 +1,52 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file i_loop_node.py
@date2025/3/11 18:19
@desc:
"""
from typing import Type
from application.flow.i_step_node import INode, NodeResult
from rest_framework import serializers
from common.exception.app_exception import AppApiException
from common.util.field_message import ErrMessage
from django.utils.translation import gettext_lazy as _
class ILoopNodeSerializer(serializers.Serializer):
loop_type = serializers.CharField(required=True, error_messages=ErrMessage.char(_("loop_type")))
array = serializers.ListField(required=False, allow_null=True,
error_messages=ErrMessage.char(_("array")))
number = serializers.IntegerField(required=False, allow_null=True,
error_messages=ErrMessage.char(_("number")))
loop_body = serializers.DictField(required=True, error_messages=ErrMessage.char("循环体"))
def is_valid(self, *, raise_exception=False):
super().is_valid(raise_exception=True)
loop_type = self.data.get('loop_type')
if loop_type == 'ARRAY':
array = self.data.get('array')
if array is None or len(array) == 0:
message = _('{field}, this field is required.', field='array')
raise AppApiException(500, message)
elif loop_type == 'NUMBER':
number = self.data.get('number')
if number is None:
message = _('{field}, this field is required.', field='number')
raise AppApiException(500, message)
class ILoopNode(INode):
type = 'loop-node'
def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
return ILoopNodeSerializer
def _run(self):
return self.execute(**self.node_params_serializer.data, **self.flow_params_serializer.data)
def execute(self, loop_type, array, number, loop_body, stream, **kwargs) -> NodeResult:
pass

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file __init__.py.py
@date2025/3/11 18:24
@desc:
"""
from .base_loop_node import BaseLoopNode

View File

@ -0,0 +1,130 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file base_loop_node.py
@date2025/3/11 18:24
@desc:
"""
import time
from typing import Dict
from application.flow.i_step_node import NodeResult, WorkFlowPostHandler, INode
from application.flow.step_node.loop_node.i_loop_node import ILoopNode
from application.flow.tools import Reasoning
from common.handle.impl.response.loop_to_response import LoopToResponse
def _write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow, answer: str,
reasoning_content: str):
node.context['answer'] = answer
node.context['run_time'] = time.time() - node.context['start_time']
node.context['reasoning_content'] = reasoning_content
if workflow.is_result(node, NodeResult(node_variable, workflow_variable)):
node.answer_text = answer
def write_context_stream(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
"""
写入上下文数据 (流式)
@param node_variable: 节点数据
@param workflow_variable: 全局数据
@param node: 节点
@param workflow: 工作流管理器
"""
response = node_variable.get('result')
answer = ''
reasoning_content = ''
for chunk in response:
content_chunk = chunk.get('content', '')
reasoning_content_chunk = chunk.get('reasoning_content', '')
reasoning_content += reasoning_content_chunk
answer += content_chunk
yield {'content': content_chunk,
'reasoning_content': reasoning_content_chunk}
_write_context(node_variable, workflow_variable, node, workflow, answer, reasoning_content)
def write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
"""
写入上下文数据
@param node_variable: 节点数据
@param workflow_variable: 全局数据
@param node: 节点实例对象
@param workflow: 工作流管理器
"""
response = node_variable.get('result')
model_setting = node.context.get('model_setting',
{'reasoning_content_enable': False, 'reasoning_content_end': '</think>',
'reasoning_content_start': '<think>'})
reasoning = Reasoning(model_setting.get('reasoning_content_start'), model_setting.get('reasoning_content_end'))
reasoning_result = reasoning.get_reasoning_content(response)
reasoning_result_end = reasoning.get_end_reasoning_content()
content = reasoning_result.get('content') + reasoning_result_end.get('content')
if 'reasoning_content' in response.response_metadata:
reasoning_content = response.response_metadata.get('reasoning_content', '')
else:
reasoning_content = reasoning_result.get('reasoning_content') + reasoning_result_end.get('reasoning_content')
_write_context(node_variable, workflow_variable, node, workflow, content, reasoning_content)
def loop_number(number, loop_body):
"""
指定次数循环
@return:
"""
pass
def loop_array(array, loop_body):
"""
循环数组
@return:
"""
pass
def loop_loop(loop_body):
"""
无线循环
@return:
"""
pass
class LoopWorkFlowPostHandler(WorkFlowPostHandler):
def handler(self, chat_id,
chat_record_id,
answer,
workflow):
pass
class BaseLoopNode(ILoopNode):
def save_context(self, details, workflow_manage):
self.context['result'] = details.get('result')
self.answer_text = str(details.get('result'))
def execute(self, loop_type, array, number, loop_body, stream, **kwargs) -> NodeResult:
from application.flow.workflow_manage import WorkflowManage, Flow
workflow_manage = WorkflowManage(Flow.new_instance(loop_body), self.workflow_manage.params,
LoopWorkFlowPostHandler(self.workflow_manage.work_flow_post_handler.chat_info
,
self.workflow_manage.work_flow_post_handler.client_id,
self.workflow_manage.work_flow_post_handler.client_type)
, base_to_response=LoopToResponse())
result = workflow_manage.stream()
return NodeResult({"result": result}, {}, _write_context=write_context_stream)
def get_details(self, index: int, **kwargs):
return {
'name': self.node.properties.get('stepName'),
"index": index,
"result": self.context.get('result'),
"params": self.context.get('params'),
'run_time': self.context.get('run_time'),
'type': self.node.type,
'status': self.status,
'err_message': self.err_message
}

View File

@ -88,7 +88,7 @@ class BaseSearchDatasetNode(ISearchDatasetStepNode):
'is_hit_handling_method_list': [row for row in result if row.get('is_hit_handling_method')],
'data': '\n'.join(
[f"{reset_title(paragraph.get('title', ''))}{paragraph.get('content')}" for paragraph in
paragraph_list])[0:dataset_setting.get('max_paragraph_char_number', 5000)],
result])[0:dataset_setting.get('max_paragraph_char_number', 5000)],
'directly_return': '\n'.join(
[paragraph.get('content') for paragraph in
result if

View File

@ -33,7 +33,10 @@ def get_global_variable(node):
class BaseStartStepNode(IStarNode):
def save_context(self, details, workflow_manage):
base_node = self.workflow_manage.get_base_node()
default_global_variable = get_default_global_variable(base_node.properties.get('input_field_list', []))
default_global_variable = {}
if base_node is not None:
default_global_variable = get_default_global_variable(base_node.properties.get('input_field_list', []))
workflow_variable = {**default_global_variable, **get_global_variable(self)}
self.context['question'] = details.get('question')
self.context['run_time'] = details.get('run_time')
@ -50,7 +53,9 @@ class BaseStartStepNode(IStarNode):
def execute(self, question, **kwargs) -> NodeResult:
base_node = self.workflow_manage.get_base_node()
default_global_variable = get_default_global_variable(base_node.properties.get('input_field_list', []))
default_global_variable = {}
if base_node is not None:
default_global_variable = get_default_global_variable(base_node.properties.get('input_field_list', []))
workflow_variable = {**default_global_variable, **get_global_variable(self)}
"""
开始节点 初始化全局变量

View File

@ -338,6 +338,12 @@ class WorkflowManage:
node.node_chunk.end()
self.node_context.append(node)
def stream(self):
close_old_connections()
language = get_language()
self.run_chain_async(self.start_node, None, language)
return self.await_result()
def run(self):
close_old_connections()
language = get_language()
@ -801,6 +807,8 @@ class WorkflowManage:
@return:
"""
base_node_list = [node for node in self.flow.nodes if node.type == 'base-node']
if len(base_node_list) == 0:
return None
return base_node_list[0]
def get_node_cls_by_id(self, node_id, up_node_id_list=None,

View File

@ -0,0 +1,27 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file LoopToResponse.py
@date2025/3/12 17:21
@desc:
"""
import json
from common.handle.impl.response.system_to_response import SystemToResponse
class LoopToResponse(SystemToResponse):
def to_stream_chunk_response(self, chat_id, chat_record_id, node_id, up_node_id_list, content, is_end,
completion_tokens,
prompt_tokens, other_params: dict = None):
if other_params is None:
other_params = {}
return {'chat_id': str(chat_id), 'chat_record_id': str(chat_record_id), 'operate': True,
'content': content, 'node_id': node_id, 'up_node_id_list': up_node_id_list,
'is_end': is_end,
'usage': {'completion_tokens': completion_tokens,
'prompt_tokens': prompt_tokens,
'total_tokens': completion_tokens + prompt_tokens},
**other_params}

View File

@ -16,5 +16,7 @@ export enum WorkflowType {
FormNode = 'form-node',
TextToSpeechNode = 'text-to-speech-node',
SpeechToTextNode = 'speech-to-text-node',
ImageGenerateNode = 'image-generate-node'
ImageGenerateNode = 'image-generate-node',
LoopNode = 'loop-node',
LoopBodyNode = 'loop-body-node'
}

View File

@ -7,7 +7,7 @@
>
<div v-resize="resizeStepContainer">
<div class="flex-between">
<div class="flex align-center" style="width: 70%;">
<div class="flex align-center" style="width: 70%">
<component
:is="iconComponent(`${nodeModel.type}-icon`)"
class="mr-8"
@ -290,6 +290,7 @@ const resizeStepContainer = (wh: any) => {
}
function clickNodes(item: any) {
console.log('clickNodes', item)
const width = item.properties.width ? item.properties.width : 214
const nodeModel = props.nodeModel.graphModel.addNode({
type: item.type,

View File

@ -46,7 +46,6 @@ class AppNode extends HtmlResize.view {
getNodesName(number + 1)
}
}
props.model.properties.config = nodeDict[props.model.type].properties.config
if (props.model.properties.height) {
props.model.height = props.model.properties.height
}
@ -115,12 +114,11 @@ class AppNode extends HtmlResize.view {
} else {
isConnect = this.props.graphModel.edges.some((edge) => edge.sourceAnchorId == anchorData.id)
}
return lh(
'foreignObject',
{
...anchorData,
x: x - 10,
x: x - 14,
y: y - 12,
width: 30,
height: 30
@ -134,7 +132,7 @@ class AppNode extends HtmlResize.view {
}
},
dangerouslySetInnerHTML: {
__html: isConnect
__html: (type == 'children' ? true : isConnect)
? `<svg width="100%" height="100%" viewBox="0 0 42 42" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_5119_232585)">
<path d="M20.9998 29.8333C28.0875 29.8333 33.8332 24.0876 33.8332 17C33.8332 9.91231 28.0875 4.16663 20.9998 4.16663C13.9122 4.16663 8.1665 9.91231 8.1665 17C8.1665 24.0876 13.9122 29.8333 20.9998 29.8333Z" fill="white"/>

View File

@ -319,6 +319,65 @@ export const textToSpeechNode = {
}
}
}
export const loopNode = {
type: WorkflowType.LoopNode,
visible: false,
text: t('views.applicationWorkflow.nodes.loopNode.text', '循环节点'),
label: t('views.applicationWorkflow.nodes.loopNode.label', '循环节点'),
height: 252,
properties: {
stepName: t('views.applicationWorkflow.nodes.loopNode.label', '循环节点'),
workflow: {
edges: [],
nodes: [
{
x: 480,
y: 3340,
id: 'start-node',
type: 'start-node',
properties: {
config: {
fields: [],
globalFields: []
},
fields: [],
height: 361.333,
showNode: true,
stepName: '开始',
globalFields: []
}
}
]
},
config: {
fields: [
{
label: t('loop.item', '循环参数'),
value: 'item'
},
{
label: t('common.result'),
value: 'result'
}
]
}
}
}
export const loopBodyNode = {
type: WorkflowType.LoopBodyNode,
text: t('views.applicationWorkflow.nodes.loopBodyNode.text', '循环体'),
label: t('views.applicationWorkflow.nodes.loopBodyNode.label', '循环体'),
height: 600,
properties: {
width: 1800,
stepName: t('views.applicationWorkflow.nodes.loopBodyNode.label', '循环体'),
config: {
fields: []
}
}
}
export const menuNodes = [
aiChatNode,
imageUnderstandNode,
@ -332,7 +391,8 @@ export const menuNodes = [
documentExtractNode,
speechToTextNode,
textToSpeechNode,
variableAssignNode
variableAssignNode,
loopNode
]
/**
@ -426,7 +486,9 @@ export const nodeDict: any = {
[WorkflowType.TextToSpeechNode]: textToSpeechNode,
[WorkflowType.SpeechToTextNode]: speechToTextNode,
[WorkflowType.ImageGenerateNode]: imageGenerateNode,
[WorkflowType.VariableAssignNode]: variableAssignNode
[WorkflowType.VariableAssignNode]: variableAssignNode,
[WorkflowType.LoopNode]: loopNode,
[WorkflowType.LoopBodyNode]: loopBodyNode
}
export function isWorkFlow(type: string | undefined) {
return type === 'WORK_FLOW'

View File

@ -0,0 +1,73 @@
import { BezierEdge, BezierEdgeModel, h } from '@logicflow/core'
class CustomEdgeModel2 extends BezierEdgeModel {
getArrowStyle() {
const arrowStyle = super.getArrowStyle()
arrowStyle.offset = 0
arrowStyle.verticalLength = 0
return arrowStyle
}
getEdgeStyle() {
const style = super.getEdgeStyle()
// svg属性
style.strokeWidth = 2
style.stroke = '#BBBFC4'
style.offset = 0
return style
}
/**
* 使
*/
getData() {
const data: any = super.getData()
if (data) {
data.sourceAnchorId = this.sourceAnchorId
data.targetAnchorId = this.targetAnchorId
}
return data
}
/**
* 使
*/
updatePathByAnchor() {
// TODO
const sourceNodeModel = this.graphModel.getNodeModelById(this.sourceNodeId)
const sourceAnchor = sourceNodeModel
.getDefaultAnchor()
.find((anchor: any) => anchor.id === this.sourceAnchorId)
const targetNodeModel = this.graphModel.getNodeModelById(this.targetNodeId)
const targetAnchor = targetNodeModel
.getDefaultAnchor()
.find((anchor: any) => anchor.id === this.targetAnchorId)
if (sourceAnchor && targetAnchor) {
const startPoint = {
x: sourceAnchor.x,
y: sourceAnchor.y - 10
}
this.updateStartPoint(startPoint)
const endPoint = {
x: targetAnchor.x,
y: targetAnchor.y + 3
}
this.updateEndPoint(endPoint)
}
// 这里需要将原有的pointsList设置为空才能触发bezier的自动计算control点。
this.pointsList = []
this.initPoints()
}
setAttributes(): void {
super.setAttributes()
this.isHitable = true
this.zIndex = 0
}
}
export default {
type: 'loop-edge',
view: BezierEdge,
model: CustomEdgeModel2
}

View File

@ -91,12 +91,20 @@ export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) {
return
}
if (elements.edges.length > 0 && elements.nodes.length == 0) {
elements.edges.forEach((edge: any) => lf.deleteEdge(edge.id))
elements.edges.forEach((edge: any) => {
if (edge.type === 'app-edge') {
lf.deleteEdge(edge.id)
}
})
return
}
const nodes = elements.nodes.filter((node) => ['start-node', 'base-node'].includes(node.type))
const nodes = elements.nodes.filter((node) =>
['start-node', 'base-node', 'loop-body-node'].includes(node.type)
)
if (nodes.length > 0) {
MsgError(`${nodes[0].properties?.stepName}${t('views.applicationWorkflow.delete.deleteMessage')}`)
MsgError(
`${nodes[0].properties?.stepName}${t('views.applicationWorkflow.delete.deleteMessage')}`
)
return
}
MsgConfirm(t('common.tip'), t('views.applicationWorkflow.delete.confirmTitle'), {
@ -107,7 +115,17 @@ export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) {
if (graph.textEditElement) return true
elements.edges.forEach((edge: any) => lf.deleteEdge(edge.id))
elements.nodes.forEach((node: any) => lf.deleteNode(node.id))
elements.nodes.forEach((node: any) => {
if (node.type === 'loop-node') {
const next = lf.getNodeOutgoingNode(node.id)
next.forEach((n: any) => {
if (n.type === 'loop-body-node') {
lf.deleteNode(n.id)
}
})
}
lf.deleteNode(node.id)
})
})
return false

View File

@ -65,9 +65,9 @@ export function getTeleport(): any {
// 比对当前界面显示的flowId只更新items[当前页面flowId:nodeId]的数据
// 比如items[0]属于Page1的数据那么Page2无论active=true/false都无法执行items[0]
if (id.startsWith(props.flowId)) {
children.push(items[id])
}
// if (id.startsWith(props.flowId)) {
children.push(items[id])
// }
})
return h(
Fragment,

View File

@ -10,7 +10,8 @@ const end_nodes: Array<string> = [
WorkflowType.Application,
WorkflowType.SpeechToTextNode,
WorkflowType.TextToSpeechNode,
WorkflowType.ImageGenerateNode,
WorkflowType.ImageGenerateNode,
WorkflowType.LoopBodyNode
]
export class WorkFlowInstance {
nodes

View File

@ -8,6 +8,7 @@
import LogicFlow from '@logicflow/core'
import { ref, onMounted, computed } from 'vue'
import AppEdge from './common/edge'
import loopEdge from './common/loopEdge'
import Control from './common/NodeControl.vue'
import { baseNodes } from '@/workflow/common/data'
import '@logicflow/extension/lib/style/index.css'
@ -93,7 +94,11 @@ const renderGraphData = (data?: any) => {
flowId.value = lf.value.graphModel.flowId
})
initDefaultShortcut(lf.value, lf.value.graphModel)
lf.value.batchRegister([...Object.keys(nodes).map((key) => nodes[key].default), AppEdge])
lf.value.batchRegister([
...Object.keys(nodes).map((key) => nodes[key].default),
AppEdge,
loopEdge
])
lf.value.setDefaultEdgeType('app-edge')
lf.value.render(data ? data : {})
@ -117,7 +122,18 @@ const validate = () => {
return Promise.all(lf.value.graphModel.nodes.map((element: any) => element?.validate?.()))
}
const getGraphData = () => {
return lf.value.getGraphData()
const graph_data = lf.value.getGraphData()
graph_data.nodes = graph_data.nodes.filter((node: any) => {
if (node.type === 'loop-body-node') {
const node_model = lf.value.getNodeModelById(node.id)
console.log(node_model)
node_model.set_loop_body()
return false
}
return true
})
graph_data.edges = graph_data.edges.filter((node: any) => node.type !== 'loop-edge')
return graph_data
}
const onmousedown = (shapeItem: ShapeItem) => {

View File

@ -0,0 +1,222 @@
<template>
<div @mousedown="mousedown" class="workflow-node-container p-16" style="overflow: visible">
<div
class="step-container app-card p-16"
:class="{ isSelected: props.nodeModel.isSelected, error: node_status !== 200 }"
style="overflow: visible"
>
<div v-resize="resizeStepContainer">
<div class="flex-between">
<div class="flex align-center" style="width: 70%">
<component
:is="iconComponent(`${nodeModel.type}-icon`)"
class="mr-8"
:size="24"
:item="nodeModel?.properties.node_data"
/>
<h4 class="ellipsis-1 break-all">{{ nodeModel.properties.stepName }}</h4>
</div>
</div>
<el-collapse-transition>
<div @mousedown.stop @keydown.stop @click.stop v-show="showNode" class="mt-16">
<el-alert
v-if="node_status != 200"
class="mb-16"
:title="
props.nodeModel.type === 'application-node'
? $t('views.applicationWorkflow.tip.applicationNodeError')
: $t('views.applicationWorkflow.tip.functionNodeError')
"
type="error"
show-icon
:closable="false"
/>
<slot></slot>
<template v-if="nodeFields.length > 0">
<h5 class="title-decoration-1 mb-8 mt-8">
{{ $t('common.param.outputParam') }}
</h5>
<template v-for="(item, index) in nodeFields" :key="index">
<div
class="flex-between border-r-4 p-8-12 mb-8 layout-bg lighter"
@mouseenter="showicon = index"
@mouseleave="showicon = null"
>
<span style="max-width: 92%">{{ item.label }} {{ '{' + item.value + '}' }}</span>
<el-tooltip
effect="dark"
:content="$t('views.applicationWorkflow.setting.copyParam')"
placement="top"
v-if="showicon === index"
>
<el-button link @click="copyClick(item.globeLabel)" style="padding: 0">
<AppIcon iconName="app-copy"></AppIcon>
</el-button>
</el-tooltip>
</div>
</template>
</template>
</div>
</el-collapse-transition>
</div>
</div>
<el-dialog
:title="$t('views.applicationWorkflow.nodeName')"
v-model="nodeNameDialogVisible"
:close-on-click-modal="false"
:close-on-press-escape="false"
:destroy-on-close="true"
append-to-body
@submit.prevent
>
<el-form label-position="top" ref="titleFormRef" :model="form">
<el-form-item
prop="title"
:rules="[
{
required: true,
message: $t('common.inputPlaceholder'),
trigger: 'blur'
}
]"
>
<el-input v-model="form.title" @blur="form.title = form.title.trim()" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click.prevent="nodeNameDialogVisible = false">
{{ $t('common.cancel') }}
</el-button>
<el-button type="primary" @click="editName(titleFormRef)">
{{ $t('common.save') }}
</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { set } from 'lodash'
import { iconComponent } from '../../icons/utils'
import { copyClick } from '@/utils/clipboard'
import { MsgError } from '@/utils/message'
import type { FormInstance } from 'element-plus'
import { t } from '@/locales'
const height = ref<{
stepContainerHeight: number
inputContainerHeight: number
outputContainerHeight: number
}>({
stepContainerHeight: 0,
inputContainerHeight: 0,
outputContainerHeight: 0
})
const showAnchor = ref<boolean>(false)
const anchorData = ref<any>()
const titleFormRef = ref()
const nodeNameDialogVisible = ref<boolean>(false)
const form = ref<any>({
title: ''
})
const showNode = computed({
set: (v) => {
set(props.nodeModel.properties, 'showNode', v)
},
get: () => {
if (props.nodeModel.properties.showNode !== undefined) {
return props.nodeModel.properties.showNode
}
set(props.nodeModel.properties, 'showNode', true)
return true
}
})
const node_status = computed(() => {
if (props.nodeModel.properties.status) {
return props.nodeModel.properties.status
}
return 200
})
const editName = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid) => {
if (valid) {
if (
!props.nodeModel.graphModel.nodes?.some(
(node: any) => node.properties.stepName === form.value.title
)
) {
set(props.nodeModel.properties, 'stepName', form.value.title)
nodeNameDialogVisible.value = false
formEl.resetFields()
} else {
MsgError(t('views.applicationWorkflow.tip.repeatedNodeError'))
}
}
})
}
const mousedown = () => {
props.nodeModel.graphModel.clearSelectElements()
set(props.nodeModel, 'isSelected', true)
set(props.nodeModel, 'isHovered', true)
props.nodeModel.graphModel.toFront(props.nodeModel.id)
}
const showicon = ref<number | null>(null)
const resizeStepContainer = (wh: any) => {
if (wh.height) {
if (!props.nodeModel.virtual) {
height.value.stepContainerHeight = wh.height
props.nodeModel.setHeight(height.value.stepContainerHeight)
}
}
}
const props = defineProps<{
nodeModel: any
}>()
const nodeFields = computed(() => {
if (props.nodeModel.properties.config.fields) {
const fields = props.nodeModel.properties.config.fields?.map((field: any) => {
return {
label: field.label,
value: field.value,
globeLabel: `{{${props.nodeModel.properties.stepName}.${field.value}}}`,
globeValue: `{{context['${props.nodeModel.id}'].${field.value}}}`
}
})
return fields
}
return []
})
</script>
<style lang="scss" scoped>
.workflow-node-container {
.step-container {
border: 2px solid #ffffff !important;
box-sizing: border-box;
&:hover {
box-shadow: 0px 6px 24px 0px rgba(31, 35, 41, 0.08);
}
&.isSelected {
border: 2px solid var(--el-color-primary) !important;
}
&.error {
border: 1px solid #f54a45 !important;
}
}
.arrow-icon {
transition: 0.2s;
}
}
:deep(.el-card) {
overflow: visible;
}
</style>

View File

@ -0,0 +1,39 @@
import LoopNode from './index.vue'
import { AppNode, AppNodeModel } from '@/workflow/common/app-node'
import { WorkflowType } from '@/enums/workflow'
class LoopBodyNodeView extends AppNode {
constructor(props: any) {
super(props, LoopNode)
}
}
class LoopBodyModel extends AppNodeModel {
refreshBranch() {
// 更新节点连接边的path
this.incoming.edges.forEach((edge: any) => {
// 调用自定义的更新方案
edge.updatePathByAnchor()
})
this.outgoing.edges.forEach((edge: any) => {
edge.updatePathByAnchor()
})
}
getDefaultAnchor() {
const { id, x, y, width, height } = this
const showNode = this.properties.showNode === undefined ? true : this.properties.showNode
const anchors: any = []
anchors.push({
edgeAddable: false,
x: x,
y: y - height / 2 + 10,
id: `${id}_children`,
type: 'children'
})
return anchors
}
}
export default {
type: 'loop-body-node',
model: LoopBodyModel,
view: LoopBodyNodeView
}

View File

@ -0,0 +1,93 @@
<template>
<LoopBodyContainer :nodeModel="nodeModel">
<div ref="containerRef" @wheel.stop style="height: 550px"></div>
</LoopBodyContainer>
</template>
<script setup lang="ts">
import { set } from 'lodash'
import AppEdge from '@/workflow/common/edge'
import { ref, onMounted } from 'vue'
import LogicFlow from '@logicflow/core'
import Dagre from '@/workflow/plugins/dagre'
import { initDefaultShortcut } from '@/workflow/common/shortcut'
import LoopBodyContainer from '@/workflow/nodes/loop-body-node/LoopBodyContainer.vue'
const nodes: any = import.meta.glob('@/workflow/nodes/**/index.ts', { eager: true })
const props = defineProps<{ nodeModel: any }>()
const containerRef = ref()
const validate = () => {
return Promise.all(lf.value.graphModel.nodes.map((element: any) => element?.validate?.()))
}
const set_loop_body = () => {
const loop_node_id = props.nodeModel.properties.loop_node_id
const loop_node = props.nodeModel.graphModel.getNodeModelById(loop_node_id)
loop_node.properties.node_data.loop_body = lf.value.getGraphData()
}
const lf = ref()
const renderGraphData = (data?: any) => {
const container: any = containerRef.value
if (container) {
lf.value = new LogicFlow({
plugins: [Dagre],
textEdit: false,
adjustEdge: false,
adjustEdgeStartAndEnd: false,
background: {
backgroundColor: '#f5f6f7'
},
grid: {
size: 10,
type: 'dot',
config: {
color: '#DEE0E3',
thickness: 1
}
},
keyboard: {
enabled: true
},
isSilentMode: false,
container: container
})
lf.value.setTheme({
bezier: {
stroke: '#afafaf',
strokeWidth: 1
}
})
initDefaultShortcut(lf.value, lf.value.graphModel)
lf.value.batchRegister([...Object.keys(nodes).map((key) => nodes[key].default), AppEdge])
lf.value.setDefaultEdgeType('app-edge')
lf.value.render(data ? data : {})
lf.value.graphModel.eventCenter.on('delete_edge', (id_list: Array<string>) => {
id_list.forEach((id: string) => {
lf.value.deleteEdge(id)
})
})
lf.value.graphModel.eventCenter.on('anchor:drop', (data: any) => {
//
data.nodeModel.clear_next_node_field(false)
})
lf.value.graphModel.eventCenter.on('anchor:drop', (data: any) => {
//
data.nodeModel.clear_next_node_field(false)
})
lf.value.graphModel.eventCenter.on('history:change', (data: any) => {
set(props.nodeModel.properties, 'workflow', lf.value.getGraphData())
})
setTimeout(() => {
lf.value?.fitView()
}, 500)
}
}
onMounted(() => {
renderGraphData(props.nodeModel.properties.workflow)
set(props.nodeModel, 'validate', validate)
set(props.nodeModel, 'set_loop_body', set_loop_body)
})
</script>
<style lang="scss" scoped></style>

View File

@ -0,0 +1,56 @@
import LoopNode from './index.vue'
import { AppNode, AppNodeModel } from '@/workflow/common/app-node'
import { WorkflowType } from '@/enums/workflow'
class LoopNodeView extends AppNode {
constructor(props: any) {
super(props, LoopNode)
}
}
class LoopModel extends AppNodeModel {
refreshBranch() {
// 更新节点连接边的path
this.incoming.edges.forEach((edge: any) => {
// 调用自定义的更新方案
edge.updatePathByAnchor()
})
this.outgoing.edges.forEach((edge: any) => {
edge.updatePathByAnchor()
})
}
getDefaultAnchor() {
const { id, x, y, width, height } = this
const showNode = this.properties.showNode === undefined ? true : this.properties.showNode
const anchors: any = []
if (this.type !== WorkflowType.Base) {
if (this.type !== WorkflowType.Start) {
anchors.push({
x: x - width / 2 + 10,
y: showNode ? y : y - 15,
id: `${id}_left`,
edgeAddable: false,
type: 'left'
})
}
anchors.push({
x: x + width / 2 - 10,
y: showNode ? y : y - 15,
id: `${id}_right`,
type: 'right'
})
}
anchors.push({
x: x,
y: y + height / 2 - 25,
id: `${id}_children`,
type: 'children'
})
return anchors
}
}
export default {
type: 'loop-node',
model: LoopModel,
view: LoopNodeView
}

View File

@ -0,0 +1,154 @@
<template>
<NodeContainer :nodeModel="nodeModel">
<el-card shadow="never" class="card-never" style="--el-card-padding: 12px">
<el-form
@submit.prevent
:model="form_data"
label-position="top"
require-asterisk-position="right"
label-width="auto"
ref="replyNodeFormRef"
>
<el-form-item
:label="$t('views.applicationWorkflow.nodes.loopNode.loopType.label', '循环类型')"
@click.prevent
>
<template #label>
<div class="flex align-center">
<div class="mr-4">
<span
>{{ $t('views.applicationWorkflow.nodes.loopNode.loopType.label', '循环类型')
}}<span class="danger">*</span></span
>
</div>
</div>
</template>
<el-select v-model="form_data.loop_type" type="small">
<el-option
:label="$t('views.applicationWorkflow.nodes.loopNode.array', '数组循环')"
value="ARRAY"
/>
<el-option
:label="$t('views.applicationWorkflow.nodes.loopNode.number', '指定次数循环')"
value="NUMBER"
/>
<el-option
:label="$t('views.applicationWorkflow.nodes.loopNode.loop', '无限循环')"
value="LOOP"
/>
</el-select>
</el-form-item>
<el-form-item
v-if="form_data.loop_type == 'ARRAY'"
:label="$t('views.applicationWorkflow.nodes.loopNode.loopType.label', '循环数组')"
@click.prevent
prop="array"
:rules="{
message: $t(
'views.applicationWorkflow.nodes.loopNode.array.requiredMessage',
'循环数组必填'
),
trigger: 'blur',
required: true
}"
>
<NodeCascader
ref="nodeCascaderRef"
:nodeModel="nodeModel"
class="w-full"
:placeholder="
$t('views.applicationWorkflow.nodes.loopNode.array.placeholder', '请选择循环数组')
"
v-model="form_data.array"
/>
</el-form-item>
<el-form-item
v-else-if="form_data.loop_type == 'NUMBER'"
:label="$t('views.applicationWorkflow.nodes.loopNode.loopType.label', '循环数组')"
@click.prevent
prop="number"
:rules="{
message: $t(
'views.applicationWorkflow.nodes.loopNode.array.requiredMessage',
'循环数组必填'
),
trigger: 'blur',
required: true
}"
>
<el-input-number v-model="form_data.number" :min="1" />
</el-form-item>
</el-form>
</el-card>
</NodeContainer>
</template>
<script setup lang="ts">
import { set } from 'lodash'
import NodeContainer from '@/workflow/common/NodeContainer.vue'
import { ref, computed, onMounted } from 'vue'
import { isLastNode } from '@/workflow/common/data'
import { loopBodyNode } from '@/workflow/common/data'
import NodeCascader from '@/workflow/common/NodeCascader.vue'
const props = defineProps<{ nodeModel: any }>()
const form = {
loop_type: 'ARRAY',
array: [],
number: 1
}
const form_data = computed({
get: () => {
if (props.nodeModel.properties.node_data) {
return props.nodeModel.properties.node_data
} else {
set(props.nodeModel.properties, 'node_data', form)
}
return props.nodeModel.properties.node_data
},
set: (value) => {
set(props.nodeModel.properties, 'node_data', value)
}
})
const replyNodeFormRef = ref()
const nodeCascaderRef = ref()
const validate = () => {
return Promise.all([
nodeCascaderRef.value ? nodeCascaderRef.value.validate() : Promise.resolve(''),
replyNodeFormRef.value?.validate()
]).catch((err: any) => {
return Promise.reject({ node: props.nodeModel, errMessage: err })
})
}
onMounted(() => {
if (typeof props.nodeModel.properties.node_data?.is_result === 'undefined') {
if (isLastNode(props.nodeModel)) {
set(props.nodeModel.properties.node_data, 'is_result', true)
}
}
set(props.nodeModel, 'validate', validate)
const nodeOutgoingNode = props.nodeModel.graphModel.getNodeOutgoingNode(props.nodeModel.id)
if (!nodeOutgoingNode.some((item: any) => item.type == loopBodyNode.type)) {
const nodeModel = props.nodeModel.graphModel.addNode({
type: loopBodyNode.type,
properties: {
...loopBodyNode.properties,
workflow: props.nodeModel.properties.node_data.loop_body,
loop_node_id: props.nodeModel.id
},
x: props.nodeModel.x,
y: props.nodeModel.y + loopBodyNode.height
})
props.nodeModel.graphModel.addEdge({
type: 'loop-edge',
sourceNodeId: props.nodeModel.id,
sourceAnchorId: props.nodeModel.id + '_children',
targetNodeId: nodeModel.id,
virtual: true
})
}
})
</script>
<style lang="scss" scoped></style>

View File

@ -1,6 +1,8 @@
<template>
<NodeContainer :nodeModel="nodeModel">
<h5 class="title-decoration-1 mb-8">{{ $t('views.applicationWorkflow.variable.global') }}</h5>
<h5 v-if="nodeModel.properties.config.globalFields.length > 0" class="title-decoration-1 mb-8">
{{ $t('views.applicationWorkflow.variable.global') }}
</h5>
<div
v-for="(item, index) in nodeModel.properties.config.globalFields"
:key="index"
@ -63,7 +65,6 @@ const refreshFieldList = () => {
const refreshFieldList = getRefreshFieldList()
set(props.nodeModel.properties.config, 'globalFields', [...globalFields, ...refreshFieldList])
}
props.nodeModel.graphModel.eventCenter.on('refreshFieldList', refreshFieldList)
const refreshFileUploadConfig = () => {
let fields = cloneDeep(props.nodeModel.properties.config.fields)
@ -101,11 +102,13 @@ const refreshFileUploadConfig = () => {
set(props.nodeModel.properties.config, 'fields', [...fields, ...fileUploadFields])
}
props.nodeModel.graphModel.eventCenter.on('refreshFileUploadConfig', refreshFileUploadConfig)
onMounted(() => {
refreshFieldList()
refreshFileUploadConfig()
console.log(props.nodeModel.graphModel)
// refreshFieldList()
// refreshFileUploadConfig()
// props.nodeModel.graphModel.eventCenter.on('refreshFileUploadConfig', refreshFileUploadConfig)
// props.nodeModel.graphModel.eventCenter.on('refreshFieldList', refreshFieldList)
})
</script>
<style lang="scss" scoped></style>