51 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
59 changed files with 2909 additions and 1050 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;
@@ -53,7 +57,7 @@ create table hudi_collect_build_b12.service_ai_flow_task_template
modified_time datetime(6) comment '记录更新时间', modified_time datetime(6) comment '记录更新时间',
description varchar(255) comment '模板功能、内容说明', description varchar(255) comment '模板功能、内容说明',
flow_graph longtext not null 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,5 +1,7 @@
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.core.entity.amis.AmisResponse;
@@ -9,6 +11,7 @@ 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 java.util.Map;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
@@ -37,9 +40,27 @@ public class TaskController extends SimpleControllerSupport<FlowTask, TaskContro
} }
@GetMapping("input_data/{id}") @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 { public AmisResponse<?> getInputSchema(@PathVariable("id") Long id) throws JsonProcessingException {
var task = flowTaskService.detailOrThrow(id); var task = flowTaskService.detailOrThrow(id);
return AmisResponse.responseSuccess(mapper.readValue(task.getInput(), Map.class)); 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
@@ -47,8 +68,11 @@ public class TaskController extends SimpleControllerSupport<FlowTask, TaskContro
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;
}; };
} }
@@ -74,14 +98,15 @@ 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 String templateName;
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()))")
@Mapping(target = "templateName", source = "task.template.name")
public abstract ListItem from(FlowTask task); public abstract ListItem from(FlowTask task);
} }
} }
@@ -92,10 +117,11 @@ 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()))")
@Mapping(target = "templateName", source = "task.template.name")
public abstract DetailItem from(FlowTask task); public abstract DetailItem from(FlowTask task);
} }
} }

View File

@@ -1,5 +1,6 @@
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;
@@ -38,9 +39,18 @@ public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemp
@GetMapping("input_schema/{id}") @GetMapping("input_schema/{id}")
public AmisResponse<?> getInputSchema(@PathVariable("id") Long id) throws JsonProcessingException { public AmisResponse<?> getInputSchema(@PathVariable("id") Long id) throws JsonProcessingException {
var template = flowTaskTemplateService.detailOrThrow(id); var template = flowTaskTemplateService.detailOrThrow(id);
if (ObjectUtil.isEmpty(template.getInputSchema())) {
return AmisResponse.responseSuccess();
}
return AmisResponse.responseSuccess(mapper.readValue(template.getInputSchema(), Map.class)); return AmisResponse.responseSuccess(mapper.readValue(template.getInputSchema(), Map.class));
} }
@GetMapping("flow_graph/{id}")
public AmisResponse<?> getFlowGraph(@PathVariable("id") Long id) throws JsonProcessingException {
var template = flowTaskTemplateService.detailOrThrow(id);
return AmisResponse.responseSuccess(mapper.readValue(template.getFlowGraph(), Map.class));
}
@PostMapping("update_flow_graph") @PostMapping("update_flow_graph")
public AmisResponse<?> updateFlowGraph(@RequestBody UpdateGraphItem item) throws JsonProcessingException { public AmisResponse<?> updateFlowGraph(@RequestBody UpdateGraphItem item) throws JsonProcessingException {
flowTaskTemplateService.updateFlowGraph(item.getId(), mapper.writeValueAsString(item.getGraph())); flowTaskTemplateService.updateFlowGraph(item.getId(), mapper.writeValueAsString(item.getGraph()));
@@ -49,8 +59,7 @@ public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemp
@Override @Override
protected SaveItemMapper<FlowTaskTemplate, SaveItem> saveItemMapper() { protected SaveItemMapper<FlowTaskTemplate, SaveItem> saveItemMapper() {
var map = Mappers.getMapper(SaveItem.Mapper.class); return Mappers.getMapper(SaveItem.Mapper.class);
return item -> map.from(item, mapper);
} }
@Override @Override
@@ -69,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);
}
} }
} }
@@ -105,6 +108,9 @@ public class TaskTemplateController extends SimpleControllerSupport<FlowTaskTemp
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> mapJson(String source, @Context ObjectMapper mapper) throws Exception { public Map<String, Object> mapJson(String source, @Context ObjectMapper mapper) throws Exception {
if (ObjectUtil.isNull(source)) {
return null;
}
return mapper.readValue(source, new TypeReference<>() { return mapper.readValue(source, new TypeReference<>() {
}); });
} }

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,7 +28,7 @@ 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(nullable = false, columnDefinition = "longtext") @Column(nullable = false, columnDefinition = "longtext")

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,22 +1,45 @@
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; 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) @Transactional(rollbackFor = Exception.class)
public void updateFlowGraph(Long id, String flowGraph) { public void updateFlowGraph(Long id, String flowGraph) throws JsonProcessingException {
var template = detailOrThrow(id); 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); template.setFlowGraph(flowGraph);
save(template); 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

@@ -1,5 +1,6 @@
import {type Connection, type Edge, getOutgoers, type Node} from '@xyflow/react' import {type Connection, type Edge, getOutgoers, type Node} from '@xyflow/react'
import {find, isEmpty, isEqual, lpad, toStr} from 'licia' import {find, has, isEmpty, isEqual, lpad, toStr} from 'licia'
import {NodeRegistryMap} from './NodeRegistry.tsx'
export class CheckError extends Error { export class CheckError extends Error {
readonly id: string readonly id: string
@@ -19,14 +20,28 @@ export class CheckError extends Error {
const getNodeById = (id: string, nodes: Node[]) => find(nodes, (n: Node) => isEqual(n.id, id)) 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 // @ts-ignore
export const checkAddNode: (type: string, nodes: Node[], edges: Edge[]) => void = (type, nodes, edges) => { 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 sourceNodeNotFoundError = () => new CheckError(200, '连线起始节点未找到')
export const targetNodeNotFoundError = () => new CheckError(201, '连线目标节点未找到') export const targetNodeNotFoundError = () => new CheckError(201, '连线目标节点未找到')
export const nodeToSelfError = () => new CheckError(203, '节点不能直连自身') export const nodeToSelfError = () => new CheckError(203, '节点不能直连自身')
export const hasCycleError = () => new CheckError(204, '禁止流程循环') 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>()) => { const hasCycle = (sourceNode: Node, targetNode: Node, nodes: Node[], edges: Edge[], visited = new Set<string>()) => {
if (visited.has(targetNode.id)) return false if (visited.has(targetNode.id)) return false
@@ -47,6 +62,10 @@ export const checkAddConnection: (connection: Connection, nodes: Node[], edges:
throw targetNodeNotFoundError() throw targetNodeNotFoundError()
} }
if (!isEqual(sourceNode.parentId, targetNode.parentId)) {
throw differentParent()
}
// 禁止流程出现环,必须是有向无环图 // 禁止流程出现环,必须是有向无环图
if (isEqual(sourceNode.id, targetNode.id)) { if (isEqual(sourceNode.id, targetNode.id)) {
throw nodeToSelfError() throw nodeToSelfError()
@@ -63,6 +82,8 @@ export const checkAddConnection: (connection: Connection, nodes: Node[], edges:
export const atLeastOneNode = () => new CheckError(300, '至少包含一个节点') export const atLeastOneNode = () => new CheckError(300, '至少包含一个节点')
export const hasUnfinishedNode = (nodeId: string) => new CheckError(301, `存在尚未配置完成的节点: ${nodeId}`) 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 // @ts-ignore
export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nodes, edges, data) => { export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nodes, edges, data) => {
@@ -71,8 +92,20 @@ export const checkSave: (nodes: Node[], edges: Edge[], data: any) => void = (nod
} }
for (let node of nodes) { for (let node of nodes) {
if (!data[node.id] || !data[node.id]?.finished) { if (!has(data, node.id) || !data[node.id]?.finished) {
throw hasUnfinishedNode(node.id) 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

@@ -1,34 +1,21 @@
import {PlusCircleFilled, RollbackOutlined, SaveFilled} from '@ant-design/icons' import {RollbackOutlined, SaveFilled} from '@ant-design/icons'
import { import {Background, BackgroundVariant, Controls, MiniMap, Panel, ReactFlow} from '@xyflow/react'
Background, import {Button, message, Popconfirm, Space} from 'antd'
BackgroundVariant, import {arrToMap} from 'licia'
Controls, import {useEffect} from 'react'
type Edge, import {useNavigate} from 'react-router'
MiniMap,
type Node,
type NodeProps,
ReactFlow
} from '@xyflow/react'
import type {Schema} from 'amis'
import {Button, Drawer, Dropdown, message, Popconfirm, Space} from 'antd'
import {arrToMap, find, isEqual, isNil, randomId} from 'licia'
import {type JSX, type MemoExoticComponent, useEffect, useState} from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import '@xyflow/react/dist/style.css' import '@xyflow/react/dist/style.css'
import {amisRender, commonInfo, horizontalFormOptions} from '../../util/amis.tsx' import {commonInfo} from '../../util/amis.tsx'
import {checkAddConnection, checkAddNode, checkSave} from './FlowChecker.tsx' import AddNodeButton from './component/AddNodeButton.tsx'
import CodeNode from './node/CodeNode.tsx' import {checkAddConnection, checkSave} from './FlowChecker.tsx'
import OutputNode from './node/OutputNode.tsx' import {useNodeDrag} from './Helper.tsx'
import KnowledgeNode from './node/KnowledgeNode.tsx' import {NodeRegistryMap} from './NodeRegistry.tsx'
import LlmNode from './node/LlmNode.tsx'
import SwitchNode from './node/SwitchNode.tsx'
import {useDataStore} from './store/DataStore.ts' import {useDataStore} from './store/DataStore.ts'
import {useFlowStore} from './store/FlowStore.ts' import {useFlowStore} from './store/FlowStore.ts'
import {useNavigate} from 'react-router' import {flowDotColor, type FlowEditorProps} from './types.ts'
const FlowableDiv = styled.div` const FlowableDiv = styled.div`
height: 100%;
.react-flow__node.selectable { .react-flow__node.selectable {
&:focus { &:focus {
box-shadow: 0 0 20px 1px #e8e8e8; box-shadow: 0 0 20px 1px #e8e8e8;
@@ -48,13 +35,6 @@ const FlowableDiv = styled.div`
} }
} }
.toolbar {
position: absolute;
right: 20px;
top: 20px;
z-index: 10;
}
.node-card { .node-card {
cursor: default; cursor: default;
@@ -63,54 +43,12 @@ const FlowableDiv = styled.div`
} }
` `
export type GraphData = { nodes: Node[], edges: Edge[], data: any }
export type FlowEditorProps = {
graphData: GraphData,
onGraphDataChange: (graphData: GraphData) => void,
}
function FlowEditor(props: FlowEditorProps) { function FlowEditor(props: FlowEditorProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [messageApi, contextHolder] = message.useMessage()
const [nodeDef] = useState<{
key: string,
name: string,
component: MemoExoticComponent<(props: NodeProps) => JSX.Element>
}[]>([
{
key: 'output-node',
name: '输出',
component: OutputNode,
},
{
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 {data, setData} = useDataStore()
const { const {
nodes, nodes,
addNode,
removeNode,
setNodes, setNodes,
onNodesChange, onNodesChange,
edges, edges,
@@ -119,90 +57,6 @@ function FlowEditor(props: FlowEditorProps) {
onConnect, onConnect,
} = useFlowStore() } = 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,
finished: true,
}
)
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,
}
useEffect(() => { useEffect(() => {
// language=JSON // 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 = 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":"你也是个聪明人"}}}')
@@ -212,82 +66,21 @@ function FlowEditor(props: FlowEditorProps) {
let initialNodeData = props.graphData?.data ?? {} let initialNodeData = props.graphData?.data ?? {}
setData(initialNodeData) setData(initialNodeData)
for (let node of initialNodes) {
node.data = initialNodeHandlers
}
setNodes(initialNodes) setNodes(initialNodes)
setEdges(initialEdges) setEdges(initialEdges)
}, [props.graphData]) }, [props.graphData])
const {
onNodeDragStart,
onNodeDrag,
onNodeDragEnd,
} = useNodeDrag([props.graphData])
return ( return (
<FlowableDiv> <FlowableDiv className="h-full w-full">
{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>
<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
messageApi.error(e.toString())
}
}}>
<SaveFilled/>
</Button>
</Space>
<Drawer
title="节点编辑"
open={open}
closeIcon={false}
maskClosable={false}
destroyOnHidden
size="large"
>
{currentNodeForm}
</Drawer>
<ReactFlow <ReactFlow
className="rounded-xl"
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
@@ -301,19 +94,55 @@ function FlowEditor(props: FlowEditorProps) {
onConnect(connection) onConnect(connection)
} catch (e) { } catch (e) {
// @ts-ignore // @ts-ignore
messageApi.error(e.toString()) message.error(e.toString())
} }
}} }}
// @ts-ignore // @ts-ignore
nodeTypes={arrToMap( nodeTypes={arrToMap(Object.keys(NodeRegistryMap), key => NodeRegistryMap[key]!.component)}
nodeDef.map(def => def.key), onNodeDragStart={onNodeDragStart}
key => find(nodeDef, def => isEqual(key, def.key))!.component) onNodeDrag={onNodeDrag}
} onNodeDragStop={onNodeDragEnd}
onEdgesDelete={() => console.info('delete')}
fitView 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/> <Controls/>
<MiniMap/> <MiniMap/>
<Background variant={BackgroundVariant.Cross} gap={20} size={3}/> <Background
variant={BackgroundVariant.Cross}
gap={20}
size={3}
color={flowDotColor}
/>
</ReactFlow> </ReactFlow>
</FlowableDiv> </FlowableDiv>
) )

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

@@ -1,49 +1,59 @@
import {DeleteFilled, EditFilled} from '@ant-design/icons' import {CopyFilled, DeleteFilled, EditFilled} from '@ant-design/icons'
import {Handle, type HandleProps, type NodeProps, Position, useNodeConnections} from '@xyflow/react' import {type Edge, Handle, type Node, type NodeProps, NodeResizeControl, NodeToolbar, Position} from '@xyflow/react'
import type {Schema} from 'amis' import {type ClassName, classnames, type Schema} from 'amis'
import {Card, Dropdown} from 'antd' import {Button, Drawer, Space, Tooltip} from 'antd'
import {isEmpty, isEqual, isNil} from 'licia' import {type CSSProperties, type JSX, useCallback, useState} from 'react'
import {type JSX} from 'react' import styled from 'styled-components'
import {horizontalFormOptions} from '../../../util/amis.tsx' 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 type AmisNodeType = 'normal' | 'start' | 'end' export function inputsFormColumns(
nodeId: string,
export function inputsFormColumns(required: boolean = false, preload?: any): Schema[] { nodes: Node[],
edges: Edge[],
data: any,
): Schema[] {
return [ return [
{ {
type: 'input-kvs', type: 'input-kvs',
name: 'inputs', name: 'inputs',
label: '输入变量', label: '输入变量',
value: preload,
addButtonText: '新增输入', addButtonText: '新增输入',
draggable: false, draggable: false,
keyItem: { keyItem: {
...horizontalFormOptions(), ...horizontalFormOptions(),
label: '参数名称', label: '参数名称',
}, },
required: required,
valueItems: [ valueItems: [
{ {
...horizontalFormOptions(), ...horizontalFormOptions(),
type: 'select', type: 'select',
name: 'type', name: 'variable',
label: '变量', label: '变量',
required: true, required: true,
options: [], selectMode: 'group',
options: generateAllIncomerOutputVariablesFormOptions(
nodeId,
nodes,
edges,
data,
),
}, },
], ],
}, },
] ]
} }
export function outputsFormColumns(editable: boolean = false, required: boolean = false, preload?: any): Schema[] { export function outputsFormColumns(editable: boolean = false, required: boolean = false): Schema[] {
return [ return [
{ {
disabled: !editable, disabled: !editable,
type: 'input-kvs', type: 'input-kvs',
name: 'outputs', name: 'outputs',
label: '输出变量', label: '输出变量',
value: preload,
addButtonText: '新增输出', addButtonText: '新增输出',
draggable: false, draggable: false,
keyItem: { keyItem: {
@@ -59,140 +69,229 @@ export function outputsFormColumns(editable: boolean = false, required: boolean
label: '参数', label: '参数',
required: true, required: true,
selectFirst: true, selectFirst: true,
options: [ options: Object.keys(OutputVariableTypeMap).map(key => ({
{ // @ts-ignore
label: '文本', label: OutputVariableTypeMap[key],
value: 'string', value: key,
}, })),
{
label: '数字',
value: 'number',
},
{
label: '文本数组',
value: 'array-string',
},
{
label: '对象数组',
value: 'array-object',
},
],
}, },
], ],
}, },
] ]
} }
export const LimitHandler = (props: HandleProps & { limit: number }) => { type AmisNodeProps = {
const connections = useNodeConnections({ className: ClassName,
handleType: props.type, 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 ( return (
<Handle <>
{...props} <StartNodeHandler/>
isConnectable={connections.length < props.limit} <EndNodeHandler/>
/> </>
) )
} }
type AmisNodeProps = { export const nodeClassName = (name: string) => {
nodeProps: NodeProps return `flow-node flow-node-${name}`
type: AmisNodeType
defaultNodeName: String
defaultNodeDescription?: String
extraNodeDescription?: (nodeData: any) => JSX.Element
handlers?: (nodeData: any) => JSX.Element
columnSchema?: Schema[]
} }
const AmisNode: (props: AmisNodeProps) => JSX.Element = ({ const AmisNode: (props: AmisNodeProps) => JSX.Element = ({
className,
style,
nodeProps, nodeProps,
type,
defaultNodeName,
defaultNodeDescription,
extraNodeDescription, extraNodeDescription,
handlers, handler,
columnSchema, formSchema,
resize,
}) => { }) => {
const {id, data} = nodeProps const {removeNode} = useFlowStore()
const {getDataById, removeNode, editNode} = data const {getDataById, setDataById, removeDataById} = useDataStore()
const {id} = nodeProps
// @ts-ignore // @ts-ignore
const nodeData = getDataById(id) const nodeData = getDataById(id)
const nodeName = isEmpty(nodeData?.node?.name) ? defaultNodeName : nodeData.node.name const nodeName = nodeData?.node?.name ?? ''
const nodeDescription = isEmpty(nodeData?.node?.description) ? defaultNodeDescription : nodeData.node?.description const nodeDescription = nodeData?.node?.description ?? ''
return (
<div className="w-64"> const [editDrawerOpen, setEditDrawerOpen] = useState(false)
<Dropdown const [editDrawerForm, setEditDrawerForm] = useState<JSX.Element>(<></>)
className="card-container" const onOpenEditDrawerClick = useCallback(() => {
trigger={['contextMenu']} const schema = formSchema?.()
menu={{ setEditDrawerForm(
items: [ amisRender(
{
type: 'wrapper',
size: 'none',
body: [
{ {
key: 'edit', debug: commonInfo.debug,
label: '编辑', type: 'form',
icon: <EditFilled className="text-gray-600 hover:text-blue-500"/>, ...horizontalFormOptions(),
}, wrapWithPanel: false,
{ onEvent: {
key: 'remove', submitSucc: {
label: '删除', actions: [
icon: <DeleteFilled className="text-red-500 hover:text-red-500"/>, {
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',
},
],
},
],
}, },
], ],
onClick: menu => { },
switch (menu.key) { getDataById(id),
case 'edit': ),
// @ts-ignore )
editNode( setEditDrawerOpen(true)
id, }, [id])
[ const onRemoveClick = useCallback(() => {
{ removeNode(id)
type: 'input-text', removeDataById(id)
name: 'node.name', }, [])
label: '节点名称', return (
placeholder: nodeName, <AmisNodeContainerDiv className={classnames(className, 'w-64')} style={style}>
}, <Drawer
{ title="节点编辑"
type: 'textarea', open={editDrawerOpen}
name: 'node.description', closeIcon={false}
label: '节点描述', maskClosable={false}
placeholder: nodeDescription, destroyOnHidden
}, size="large"
{
type: 'divider',
},
...(columnSchema ?? []),
],
)
break
case 'remove':
// @ts-ignore
removeNode(id)
break
}
},
}}
> >
<Card {editDrawerForm}
className="node-card" </Drawer>
title={nodeName} <NodeToolbar>
extra={<span className="text-gray-300 text-xs">{id}</span>} <Space>
size="small" <Tooltip title="复制节点">
> <Button
<div className="card-description p-2 text-secondary text-sm"> 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} {nodeDescription}
{extraNodeDescription?.(nodeData)}
</div> </div>
</Card> <div className="node-card-description-extra flex-1 mt-1">
</Dropdown> {extraNodeDescription}
{isNil(handlers) </div>
? <> </div>
{isEqual(type, 'start') || isEqual(type, 'normal') </div>
? <Handle type="source" position={Position.Right} id="source"/> : undefined} {resize ? <>
{isEqual(type, 'end') || isEqual(type, 'normal') <NodeResizeControl
? <Handle type="target" position={Position.Left} id="target"/> : undefined} minWidth={resize.minWidth}
</> minHeight={resize.minHeight}
: handlers?.(nodeData)} />
</div> </> : undefined}
{handler}
</AmisNodeContainerDiv>
) )
} }

View File

@@ -1,52 +1,72 @@
import type {NodeProps} from '@xyflow/react' import type {NodeProps} from '@xyflow/react'
import AmisNode, {inputsFormColumns, outputsFormColumns} from './AmisNode.tsx' import {Tag} from 'antd'
import React from 'react' 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 CodeNode = (props: NodeProps) => AmisNode({ const languageMap: Record<string, string> = {
nodeProps: props, 'javascript': 'Javascript',
type: 'normal', 'python': 'Python',
defaultNodeName: '代码执行', 'Lua': 'lua',
defaultNodeDescription: '执行自定义的处理代码', }
columnSchema: [
...inputsFormColumns(), const CodeNode = (props: NodeProps) => {
{ const {getNodes, getEdges} = useFlowStore()
type: 'divider', const {getData, getDataById} = useDataStore()
},
{ const nodeData = getDataById(props.id)
type: 'select',
name: 'type', const formSchema: () => FormSchema = useCallback(() => ({
label: '代码类型', columns: [
required: true, ...inputsFormColumns(props.id, getNodes(), getEdges(), getData()),
options: [ {
{ type: 'divider',
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: 'select',
type: 'divider', name: 'type',
}, label: '代码类型',
...outputsFormColumns(true, true, {result: {type: 'string'}}), 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) 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

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

View File

@@ -1,51 +1,80 @@
import type {NodeProps} from '@xyflow/react' import type {NodeProps} from '@xyflow/react'
import {Tag} from 'antd' import {Tag} from 'antd'
import AmisNode, {inputsFormColumns, outputsFormColumns} from './AmisNode.tsx' import React, {useCallback, useEffect, useMemo} from 'react'
import React 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> = { const modelMap: Record<string, string> = {
qwen3: 'Qwen3', qwen3: 'Qwen3',
deepseek: 'Deepseek', deepseek: 'Deepseek',
} }
const LlmNode = (props: NodeProps) => AmisNode({ const LlmNode = (props: NodeProps) => {
nodeProps: props, const {getNodes, getEdges} = useFlowStore()
type: 'normal', const {getData, mergeDataById, getDataById} = useDataStore()
defaultNodeName: '大模型',
defaultNodeDescription: '使用大模型对话', const nodeData = getDataById(props.id)
extraNodeDescription: nodeData => {
const model = nodeData?.model as string | undefined useEffect(() => {
return model 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"> ? <div className="mt-2 flex justify-between">
<span></span> <span></span>
<Tag className="m-0" color="blue">{modelMap[model]}</Tag> <Tag className="m-0" color="blue">{modelMap[nodeData.model]}</Tag>
</div> </div>
: <></> : <></>
}, }, [nodeData])
columnSchema: [
...inputsFormColumns(), return (
{ <AmisNode
type: 'divider', className={nodeClassName('llm')}
}, nodeProps={props}
{ extraNodeDescription={extraNodeDescription}
type: 'select', formSchema={formSchema}
name: 'model', handler={<NormalNodeHandler/>}
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) 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

@@ -1,13 +1,26 @@
import type {NodeProps} from '@xyflow/react' import type {NodeProps} from '@xyflow/react'
import AmisNode, {outputsFormColumns} from './AmisNode.tsx' import React, {useCallback} from 'react'
import React 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) => AmisNode({ const OutputNode = (props: NodeProps) => {
nodeProps: props, const {getNodes, getEdges} = useFlowStore()
type: 'end', const {getData} = useDataStore()
defaultNodeName: '输出节点',
defaultNodeDescription: '定义输出变量', const formSchema: () => FormSchema = useCallback(() => ({
columnSchema: outputsFormColumns(true), 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) export default React.memo(OutputNode)

View File

@@ -1,55 +1,97 @@
import {Handle, type NodeProps, Position} from '@xyflow/react' import {Handle, type NodeProps, Position} from '@xyflow/react'
import type {ConditionValue} from 'amis'
import {Tag} from 'antd' import {Tag} from 'antd'
import React from 'react' import {contain, isEqual} from 'licia'
import AmisNode from './AmisNode.tsx' 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 cases = [ const SwitchNode = (props: NodeProps) => {
{ const {getNodes, getEdges, removeEdges} = useFlowStore()
index: 1, const {getData, getDataById} = useDataStore()
},
{
index: 2,
},
{
index: 3,
},
]
const SwitchNode = (props: NodeProps) => AmisNode({ const nodeData = getDataById(props.id)
nodeProps: props,
type: 'normal',
defaultNodeName: '分支节点',
defaultNodeDescription: '根据不同的情况前往不同的分支',
columnSchema: [],
// @ts-ignore // @ts-ignore
extraNodeDescription: nodeData => { 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 ( return (
<div className="mt-2"> <div className="mt-2">
{cases.map(item => ( {conditions.map((item, index) => (
<div key={item.index} className="mt-1"> <div key={item.id} className="mt-1">
<Tag className="m-0" color="blue"> {item.index}</Tag> <Tag className="m-0" color="blue"> {index + 1}</Tag>
</div> </div>
))} ))}
</div> </div>
) )
}, }, [nodeData])
// @ts-ignore
handlers: 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 ( return (
<> <>
<Handle type="target" position={Position.Left}/> <Handle type="target" position={Position.Left}/>
{cases.map((item, index) => ( {conditions.map((item, index) => (
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
key={item.index} key={item.id}
id={`${item.index}`} id={item.id}
style={{top: 85 + (25 * index)}} style={{top: 91 + (26 * index)}}
/> />
))} ))}
</> </>
) )
}, }, [nodeData])
})
return (
<AmisNode
className={nodeClassName('switch')}
nodeProps={props}
extraNodeDescription={extraNodeDescription}
formSchema={formSchema}
handler={handler}
/>
)
}
export default React.memo(SwitchNode) 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

@@ -21,6 +21,7 @@ const ProLayoutDiv = styled.div`
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[] = [
{ {

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,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

@@ -1,9 +1,8 @@
import {isEmpty, isEqual} from 'licia' import {isEqual} from 'licia'
import React from 'react' import React from 'react'
import {useParams} from 'react-router' 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'
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 (
@@ -38,27 +38,13 @@ const FlowTaskTemplateEdit: React.FC = () => {
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, '入参表单预览'),
},
},
})
}
}, },
}, },
], ],
@@ -87,71 +73,6 @@ const FlowTaskTemplateEdit: React.FC = () => {
maxLength: 500, maxLength: 500,
showCounter: true, 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',
buttons: [ buttons: [

View File

@@ -1,10 +1,11 @@
import React, {useState} from 'react'
import styled from 'styled-components'
import {useNavigate, useParams} from 'react-router'
import {useMount} from 'ahooks' import {useMount} from 'ahooks'
import axios from 'axios' 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' import {commonInfo} from '../../../../util/amis.tsx'
import FlowEditor, {type GraphData} from '../../../../components/flow/FlowEditor.tsx'
const FlowTaskTemplateFlowEditDiv = styled.div` const FlowTaskTemplateFlowEditDiv = styled.div`
` `
@@ -18,8 +19,8 @@ const FlowTaskTemplateFlowEdit: React.FC = () => {
let {data} = await axios.get( let {data} = await axios.get(
`${commonInfo.baseAiUrl}/flow_task/template/detail/${template_id}`, `${commonInfo.baseAiUrl}/flow_task/template/detail/${template_id}`,
{ {
headers: commonInfo.authorizationHeaders headers: commonInfo.authorizationHeaders,
} },
) )
setGraphData(data?.data?.flowGraph) setGraphData(data?.data?.flowGraph)
}) })
@@ -33,11 +34,11 @@ const FlowTaskTemplateFlowEdit: React.FC = () => {
`${commonInfo.baseAiUrl}/flow_task/template/update_flow_graph`, `${commonInfo.baseAiUrl}/flow_task/template/update_flow_graph`,
{ {
id: template_id, id: template_id,
graph: data graph: data,
}, },
{ {
headers: commonInfo.authorizationHeaders headers: commonInfo.authorizationHeaders,
} },
) )
navigate(-1) navigate(-1)
}} }}

View File

@@ -2519,7 +2519,7 @@ export function pictureFromIds(field: string) {
return `\${ARRAYMAP(${field},id => '${commonInfo.baseAiUrl}/upload/download/' + id)}` return `\${ARRAYMAP(${field},id => '${commonInfo.baseAiUrl}/upload/download/' + id)}`
} }
const formInputFileStaticColumns = [ export const formInputFileStaticColumns = [
{ {
name: 'filename', name: 'filename',
label: '文件名', label: '文件名',
@@ -2533,7 +2533,7 @@ const formInputFileStaticColumns = [
type: 'action', type: 'action',
label: '预览', label: '预览',
level: 'link', level: 'link',
icon: 'fas fa-eye' icon: 'fas fa-eye',
}, },
{ {
type: 'action', type: 'action',
@@ -2542,12 +2542,12 @@ const formInputFileStaticColumns = [
icon: 'fa fa-download', icon: 'fa fa-download',
actionType: 'ajax', actionType: 'ajax',
// api: { // api: {
// ...apiGet('${base}/upload/download/${id}'), // ...apiGet('${base}/upload/download/${id}'),
// responseType: 'blob', // responseType: 'blob',
// } // }
} },
] ],
} },
] ]
export function formInputSingleFileStatic(field: string, label: string) { export function formInputSingleFileStatic(field: string, label: string) {