55 Commits

Author SHA1 Message Date
v-zhangjc9
a2a8c39145 feat(ai-web): 输出节点改为多变量输出 2025-07-21 18:33:45 +08:00
v-zhangjc9
82ad3e7854 fix(web): 修复api引用错误 2025-07-21 10:08:37 +08:00
1e3e14c590 refactor(ai-web): 优化inputSchema可以为空,方便各处判断流程是否有入参 2025-07-21 00:05:28 +08:00
267eecbf45 refactor(ai-web): 使用输入节点代替inputSchema
将输入表单放在流程图中一起定义,方便统一参数的处理,不需要单独为输入表单的变量进行合并和操作
2025-07-20 19:27:09 +08:00
a5282762ed refractor(web): 增加输入节点 2025-07-20 17:17:22 +08:00
v-zhangjc9
77a09472aa feat(ai-web): 完成代码节点的执行 2025-07-18 15:48:35 +08:00
v-zhangjc9
4cfa110f2f feat(web): 增加数组的判断 2025-07-18 11:07:48 +08:00
v-zhangjc9
ad9dade6a1 feat(web): 完成子流程节点和代码节点的运行 2025-07-17 18:38:19 +08:00
v-zhangjc9
3a61da054e feat(web): 增加循环节点参数 2025-07-17 18:37:43 +08:00
fbc6144d82 refractor(web): 优化选择节点的选择器 2025-07-16 23:08:25 +08:00
72a9d58f4c fix(web): 修复节点入参不更新 2025-07-16 22:54:48 +08:00
v-zhangjc9
a3c2250285 feat(web): 完成分支节点动态改变handle 2025-07-16 19:54:48 +08:00
91e6f49342 feat(web): 增加判断节点对不同类型的适配和处理 2025-07-15 23:19:33 +08:00
v-zhangjc9
35c5150a1f feat(web): 入参增加类型 2025-07-15 17:32:23 +08:00
v-zhangjc9
a7b245a670 refractor(web): 修复入参解析 2025-07-15 16:27:00 +08:00
v-zhangjc9
df8270676a feat(web): 增加循环节点的基本参数 2025-07-15 11:11:36 +08:00
v-zhangjc9
b0e4f5853e feat(web): 增加循环节点的输出变量 2025-07-15 09:09:23 +08:00
0efb041c71 fix(web): 不太优雅地修复循环节点初始化高度的问题 2025-07-14 23:34:58 +08:00
fa06207af8 feat(web): 实现循环节点中新增节点 2025-07-14 23:12:43 +08:00
c7e0b56850 feat(web): 增加循环节点子节点的循环入参 2025-07-14 22:41:27 +08:00
v-zhangjc9
04bc9a2c16 feat(web): 完成循环节点的基本配置 2025-07-14 19:10:58 +08:00
v-zhangjc9
c77395fec4 feat(web): 升级部份依赖 2025-07-14 12:16:13 +08:00
21f02660e9 refractor(web): 优化节点和流程图显示效果 2025-07-13 22:42:37 +08:00
f6e9c9bc70 fix(web): 修复模板节点没有输出 2025-07-13 22:24:40 +08:00
v-zhangjc9
e798332828 feat(ai-web): 增加模板渲染的处理 2025-07-12 23:24:22 +08:00
v-zhangjc9
b0d41e0d88 feat(web): 增加模板节点 2025-07-12 22:56:15 +08:00
v-zhangjc9
02b2d44ccc feat(ai-web): 修复出现遗留边的时候导致出现的null错误 2025-07-12 21:46:00 +08:00
v-zhangjc9
47de3cc376 feat(web): 输出节点增加输出类型 2025-07-12 21:16:06 +08:00
v-zhangjc9
5b9920449d feat(web): 增加节点图标 2025-07-12 20:46:12 +08:00
v-zhangjc9
60f6b79167 feat(web): 增加节点分组 2025-07-12 19:09:47 +08:00
v-zhangjc9
528e66c497 feat(ai-web): 实现入参解析 2025-07-11 16:50:11 +08:00
v-zhangjc9
707f538213 feat(web): 增加文本段输入 2025-07-11 14:43:48 +08:00
v-zhangjc9
eae0d8dacd fix(web): 修复变量校验没有包含入参 2025-07-11 14:43:26 +08:00
v-zhangjc9
bf37c163fb feat(ai-web): 完成流程任务的前后端拉通 2025-07-11 14:26:30 +08:00
v-zhangjc9
ac2b6b1611 feat(web): 增加表单数据校验 2025-07-11 11:12:06 +08:00
v-zhangjc9
863638deaa feat(web): 优化节点展现 2025-07-11 09:46:12 +08:00
v-zhangjc9
fad190567b feat(web): 节点描述和名称直接放在节点数据中 2025-07-10 16:34:47 +08:00
v-zhangjc9
333da7ef88 style(web): 移除未使用的引入 2025-07-10 14:35:29 +08:00
v-zhangjc9
d0ca36e9d7 style(web): 优化命名 2025-07-10 14:33:36 +08:00
v-zhangjc9
f70b3b2a32 fix(web): 移除节点未移除节点数据 2025-07-10 14:28:32 +08:00
v-zhangjc9
f707a0d2b5 refactor(web): 优化节点展现 2025-07-10 12:44:37 +08:00
v-zhangjc9
5e763637da refactor(web): 优化流程节点的定义和实现 2025-07-10 12:08:13 +08:00
v-zhangjc9
898e20d5d7 fix(web): 修复节点option key错误 2025-07-08 10:29:55 +08:00
605cfb7182 feat(web): 尝试增加校验 2025-07-07 23:31:04 +08:00
v-zhangjc9
3afdff0a05 feat(web): 完成前序节点输出变量注入 2025-07-07 19:50:22 +08:00
v-zhangjc9
f523fc7638 feat(web): 优化节点编辑性能 2025-07-07 18:51:35 +08:00
03d0d9d85b feat(web): 尝试优化流程图性能 2025-07-06 22:39:26 +08:00
187c565da4 feat(ai-web): 将任务模板信息固化到任务对象中
保证每个任务对象都有对应的模板信息可以追溯,不会随着模板被修改而失效
2025-07-05 18:45:24 +08:00
f3dfff5075 fix(web): 修复删除api的路径 2025-07-05 17:41:27 +08:00
69420094ec feat(web): 实现任务显示文件列表 2025-07-05 17:39:44 +08:00
v-zhangjc9
051b3dbad2 feat(ai-web): 完成执行任务的创建(提交漏的文件) 2025-07-05 14:48:53 +08:00
v-zhangjc9
64ef5514b4 feat(ai-web): 完成执行任务的创建 2025-07-04 19:31:24 +08:00
v-zhangjc9
4f1dc84405 feat(web): 调整侧边栏实现 2025-07-04 10:54:45 +08:00
d979b3941d feat(ai-web): 完成自研流程图的保存 2025-07-03 23:40:41 +08:00
abdbb5ed03 feat(web): 增加任务和流程图CRUD 2025-07-03 22:21:31 +08:00
75 changed files with 3420 additions and 1798 deletions

View File

@@ -35,14 +35,18 @@ create table hudi_collect_build_b12.service_ai_file
create table hudi_collect_build_b12.service_ai_flow_task create table hudi_collect_build_b12.service_ai_flow_task
( (
id bigint not null comment '记录唯一标记', id bigint not null comment '记录唯一标记',
created_time datetime(6) comment '记录创建时间', created_time datetime(6) comment '记录创建时间',
modified_time datetime(6) comment '记录更新时间', modified_time datetime(6) comment '记录更新时间',
error longtext comment '任务运行产生的报错', comment text comment '任务注释,用于额外说明',
input longtext comment '任务输入', error longtext comment '任务运行产生的报错',
result longtext comment '任务运行结果', input longtext comment '任务输入',
status enum ('ERROR','FINISHED','RUNNING') not null comment '任务运行状态', result longtext comment '任务运行结果',
template_id bigint not null comment '流程任务对应的模板', status enum ('ERROR','FINISHED','RUNNING') not null comment '任务运行状态',
template_description varchar(255) comment '任务对应的模板功能、内容说明',
template_flow_graph longtext not null comment '任务对应的模板前端流程图数据',
template_input_schema longtext comment '任务对应的模板入参Schema',
template_name varchar(255) not null comment '任务对应的模板名称',
primary key (id) primary key (id)
) comment ='流程任务记录' charset = utf8mb4; ) comment ='流程任务记录' charset = utf8mb4;
@@ -52,8 +56,8 @@ create table hudi_collect_build_b12.service_ai_flow_task_template
created_time datetime(6) comment '记录创建时间', created_time datetime(6) comment '记录创建时间',
modified_time datetime(6) comment '记录更新时间', modified_time datetime(6) comment '记录更新时间',
description varchar(255) comment '模板功能、内容说明', description varchar(255) comment '模板功能、内容说明',
flow_graph longtext comment '前端流程图数据', flow_graph longtext not null comment '前端流程图数据',
input_schema longtext not null comment '模板入参Schema', input_schema longtext comment '模板入参Schema',
name varchar(255) not null comment '模板名称', name varchar(255) not null comment '模板名称',
primary key (id) primary key (id)
) comment ='流程任务模板' charset = utf8mb4; ) comment ='流程任务模板' charset = utf8mb4;

View File

@@ -30,6 +30,7 @@
<curator.version>5.1.0</curator.version> <curator.version>5.1.0</curator.version>
<hutool.version>5.8.27</hutool.version> <hutool.version>5.8.27</hutool.version>
<mapstruct.version>1.6.3</mapstruct.version> <mapstruct.version>1.6.3</mapstruct.version>
<liteflow.version>2.13.2</liteflow.version>
</properties> </properties>
<dependencies> <dependencies>
@@ -153,12 +154,27 @@
<dependency> <dependency>
<groupId>com.yomahub</groupId> <groupId>com.yomahub</groupId>
<artifactId>liteflow-spring-boot-starter</artifactId> <artifactId>liteflow-spring-boot-starter</artifactId>
<version>2.13.2</version> <version>${liteflow.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.yomahub</groupId> <groupId>com.yomahub</groupId>
<artifactId>liteflow-el-builder</artifactId> <artifactId>liteflow-el-builder</artifactId>
<version>2.13.2</version> <version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-graaljs</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-python</artifactId>
<version>${liteflow.version}</version>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-lua</artifactId>
<version>${liteflow.version}</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.noear</groupId> <groupId>org.noear</groupId>

View File

@@ -50,6 +50,10 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId> <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<dependency> <dependency>
<groupId>com.blinkfox</groupId> <groupId>com.blinkfox</groupId>
<artifactId>fenix-spring-boot-starter</artifactId> <artifactId>fenix-spring-boot-starter</artifactId>
@@ -82,6 +86,14 @@
<groupId>org.noear</groupId> <groupId>org.noear</groupId>
<artifactId>solon-ai-dialect-openai</artifactId> <artifactId>solon-ai-dialect-openai</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-graaljs</artifactId>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-python</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.hibernate.orm</groupId> <groupId>org.hibernate.orm</groupId>

View File

@@ -27,6 +27,8 @@ import org.springframework.scheduling.annotation.EnableScheduling;
public class WebApplication implements ApplicationRunner { public class WebApplication implements ApplicationRunner {
public static void main(String[] args) { public static void main(String[] args) {
System.setProperty("polyglot.engine.WarnInterpreterOnly", "false");
SpringApplication.run(WebApplication.class, args); SpringApplication.run(WebApplication.class, args);
} }

View File

@@ -21,7 +21,10 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.collections.api.factory.Sets;
import org.eclipse.collections.api.list.ImmutableList; import org.eclipse.collections.api.list.ImmutableList;
import org.eclipse.collections.api.set.ImmutableSet;
import org.mapstruct.factory.Mappers;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -56,6 +59,26 @@ public class DataFileController {
this.sliceFolderPath = StrUtil.format("{}/slice", uploadFolderPath); this.sliceFolderPath = StrUtil.format("{}/slice", uploadFolderPath);
} }
@PostMapping("/detail")
public AmisResponse<?> detail(@RequestBody DetailRequest request) {
var mapper = Mappers.getMapper(DetailResponse.Mapper.class);
return AmisResponse.responseCrudData(dataFileService.downloadFile(request.ids).collect(mapper::from));
}
@GetMapping("/detail")
public AmisResponse<?> detail(@RequestParam("ids") String ids) {
if (StrUtil.isBlank(ids)) {
return AmisResponse.responseCrudData(Sets.immutable.empty());
}
var mapper = Mappers.getMapper(DetailResponse.Mapper.class);
return AmisResponse.responseCrudData(
dataFileService.downloadFile(
Sets.immutable.ofAll(StrUtil.split(ids, ","))
.collect(Long::parseLong)
).collect(mapper::from)
);
}
@PostMapping("") @PostMapping("")
public AmisResponse<FinishResponse> upload(@RequestParam("file") MultipartFile file) throws IOException { public AmisResponse<FinishResponse> upload(@RequestParam("file") MultipartFile file) throws IOException {
String filename = file.getOriginalFilename(); String filename = file.getOriginalFilename();
@@ -170,6 +193,24 @@ public class DataFileController {
} }
} }
@Data
public static final class DetailRequest {
private ImmutableSet<Long> ids;
}
@Data
public static final class DetailResponse {
private Long id;
private String filename;
private Long size;
private String md5;
@org.mapstruct.Mapper
public interface Mapper {
DetailResponse from(DataFile file);
}
}
@Data @Data
public static final class StartRequest { public static final class StartRequest {
private String name; private String name;

View File

@@ -1,20 +1,26 @@
package com.lanyuanxiaoyao.service.ai.web.controller.task; package com.lanyuanxiaoyao.service.ai.web.controller.task;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.lanyuanxiaoyao.service.ai.core.entity.amis.AmisResponse;
import com.lanyuanxiaoyao.service.ai.web.base.controller.SimpleControllerSupport; import com.lanyuanxiaoyao.service.ai.web.base.controller.SimpleControllerSupport;
import com.lanyuanxiaoyao.service.ai.web.base.entity.SimpleItem; import com.lanyuanxiaoyao.service.ai.web.base.entity.SimpleItem;
import com.lanyuanxiaoyao.service.ai.web.entity.FlowTask; import com.lanyuanxiaoyao.service.ai.web.entity.FlowTask;
import com.lanyuanxiaoyao.service.ai.web.entity.FlowTaskTemplate; import com.lanyuanxiaoyao.service.ai.web.entity.FlowTaskTemplate;
import com.lanyuanxiaoyao.service.ai.web.service.task.FlowTaskService; import com.lanyuanxiaoyao.service.ai.web.service.task.FlowTaskService;
import com.lanyuanxiaoyao.service.ai.web.service.task.FlowTaskTemplateService; import com.lanyuanxiaoyao.service.ai.web.service.task.FlowTaskTemplateService;
import java.lang.reflect.InvocationTargetException;
import java.util.Map;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.mapstruct.Context;
import org.mapstruct.Mapping; import org.mapstruct.Mapping;
import org.mapstruct.factory.Mappers; import org.mapstruct.factory.Mappers;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -22,22 +28,51 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("flow_task") @RequestMapping("flow_task")
public class TaskController extends SimpleControllerSupport<FlowTask, TaskController.SaveItem, TaskController.ListItem, TaskController.DetailItem> { public class TaskController extends SimpleControllerSupport<FlowTask, TaskController.SaveItem, TaskController.ListItem, TaskController.DetailItem> {
private final FlowTaskService flowTaskService;
private final FlowTaskTemplateService flowTaskTemplateService; private final FlowTaskTemplateService flowTaskTemplateService;
private final ObjectMapper mapper; private final ObjectMapper mapper;
public TaskController(FlowTaskService flowTaskService, FlowTaskTemplateService flowTaskTemplateService, Jackson2ObjectMapperBuilder builder) { public TaskController(FlowTaskService flowTaskService, FlowTaskTemplateService flowTaskTemplateService, Jackson2ObjectMapperBuilder builder) {
super(flowTaskService); super(flowTaskService);
this.flowTaskService = flowTaskService;
this.flowTaskTemplateService = flowTaskTemplateService; this.flowTaskTemplateService = flowTaskTemplateService;
this.mapper = builder.build(); this.mapper = builder.build();
} }
@GetMapping("input_data/{id}")
public AmisResponse<?> getInputData(@PathVariable("id") Long id) throws JsonProcessingException {
var task = flowTaskService.detailOrThrow(id);
if (ObjectUtil.isEmpty(task.getInput())) {
return AmisResponse.responseSuccess();
}
return AmisResponse.responseSuccess(mapper.readValue(task.getInput(), Map.class));
}
@GetMapping("input_schema/{id}")
public AmisResponse<?> getInputSchema(@PathVariable("id") Long id) throws JsonProcessingException {
var task = flowTaskService.detailOrThrow(id);
if (ObjectUtil.isEmpty(task.getTemplateInputSchema())) {
return AmisResponse.responseSuccess();
}
return AmisResponse.responseSuccess(mapper.readValue(task.getTemplateInputSchema(), Map.class));
}
@GetMapping("execute/{id}")
public AmisResponse<?> execute(@PathVariable("id") Long id) throws JsonProcessingException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
flowTaskService.execute(id);
return AmisResponse.responseSuccess();
}
@Override @Override
protected SaveItemMapper<FlowTask, SaveItem> saveItemMapper() { protected SaveItemMapper<FlowTask, SaveItem> saveItemMapper() {
return item -> { return item -> {
FlowTask task = new FlowTask(); FlowTask task = new FlowTask();
FlowTaskTemplate template = flowTaskTemplateService.detailOrThrow(item.getTemplateId()); FlowTaskTemplate template = flowTaskTemplateService.detailOrThrow(item.getTemplateId());
task.setTemplate(template); task.setTemplateName(template.getName());
task.setInput(mapper.writeValueAsString(item.getInput())); task.setTemplateDescription(template.getDescription());
task.setTemplateInputSchema(template.getInputSchema());
task.setTemplateFlowGraph(template.getFlowGraph());
task.setInput(ObjectUtil.isEmpty(item.getInput()) ? null : mapper.writeValueAsString(item.getInput()));
return task; return task;
}; };
} }
@@ -45,13 +80,13 @@ public class TaskController extends SimpleControllerSupport<FlowTask, TaskContro
@Override @Override
protected ListItemMapper<FlowTask, ListItem> listItemMapper() { protected ListItemMapper<FlowTask, ListItem> listItemMapper() {
ListItem.Mapper map = Mappers.getMapper(ListItem.Mapper.class); ListItem.Mapper map = Mappers.getMapper(ListItem.Mapper.class);
return task -> map.from(task, mapper); return map::from;
} }
@Override @Override
protected DetailItemMapper<FlowTask, DetailItem> detailItemMapper() { protected DetailItemMapper<FlowTask, DetailItem> detailItemMapper() {
DetailItem.Mapper map = Mappers.getMapper(DetailItem.Mapper.class); DetailItem.Mapper map = Mappers.getMapper(DetailItem.Mapper.class);
return task -> map.from(task, mapper); return map::from;
} }
@Data @Data
@@ -63,18 +98,16 @@ public class TaskController extends SimpleControllerSupport<FlowTask, TaskContro
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public static class ListItem extends SimpleItem { public static class ListItem extends SimpleItem {
private Long templateId; private String templateName;
private Object input;
private FlowTask.Status status; private FlowTask.Status status;
private Boolean hasInput;
@org.mapstruct.Mapper @org.mapstruct.Mapper(imports = {
StrUtil.class
})
public static abstract class Mapper { public static abstract class Mapper {
@Mapping(target = "templateId", source = "task.template.id") @Mapping(target = "hasInput", expression = "java(StrUtil.isNotBlank(task.getInput()))")
public abstract ListItem from(FlowTask task, @Context ObjectMapper mapper) throws JsonProcessingException; public abstract ListItem from(FlowTask task);
protected Object mapInput(String input, @Context ObjectMapper mapper) throws JsonProcessingException {
return mapper.readValue(input, Object.class);
}
} }
} }
@@ -84,10 +117,12 @@ public class TaskController extends SimpleControllerSupport<FlowTask, TaskContro
private String error; private String error;
private String result; private String result;
@org.mapstruct.Mapper @org.mapstruct.Mapper(imports = {
StrUtil.class
})
public static abstract class Mapper extends ListItem.Mapper { public static abstract class Mapper extends ListItem.Mapper {
@Mapping(target = "templateId", source = "task.template.id") @Mapping(target = "hasInput", expression = "java(StrUtil.isNotBlank(task.getInput()))")
public abstract DetailItem from(FlowTask task, @Context ObjectMapper mapper) throws JsonProcessingException; public abstract DetailItem from(FlowTask task);
} }
} }
} }

View File

@@ -1,12 +1,11 @@
package com.lanyuanxiaoyao.service.ai.web.controller.task; package com.lanyuanxiaoyao.service.ai.web.controller.task;
import cn.hutool.core.util.ObjectUtil;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.lanyuanxiaoyao.service.ai.core.entity.amis.AmisCrudResponse;
import com.lanyuanxiaoyao.service.ai.core.entity.amis.AmisResponse; import com.lanyuanxiaoyao.service.ai.core.entity.amis.AmisResponse;
import com.lanyuanxiaoyao.service.ai.web.base.controller.SimpleControllerSupport; import com.lanyuanxiaoyao.service.ai.web.base.controller.SimpleControllerSupport;
import com.lanyuanxiaoyao.service.ai.web.base.controller.query.Query;
import com.lanyuanxiaoyao.service.ai.web.base.entity.SimpleItem; import com.lanyuanxiaoyao.service.ai.web.base.entity.SimpleItem;
import com.lanyuanxiaoyao.service.ai.web.entity.FlowTaskTemplate; import com.lanyuanxiaoyao.service.ai.web.entity.FlowTaskTemplate;
import com.lanyuanxiaoyao.service.ai.web.service.task.FlowTaskTemplateService; import com.lanyuanxiaoyao.service.ai.web.service.task.FlowTaskTemplateService;
@@ -17,6 +16,10 @@ import lombok.extern.slf4j.Slf4j;
import org.mapstruct.Context; import org.mapstruct.Context;
import org.mapstruct.factory.Mappers; import org.mapstruct.factory.Mappers;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -24,32 +27,39 @@ import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
@RequestMapping("flow_task/template") @RequestMapping("flow_task/template")
public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemplate, TaskTemplateController.SaveItem, TaskTemplateController.ListItem, TaskTemplateController.DetailItem> { public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemplate, TaskTemplateController.SaveItem, TaskTemplateController.ListItem, TaskTemplateController.DetailItem> {
private final FlowTaskTemplateService flowTaskTemplateService;
private final ObjectMapper mapper; private final ObjectMapper mapper;
public TaskTemplateController(FlowTaskTemplateService flowTaskTemplateService, Jackson2ObjectMapperBuilder builder) { public TaskTemplateController(FlowTaskTemplateService flowTaskTemplateService, Jackson2ObjectMapperBuilder builder) {
super(flowTaskTemplateService); super(flowTaskTemplateService);
this.flowTaskTemplateService = flowTaskTemplateService;
this.mapper = builder.build(); this.mapper = builder.build();
} }
@Override @GetMapping("input_schema/{id}")
public AmisResponse<Long> save(SaveItem saveItem) throws Exception { public AmisResponse<?> getInputSchema(@PathVariable("id") Long id) throws JsonProcessingException {
log.info("Save: {}", saveItem); var template = flowTaskTemplateService.detailOrThrow(id);
SaveItem.Mapper map = Mappers.getMapper(SaveItem.Mapper.class); if (ObjectUtil.isEmpty(template.getInputSchema())) {
log.info("Mapper: {}", map.from(saveItem, mapper)); return AmisResponse.responseSuccess();
return super.save(saveItem); }
return AmisResponse.responseSuccess(mapper.readValue(template.getInputSchema(), Map.class));
} }
@Override @GetMapping("flow_graph/{id}")
public AmisCrudResponse list(Query query) throws Exception { public AmisResponse<?> getFlowGraph(@PathVariable("id") Long id) throws JsonProcessingException {
AmisCrudResponse list = super.list(query); var template = flowTaskTemplateService.detailOrThrow(id);
log.info("List: {}", list); return AmisResponse.responseSuccess(mapper.readValue(template.getFlowGraph(), Map.class));
return list; }
@PostMapping("update_flow_graph")
public AmisResponse<?> updateFlowGraph(@RequestBody UpdateGraphItem item) throws JsonProcessingException {
flowTaskTemplateService.updateFlowGraph(item.getId(), mapper.writeValueAsString(item.getGraph()));
return AmisResponse.responseSuccess();
} }
@Override @Override
protected SaveItemMapper<FlowTaskTemplate, SaveItem> saveItemMapper() { protected SaveItemMapper<FlowTaskTemplate, SaveItem> saveItemMapper() {
SaveItem.Mapper map = Mappers.getMapper(SaveItem.Mapper.class); return Mappers.getMapper(SaveItem.Mapper.class);
return item -> map.from(item, mapper);
} }
@Override @Override
@@ -59,7 +69,7 @@ public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemp
@Override @Override
protected DetailItemMapper<FlowTaskTemplate, DetailItem> detailItemMapper() { protected DetailItemMapper<FlowTaskTemplate, DetailItem> detailItemMapper() {
DetailItem.Mapper map = Mappers.getMapper(DetailItem.Mapper.class); var map = Mappers.getMapper(DetailItem.Mapper.class);
return template -> map.from(template, mapper); return template -> map.from(template, mapper);
} }
@@ -68,15 +78,9 @@ public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemp
private Long id; private Long id;
private String name; private String name;
private String description; private String description;
private Map<String, Object> inputSchema;
@org.mapstruct.Mapper @org.mapstruct.Mapper
public static abstract class Mapper { public interface Mapper extends SaveItemMapper<FlowTaskTemplate, SaveItem> {
public abstract FlowTaskTemplate from(SaveItem saveItem, @Context ObjectMapper mapper) throws Exception;
protected String mapInputSchema(Map<String, Object> inputSchema, @Context ObjectMapper mapper) throws JsonProcessingException {
return mapper.writeValueAsString(inputSchema);
}
} }
} }
@@ -97,14 +101,25 @@ public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemp
private String name; private String name;
private String description; private String description;
private Map<String, Object> inputSchema; private Map<String, Object> inputSchema;
private Map<String, Object> flowGraph;
@org.mapstruct.Mapper @org.mapstruct.Mapper
public static abstract class Mapper { public static abstract class Mapper {
public abstract DetailItem from(FlowTaskTemplate template, @Context ObjectMapper mapper) throws Exception; public abstract DetailItem from(FlowTaskTemplate template, @Context ObjectMapper mapper) throws Exception;
public Map<String, Object> mapInputSchema(String inputSchema, @Context ObjectMapper mapper) throws Exception { public Map<String, Object> mapJson(String source, @Context ObjectMapper mapper) throws Exception {
return mapper.readValue(inputSchema, new TypeReference<>() {}); if (ObjectUtil.isNull(source)) {
return null;
}
return mapper.readValue(source, new TypeReference<>() {
});
} }
} }
} }
@Data
public static class UpdateGraphItem {
private Long id;
private Map<String, Object> graph;
}
} }

View File

@@ -1,5 +1,6 @@
package com.lanyuanxiaoyao.service.ai.web.engine; package com.lanyuanxiaoyao.service.ai.web.engine;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowGraph; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowGraph;
import com.lanyuanxiaoyao.service.ai.web.engine.store.FlowStore; import com.lanyuanxiaoyao.service.ai.web.engine.store.FlowStore;
import java.lang.reflect.InvocationTargetException; import java.lang.reflect.InvocationTargetException;
@@ -21,7 +22,11 @@ public class FlowExecutor {
} }
public void execute(FlowGraph graph) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException { public void execute(FlowGraph graph) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
var runner = new FlowGraphRunner(graph, flowStore, runnerMap); execute(graph, new FlowContext());
}
public void execute(FlowGraph graph, FlowContext context) throws InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
var runner = new FlowGraphRunner(graph, context, flowStore, runnerMap);
runner.run(); runner.run();
} }
} }

View File

@@ -1,5 +1,7 @@
package com.lanyuanxiaoyao.service.ai.web.engine; package com.lanyuanxiaoyao.service.ai.web.engine;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowEdge; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowEdge;
@@ -20,6 +22,7 @@ import org.eclipse.collections.api.multimap.set.ImmutableSetMultimap;
*/ */
public final class FlowGraphRunner { public final class FlowGraphRunner {
private final FlowGraph flowGraph; private final FlowGraph flowGraph;
private final FlowContext flowContext;
private final FlowStore flowStore; private final FlowStore flowStore;
private final ImmutableMap<String, Class<? extends FlowNodeRunner>> nodeRunnerClass; private final ImmutableMap<String, Class<? extends FlowNodeRunner>> nodeRunnerClass;
private final Queue<FlowNode> executionQueue = new LinkedList<>(); private final Queue<FlowNode> executionQueue = new LinkedList<>();
@@ -27,8 +30,9 @@ public final class FlowGraphRunner {
private final ImmutableSetMultimap<String, FlowEdge> nodeOutputMap; private final ImmutableSetMultimap<String, FlowEdge> nodeOutputMap;
private final ImmutableMap<String, FlowNode> nodeMap; private final ImmutableMap<String, FlowNode> nodeMap;
public FlowGraphRunner(FlowGraph flowGraph, FlowStore flowStore, ImmutableMap<String, Class<? extends FlowNodeRunner>> nodeRunnerClass) { public FlowGraphRunner(FlowGraph flowGraph, FlowContext flowContext, FlowStore flowStore, ImmutableMap<String, Class<? extends FlowNodeRunner>> nodeRunnerClass) {
this.flowGraph = flowGraph; this.flowGraph = flowGraph;
this.flowContext = flowContext;
this.flowStore = flowStore; this.flowStore = flowStore;
this.nodeRunnerClass = nodeRunnerClass; this.nodeRunnerClass = nodeRunnerClass;
@@ -40,13 +44,17 @@ public final class FlowGraphRunner {
public void run() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { public void run() throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
flowStore.init(flowGraph); flowStore.init(flowGraph);
var context = new FlowContext();
for (FlowNode node : flowGraph.nodes()) { for (FlowNode node : flowGraph.nodes()) {
executionQueue.offer(node); if (ObjectUtil.isNull(node.parentId())) {
executionQueue.offer(node);
}
} }
while (!executionQueue.isEmpty()) { while (!executionQueue.isEmpty()) {
var node = executionQueue.poll(); var node = executionQueue.poll();
process(node, context); if (ObjectUtil.isNull(node)) {
continue;
}
process(node, flowContext);
} }
} }
@@ -72,8 +80,17 @@ public final class FlowGraphRunner {
var runner = runnerClazz.getDeclaredConstructor().newInstance(); var runner = runnerClazz.getDeclaredConstructor().newInstance();
runner.setNodeId(node.id()); runner.setNodeId(node.id());
runner.setContext(context); runner.setContext(context);
// 处理子流程节点的逻辑
if (runner instanceof FlowNodeSubflowRunner subflowRunner) {
var subflowNodes = flowGraph.nodes().select(n -> StrUtil.equals(n.parentId(), node.id()));
var subGraph = new FlowGraph(IdUtil.fastUUID(), subflowNodes, flowGraph.edges());
subflowRunner.setSubGraph(subGraph);
}
runner.run(); runner.run();
// 处理选择节点的逻辑
if (runner instanceof FlowNodeOptionalRunner) { if (runner instanceof FlowNodeOptionalRunner) {
var targetPoint = ((FlowNodeOptionalRunner) runner).getTargetPoint(); var targetPoint = ((FlowNodeOptionalRunner) runner).getTargetPoint();
for (FlowEdge edge : nodeOutputMap.get(node.id())) { for (FlowEdge edge : nodeOutputMap.get(node.id())) {

View File

@@ -0,0 +1,58 @@
package com.lanyuanxiaoyao.service.ai.web.engine;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.template.TemplateEngine;
import cn.hutool.extra.template.TemplateUtil;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.collections.api.factory.Maps;
import org.eclipse.collections.api.map.ImmutableMap;
/**
* @author lanyuanxiaoyao
* @version 20250711
*/
@Slf4j
public class FlowHelper {
private static final TemplateEngine TEMPLATE_ENGINE = TemplateUtil.createEngine();
public static ImmutableMap<String, Object> generateInputVariablesMap(String nodeId, FlowContext context) {
var variableMap = Maps.mutable.<String, Object>empty();
var currentNodeData = context.get(nodeId);
if (currentNodeData.containsKey("inputs")) {
var inputsMap = (Map<String, Map<String, String>>) currentNodeData.get("inputs");
for (String variableName : inputsMap.keySet()) {
var expression = inputsMap.get(variableName).get("variable");
var targetVariable = generateVariable(expression, context);
if (ObjectUtil.isNotNull(targetVariable)) {
variableMap.put(variableName, targetVariable);
}
}
}
return variableMap.toImmutable();
}
public static Object generateVariable(String expression, FlowContext context) {
if (StrUtil.contains(expression, ".")) {
var splits = StrUtil.splitTrim(expression, ".", 2);
var targetNodeId = splits.get(0);
var targetVariableName = splits.get(1);
if (!context.getData().containsKey(targetNodeId)) {
throw new RuntimeException(StrUtil.format("Target node id not found: {}", targetNodeId));
}
var targetNodeData = context.getData().get(targetNodeId);
if (!targetNodeData.containsKey(targetVariableName)) {
throw new RuntimeException(StrUtil.format("Target node variable not found: {}.{}", targetNodeId, targetVariableName));
}
return targetNodeData.get(targetVariableName);
}
return null;
}
public static String renderTemplateText(String templateText, Map<?, ?> data) {
var template = TEMPLATE_ENGINE.getTemplate(templateText);
return template.render(data);
}
}

View File

@@ -2,6 +2,7 @@ package com.lanyuanxiaoyao.service.ai.web.engine;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext; import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import lombok.Getter; import lombok.Getter;
import org.eclipse.collections.api.map.MutableMap;
public abstract class FlowNodeRunner { public abstract class FlowNodeRunner {
@Getter @Getter
@@ -19,6 +20,10 @@ public abstract class FlowNodeRunner {
this.context = context; this.context = context;
} }
protected MutableMap<String, Object> getData() {
return context.get(nodeId);
}
protected <T> T getData(String key) { protected <T> T getData(String key) {
var data = context.get(nodeId); var data = context.get(nodeId);
return (T) data.get(key); return (T) data.get(key);

View File

@@ -0,0 +1,17 @@
package com.lanyuanxiaoyao.service.ai.web.engine;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowGraph;
import lombok.Getter;
import lombok.Setter;
/**
* 包含子流程的流程
*
* @author lanyuanxiaoyao
* @version 20250717
*/
public abstract class FlowNodeSubflowRunner extends FlowNodeRunner {
@Getter
@Setter
private FlowGraph subGraph;
}

View File

@@ -1,5 +1,6 @@
package com.lanyuanxiaoyao.service.ai.web.engine.entity; package com.lanyuanxiaoyao.service.ai.web.engine.entity;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/** /**
@@ -12,7 +13,9 @@ public record FlowEdge(
String id, String id,
String source, String source,
String target, String target,
@JsonProperty("sourceHandle")
String sourcePoint, String sourcePoint,
@JsonProperty("targetHandle")
String targetPoint String targetPoint
) { ) {
public enum Status { public enum Status {

View File

@@ -10,7 +10,8 @@ import java.time.LocalDateTime;
*/ */
public record FlowNode( public record FlowNode(
String id, String id,
String type String type,
String parentId
) { ) {
public enum Status { public enum Status {
INITIAL, RUNNING, FINISHED, SKIPPED INITIAL, RUNNING, FINISHED, SKIPPED

View File

@@ -0,0 +1,37 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lanyuanxiaoyao.service.ai.web.configuration.SpringBeanGetter;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowHelper;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeRunner;
import com.lanyuanxiaoyao.service.ai.web.engine.node.code.CodeExecutor;
import com.lanyuanxiaoyao.service.ai.web.engine.node.code.JavaScriptCodeExecutor;
import com.lanyuanxiaoyao.service.ai.web.engine.node.code.PythonCodeExecutor;
import lombok.extern.slf4j.Slf4j;
/**
* @author lanyuanxiaoyao
* @version 20250717
*/
@Slf4j
public class CodeNode extends FlowNodeRunner {
@Override
public void run() {
var mapper = SpringBeanGetter.getBean(ObjectMapper.class);
var inputVariablesMap = FlowHelper.generateInputVariablesMap(getNodeId(), getContext());
var type = this.<String>getData("type");
var script = this.<String>getData("content");
CodeExecutor executor = switch (type) {
case "javascript" -> new JavaScriptCodeExecutor(mapper);
case "python" -> new PythonCodeExecutor(mapper);
default -> null;
};
if (ObjectUtil.isNull(executor)) {
throw new RuntimeException(StrUtil.format("Unsupported type: {}", type));
}
var result = executor.execute(script, inputVariablesMap);
result.forEachKeyValue(this::setData);
}
}

View File

@@ -0,0 +1,25 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeRunner;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
/**
* @author lanyuanxiaoyao
* @version 20250711
*/
@Slf4j
public class InputNode extends FlowNodeRunner {
public static final String KEY = "flow_inputs";
@Override
public void run() {
var inputData = getContext().getData().get(KEY);
var inputs = this.<Map<String, Object>>getData("inputs");
for (String variable : inputs.keySet()) {
if (inputData.containsKey(variable)) {
setData(variable, inputData.get(variable));
}
}
}
}

View File

@@ -0,0 +1,31 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node;
import cn.hutool.core.util.StrUtil;
import com.lanyuanxiaoyao.service.ai.web.configuration.SpringBeanGetter;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowHelper;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeRunner;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
/**
* @author lanyuanxiaoyao
* @version 20250711
*/
@Slf4j
public class LlmNode extends FlowNodeRunner {
@Override
public void run() {
var variableMap = FlowHelper.generateInputVariablesMap(getNodeId(), getContext());
var sourcePrompt = (String) getData("systemPrompt");
if (StrUtil.isNotBlank(sourcePrompt)) {
var prompt = FlowHelper.renderTemplateText(sourcePrompt, variableMap.toMap());
var builder = SpringBeanGetter.getBean("chat", ChatClient.Builder.class);
var client = builder.build();
var content = client.prompt()
.user(prompt)
.call()
.content();
setData("text", content);
}
}
}

View File

@@ -0,0 +1,18 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeSubflowRunner;
import lombok.extern.slf4j.Slf4j;
/**
* 循环节点
*
* @author lanyuanxiaoyao
* @version 20250717
*/
@Slf4j
public class LoopNode extends FlowNodeSubflowRunner {
@Override
public void run() {
log.info("{}", getSubGraph());
}
}

View File

@@ -0,0 +1,20 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowHelper;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeRunner;
import lombok.extern.slf4j.Slf4j;
/**
* @author lanyuanxiaoyao
* @version 20250711
*/
@Slf4j
public class OutputNode extends FlowNodeRunner {
private static final String KEY = "flow_outputs";
@Override
public void run() {
var variableMap = FlowHelper.generateInputVariablesMap(getNodeId(), getContext());
getContext().getData().put(KEY, variableMap.toMap());
}
}

View File

@@ -0,0 +1,87 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.StrUtil;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowHelper;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowNodeOptionalRunner;
import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
/**
* @author lanyuanxiaoyao
* @version 20250717
*/
@SuppressWarnings("unchecked")
@Slf4j
public class SwitchNode extends FlowNodeOptionalRunner {
@Override
public String runOptional() {
var conditions = this.<List<Map<String, Object>>>getData("conditions");
for (Map<String, Object> item : conditions) {
var condition = (Map<String, Object>) item.getOrDefault("condition", Map.of());
var id = (String) condition.getOrDefault("id", "");
var conjunction = (String) condition.getOrDefault("conjunction", "and");
var conditionChildren = ((List<Map<String, Object>>) condition.getOrDefault("children", List.<Map<String, Object>>of()));
if (
StrUtil.equals(conjunction, "and")
&& conditionChildren.stream().allMatch(this::check)
) {
return id;
} else if (
StrUtil.equals(conjunction, "or")
&& conditionChildren.stream().anyMatch(this::check)
) {
return id;
}
}
return "";
}
private Boolean check(Map<String, Object> condition) {
var leftVariable = (String) BeanUtil.getProperty(condition, "left.field");
var left = FlowHelper.generateVariable(leftVariable, getContext());
var operator = (String) condition.get("op");
var right = condition.get("right");
if (left instanceof CharSequence || left instanceof Boolean) {
String source = StrUtil.toStringOrNull(left);
String target = StrUtil.toStringOrNull(right);
return switch (operator) {
case "equal" -> StrUtil.equals(source, target);
case "not_equal" -> !StrUtil.equals(source, target);
case "is_empty" -> StrUtil.isBlank(source);
case "is_not_empty" -> StrUtil.isNotBlank(source);
case "like" -> StrUtil.contains(source, target);
case "not_like" -> !StrUtil.contains(source, target);
case "starts_with" -> StrUtil.startWith(source, target);
case "ends_with" -> StrUtil.endWith(source, target);
default -> false;
};
} else if (left instanceof Number source) {
var sourceNumber = new BigDecimal(StrUtil.toString(source));
var targetNumber = new BigDecimal(StrUtil.toString(right));
return switch (operator) {
case "equal" -> NumberUtil.equals(sourceNumber, targetNumber);
case "not_equal" -> !NumberUtil.equals(sourceNumber, targetNumber);
case "greater" -> NumberUtil.isGreater(sourceNumber, targetNumber);
case "greater_equal" -> NumberUtil.isGreaterOrEqual(sourceNumber, targetNumber);
case "less" -> NumberUtil.isLess(sourceNumber, targetNumber);
case "less_equal" -> NumberUtil.isLessOrEqual(sourceNumber, targetNumber);
default -> false;
};
} else if (left instanceof Collection<?> source) {
return switch (operator) {
case "is_empty" -> CollectionUtil.isEmpty(source);
case "is_not_empty" -> CollectionUtil.isNotEmpty(source);
case "contain" -> CollectionUtil.safeContains(source, right);
case "not_contain" -> !CollectionUtil.safeContains(source, right);
default -> false;
};
}
return false;
}
}

View File

@@ -0,0 +1,11 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node.code;
import org.eclipse.collections.api.map.ImmutableMap;
/**
* @author lanyuanxiaoyao
* @version 20250717
*/
public interface CodeExecutor {
ImmutableMap<String, Object> execute(String script, ImmutableMap<String, Object> inputVariablesMap);
}

View File

@@ -0,0 +1,57 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node.code;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.collections.api.factory.Maps;
import org.eclipse.collections.api.map.ImmutableMap;
import org.graalvm.polyglot.Context;
import org.graalvm.polyglot.Engine;
import org.graalvm.polyglot.Source;
/**
* @author lanyuanxiaoyao
* @version 20250717
*/
@Slf4j
public class JavaScriptCodeExecutor implements CodeExecutor {
private final ObjectMapper mapper;
public JavaScriptCodeExecutor(ObjectMapper mapper) {
this.mapper = mapper;
}
@SneakyThrows
@Override
public ImmutableMap<String, Object> execute(String script, ImmutableMap<String, Object> inputVariablesMap) {
if (StrUtil.isBlank(script)) {
return Maps.immutable.empty();
}
try (var engin = Engine.create()) {
try (
var context = Context.newBuilder()
.allowAllAccess(true)
.engine(engin)
.build()
) {
var bindings = context.getBindings("js");
bindings.putMember("context", inputVariablesMap);
var result = context.eval(
Source.create(
"js",
"""
function process() {
%s
}
var result = process();
JSON.stringify(result? result: {})
""".formatted(script)
)
);
return mapper.readValue(result.asString(), new TypeReference<>() {});
}
}
}
}

View File

@@ -0,0 +1,50 @@
package com.lanyuanxiaoyao.service.ai.web.engine.node.code;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Arrays;
import java.util.stream.Collectors;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.collections.api.map.ImmutableMap;
import org.python.core.PySystemState;
import org.python.util.PythonInterpreter;
/**
* @author lanyuanxiaoyao
* @version 20250718
*/
@Slf4j
public class PythonCodeExecutor implements CodeExecutor {
private final ObjectMapper mapper;
public PythonCodeExecutor(ObjectMapper mapper) {
this.mapper = mapper;
}
@SneakyThrows
@Override
public ImmutableMap<String, Object> execute(String script, ImmutableMap<String, Object> inputVariablesMap) {
try (var systemState = new PySystemState()) {
systemState.setdefaultencoding("UTF-8");
var interpreter = new PythonInterpreter(null, systemState);
script = Arrays.stream(script.split("\n"))
.map(line -> " " + line)
.collect(Collectors.joining("\n"));
interpreter.set("context", inputVariablesMap);
var pythonScript = interpreter.compile(
"""
import json
def process():
%s
result = json.dumps(process())
""".formatted(script)
);
interpreter.exec(pythonScript);
var result = interpreter.get("result");
return mapper.readValue((String) result.__tojava__(String.class), new TypeReference<>() {});
}
}
}

View File

@@ -3,14 +3,10 @@ package com.lanyuanxiaoyao.service.ai.web.entity;
import com.lanyuanxiaoyao.service.ai.web.base.entity.SimpleEntity; import com.lanyuanxiaoyao.service.ai.web.base.entity.SimpleEntity;
import com.lanyuanxiaoyao.service.common.Constants; import com.lanyuanxiaoyao.service.common.Constants;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.ConstraintMode;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners; import jakarta.persistence.EntityListeners;
import jakarta.persistence.EnumType; import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated; import jakarta.persistence.Enumerated;
import jakarta.persistence.ForeignKey;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import lombok.Getter; import lombok.Getter;
import lombok.Setter; import lombok.Setter;
@@ -28,10 +24,23 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@Table(catalog = Constants.DATABASE_NAME, name = "service_ai_flow_task") @Table(catalog = Constants.DATABASE_NAME, name = "service_ai_flow_task")
@Comment("流程任务记录") @Comment("流程任务记录")
public class FlowTask extends SimpleEntity { public class FlowTask extends SimpleEntity {
@Comment("流程任务对应的模板") // 每个任务对应的模板都是唯一,避免模板修改之后任务的状态、运行等状态都无法展示
@ManyToOne // 不管允许不允许任务重跑,这些都要保存下来
@JoinColumn(nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) @Comment("任务对应的模板名称")
private FlowTaskTemplate template; @Column(nullable = false)
private String templateName;
@Comment("任务对应的模板功能、内容说明")
private String templateDescription;
@Comment("任务对应的模板入参Schema")
@Column(columnDefinition = "longtext")
private String templateInputSchema;
@Comment("任务对应的模板前端流程图数据")
@Column(nullable = false, columnDefinition = "longtext")
private String templateFlowGraph = "{}";
@Comment("任务注释,用于额外说明")
@Column(columnDefinition = "text")
private String comment;
@Comment("任务输入") @Comment("任务输入")
@Column(columnDefinition = "longtext") @Column(columnDefinition = "longtext")
private String input; private String input;

View File

@@ -28,9 +28,9 @@ public class FlowTaskTemplate extends SimpleEntity {
@Comment("模板功能、内容说明") @Comment("模板功能、内容说明")
private String description; private String description;
@Comment("模板入参Schema") @Comment("模板入参Schema")
@Column(nullable = false, columnDefinition = "longtext") @Column(columnDefinition = "longtext")
private String inputSchema; private String inputSchema;
@Comment("前端流程图数据") @Comment("前端流程图数据")
@Column(columnDefinition = "longtext") @Column(nullable = false, columnDefinition = "longtext")
private String flowGraph; private String flowGraph = "{}";
} }

View File

@@ -0,0 +1,14 @@
package com.lanyuanxiaoyao.service.ai.web.entity.vo;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowEdge;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowNode;
import lombok.Data;
import org.eclipse.collections.api.map.MutableMap;
import org.eclipse.collections.api.set.ImmutableSet;
@Data
public class FlowGraphVo {
private ImmutableSet<FlowNode> nodes;
private ImmutableSet<FlowEdge> edges;
private MutableMap<String, MutableMap<String, Object>> data;
}

View File

@@ -7,9 +7,7 @@ import com.lanyuanxiaoyao.service.ai.web.entity.context.FeedbackContext;
import com.lanyuanxiaoyao.service.ai.web.repository.FeedbackRepository; import com.lanyuanxiaoyao.service.ai.web.repository.FeedbackRepository;
import com.yomahub.liteflow.core.FlowExecutor; import com.yomahub.liteflow.core.FlowExecutor;
import java.util.List; import java.util.List;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -24,7 +22,7 @@ import org.springframework.transaction.annotation.Transactional;
this.executor = executor; this.executor = executor;
} }
@Scheduled(initialDelay = 1, fixedDelay = 1, timeUnit = TimeUnit.MINUTES) // @Scheduled(initialDelay = 1, fixedDelay = 1, timeUnit = TimeUnit.MINUTES)
public void analysis() { public void analysis() {
List<Feedback> feedbacks = repository.findAll( List<Feedback> feedbacks = repository.findAll(
builder -> builder builder -> builder

View File

@@ -1,15 +1,66 @@
package com.lanyuanxiaoyao.service.ai.web.service.task; package com.lanyuanxiaoyao.service.ai.web.service.task;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lanyuanxiaoyao.service.ai.web.base.service.SimpleServiceSupport; import com.lanyuanxiaoyao.service.ai.web.base.service.SimpleServiceSupport;
import com.lanyuanxiaoyao.service.ai.web.engine.FlowExecutor;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowContext;
import com.lanyuanxiaoyao.service.ai.web.engine.entity.FlowGraph;
import com.lanyuanxiaoyao.service.ai.web.engine.node.CodeNode;
import com.lanyuanxiaoyao.service.ai.web.engine.node.InputNode;
import com.lanyuanxiaoyao.service.ai.web.engine.node.LlmNode;
import com.lanyuanxiaoyao.service.ai.web.engine.node.LoopNode;
import com.lanyuanxiaoyao.service.ai.web.engine.node.OutputNode;
import com.lanyuanxiaoyao.service.ai.web.engine.node.SwitchNode;
import com.lanyuanxiaoyao.service.ai.web.engine.store.InMemoryFlowStore;
import com.lanyuanxiaoyao.service.ai.web.entity.FlowTask; import com.lanyuanxiaoyao.service.ai.web.entity.FlowTask;
import com.lanyuanxiaoyao.service.ai.web.entity.vo.FlowGraphVo;
import com.lanyuanxiaoyao.service.ai.web.repository.FlowTaskRepository; import com.lanyuanxiaoyao.service.ai.web.repository.FlowTaskRepository;
import java.lang.reflect.InvocationTargetException;
import java.util.Map;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.collections.api.factory.Maps;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Slf4j @Slf4j
@Service @Service
public class FlowTaskService extends SimpleServiceSupport<FlowTask> { public class FlowTaskService extends SimpleServiceSupport<FlowTask> {
public FlowTaskService(FlowTaskRepository flowTaskRepository) { private final ObjectMapper mapper;
public FlowTaskService(FlowTaskRepository flowTaskRepository, Jackson2ObjectMapperBuilder builder) {
super(flowTaskRepository); super(flowTaskRepository);
this.mapper = builder.build();
}
public void execute(Long id) throws JsonProcessingException, InvocationTargetException, NoSuchMethodException, InstantiationException, IllegalAccessException {
var flowTask = detailOrThrow(id);
var graphVo = mapper.readValue(flowTask.getTemplateFlowGraph(), FlowGraphVo.class);
var flowGraph = new FlowGraph(IdUtil.fastUUID(), graphVo.getNodes(), graphVo.getEdges());
var store = new InMemoryFlowStore();
var executor = new FlowExecutor(
store,
Maps.immutable.ofAll(Map.of(
"loop-node", LoopNode.class,
"switch-node", SwitchNode.class,
"code-node", CodeNode.class,
"llm-node", LlmNode.class,
"input-node", InputNode.class,
"output-node", OutputNode.class
))
);
FlowContext context = new FlowContext();
context.setData(graphVo.getData());
if (ObjectUtil.isNotEmpty(flowTask.getInput())) {
context.getData().put(InputNode.KEY, mapper.readValue(flowTask.getInput(), new TypeReference<>() {
}));
}
executor.execute(flowGraph, context);
} }
} }

View File

@@ -1,15 +1,46 @@
package com.lanyuanxiaoyao.service.ai.web.service.task; package com.lanyuanxiaoyao.service.ai.web.service.task;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lanyuanxiaoyao.service.ai.web.base.service.SimpleServiceSupport; import com.lanyuanxiaoyao.service.ai.web.base.service.SimpleServiceSupport;
import com.lanyuanxiaoyao.service.ai.web.entity.FlowTaskTemplate; import com.lanyuanxiaoyao.service.ai.web.entity.FlowTaskTemplate;
import com.lanyuanxiaoyao.service.ai.web.entity.vo.FlowGraphVo;
import com.lanyuanxiaoyao.service.ai.web.repository.FlowTaskTemplateRepository; import com.lanyuanxiaoyao.service.ai.web.repository.FlowTaskTemplateRepository;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j @Slf4j
@Service @Service
public class FlowTaskTemplateService extends SimpleServiceSupport<FlowTaskTemplate> { public class FlowTaskTemplateService extends SimpleServiceSupport<FlowTaskTemplate> {
public FlowTaskTemplateService(FlowTaskTemplateRepository flowTaskTemplateRepository) { private final ObjectMapper mapper;
public FlowTaskTemplateService(FlowTaskTemplateRepository flowTaskTemplateRepository, Jackson2ObjectMapperBuilder builder) {
super(flowTaskTemplateRepository); super(flowTaskTemplateRepository);
this.mapper = builder.build();
}
@Transactional(rollbackFor = Exception.class)
public void updateFlowGraph(Long id, String flowGraph) throws JsonProcessingException {
var template = detailOrThrow(id);
var graph = mapper.readValue(flowGraph, FlowGraphVo.class);
// 如果发现输入节点,就单独提取出来
var inputNode = graph.getNodes()
.detectOptional(node -> StrUtil.equals(node.type(), "input-node") && ObjectUtil.isEmpty(node.parentId()))
.orElse(null);
if (ObjectUtil.isNotNull(inputNode)) {
var nodeId = inputNode.id();
var nodeData = graph.getData().getOrDefault(nodeId, null);
if (ObjectUtil.isNotNull(nodeData) && nodeData.containsKey("inputs")) {
template.setInputSchema(mapper.writeValueAsString(nodeData.get("inputs")));
}
}
template.setFlowGraph(flowGraph);
save(template);
} }
} }

View File

@@ -0,0 +1,29 @@
package com.lanyuanxiaoyao.service.ai.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.eclipsecollections.EclipseCollectionsModule;
import com.lanyuanxiaoyao.service.ai.web.engine.node.code.JavaScriptCodeExecutor;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.collections.api.factory.Maps;
/**
* @author lanyuanxiaoyao
* @version 20250717
*/
@Slf4j
public class TestCodeExecutor {
public static void main(String[] args) {
var mapper = new ObjectMapper();
mapper.registerModule(new EclipseCollectionsModule());
var executor = new JavaScriptCodeExecutor(mapper);
var result = executor.execute(
"""
return {'code': 1, 'text': context.get('text')}
""",
Maps.immutable.of(
"text", "hello world"
)
);
log.info("Result: {}", result);
}
}

View File

@@ -35,15 +35,15 @@ public class TestFlow {
var graph = new FlowGraph( var graph = new FlowGraph(
"graph-1", "graph-1",
Sets.immutable.of( Sets.immutable.of(
new FlowNode("node-1", "plain-node"), new FlowNode("node-1", "plain-node", null),
new FlowNode("node-2", "plain-node"), new FlowNode("node-2", "plain-node", null),
new FlowNode("node-4", "plain-node"), new FlowNode("node-4", "plain-node", null),
new FlowNode("node-6", "plain-node"), new FlowNode("node-6", "plain-node", null),
new FlowNode("node-7", "plain-node"), new FlowNode("node-7", "plain-node", null),
new FlowNode("node-5", "plain-node"), new FlowNode("node-5", "plain-node", null),
new FlowNode("node-8", "option-node"), new FlowNode("node-8", "option-node", null),
new FlowNode("node-9", "plain-node"), new FlowNode("node-9", "plain-node", null),
new FlowNode("node-3", "plain-node") new FlowNode("node-3", "plain-node", null)
), ),
Sets.immutable.of( Sets.immutable.of(
new FlowEdge("edge-1", "node-1", "node-2", null, null), new FlowEdge("edge-1", "node-1", "node-2", null, null),

View File

@@ -15,33 +15,33 @@
"@ant-design/x": "^1.4.0", "@ant-design/x": "^1.4.0",
"@echofly/fetch-event-source": "^3.0.2", "@echofly/fetch-event-source": "^3.0.2",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.7.2",
"@lightenna/react-mermaid-diagram": "^1.0.20", "@lightenna/react-mermaid-diagram": "^1.0.21",
"@xyflow/react": "^12.7.1", "@xyflow/react": "^12.8.2",
"ahooks": "^3.8.5", "ahooks": "^3.9.0",
"amis": "^6.12.0", "amis": "^6.12.0",
"antd": "^5.26.2", "antd": "^5.26.4",
"axios": "^1.10.0", "axios": "^1.10.0",
"chart.js": "^4.5.0", "chart.js": "^4.5.0",
"echarts-for-react": "^3.0.2", "echarts-for-react": "^3.0.2",
"licia": "^1.48.0", "licia": "^1.48.0",
"mermaid": "^11.7.0", "mermaid": "^11.8.1",
"react": "^18.3.1", "react": "^18.3.1",
"react-chartjs-2": "^5.3.0", "react-chartjs-2": "^5.3.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.6.2", "react-router": "^7.6.3",
"styled-components": "^6.1.19", "styled-components": "^6.1.19",
"yocto-queue": "^1.2.1", "yocto-queue": "^1.2.1",
"zustand": "^5.0.5" "zustand": "^5.0.6"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.3.23", "@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.10.2", "@vitejs/plugin-react-swc": "^3.10.2",
"globals": "^16.2.0", "globals": "^16.3.0",
"sass": "^1.89.2", "sass": "^1.89.2",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.0", "vite": "^7.0.4",
"vite-plugin-javascript-obfuscator": "^3.1.0", "vite-plugin-javascript-obfuscator": "^3.1.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,62 @@
import {type Connection, type Node} from '@xyflow/react'
import {expect, test} from 'vitest'
import {
checkAddConnection,
hasCycleError,
nodeToSelfError,
sourceNodeNotFoundError,
targetNodeNotFoundError,
} from './FlowChecker.tsx'
const createNode = (id: string, type: string): Node => {
return {
data: {},
position: {
x: 0,
y: 0
},
id,
type,
}
}
const createConnection = function (source: string, target: string, sourceHandle: string | null = null, targetHandle: string | null = null): Connection {
return {
source,
target,
sourceHandle,
targetHandle,
}
}
/* check add connection */
test(sourceNodeNotFoundError().message, () => {
expect(() => checkAddConnection(createConnection('a', 'b'), [], []))
})
test(targetNodeNotFoundError().message, () => {
expect(() => checkAddConnection(createConnection('a', 'b'), [createNode('a', 'normal-node')], []))
})
test(nodeToSelfError().message, () => {
expect(() => {
// language=JSON
const {
nodes,
edges
} = JSON.parse('{\n "nodes": [\n {\n "id": "P14abHl4uY",\n "type": "start-node",\n "position": {\n "x": 100,\n "y": 100\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 82\n }\n },\n {\n "id": "3YDRebKqCX",\n "type": "end-node",\n "position": {\n "x": 773.3027344262372,\n "y": 101.42648884412338\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "YXJ91nHVaz",\n "type": "llm-node",\n "position": {\n "x": 430.94541183662506,\n "y": 101.42648884412338\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": true,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "P14abHl4uY",\n "target": "YXJ91nHVaz",\n "id": "xy-edge__P14abHl4uY-YXJ91nHVaz"\n },\n {\n "source": "YXJ91nHVaz",\n "target": "3YDRebKqCX",\n "id": "xy-edge__YXJ91nHVaz-3YDRebKqCX"\n }\n ],\n "data": {}\n}')
checkAddConnection(createConnection('YXJ91nHVaz', 'YXJ91nHVaz'), nodes, edges)
}).toThrowError(nodeToSelfError())
})
test(hasCycleError().message, () => {
expect(() => {
// language=JSON
const {
nodes,
edges,
} = JSON.parse('{\n "nodes": [\n {\n "id": "-DKfXm7r3f",\n "type": "start-node",\n "position": {\n "x": -75.45812782717618,\n "y": 14.410669352596976\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 82\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "2uL3Hw2CAW",\n "type": "end-node",\n "position": {\n "x": 734.7875356349059,\n "y": -1.2807079327602473\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "yp-yYfKUzC",\n "type": "llm-node",\n "position": {\n "x": 338.2236369686051,\n "y": -92.5759939566568\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "N4HQPN-NYZ",\n "type": "llm-node",\n "position": {\n "x": 332.51768159211156,\n "y": 114.26488844123382\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": true,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "-DKfXm7r3f",\n "target": "yp-yYfKUzC",\n "id": "xy-edge__-DKfXm7r3f-yp-yYfKUzC"\n },\n {\n "source": "yp-yYfKUzC",\n "target": "2uL3Hw2CAW",\n "id": "xy-edge__yp-yYfKUzC-2uL3Hw2CAW"\n },\n {\n "source": "-DKfXm7r3f",\n "target": "N4HQPN-NYZ",\n "id": "xy-edge__-DKfXm7r3f-N4HQPN-NYZ"\n },\n {\n "source": "N4HQPN-NYZ",\n "target": "yp-yYfKUzC",\n "id": "xy-edge__N4HQPN-NYZ-yp-yYfKUzC"\n }\n ],\n "data": {}\n}')
// language=JSON
checkAddConnection(JSON.parse('{\n "source": "yp-yYfKUzC",\n "sourceHandle": null,\n "target": "N4HQPN-NYZ",\n "targetHandle": null\n}'), nodes, edges)
}).toThrowError(hasCycleError())
})

View File

@@ -0,0 +1,111 @@
import {type Connection, type Edge, getOutgoers, type Node} from '@xyflow/react'
import {find, has, isEmpty, isEqual, lpad, toStr} from 'licia'
import {NodeRegistryMap} from './NodeRegistry.tsx'
export class CheckError extends Error {
readonly id: string
constructor(
id: number,
message: string,
) {
super(message)
this.id = `E${lpad(toStr(id), 6, '0')}`
}
public toString(): string {
return `${this.id}: ${this.message}`
}
}
const getNodeById = (id: string, nodes: Node[]) => find(nodes, (n: Node) => isEqual(n.id, id))
export const typeNotFound = (type: string) => new CheckError(100, `类型 ${type} 不存在`)
export const addNodeError = (message?: string) => new CheckError(101, message ?? '无法添加节点')
// @ts-ignore
export const checkAddNode: (type: string, parentId: string | undefined, nodes: Node[], edges: Edge[]) => void = (type, parentId, nodes, edges) => {
let nodeDefine = NodeRegistryMap[type]
if (!nodeDefine) {
throw typeNotFound(type)
}
for (const checker of nodeDefine.checkers.add) {
let checkResult = checker(type, parentId, nodes, edges, undefined)
if (checkResult.error) {
throw addNodeError(checkResult.message)
}
}
}
export const sourceNodeNotFoundError = () => new CheckError(200, '连线起始节点未找到')
export const targetNodeNotFoundError = () => new CheckError(201, '连线目标节点未找到')
export const nodeToSelfError = () => new CheckError(203, '节点不能直连自身')
export const hasCycleError = () => new CheckError(204, '禁止流程循环')
export const differentParent = () => new CheckError(205, '子流程禁止连接外部节点')
const hasCycle = (sourceNode: Node, targetNode: Node, nodes: Node[], edges: Edge[], visited = new Set<string>()) => {
if (visited.has(targetNode.id)) return false
visited.add(targetNode.id)
for (const outgoer of getOutgoers(targetNode, nodes, edges)) {
if (isEqual(outgoer.id, sourceNode.id)) return true
if (hasCycle(sourceNode, outgoer, nodes, edges, visited)) return true
}
}
export const checkAddConnection: (connection: Connection, nodes: Node[], edges: Edge[]) => void = (connection, nodes, edges) => {
let sourceNode = getNodeById(connection.source, nodes)
if (!sourceNode) {
throw sourceNodeNotFoundError()
}
let targetNode = getNodeById(connection.target, nodes)
if (!targetNode) {
throw targetNodeNotFoundError()
}
if (!isEqual(sourceNode.parentId, targetNode.parentId)) {
throw differentParent()
}
// 禁止流程出现环,必须是有向无环图
if (isEqual(sourceNode.id, targetNode.id)) {
throw nodeToSelfError()
} else if (hasCycle(sourceNode, targetNode, nodes, edges)) {
throw hasCycleError()
}
// let newEdges = [...clone(edges), {...connection, id: uuid()}]
// let {hasAbnormalEdges} = getParallelInfo(nodes, newEdges)
// if (hasAbnormalEdges) {
// throw hasRedundantEdgeError()
// }
}
export const atLeastOneNode = () => new CheckError(300, '至少包含一个节点')
export const hasUnfinishedNode = (nodeId: string) => new CheckError(301, `存在尚未配置完成的节点: ${nodeId}`)
export const nodeTypeNotFound = () => new CheckError(302, '节点类型不存在')
export const saveNodeError = (nodeId: string, reason?: string) => new CheckError(303, reason ?? `节点配置存在错误:${nodeId}`)
// @ts-ignore
export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nodes, edges, data) => {
if (isEmpty(nodes)) {
throw atLeastOneNode()
}
for (let node of nodes) {
if (!has(data, node.id) || !data[node.id]?.finished) {
throw hasUnfinishedNode(node.id)
}
if (!has(node, 'type')) {
throw nodeTypeNotFound()
}
let nodeType = node.type!
let nodeDefine = NodeRegistryMap[nodeType]
for (let checker of nodeDefine.checkers.save) {
let checkResult = checker(node.id, node.parentId, nodes, edges, data)
if (checkResult.error) {
throw saveNodeError(node.id, checkResult.message)
}
}
}
}

View File

@@ -0,0 +1,151 @@
import {RollbackOutlined, SaveFilled} from '@ant-design/icons'
import {Background, BackgroundVariant, Controls, MiniMap, Panel, ReactFlow} from '@xyflow/react'
import {Button, message, Popconfirm, Space} from 'antd'
import {arrToMap} from 'licia'
import {useEffect} from 'react'
import {useNavigate} from 'react-router'
import styled from 'styled-components'
import '@xyflow/react/dist/style.css'
import {commonInfo} from '../../util/amis.tsx'
import AddNodeButton from './component/AddNodeButton.tsx'
import {checkAddConnection, checkSave} from './FlowChecker.tsx'
import {useNodeDrag} from './Helper.tsx'
import {NodeRegistryMap} from './NodeRegistry.tsx'
import {useDataStore} from './store/DataStore.ts'
import {useFlowStore} from './store/FlowStore.ts'
import {flowDotColor, type FlowEditorProps} from './types.ts'
const FlowableDiv = styled.div`
.react-flow__node.selectable {
&:focus {
box-shadow: 0 0 20px 1px #e8e8e8;
border-radius: 8px;
}
}
.react-flow__handle.connectionindicator {
width: 10px;
height: 10px;
background-color: #ffffff;
border: 1px solid #000000;
&:hover {
background-color: #e8e8e8;
border: 1px solid #c6c6c6;
}
}
.node-card {
cursor: default;
.card-container {
}
}
`
function FlowEditor(props: FlowEditorProps) {
const navigate = useNavigate()
const {data, setData} = useDataStore()
const {
nodes,
setNodes,
onNodesChange,
edges,
setEdges,
onEdgesChange,
onConnect,
} = useFlowStore()
useEffect(() => {
// language=JSON
// let initialData = JSON.parse('{"nodes":[{"id":"TCxPixrdkI","type":"start-node","position":{"x":-256,"y":109.5},"data":{},"measured":{"width":256,"height":83},"selected":false,"dragging":false},{"id":"tGs78_ietp","type":"llm-node","position":{"x":108,"y":-2.5},"data":{},"measured":{"width":256,"height":105},"selected":false,"dragging":false},{"id":"OeZdaU7LpY","type":"llm-node","position":{"x":111,"y":196},"data":{},"measured":{"width":256,"height":105},"selected":false,"dragging":false},{"id":"LjfoCYZo-E","type":"knowledge-node","position":{"x":497.62196259607214,"y":-10.792497317791003},"data":{},"measured":{"width":256,"height":75},"selected":false,"dragging":false},{"id":"sQM_22GYB5","type":"end-node","position":{"x":874.3164534765615,"y":151.70316541496913},"data":{},"measured":{"width":256,"height":75},"selected":false,"dragging":false},{"id":"KpMH_xc3ZZ","type":"llm-node","position":{"x":529.6286840434341,"y":150.4721376669937},"data":{},"measured":{"width":256,"height":75},"selected":false,"dragging":false},{"id":"pOrR6EMVbe","type":"switch-node","position":{"x":110.33793030183864,"y":373.9551529987239},"data":{},"measured":{"width":256,"height":157},"selected":false,"dragging":false}],"edges":[{"source":"TCxPixrdkI","sourceHandle":"source","target":"tGs78_ietp","targetHandle":"target","id":"xy-edge__TCxPixrdkIsource-tGs78_ietptarget"},{"source":"TCxPixrdkI","sourceHandle":"source","target":"OeZdaU7LpY","targetHandle":"target","id":"xy-edge__TCxPixrdkIsource-OeZdaU7LpYtarget"},{"source":"tGs78_ietp","sourceHandle":"source","target":"LjfoCYZo-E","targetHandle":"target","id":"xy-edge__tGs78_ietpsource-LjfoCYZo-Etarget"},{"source":"LjfoCYZo-E","sourceHandle":"source","target":"KpMH_xc3ZZ","targetHandle":"target","id":"xy-edge__LjfoCYZo-Esource-KpMH_xc3ZZtarget"},{"source":"OeZdaU7LpY","sourceHandle":"source","target":"KpMH_xc3ZZ","targetHandle":"target","id":"xy-edge__OeZdaU7LpYsource-KpMH_xc3ZZtarget"},{"source":"KpMH_xc3ZZ","sourceHandle":"source","target":"sQM_22GYB5","targetHandle":"target","id":"xy-edge__KpMH_xc3ZZsource-sQM_22GYB5target"},{"source":"TCxPixrdkI","sourceHandle":"source","target":"pOrR6EMVbe","id":"xy-edge__TCxPixrdkIsource-pOrR6EMVbe"},{"source":"pOrR6EMVbe","sourceHandle":"3","target":"sQM_22GYB5","targetHandle":"target","id":"xy-edge__pOrR6EMVbe3-sQM_22GYB5target"},{"source":"pOrR6EMVbe","sourceHandle":"1","target":"KpMH_xc3ZZ","targetHandle":"target","id":"xy-edge__pOrR6EMVbe1-KpMH_xc3ZZtarget"}],"data":{"tGs78_ietp":{"model":"qwen3","outputs":{"text":{"type":"string"}},"systemPrompt":"你是个聪明人"},"OeZdaU7LpY":{"model":"qwen3","outputs":{"text":{"type":"string"}},"systemPrompt":"你也是个聪明人"}}}')
// let initialData: any = {}
let initialNodes = props.graphData?.nodes ?? []
let initialEdges = props.graphData?.edges ?? []
let initialNodeData = props.graphData?.data ?? {}
setData(initialNodeData)
setNodes(initialNodes)
setEdges(initialEdges)
}, [props.graphData])
const {
onNodeDragStart,
onNodeDrag,
onNodeDragEnd,
} = useNodeDrag([props.graphData])
return (
<FlowableDiv className="h-full w-full">
<ReactFlow
className="rounded-xl"
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={(connection) => {
try {
if (commonInfo.debug) {
console.info('Connection', JSON.stringify(connection), JSON.stringify({nodes, edges, data}))
}
checkAddConnection(connection, nodes, edges)
onConnect(connection)
} catch (e) {
// @ts-ignore
message.error(e.toString())
}
}}
// @ts-ignore
nodeTypes={arrToMap(Object.keys(NodeRegistryMap), key => NodeRegistryMap[key]!.component)}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragEnd}
onEdgesDelete={() => console.info('delete')}
fitView
>
<Panel position="top-right">
<Space className="toolbar">
<AddNodeButton/>
<Popconfirm
title="返回上一页"
description="未保存的流程图将会被丢弃,确认是否返回"
onConfirm={() => navigate(-1)}
>
<Button type="default">
<RollbackOutlined/>
</Button>
</Popconfirm>
<Button type="primary" onClick={() => {
try {
if (commonInfo.debug) {
console.info('Save', JSON.stringify({nodes, edges, data}))
}
checkSave(nodes, edges, data)
props.onGraphDataChange({nodes, edges, data})
} catch (e) {
// @ts-ignore
message.error(e.toString())
}
}}>
<SaveFilled/>
</Button>
</Space>
</Panel>
<Controls/>
<MiniMap/>
<Background
variant={BackgroundVariant.Cross}
gap={20}
size={3}
color={flowDotColor}
/>
</ReactFlow>
</FlowableDiv>
)
}
export default FlowEditor

View File

@@ -0,0 +1,206 @@
import {type Edge, getIncomers, type Node} from '@xyflow/react'
import type {Option} from 'amis/lib/Schema'
import {contain, find, has, isEqual, max, min, unique} from 'licia'
import {type DependencyList, type MouseEvent as ReactMouseEvent, useCallback, useRef} from 'react'
import Queue from 'yocto-queue'
import {useFlowStore} from './store/FlowStore.ts'
import {type OutputVariable, type OutputVariableType} from './types.ts'
export const getAllIncomerNodeById: (id: string, nodes: Node[], edges: Edge[]) => string[] = (id, nodes, edges) => {
let queue = new Queue<Node>()
queue.enqueue(find(nodes, node => isEqual(node.id, id))!)
let result: string[] = []
while (queue.size !== 0) {
let currentNode = queue.dequeue()!
for (const incomer of getIncomers(currentNode, nodes, edges)) {
result.push(incomer.id)
queue.enqueue(incomer)
}
}
return unique(result, (a, b) => isEqual(a, b))
}
export const getAllIncomerNodeOutputVariables: (id: string, nodes: Node[], edges: Edge[], data: any) => OutputVariable[] = (id, nodes, edges, data) => {
let currentNode = find(nodes, n => isEqual(id, n.id))
if (!currentNode) {
return []
}
let incomerIds = getAllIncomerNodeById(id, nodes, edges)
if (currentNode.parentId) {
incomerIds = [
...incomerIds,
...getAllIncomerNodeById(currentNode.parentId, nodes, edges),
]
}
let incomerVariables: OutputVariable[] = []
for (const incomerId of incomerIds) {
let nodeData = data[incomerId] ?? {}
let group = incomerId
if (has(nodeData, 'node') && has(nodeData.node, 'name')) {
group = `${nodeData.node.name} ${incomerId}`
}
if (has(nodeData, 'outputs')) {
let outputs = nodeData?.outputs ?? {}
for (const key of Object.keys(outputs)) {
incomerVariables.push({
group: group,
name: key,
type: outputs[key].type,
variable: `${incomerId}.${key}`,
})
}
}
}
return [
...(currentNode.parentId ? [
{
group: '循环入参',
name: 'loopIndex (当前迭代索引)',
type: 'number',
variable: 'loopIndex',
} as OutputVariable,
{
group: '循环入参',
name: 'loopItem (当前迭代对象)',
type: 'object',
variable: 'loopItem',
} as OutputVariable,
] : []),
...incomerVariables,
]
}
export const generateAllIncomerOutputVariablesFormOptions: (id: string, nodes: Node[], edges: Edge[], data: any, targetTypes?: OutputVariableType[]) => Option[] = (id, nodes, edges, data, targetTypes) => {
let optionMap: Record<string, Option[]> = {}
for (const item of getAllIncomerNodeOutputVariables(id, nodes, edges, data)) {
if (targetTypes && !contain(targetTypes, item.type)) {
continue
}
if (!optionMap[item.group]) {
optionMap[item.group] = []
}
optionMap[item.group].push({
label: item.name,
value: item.variable,
})
}
return Object.keys(optionMap)
.map(key => ({
label: key,
children: optionMap[key],
}))
}
type ConditionOperator = string | { label: string, value: string }
const textOperators: ConditionOperator[] = ['equal', 'not_equal', 'is_empty', 'is_not_empty', 'like', 'not_like', 'starts_with', 'ends_with']
const textDefaultOperator: string = 'equal'
const booleanOperators: ConditionOperator[] = ['equal', 'not_equal']
const booleanDefaultOperator: string = 'equal'
const numberOperators: ConditionOperator[] = [
'equal',
'not_equal',
{label: '大于', value: 'greater'},
{label: '大于或等于', value: 'greater_equal'},
{label: '小于', value: 'less'},
{label: '小于或等于', value: 'less_equal'},
]
const numberDefaultOperator: string = 'equal'
const arrayOperators: ConditionOperator[] = ['is_empty', 'is_not_empty']
const arrayDefaultOperator: string = 'is_empty'
export const generateAllIncomerOutputVariablesConditions: (id: string, nodes: Node[], edges: Edge[], data: any) => Option[] = (id, nodes, edges, data) => {
let optionMap: Record<string, Option[]> = {}
for (const item of getAllIncomerNodeOutputVariables(id, nodes, edges, data)) {
if (!optionMap[item.group]) {
optionMap[item.group] = []
}
optionMap[item.group].push({
label: item.name,
type: 'custom',
name: item.variable,
...(item.type === 'text' ? {
value: {
type: 'input-text',
required: true,
clearable: true,
},
defaultOp: textDefaultOperator,
operators: textOperators,
} : {}),
...(item.type === 'boolean' ? {
value: {
type: 'select',
required: true,
selectFirst: true,
options: [
{label: '真', value: true},
{label: '假', value: false},
],
},
defaultOp: booleanDefaultOperator,
operators: booleanOperators,
} : {}),
...(item.type === 'number' ? {
value: {
type: 'input-number',
required: true,
clearable: true,
},
defaultOp: numberDefaultOperator,
operators: numberOperators,
} : {}),
...((item.type === 'array-text' || item.type === 'array-object') ? {
defaultOp: arrayDefaultOperator,
operators: arrayOperators,
} : {}),
})
}
return Object.keys(optionMap)
.map(key => ({
label: key,
children: optionMap[key],
}))
}
// 处理循环节点的边界问题
export const useNodeDrag = (deps: DependencyList) => {
const currentPosition = useRef({x: 0, y: 0} as { x: number, y: number })
const {setNode, getNodeById} = useFlowStore()
const onNodeDragStart = useCallback(() => {
}, deps)
const onNodeDrag = useCallback((event: ReactMouseEvent, node: Node) => {
event.stopPropagation()
if (node.parentId) {
let parentNode = getNodeById(node.parentId)
if (parentNode) {
let newPosition = {
x: max(min(node.position.x, (parentNode.measured?.width ?? 0) - (node.measured?.width ?? 0) - 28), 28),
y: max(min(node.position.y, (parentNode.measured?.height ?? 0) - (node.measured?.height ?? 0) - 28), 130),
}
setNode({
...node,
position: newPosition,
})
currentPosition.current = newPosition
}
}
}, deps)
const onNodeDragEnd = useCallback((_event: ReactMouseEvent, node: Node) => {
if (node.parentId) {
setNode({
...node,
position: currentPosition.current,
})
}
}, deps)
return {
onNodeDragStart,
onNodeDrag,
onNodeDragEnd,
}
}

View File

@@ -0,0 +1,159 @@
import {has, isEmpty, isEqual} from 'licia'
import {getAllIncomerNodeOutputVariables} from './Helper.tsx'
import CodeNode from './node/CodeNode.tsx'
import KnowledgeNode from './node/KnowledgeNode.tsx'
import LlmNode from './node/LlmNode.tsx'
import LoopNode from './node/LoopNode.tsx'
import OutputNode from './node/OutputNode.tsx'
import SwitchNode from './node/SwitchNode.tsx'
import TemplateNode from './node/TemplateNode.tsx'
import type {AddNodeChecker, NodeDefine, SaveNodeChecker} from './types.ts'
import InputNode from './node/InputNode.tsx'
const inputSingleVariableChecker: (field: string) => SaveNodeChecker = field => {
return (id, _parentId, nodes, edges, data) => {
let nodeData = data[id] ?? {}
if (has(nodeData, field)) {
let expression = nodeData?.[field] ?? ''
if (!isEmpty(expression)) {
let outputVariables = new Set(getAllIncomerNodeOutputVariables(id, nodes, edges, data).map(i => i.variable))
if (!outputVariables.has(expression)) {
return {
error: true,
message: `节点 ${id} 存在错误:变量 ${expression} 不存在`,
}
}
}
}
return {error: false}
}
}
const inputMultiVariableChecker: SaveNodeChecker = (id, _parentId, nodes, edges, data) => {
let nodeData = data[id] ?? {}
if (has(nodeData, 'inputs')) {
let inputs = nodeData?.inputs ?? {}
if (!isEmpty(inputs)) {
let outputVariables = new Set(getAllIncomerNodeOutputVariables(id, nodes, edges, data).map(i => i.variable))
for (const key of Object.keys(inputs)) {
let variable = inputs[key]?.variable ?? ''
if (!outputVariables.has(variable)) {
return {
error: true,
message: `节点 ${id} 存在错误:变量 ${variable} 不存在`,
}
}
}
}
}
return {error: false}
}
const noMoreThanOneNodeType: AddNodeChecker = (type, parentId, nodes) => {
return {
error: nodes.filter(n => isEqual(n.parentId, parentId) && isEqual(n.type, type)).length > 0,
message: `同一个流程(子流程)中类型为 ${type} 的节点至多有一个`
}
}
export const NodeRegistry: NodeDefine[] = [
{
key: 'llm-node',
group: '普通节点',
name: '大模型',
icon: <i className="fa fa-message"/>,
description: '使用大模型对话',
component: LlmNode,
checkers: {
add: [],
save: [inputMultiVariableChecker]
},
},
{
key: 'knowledge-node',
group: '普通节点',
name: '知识库',
icon: <i className="fa fa-book-bookmark"/>,
description: '',
component: KnowledgeNode,
checkers: {
add: [],
save: [inputMultiVariableChecker]
},
},
{
key: 'code-node',
group: '普通节点',
name: '代码执行',
icon: <i className="fa fa-code"/>,
description: '执行自定义的处理代码',
component: CodeNode,
checkers: {
add: [],
save: [inputMultiVariableChecker]
},
},
{
key: 'template-node',
group: '普通节点',
name: '模板替换',
icon: <i className="fa fa-pen-nib"/>,
description: '使用模板聚合转换变量表示',
component: TemplateNode,
checkers: {
add: [],
save: [inputMultiVariableChecker]
},
},
{
key: 'switch-node',
group: '逻辑节点',
name: '分支',
icon: <i className="fa fa-code-fork"/>,
description: '根据不同的情况前往不同的分支',
component: SwitchNode,
checkers: {
add: [],
save: [],
},
},
{
key: 'loop-node',
group: '逻辑节点',
name: '循环',
icon: <i className="fa fa-repeat"/>,
description: '实现循环执行流程',
component: LoopNode,
checkers: {
add: [],
save: [],
},
},
// 特殊节点特殊判断
{
key: 'input-node',
group: '数据节点',
name: '输入',
icon: <i className="fa fa-file"/>,
description: '定义流程输入变量',
component: InputNode,
checkers: {
add: [noMoreThanOneNodeType],
save: [],
},
},
{
key: 'output-node',
group: '数据节点',
name: '输出',
icon: <i className="fa fa-file"/>,
description: '定义流程输出变量',
component: OutputNode,
checkers: {
add: [noMoreThanOneNodeType],
save: [inputSingleVariableChecker('output')]
},
},
]
export const NodeRegistryMap: Record<string, NodeDefine> = NodeRegistry.reduce((a, v) => ({...a, [v.key]: v}), {})

View File

@@ -0,0 +1,78 @@
import {PlusCircleFilled} from '@ant-design/icons'
import {Button, Dropdown, message} from 'antd'
import type {ButtonProps} from 'antd/lib'
import {isEqual, randomId, unique} from 'licia'
import {commonInfo} from '../../../util/amis.tsx'
import {checkAddNode} from '../FlowChecker.tsx'
import {NodeRegistry, NodeRegistryMap} from '../NodeRegistry.tsx'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
export type AddNodeButtonProps = ButtonProps & {
parent?: string
onlyIcon?: boolean
}
const AddNodeButton = (props: AddNodeButtonProps) => {
const {data, setDataById} = useDataStore()
const {nodes, addNode, edges} = useFlowStore()
return (
<Dropdown
menu={{
items: unique(NodeRegistry.map(i => i.group))
.map(group => ({
type: 'group',
label: group,
children: NodeRegistry
.filter(i => isEqual(group, i.group))
// 循环节点里不能再嵌套循环节点
.filter(i => !props.parent || (props.parent && !isEqual(i.key, 'loop-node')))
.map(i => ({key: i.key, label: i.name, icon: i.icon})),
})),
onClick: ({key}) => {
try {
if (commonInfo.debug) {
console.info('Add', key, JSON.stringify({nodes, edges, data}))
}
checkAddNode(key, props.parent, nodes, edges)
let nodeId = randomId(10, 'qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM')
let define = NodeRegistryMap[key]
setDataById(
nodeId,
{
node: {
name: define.name,
description: define.description,
},
},
)
addNode({
id: nodeId,
type: key,
position: {x: 50, y: 130},
data: {},
// 如果是循环节点就将节点加入到循环节点中
...(props.parent ? {
parentId: props.parent,
extent: 'parent',
} : {}),
})
} catch (e) {
// @ts-ignore
message.error(e.toString())
}
},
}}
>
<Button {...props}>
<PlusCircleFilled/>
{props.onlyIcon ? undefined : '新增节点'}
</Button>
</Dropdown>
)
}
export default AddNodeButton

View File

@@ -0,0 +1,298 @@
import {CopyFilled, DeleteFilled, EditFilled} from '@ant-design/icons'
import {type Edge, Handle, type Node, type NodeProps, NodeResizeControl, NodeToolbar, Position} from '@xyflow/react'
import {type ClassName, classnames, type Schema} from 'amis'
import {Button, Drawer, Space, Tooltip} from 'antd'
import {type CSSProperties, type JSX, useCallback, useState} from 'react'
import styled from 'styled-components'
import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx'
import {generateAllIncomerOutputVariablesFormOptions} from '../Helper.tsx'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import {type FormSchema, OutputVariableTypeMap} from '../types.ts'
export function inputsFormColumns(
nodeId: string,
nodes: Node[],
edges: Edge[],
data: any,
): Schema[] {
return [
{
type: 'input-kvs',
name: 'inputs',
label: '输入变量',
addButtonText: '新增输入',
draggable: false,
keyItem: {
...horizontalFormOptions(),
label: '参数名称',
},
valueItems: [
{
...horizontalFormOptions(),
type: 'select',
name: 'variable',
label: '变量',
required: true,
selectMode: 'group',
options: generateAllIncomerOutputVariablesFormOptions(
nodeId,
nodes,
edges,
data,
),
},
],
},
]
}
export function outputsFormColumns(editable: boolean = false, required: boolean = false): Schema[] {
return [
{
disabled: !editable,
type: 'input-kvs',
name: 'outputs',
label: '输出变量',
addButtonText: '新增输出',
draggable: false,
keyItem: {
...horizontalFormOptions(),
label: '参数名称',
},
required: required,
valueItems: [
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数',
required: true,
selectFirst: true,
options: Object.keys(OutputVariableTypeMap).map(key => ({
// @ts-ignore
label: OutputVariableTypeMap[key],
value: key,
})),
},
],
},
]
}
type AmisNodeProps = {
className: ClassName,
style?: CSSProperties,
nodeProps: NodeProps
extraNodeDescription?: JSX.Element
handler: JSX.Element
formSchema?: () => FormSchema,
resize?: { minWidth: number, minHeight: number }
}
const AmisNodeContainerDiv = styled.div`
`
export const StartNodeHandler = () => {
return <Handle type="source" position={Position.Right} id="source"/>
}
export const EndNodeHandler = () => {
return <Handle type="target" position={Position.Left} id="target"/>
}
export const NormalNodeHandler = () => {
return (
<>
<StartNodeHandler/>
<EndNodeHandler/>
</>
)
}
export const nodeClassName = (name: string) => {
return `flow-node flow-node-${name}`
}
const AmisNode: (props: AmisNodeProps) => JSX.Element = ({
className,
style,
nodeProps,
extraNodeDescription,
handler,
formSchema,
resize,
}) => {
const {removeNode} = useFlowStore()
const {getDataById, setDataById, removeDataById} = useDataStore()
const {id} = nodeProps
// @ts-ignore
const nodeData = getDataById(id)
const nodeName = nodeData?.node?.name ?? ''
const nodeDescription = nodeData?.node?.description ?? ''
const [editDrawerOpen, setEditDrawerOpen] = useState(false)
const [editDrawerForm, setEditDrawerForm] = useState<JSX.Element>(<></>)
const onOpenEditDrawerClick = useCallback(() => {
const schema = formSchema?.()
setEditDrawerForm(
amisRender(
{
type: 'wrapper',
size: 'none',
body: [
{
debug: commonInfo.debug,
type: 'form',
...horizontalFormOptions(),
wrapWithPanel: false,
onEvent: {
submitSucc: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
setDataById(
id,
{
...context.props.data,
finished: true,
},
)
setEditDrawerOpen(false)
},
},
],
},
...(schema?.events ?? {})
},
body: [
{
type: 'input-text',
name: 'node.name',
label: '节点名称',
placeholder: nodeName,
},
{
type: 'textarea',
name: 'node.description',
label: '节点描述',
placeholder: nodeDescription,
},
{
type: 'divider',
},
...(schema?.columns ?? []),
{
type: 'wrapper',
size: 'none',
className: 'space-x-2 text-right',
body: [
{
type: 'action',
label: '取消',
onEvent: {
click: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
setEditDrawerOpen(false)
},
},
],
},
},
},
{
type: 'submit',
label: '保存',
level: 'primary',
},
],
},
],
},
],
},
getDataById(id),
),
)
setEditDrawerOpen(true)
}, [id])
const onRemoveClick = useCallback(() => {
removeNode(id)
removeDataById(id)
}, [])
return (
<AmisNodeContainerDiv className={classnames(className, 'w-64')} style={style}>
<Drawer
title="节点编辑"
open={editDrawerOpen}
closeIcon={false}
maskClosable={false}
destroyOnHidden
size="large"
>
{editDrawerForm}
</Drawer>
<NodeToolbar>
<Space>
<Tooltip title="复制节点">
<Button
className="text-secondary"
disabled
type="text"
size="small"
icon={<CopyFilled/>}
/>
</Tooltip>
<Tooltip title="编辑节点">
<Button
className="text-secondary"
type="text"
size="small"
icon={<EditFilled/>}
onClick={() => onOpenEditDrawerClick()}
/>
</Tooltip>
<Tooltip title="删除节点">
<Button
className="text-secondary"
type="text"
size="small"
icon={<DeleteFilled/>}
onClick={() => onRemoveClick()}
/>
</Tooltip>
</Space>
</NodeToolbar>
<div className="node-card h-full flex flex-col bg-white rounded-md border border-gray-100 border-solid">
<div
className="node-card-header items-center flex justify-between p-2 border-t-0 border-l-0 border-r-0 border-b border-gray-100 border-solid">
<span className="font-bold">{nodeName}</span>
<span className="text-gray-300 text-sm">{id}</span>
</div>
<div className="node-card-description flex flex-col flex-1 p-2 text-secondary text-sm">
<div className="node-card-description-node">
{nodeDescription}
</div>
<div className="node-card-description-extra flex-1 mt-1">
{extraNodeDescription}
</div>
</div>
</div>
{resize ? <>
<NodeResizeControl
minWidth={resize.minWidth}
minHeight={resize.minHeight}
/>
</> : undefined}
{handler}
</AmisNodeContainerDiv>
)
}
export default AmisNode

View File

@@ -0,0 +1,72 @@
import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd'
import React, {useCallback, useMemo} from 'react'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx'
import type {FormSchema} from '../types.ts'
const languageMap: Record<string, string> = {
'javascript': 'Javascript',
'python': 'Python',
'Lua': 'lua',
}
const CodeNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData, getDataById} = useDataStore()
const nodeData = getDataById(props.id)
const formSchema: () => FormSchema = useCallback(() => ({
columns: [
...inputsFormColumns(props.id, getNodes(), getEdges(), getData()),
{
type: 'divider',
},
{
type: 'select',
name: 'type',
label: '代码类型',
required: true,
selectFirst: true,
options: Object.keys(languageMap).map(key => ({label: languageMap[key], value: key})),
},
{
type: 'editor',
required: true,
label: '代码内容',
name: 'content',
language: '${type}',
options: {
wordWrap: 'bounded',
},
},
{
type: 'divider',
},
...outputsFormColumns(true, false),
]
}), [props.id])
const extraNodeDescription = useMemo(() => {
return nodeData?.type
? <div className="mt-2 flex justify-between">
<span></span>
<Tag className="m-0" color="blue">{languageMap[nodeData.type]}</Tag>
</div>
: <></>
}, [nodeData])
return (
<AmisNode
className={nodeClassName('code')}
nodeProps={props}
extraNodeDescription={extraNodeDescription}
formSchema={formSchema}
handler={<NormalNodeHandler/>}
/>
)
}
export default React.memo(CodeNode)

View File

@@ -0,0 +1,123 @@
import type {NodeProps} from '@xyflow/react'
import React, {useCallback} from 'react'
import AmisNode, {nodeClassName, outputsFormColumns, StartNodeHandler} from './AmisNode.tsx'
import {horizontalFormOptions} from '../../../util/amis.tsx'
import {typeMap} from '../../../pages/ai/task/InputSchema.tsx'
import type {FormSchema, OutputVariableType} from '../types.ts'
import {isEmpty} from 'licia'
const originTypeMap: Record<string, OutputVariableType> = {
text: 'text',
textarea: 'text',
number: 'number',
files: 'array-text',
}
const InputNode = (props: NodeProps) => {
const formSchema: () => FormSchema = useCallback(() => ({
events: {
change: {
actions: [
{
actionType: 'validate',
},
{
actionType: 'custom',
// @ts-ignore
script: (context, doAction, event) => {
let data = event?.data
console.log(data)
if (data && isEmpty(data?.validateResult?.error ?? undefined)) {
let inputs = data.validateResult?.payload?.inputs ?? {}
if (inputs) {
let outputs: Record<string, { type: OutputVariableType }> = {}
for (let key of Object.keys(inputs)) {
outputs[key] = {
type: originTypeMap[inputs[key].type],
}
}
doAction({
actionType: 'setValue',
args: {
value: {
outputs
},
},
})
}
}
},
},
]
}
},
columns: [
{
type: 'input-kvs',
name: 'inputs',
label: '输入变量',
required: true,
addButtonText: '新增入参',
draggable: false,
keyItem: {
label: '参数名称',
...horizontalFormOptions(),
validations: {
isAlphanumeric: true,
},
},
valueItems: [
{
...horizontalFormOptions(),
type: 'input-text',
name: 'label',
required: true,
label: '中文名称',
clearValueOnEmpty: true,
clearable: true,
},
{
...horizontalFormOptions(),
type: 'input-text',
name: 'description',
label: '参数描述',
clearValueOnEmpty: true,
clearable: true,
},
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数类型',
required: true,
selectFirst: true,
options: Object.keys(typeMap).map(key => ({label: typeMap[key], value: key})),
},
{
...horizontalFormOptions(),
type: 'switch',
name: 'required',
label: '是否必填',
required: true,
value: true,
},
],
},
{
type: 'divider',
},
...outputsFormColumns(false, false),
]
}), [props.id])
return (
<AmisNode
className={nodeClassName('input')}
nodeProps={props}
formSchema={formSchema}
handler={<StartNodeHandler/>}
/>
)
}
export default React.memo(InputNode)

View File

@@ -0,0 +1,91 @@
import type {NodeProps} from '@xyflow/react'
import React, {useCallback, useEffect} from 'react'
import {commonInfo} from '../../../util/amis.tsx'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx'
import type {FormSchema} from '../types.ts'
const KnowledgeNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData, mergeDataById} = useDataStore()
useEffect(() => {
mergeDataById(
props.id,
{
outputs: {
result: {
type: 'array-string',
},
},
},
)
}, [props.id])
const formSchema: () => FormSchema = useCallback(() => ({
columns: [
...inputsFormColumns(props.id, getNodes(), getEdges(), getData()),
{
type: 'divider',
},
{
type: 'select',
name: 'knowledgeId',
label: '知识库',
required: true,
options: [],
source: {
method: 'get',
url: `${commonInfo.baseAiUrl}/knowledge/list`,
// @ts-ignore
adaptor: (payload, response, api, context) => {
return {
...payload,
data: {
items: payload.data.items.map((item: any) => ({value: item['id'], label: item['name']})),
},
}
},
},
},
{
type: 'input-text',
name: 'query',
label: '查询文本',
required: true,
},
{
type: 'input-range',
name: 'count',
label: '返回数量',
required: true,
value: 3,
max: 10,
},
{
type: 'input-range',
name: 'score',
label: '匹配阀值',
required: true,
value: 0.6,
max: 1,
step: 0.05,
},
{
type: 'divider',
},
...outputsFormColumns(false, true),
]
}), [props.id])
return (
<AmisNode
className={nodeClassName('knowledge')}
nodeProps={props}
formSchema={formSchema}
handler={<NormalNodeHandler/>}
/>
)
}
export default React.memo(KnowledgeNode)

View File

@@ -0,0 +1,80 @@
import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd'
import React, {useCallback, useEffect, useMemo} from 'react'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx'
import type {FormSchema} from '../types.ts'
const modelMap: Record<string, string> = {
qwen3: 'Qwen3',
deepseek: 'Deepseek',
}
const LlmNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData, mergeDataById, getDataById} = useDataStore()
const nodeData = getDataById(props.id)
useEffect(() => {
mergeDataById(
props.id,
{
outputs: {
text: {
type: 'text',
},
},
},
)
}, [props.id])
const formSchema: () => FormSchema = useCallback(() => ({
columns: [
...inputsFormColumns(props.id, getNodes(), getEdges(), getData()),
{
type: 'divider',
},
{
type: 'select',
name: 'model',
label: '大模型',
required: true,
selectFirst: true,
options: Object.keys(modelMap).map(key => ({label: modelMap[key], value: key})),
},
{
type: 'textarea',
name: 'systemPrompt',
label: '系统提示词',
required: true,
},
{
type: 'divider',
},
...outputsFormColumns(false, true),
]
}), [props.id])
const extraNodeDescription = useMemo(() => {
return nodeData?.model
? <div className="mt-2 flex justify-between">
<span></span>
<Tag className="m-0" color="blue">{modelMap[nodeData.model]}</Tag>
</div>
: <></>
}, [nodeData])
return (
<AmisNode
className={nodeClassName('llm')}
nodeProps={props}
extraNodeDescription={extraNodeDescription}
formSchema={formSchema}
handler={<NormalNodeHandler/>}
/>
)
}
export default React.memo(LlmNode)

View File

@@ -0,0 +1,153 @@
import {Background, BackgroundVariant, type NodeProps} from '@xyflow/react'
import {classnames} from 'amis'
import React, {useCallback, useEffect, useMemo} from 'react'
import AddNodeButton from '../component/AddNodeButton.tsx'
import {generateAllIncomerOutputVariablesFormOptions} from '../Helper.tsx'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import {flowBackgroundColor, flowDotColor, type FormSchema} from '../types.ts'
import AmisNode, {nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx'
const LoopNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData, mergeDataById} = useDataStore()
useEffect(() => {
mergeDataById(
props.id,
{
failFast: true,
parallel: false,
type: 'for',
count: 1,
outputs: {
output: {
type: 'array-object',
},
},
},
)
}, [props.id])
const formSchema: () => FormSchema = useCallback(() => ({
columns: [
{
type: 'switch',
name: 'failFast',
label: '快速失败',
required: true,
description: '执行过程中一旦出现错误,及时中断循环任务的执行',
},
{
disabled: true,
type: 'switch',
name: 'parallel',
label: '并行执行',
required: true,
},
{
type: 'select',
name: 'type',
label: '循环模式',
required: true,
options: [
{
label: '次数循环',
value: 'for',
},
{
label: '次数循环 (引用变量)',
value: 'for-variable',
},
{
label: '对象循环',
value: 'for-object',
},
],
},
{
visibleOn: '${type === \'for\'}',
type: 'input-number',
name: 'count',
label: '循环次数',
required: true,
min: 1,
precision: 0,
},
{
visibleOn: '${type === \'for-variable\'}',
type: 'select',
name: 'countVariable',
label: '循环次数',
required: true,
selectMode: 'group',
options: generateAllIncomerOutputVariablesFormOptions(
props.id,
getNodes(),
getEdges(),
getData(),
['number'],
),
},
{
visibleOn: '${type === \'for-object\'}',
type: 'select',
name: 'countObject',
label: '循环对象',
required: true,
selectMode: 'group',
options: generateAllIncomerOutputVariablesFormOptions(
props.id,
getNodes(),
getEdges(),
getData(),
['array-text', 'array-object'],
),
},
{
type: 'divider',
},
...outputsFormColumns(false, true),
]
}), [props.id])
const extraNodeDescription = useMemo(() => {
return (
<div className="nodrag relative w-full h-full" style={{minHeight: '211px'}}>
<Background
id={`loop-background-${props.id}`}
className="rounded-xl"
variant={BackgroundVariant.Cross}
gap={20}
size={3}
style={{
zIndex: 0,
}}
color={flowDotColor}
bgColor={flowBackgroundColor}
/>
<AddNodeButton className="mt-2 ml-2" parent={props.id} onlyIcon/>
</div>
)
}, [props.id])
return (
<AmisNode
className={classnames('w-full', 'h-full', nodeClassName('loop'))}
style={{
minWidth: '350px',
minHeight: '290px',
}}
nodeProps={props}
extraNodeDescription={extraNodeDescription}
formSchema={formSchema}
handler={<NormalNodeHandler/>}
resize={{
minWidth: 350,
minHeight: 290,
}}
/>
)
}
export default React.memo(LoopNode)

View File

@@ -0,0 +1,26 @@
import type {NodeProps} from '@xyflow/react'
import React, {useCallback} from 'react'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import type {FormSchema} from '../types.ts'
import AmisNode, {EndNodeHandler, inputsFormColumns, nodeClassName} from './AmisNode.tsx'
const OutputNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData} = useDataStore()
const formSchema: () => FormSchema = useCallback(() => ({
columns: inputsFormColumns(props.id, getNodes(), getEdges(), getData()),
}), [props.id])
return (
<AmisNode
className={nodeClassName('output')}
nodeProps={props}
formSchema={formSchema}
handler={<EndNodeHandler/>}
/>
)
}
export default React.memo(OutputNode)

View File

@@ -0,0 +1,97 @@
import {Handle, type NodeProps, Position} from '@xyflow/react'
import type {ConditionValue} from 'amis'
import {Tag} from 'antd'
import {contain, isEqual} from 'licia'
import React, {useCallback, useMemo} from 'react'
import {generateAllIncomerOutputVariablesConditions} from '../Helper.tsx'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {nodeClassName} from './AmisNode.tsx'
import type {FormSchema} from '../types.ts'
const SwitchNode = (props: NodeProps) => {
const {getNodes, getEdges, removeEdges} = useFlowStore()
const {getData, getDataById} = useDataStore()
const nodeData = getDataById(props.id)
// @ts-ignore
const conditions: ConditionValue[] = nodeData?.conditions?.map(c => c.condition) ?? []
const formSchema: () => FormSchema = useCallback(() => ({
columns: [
{
type: 'combo',
name: 'conditions',
label: '分支',
multiple: true,
required: true,
items: [
{
type: 'condition-builder',
name: 'condition',
label: '条件',
required: true,
builderMode: 'simple',
showANDOR: true,
fields: generateAllIncomerOutputVariablesConditions(
props.id,
getNodes(),
getEdges(),
getData(),
),
},
],
},
]
}), [props.id])
const extraNodeDescription = useMemo(() => {
return (
<div className="mt-2">
{conditions.map((item, index) => (
<div key={item.id} className="mt-1">
<Tag className="m-0" color="blue"> {index + 1}</Tag>
</div>
))}
</div>
)
}, [nodeData])
const handler = useMemo(() => {
// @ts-ignore
const conditions: ConditionValue[] = nodeData?.conditions?.map(c => c.condition) ?? []
// 移除不该存在的边
const conditionIds = conditions.map(c => c.id)
const removeEdgeIds = getEdges()
.filter(edge => isEqual(edge.source, props.id) && !contain(conditionIds, edge.sourceHandle))
.map(edge => edge.id)
removeEdges(removeEdgeIds)
return (
<>
<Handle type="target" position={Position.Left}/>
{conditions.map((item, index) => (
<Handle
type="source"
position={Position.Right}
key={item.id}
id={item.id}
style={{top: 91 + (26 * index)}}
/>
))}
</>
)
}, [nodeData])
return (
<AmisNode
className={nodeClassName('switch')}
nodeProps={props}
extraNodeDescription={extraNodeDescription}
formSchema={formSchema}
handler={handler}
/>
)
}
export default React.memo(SwitchNode)

View File

@@ -0,0 +1,94 @@
import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd'
import React, {useCallback, useEffect, useMemo} from 'react'
import {useDataStore} from '../store/DataStore.ts'
import {useFlowStore} from '../store/FlowStore.ts'
import AmisNode, {inputsFormColumns, nodeClassName, NormalNodeHandler, outputsFormColumns} from './AmisNode.tsx'
import type {FormSchema} from '../types.ts'
const typeMap: Record<string, string> = {
default: '默认',
json: 'JSON',
'template-markdown': 'Markdown',
'template-rich-text': '富文本',
}
const TemplateNode = (props: NodeProps) => {
const {getNodes, getEdges} = useFlowStore()
const {getData, getDataById, mergeDataById} = useDataStore()
const nodeData = getDataById(props.id)
useEffect(() => {
mergeDataById(
props.id,
{
outputs: {
text: {
type: 'text',
},
},
},
)
}, [props.id])
const formSchema: () => FormSchema = useCallback(() => ({
columns: [
...inputsFormColumns(props.id, getNodes(), getEdges(), getData()),
{
type: 'divider',
},
{
type: 'select',
name: 'type',
label: '模板类型',
required: true,
selectFirst: true,
options: Object.keys(typeMap).map(key => ({label: typeMap[key], value: key})),
},
{
visibleOn: 'type === \'template-markdown\'',
type: 'editor',
required: true,
label: '模板内容',
name: 'template',
language: 'markdown',
options: {
wordWrap: 'bounded',
},
},
{
visibleOn: 'type === \'template-rich-text\'',
type: 'input-rich-text',
required: true,
name: 'template',
label: '模板内容',
options: {
min_height: 500,
},
},
...outputsFormColumns(false, true),
]
}), [props.id])
const extraNodeDescription = useMemo(() => {
return nodeData?.type
? <div className="mt-2 flex justify-between">
<span></span>
<Tag className="m-0" color="blue">{typeMap[nodeData.type]}</Tag>
</div>
: <></>
}, [nodeData])
return (
<AmisNode
className={nodeClassName('template')}
nodeProps={props}
extraNodeDescription={extraNodeDescription}
formSchema={formSchema}
handler={<NormalNodeHandler/>}
/>
)
}
export default React.memo(TemplateNode)

View File

@@ -6,11 +6,13 @@ export const useDataStore = create<{
setData: (data: Record<string, any>) => void, setData: (data: Record<string, any>) => void,
getDataById: (id: string) => any, getDataById: (id: string) => any,
setDataById: (id: string, data: any) => void, setDataById: (id: string, data: any) => void,
mergeDataById: (id: string, data: any) => void,
removeDataById: (id: string) => void,
}>((set, get) => ({ }>((set, get) => ({
data: {}, data: {},
getData: () => get().data, getData: () => get().data,
setData: (data) => set({ setData: (data) => set({
data: data data: data,
}), }),
getDataById: id => get().data[id], getDataById: id => get().data[id],
setDataById: (id, data) => { setDataById: (id, data) => {
@@ -20,4 +22,21 @@ export const useDataStore = create<{
data: updateData, data: updateData,
}) })
}, },
mergeDataById: (id, data) => {
let updateData = get().data
updateData[id] = {
...(updateData[id] ?? {}),
...data,
}
set({
data: updateData,
})
},
removeDataById: (id) => {
let data = get().data
delete data[id]
set({
data,
})
},
})) }))

View File

@@ -8,24 +8,30 @@ import {
type OnEdgesChange, type OnEdgesChange,
type OnNodesChange, type OnNodesChange,
} from '@xyflow/react' } from '@xyflow/react'
import {filter, find, isEqual} from 'licia' import {contain, filter, find, isEqual} from 'licia'
import {create} from 'zustand/react' import {create} from 'zustand/react'
export const useFlowStore = create<{ export const useFlowStore = create<{
nodes: Node[], nodes: Node[],
getNodes: () => Node[],
onNodesChange: OnNodesChange, onNodesChange: OnNodesChange,
getNodeById: (id: string) => Node | undefined, getNodeById: (id: string) => Node | undefined,
addNode: (node: Node) => void, addNode: (node: Node) => void,
removeNode: (id: string) => void, removeNode: (id: string) => void,
setNodes: (nodes: Node[]) => void, setNodes: (nodes: Node[]) => void,
setNode: (node: Node) => void,
edges: Edge[], edges: Edge[],
getEdges: () => Edge[],
onEdgesChange: OnEdgesChange, onEdgesChange: OnEdgesChange,
removeEdge: (id: string) => void,
removeEdges: (ids: string[]) => void,
setEdges: (edges: Edge[]) => void, setEdges: (edges: Edge[]) => void,
onConnect: OnConnect, onConnect: OnConnect,
}>((set, get) => ({ }>((set, get) => ({
nodes: [], nodes: [],
getNodes: () => get().nodes,
onNodesChange: changes => { onNodesChange: changes => {
set({ set({
nodes: applyNodeChanges(changes, get().nodes), nodes: applyNodeChanges(changes, get().nodes),
@@ -39,13 +45,34 @@ export const useFlowStore = create<{
}) })
}, },
setNodes: nodes => set({nodes}), setNodes: nodes => set({nodes}),
setNode: node => {
set({
nodes: get().nodes.map(n => {
if (isEqual(node.id, n.id)) {
return node
}
return n
}),
})
},
edges: [], edges: [],
getEdges: () => get().edges,
onEdgesChange: changes => { onEdgesChange: changes => {
set({ set({
edges: applyEdgeChanges(changes, get().edges), edges: applyEdgeChanges(changes, get().edges),
}) })
}, },
removeEdge: id => {
set({
edges: filter(get().edges, edge => !isEqual(edge.id, id)),
})
},
removeEdges: ids => {
set({
edges: filter(get().edges, edge => !contain(ids, edge.id)),
})
},
setEdges: edges => set({edges}), setEdges: edges => set({edges}),
onConnect: connection => { onConnect: connection => {

View File

@@ -0,0 +1,67 @@
import type {Edge, Node} from '@xyflow/react'
import type {JSX} from 'react'
import type {ListenerAction, Schema} from 'amis'
export const flowBackgroundColor = '#fafafa'
export const flowDotColor = '#dedede'
export type InputFormOptions = {
label: string
value: string
}
export type InputFormOptionsGroup = {
group: string,
variables: InputFormOptions[],
}
export type NodeError = {
error: boolean,
message?: string,
}
export type AddNodeChecker = (type: string, parentId: string | undefined, nodes: Node[], edges: Edge[], data: any) => NodeError
export type SaveNodeChecker = (id: string, parentId: string | undefined, nodes: Node[], edges: Edge[], data: any) => NodeError
export type GraphData = { nodes: Node[], edges: Edge[], data: any }
export type FlowEditorProps = {
graphData: GraphData,
onGraphDataChange: (graphData: GraphData) => void,
}
export type OutputVariableType = 'text' | 'boolean' | 'number' | 'object' | 'array-text' | 'array-object'
export const OutputVariableTypeMap: Record<OutputVariableType, string> = {
'text': '文本',
'boolean': '布尔值',
'number': '数字',
'object': '对象',
'array-text': '文本数组',
'array-object': '对象数组',
}
export type NodeDefine = {
key: string,
group: string,
name: string,
icon: JSX.Element,
description: string,
component: any,
checkers: {
add: AddNodeChecker[],
save: SaveNodeChecker[],
},
}
export type OutputVariable = {
group: string,
name: string | undefined,
type: OutputVariableType,
variable: string,
}
export type FormSchema = {
events?: Record<string, { actions: ListenerAction[] }>
columns: Schema[]
}

View File

@@ -1,7 +1,7 @@
import {createRoot} from 'react-dom/client' import {createRoot} from 'react-dom/client'
import {createHashRouter, RouterProvider} from 'react-router' import {createHashRouter, RouterProvider} from 'react-router'
import './index.scss' import './index.scss'
import './components/Registry.ts' import './components/amis/Registry.ts'
import {routes} from './route.tsx' import {routes} from './route.tsx'

View File

@@ -1,7 +1,7 @@
import {type AppItemProps, ProLayout} from '@ant-design/pro-components' import {type AppItemProps, ProLayout} from '@ant-design/pro-components'
import {ConfigProvider} from 'antd' import {ConfigProvider} from 'antd'
import {dateFormat} from 'licia' import {dateFormat} from 'licia'
import React, {useMemo} from 'react' import React, {useMemo, useState} from 'react'
import {Outlet, useLocation, useNavigate} from 'react-router' import {Outlet, useLocation, useNavigate} from 'react-router'
import styled from 'styled-components' import styled from 'styled-components'
import {menus} from '../route.tsx' import {menus} from '../route.tsx'
@@ -14,13 +14,14 @@ const ProLayoutDiv = styled.div`
margin: 0; margin: 0;
.ant-menu-sub > .ant-menu-item { .ant-menu-sub > .ant-menu-item {
padding-left: 16px !important; //padding-left: 28px !important;
} }
` `
const defaultAppIcon = const defaultAppIcon =
<img <img
src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAMAAAC7IEhfAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAMAUExURUdwTBR20EyHx1iq5EWJ1hNyy0mR1EhzwnbB/8P5/Fx/vVuj5zyO4lCY102P0L3u/3K88dz4/8v1/0fW/jLh/013ujN4wBliuEuEy0FrqT13wFmCvkaH0GSLyBVVqe38/0il4FKu7crr/eL3/7Ls/zd3t1WN1TZcnTloojx6whpYpTdfoDVwtVeS1WeOyiNhsTFyuy50wHCPxDFgqLjK5G2Ow2Gy5ylpvB9uxA5x03W/71Wh4E2U2tr2/i6C3G7J+Gex7KTI8eb9/cbz/yC+/srl++P7/Znf+MPv/cn3/rz6/dL8/2fe/8v+/s/6/knN87f5/jx5uRZtwjRppU6I1liM2Bhcrmyl2kZ5uA9vzjd0wj95yjVstqze9hiD2zOH1kqv7Wqv5K7S7LLn/YfT+sfp/bPf+4jb/YbX+735/SjQ/T7J+VjV9rTs+0ns/6Dk/mPJ7eD9/b/8/Q9YxA5Zxw5Zwg5dwg5byhdn0kSO2xFezg5gwg1XwRBjwkqW4EeT3kuY20mV2BBVtg5avxFoxQ9Yu0+g3xJYslWi5h2q9SKA4AtTwAxjzUGN2UiN0VOm4yKj8NX7/0WQ3RFtyUaR1h95zSOc706J0Bt34VCi5yqw9F6s6SOI5Bx02h2M6Rh13SCX7k2c3E6a4lCd5EeQ2iVtyCJ54BVt3ROV8x+0+jS9+hGq+Ra0/WK37sPy/iOR6R1w2A5p0y9nrjJ/1EaO1yV1yDt+zBaJ4hF83FuJyg5nyhei9hJ96BN35ByV5ymq8S151muu5UCr6hzE/xS+/47h/3/r/xyG5SKV6yJ43BlswBJivA5z2A9duw5s1C590CZXpDVvuhec9BGM7xJQrxKE61yo4DOF4DeZ64jG8bbp/h/Q/j2F0xpr1xliyROA4CRst1mc10d8whNQqDqb3Sig5qrh/qLW9iZ/5UeY5pDU9UWf6J7O9oju/mDV/aXp/orj/6P7/4z3/2Pr/iLa/kaDxnuw57rf9ZfW+2Sd5G3X/7LW9zyR51TI+Dao7Zno//A+0MwAAADpdFJOUwD7Lv/+/v4CAQIK/v7+/P7+b/7+/iKh/f5LjBv8z/4d/v5cNf6A5piF/Pts9PST/tP+Qd0Oc/72Pf5B/Pv0/vv7zqD6/4qU+5JjlCv7ff79/lz628SK/pmm+/5cnh/+/vPht/r+pPKX44z7+/z7/P7+wNL////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+///////////////////////+1awirQAAA+BJREFUOMtjYCAXcMIAAVW4OKjKgJjDW8nXyytT0NOKCyjAgUudt29RwMdPb48dO/b2aEa6MkQvpjrlQqCqz3ki/j4+2VnCv44+S5XHVAm0xHPixHXuOSIiX4TfHeo87OJin/IjTYmBgwNdndvEibkF/sIiX26/+7f6yJo1K/fPtL/53BXDPKeJQkLvPx87Knz79qzOI2tWr959acFaAYE3rii2cwDVOQutU8k/+uHv4cNrQMpWrFgxTXHtWoc3gkgqORkEJzqvW3fr/f8Paye4rFzZufvSisuXr95QVJw9W+C3MlwlB4O87Tp+fn6VV2sVgOo6wequ7tkz4zWP3QHrm45IBroJJd/if37w0CygaZ0Tpi1YMO/qnr17b7yeMePFAZufSlAjQQbeyle5qbBq1apZIGUn1s87DlS3/DrPsj8vr/McdES48Lv7q4NPVq3avx+sbP2cOV0zZsxfftfuZcdsbm6Bb1YM7BATk64pPHk6C6hqGljVnK6uZSfnzl2+bObMmR3csx0OhkIUMpgkHgKatW/fvO3HjwMVdXWdPHly7oYNd3mA6jo6uLm5Q0ABDcSxCU/37du+/f79HfPnz507dwNQ0Ya7dyuudyzu6Fi8eNGiOjUukEp2BqZDcXE7dpw5c+bcuY0bK+CgbtEioLK6+kmVLKIQhRZP4iwtgWp6gKCqpwoCyivqQACorLeXhQnkSKDC83a7dgEVlfeUw0BJScm9SfVAAFTHwjIdqpBpafyuspISIEICxSWV9ZMmLQGr0wVbzcEgbb518+YDFcWlxUAAUgMGNXfqKisrl1QCFaqZgL3NwGkgzsbb2LC5u7sYAUprWu/UL2HZto1FZrosJGbYGWIMbdjYpjRuvtddU1MDVAMB3U3bljZdubJNZqceJMDZGSQv8vX1TZnS2HCguwaqqrS5ubm1CQiuRE3faQSNGQYu469glVMalnZ315SBAUghUGX7Y/3psrD0yM6gvQmoEGh5Y0Pv+Xs1pdUQlUCF7e2PzXeawgwEGQm0nI2tsaFhYdvC3qVl1WClZ1vb2+XkHskisgLQlRc0IeoWtrUxn2+urT5dXVt99ixQ4UWEC8EqNS5o8jaClLX191bXQsDps+1hWo+QLAYnIfULMrxAdf39s6dWt4BB7enT4lqb9Bg40DK2+iZea2tmZub+qbsmA0HLqZZT4vqbpBgYMIoKbZ2H166Z1U9mhYKgUw+CJdHVgd0pGvHwmllvJCMYsJ56oKPBheI+hEoGpnDdrVungsBWQwNVaQas6oCWAIWlo6WkxMTEVAOZQFo5cJW57Mgm4FYGMRUGyK0tAGzv0vrmaa6xAAAAAElFTkSuQmCC'} src={'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAoCAMAAAC7IEhfAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAMAUExURUdwTBR20EyHx1iq5EWJ1hNyy0mR1EhzwnbB/8P5/Fx/vVuj5zyO4lCY102P0L3u/3K88dz4/8v1/0fW/jLh/013ujN4wBliuEuEy0FrqT13wFmCvkaH0GSLyBVVqe38/0il4FKu7crr/eL3/7Ls/zd3t1WN1TZcnTloojx6whpYpTdfoDVwtVeS1WeOyiNhsTFyuy50wHCPxDFgqLjK5G2Ow2Gy5ylpvB9uxA5x03W/71Wh4E2U2tr2/i6C3G7J+Gex7KTI8eb9/cbz/yC+/srl++P7/Znf+MPv/cn3/rz6/dL8/2fe/8v+/s/6/knN87f5/jx5uRZtwjRppU6I1liM2Bhcrmyl2kZ5uA9vzjd0wj95yjVstqze9hiD2zOH1kqv7Wqv5K7S7LLn/YfT+sfp/bPf+4jb/YbX+735/SjQ/T7J+VjV9rTs+0ns/6Dk/mPJ7eD9/b/8/Q9YxA5Zxw5Zwg5dwg5byhdn0kSO2xFezg5gwg1XwRBjwkqW4EeT3kuY20mV2BBVtg5avxFoxQ9Yu0+g3xJYslWi5h2q9SKA4AtTwAxjzUGN2UiN0VOm4yKj8NX7/0WQ3RFtyUaR1h95zSOc706J0Bt34VCi5yqw9F6s6SOI5Bx02h2M6Rh13SCX7k2c3E6a4lCd5EeQ2iVtyCJ54BVt3ROV8x+0+jS9+hGq+Ra0/WK37sPy/iOR6R1w2A5p0y9nrjJ/1EaO1yV1yDt+zBaJ4hF83FuJyg5nyhei9hJ96BN35ByV5ymq8S151muu5UCr6hzE/xS+/47h/3/r/xyG5SKV6yJ43BlswBJivA5z2A9duw5s1C590CZXpDVvuhec9BGM7xJQrxKE61yo4DOF4DeZ64jG8bbp/h/Q/j2F0xpr1xliyROA4CRst1mc10d8whNQqDqb3Sig5qrh/qLW9iZ/5UeY5pDU9UWf6J7O9oju/mDV/aXp/orj/6P7/4z3/2Pr/iLa/kaDxnuw57rf9ZfW+2Sd5G3X/7LW9zyR51TI+Dao7Zno//A+0MwAAADpdFJOUwD7Lv/+/v4CAQIK/v7+/P7+b/7+/iKh/f5LjBv8z/4d/v5cNf6A5piF/Pts9PST/tP+Qd0Oc/72Pf5B/Pv0/vv7zqD6/4qU+5JjlCv7ff79/lz628SK/pmm+/5cnh/+/vPht/r+pPKX44z7+/z7/P7+wNL////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+///////////////////////+1awirQAAA+BJREFUOMtjYCAXcMIAAVW4OKjKgJjDW8nXyytT0NOKCyjAgUudt29RwMdPb48dO/b2aEa6MkQvpjrlQqCqz3ki/j4+2VnCv44+S5XHVAm0xHPixHXuOSIiX4TfHeo87OJin/IjTYmBgwNdndvEibkF/sIiX26/+7f6yJo1K/fPtL/53BXDPKeJQkLvPx87Knz79qzOI2tWr959acFaAYE3rii2cwDVOQutU8k/+uHv4cNrQMpWrFgxTXHtWoc3gkgqORkEJzqvW3fr/f8Paye4rFzZufvSisuXr95QVJw9W+C3MlwlB4O87Tp+fn6VV2sVgOo6wequ7tkz4zWP3QHrm45IBroJJd/if37w0CygaZ0Tpi1YMO/qnr17b7yeMePFAZufSlAjQQbeyle5qbBq1apZIGUn1s87DlS3/DrPsj8vr/McdES48Lv7q4NPVq3avx+sbP2cOV0zZsxfftfuZcdsbm6Bb1YM7BATk64pPHk6C6hqGljVnK6uZSfnzl2+bObMmR3csx0OhkIUMpgkHgKatW/fvO3HjwMVdXWdPHly7oYNd3mA6jo6uLm5Q0ABDcSxCU/37du+/f79HfPnz507dwNQ0Ya7dyuudyzu6Fi8eNGiOjUukEp2BqZDcXE7dpw5c+bcuY0bK+CgbtEioLK6+kmVLKIQhRZP4iwtgWp6gKCqpwoCyivqQACorLeXhQnkSKDC83a7dgEVlfeUw0BJScm9SfVAAFTHwjIdqpBpafyuspISIEICxSWV9ZMmLQGr0wVbzcEgbb518+YDFcWlxUAAUgMGNXfqKisrl1QCFaqZgL3NwGkgzsbb2LC5u7sYAUprWu/UL2HZto1FZrosJGbYGWIMbdjYpjRuvtddU1MDVAMB3U3bljZdubJNZqceJMDZGSQv8vX1TZnS2HCguwaqqrS5ubm1CQiuRE3faQSNGQYu469glVMalnZ315SBAUghUGX7Y/3psrD0yM6gvQmoEGh5Y0Pv+Xs1pdUQlUCF7e2PzXeawgwEGQm0nI2tsaFhYdvC3qVl1WClZ1vb2+XkHskisgLQlRc0IeoWtrUxn2+urT5dXVt99ixQ4UWEC8EqNS5o8jaClLX191bXQsDps+1hWo+QLAYnIfULMrxAdf39s6dWt4BB7enT4lqb9Bg40DK2+iZea2tmZub+qbsmA0HLqZZT4vqbpBgYMIoKbZ2H166Z1U9mhYKgUw+CJdHVgd0pGvHwmllvJCMYsJ56oKPBheI+hEoGpnDdrVungsBWQwNVaQas6oCWAIWlo6WkxMTEVAOZQFo5cJW57Mgm4FYGMRUGyK0tAGzv0vrmaa6xAAAAAElFTkSuQmCC'}
alt=""
/> />
const apps: AppItemProps[] = [ const apps: AppItemProps[] = [
{ {
@@ -52,10 +53,13 @@ const apps: AppItemProps[] = [
const App: React.FC = () => { const App: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [collapsed, setCollapsed] = useState<boolean>(false)
const currentYear = useMemo(() => dateFormat(new Date(), 'yyyy'), []) const currentYear = useMemo(() => dateFormat(new Date(), 'yyyy'), [])
return ( return (
<ProLayoutDiv> <ProLayoutDiv>
<ProLayout <ProLayout
collapsed={collapsed}
onCollapse={setCollapsed}
siderWidth={180} siderWidth={180}
token={{ token={{
colorTextAppListIcon: '#dfdfdf', colorTextAppListIcon: '#dfdfdf',
@@ -78,7 +82,6 @@ const App: React.FC = () => {
}, },
}} }}
appList={apps} appList={apps}
defaultCollapsed={false}
breakpoint={false} breakpoint={false}
disableMobile={true} disableMobile={true}
logo={<img src="icon.png" alt="logo"/>} logo={<img src="icon.png" alt="logo"/>}
@@ -86,11 +89,12 @@ const App: React.FC = () => {
route={menus} route={menus}
location={{pathname: location.pathname}} location={{pathname: location.pathname}}
menu={{type: 'sub'}} menu={{type: 'sub'}}
menuItemRender={(item) => { menuItemRender={(item, defaultDom) => {
return ( return (
<div onClick={() => navigate(item.path || '/')}> <div onClick={() => navigate(item.path || '/')}>
{item.icon} {/*<span className="align-center">{item.icon}</span>*/}
<span className="ml-2">{item.name}</span> {/*<span className="ml-2">{item.name}</span>*/}
{defaultDom}
</div> </div>
) )
}} }}

File diff suppressed because one or more lines are too long

View File

@@ -42,7 +42,6 @@ function Conversation() {
const [input, setInput] = useState<string>('') const [input, setInput] = useState<string>('')
useUnmount(() => { useUnmount(() => {
console.log('Page Unmount')
abortController.current?.abort() abortController.current?.abort()
}) })

View File

@@ -1,113 +0,0 @@
import {type Connection, type Node} from '@xyflow/react'
import {uuid} from 'licia'
import {expect, test} from 'vitest'
import {
atLeastOneEndNodeError,
atLeastOneStartNodeError,
checkAddConnection,
checkAddNode,
checkSave,
hasCycleError,
hasRedundantEdgeError,
multiEndNodeError,
multiStartNodeError,
nodeToSelfError,
sourceNodeNotFoundError,
startNodeToEndNodeError,
targetNodeNotFoundError,
} from './FlowChecker.tsx'
const createNode = (id: string, type: string): Node => {
return {
data: {},
position: {
x: 0,
y: 0
},
id,
type,
}
}
const createStartNode = (id: string): Node => createNode(id, 'start-node')
const createEndNode = (id: string): Node => createNode(id, 'end-node')
const createConnection = function (source: string, target: string, sourceHandle: string | null = null, targetHandle: string | null = null): Connection {
return {
source,
target,
sourceHandle,
targetHandle,
}
}
/* check add node */
test(multiStartNodeError().message, () => {
expect(() => checkAddNode('start-node', [createStartNode(uuid())], [])).toThrowError(multiStartNodeError())
})
test(multiEndNodeError().message, () => {
expect(() => checkAddNode('end-node', [createEndNode(uuid())], [])).toThrowError(multiEndNodeError())
})
/* check add connection */
test(sourceNodeNotFoundError().message, () => {
expect(() => checkAddConnection(createConnection('a', 'b'), [], []))
})
test(targetNodeNotFoundError().message, () => {
expect(() => checkAddConnection(createConnection('a', 'b'), [createStartNode('a')], []))
})
test(startNodeToEndNodeError().message, () => {
expect(() => checkAddConnection(
createConnection('a', 'b'),
[createStartNode('a'), createEndNode('b')],
[]
))
})
test(nodeToSelfError().message, () => {
expect(() => {
// language=JSON
const {
nodes,
edges
} = JSON.parse('{\n "nodes": [\n {\n "id": "P14abHl4uY",\n "type": "start-node",\n "position": {\n "x": 100,\n "y": 100\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 82\n }\n },\n {\n "id": "3YDRebKqCX",\n "type": "end-node",\n "position": {\n "x": 773.3027344262372,\n "y": 101.42648884412338\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "YXJ91nHVaz",\n "type": "llm-node",\n "position": {\n "x": 430.94541183662506,\n "y": 101.42648884412338\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": true,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "P14abHl4uY",\n "target": "YXJ91nHVaz",\n "id": "xy-edge__P14abHl4uY-YXJ91nHVaz"\n },\n {\n "source": "YXJ91nHVaz",\n "target": "3YDRebKqCX",\n "id": "xy-edge__YXJ91nHVaz-3YDRebKqCX"\n }\n ],\n "data": {}\n}')
checkAddConnection(createConnection('YXJ91nHVaz', 'YXJ91nHVaz'), nodes, edges)
}).toThrowError(nodeToSelfError())
})
test(hasCycleError().message, () => {
expect(() => {
// language=JSON
const {
nodes,
edges,
} = JSON.parse('{\n "nodes": [\n {\n "id": "-DKfXm7r3f",\n "type": "start-node",\n "position": {\n "x": -75.45812782717618,\n "y": 14.410669352596976\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 82\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "2uL3Hw2CAW",\n "type": "end-node",\n "position": {\n "x": 734.7875356349059,\n "y": -1.2807079327602473\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "yp-yYfKUzC",\n "type": "llm-node",\n "position": {\n "x": 338.2236369686051,\n "y": -92.5759939566568\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "N4HQPN-NYZ",\n "type": "llm-node",\n "position": {\n "x": 332.51768159211156,\n "y": 114.26488844123382\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 74\n },\n "selected": true,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "-DKfXm7r3f",\n "target": "yp-yYfKUzC",\n "id": "xy-edge__-DKfXm7r3f-yp-yYfKUzC"\n },\n {\n "source": "yp-yYfKUzC",\n "target": "2uL3Hw2CAW",\n "id": "xy-edge__yp-yYfKUzC-2uL3Hw2CAW"\n },\n {\n "source": "-DKfXm7r3f",\n "target": "N4HQPN-NYZ",\n "id": "xy-edge__-DKfXm7r3f-N4HQPN-NYZ"\n },\n {\n "source": "N4HQPN-NYZ",\n "target": "yp-yYfKUzC",\n "id": "xy-edge__N4HQPN-NYZ-yp-yYfKUzC"\n }\n ],\n "data": {}\n}')
// language=JSON
checkAddConnection(JSON.parse('{\n "source": "yp-yYfKUzC",\n "sourceHandle": null,\n "target": "N4HQPN-NYZ",\n "targetHandle": null\n}'), nodes, edges)
}).toThrowError(hasCycleError())
})
test(hasRedundantEdgeError().message, () => {
expect(() => {
// language=JSON
const {
nodes,
edges,
} = JSON.parse('{\n "nodes": [\n {\n "id": "TCxPixrdkI",\n "type": "start-node",\n "position": {\n "x": -256,\n "y": 109.5\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 83\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "tGs78_ietp",\n "type": "llm-node",\n "position": {\n "x": 108,\n "y": -2.5\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 105\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "OeZdaU7LpY",\n "type": "llm-node",\n "position": {\n "x": 111,\n "y": 196\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 105\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "LjfoCYZo-E",\n "type": "knowledge-node",\n "position": {\n "x": 497.62196259607214,\n "y": -10.792497317791003\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 75\n },\n "selected": true,\n "dragging": false\n },\n {\n "id": "sQM_22GYB5",\n "type": "end-node",\n "position": {\n "x": 874.3164534765615,\n "y": 151.70316541496913\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 75\n },\n "selected": false,\n "dragging": false\n },\n {\n "id": "KpMH_xc3ZZ",\n "type": "llm-node",\n "position": {\n "x": 529.6286840434341,\n "y": 150.4721376669937\n },\n "data": {},\n "measured": {\n "width": 256,\n "height": 75\n },\n "selected": false,\n "dragging": false\n }\n ],\n "edges": [\n {\n "source": "TCxPixrdkI",\n "sourceHandle": "source",\n "target": "tGs78_ietp",\n "targetHandle": "target",\n "id": "xy-edge__TCxPixrdkIsource-tGs78_ietptarget"\n },\n {\n "source": "TCxPixrdkI",\n "sourceHandle": "source",\n "target": "OeZdaU7LpY",\n "targetHandle": "target",\n "id": "xy-edge__TCxPixrdkIsource-OeZdaU7LpYtarget"\n },\n {\n "source": "tGs78_ietp",\n "sourceHandle": "source",\n "target": "LjfoCYZo-E",\n "targetHandle": "target",\n "id": "xy-edge__tGs78_ietpsource-LjfoCYZo-Etarget"\n },\n {\n "source": "LjfoCYZo-E",\n "sourceHandle": "source",\n "target": "KpMH_xc3ZZ",\n "targetHandle": "target",\n "id": "xy-edge__LjfoCYZo-Esource-KpMH_xc3ZZtarget"\n },\n {\n "source": "OeZdaU7LpY",\n "sourceHandle": "source",\n "target": "KpMH_xc3ZZ",\n "targetHandle": "target",\n "id": "xy-edge__OeZdaU7LpYsource-KpMH_xc3ZZtarget"\n },\n {\n "source": "KpMH_xc3ZZ",\n "sourceHandle": "source",\n "target": "sQM_22GYB5",\n "targetHandle": "target",\n "id": "xy-edge__KpMH_xc3ZZsource-sQM_22GYB5target"\n }\n ],\n "data": {\n "tGs78_ietp": {\n "model": "qwen3",\n "outputs": {\n "text": {\n "type": "string"\n }\n },\n "systemPrompt": "你是个聪明人"\n },\n "OeZdaU7LpY": {\n "model": "qwen3",\n "outputs": {\n "text": {\n "type": "string"\n }\n },\n "systemPrompt": "你也是个聪明人"\n }\n }\n}')
// language=JSON
checkAddConnection(JSON.parse('{\n "source": "OeZdaU7LpY",\n "sourceHandle": "source",\n "target": "LjfoCYZo-E",\n "targetHandle": "target"\n}'), nodes, edges)
}).toThrowError(hasRedundantEdgeError())
})
/* check save */
test(atLeastOneStartNodeError().message, () => {
expect(() => checkSave([], [], {})).toThrowError(atLeastOneStartNodeError())
})
test(atLeastOneEndNodeError().message, () => {
expect(() => checkSave([createStartNode(uuid())], [], {})).toThrowError(atLeastOneEndNodeError())
})

View File

@@ -1,274 +0,0 @@
import {type Connection, type Edge, getConnectedEdges, getIncomers, getOutgoers, type Node} from '@xyflow/react'
import {clone, find, findIdx, isEqual, lpad, toStr, uuid} from 'licia'
export class CheckError extends Error {
readonly id: string
constructor(
id: number,
message: string,
) {
super(message)
this.id = `E${lpad(toStr(id), 6, '0')}`
}
public toString(): string {
return `${this.id}: ${this.message}`
}
}
export const multiStartNodeError = () => new CheckError(100, '只能存在1个开始节点')
export const multiEndNodeError = () => new CheckError(101, '只能存在1个结束节点')
const getNodeById = (id: string, nodes: Node[]) => find(nodes, (n: Node) => isEqual(n.id, id))
// @ts-ignore
export const checkAddNode: (type: string, nodes: Node[], edges: Edge[]) => void = (type, nodes, edges) => {
if (isEqual(type, 'start-node') && findIdx(nodes, (node: Node) => isEqual(type, node.type)) > -1) {
throw multiStartNodeError()
}
if (isEqual(type, 'end-node') && findIdx(nodes, (node: Node) => isEqual(type, node.type)) > -1) {
throw multiEndNodeError()
}
}
export const sourceNodeNotFoundError = () => new CheckError(200, '连线起始节点未找到')
export const targetNodeNotFoundError = () => new CheckError(201, '连线目标节点未找到')
export const startNodeToEndNodeError = () => new CheckError(202, '开始节点不能直连结束节点')
export const nodeToSelfError = () => new CheckError(203, '节点不能直连自身')
export const hasCycleError = () => new CheckError(204, '禁止流程循环')
export const nodeNotOnlyToEndNode = () => new CheckError(206, '直连结束节点的节点不允许连接其他节点')
export const hasRedundantEdgeError = () => new CheckError(207, '禁止出现冗余边')
const hasCycle = (sourceNode: Node, targetNode: Node, nodes: Node[], edges: Edge[], visited = new Set<string>()) => {
if (visited.has(targetNode.id)) return false
visited.add(targetNode.id)
for (const outgoer of getOutgoers(targetNode, nodes, edges)) {
if (isEqual(outgoer.id, sourceNode.id)) return true
if (hasCycle(sourceNode, outgoer, nodes, edges, visited)) return true
}
}
/* 摘自Dify的流程合法性判断 */
type ParallelInfoItem = {
parallelNodeId: string
depth: number
isBranch?: boolean
}
type NodeParallelInfo = {
parallelNodeId: string
edgeHandleId: string
depth: number
}
type NodeHandle = {
node: Node
handle: string
}
type NodeStreamInfo = {
upstreamNodes: Set<string>
downstreamEdges: Set<string>
}
const groupBy = (array: Record<string, any>[], iteratee: string) => {
const result: Record<string, any[]> = {}
for (const item of array) {
// 获取属性值并转换为字符串键
const key = item[iteratee]
if (!result[key]) {
result[key] = []
}
result[key].push(item)
}
return result
}
// @ts-ignore
export const getParallelInfo = (nodes: Node[], edges: Edge[], parentNodeId?: string) => {
// 等到有子图的时候再考虑
/*if (parentNodeId) {
const parentNode = nodes.find(node => node.id === parentNodeId)
if (!parentNode)
throw new Error('Parent node not found')
startNode = nodes.find(node => node.id === (parentNode.data as (IterationNodeType | LoopNodeType)).start_node_id)
}
else {
startNode = nodes.find(node => isEqual(node.type, 'start_node'))
}*/
let startNode = nodes.find(node => isEqual(node.type, 'start-node'))
if (!startNode)
throw new Error('Start node not found')
const parallelList = [] as ParallelInfoItem[]
const nextNodeHandles = [{node: startNode, handle: 'source'}]
let hasAbnormalEdges = false
const traverse = (firstNodeHandle: NodeHandle) => {
const nodeEdgesSet = {} as Record<string, Set<string>>
const totalEdgesSet = new Set<string>()
const nextHandles = [firstNodeHandle]
const streamInfo = {} as Record<string, NodeStreamInfo>
const parallelListItem = {
parallelNodeId: '',
depth: 0,
} as ParallelInfoItem
const nodeParallelInfoMap = {} as Record<string, NodeParallelInfo>
nodeParallelInfoMap[firstNodeHandle.node.id] = {
parallelNodeId: '',
edgeHandleId: '',
depth: 0,
}
while (nextHandles.length) {
const currentNodeHandle = nextHandles.shift()!
const {node: currentNode, handle: currentHandle = 'source'} = currentNodeHandle
const currentNodeHandleKey = currentNode.id
const connectedEdges = edges.filter(edge => edge.source === currentNode.id && edge.sourceHandle === currentHandle)
const connectedEdgesLength = connectedEdges.length
const outgoers = nodes.filter(node => connectedEdges.some(edge => edge.target === node.id))
const incomers = getIncomers(currentNode, nodes, edges)
if (!streamInfo[currentNodeHandleKey]) {
streamInfo[currentNodeHandleKey] = {
upstreamNodes: new Set<string>(),
downstreamEdges: new Set<string>(),
}
}
if (nodeEdgesSet[currentNodeHandleKey]?.size > 0 && incomers.length > 1) {
const newSet = new Set<string>()
for (const item of totalEdgesSet) {
if (!streamInfo[currentNodeHandleKey].downstreamEdges.has(item))
newSet.add(item)
}
if (isEqual(nodeEdgesSet[currentNodeHandleKey], newSet)) {
parallelListItem.depth = nodeParallelInfoMap[currentNode.id].depth
nextNodeHandles.push({node: currentNode, handle: currentHandle})
break
}
}
if (nodeParallelInfoMap[currentNode.id].depth > parallelListItem.depth)
parallelListItem.depth = nodeParallelInfoMap[currentNode.id].depth
outgoers.forEach((outgoer) => {
const outgoerConnectedEdges = getConnectedEdges([outgoer], edges).filter(edge => edge.source === outgoer.id)
const sourceEdgesGroup = groupBy(outgoerConnectedEdges, 'sourceHandle')
const incomers = getIncomers(outgoer, nodes, edges)
if (outgoers.length > 1 && incomers.length > 1)
hasAbnormalEdges = true
Object.keys(sourceEdgesGroup).forEach((sourceHandle) => {
nextHandles.push({node: outgoer, handle: sourceHandle})
})
if (!outgoerConnectedEdges.length)
nextHandles.push({node: outgoer, handle: 'source'})
const outgoerKey = outgoer.id
if (!nodeEdgesSet[outgoerKey])
nodeEdgesSet[outgoerKey] = new Set<string>()
if (nodeEdgesSet[currentNodeHandleKey]) {
for (const item of nodeEdgesSet[currentNodeHandleKey])
nodeEdgesSet[outgoerKey].add(item)
}
if (!streamInfo[outgoerKey]) {
streamInfo[outgoerKey] = {
upstreamNodes: new Set<string>(),
downstreamEdges: new Set<string>(),
}
}
if (!nodeParallelInfoMap[outgoer.id]) {
nodeParallelInfoMap[outgoer.id] = {
...nodeParallelInfoMap[currentNode.id],
}
}
if (connectedEdgesLength > 1) {
const edge = connectedEdges.find(edge => edge.target === outgoer.id)!
nodeEdgesSet[outgoerKey].add(edge.id)
totalEdgesSet.add(edge.id)
streamInfo[currentNodeHandleKey].downstreamEdges.add(edge.id)
streamInfo[outgoerKey].upstreamNodes.add(currentNodeHandleKey)
for (const item of streamInfo[currentNodeHandleKey].upstreamNodes)
streamInfo[item].downstreamEdges.add(edge.id)
if (!parallelListItem.parallelNodeId)
parallelListItem.parallelNodeId = currentNode.id
const prevDepth = nodeParallelInfoMap[currentNode.id].depth + 1
const currentDepth = nodeParallelInfoMap[outgoer.id].depth
nodeParallelInfoMap[outgoer.id].depth = Math.max(prevDepth, currentDepth)
} else {
for (const item of streamInfo[currentNodeHandleKey].upstreamNodes)
streamInfo[outgoerKey].upstreamNodes.add(item)
nodeParallelInfoMap[outgoer.id].depth = nodeParallelInfoMap[currentNode.id].depth
}
})
}
parallelList.push(parallelListItem)
}
while (nextNodeHandles.length) {
const nodeHandle = nextNodeHandles.shift()!
traverse(nodeHandle)
}
return {
parallelList,
hasAbnormalEdges,
}
}
export const checkAddConnection: (connection: Connection, nodes: Node[], edges: Edge[]) => void = (connection, nodes, edges) => {
let sourceNode = getNodeById(connection.source, nodes)
if (!sourceNode) {
throw sourceNodeNotFoundError()
}
let targetNode = getNodeById(connection.target, nodes)
if (!targetNode) {
throw targetNodeNotFoundError()
}
// 禁止短路整个流程
if (isEqual('start-node', sourceNode.type) && isEqual('end-node', targetNode.type)) {
throw startNodeToEndNodeError()
}
// 禁止流程出现环,必须是有向无环图
if (isEqual(sourceNode.id, targetNode.id)) {
throw nodeToSelfError()
} else if (hasCycle(sourceNode, targetNode, nodes, edges)) {
throw hasCycleError()
}
let newEdges = [...clone(edges), {...connection, id: uuid()}]
let {hasAbnormalEdges} = getParallelInfo(nodes, newEdges)
if (hasAbnormalEdges) {
throw hasRedundantEdgeError()
}
}
export const atLeastOneStartNodeError = () => new CheckError(300, '至少存在1个开始节点')
export const atLeastOneEndNodeError = () => new CheckError(301, '至少存在1个结束节点')
// @ts-ignore
export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nodes, edges, data) => {
if (nodes.filter(n => isEqual('start-node', n.type)).length < 1) {
throw atLeastOneStartNodeError()
}
if (nodes.filter(n => isEqual('end-node', n.type)).length < 1) {
throw atLeastOneEndNodeError()
}
}

View File

@@ -1,299 +0,0 @@
import {PlusCircleFilled, SaveFilled} from '@ant-design/icons'
import {Background, BackgroundVariant, Controls, MiniMap, type NodeProps, ReactFlow} from '@xyflow/react'
import {useMount} from 'ahooks'
import type {Schema} from 'amis'
import {Button, Drawer, Dropdown, message, Space} from 'antd'
import {arrToMap, find, isEqual, isNil, randomId} from 'licia'
import {type JSX, type MemoExoticComponent, useState} from 'react'
import styled from 'styled-components'
import '@xyflow/react/dist/style.css'
import {amisRender, commonInfo, horizontalFormOptions} from '../../../util/amis.tsx'
import {checkAddConnection, checkAddNode, checkSave} from './FlowChecker.tsx'
import CodeNode from './node/CodeNode.tsx'
import EndNode from './node/EndNode.tsx'
import KnowledgeNode from './node/KnowledgeNode.tsx'
import LlmNode from './node/LlmNode.tsx'
import StartNode from './node/StartNode.tsx'
import SwitchNode from './node/SwitchNode.tsx'
import {useDataStore} from './store/DataStore.ts'
import {useFlowStore} from './store/FlowStore.ts'
const FlowableDiv = styled.div`
height: 100%;
.react-flow__node.selectable {
&:focus {
box-shadow: 0 0 20px 1px #e8e8e8;
border-radius: 8px;
}
}
.react-flow__handle.connectionindicator {
width: 10px;
height: 10px;
background-color: #ffffff;
border: 1px solid #000000;
&:hover {
background-color: #e8e8e8;
border: 1px solid #c6c6c6;
}
}
.toolbar {
position: absolute;
right: 20px;
top: 20px;
z-index: 10;
}
.node-card {
cursor: default;
.card-container {
}
}
`
export type FlowEditorProps = {
}
function FlowEditor() {
const [messageApi, contextHolder] = message.useMessage()
const [nodeDef] = useState<{
key: string,
name: string,
component: MemoExoticComponent<(props: NodeProps) => JSX.Element>
}[]>([
{
key: 'start-node',
name: '开始',
component: StartNode,
},
{
key: 'end-node',
name: '结束',
component: EndNode,
},
{
key: 'llm-node',
name: '大模型',
component: LlmNode,
},
{
key: 'knowledge-node',
name: '知识库',
component: KnowledgeNode,
},
{
key: 'code-node',
name: '代码执行',
component: CodeNode,
},
{
key: 'switch-node',
name: '条件分支',
component: SwitchNode,
},
])
const [open, setOpen] = useState(false)
const {data, setData, getDataById, setDataById} = useDataStore()
const {
nodes,
addNode,
removeNode,
setNodes,
onNodesChange,
edges,
setEdges,
onEdgesChange,
onConnect,
} = useFlowStore()
const [currentNodeForm, setCurrentNodeForm] = useState<JSX.Element>()
const editNode = (id: string, columnSchema?: Schema[]) => {
if (!isNil(columnSchema)) {
setCurrentNodeForm(
amisRender(
{
type: 'wrapper',
size: 'none',
body: [
{
debug: commonInfo.debug,
type: 'form',
...horizontalFormOptions(),
wrapWithPanel: false,
onEvent: {
submitSucc: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
setDataById(id, context.props.data)
setOpen(false)
},
},
],
},
},
body: [
...(columnSchema ?? []),
{
type: 'wrapper',
size: 'none',
className: 'space-x-2 text-right',
body: [
{
type: 'action',
label: '取消',
onEvent: {
click: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, action, event) => {
setOpen(false)
},
},
],
},
},
},
{
type: 'submit',
label: '保存',
level: 'primary',
},
],
},
],
},
],
},
getDataById(id),
),
)
setOpen(true)
}
}
// 用于透传node操作到主流程
const initialNodeHandlers = {
getDataById,
setDataById,
removeNode,
editNode,
}
useMount(() => {
// language=JSON
let initialData = JSON.parse('{"nodes":[{"id":"TCxPixrdkI","type":"start-node","position":{"x":-256,"y":109.5},"data":{},"measured":{"width":256,"height":83},"selected":false,"dragging":false},{"id":"tGs78_ietp","type":"llm-node","position":{"x":108,"y":-2.5},"data":{},"measured":{"width":256,"height":105},"selected":false,"dragging":false},{"id":"OeZdaU7LpY","type":"llm-node","position":{"x":111,"y":196},"data":{},"measured":{"width":256,"height":105},"selected":false,"dragging":false},{"id":"LjfoCYZo-E","type":"knowledge-node","position":{"x":497.62196259607214,"y":-10.792497317791003},"data":{},"measured":{"width":256,"height":75},"selected":false,"dragging":false},{"id":"sQM_22GYB5","type":"end-node","position":{"x":874.3164534765615,"y":151.70316541496913},"data":{},"measured":{"width":256,"height":75},"selected":false,"dragging":false},{"id":"KpMH_xc3ZZ","type":"llm-node","position":{"x":529.6286840434341,"y":150.4721376669937},"data":{},"measured":{"width":256,"height":75},"selected":false,"dragging":false},{"id":"pOrR6EMVbe","type":"switch-node","position":{"x":110.33793030183864,"y":373.9551529987239},"data":{},"measured":{"width":256,"height":157},"selected":false,"dragging":false}],"edges":[{"source":"TCxPixrdkI","sourceHandle":"source","target":"tGs78_ietp","targetHandle":"target","id":"xy-edge__TCxPixrdkIsource-tGs78_ietptarget"},{"source":"TCxPixrdkI","sourceHandle":"source","target":"OeZdaU7LpY","targetHandle":"target","id":"xy-edge__TCxPixrdkIsource-OeZdaU7LpYtarget"},{"source":"tGs78_ietp","sourceHandle":"source","target":"LjfoCYZo-E","targetHandle":"target","id":"xy-edge__tGs78_ietpsource-LjfoCYZo-Etarget"},{"source":"LjfoCYZo-E","sourceHandle":"source","target":"KpMH_xc3ZZ","targetHandle":"target","id":"xy-edge__LjfoCYZo-Esource-KpMH_xc3ZZtarget"},{"source":"OeZdaU7LpY","sourceHandle":"source","target":"KpMH_xc3ZZ","targetHandle":"target","id":"xy-edge__OeZdaU7LpYsource-KpMH_xc3ZZtarget"},{"source":"KpMH_xc3ZZ","sourceHandle":"source","target":"sQM_22GYB5","targetHandle":"target","id":"xy-edge__KpMH_xc3ZZsource-sQM_22GYB5target"},{"source":"TCxPixrdkI","sourceHandle":"source","target":"pOrR6EMVbe","id":"xy-edge__TCxPixrdkIsource-pOrR6EMVbe"},{"source":"pOrR6EMVbe","sourceHandle":"3","target":"sQM_22GYB5","targetHandle":"target","id":"xy-edge__pOrR6EMVbe3-sQM_22GYB5target"},{"source":"pOrR6EMVbe","sourceHandle":"1","target":"KpMH_xc3ZZ","targetHandle":"target","id":"xy-edge__pOrR6EMVbe1-KpMH_xc3ZZtarget"}],"data":{"tGs78_ietp":{"model":"qwen3","outputs":{"text":{"type":"string"}},"systemPrompt":"你是个聪明人"},"OeZdaU7LpY":{"model":"qwen3","outputs":{"text":{"type":"string"}},"systemPrompt":"你也是个聪明人"}}}')
// let initialData: any = {}
let initialNodes = initialData?.nodes ?? []
let initialEdges = initialData?.edges ?? []
let initialNodeData = initialData?.data ?? {}
setData(initialNodeData)
for (let node of initialNodes) {
node.data = initialNodeHandlers
}
setNodes(initialNodes)
setEdges(initialEdges)
})
return (
<FlowableDiv>
{contextHolder}
<Space className="toolbar">
<Dropdown
menu={{
items: nodeDef.map(def => ({key: def.key, label: def.name})),
onClick: ({key}) => {
try {
if (commonInfo.debug) {
console.info('Add', key, JSON.stringify({nodes, edges, data}))
}
checkAddNode(key, nodes, edges)
addNode({
id: randomId(10),
type: key,
position: {x: 100, y: 100},
data: initialNodeHandlers,
})
} catch (e) {
// @ts-ignore
messageApi.error(e.toString())
}
},
}}
>
<Button type="default">
<PlusCircleFilled/>
</Button>
</Dropdown>
<Button type="primary" onClick={() => {
try {
if (commonInfo.debug) {
console.info('Save', JSON.stringify({nodes, edges, data}))
}
checkSave(nodes, edges, data)
// let saveData = {nodes, edges, data}
// console.log(buildEL(nodes, edges))
} catch (e) {
// @ts-ignore
messageApi.error(e.toString())
}
}}>
<SaveFilled/>
</Button>
</Space>
<Drawer
title="节点编辑"
open={open}
closeIcon={false}
maskClosable={false}
destroyOnHidden
size="large"
>
{currentNodeForm}
</Drawer>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={(connection) => {
try {
if (commonInfo.debug) {
console.info('Connection', JSON.stringify(connection), JSON.stringify({nodes, edges, data}))
}
checkAddConnection(connection, nodes, edges)
onConnect(connection)
} catch (e) {
// @ts-ignore
messageApi.error(e.toString())
}
}}
// @ts-ignore
nodeTypes={arrToMap(
nodeDef.map(def => def.key),
key => find(nodeDef, def => isEqual(key, def.key))!.component)
}
fitView
>
<Controls/>
<MiniMap/>
<Background variant={BackgroundVariant.Cross} gap={20} size={3}/>
</ReactFlow>
</FlowableDiv>
)
}
export default FlowEditor

View File

@@ -1,76 +0,0 @@
import {type Edge, type Node} from '@xyflow/react'
export const buildEL = (nodes: Node[], edges: Edge[]): string => {
const nodeMap: Map<string, Node> = new Map<string, Node>()
// 构建邻接列表和内图
const adjList = new Map<string, string[]>()
const inDegree = new Map<string, number>()
for (const node of nodes) {
nodeMap.set(node.id, node)
adjList.set(node.id, [])
inDegree.set(node.id, 0)
}
for (const edge of edges) {
adjList.get(edge.source)!.push(edge.target)
inDegree.set(edge.target, inDegree.get(edge.target)! + 1)
}
// Compute levels (longest path from start)
const levelMap = new Map<string, number>()
function computeLevel(nodeId: string): number {
if (levelMap.has(nodeId)) return levelMap.get(nodeId)!
const preds = edges.filter(e => e.target === nodeId).map(e => e.source)
const level = preds.length === 0 ? 0 : Math.max(...preds.map(p => computeLevel(p))) + 1
levelMap.set(nodeId, level)
return level
}
for (const node of nodes) computeLevel(node.id)
// Group nodes by level
const maxLevel = Math.max(...Array.from(levelMap.values()))
const levels: string[][] = Array.from({length: maxLevel + 1}, () => [])
for (const node of nodes) levels[levelMap.get(node.id)!].push(node.id)
const covertNodeFromId = (id: string) => {
let node = nodeMap.get(id)!
return `node("${node.type}").bind("nodeId", ${node.id})`
}
// Build EL expression
const expressions: string[] = []
for (let i = 0; i <= maxLevel; i++) {
const nodesAtLevel = levels[i]
if (nodesAtLevel.length === 0) continue
// 识别从这个级别开始的串行链
const serialChains: string[] = []
for (const nodeId of nodesAtLevel) {
let chain = [nodeId]
let current = nodeId
while (adjList.get(current)?.length === 1) {
const next = adjList.get(current)![0]
if (inDegree.get(next) === 1 && levelMap.get(next) === i + chain.length) {
chain.push(next)
current = next
} else break
}
if (chain.length > 1) {
serialChains.push(`THEN(${chain.map(id => covertNodeFromId(id)).join(',')})`)
// Remove processed nodes from their levels
for (let j = 1; j < chain.length; j++) {
const level = levelMap.get(chain[j])!
levels[level] = levels[level].filter(n => n !== chain[j])
}
} else {
serialChains.push(covertNodeFromId(nodeId))
}
}
// Combine chains or nodes at this level
expressions.push(serialChains.length > 1 ? `WHEN(${serialChains.join(', ')})` : serialChains[0])
}
return `THEN(${expressions.join(',')})`
}

View File

@@ -1,199 +0,0 @@
import {DeleteFilled, EditFilled} from '@ant-design/icons'
import {Handle, type HandleProps, type NodeProps, Position, useNodeConnections} from '@xyflow/react'
import type {Schema} from 'amis'
import {Card, Dropdown} from 'antd'
import {isEmpty, isEqual, isNil} from 'licia'
import {type JSX} from 'react'
import {horizontalFormOptions} from '../../../../util/amis.tsx'
export type AmisNodeType = 'normal' | 'start' | 'end'
export function inputsFormColumns(required: boolean = false, preload?: any): Schema[] {
return [
{
type: 'input-kvs',
name: 'inputs',
label: '输入变量',
value: preload,
addButtonText: '新增输入',
draggable: false,
keyItem: {
...horizontalFormOptions(),
label: '参数名称',
},
required: required,
valueItems: [
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '变量',
required: true,
options: [],
},
],
},
]
}
export function outputsFormColumns(editable: boolean = false, required: boolean = false, preload?: any): Schema[] {
return [
{
disabled: !editable,
type: 'input-kvs',
name: 'outputs',
label: '输出变量',
value: preload,
addButtonText: '新增输出',
draggable: false,
keyItem: {
...horizontalFormOptions(),
label: '参数名称',
},
required: required,
valueItems: [
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数',
required: true,
selectFirst: true,
options: [
{
label: '文本',
value: 'string',
},
{
label: '数字',
value: 'number',
},
{
label: '文本数组',
value: 'array-string',
},
{
label: '对象数组',
value: 'array-object',
},
],
},
],
},
]
}
export const LimitHandler = (props: HandleProps & { limit: number }) => {
const connections = useNodeConnections({
handleType: props.type,
})
return (
<Handle
{...props}
isConnectable={connections.length < props.limit}
/>
)
}
type AmisNodeProps = {
nodeProps: NodeProps
type: AmisNodeType
defaultNodeName: String
defaultNodeDescription?: String
extraNodeDescription?: (nodeData: any) => JSX.Element
handlers?: (nodeData: any) => JSX.Element
columnSchema?: Schema[]
}
const AmisNode: (props: AmisNodeProps) => JSX.Element = ({
nodeProps,
type,
defaultNodeName,
defaultNodeDescription,
extraNodeDescription,
handlers,
columnSchema,
}) => {
const {id, data} = nodeProps
const {getDataById, removeNode, editNode} = data
// @ts-ignore
const nodeData = getDataById(id)
const nodeName = isEmpty(nodeData?.node?.name) ? defaultNodeName : nodeData.node.name
const nodeDescription = isEmpty(nodeData?.node?.description) ? defaultNodeDescription : nodeData.node?.description
return (
<div className="w-64">
<Dropdown
className="card-container"
trigger={['contextMenu']}
menu={{
items: [
{
key: 'edit',
label: '编辑',
icon: <EditFilled className="text-gray-600 hover:text-blue-500"/>,
},
{
key: 'remove',
label: '删除',
icon: <DeleteFilled className="text-red-500 hover:text-red-500"/>,
},
],
onClick: menu => {
switch (menu.key) {
case 'edit':
// @ts-ignore
editNode(
id,
[
{
type: 'input-text',
name: 'node.name',
label: '节点名称',
placeholder: nodeName,
},
{
type: 'textarea',
name: 'node.description',
label: '节点描述',
placeholder: nodeDescription,
},
{
type: 'divider',
},
...(columnSchema ?? []),
],
)
break
case 'remove':
// @ts-ignore
removeNode(id)
break
}
},
}}
>
<Card
className="node-card"
title={nodeName}
extra={<span className="text-gray-300 text-xs">{id}</span>}
size="small"
>
<div className="card-description p-2 text-secondary text-sm">
{nodeDescription}
{extraNodeDescription?.(nodeData)}
</div>
</Card>
</Dropdown>
{isNil(handlers)
? <>
{isEqual(type, 'start') || isEqual(type, 'normal')
? <Handle type="source" position={Position.Right} id="source"/> : undefined}
{isEqual(type, 'end') || isEqual(type, 'normal')
? <Handle type="target" position={Position.Left} id="target"/> : undefined}
</>
: handlers?.(nodeData)}
</div>
)
}
export default AmisNode

View File

@@ -1,52 +0,0 @@
import type {NodeProps} from '@xyflow/react'
import AmisNode, {inputsFormColumns, outputsFormColumns} from './AmisNode.tsx'
import React from 'react'
const CodeNode = (props: NodeProps) => AmisNode({
nodeProps: props,
type: 'normal',
defaultNodeName: '代码执行',
defaultNodeDescription: '执行自定义的处理代码',
columnSchema: [
...inputsFormColumns(),
{
type: 'divider',
},
{
type: 'select',
name: 'type',
label: '代码类型',
required: true,
options: [
{
value: 'javascript',
label: 'JavaScript',
},
{
value: 'python',
label: 'Python',
},
{
value: 'lua',
label: 'Lua',
},
],
},
{
type: 'editor',
required: true,
label: '代码内容',
name: 'content',
language: '${type}',
options: {
wordWrap: 'bounded',
},
},
{
type: 'divider',
},
...outputsFormColumns(true, true, {result: {type: 'string'}}),
],
})
export default React.memo(CodeNode)

View File

@@ -1,13 +0,0 @@
import type {NodeProps} from '@xyflow/react'
import AmisNode, {outputsFormColumns} from './AmisNode.tsx'
import React from 'react'
const EndNode = (props: NodeProps) => AmisNode({
nodeProps: props,
type: 'end',
defaultNodeName: '结束节点',
defaultNodeDescription: '定义输出变量',
columnSchema: outputsFormColumns(true),
})
export default React.memo(EndNode)

View File

@@ -1,66 +0,0 @@
import type {NodeProps} from '@xyflow/react'
import {commonInfo} from '../../../../util/amis.tsx'
import AmisNode, {inputsFormColumns, outputsFormColumns} from './AmisNode.tsx'
import React from 'react'
const KnowledgeNode = (props: NodeProps) => AmisNode({
nodeProps: props,
type: 'normal',
defaultNodeName: '知识库',
defaultNodeDescription: '查询知识库获取外部知识',
columnSchema: [
...inputsFormColumns(),
{
type: 'divider',
},
{
type: 'select',
name: 'knowledgeId',
label: '知识库',
required: true,
options: [],
source: {
method: 'get',
url: `${commonInfo.baseAiUrl}/knowledge/list`,
// @ts-ignore
adaptor: (payload, response, api, context) => {
return {
...payload,
data: {
items: payload.data.items.map((item: any) => ({value: item['id'], label: item['name']})),
},
}
},
},
},
{
type: 'input-text',
name: 'query',
label: '查询文本',
required: true,
},
{
type: 'input-range',
name: 'count',
label: '返回数量',
required: true,
value: 3,
max: 10,
},
{
type: 'input-range',
name: 'score',
label: '匹配阀值',
required: true,
value: 0.6,
max: 1,
step: 0.05,
},
{
type: 'divider',
},
...outputsFormColumns(false, true, {result: {type: 'array-string'}}),
],
})
export default React.memo(KnowledgeNode)

View File

@@ -1,51 +0,0 @@
import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd'
import AmisNode, {inputsFormColumns, outputsFormColumns} from './AmisNode.tsx'
import React from 'react'
const modelMap: Record<string, string> = {
qwen3: 'Qwen3',
deepseek: 'Deepseek',
}
const LlmNode = (props: NodeProps) => AmisNode({
nodeProps: props,
type: 'normal',
defaultNodeName: '大模型',
defaultNodeDescription: '使用大模型对话',
extraNodeDescription: nodeData => {
const model = nodeData?.model as string | undefined
return model
? <div className="mt-2 flex justify-between">
<span></span>
<Tag className="m-0" color="blue">{modelMap[model]}</Tag>
</div>
: <></>
},
columnSchema: [
...inputsFormColumns(),
{
type: 'divider',
},
{
type: 'select',
name: 'model',
label: '大模型',
required: true,
selectFirst: true,
options: Object.keys(modelMap).map(key => ({label: modelMap[key], value: key})),
},
{
type: 'textarea',
name: 'systemPrompt',
label: '系统提示词',
required: true,
},
{
type: 'divider',
},
...outputsFormColumns(false, true, {text: {type: 'string'}}),
],
})
export default React.memo(LlmNode)

View File

@@ -1,68 +0,0 @@
import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd'
import {each} from 'licia'
import React, {type JSX} from 'react'
import {horizontalFormOptions} from '../../../../util/amis.tsx'
import AmisNode from './AmisNode.tsx'
const typeMap: Record<string, string> = {
text: '文本',
number: '数字',
files: '文件',
}
const StartNode = (props: NodeProps) => AmisNode({
nodeProps: props,
type: 'start',
defaultNodeName: '开始节点',
defaultNodeDescription: '定义输入变量',
extraNodeDescription: nodeData => {
const variables: JSX.Element[] = []
const inputs = (nodeData?.inputs ?? {}) as Record<string, { type: string, description: string }>
each(inputs, (value, key) => {
variables.push(
<div className="mt-1 flex justify-between" key={key}>
<span>{key}</span>
<Tag className="m-0" color="blue">{typeMap[value.type]}</Tag>
</div>,
)
})
return (
<div className="mt-2">
{...variables}
</div>
)
},
columnSchema: [
{
type: 'input-kvs',
name: 'inputs',
label: '输入变量',
addButtonText: '新增入参',
draggable: false,
keyItem: {
label: '参数名称',
...horizontalFormOptions(),
},
valueItems: [
{
...horizontalFormOptions(),
type: 'input-text',
name: 'description',
label: '参数描述',
},
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数类型',
required: true,
selectFirst: true,
options: Object.keys(typeMap).map(key => ({label: typeMap[key], value: key})),
},
],
},
],
})
export default React.memo(StartNode)

View File

@@ -1,55 +0,0 @@
import {Handle, type NodeProps, Position} from '@xyflow/react'
import {Tag} from 'antd'
import React from 'react'
import AmisNode from './AmisNode.tsx'
const cases = [
{
index: 1,
},
{
index: 2,
},
{
index: 3,
},
]
const SwitchNode = (props: NodeProps) => AmisNode({
nodeProps: props,
type: 'normal',
defaultNodeName: '分支节点',
defaultNodeDescription: '根据不同的情况前往不同的分支',
columnSchema: [],
// @ts-ignore
extraNodeDescription: nodeData => {
return (
<div className="mt-2">
{cases.map(item => (
<div key={item.index} className="mt-1">
<Tag className="m-0" color="blue"> {item.index}</Tag>
</div>
))}
</div>
)
},
// @ts-ignore
handlers: nodeData => {
return (
<>
<Handle type="target" position={Position.Left}/>
{cases.map((item, index) => (
<Handle
type="source"
position={Position.Right}
key={item.index}
id={`${item.index}`}
style={{top: 85 + (25 * index)}}
/>
))}
</>
)
},
})
export default React.memo(SwitchNode)

View File

@@ -1,6 +1,21 @@
import React from 'react' import React from 'react'
import {amisRender, commonInfo, crudCommonOptions, paginationTemplate,} from '../../../util/amis.tsx'
import {useNavigate} from 'react-router' import {useNavigate} from 'react-router'
import {
amisRender,
commonInfo,
crudCommonOptions,
mappingField,
mappingItem,
paginationTemplate,
readOnlyDialogOptions,
} from '../../../util/amis.tsx'
import {generateInputForm} from './InputSchema.tsx'
const statusMapping = [
mappingItem('完成', 'FINISHED', 'label-success'),
mappingItem('执行中', 'RUNNING', 'label-warning'),
mappingItem('错误', 'ERROR', 'label-danger'),
]
const FlowTask: React.FC = () => { const FlowTask: React.FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -20,8 +35,8 @@ const FlowTask: React.FC = () => {
page: { page: {
index: '${page}', index: '${page}',
size: '${perPage}', size: '${perPage}',
} },
} },
}, },
...crudCommonOptions(), ...crudCommonOptions(),
...paginationTemplate( ...paginationTemplate(
@@ -55,9 +70,16 @@ const FlowTask: React.FC = () => {
label: '任务ID', label: '任务ID',
width: 200, width: 200,
}, },
{
name: 'templateName',
label: '模板',
},
{ {
name: 'status', name: 'status',
label: '状态', label: '状态',
width: 50,
align: 'center',
...mappingField('status', statusMapping),
}, },
{ {
type: 'operation', type: 'operation',
@@ -65,10 +87,59 @@ const FlowTask: React.FC = () => {
width: 200, width: 200,
buttons: [ buttons: [
{ {
visibleOn: 'hasInput',
type: 'action', type: 'action',
label: '重新执行', label: '查看',
level: 'link', level: 'link',
size: 'sm', size: 'sm',
actionType: 'dialog',
dialog: {
title: '查看',
size: 'md',
...readOnlyDialogOptions(),
body: [
{
type: 'service',
schemaApi: {
method: 'get',
url: `${commonInfo.baseAiUrl}/flow_task/input_schema/\${id}`,
// @ts-ignore
adaptor: (payload, response, api, context) => {
return {
...payload,
data: {
...generateInputForm(payload.data ?? {}, undefined, false, true),
id: 'db8a4d10-0c47-4e27-b1a4-d0f2e1c15992',
initApi: {
method: 'get',
url: `${commonInfo.baseAiUrl}/flow_task/input_data/\${id}`,
// @ts-ignore
adaptor: (payload, response, api, context) => {
console.log(payload)
return {
...payload,
data: {
inputData: payload.data ?? {},
},
}
},
},
static: true,
},
}
},
},
},
],
},
},
{
type: 'action',
label: '执行',
level: 'link',
size: 'sm',
actionType: 'ajax',
api: `get:${commonInfo.baseAiUrl}/flow_task/execute/\${id}`,
}, },
{ {
type: 'action', type: 'action',
@@ -77,7 +148,7 @@ const FlowTask: React.FC = () => {
level: 'link', level: 'link',
size: 'sm', size: 'sm',
actionType: 'ajax', actionType: 'ajax',
api: `get:${commonInfo.baseAiUrl}/task/remove/\${id}`, api: `get:${commonInfo.baseAiUrl}/flow_task/remove/\${id}`,
confirmText: '确认删除任务记录:${name}', confirmText: '确认删除任务记录:${name}',
confirmTitle: '删除', confirmTitle: '删除',
}, },

View File

@@ -1,5 +1,7 @@
import {isEmpty} from 'licia'
import React from 'react' import React from 'react'
import {amisRender,} from '../../../util/amis.tsx' import {amisRender, commonInfo} from '../../../util/amis.tsx'
import {generateInputForm} from './InputSchema.tsx'
const FlowTaskAdd: React.FC = () => { const FlowTaskAdd: React.FC = () => {
// const navigate = useNavigate() // const navigate = useNavigate()
@@ -7,25 +9,165 @@ const FlowTaskAdd: React.FC = () => {
<div className="task-template"> <div className="task-template">
{amisRender( {amisRender(
{ {
id: 'e81515a4-8a73-457a-974d-7e9196eeb524',
type: 'page', type: 'page',
title: '发起任务', title: '发起任务',
body: { body: {
id: '74a1a3e5-41a6-4979-88e7-65f15bce4d4c',
type: 'wizard', type: 'wizard',
wrapWithPanel: false, wrapWithPanel: false,
steps:[ steps: [
{ {
title: '选择任务模板', title: '选择任务模板',
body: [] actions: [
{
type: 'action',
level: 'primary',
actionType: 'next',
label: '下一步',
disabledOn: '${templateId === undefined}',
},
],
body: [
{
type: 'service',
api: `get:${commonInfo.baseAiUrl}/flow_task/template/list`,
body: [
{
type: 'table2',
source: '$items',
rowSelection: {
type: 'radio',
keyField: 'id',
rowClick: true,
fixed: true,
},
onEvent: {
selectedChange: {
actions: [
{
actionType: 'custom',
// @ts-ignore
script: (context, doAction, event) => {
let selectedIds = (event?.data?.selectedItems ?? []).map((item: any) => item.id)
if (!isEmpty(selectedIds)) {
doAction({
actionType: 'setValue',
componentId: 'e81515a4-8a73-457a-974d-7e9196eeb524',
args: {
value: {
templateId: selectedIds[0],
},
},
})
} else {
doAction({
actionType: 'setValue',
componentId: 'e81515a4-8a73-457a-974d-7e9196eeb524',
args: {
value: {
templateId: undefined,
},
},
})
}
},
},
],
},
},
columns: [
{
name: 'name',
title: '名称',
width: 200,
},
{
name: 'description',
title: '模板描述',
},
],
},
],
},
],
}, },
{ {
title: '填写任务信息', title: '填写任务信息',
body: [] actions: [
{
type: 'action',
actionType: 'prev',
label: '上一步',
onEvent: {
click: {
actions: [
{
actionType: 'setValue',
componentId: 'e81515a4-8a73-457a-974d-7e9196eeb524',
args: {
value: {
templateId: undefined,
},
},
},
],
},
},
},
{
type: 'action',
level: 'primary',
label: '提交',
onEvent: {
click: {
actions: [
{
actionType: 'validate',
componentId: 'db8a4d10-0c47-4e27-b1a4-d0f2e1c15992',
},
{
actionType: 'stopPropagation',
expression: '${event.data.validateResult.error !== \'\'}',
},
{
actionType: 'submit',
componentId: 'db8a4d10-0c47-4e27-b1a4-d0f2e1c15992',
},
],
},
},
},
],
body: [
{
type: 'service',
schemaApi: {
method: 'get',
url: `${commonInfo.baseAiUrl}/flow_task/template/input_schema/\${templateId}`,
// @ts-ignore
adaptor: (payload, response, api, context) => {
return {
...payload,
data: {
id: 'db8a4d10-0c47-4e27-b1a4-d0f2e1c15992',
api: {
method: 'post',
url: `${commonInfo.baseAiUrl}/flow_task/save`,
data: {
templateId: '${templateId|default:undefined}',
input: '${inputData|default:undefined}',
}
},
...generateInputForm(payload.data ?? {}, undefined, false),
},
}
},
},
},
],
}, },
{ ],
title: '完成',
body: []
},
]
}, },
}, },
)} )}

View File

@@ -1,7 +1,9 @@
import type {Schema} from 'amis' import type {Schema} from 'amis'
import {commonInfo, formInputFileStaticColumns} from '../../../util/amis.tsx'
export const typeMap: Record<string, string> = { export const typeMap: Record<string, string> = {
text: '文本', text: '文本',
textarea: '文本段',
number: '数字', number: '数字',
files: '文件', files: '文件',
} }
@@ -12,35 +14,65 @@ export type InputField = {
description?: string description?: string
} }
export const generateInputForm: (inputSchema: Record<string, InputField>) => Schema = inputSchema => { export const generateInputForm: (inputSchema: Record<string, InputField>, title?: string, border?: boolean, staticView?: boolean) => Schema = (inputSchema, title, border, staticView) => {
let items: Schema[] = [] let items: Schema[] = []
for (const name of Object.keys(inputSchema)) { for (const name of Object.keys(inputSchema)) {
let field = inputSchema[name] let field = inputSchema[name]
// @ts-ignore // @ts-ignore
let formItem: Schema = { let commonMeta: Schema = {
name: name, name: `inputData.${name}`,
...field, ...field,
} }
switch (field.type) { switch (field.type) {
case 'text': case 'text':
formItem = { items.push({
...formItem, ...commonMeta,
type: 'input-text', type: 'input-text',
clearValueOnEmpty: true, clearValueOnEmpty: true,
} })
break
case 'textarea':
items.push({
...commonMeta,
type: 'textarea',
})
break break
case 'number': case 'number':
formItem.type = 'input-number' commonMeta.type = 'input-number'
break break
case 'files': case 'files':
formItem.type = 'input-file' if (staticView) {
items.push({
...commonMeta,
type: 'control',
body: {
type: 'crud',
api: `${commonInfo.baseAiUrl}/upload/detail?ids=\${JOIN(inputData.${name}, ',')}`,
columns: formInputFileStaticColumns,
},
})
} else {
items.push({
...commonMeta,
type: 'input-file',
autoUpload: false,
drag: true,
multiple: true,
joinValues: false,
extractValue: true,
accept: '*',
maxSize: 104857600,
receiver: `${commonInfo.baseAiUrl}/upload`,
})
}
break break
} }
items.push(formItem)
} }
return { return {
debug: commonInfo.debug,
type: 'form', type: 'form',
title: '入参表单预览', title: title,
wrapWithPanel: border,
canAccessSuperData: false, canAccessSuperData: false,
actions: [], actions: [],
body: items, body: items,

View File

@@ -97,7 +97,7 @@ const FlowTaskTemplate: React.FC = () => {
actionType: 'custom', actionType: 'custom',
// @ts-ignore // @ts-ignore
script: (context, doAction, event) => { script: (context, doAction, event) => {
navigate(`/ai/flow_task_template/edit/${context.props.data['id']}`) navigate(`/ai/flow_task_template/flow/edit/${context.props.data['id']}`)
}, },
}, },
], ],

View File

@@ -1,9 +1,8 @@
import {isEmpty, isEqual} from 'licia' import {isEqual} from 'licia'
import React from 'react' import React from 'react'
import {useNavigate, useParams} from 'react-router'
import styled from 'styled-components' import styled from 'styled-components'
import {amisRender, commonInfo, horizontalFormOptions} from '../../../../util/amis.tsx' import {amisRender, commonInfo, horizontalFormOptions} from '../../../../util/amis.tsx'
import {generateInputForm, typeMap} from '../InputSchema.tsx'
import { useParams } from 'react-router'
const TemplateEditDiv = styled.div` const TemplateEditDiv = styled.div`
.antd-EditorControl { .antd-EditorControl {
@@ -12,6 +11,7 @@ const TemplateEditDiv = styled.div`
` `
const FlowTaskTemplateEdit: React.FC = () => { const FlowTaskTemplateEdit: React.FC = () => {
const navigate = useNavigate()
const {template_id} = useParams() const {template_id} = useParams()
const preloadTemplateId = isEqual(template_id, '-1') ? undefined : template_id const preloadTemplateId = isEqual(template_id, '-1') ? undefined : template_id
return ( return (
@@ -33,35 +33,18 @@ const FlowTaskTemplateEdit: React.FC = () => {
}, },
}, },
initApi: preloadTemplateId initApi: preloadTemplateId
? { ? `get:${commonInfo.baseAiUrl}/flow_task/template/detail/${preloadTemplateId}`
method: 'GET',
url: `${commonInfo.baseAiUrl}/flow_task/template/detail/${preloadTemplateId}`,
}
: undefined, : undefined,
wrapWithPanel: false, wrapWithPanel: false,
...horizontalFormOptions(), ...horizontalFormOptions(),
onEvent: { onEvent: {
change: { submitSucc: {
actions: [ actions: [
{
actionType: 'validate',
},
{ {
actionType: 'custom', actionType: 'custom',
// @ts-ignore // @ts-ignore
script: (context, doAction, event) => { script: (context, doAction, event) => {
let data = event?.data ?? {} navigate(-1)
let inputSchema = data.inputSchema ?? []
if (!isEmpty(inputSchema) && isEmpty(data?.validateResult?.error ?? undefined)) {
doAction({
actionType: 'setValue',
args: {
value: {
inputPreview: generateInputForm(inputSchema),
},
},
})
}
}, },
}, },
], ],
@@ -78,6 +61,8 @@ const FlowTaskTemplateEdit: React.FC = () => {
label: '名称', label: '名称',
required: true, required: true,
clearable: true, clearable: true,
maxLength: 10,
showCounter: true,
}, },
{ {
type: 'textarea', type: 'textarea',
@@ -85,71 +70,8 @@ const FlowTaskTemplateEdit: React.FC = () => {
label: '描述', label: '描述',
required: true, required: true,
clearable: true, clearable: true,
}, maxLength: 500,
{ showCounter: true,
type: 'group',
body: [
{
type: 'wrapper',
size: 'none',
body: [
{
type: 'input-kvs',
name: 'inputSchema',
label: '输入变量',
addButtonText: '新增入参',
draggable: false,
keyItem: {
label: '参数名称',
...horizontalFormOptions(),
validations: {
isAlphanumeric: true,
},
},
valueItems: [
{
...horizontalFormOptions(),
type: 'input-text',
name: 'label',
required: true,
label: '中文名称',
clearValueOnEmpty: true,
clearable: true,
},
{
...horizontalFormOptions(),
type: 'input-text',
name: 'description',
label: '参数描述',
clearValueOnEmpty: true,
clearable: true,
},
{
...horizontalFormOptions(),
type: 'select',
name: 'type',
label: '参数类型',
required: true,
selectFirst: true,
options: Object.keys(typeMap).map(key => ({label: typeMap[key], value: key})),
},
{
...horizontalFormOptions(),
type: 'switch',
name: 'required',
label: '是否必填',
required: true,
value: true,
},
],
},
],
},
{
type: 'amis',
name: 'inputPreview',
},
],
}, },
{ {
type: 'button-toolbar', type: 'button-toolbar',

View File

@@ -0,0 +1,50 @@
import {useMount} from 'ahooks'
import axios from 'axios'
import React, {useState} from 'react'
import {useNavigate, useParams} from 'react-router'
import styled from 'styled-components'
import FlowEditor from '../../../../components/flow/FlowEditor.tsx'
import type {GraphData} from '../../../../components/flow/types.ts'
import {commonInfo} from '../../../../util/amis.tsx'
const FlowTaskTemplateFlowEditDiv = styled.div`
`
const FlowTaskTemplateFlowEdit: React.FC = () => {
const navigate = useNavigate()
const {template_id} = useParams()
const [graphData, setGraphData] = useState<GraphData>({nodes: [], edges: [], data: {}})
useMount(async () => {
let {data} = await axios.get(
`${commonInfo.baseAiUrl}/flow_task/template/detail/${template_id}`,
{
headers: commonInfo.authorizationHeaders,
},
)
setGraphData(data?.data?.flowGraph)
})
return (
<FlowTaskTemplateFlowEditDiv className="h-full w-full">
<FlowEditor
graphData={graphData}
onGraphDataChange={async data => {
await axios.post(
`${commonInfo.baseAiUrl}/flow_task/template/update_flow_graph`,
{
id: template_id,
graph: data,
},
{
headers: commonInfo.authorizationHeaders,
},
)
navigate(-1)
}}
/>
</FlowTaskTemplateFlowEditDiv>
)
}
export default FlowTaskTemplateFlowEdit

View File

@@ -18,7 +18,6 @@ import {values} from 'licia'
import {Navigate, type RouteObject} from 'react-router' import {Navigate, type RouteObject} from 'react-router'
import Conversation from './pages/ai/Conversation.tsx' import Conversation from './pages/ai/Conversation.tsx'
import Feedback from './pages/ai/feedback/Feedback.tsx' import Feedback from './pages/ai/feedback/Feedback.tsx'
import FlowEditor from './pages/ai/flow/FlowEditor.tsx'
import DataDetail from './pages/ai/knowledge/DataDetail.tsx' import DataDetail from './pages/ai/knowledge/DataDetail.tsx'
import DataImport from './pages/ai/knowledge/DataImport.tsx' import DataImport from './pages/ai/knowledge/DataImport.tsx'
import DataSegment from './pages/ai/knowledge/DataSegment.tsx' import DataSegment from './pages/ai/knowledge/DataSegment.tsx'
@@ -39,6 +38,7 @@ import Yarn from './pages/overview/Yarn.tsx'
import YarnCluster from './pages/overview/YarnCluster.tsx' import YarnCluster from './pages/overview/YarnCluster.tsx'
import Test from './pages/Test.tsx' import Test from './pages/Test.tsx'
import {commonInfo} from './util/amis.tsx' import {commonInfo} from './util/amis.tsx'
import FlowTaskTemplateFlowEdit from './pages/ai/task/template/FlowTaskTemplateFlowEdit.tsx'
export const routes: RouteObject[] = [ export const routes: RouteObject[] = [
{ {
@@ -133,9 +133,9 @@ export const routes: RouteObject[] = [
Component: FlowTaskTemplateEdit, Component: FlowTaskTemplateEdit,
}, },
{ {
path: 'flowable', path: 'flow_task_template/flow/edit/:template_id',
Component: FlowEditor, Component: FlowTaskTemplateFlowEdit,
}, }
], ],
}, },
{ {
@@ -238,11 +238,6 @@ export const menus = {
name: '知识库', name: '知识库',
icon: <DatabaseOutlined/>, icon: <DatabaseOutlined/>,
}, },
{
path: '/ai/flowable',
name: '流程编排',
icon: <GatewayOutlined/>,
},
{ {
path: '1089caa6-9477-44a5-99f1-a9c179f6cfd3', path: '1089caa6-9477-44a5-99f1-a9c179f6cfd3',
name: '流程任务', name: '流程任务',

View File

@@ -278,7 +278,7 @@ export function horizontalFormOptions() {
return { return {
mode: 'horizontal', mode: 'horizontal',
horizontal: { horizontal: {
leftFixed: 'sm' leftFixed: 'sm',
}, },
} }
} }
@@ -2517,4 +2517,61 @@ export function time(field: string) {
export function pictureFromIds(field: string) { export function pictureFromIds(field: string) {
return `\${ARRAYMAP(${field},id => '${commonInfo.baseAiUrl}/upload/download/' + id)}` return `\${ARRAYMAP(${field},id => '${commonInfo.baseAiUrl}/upload/download/' + id)}`
}
export const formInputFileStaticColumns = [
{
name: 'filename',
label: '文件名',
},
{
type: 'operation',
label: '操作',
width: 140,
buttons: [
{
type: 'action',
label: '预览',
level: 'link',
icon: 'fas fa-eye',
},
{
type: 'action',
label: '下载',
level: 'link',
icon: 'fa fa-download',
actionType: 'ajax',
// api: {
// ...apiGet('${base}/upload/download/${id}'),
// responseType: 'blob',
// }
},
],
},
]
export function formInputSingleFileStatic(field: string, label: string) {
return {
visibleOn: '${static}',
type: 'control',
label: label,
required: true,
body: {
type: 'table',
source: `\${${field}|asArray}`,
columns: formInputFileStaticColumns,
},
}
}
export function formInputMultiFileStatic(field: string, label: string) {
return {
visibleOn: '${static}',
type: 'input-table',
label: label,
name: field,
required: true,
resizable: false,
columns: formInputFileStaticColumns,
}
} }