feat: loop node Unfinished

This commit is contained in:
shaohuzhang1 2025-09-12 20:10:43 +08:00
parent 4cb39127be
commit 232dae1d88
32 changed files with 1563 additions and 84 deletions

View File

@ -168,7 +168,7 @@ class INode:
self.runtime_node_id, self.context.get('reasoning_content', '') if reasoning_content_enable else '')]
def __init__(self, node, workflow_params, workflow_manage, up_node_id_list=None,
get_node_params=lambda node: node.properties.get('node_data')):
get_node_params=lambda node: node.properties.get('node_data'), salt=None):
# 当前步骤上下文,用于存储当前步骤信息
self.status = 200
self.err_message = ''
@ -188,7 +188,8 @@ class INode:
self.runtime_node_id = sha1(uuid.NAMESPACE_DNS.bytes + bytes(str(uuid.uuid5(uuid.NAMESPACE_DNS,
"".join([*sorted(up_node_id_list),
node.id]))),
"utf-8")).hexdigest()
"utf-8")).hexdigest() + (
"__" + str(salt) if salt is not None else '')
def valid_args(self, node_params, flow_params):
flow_params_serializer_class = self.get_flow_params_serializer_class()

View File

@ -0,0 +1,167 @@
# coding=utf-8
"""
@project: maxkb
@Author
@file workflow_manage.py
@date2024/1/9 17:40
@desc:
"""
from concurrent.futures import ThreadPoolExecutor
from typing import List
from django.db import close_old_connections
from django.utils.translation import get_language
from langchain_core.prompts import PromptTemplate
from application.flow.common import Workflow
from application.flow.i_step_node import WorkFlowPostHandler, INode
from application.flow.step_node import get_node
from application.flow.workflow_manage import WorkflowManage
from common.handle.base_to_response import BaseToResponse
from common.handle.impl.response.system_to_response import SystemToResponse
executor = ThreadPoolExecutor(max_workers=200)
class NodeResultFuture:
def __init__(self, r, e, status=200):
self.r = r
self.e = e
self.status = status
def result(self):
if self.status == 200:
return self.r
else:
raise self.e
def await_result(result, timeout=1):
try:
result.result(timeout)
return False
except Exception as e:
return True
class NodeChunkManage:
def __init__(self, work_flow):
self.node_chunk_list = []
self.current_node_chunk = None
self.work_flow = work_flow
def add_node_chunk(self, node_chunk):
self.node_chunk_list.append(node_chunk)
def contains(self, node_chunk):
return self.node_chunk_list.__contains__(node_chunk)
def pop(self):
if self.current_node_chunk is None:
try:
current_node_chunk = self.node_chunk_list.pop(0)
self.current_node_chunk = current_node_chunk
except IndexError as e:
pass
if self.current_node_chunk is not None:
try:
chunk = self.current_node_chunk.chunk_list.pop(0)
return chunk
except IndexError as e:
if self.current_node_chunk.is_end():
self.current_node_chunk = None
if self.work_flow.answer_is_not_empty():
chunk = self.work_flow.base_to_response.to_stream_chunk_response(
self.work_flow.params['chat_id'],
self.work_flow.params['chat_record_id'],
'\n\n', False, 0, 0)
self.work_flow.append_answer('\n\n')
return chunk
return self.pop()
return None
class LoopWorkflowManage(WorkflowManage):
def __init__(self, flow: Workflow,
params,
work_flow_post_handler: WorkFlowPostHandler,
parentWorkflowManage,
loop_params,
base_to_response: BaseToResponse = SystemToResponse(), start_node_id=None,
start_node_data=None, chat_record=None, child_node=None):
self.parentWorkflowManage = parentWorkflowManage
self.loop_params = loop_params
super().__init__(flow, params, work_flow_post_handler, base_to_response, None, None, None,
None,
None, start_node_id, start_node_data, chat_record, child_node)
def get_node_cls_by_id(self, node_id, up_node_id_list=None,
get_node_params=lambda node: node.properties.get('node_data')):
for node in self.flow.nodes:
if node.id == node_id:
node_instance = get_node(node.type)(node,
self.params, self, up_node_id_list,
get_node_params,
salt=self.get_index())
return node_instance
return None
def stream(self):
close_old_connections()
language = get_language()
self.run_chain_async(self.start_node, None, language)
return self.await_result()
def get_index(self):
return self.loop_params.get('index')
def get_start_node(self):
start_node_list = [node for node in self.flow.nodes if
['loop-start-node'].__contains__(node.type)]
return start_node_list[0]
def get_reference_field(self, node_id: str, fields: List[str]):
"""
@param node_id: 节点id
@param fields: 字段
@return:
"""
if node_id == 'global':
return self.parentWorkflowManage.get_reference_field(node_id, fields)
elif node_id == 'chat':
return self.parentWorkflowManage.get_reference_field(node_id, fields)
else:
node = self.get_node_by_id(node_id)
if node:
return node.get_reference_field(fields)
return self.parentWorkflowManage.get_reference_field(node_id, fields)
def get_workflow_content(self):
context = {
'global': self.context,
'chat': self.chat_context
}
for node in self.node_context:
context[node.id] = node.context
return context
def reset_prompt(self, prompt: str):
prompt = super().reset_prompt(prompt)
prompt = self.parentWorkflowManage.reset_prompt(prompt)
return prompt
def generate_prompt(self, prompt: str):
"""
格式化生成提示词
@param prompt: 提示词信息
@return: 格式化后的提示词
"""
context = {**self.get_workflow_content(), **self.parentWorkflowManage.get_workflow_content()}
prompt = self.reset_prompt(prompt)
prompt_template = PromptTemplate.from_template(prompt, template_format='jinja2')
value = prompt_template.format(context=context)
return value

View File

@ -14,6 +14,8 @@ from .document_extract_node import *
from .form_node import *
from .image_generate_step_node import *
from .image_understand_step_node import *
from .loop_node import *
from .loop_start_node import *
from .mcp_node import BaseMcpNode
from .question_node import *
from .reranker_node import *
@ -30,7 +32,7 @@ node_list = [BaseStartStepNode, BaseChatNode, BaseSearchKnowledgeNode, BaseQuest
BaseToolNodeNode, BaseToolLibNodeNode, BaseRerankerNode, BaseApplicationNode,
BaseDocumentExtractNode,
BaseImageUnderstandNode, BaseFormNode, BaseSpeechToTextNode, BaseTextToSpeechNode,
BaseImageGenerateNode, BaseVariableAssignNode, BaseMcpNode]
BaseImageGenerateNode, BaseVariableAssignNode, BaseMcpNode, BaseLoopNode, BaseLoopStartStepNode]
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,56 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file i_loop_node.py
@date2025/3/11 18:19
@desc:
"""
from typing import Type
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from application.flow.i_step_node import INode, NodeResult
from common.exception.app_exception import AppApiException
class ILoopNodeSerializer(serializers.Serializer):
loop_type = serializers.CharField(required=True, label=_("loop_type"))
array = serializers.ListField(required=False, allow_null=True,
label=_("array"))
number = serializers.IntegerField(required=False, allow_null=True,
label=_("number"))
loop_body = serializers.DictField(required=True, label="循环体")
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):
array = self.node_params_serializer.data.get('array')
if self.node_params_serializer.data.get('loop_type') == 'ARRAY':
array = self.workflow_manage.get_reference_field(
array[0],
array[1:])
return self.execute(**{**self.node_params_serializer.data, "array": array}, **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,252 @@
# coding=utf-8
"""
@project: MaxKB
@Author
@file base_loop_node.py
@date2025/3/11 18:24
@desc:
"""
import time
from typing import Dict, List
from application.flow.common import Answer
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 application.models import ChatRecord
from common.handle.impl.response.loop_to_response import LoopToResponse
def _is_interrupt_exec(node, node_variable: Dict, workflow_variable: Dict):
return node.context.get('is_interrupt_exec', False)
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')
workflow_manage = node_variable.get('workflow_manage')
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}
runtime_details = workflow_manage.get_runtime_details()
_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 get_answer_list(instance, child_node_node_dict, runtime_node_id):
answer_list = instance.get_record_answer_list()
for a in answer_list:
_v = child_node_node_dict.get(a.get('runtime_node_id'))
if _v:
a['runtime_node_id'] = runtime_node_id
a['child_node'] = _v
return answer_list
def insert_or_replace(arr, index, value):
if index < len(arr):
arr[index] = value # 替换
else:
# 在末尾插入足够多的None然后替换最后一个
arr.extend([None] * (index - len(arr) + 1))
arr[index] = value
return arr
def loop_number(number: int, workflow_manage_new_instance, node: INode):
loop_global_data = {}
break_outer = False
is_interrupt_exec = False
loop_node_data = node.context.get('loop_node_data') or []
loop_answer_data = node.context.get("loop_answer_data") or []
current_index = node.context.get("current_index") or 0
node_params = node.node_params
start_node_id = node_params.get('child_node', {}).get('runtime_node_id')
start_node_data = None
chat_record = None
child_node = None
if start_node_id:
chat_record_id = node_params.get('child_node', {}).get('chat_record_id')
child_node = node_params.get('child_node', {}).get('child_node')
start_node_data = node_params.get('node_data')
chat_record = ChatRecord(id=chat_record_id, answer_text_list=[], answer_text='',
details=loop_node_data[current_index])
for index in range(current_index, number):
"""
指定次数循环
@return:
"""
instance = workflow_manage_new_instance({'index': index, 'item': index}, loop_global_data, start_node_id,
start_node_data, chat_record, child_node)
response = instance.stream()
answer = ''
current_index = index
reasoning_content = ''
child_node_node_dict = {}
for chunk in response:
child_node = chunk.get('child_node')
runtime_node_id = chunk.get('runtime_node_id', '')
chat_record_id = chunk.get('chat_record_id', '')
child_node_node_dict[runtime_node_id] = {
'runtime_node_id': runtime_node_id,
'chat_record_id': chat_record_id,
'child_node': child_node}
content_chunk = chunk.get('content', '')
reasoning_content_chunk = chunk.get('reasoning_content', '')
reasoning_content += reasoning_content_chunk
answer += content_chunk
yield chunk
node_type = chunk.get('node_type')
if node_type == 'form-node':
break_outer = True
is_interrupt_exec = True
start_node_id = None
start_node_data = None
chat_record = None
child_node = None
loop_global_data = instance.context
insert_or_replace(loop_node_data, index, instance.get_runtime_details())
insert_or_replace(loop_answer_data, index,
get_answer_list(instance, child_node_node_dict, node.runtime_node_id))
if break_outer:
break
node.context['is_interrupt_exec'] = is_interrupt_exec
node.context['loop_node_data'] = loop_node_data
node.context['loop_answer_data'] = loop_answer_data
node.context["index"] = current_index
node.context["item"] = current_index
def loop_array(array, workflow_manage_new_instance, node: INode):
loop_global_data = {}
loop_execute_details = []
for item, index in zip(array, range(len(array))):
"""
指定次数循环
@return:
"""
instance = workflow_manage_new_instance({'index': index, 'item': item}, loop_global_data)
response = instance.stream()
for chunk in response:
yield chunk
node_type = chunk.get('node_type')
if node_type == 'form-node':
break
loop_global_data = instance.context
runtime_details = instance.get_runtime_details()
loop_execute_details.append(runtime_details)
node.context['loop_execute_details'] = loop_execute_details
def get_write_context(loop_type, array, number, loop_body, stream):
def inner_write_context(node_variable: Dict, workflow_variable: Dict, node: INode, workflow):
if loop_type == 'ARRAY':
return loop_array(array, node_variable['workflow_manage_new_instance'], node)
return loop_number(number, node_variable['workflow_manage_new_instance'], node)
return inner_write_context
class LoopWorkFlowPostHandler(WorkFlowPostHandler):
def handler(self, 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 get_answer_list(self) -> List[Answer] | None:
result = []
for answer_list in (self.context.get("loop_answer_data") or []):
for a in answer_list:
if isinstance(a, dict):
result.append(Answer(**a))
return result
def execute(self, loop_type, array, number, loop_body, stream, **kwargs) -> NodeResult:
from application.flow.loop_workflow_manage import LoopWorkflowManage, Workflow
def workflow_manage_new_instance(loop_data, global_data, start_node_id=None,
start_node_data=None, chat_record=None, child_node=None):
workflow_manage = LoopWorkflowManage(Workflow.new_instance(loop_body), self.workflow_manage.params,
LoopWorkFlowPostHandler(
self.workflow_manage.work_flow_post_handler.chat_info)
,
self.workflow_manage,
loop_data,
base_to_response=LoopToResponse(),
start_node_id=start_node_id,
start_node_data=start_node_data,
chat_record=chat_record,
child_node=child_node
)
return workflow_manage
return NodeResult({'workflow_manage_new_instance': workflow_manage_new_instance}, {},
_write_context=get_write_context(loop_type, array, number, loop_body, stream),
_is_interrupt=_is_interrupt_exec)
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,
'current_index': self.context.get("index"),
"current_item": self.context.get("item"),
'loop_type': self.context.get("loop_type"),
'status': self.status,
'loop_node_data': self.context.get("loop_node_data"),
'loop_answer_data': self.context.get("loop_answer_data"),
'err_message': self.err_message
}

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: maxkb
@Author
@file __init__.py
@date2024/6/11 15:30
@desc:
"""
from .impl import *

View File

@ -0,0 +1,20 @@
# coding=utf-8
"""
@project: maxkb
@Author
@file i_start_node.py
@date2024/6/3 16:54
@desc:
"""
from application.flow.i_step_node import INode, NodeResult
class ILoopStarNode(INode):
type = 'loop-start-node'
def _run(self):
return self.execute(**self.flow_params_serializer.data)
def execute(self, **kwargs) -> NodeResult:
pass

View File

@ -0,0 +1,9 @@
# coding=utf-8
"""
@project: maxkb
@Author
@file __init__.py
@date2024/6/11 15:36
@desc:
"""
from .base_start_node import BaseLoopStartStepNode

View File

@ -0,0 +1,55 @@
# coding=utf-8
"""
@project: maxkb
@Author
@file base_start_node.py
@date2024/6/3 17:17
@desc:
"""
from typing import Type
from rest_framework import serializers
from application.flow.i_step_node import NodeResult
from application.flow.step_node.loop_start_node.i_loop_start_node import ILoopStarNode
class BaseLoopStartStepNode(ILoopStarNode):
def save_context(self, details, workflow_manage):
self.context['index'] = details.get('current_index')
self.context['item'] = details.get('current_item')
def get_node_params_serializer_class(self) -> Type[serializers.Serializer]:
pass
def execute(self, **kwargs) -> NodeResult:
"""
开始节点 初始化全局变量
"""
loop_params = self.workflow_manage.loop_params
node_variable = {
'index': loop_params.get("index"),
'item': loop_params.get("item")
}
self.workflow_manage.chat_context = self.workflow_manage.get_chat_info().get_chat_variable()
return NodeResult(node_variable, {})
def get_details(self, index: int, **kwargs):
global_fields = []
for field in self.node.properties.get('config')['globalFields']:
key = field['value']
global_fields.append({
'label': field['label'],
'key': key,
'value': self.workflow_manage.context[key] if key in self.workflow_manage.context else ''
})
return {
'name': self.node.properties.get('stepName'),
"index": index,
"current_index": self.context.get('index'),
"current_item": self.context.get('item'),
'run_time': self.context.get('run_time'),
'type': self.node.type,
'status': self.status,
'err_message': self.err_message,
}

View File

@ -187,6 +187,8 @@ class WorkflowManage:
is_result = False
if n.type == 'application-node':
is_result = True
if n.type == 'loop-node':
is_result = True
return {**n.properties.get('node_data'), 'form_data': start_node_data, 'node_data': start_node_data,
'child_node': self.child_node, 'is_result': is_result}
@ -194,6 +196,12 @@ class WorkflowManage:
get_node_params=get_node_params)
self.start_node.valid_args(
{**self.start_node.node_params, 'form_data': start_node_data}, self.start_node.workflow_params)
if self.start_node.type == 'loop-node':
loop_node_data = node_details.get('loop_node_data', {})
self.start_node.context['loop_node_data'] = loop_node_data
self.start_node.context['current_index'] = node_details.get('current_index')
self.start_node.context['current_item'] = node_details.get('current_item')
self.start_node.context['loop_answer_data']=node_details.get('loop_answer_data', {})
if self.start_node.type == 'application-node':
application_node_dict = node_details.get('application_node_dict', {})
self.start_node.context['application_node_dict'] = application_node_dict
@ -500,6 +508,10 @@ class WorkflowManage:
details_result[node.runtime_node_id] = details
return details_result
def get_record_answer_list(self):
answer_text_list = self.get_answer_text_list()
return reduce(lambda pre, _n: [*pre, *_n], answer_text_list, [])
def get_answer_text_list(self):
result = []
answer_list = reduce(lambda x, y: [*x, *y],
@ -546,10 +558,6 @@ class WorkflowManage:
return all([any([self.dependent_node(up_node_id, node) for node in self.node_context]) for up_node_id in
up_node_id_list])
def get_up_node_id_list(self, node_id):
up_node_id_list = [edge.sourceNodeId for edge in self.flow.edges if edge.targetNodeId == node_id]
return up_node_id_list
def get_next_node_list(self, current_node, current_node_result):
"""
获取下一个可执行节点列表

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

@ -2,8 +2,8 @@
<div class="item-content mb-16 lighter">
<template v-for="(answer_text, index) in answer_text_list" :key="index">
<div class="avatar mr-8" v-if="showAvatar">
<img v-if="application.avatar" :src="application.avatar" height="28px" width="28px"/>
<LogoIcon v-else height="28px" width="28px"/>
<img v-if="application.avatar" :src="application.avatar" height="28px" width="28px" />
<LogoIcon v-else height="28px" width="28px" />
</div>
<div
class="content"
@ -75,12 +75,11 @@
</div>
</template>
<script setup lang="ts">
import {computed, onMounted} from 'vue'
import KnowledgeSourceComponent
from '@/components/ai-chat/component/knowledge-source-component/index.vue'
import { computed, onMounted } from 'vue'
import KnowledgeSourceComponent from '@/components/ai-chat/component/knowledge-source-component/index.vue'
import MdRenderer from '@/components/markdown/MdRenderer.vue'
import OperationButton from '@/components/ai-chat/component/operation-button/index.vue'
import {type chatType} from '@/api/type/application'
import { type chatType } from '@/api/type/application'
import bus from '@/bus'
const props = defineProps<{
@ -157,7 +156,7 @@ function showSource(row: any) {
}
const regenerationChart = (chat: chatType) => {
props.sendMessage(chat.problem_text, {re_chat: true})
props.sendMessage(chat.problem_text, { re_chat: true })
}
const stopChat = (chat: chatType) => {
props.chatManagement.stop(chat.id)

View File

@ -1,7 +1,7 @@
export enum SearchMode {
embedding = 'views.application.dialog.vectorSearch',
keywords = 'views.application.dialog.fullTextSearch',
blend = 'views.application.dialog.hybridSearch'
blend = 'views.application.dialog.hybridSearch',
}
export enum WorkflowType {
@ -24,4 +24,13 @@ export enum WorkflowType {
SpeechToTextNode = 'speech-to-text-node',
ImageGenerateNode = 'image-generate-node',
McpNode = 'mcp-node',
LoopNode = 'loop-node',
LoopBodyNode = 'loop-body-node',
LoopStartNode = 'loop-start-node',
}
export enum WorkflowMode {
// 应用工作流
Application = 'application',
// 应用工作流循环
ApplicationLoop = 'application-loop',
}

View File

@ -119,8 +119,8 @@
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { menuNodes, toolLibNode, applicationNode } from '@/workflow/common/data'
import { ref, onMounted, computed, inject } from 'vue'
import { getMenuNodes, toolLibNode, applicationNode } from '@/workflow/common/data'
import { iconComponent } from '@/workflow/icons/utils'
import { loadSharedApi } from '@/utils/dynamics-api/shared-api'
import { isWorkFlow } from '@/utils/application'
@ -129,9 +129,12 @@ import NodeContent from './NodeContent.vue'
import { SourceTypeEnum } from '@/enums/common'
import permissionMap from '@/permission'
import { useRoute } from 'vue-router'
import { WorkflowMode } from '@/enums/application'
const workflowModel = inject('workflowMode') as WorkflowMode
const route = useRoute()
const { user, folder } = useStore()
const menuNodes = getMenuNodes(workflowModel || WorkflowMode.Application)
const search_text = ref<string>('')
const props = defineProps({
show: {
@ -165,7 +168,7 @@ const filter_menu_nodes = computed(() => {
if (!search_text.value) return menuNodes
const searchTerm = search_text.value.toLowerCase()
return menuNodes.reduce((result: any[], item) => {
return (menuNodes || []).reduce((result: any[], item) => {
const filteredList = item.list.filter((listItem) =>
listItem.label.toLowerCase().includes(searchTerm),
)
@ -254,7 +257,7 @@ async function getToolList() {
systemType: 'workspace',
}).getToolList({
folder_id: folder.currentFolder?.id || user.getWorkspaceId(),
tool_type: 'CUSTOM'
tool_type: 'CUSTOM',
})
toolList.value = res.data?.tools || res.data || []
toolList.value = toolList.value?.filter((item: any) => item.is_active)

View File

@ -19,15 +19,17 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { ref, onMounted, computed, inject } from 'vue'
import { iconComponent } from '../icons/utils'
import { t } from '@/locales'
import { WorkflowMode } from '@/enums/application'
const props = defineProps<{
nodeModel: any
modelValue: Array<any>
global?: boolean
}>()
const emit = defineEmits(['update:modelValue'])
const workflowMode = inject('workflowMode') as WorkflowMode
const data = computed({
set: (value) => {
emit('update:modelValue', value)
@ -50,15 +52,7 @@ const wheel = (e: any) => {
function visibleChange(bool: boolean) {
if (bool) {
options.value = props.global
? props.nodeModel
.get_up_node_field_list(false, true)
.filter(
(v: any) => ['global', 'chat'].includes(v.value) && v.children && v.children.length > 0,
)
: props.nodeModel
.get_up_node_field_list(false, true)
.filter((v: any) => v.children && v.children.length > 0)
initOptions()
}
}
@ -83,15 +77,40 @@ const validate = () => {
}
return Promise.resolve('')
}
defineExpose({ validate })
onMounted(() => {
options.value = props.global
? props.nodeModel
.get_up_node_field_list(false, true)
.filter(
const get_up_node_field_list = (contain_self: boolean, use_cache: boolean) => {
const result = props.nodeModel.get_up_node_field_list(contain_self, use_cache)
if (props.nodeModel.graphModel.get_up_node_field_list) {
const _u = props.nodeModel.graphModel.get_up_node_field_list(contain_self, use_cache)
_u.forEach((item: any) => {
result.push(item)
})
}
return result.filter((v: any) => v.children && v.children.length > 0)
}
const initOptions = () => {
if (workflowMode == WorkflowMode.ApplicationLoop) {
options.value = props.global
? get_up_node_field_list(false, true).filter(
(v: any) => ['global', 'chat'].includes(v.value) && v.children && v.children.length > 0,
)
: props.nodeModel.get_up_node_field_list(false, true)
: get_up_node_field_list(false, true).filter((v: any) => v.children && v.children.length > 0)
} else {
options.value = props.global
? props.nodeModel
.get_up_node_field_list(false, true)
.filter(
(v: any) => ['global', 'chat'].includes(v.value) && v.children && v.children.length > 0,
)
: props.nodeModel
.get_up_node_field_list(false, true)
.filter((v: any) => v.children && v.children.length > 0)
}
}
defineExpose({ validate })
onMounted(() => {
initOptions()
})
</script>
<style scoped></style>

View File

@ -334,7 +334,9 @@ const nodeFields = computed(() => {
})
function showOperate(type: string) {
return type !== WorkflowType.Base && type !== WorkflowType.Start
return ![WorkflowType.Start, WorkflowType.Base, WorkflowType.LoopStartNode.toString()].includes(
type,
)
}
const openNodeMenu = (anchorValue: any) => {
showAnchor.value = true

View File

@ -18,7 +18,6 @@ const getNodeName = (nodes: Array<any>, baseName: string) => {
while (true) {
if (index > 0) {
name = baseName + index
console.log(name)
}
if (!nodes.some((node: any) => node.properties.stepName === name.trim())) {
return name
@ -85,7 +84,7 @@ class AppNode extends HtmlResize.view {
if (!this.up_node_field_dict || !use_cache) {
const up_node_list = this.props.graphModel.getNodeIncomingNode(this.props.model.id)
this.up_node_field_dict = up_node_list
.filter((node) => node.id != 'start-node')
.filter((node) => node.id != 'start-node' && node.id != 'loop-start-node')
.map((node) => node.get_up_node_field_dict(true, use_cache))
.reduce((pre, next) => ({ ...pre, ...next }), {})
}
@ -103,9 +102,10 @@ class AppNode extends HtmlResize.view {
(pre, next) => [...pre, ...next],
[],
)
const start_node_field_list = this.props.graphModel
.getNodeModelById('start-node')
.get_node_field_list()
const start_node_field_list = (
this.props.graphModel.getNodeModelById('start-node') ||
this.props.graphModel.getNodeModelById('loop-start-node')
).get_node_field_list()
return [...start_node_field_list, ...result]
}
@ -219,7 +219,15 @@ class AppNode extends HtmlResize.view {
if (root) {
if (isActive()) {
connect(this.targetId(), this.component, root, model, graphModel)
connect(
this.targetId(),
this.component,
root,
model,
graphModel,
undefined,
this.props.graphModel.get_provide,
)
} else {
this.r = h(this.component, {
properties: this.props.model.properties,
@ -395,7 +403,7 @@ class AppNodeModel extends HtmlResize.model {
const anchors: any = []
if (this.type !== WorkflowType.Base) {
if (this.type !== WorkflowType.Start) {
if (![WorkflowType.Start, WorkflowType.LoopStartNode.toString()].includes(this.type)) {
anchors.push({
x: x - width / 2 + 10,
y: showNode ? y : y - 15,

View File

@ -1,4 +1,4 @@
import { WorkflowType } from '@/enums/application'
import { WorkflowType, WorkflowMode } from '@/enums/application'
import { t } from '@/locales'
export const startNode = {
@ -360,7 +360,113 @@ export const toolNode = {
},
},
}
export const loopStartNode = {
id: WorkflowType.LoopStartNode,
type: WorkflowType.LoopStartNode,
x: 480,
y: 3340,
properties: {
height: 364,
stepName: t('views.applicationWorkflow.nodes.loopStartNode.label', '循环开始'),
config: {
fields: [
{
label: t('views.applicationWorkflow.nodes.startNode.index', '下标'),
value: 'index',
},
{
label: t('views.applicationWorkflow.nodes.startNode.item', '循环元素'),
value: 'item',
},
],
globalFields: [],
},
showNode: true,
},
}
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: 'loop-start-node',
type: 'loop-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 = [
{
label: t('views.applicationWorkflow.nodes.classify.aiCapability'),
list: [
aiChatNode,
questionNode,
imageGenerateNode,
imageUnderstandNode,
textToSpeechNode,
speechToTextNode,
],
},
{ label: t('views.knowledge.title'), list: [searchKnowledgeNode, rerankerNode] },
{
label: t('views.applicationWorkflow.nodes.classify.businessLogic'),
list: [loopNode, conditionNode, formNode, variableAssignNode, replyNode],
},
{
label: t('views.applicationWorkflow.nodes.classify.other'),
list: [mcpNode, documentExtractNode, toolNode],
},
]
export const applicationLoopMenuNodes = [
{
label: t('views.applicationWorkflow.nodes.classify.aiCapability'),
list: [
@ -383,6 +489,14 @@ export const menuNodes = [
},
]
export const getMenuNodes = (workflowMode: WorkflowMode) => {
if (workflowMode == WorkflowMode.Application) {
return menuNodes
}
if (workflowMode == WorkflowMode.ApplicationLoop) {
return applicationLoopMenuNodes
}
}
/**
*
@ -462,6 +576,9 @@ export const nodeDict: any = {
[WorkflowType.ImageGenerateNode]: imageGenerateNode,
[WorkflowType.VariableAssignNode]: variableAssignNode,
[WorkflowType.McpNode]: mcpNode,
[WorkflowType.LoopNode]: loopNode,
[WorkflowType.LoopBodyNode]: loopBodyNode,
[WorkflowType.LoopStartNode]: loopStartNode,
}
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

@ -43,7 +43,7 @@ let CHILDREN_TRANSLATION_DISTANCE = 40
export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) {
const { keyboard } = lf
const {
options: { keyboard: keyboardOptions }
options: { keyboard: keyboardOptions },
} = keyboard
const copy_node = () => {
CHILDREN_TRANSLATION_DISTANCE = TRANSLATION_DISTANCE
@ -57,7 +57,7 @@ export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) {
return true
}
const base_nodes = elements.nodes.filter(
(node: any) => node.type === WorkflowType.Start || node.type === WorkflowType.Base
(node: any) => node.type === WorkflowType.Start || node.type === WorkflowType.Base,
)
if (base_nodes.length > 0) {
MsgError(base_nodes[0]?.properties?.stepName + t('views.applicationWorkflow.tip.cannotCopy'))
@ -91,23 +91,39 @@ 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
.filter((edge) => !['loop-edge'].includes(edge.type || ''))
.forEach((edge: any) => 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'), {
confirmButtonText: t('common.confirm'),
confirmButtonClass: 'danger'
confirmButtonClass: 'danger',
}).then(() => {
if (!keyboardOptions?.enabled) return true
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

@ -10,22 +10,26 @@ export function connect(
container: HTMLDivElement,
node: BaseNodeModel | BaseEdgeModel,
graph: GraphModel,
get_props?: any
get_props?: any,
get_provide?: any,
) {
if (!get_props) {
get_props = (node: BaseNodeModel | BaseEdgeModel, graph: GraphModel) => {
return { nodeModel: node, graph }
}
}
if (!get_provide) {
get_provide = (node: BaseNodeModel | BaseEdgeModel, graph: GraphModel) => ({
getNode: () => node,
getGraph: () => graph,
})
}
if (active) {
items[id] = markRaw(
defineComponent({
render: () => h(Teleport, { to: container } as any, [h(component, get_props(node, graph))]),
provide: () => ({
getNode: () => node,
getGraph: () => graph
})
})
provide: () => get_provide(node, graph),
}),
)
}
}
@ -50,8 +54,8 @@ export function getTeleport(): any {
props: {
flowId: {
type: String,
required: true
}
required: true,
},
},
setup(props) {
return () => {
@ -65,16 +69,15 @@ 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])
}
children.push(items[id])
})
return h(
Fragment,
{},
children.map((item) => h(item))
children.map((item) => h(item)),
)
}
}
},
})
}

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'
@ -35,22 +36,6 @@ const props = defineProps({
data: Object || null,
})
const defaultData = {
nodes: [...baseNodes],
}
const graphData = computed({
get: () => {
if (props.data) {
return props.data
} else {
return defaultData
}
},
set: (value) => {
return value
},
})
const lf = ref()
onMounted(() => {
renderGraphData()
@ -82,6 +67,7 @@ const renderGraphData = (data?: any) => {
},
isSilentMode: false,
container: container,
saa: 'sssssss',
})
lf.value.setTheme({
bezier: {
@ -89,11 +75,16 @@ const renderGraphData = (data?: any) => {
strokeWidth: 1,
},
})
lf.value.graphModel.get = 'sdasdaad'
lf.value.on('graph:rendered', () => {
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 +108,17 @@ 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.forEach((node: any) => {
if (node.type === 'loop-body-node') {
const node_model = lf.value.getNodeModelById(node.id)
node_model.set_loop_body()
}
})
const _graph_data = lf.value.getGraphData()
_graph_data.nodes = _graph_data.nodes.filter((node: any) => node.type !== 'loop-body-node')
_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; background: #fff"
>
<div v-resize="resizeStepContainer">
<div class="flex-between">
<div class="flex align-center" style="width: 600px">
<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, provide } from 'vue'
import { set } from 'lodash'
import { iconComponent } from '../../icons/utils'
import { copyClick } from '@/utils/clipboard'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { t } from '@/locales'
import { WorkflowMode } from '@/enums/application'
provide('workflowMode', WorkflowMode.ApplicationLoop)
const height = ref<{
stepContainerHeight: number
inputContainerHeight: number
outputContainerHeight: number
}>({
stepContainerHeight: 0,
inputContainerHeight: 0,
outputContainerHeight: 0,
})
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 {
ElMessage.error(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,44 @@
import { WorkflowMode } from './../../../enums/application'
import LoopNode from './index.vue'
import { AppNode, AppNodeModel } from '@/workflow/common/app-node'
class LoopBodyNodeView extends AppNode {
constructor(props: any) {
super(props, LoopNode)
}
get_up_node_field_list(contain_self: boolean, use_cache: boolean) {
const loop_node_id = this.props.model.properties.loop_node_id
const loop_node = this.props.graphModel.getNodeModelById(loop_node_id)
return loop_node.get_up_node_field_list(contain_self, use_cache)
}
}
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,100 @@
<template>
<LoopBodyContainer :nodeModel="nodeModel">
<div ref="containerRef" @wheel.stop style="height: 550px"></div>
</LoopBodyContainer>
</template>
<script setup lang="ts">
import { set, cloneDeep } 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'
import { WorkflowMode } from '@/enums/application'
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.graphModel.get_provide = (node: any, graph: any) => {
return {
getNode: () => node,
getGraph: () => graph,
workflowMode: WorkflowMode.ApplicationLoop,
}
}
lf.value.graphModel.get_up_node_field_list = props.nodeModel.get_up_node_field_list
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)
})
setTimeout(() => {
lf.value?.fitView()
}, 500)
}
}
onMounted(() => {
renderGraphData(cloneDeep(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/application'
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,158 @@
<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, loopStartNode } 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)) {
let workflow = { nodes: [loopStartNode], edges: [] }
if (props.nodeModel.properties.node_data.loop_body) {
workflow = props.nodeModel.properties.node_data.loop_body
}
const nodeModel = props.nodeModel.graphModel.addNode({
type: loopBodyNode.type,
properties: {
...loopBodyNode.properties,
workflow: workflow,
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

@ -0,0 +1,12 @@
import LoopStartNodeVue from './index.vue'
import { AppNode, AppNodeModel } from '@/workflow/common/app-node'
class LoopStartNode extends AppNode {
constructor(props: any) {
super(props, LoopStartNodeVue)
}
}
export default {
type: 'loop-start-node',
model: AppNodeModel,
view: LoopStartNode,
}

View File

@ -0,0 +1,12 @@
<template>
<NodeContainer :nodeModel="nodeModel"> </NodeContainer>
</template>
<script setup lang="ts">
import { cloneDeep, set } from 'lodash'
import NodeContainer from '@/workflow/common/NodeContainer.vue'
import { ref, onMounted } from 'vue'
import { t } from '@/locales'
const props = defineProps<{ nodeModel: any }>()
onMounted(() => {})
</script>
<style lang="scss" scoped></style>