tycoding преди 1 година
родител
ревизия
1e1662ce57
променени са 49 файла, в които са добавени 3489 реда и са изтрити 5 реда
  1. 31 0
      langchat-flow/pom.xml
  2. 95 0
      langchat-flow/src/main/java/cn/tycoding/langchat/flow/controller/AigcFlowController.java
  3. 71 0
      langchat-flow/src/main/java/cn/tycoding/langchat/flow/entity/AigcFlow.java
  4. 53 0
      langchat-flow/src/main/java/cn/tycoding/langchat/flow/entity/AigcFlowScript.java
  5. 15 0
      langchat-flow/src/main/java/cn/tycoding/langchat/flow/mapper/AigcFlowMapper.java
  6. 14 0
      langchat-flow/src/main/java/cn/tycoding/langchat/flow/service/AigcFlowService.java
  7. 19 0
      langchat-flow/src/main/java/cn/tycoding/langchat/flow/service/impl/AigcFlowServiceImpl.java
  8. 4 0
      langchat-server/pom.xml
  9. 227 4
      langchat-ui/package-lock.json
  10. 7 0
      langchat-ui/package.json
  11. 317 0
      langchat-ui/pnpm-lock.yaml
  12. 74 0
      langchat-ui/src/api/aigc/flow.ts
  13. 31 0
      langchat-ui/src/router/base.ts
  14. 167 0
      langchat-ui/src/styles/flow.less
  15. 7 0
      langchat-ui/src/styles/index.less
  16. 52 0
      langchat-ui/src/views/flow/coding.vue
  17. 124 0
      langchat-ui/src/views/flow/columns.ts
  18. 56 0
      langchat-ui/src/views/flow/custom/CustomEdge.vue
  19. 181 0
      langchat-ui/src/views/flow/custom/CustomExec.vue
  20. 127 0
      langchat-ui/src/views/flow/custom/CustomZoom.vue
  21. 56 0
      langchat-ui/src/views/flow/edit.vue
  22. 184 0
      langchat-ui/src/views/flow/index.vue
  23. 105 0
      langchat-ui/src/views/flow/initialize/FlowTemplate.vue
  24. 27 0
      langchat-ui/src/views/flow/initialize/index.ts
  25. 63 0
      langchat-ui/src/views/flow/layout/CardLayout.vue
  26. 107 0
      langchat-ui/src/views/flow/layout/GraphLayout.vue
  27. 65 0
      langchat-ui/src/views/flow/layout/GraphMenuLayout.vue
  28. 60 0
      langchat-ui/src/views/flow/layout/Layout.vue
  29. 102 0
      langchat-ui/src/views/flow/layout/PinLayout.vue
  30. 66 0
      langchat-ui/src/views/flow/layout/components/NodeCard.vue
  31. 63 0
      langchat-ui/src/views/flow/layout/components/PluginCard.vue
  32. 14 0
      langchat-ui/src/views/flow/node/AssistNode.vue
  33. 89 0
      langchat-ui/src/views/flow/node/LLMNode.vue
  34. 186 0
      langchat-ui/src/views/flow/node/NodeLayout.vue
  35. 39 0
      langchat-ui/src/views/flow/node/common/End.vue
  36. 43 0
      langchat-ui/src/views/flow/node/common/Start.vue
  37. 7 0
      langchat-ui/src/views/flow/node/index.ts
  38. 34 0
      langchat-ui/src/views/flow/pin/BlankPin.vue
  39. 9 0
      langchat-ui/src/views/flow/pin/index.ts
  40. 36 0
      langchat-ui/src/views/flow/pin/node/AssistPin.vue
  41. 7 0
      langchat-ui/src/views/flow/pin/node/EndPin.vue
  42. 36 0
      langchat-ui/src/views/flow/pin/node/LLMPin.vue
  43. 7 0
      langchat-ui/src/views/flow/pin/node/StartPin.vue
  44. 113 0
      langchat-ui/src/views/flow/pin/plugin/KnowledgeDoc.vue
  45. 21 0
      langchat-ui/src/views/flow/pin/plugin/KnowledgeWeb.vue
  46. 207 0
      langchat-ui/src/views/flow/store/get.ts
  47. 62 0
      langchat-ui/src/views/flow/store/index.ts
  48. 3 1
      langchat-ui/src/views/upms/menu/edit.vue
  49. 6 0
      pom.xml

+ 31 - 0
langchat-flow/pom.xml

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.tycoding</groupId>
+        <artifactId>langchat</artifactId>
+        <version>${revision}</version>
+        <relativePath>../pom.xml</relativePath>
+    </parent>
+
+    <artifactId>langchat-flow</artifactId>
+    <version>${revision}</version>
+
+    <dependencies>
+        <dependency>
+            <groupId>cn.tycoding</groupId>
+            <artifactId>langchat-upms</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>cn.tycoding</groupId>
+            <artifactId>langchat-aigc</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.sun.mail</groupId>
+            <artifactId>javax.mail</artifactId>
+            <version>1.6.2</version>
+        </dependency>
+    </dependencies>
+
+</project>

+ 95 - 0
langchat-flow/src/main/java/cn/tycoding/langchat/flow/controller/AigcFlowController.java

@@ -0,0 +1,95 @@
+package cn.tycoding.langchat.flow.controller;
+
+import cn.tycoding.langchat.common.exception.ServiceException;
+import cn.tycoding.langchat.common.utils.MybatisUtil;
+import cn.tycoding.langchat.common.utils.QueryPage;
+import cn.tycoding.langchat.common.utils.R;
+import cn.tycoding.langchat.flow.entity.AigcFlow;
+import cn.tycoding.langchat.flow.service.AigcFlowService;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * @author tycoding
+ * @since 2024/6/17
+ */
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/langchat/flow")
+public class AigcFlowController {
+
+    private final AigcFlowService flowService;
+//    private final FlowExecutor flowExecutor;
+//    private final LcAppService appService;
+
+    @GetMapping("/list")
+    public R<List<AigcFlow>> list(AigcFlow data) {
+        return R.ok(flowService.list(Wrappers.<AigcFlow>lambdaQuery()
+                .eq(AigcFlow::getIsPublish, true)
+                .orderByDesc(AigcFlow::getCreateTime)
+        ));
+    }
+
+    @GetMapping("/page")
+    public R list(AigcFlow data, QueryPage queryPage) {
+        Page<AigcFlow> page = new Page<>(queryPage.getPage(), queryPage.getLimit());
+        return R.ok(MybatisUtil.getData(flowService.page(page, Wrappers.<AigcFlow>lambdaQuery()
+                .orderByDesc(AigcFlow::getCreateTime)
+        )));
+    }
+
+    @GetMapping("/{id}")
+    public R<AigcFlow> findById(@PathVariable String id) {
+        return R.ok(flowService.getById(id));
+    }
+
+    @PostMapping
+    public R add(@RequestBody AigcFlow data) {
+        data.setCreateTime(new Date());
+        flowService.save(data);
+        return R.ok(data);
+    }
+
+    @PutMapping
+    public R update(@RequestBody AigcFlow data) {
+        data.setUpdateTime(new Date());
+        flowService.updateById(data);
+        return R.ok();
+    }
+
+    @DeleteMapping("/{id}")
+    public R delete(@PathVariable String id) {
+        flowService.removeById(id);
+        return R.ok();
+    }
+
+    @PutMapping("/publish")
+    public R publish(@RequestBody AigcFlow data) {
+        if (data.getId() == null) {
+            throw new ServiceException("Flow数据异常");
+        }
+        if (data.getFlow() == null) {
+            data = flowService.getById(data.getId());
+        }
+
+//        ElParser parser = new ElParser(data.getFlow());
+//        String script = parser.genEl();
+//        log.info("EL解析脚本:\n{}", script);
+//
+//        data.setPublishTime(new Date());
+//        flowService.updateById(new LcFlow().setId(data.getId()).setScript(script));
+//        flowExecutor.reloadRule();
+//
+//        // 更新应用数据
+//        appService.update(Wrappers.<LcApp>lambdaUpdate().eq(LcApp::getFlowId, data.getId()).eq(LcApp::getFlowScript, script));
+        return R.ok();
+    }
+}
+

+ 71 - 0
langchat-flow/src/main/java/cn/tycoding/langchat/flow/entity/AigcFlow.java

@@ -0,0 +1,71 @@
+package cn.tycoding.langchat.flow.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * @author tycoding
+ * @since 2024/6/17
+ */
+@Data
+@Accessors(chain = true)
+public class AigcFlow implements Serializable {
+    private static final long serialVersionUID = -94320446004505219L;
+
+    /**
+     * 主键
+     */
+    @TableId(type = IdType.ASSIGN_UUID)
+    private String id;
+
+    /**
+     * 模型名称
+     */
+    private String name;
+
+    /**
+     * 流程图
+     */
+    private String flow;
+
+    /**
+     * Flow Chain EL脚本
+     */
+    private String script;
+
+    /**
+     * Flow类型
+     */
+    private String flowType = "type";
+
+    /**
+     * 描述
+     */
+    private String des;
+
+    /**
+     * 是否发布
+     */
+    private Boolean isPublish = false;
+
+    /**
+     * 创建时间
+     */
+    private Date createTime;
+
+    /**
+     * 发布时间
+     */
+    private Date publishTime;
+
+    /**
+     * 更新时间
+     */
+    private Date updateTime;
+}
+

+ 53 - 0
langchat-flow/src/main/java/cn/tycoding/langchat/flow/entity/AigcFlowScript.java

@@ -0,0 +1,53 @@
+package cn.tycoding.langchat.flow.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * @author tycoding
+ * @since 2024/6/17
+ */
+@Data
+public class AigcFlowScript implements Serializable {
+    private static final long serialVersionUID = -94320446004505219L;
+
+    /**
+     * 主键
+     */
+    @TableId(type = IdType.ASSIGN_UUID)
+    private String id;
+
+    /**
+     * Flow类型
+     */
+    private String flowType;
+
+    /**
+     * 脚本ID
+     */
+    private String scriptId;
+
+    /**
+     * 脚本类型
+     */
+    private String scriptType;
+
+    /**
+     * 脚本名称
+     */
+    private String flowName;
+
+    /**
+     * 脚本内容
+     */
+    private String scriptData;
+
+    /**
+     * 创建时间
+     */
+    private String createTime;
+}
+

+ 15 - 0
langchat-flow/src/main/java/cn/tycoding/langchat/flow/mapper/AigcFlowMapper.java

@@ -0,0 +1,15 @@
+package cn.tycoding.langchat.flow.mapper;
+
+import cn.tycoding.langchat.flow.entity.AigcFlow;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+/**
+ * @author tycoding
+ * @since 2024/6/17
+ */
+@Mapper
+public interface AigcFlowMapper extends BaseMapper<AigcFlow> {
+
+}
+

+ 14 - 0
langchat-flow/src/main/java/cn/tycoding/langchat/flow/service/AigcFlowService.java

@@ -0,0 +1,14 @@
+package cn.tycoding.langchat.flow.service;
+
+
+import cn.tycoding.langchat.flow.entity.AigcFlow;
+import com.baomidou.mybatisplus.extension.service.IService;
+
+/**
+ * @author tycoding
+ * @since 2024/6/17
+ */
+public interface AigcFlowService extends IService<AigcFlow> {
+
+}
+

+ 19 - 0
langchat-flow/src/main/java/cn/tycoding/langchat/flow/service/impl/AigcFlowServiceImpl.java

@@ -0,0 +1,19 @@
+package cn.tycoding.langchat.flow.service.impl;
+
+import cn.tycoding.langchat.flow.entity.AigcFlow;
+import cn.tycoding.langchat.flow.mapper.AigcFlowMapper;
+import cn.tycoding.langchat.flow.service.AigcFlowService;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+/**
+ * @author tycoding
+ * @since 2024/6/17
+ */
+@Service
+@RequiredArgsConstructor
+public class AigcFlowServiceImpl extends ServiceImpl<AigcFlowMapper, AigcFlow> implements AigcFlowService {
+
+}
+

+ 4 - 0
langchat-server/pom.xml

@@ -23,6 +23,10 @@
             <groupId>cn.tycoding</groupId>
             <artifactId>langchat-auth</artifactId>
         </dependency>
+        <dependency>
+            <groupId>cn.tycoding</groupId>
+            <artifactId>langchat-flow</artifactId>
+        </dependency>
     </dependencies>
 
     <build>

+ 227 - 4
langchat-ui/package-lock.json

@@ -13,6 +13,9 @@
         "@types/uuid": "^9.0.2",
         "@vicons/antd": "^0.12.0",
         "@vicons/ionicons5": "^0.12.0",
+        "@vue-flow/background": "^1.3.0",
+        "@vue-flow/core": "^1.36.0",
+        "@vue-flow/minimap": "^1.5.0",
         "@vueuse/core": "^9.13.0",
         "axios": "^1.4.0",
         "blueimp-md5": "^2.19.0",
@@ -25,7 +28,7 @@
         "markdown-it-link-attributes": "^4.0.1",
         "mitt": "^3.0.1",
         "mockjs": "^1.1.0",
-        "naive-ui": "^2.36.0",
+        "naive-ui": "^2.38.2",
         "pinia": "^2.1.6",
         "qs": "^6.11.2",
         "uuid": "^9.0.0",
@@ -2662,6 +2665,130 @@
         "vue": "^3.0.0"
       }
     },
+    "node_modules/@vue-flow/background": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/@vue-flow/background/-/background-1.3.0.tgz",
+      "integrity": "sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==",
+      "peerDependencies": {
+        "@vue-flow/core": "^1.23.0",
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/@vue-flow/core": {
+      "version": "1.36.0",
+      "resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.36.0.tgz",
+      "integrity": "sha512-Cu19fe0AGux3q/jkf5IZEElJUSwMMMLB0wqNygbO5A/LG2HC9cCVkavJbn851KaoD07U5/SsHznoUClnGz34pw==",
+      "dependencies": {
+        "@vueuse/core": "^10.5.0",
+        "d3-drag": "^3.0.0",
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@types/web-bluetooth": {
+      "version": "0.0.20",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
+      "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/core": {
+      "version": "10.11.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.0.tgz",
+      "integrity": "sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.20",
+        "@vueuse/metadata": "10.11.0",
+        "@vueuse/shared": "10.11.0",
+        "vue-demi": ">=0.14.8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/core/node_modules/vue-demi": {
+      "version": "0.14.8",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.8.tgz",
+      "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
+      "hasInstallScript": true,
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/metadata": {
+      "version": "10.11.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.0.tgz",
+      "integrity": "sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/shared": {
+      "version": "10.11.0",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.0.tgz",
+      "integrity": "sha512-fyNoIXEq3PfX1L3NkNhtVQUSRtqYwJtJg+Bp9rIzculIZWHTkKSysujrOk2J+NrRulLTQH9+3gGSfYLWSEWU1A==",
+      "dependencies": {
+        "vue-demi": ">=0.14.8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vue-flow/core/node_modules/@vueuse/shared/node_modules/vue-demi": {
+      "version": "0.14.8",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.8.tgz",
+      "integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
+      "hasInstallScript": true,
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue-flow/minimap": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/@vue-flow/minimap/-/minimap-1.5.0.tgz",
+      "integrity": "sha512-JhxXDF+8uTc7sgkZHDIvFpHqSl4wsK9xp8Kz5OHwNcXlgGcwqj4yad6jcc1B6bGxm+huESpNmoPotQbpMn6rVw==",
+      "dependencies": {
+        "d3-selection": "^3.0.0",
+        "d3-zoom": "^3.0.0"
+      },
+      "peerDependencies": {
+        "@vue-flow/core": "^1.23.0",
+        "vue": "^3.3.0"
+      }
+    },
     "node_modules/@vue/babel-helper-vue-transform-on": {
       "version": "1.2.2",
       "resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.2.2.tgz",
@@ -4348,6 +4475,102 @@
         "node": ">=4"
       }
     },
+    "node_modules/d3-color": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/d3-color/-/d3-color-3.1.0.tgz",
+      "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-dispatch": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
+      "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-drag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-drag/-/d3-drag-3.0.0.tgz",
+      "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-selection": "3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-ease": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-ease/-/d3-ease-3.0.1.tgz",
+      "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-interpolate": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+      "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+      "dependencies": {
+        "d3-color": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-selection": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-selection/-/d3-selection-3.0.0.tgz",
+      "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-timer": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-timer/-/d3-timer-3.0.1.tgz",
+      "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/d3-transition": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmmirror.com/d3-transition/-/d3-transition-3.0.1.tgz",
+      "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
+      "dependencies": {
+        "d3-color": "1 - 3",
+        "d3-dispatch": "1 - 3",
+        "d3-ease": "1 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-timer": "1 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "d3-selection": "2 - 3"
+      }
+    },
+    "node_modules/d3-zoom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmmirror.com/d3-zoom/-/d3-zoom-3.0.0.tgz",
+      "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
+      "dependencies": {
+        "d3-dispatch": "1 - 3",
+        "d3-drag": "2 - 3",
+        "d3-interpolate": "1 - 3",
+        "d3-selection": "2 - 3",
+        "d3-transition": "2 - 3"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
     "node_modules/dargs": {
       "version": "7.0.0",
       "resolved": "https://registry.npmmirror.com/dargs/-/dargs-7.0.0.tgz",
@@ -8606,9 +8829,9 @@
       }
     },
     "node_modules/naive-ui": {
-      "version": "2.38.1",
-      "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.38.1.tgz",
-      "integrity": "sha512-AnU1FQ7K/CbhguAX++V4kCFjk7h7RvWt4nvZPRjORMpq+fUIlzD+EcQ5Cv1VqDloNF8+eMv4Akc2Ogacc9S+5A==",
+      "version": "2.38.2",
+      "resolved": "https://registry.npmmirror.com/naive-ui/-/naive-ui-2.38.2.tgz",
+      "integrity": "sha512-WhZ+6DW61aYSmFyfH7evcSGFmd2xR68Yq1mNRrVdJwBhZsnNdAUsMN9IeNCVEPMCND/jzYZghkStoNoR5Xa09g==",
       "dependencies": {
         "@css-render/plugin-bem": "^0.15.12",
         "@css-render/vue3-ssr": "^0.15.12",

+ 7 - 0
langchat-ui/package.json

@@ -28,11 +28,17 @@
     "test prod gzip": "http-server dist --cors --gzip -c-1"
   },
   "dependencies": {
+    "@codemirror/lang-javascript": "^6.2.2",
+    "@codemirror/theme-one-dark": "^6.1.2",
     "@iconify/vue": "^4.1.1",
+    "@rmp135/vue-splitter": "^2.0.2",
     "@traptitech/markdown-it-katex": "^3.6.0",
     "@types/uuid": "^9.0.2",
     "@vicons/antd": "^0.12.0",
     "@vicons/ionicons5": "^0.12.0",
+    "@vue-flow/background": "^1.3.0",
+    "@vue-flow/core": "^1.36.0",
+    "@vue-flow/minimap": "^1.5.0",
     "@vueuse/core": "^9.13.0",
     "axios": "^1.4.0",
     "blueimp-md5": "^2.19.0",
@@ -51,6 +57,7 @@
     "uuid": "^9.0.0",
     "vfonts": "^0.0.3",
     "vue": "^3.3.4",
+    "vue-codemirror": "^6.1.1",
     "vue-router": "^4.2.4",
     "vue-types": "^4.2.1",
     "vue3-tree-org": "^4.2.2"

+ 317 - 0
langchat-ui/pnpm-lock.yaml

@@ -5,9 +5,18 @@ settings:
   excludeLinksFromLockfile: false
 
 dependencies:
+  '@codemirror/lang-javascript':
+    specifier: ^6.2.2
+    version: 6.2.2
+  '@codemirror/theme-one-dark':
+    specifier: ^6.1.2
+    version: 6.1.2
   '@iconify/vue':
     specifier: ^4.1.1
     version: 4.1.1(vue@3.3.4)
+  '@rmp135/vue-splitter':
+    specifier: ^2.0.2
+    version: 2.0.2
   '@traptitech/markdown-it-katex':
     specifier: ^3.6.0
     version: 3.6.0
@@ -20,6 +29,15 @@ dependencies:
   '@vicons/ionicons5':
     specifier: ^0.12.0
     version: 0.12.0
+  '@vue-flow/background':
+    specifier: ^1.3.0
+    version: 1.3.0(@vue-flow/core@1.36.0)(vue@3.3.4)
+  '@vue-flow/core':
+    specifier: ^1.36.0
+    version: 1.36.0(vue@3.3.4)
+  '@vue-flow/minimap':
+    specifier: ^1.5.0
+    version: 1.5.0(@vue-flow/core@1.36.0)(vue@3.3.4)
   '@vueuse/core':
     specifier: ^9.13.0
     version: 9.13.0(vue@3.3.4)
@@ -74,6 +92,9 @@ dependencies:
   vue:
     specifier: ^3.3.4
     version: 3.3.4
+  vue-codemirror:
+    specifier: ^6.1.1
+    version: 6.1.1(codemirror@6.0.1)(vue@3.3.4)
   vue-router:
     specifier: ^4.2.4
     version: 4.2.4(vue@3.3.4)
@@ -660,6 +681,89 @@ packages:
     resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
     dev: true
 
+  /@codemirror/autocomplete@6.16.2(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(@lezer/common@1.2.1):
+    resolution: {integrity: sha512-MjfDrHy0gHKlPWsvSsikhO1+BOh+eBHNgfH1OXs1+DAf30IonQldgMM3kxLDTG9ktE7kDLaA1j/l7KMPA4KNfw==}
+    peerDependencies:
+      '@codemirror/language': ^6.0.0
+      '@codemirror/state': ^6.0.0
+      '@codemirror/view': ^6.0.0
+      '@lezer/common': ^1.0.0
+    dependencies:
+      '@codemirror/language': 6.10.2
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      '@lezer/common': 1.2.1
+    dev: false
+
+  /@codemirror/commands@6.6.0:
+    resolution: {integrity: sha512-qnY+b7j1UNcTS31Eenuc/5YJB6gQOzkUoNmJQc0rznwqSRpeaWWpjkWy2C/MPTcePpsKJEM26hXrOXl1+nceXg==}
+    dependencies:
+      '@codemirror/language': 6.10.2
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      '@lezer/common': 1.2.1
+    dev: false
+
+  /@codemirror/lang-javascript@6.2.2:
+    resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==}
+    dependencies:
+      '@codemirror/autocomplete': 6.16.2(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(@lezer/common@1.2.1)
+      '@codemirror/language': 6.10.2
+      '@codemirror/lint': 6.8.0
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      '@lezer/common': 1.2.1
+      '@lezer/javascript': 1.4.17
+    dev: false
+
+  /@codemirror/language@6.10.2:
+    resolution: {integrity: sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==}
+    dependencies:
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      '@lezer/common': 1.2.1
+      '@lezer/highlight': 1.2.0
+      '@lezer/lr': 1.4.1
+      style-mod: 4.1.2
+    dev: false
+
+  /@codemirror/lint@6.8.0:
+    resolution: {integrity: sha512-lsFofvaw0lnPRJlQylNsC4IRt/1lI4OD/yYslrSGVndOJfStc58v+8p9dgGiD90ktOfL7OhBWns1ZETYgz0EJA==}
+    dependencies:
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      crelt: 1.0.6
+    dev: false
+
+  /@codemirror/search@6.5.6:
+    resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
+    dependencies:
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      crelt: 1.0.6
+    dev: false
+
+  /@codemirror/state@6.4.1:
+    resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==}
+    dev: false
+
+  /@codemirror/theme-one-dark@6.1.2:
+    resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==}
+    dependencies:
+      '@codemirror/language': 6.10.2
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      '@lezer/highlight': 1.2.0
+    dev: false
+
+  /@codemirror/view@6.28.1:
+    resolution: {integrity: sha512-BUWr+zCJpMkA/u69HlJmR+YkV4yPpM81HeMkOMZuwFa8iM5uJdEPKAs1icIRZKkKmy0Ub1x9/G3PQLTXdpBxrQ==}
+    dependencies:
+      '@codemirror/state': 6.4.1
+      style-mod: 4.1.2
+      w3c-keyname: 2.2.8
+    dev: false
+
   /@commitlint/cli@17.7.0(@types/node@18.17.4)(typescript@4.9.5):
     resolution: {integrity: sha512-28PNJaGuBQZNoz3sd+6uO3b4+5PY+vWzyBfy5JOvFB7QtoZVXf2FYTQs5VO1cn7yAd3y9/0Rx0x6Vx82W/zhuA==}
     engines: {node: '>=v14'}
@@ -1440,6 +1544,30 @@ packages:
     resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
     dev: false
 
+  /@lezer/common@1.2.1:
+    resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==}
+    dev: false
+
+  /@lezer/highlight@1.2.0:
+    resolution: {integrity: sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==}
+    dependencies:
+      '@lezer/common': 1.2.1
+    dev: false
+
+  /@lezer/javascript@1.4.17:
+    resolution: {integrity: sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==}
+    dependencies:
+      '@lezer/common': 1.2.1
+      '@lezer/highlight': 1.2.0
+      '@lezer/lr': 1.4.1
+    dev: false
+
+  /@lezer/lr@1.4.1:
+    resolution: {integrity: sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==}
+    dependencies:
+      '@lezer/common': 1.2.1
+    dev: false
+
   /@nodelib/fs.scandir@2.1.5:
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -1461,6 +1589,10 @@ packages:
       fastq: 1.15.0
     dev: true
 
+  /@rmp135/vue-splitter@2.0.2:
+    resolution: {integrity: sha512-0MuNq56QDpJg2wkpFpo+NvS5xnQQwz5Tc7Wh4sNIumj+gAI5mdj0P11LA7nSDkVRVVY6Tl6ExaGkGk5loAvjkg==}
+    dev: false
+
   /@rollup/pluginutils@4.2.1:
     resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==}
     engines: {node: '>= 8.0.0'}
@@ -1636,6 +1768,10 @@ packages:
     resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
     dev: false
 
+  /@types/web-bluetooth@0.0.20:
+    resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==}
+    dev: false
+
   /@types/yargs-parser@21.0.0:
     resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==}
     dev: true
@@ -1811,6 +1947,42 @@ packages:
       vue: 3.3.4
     dev: true
 
+  /@vue-flow/background@1.3.0(@vue-flow/core@1.36.0)(vue@3.3.4):
+    resolution: {integrity: sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==}
+    peerDependencies:
+      '@vue-flow/core': ^1.23.0
+      vue: ^3.3.0
+    dependencies:
+      '@vue-flow/core': 1.36.0(vue@3.3.4)
+      vue: 3.3.4
+    dev: false
+
+  /@vue-flow/core@1.36.0(vue@3.3.4):
+    resolution: {integrity: sha512-Cu19fe0AGux3q/jkf5IZEElJUSwMMMLB0wqNygbO5A/LG2HC9cCVkavJbn851KaoD07U5/SsHznoUClnGz34pw==}
+    peerDependencies:
+      vue: ^3.3.0
+    dependencies:
+      '@vueuse/core': 10.11.0(vue@3.3.4)
+      d3-drag: 3.0.0
+      d3-selection: 3.0.0
+      d3-zoom: 3.0.0
+      vue: 3.3.4
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+    dev: false
+
+  /@vue-flow/minimap@1.5.0(@vue-flow/core@1.36.0)(vue@3.3.4):
+    resolution: {integrity: sha512-JhxXDF+8uTc7sgkZHDIvFpHqSl4wsK9xp8Kz5OHwNcXlgGcwqj4yad6jcc1B6bGxm+huESpNmoPotQbpMn6rVw==}
+    peerDependencies:
+      '@vue-flow/core': ^1.23.0
+      vue: ^3.3.0
+    dependencies:
+      '@vue-flow/core': 1.36.0(vue@3.3.4)
+      d3-selection: 3.0.0
+      d3-zoom: 3.0.0
+      vue: 3.3.4
+    dev: false
+
   /@vue/babel-helper-vue-transform-on@1.1.5:
     resolution: {integrity: sha512-SgUymFpMoAyWeYWLAY+MkCK3QEROsiUnfaw5zxOVD/M64KQs8D/4oK6Q5omVA2hnvEOE0SCkH2TZxs/jnnUj7w==}
     dev: true
@@ -1932,6 +2104,18 @@ packages:
   /@vue/shared@3.3.4:
     resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==}
 
+  /@vueuse/core@10.11.0(vue@3.3.4):
+    resolution: {integrity: sha512-x3sD4Mkm7PJ+pcq3HX8PLPBadXCAlSDR/waK87dz0gQE+qJnaaFhc/dZVfJz+IUYzTMVGum2QlR7ImiJQN4s6g==}
+    dependencies:
+      '@types/web-bluetooth': 0.0.20
+      '@vueuse/metadata': 10.11.0
+      '@vueuse/shared': 10.11.0(vue@3.3.4)
+      vue-demi: 0.14.8(vue@3.3.4)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /@vueuse/core@9.13.0(vue@3.3.4):
     resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
     dependencies:
@@ -1944,10 +2128,23 @@ packages:
       - vue
     dev: false
 
+  /@vueuse/metadata@10.11.0:
+    resolution: {integrity: sha512-kQX7l6l8dVWNqlqyN3ePW3KmjCQO3ZMgXuBMddIu83CmucrsBfXlH+JoviYyRBws/yLTQO8g3Pbw+bdIoVm4oQ==}
+    dev: false
+
   /@vueuse/metadata@9.13.0:
     resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
     dev: false
 
+  /@vueuse/shared@10.11.0(vue@3.3.4):
+    resolution: {integrity: sha512-fyNoIXEq3PfX1L3NkNhtVQUSRtqYwJtJg+Bp9rIzculIZWHTkKSysujrOk2J+NrRulLTQH9+3gGSfYLWSEWU1A==}
+    dependencies:
+      vue-demi: 0.14.8(vue@3.3.4)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /@vueuse/shared@9.13.0(vue@3.3.4):
     resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
     dependencies:
@@ -2657,6 +2854,20 @@ packages:
     engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
     dev: true
 
+  /codemirror@6.0.1(@lezer/common@1.2.1):
+    resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
+    dependencies:
+      '@codemirror/autocomplete': 6.16.2(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.28.1)(@lezer/common@1.2.1)
+      '@codemirror/commands': 6.6.0
+      '@codemirror/language': 6.10.2
+      '@codemirror/lint': 6.8.0
+      '@codemirror/search': 6.5.6
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+    transitivePeerDependencies:
+      - '@lezer/common'
+    dev: false
+
   /collect-v8-coverage@1.0.2:
     resolution: {integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==}
     dev: true
@@ -2873,6 +3084,10 @@ packages:
       path-type: 4.0.0
     dev: true
 
+  /crelt@1.0.6:
+    resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+    dev: false
+
   /cross-env@7.0.3:
     resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
     engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -2955,6 +3170,71 @@ packages:
       - typescript
     dev: true
 
+  /d3-color@3.1.0:
+    resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-dispatch@3.0.1:
+    resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-drag@3.0.0:
+    resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-selection: 3.0.0
+    dev: false
+
+  /d3-ease@3.0.1:
+    resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-interpolate@3.0.1:
+    resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-color: 3.1.0
+    dev: false
+
+  /d3-selection@3.0.0:
+    resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-timer@3.0.1:
+    resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
+    engines: {node: '>=12'}
+    dev: false
+
+  /d3-transition@3.0.1(d3-selection@3.0.0):
+    resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+    engines: {node: '>=12'}
+    peerDependencies:
+      d3-selection: 2 - 3
+    dependencies:
+      d3-color: 3.1.0
+      d3-dispatch: 3.0.1
+      d3-ease: 3.0.1
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-timer: 3.0.1
+    dev: false
+
+  /d3-zoom@3.0.0:
+    resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+    engines: {node: '>=12'}
+    dependencies:
+      d3-dispatch: 3.0.1
+      d3-drag: 3.0.0
+      d3-interpolate: 3.0.1
+      d3-selection: 3.0.0
+      d3-transition: 3.0.1(d3-selection@3.0.0)
+    dev: false
+
   /dargs@7.0.0:
     resolution: {integrity: sha512-2iy1EkLdlBzQGvbweYRFxmFath8+K7+AKB0TlhHWkNuH+TmovaMH/Wp7V7R4u7f4SnX3OgLsU9t1NI9ioDnUpg==}
     engines: {node: '>=8'}
@@ -6855,6 +7135,10 @@ packages:
       escape-string-regexp: 1.0.5
     dev: true
 
+  /style-mod@4.1.2:
+    resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==}
+    dev: false
+
   /style-search@0.1.0:
     resolution: {integrity: sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==}
     dev: true
@@ -7490,6 +7774,20 @@ packages:
       vue: 3.3.4
     dev: false
 
+  /vue-codemirror@6.1.1(codemirror@6.0.1)(vue@3.3.4):
+    resolution: {integrity: sha512-rTAYo44owd282yVxKtJtnOi7ERAcXTeviwoPXjIc6K/IQYUsoDkzPvw/JDFtSP6T7Cz/2g3EHaEyeyaQCKoDMg==}
+    peerDependencies:
+      codemirror: 6.x
+      vue: 3.x
+    dependencies:
+      '@codemirror/commands': 6.6.0
+      '@codemirror/language': 6.10.2
+      '@codemirror/state': 6.4.1
+      '@codemirror/view': 6.28.1
+      codemirror: 6.0.1(@lezer/common@1.2.1)
+      vue: 3.3.4
+    dev: false
+
   /vue-demi@0.13.11(vue@3.3.4):
     resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==}
     engines: {node: '>=12'}
@@ -7519,6 +7817,21 @@ packages:
       vue: 3.3.4
     dev: false
 
+  /vue-demi@0.14.8(vue@3.3.4):
+    resolution: {integrity: sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==}
+    engines: {node: '>=12'}
+    hasBin: true
+    requiresBuild: true
+    peerDependencies:
+      '@vue/composition-api': ^1.0.0-rc.1
+      vue: ^3.0.0-0 || ^2.6.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+    dependencies:
+      vue: 3.3.4
+    dev: false
+
   /vue-eslint-parser@9.3.1(eslint@8.46.0):
     resolution: {integrity: sha512-Clr85iD2XFZ3lJ52/ppmUDG/spxQu6+MAeHXjjyI4I1NUYZ9xmenQp4N0oaHJhrA8OOxltCVxMRfANGa70vU0g==}
     engines: {node: ^14.17.0 || >=16.0.0}
@@ -7598,6 +7911,10 @@ packages:
       vue: 3.3.4
     dev: false
 
+  /w3c-keyname@2.2.8:
+    resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+    dev: false
+
   /walker@1.0.8:
     resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
     dependencies:

+ 74 - 0
langchat-ui/src/api/aigc/flow.ts

@@ -0,0 +1,74 @@
+import { http } from '@/utils/http/axios';
+import { AxiosProgressEvent } from 'axios';
+
+export function list(params: any) {
+  return http.request({
+    url: '/langchat/flow/list',
+    method: 'get',
+    params,
+  });
+}
+
+export function page(params: any) {
+  return http.request({
+    url: '/langchat/flow/page',
+    method: 'get',
+    params,
+  });
+}
+
+export function getById(id: string) {
+  return http.request({
+    url: `/langchat/flow/${id}`,
+    method: 'get',
+  });
+}
+
+export function add(params: any) {
+  return http.request({
+    url: '/langchat/flow',
+    method: 'post',
+    params,
+  });
+}
+
+export function update(params: any) {
+  return http.request({
+    url: '/langchat/flow',
+    method: 'put',
+    params,
+  });
+}
+
+export function del(id: string) {
+  return http.request({
+    url: `/langchat/flow/${id}`,
+    method: 'delete',
+  });
+}
+
+export function publish(params: any) {
+  return http.request({
+    url: '/langchat/flow/publish',
+    method: 'put',
+    params,
+  });
+}
+
+export function exec(
+  id: string,
+  params: any,
+  onDownloadProgress?: (progressEvent: AxiosProgressEvent) => void
+) {
+  return http.request(
+    {
+      url: `/langchat/flow/exec/${id}`,
+      method: 'post',
+      params,
+      onDownloadProgress: onDownloadProgress,
+    },
+    {
+      isTransformResponse: false,
+    }
+  );
+}

+ 31 - 0
langchat-ui/src/router/base.ts

@@ -104,4 +104,35 @@ export const BaseRoute: Array<any> = [
       },
     ],
   },
+
+  {
+    path: '/workflow',
+    name: 'flow',
+    component: 'LAYOUT',
+    show: false,
+    meta: {
+      title: '流程编辑',
+    },
+    children: [
+      {
+        path: 'flow/:id/initialize',
+        name: 'flow_initialize',
+        component: '/flow/initialize/FlowTemplate',
+        show: false,
+        meta: {
+          title: '流程初始化',
+        },
+      },
+      {
+        path: 'flow/:id/edit',
+        name: 'flow_edit',
+        component: '/flow/layout/Layout',
+        show: false,
+        meta: {
+          title: '流程编辑',
+          activeMenu: '流程编排',
+        },
+      },
+    ],
+  },
 ];

+ 167 - 0
langchat-ui/src/styles/flow.less

@@ -0,0 +1,167 @@
+.splitter {
+  background: none;
+  background: #f8f8fa !important;
+}
+.splitter:hover {
+  background: none;
+}
+.vue-splitter .splitter::before {
+  content: "";
+  display: block;
+  background: rgba(191, 191, 191, 0.2);
+  height: 100%;
+  width: 5px;
+  border-radius: 2px;
+  //margin-top: calc(23vh - 20px);
+}
+
+.right-pane .splitter::before {
+  content: "";
+  display: block;
+  border-radius: 2px;
+  background: rgba(191, 191, 191, 1);
+  height: 5px;
+  width: 70px;
+  margin: 0 auto;
+}
+
+.vue-splitter .splitter:hover::before {
+  background: rgba(153, 153, 153, 0.34);
+}
+.right-pane .splitter:hover::before {
+  background: rgba(153, 153, 153, 1) !important;
+}
+
+.vue-splitter .splitter {
+}
+
+.card-shadow {
+  box-shadow: 0 6px 16px -9px rgba(0, 0, 0, .08), 0 9px 28px 0 rgba(0, 0, 0, .05), 0 12px 48px 16px rgba(0, 0, 0, .03);
+}
+
+.node-selected {
+  transition: all 0.2s ease-out;
+  outline: 1.5px solid #60a5fa !important;
+  box-shadow: 0 0 0 5px #cbdffe99, 0 4px 12px #00000014, 0 0 0 1px #60a5fa,
+  inset 0 0 1px 1px #fff9 !important;
+  transform: translateY(-1px);
+}
+
+.custom-node {
+  outline: 1px solid #d9dade;
+  box-shadow: 0 2px 4px -1px #00000014, inset 0 0 1px 1px #fff9;
+}
+.custom-node:hover {
+  transition: all 0.2s ease-out;
+  outline: 1.5px solid #60a5fa !important;
+}
+
+.vue-flow__handle {
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: #cdcdcf;
+  border: 1px solid;
+  box-sizing: border-box;
+  border-color: #bcbcbca8;
+}
+
+.vue-flow__handle-right{
+  right: -12px !important;
+}
+
+.vue-flow__handle-left{
+  left: -13px !important;
+}
+
+.pin-title {
+  font-weight: 500;
+  color: #18181b;
+  padding: 5px 0 9px;
+  position: relative;
+}
+.hover-input {
+  margin-left: 2px !important;
+  background: transparent !important;
+  .n-input-wrapper {
+    padding-left: 4px !important;
+    padding-right: 4px !important;
+  }
+  .n-input__border {
+    border: none !important;
+  }
+}
+
+.hover-button {
+  color: #3c9bff !important;
+  .n-button__state-border {
+    border: 1px solid #3c9bff !important;
+  }
+}
+
+.vue-flow__edge .line-active {
+  stroke: #3b82f6 !important;
+  stroke-width: 2px;
+  transition: opacity 0.5s;
+}
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.25s;
+}
+
+.fade-enter,
+.fade-leave-to {
+  opacity: 0;
+}
+
+.dot-pulse {
+  position: relative;
+  left: -9999px;
+  width: 3px;
+  height: 3px;
+  border-radius: 5px;
+  background-color: #99999c;
+  color: #99999c;
+  box-shadow: 9984px 0 0 0 #99999c,9999px 0 0 0 #99999c,10014px 0 0 0 #99999c;
+  animation: dotPulse 1.5s infinite linear
+}
+
+@keyframes dotPulse {
+  0% {
+    box-shadow: 9984px 0 0 -5px #99999c,9999px 0 0 0 #99999c,10014px 0 0 2px #99999c
+  }
+
+  25% {
+    box-shadow: 9984px 0 0 0 #99999c,9999px 0 0 2px #99999c,10014px 0 0 0 #99999c
+  }
+
+  50% {
+    box-shadow: 9984px 0 0 2px #99999c,9999px 0 0 0 #99999c,10014px 0 0 -5px #99999c
+  }
+
+  75% {
+    box-shadow: 9984px 0 0 0 #99999c,9999px 0 0 -5px #99999c,10014px 0 0 0 #99999c
+  }
+
+  100% {
+    box-shadow: 9984px 0 0 -5px #99999c,9999px 0 0 0 #99999c,10014px 0 0 2px #99999c
+  }
+}
+
+.custom-form-item {
+  .n-form-item-feedback-wrapper {
+    min-height: 8px !important;
+  }
+}
+
+.min-select {
+  .n-base-selection-input {
+    padding-left: 8px !important;
+  }
+}
+
+.min-collapse {
+  .n-collapse-item-arrow {
+    font-size: 14px !important;
+  }
+}

+ 7 - 0
langchat-ui/src/styles/index.less

@@ -121,3 +121,10 @@ body {
     padding-left: 25px !important;
   }
 }
+
+.dot-bg {
+  height: 100%;
+  background-image: radial-gradient(circle at center, rgba(0, 0, 0, 0.13) 1.2px, transparent 0);
+  background-size: 15px 15px;
+  background-repeat: round;
+}

+ 52 - 0
langchat-ui/src/views/flow/coding.vue

@@ -0,0 +1,52 @@
+<script setup lang="ts">
+  import { nextTick, ref } from 'vue';
+  import { oneDark } from '@codemirror/theme-one-dark';
+  import { javascript } from '@codemirror/lang-javascript';
+  import { Codemirror } from 'vue-codemirror';
+  import { CodeWorking } from '@vicons/ionicons5';
+  import { renderIcon } from '@/utils';
+  import { getById } from '@/api/aigc/flow';
+
+  const emit = defineEmits(['reload']);
+  const code = ref('');
+  const extensions = [oneDark, javascript()];
+
+  const showModal = ref(false);
+
+  async function show(id: string) {
+    showModal.value = true;
+    await nextTick();
+    if (id) {
+      const res = await getById(id);
+      code.value = res.script;
+    }
+  }
+
+  defineExpose({ show });
+</script>
+
+<template>
+  <n-modal
+    v-model:show="showModal"
+    style="width: 30%"
+    :icon="renderIcon(CodeWorking)"
+    preset="dialog"
+    title="编辑"
+  >
+    <codemirror
+      v-model="code"
+      placeholder="Code goes here..."
+      :style="{ height: '400px' }"
+      :autofocus="true"
+      :indent-with-tab="true"
+      :tab-size="2"
+      :extensions="extensions"
+    />
+  </n-modal>
+</template>
+
+<style scoped lang="less">
+  ::v-deep(.cm-focused) {
+    outline: none !important;
+  }
+</style>

+ 124 - 0
langchat-ui/src/views/flow/columns.ts

@@ -0,0 +1,124 @@
+import { BasicColumn } from '@/components/Table';
+import { FormSchema } from '@/components/Form';
+import { h } from 'vue';
+import { NButton, NTag } from 'naive-ui';
+import router from '@/router';
+import { isNullOrWhitespace } from '@/utils/is';
+
+export const columns: BasicColumn<any>[] = [
+  // {
+  //   title: '流程ID',
+  //   key: 'id',
+  //   width: 200,
+  // },
+  {
+    title: '流程名称',
+    key: 'name',
+    width: 300,
+    align: 'center',
+    render(row: any) {
+      return h(
+        NButton,
+        {
+          text: true,
+          tag: 'a',
+          type: 'success',
+          onClick: () => {
+            if (isNullOrWhitespace(row.flow)) {
+              router.push({ name: 'flow_initialize', params: { id: row.id } });
+            } else {
+              router.push({ name: 'flow_edit', params: { id: row.id } });
+            }
+          },
+        },
+        {
+          default: () => row.name,
+        }
+      );
+    },
+  },
+  {
+    title: '发布状态',
+    key: 'type',
+    width: 90,
+    align: 'center',
+    render(row) {
+      return h(
+        NTag,
+        {
+          size: 'small',
+          type: row.isPublish ? 'success' : 'error',
+        },
+        {
+          default: () => (row.isPublish ? '已发布' : '未发布'),
+        }
+      );
+    },
+  },
+  {
+    title: '流程描述',
+    key: 'des',
+    align: 'center',
+  },
+  // {
+  //   title: '创建时间',
+  //   key: 'createTime',
+  //   align: 'center',
+  //   width: 110,
+  // },
+  {
+    title: '编辑时间',
+    key: 'updateTime',
+    align: 'center',
+    width: 110,
+  },
+  {
+    title: '发布时间',
+    key: 'publishTime',
+    align: 'center',
+    width: 110,
+  },
+];
+
+export const searchSchemas: FormSchema[] = [
+  {
+    field: 'name',
+    component: 'NInput',
+    label: '流程名称',
+    componentProps: {
+      placeholder: '请输入流程名称',
+    },
+  },
+];
+
+export const formSchemas: FormSchema[] = [
+  {
+    field: 'id',
+    label: 'ID',
+    component: 'NInput',
+    isHidden: true,
+  },
+  {
+    field: 'name',
+    label: '流程名称',
+    component: 'NInput',
+    componentProps: {
+      placeholder: '请输入流程名称',
+    },
+    rules: [{ required: true, message: '请输入流程名称', trigger: ['blur'] }],
+  },
+  {
+    field: 'des',
+    component: 'NInput',
+    label: '流程描述',
+    isFull: true,
+    componentProps: {
+      placeholder: '请输入流程描述',
+      type: 'textarea',
+      autosize: {
+        minRows: 5,
+        maxRows: 8,
+      },
+    },
+  },
+];

+ 56 - 0
langchat-ui/src/views/flow/custom/CustomEdge.vue

@@ -0,0 +1,56 @@
+<script lang="ts" setup>
+  import { computed } from 'vue';
+  import type { EdgeProps } from '@vue-flow/core';
+  import { CloseCircle } from '@vicons/ionicons5';
+  import {
+    SmoothStepEdge,
+    EdgeLabelRenderer,
+    getSmoothStepPath,
+    useVueFlow,
+    useEdge,
+  } from '@vue-flow/core';
+  import { renderPropsIcon } from '@/utils';
+
+  const { edge } = useEdge();
+  const props = defineProps<EdgeProps>();
+
+  const { removeEdges } = useVueFlow();
+
+  const path = computed(() => getSmoothStepPath(props));
+</script>
+
+<template>
+  <SmoothStepEdge
+    :source-x="sourceX"
+    :source-y="sourceY"
+    :target-x="targetX"
+    :target-y="targetY"
+    :source-position="sourcePosition"
+    :target-position="targetPosition"
+    class="custom-edge"
+    :class="edge?.selected ? 'line-active' : ''"
+  />
+  <EdgeLabelRenderer>
+    <div
+      :style="{
+        zIndex: 1,
+        pointerEvents: 'all',
+        position: 'absolute',
+        transform: `translate(-50%, -50%) translate(${path[1]}px,${path[2] + 2}px)`,
+      }"
+    >
+      <transition name="fade">
+        <n-button
+          @click="removeEdges(id)"
+          text
+          v-if="edge?.selected"
+          class="hover:!text-[#de576d]"
+          type="info"
+          :render-icon="renderPropsIcon(CloseCircle, { size: '14' })"
+        />
+      </transition>
+    </div>
+  </EdgeLabelRenderer>
+</template>
+
+<style lang="less"></style>

+ 181 - 0
langchat-ui/src/views/flow/custom/CustomExec.vue

@@ -0,0 +1,181 @@
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { v4 as uuidv4 } from 'uuid';
+  import { ChatbubblesOutline, NavigateOutline, RefreshOutline } from '@vicons/ionicons5';
+  import { exec as flowExec } from '@/api/aigc/flow';
+  import { useNotification } from 'naive-ui';
+  import { useRouter } from 'vue-router';
+
+  const emits = defineEmits(['focus-active']);
+
+  const middleRef = ref();
+  const router = useRouter();
+  const notification = useNotification();
+  const content = ref('');
+  const loading = ref(false);
+
+  function handleFocus() {
+    emits('focus-active');
+  }
+
+  const messages = ref<
+    {
+      id: string;
+      role: 'human' | 'ai';
+      content: string;
+      type: 'q' | 'a' | 'loading' | 'error';
+    }[]
+  >([]);
+
+  async function handleSubmit() {
+    loading.value = true;
+
+    try {
+      let id = uuidv4();
+      messages.value.push(
+        {
+          id: uuidv4(),
+          role: 'human',
+          content: content.value,
+          type: 'q',
+        },
+        {
+          id: id,
+          role: 'ai',
+          content: '',
+          type: 'loading',
+        }
+      );
+      const items = messages.value.filter((i) => i.id == id);
+      await flowExec(
+        String(router.currentRoute.value.params.id),
+        {
+          content: content.value,
+        },
+        ({ event }) => {
+          console.log('response', event);
+          const { responseText } = event.target;
+
+          try {
+            const data = JSON.parse(responseText);
+            notification.error({
+              duration: 5000,
+              content: data.message,
+              meta: '请检查Flow流程设计。',
+            });
+            items[0].type = 'error';
+            items[0].content = 'Error! ';
+          } catch (e) {
+            items[0].content = responseText;
+            items[0].type = 'a';
+          }
+
+          scrollToBottom();
+        }
+      );
+      content.value = '';
+      loading.value = false;
+    } finally {
+      loading.value = false;
+      scrollToBottom();
+    }
+  }
+
+  const scrollToBottom = () => {
+    const middleElement = middleRef.value;
+    if (middleElement) {
+      middleElement.scrollTop = middleElement.scrollHeight;
+    }
+  };
+
+  function handleEnter(event: KeyboardEvent) {
+    if (event.key === 'Enter' && event.ctrlKey) {
+    } else if (event.key === 'Enter') {
+      event.preventDefault();
+      handleSubmit();
+    }
+  }
+</script>
+
+<template>
+  <div class="container relative h-full card-shadow rounded-xl mb-2">
+    <div class="top absolute top-0 left-0 w-full h-10 z-10 border-b border-1">
+      <div class="w-full flex justify-between items-center p-2 absolute">
+        <div>
+          <n-badge type="success" dot />
+          <div class="inline-block ml-2">会话测试</div>
+        </div>
+        <n-button text class="mr-2">
+          <n-icon size="16" color="#18a058">
+            <RefreshOutline />
+          </n-icon>
+        </n-button>
+      </div>
+    </div>
+
+    <div
+      ref="middleRef"
+      class="middle absolute top-10 left-0 w-full bottom-[65px] z-0 overflow-y-auto"
+    >
+      <div v-if="messages.length == 0" class="flex-1 flex mt-5 h-full justify-center">
+        <div class="w-1/2 flex flex-col justify-center text-xs items-center gap-2">
+          <n-icon size="40" color="#e4e4e7">
+            <ChatbubblesOutline />
+          </n-icon>
+          <div class="text-[#69696b]">输入内容开始测试会话...</div>
+        </div>
+      </div>
+      <div v-else class="flex-1 overflow-y-auto mb-1">
+        <div class="h-full w-full flex flex-col space-y-3 relative p-2 pl-4 pr-4 mt-2">
+          <template v-for="item in messages" :key="item">
+            <div
+              v-if="item.role == 'human'"
+              class="flex justify-end p-1.5 rounded select-text self-end"
+              style="background: #d2f9d1"
+              >{{ item.content }}
+            </div>
+            <div
+              v-if="item.role == 'ai'"
+              class="flex justify-start items-center rounded self-start min-w-[40px] min-h-[33px]"
+              :style="
+                item.type === 'error' ? 'color: #d03050;background:#d0305029' : 'background:#f4f6f8'
+              "
+            >
+              <div v-if="item.type === 'loading'" class="flex justify-center items-center w-[55px]">
+                <span class="dot-pulse"></span>
+              </div>
+              <div class="p-1.5" v-else>{{ item.content }}</div>
+            </div>
+          </template>
+        </div>
+      </div>
+    </div>
+
+    <div class="bottom absolute bottom-0 left-0 w-full h-[60px] z-10">
+      <div class="pl-5 pr-5 flex justify-center items-center space-x-2 w-full">
+        <n-input
+          @focus="handleFocus"
+          v-model:value="content"
+          type="textarea"
+          size="small"
+          class="w-full rounded-lg text-xs"
+          :autosize="{ minRows: 2, maxRows: 5 }"
+          :disabled="loading"
+          @keypress="handleEnter"
+        >
+          <template #suffix>
+            <n-button @click="handleSubmit" :loading="loading" size="small" text>
+              <template #icon>
+                <n-icon color="#18a058">
+                  <NavigateOutline />
+                </n-icon>
+              </template>
+            </n-button>
+          </template>
+        </n-input>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less"></style>

+ 127 - 0
langchat-ui/src/views/flow/custom/CustomZoom.vue

@@ -0,0 +1,127 @@
+<script setup lang="ts">
+  import { FullscreenOutlined, ZoomInOutlined, ZoomOutOutlined } from '@vicons/antd';
+  import { HammerOutline, OptionsOutline } from '@vicons/ionicons5';
+  import { ref } from 'vue';
+  import { useVueFlow } from '@vue-flow/core';
+  import { publish as flowPublish, update as flowUpdate } from '@/api/aigc/flow';
+  import { renderPropsIcon, renderIcon } from '@/utils';
+  import { useFlowStore } from '@/views/flow/store';
+  import { getDatas } from '@/views/flow/store/get';
+  import { useRouter } from 'vue-router';
+
+  const flowStore = useFlowStore();
+  const router = useRouter();
+  const { zoomIn, zoomOut, fitView, onViewportChange, toObject } = useVueFlow();
+  const loading = ref(false);
+  const zoom = ref(50);
+
+  onViewportChange((e) => {
+    zoom.value = e.zoom < 0.5 ? 0 : e.zoom > 2 ? 100 : Math.floor(((e.zoom - 0.5) / 1.5) * 100);
+  });
+
+  async function handleSubmit() {
+    loading.value = true;
+    const data = {
+      id: String(router.currentRoute.value.params.id),
+      flow: JSON.stringify(getDatas(toObject())),
+    };
+    await flowUpdate(data);
+
+    try {
+      await flowPublish(data);
+      window['$message'].success('发布成功');
+    } finally {
+      loading.value = false;
+    }
+  }
+</script>
+
+<template>
+  <div class="mt-3 ml-2 absolute z-10">
+    <n-button
+      v-if="!flowStore.showCard"
+      @click="flowStore.setShowCard()"
+      circle
+      :render-icon="renderIcon(OptionsOutline)"
+    />
+  </div>
+
+  <div class="custom-zoom">
+    <div class="h-10 flex justify-center items-center w-auto pl-3 pr-3 z-10 rounded-lg">
+      <n-popover trigger="hover" placement="bottom" class="custom-popover">
+        <template #trigger>
+          <n-button
+            @click="flowStore.setShowCard()"
+            text
+            :render-icon="renderIcon(OptionsOutline)"
+            :class="flowStore.showCard ? 'text-blue-400' : ''"
+          />
+        </template>
+        <span>显示Node Card面板</span>
+      </n-popover>
+
+      <n-divider class="bg-gray-400" vertical />
+      <n-popover trigger="hover" placement="bottom" class="custom-popover">
+        <template #trigger>
+          <n-button @click="fitView" text :render-icon="renderIcon(FullscreenOutlined)" />
+        </template>
+        <span>适应屏幕</span>
+      </n-popover>
+
+      <n-popover trigger="hover" placement="bottom" class="custom-popover">
+        <template #trigger>
+          <n-button
+            @click="zoomIn({ duration: 0.2 })"
+            text
+            :render-icon="renderPropsIcon(ZoomInOutlined, { size: 14 })"
+          />
+        </template>
+        <span>放大画布</span>
+      </n-popover>
+      <span class="text-xs text-gray-700 text-center" style="min-width: 28px"> {{ zoom }}% </span>
+      <n-popover trigger="hover" placement="bottom" class="custom-popover">
+        <template #trigger>
+          <n-button
+            @click="zoomOut({ duration: 0.2 })"
+            text
+            :render-icon="renderPropsIcon(ZoomOutOutlined, { size: 14 })"
+          />
+        </template>
+        <span>缩小画布</span>
+      </n-popover>
+
+      <n-divider class="bg-gray-400" vertical />
+      <n-button
+        @click="handleSubmit"
+        :loading="loading"
+        type="primary"
+        size="small"
+        secondary
+        :render-icon="renderIcon(HammerOutline)"
+      >
+        发布流程
+      </n-button>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="less">
+  .custom-zoom {
+    top: 8px;
+    position: absolute;
+    z-index: 1000;
+    right: 50%;
+    transform: translate(50%);
+    height: 40px;
+    width: auto;
+    padding-right: 4px;
+    padding-left: 4px;
+    align-content: center;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    border-radius: 8px;
+    border: 1px solid rgba(0, 0, 0, 0.05);
+    background: rgba(228, 229, 231, 0.7);
+  }
+</style>

+ 56 - 0
langchat-ui/src/views/flow/edit.vue

@@ -0,0 +1,56 @@
+<template>
+  <basicModal @register="modalRegister" style="width: 45%">
+    <BasicForm @register="register" @submit="handleSubmit" class="mt-5" />
+  </basicModal>
+</template>
+<script lang="ts" setup>
+  import { nextTick } from 'vue';
+  import { update, getById } from '@/api/aigc/flow';
+  import { useMessage } from 'naive-ui';
+  import { formSchemas } from './columns';
+  import { BasicForm, useForm } from '@/components/Form';
+  import { basicModal, useModal } from '@/components/Modal';
+
+  const emit = defineEmits(['reload']);
+  const message = useMessage();
+
+  const [
+    modalRegister,
+    { openModal: openModal, closeModal: closeModal, setSubLoading: setSubLoading },
+  ] = useModal({
+    title: '新增/编辑',
+    closable: true,
+    maskClosable: false,
+    showCloseBtn: false,
+    showSubBtn: false,
+  });
+  const [register, { setFieldsValue }] = useForm({
+    gridProps: { cols: 1 },
+    labelWidth: 120,
+    layout: 'horizontal',
+    submitButtonText: '提交',
+    schemas: formSchemas,
+  });
+
+  async function show(id: string) {
+    openModal();
+    await nextTick();
+    if (id) {
+      setFieldsValue(await getById(id));
+    }
+  }
+
+  async function handleSubmit(values: any) {
+    if (values !== false) {
+      closeModal();
+      await update(values);
+      emit('reload');
+      message.success('修改成功');
+    } else {
+      message.error('请完善表单');
+    }
+  }
+  defineExpose({ show });
+</script>
+
+<style scoped lang="less"></style>

+ 184 - 0
langchat-ui/src/views/flow/index.vue

@@ -0,0 +1,184 @@
+<script lang="ts" setup>
+  import { h, onMounted, reactive, ref } from 'vue';
+  import { BasicTable, TableAction } from '@/components/Table';
+  import { BasicForm, useForm } from '@/components/Form/index';
+  import { add, del, page as getPage } from '@/api/aigc/flow';
+  import { columns, searchSchemas } from './columns';
+  import { DeleteOutlined, EditOutlined, PlusOutlined } from '@vicons/antd';
+  import { CreateOutline } from '@vicons/ionicons5';
+  import Edit from '@/views/flow/edit.vue';
+  import Coding from '@/views/flow/coding.vue';
+  import { useDialog, useMessage, useNotification } from 'naive-ui';
+  import { useRouter } from 'vue-router';
+  import { isNullOrWhitespace } from '@/utils/is';
+  import { renderIcon } from '@/utils';
+
+  const router = useRouter();
+  const message = useMessage();
+  const notification = useNotification();
+  const dialog = useDialog();
+  const actionRef = ref();
+  const editRef = ref();
+  const codingRef = ref();
+
+  onMounted(() => {
+    notification.create({
+      type: 'warning',
+      title: 'About Flow Design',
+      content: `此模块主要为了流程化编排应用或是机器人,其实相关代码很早就写了,但之前一直没有找到WorkFlow前后端的解决方案。
+目前会逐步开始完善此模块,
+前端采用vue-flow,后端采用LiteFlow。由于此模块的设计和开发过程都非常繁琐,请耐心等待
+      `,
+    });
+  });
+
+  const actionColumn = reactive({
+    width: 170,
+    title: '操作',
+    key: 'action',
+    fixed: 'right',
+    align: 'center',
+    render(record: any) {
+      return h(TableAction as any, {
+        style: 'text',
+        actions: [
+          {
+            size: 'tiny',
+            type: 'info',
+            label: '脚本',
+            onClick: handleCoding.bind(null, record),
+          },
+          {
+            size: 'tiny',
+            type: 'success',
+            label: '编排',
+            onClick: handleFlow.bind(null, record),
+          },
+          {
+            size: 'tiny',
+            type: 'warning',
+            icon: EditOutlined,
+            onClick: handleEdit.bind(null, record),
+          },
+          {
+            size: 'tiny',
+            type: 'error',
+            icon: DeleteOutlined,
+            onClick: handleDelete.bind(null, record),
+          },
+        ],
+      });
+    },
+  });
+
+  const [register, { getFieldsValue, setFieldsValue }] = useForm({
+    gridProps: { cols: '1 s:1 m:2 l:3 xl:4 2xl:4' },
+    labelWidth: 80,
+    schemas: searchSchemas,
+    showAdvancedButton: false,
+  });
+
+  const loadDataTable = async (res: any) => {
+    return await getPage({ ...getFieldsValue(), ...res });
+  };
+
+  function reloadTable() {
+    actionRef.value.reload();
+  }
+
+  function handleFlow(row: any) {
+    if (isNullOrWhitespace(row.flow)) {
+      router.push({ name: 'flow_initialize', params: { id: row.id } });
+    } else {
+      router.push({ name: 'flow_edit', params: { id: row.id } });
+    }
+  }
+
+  async function handleAdd() {
+    const data = await add({ name: 'New Flow Design' });
+    await actionRef.value.reload();
+    dialog.success({
+      title: '创建成功',
+      maskClosable: false,
+      content: () =>
+        h('div', {}, [
+          'New Flow Design 已经创建完成,点击去编辑!',
+          h('br'),
+          h('span', {}, 'FlowId: '),
+          h(
+            'span',
+            {
+              style: { color: '#18a058' },
+            },
+            data.id
+          ),
+        ]),
+      positiveText: '去编辑',
+      positiveButtonProps: {
+        renderIcon: renderIcon(CreateOutline),
+      },
+      onPositiveClick: () => {
+        handleFlow(data);
+      },
+    });
+  }
+
+  function handleEdit(record: Recordable) {
+    editRef.value.show(record.id);
+  }
+
+  function handleCoding(record: Recordable) {
+    codingRef.value.show(record.id);
+  }
+
+  function handleDelete(record: Recordable) {
+    dialog.info({
+      title: '提示',
+      content: `您想删除[${record.name}]流程?此操作将删除所有编排数据`,
+      positiveText: '确定',
+      negativeText: '取消',
+      onPositiveClick: async () => {
+        await del(record.id);
+        message.success('删除成功');
+        reloadTable();
+      },
+      onNegativeClick: () => {},
+    });
+  }
+
+  function handleReset(values: Recordable) {
+    reloadTable();
+  }
+</script>
+
+<template>
+  <n-card :bordered="false">
+    <BasicForm @register="register" @submit="reloadTable" @reset="handleReset" />
+
+    <BasicTable
+      :single-line="false"
+      :size="'small'"
+      :columns="columns"
+      :request="loadDataTable"
+      :row-key="(row:any) => row.id"
+      ref="actionRef"
+      :actionColumn="actionColumn"
+    >
+      <template #tableTitle>
+        <n-button type="primary" size="small" @click="handleAdd">
+          <template #icon>
+            <n-icon>
+              <PlusOutlined />
+            </n-icon>
+          </template>
+          新建
+        </n-button>
+      </template>
+    </BasicTable>
+  </n-card>
+
+  <Edit ref="editRef" @reload="reloadTable" />
+  <Coding ref="codingRef" @reload="reloadTable" />
+</template>
+
+<style lang="less" scoped></style>

+ 105 - 0
langchat-ui/src/views/flow/initialize/FlowTemplate.vue

@@ -0,0 +1,105 @@
+<script setup lang="ts">
+  import { FileTrayOutline, DocumentTextOutline, ServerOutline } from '@vicons/ionicons5';
+  import { useRouter } from 'vue-router';
+  import { Flow } from '@/api/models/flow';
+  import { update as updateFlow, getById } from '@/api/aigc/flow';
+  import { onMounted } from 'vue';
+  import { isNullOrWhitespace } from '@/utils/is';
+  import { initializeTemplate } from '@/views/flow/initialize/index';
+  import { RouteItem, useTabsViewStore } from '@/store/modules/tabsView';
+  const router = useRouter();
+  const tabsViewStore = useTabsViewStore();
+
+  let flowId = '';
+  let data: Flow = {};
+  onMounted(async () => {
+    flowId = String(router.currentRoute.value.params.id);
+    data = await getById(flowId);
+    if (!isNullOrWhitespace(data.flow)) {
+      tabsViewStore.closeCurrentTab(router.currentRoute.value as RouteItem);
+      await router.push({ name: 'flow_edit', params: { id: flowId } });
+    }
+  });
+
+  async function handler(key: string) {
+    const temp = initializeTemplate(key);
+    data.des = temp?.des;
+    data.script = temp?.script;
+    data.flow = JSON.stringify(temp?.flow);
+    await updateFlow(data);
+    await router.push({ name: 'flow_edit', params: { id: flowId } });
+  }
+
+  const templates = [
+    {
+      key: 'blank',
+      label: '空模版',
+      icon: FileTrayOutline,
+      value: '创建一个空的模版,仅包含最基础的节点,可以在此模版上自由设计。',
+    },
+    {
+      key: 'knowledge',
+      label: '从知识库中开始问答',
+      icon: ServerOutline,
+      value: '使用自定义知识库数据,从知识库中读取数据并标准化问答内容。',
+    },
+    {
+      key: 'file',
+      label: '从文档文件中开始文档',
+      icon: DocumentTextOutline,
+      value: '上传文档文件,从文档中读取数据,回答文档中有关的问题。',
+    },
+  ];
+</script>
+
+<template>
+  <n-config-provider>
+    <div class="flex dot-bg justify-center items-center w-full" style="height: calc(100vh - 130px)">
+      <n-card style="width: 900px" class="rounded-md">
+        <div class="p-6">
+          <div class="text-2xl pb-3 font-bold">创建模版</div>
+          <div>你可以通过下列模版配置初始化你的流程</div>
+        </div>
+        <div class="pl-8 pr-8 mb-10 flex flex-col gap-4">
+          <n-button
+            v-for="item in templates"
+            :key="item.key"
+            @click="handler(item.key)"
+            class="w-full justify-start h-15 pt-2 pb-3 pl-8 rounded-l"
+            dashed
+          >
+            <template #icon>
+              <n-icon size="30">
+                <component :is="item.icon" />
+              </n-icon>
+            </template>
+            <template #default>
+              <div class="flex flex-col justify-start gap-2 ml-4">
+                <div class="text-lg text-left">{{ item.label }}</div>
+                <div class="text">{{ item.value }}</div>
+              </div>
+            </template>
+          </n-button>
+        </div>
+      </n-card>
+    </div>
+  </n-config-provider>
+</template>
+
+<style scoped lang="less">
+  ::v-deep(.n-button) {
+    &:hover {
+      color: var(--n-text-color-hover) !important;
+      .text {
+        color: var(--n-text-color-hover) !important;
+      }
+    }
+    height: auto !important;
+    ::v-deep(.n-button__content) {
+      display: block !important;
+    }
+    .text {
+      color: var(--n-close-icon-color);
+    }
+  }
+</style>

+ 27 - 0
langchat-ui/src/views/flow/initialize/index.ts

@@ -0,0 +1,27 @@
+interface R {
+  des: string;
+  script: string;
+  flow: Object;
+}
+
+export function initializeTemplate(key: string): R | undefined {
+  switch (key) {
+    case 'blank':
+      return blankTemplate;
+    default:
+      break;
+  }
+  return undefined;
+}
+
+const blankTemplate: R = {
+  des: '这是一个空的模版,仅包含最基础的节点,你可以在此模版上自由设计!',
+  script: 'THEN(StartComponent,AiGenTextComponent,EndComponent)',
+  flow: [
+    { id: '1', type: 'Start', position: { x: 216, y: 190 } },
+    { id: '2', type: 'End', position: { x: 778, y: 220 } },
+    { id: '3', label: 'Assist', type: 'Assist', position: { x: 432, y: 112 } },
+    { id: 'e1-2', source: '1', target: '3', type: 'custom', animated: true },
+    { id: 'e1-4', source: '3', target: '2', type: 'custom', animated: true },
+  ],
+};

+ 63 - 0
langchat-ui/src/views/flow/layout/CardLayout.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+  import { collapses } from '@/views/flow/store/get';
+  import { useFlowStore } from '@/views/flow/store';
+  import NodeCard from './components/NodeCard.vue';
+  import PluginCard from './components/PluginCard.vue';
+
+  const flowStore = useFlowStore();
+</script>
+
+<template>
+  <n-drawer
+    v-model:show="flowStore.showCard"
+    :show-mask="false"
+    :mask-closable="false"
+    placement="left"
+    width="280px"
+    to="#graph-layout"
+    class="rounded-lg left-5 bottom-6"
+  >
+    <n-drawer-content
+      closable
+      :header-style="{ padding: '13px' }"
+      :body-content-style="{ padding: '0px' }"
+    >
+      <template #header>
+        <div class="text-[13px]">Node Card & Plugins</div>
+      </template>
+
+      <div class="p-3">
+        <n-tabs type="segment" size="small">
+          <n-tab-pane name="Node" tab="Node">
+            <NodeCard :list="collapses(true)" />
+          </n-tab-pane>
+          <n-tab-pane name="Plugins" tab="Plugins"
+            ><PluginCard :list="collapses(false)" />
+          </n-tab-pane>
+        </n-tabs>
+      </div>
+    </n-drawer-content>
+  </n-drawer>
+</template>
+
+<style scoped lang="less">
+  .panel {
+    margin-top: 8px;
+    font-size: 10px !important;
+    ::v-deep(.n-collapse-item) {
+      border: 0 !important;
+      margin: 0 !important;
+      .n-collapse-item__content-outer {
+        padding-top: 10px !important;
+      }
+    }
+
+    ::v-deep(.n-collapse-item__header-main) {
+      .n-collapse-item-arrow {
+        font-size: 14px !important;
+      }
+      font-weight: 500 !important;
+      font-size: 12px !important;
+    }
+  }
+</style>

+ 107 - 0
langchat-ui/src/views/flow/layout/GraphLayout.vue

@@ -0,0 +1,107 @@
+<script lang="ts" setup>
+  import { onMounted, ref } from 'vue';
+  import { useRouter } from 'vue-router';
+  import { ConnectionLineType, useVueFlow, VueFlow } from '@vue-flow/core';
+  import { MiniMap } from '@vue-flow/minimap';
+  import { Background } from '@vue-flow/background';
+  import '@vue-flow/core/dist/style.css';
+  import '@vue-flow/core/dist/theme-default.css';
+  import '@vue-flow/minimap/dist/style.css';
+  import CustomEdge from '@/views/flow/custom/CustomEdge.vue';
+  import CustomZoom from '@/views/flow/custom/CustomZoom.vue';
+  import { useFlowStore } from '@/views/flow/store';
+  import { nodeTypes } from '@/views/flow/store/get';
+  import CardLayout from '@/views/flow/layout/CardLayout.vue';
+
+  const router = useRouter();
+  const flowStore = useFlowStore();
+  const loading = ref(false);
+  const nodeMenuRef = ref();
+  const list = ref();
+
+  const { addNodes, onConnect, addEdges, project, vueFlowRef, onNodeContextMenu } = useVueFlow({
+    connectionLineOptions: {
+      type: ConnectionLineType.SmoothStep,
+      class: 'animated',
+    },
+  });
+
+  onMounted(async () => {
+    loading.value = true;
+    const data = flowStore.data;
+    if (data == undefined) {
+      return;
+    }
+    if (data.flow == undefined) {
+      loading.value = true;
+      return;
+    }
+    list.value = JSON.parse(data.flow);
+    loading.value = false;
+  });
+
+  onConnect((params) => {
+    addEdges({ ...params, type: 'custom', animated: true });
+  });
+
+  onNodeContextMenu((e) => {
+    e.node.selected = true;
+    console.log(e);
+    nodeMenuRef.value.show(e.node, e.event);
+  });
+
+  function onDragOver(event: any) {
+    event.preventDefault();
+    if (event.dataTransfer) {
+      event.dataTransfer.dropEffect = 'move';
+    }
+  }
+
+  function onDrop(event: any) {
+    if (event.clientX < 340) {
+      // 拖拽区域没有超过面板宽度时 忽略拖拽
+      return;
+    }
+    const isNode = event.dataTransfer?.getData('isNode');
+    if (isNode !== 'true') {
+      return;
+    }
+    const { type, data } = JSON.parse(event.dataTransfer?.getData('data'));
+
+    const { left, top } = vueFlowRef.value!.getBoundingClientRect();
+
+    const position = project({
+      x: event.clientX - left,
+      y: event.clientY - top,
+    });
+
+    const newNode = {
+      id: new Date().getTime().toString(),
+      type: type,
+      position,
+      label: type,
+      data: data,
+    };
+    console.log('添加节点', newNode);
+
+    addNodes([newNode]);
+  }
+</script>
+
+<template>
+  <div @drop="onDrop" class="h-full w-full relative" id="graph-layout">
+    <CardLayout />
+
+    <CustomZoom />
+    <VueFlow @dragover="onDragOver" v-model="list" :node-types="nodeTypes">
+      <Background />
+      <MiniMap />
+
+      <template #edge-custom="props">
+        <CustomEdge v-bind="props" />
+      </template>
+    </VueFlow>
+  </div>
+</template>
+
+<style scoped></style>

+ 65 - 0
langchat-ui/src/views/flow/layout/GraphMenuLayout.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+  import { nextTick, ref } from 'vue';
+
+  const contextmenu = ref({
+    show: false,
+    x: 0,
+    y: 0,
+    options: [
+      {
+        label: 'Standard Node',
+        key: 'standard',
+      },
+      {
+        label: 'Paste',
+        key: 'paste',
+      },
+      {
+        label: 'divider',
+        type: 'divider',
+      },
+      {
+        label: 'New Text',
+        key: 'text',
+      },
+    ],
+  });
+
+  function show(e: PointerEvent) {
+    contextmenu.value.show = false;
+    e.preventDefault();
+    nextTick().then(() => {
+      contextmenu.value.show = true;
+      contextmenu.value.x = e.clientX;
+      contextmenu.value.y = e.clientY;
+    });
+  }
+
+  function handleGraphMenuSelect(key: string) {
+    contextmenu.value.show = false;
+
+    if (key === 'standard') {
+    }
+    if (key === 'paste') {
+    }
+  }
+
+  defineExpose({ show });
+</script>
+
+<template>
+  <!-- 画布空白区域的右键菜单 -->
+  <n-dropdown
+    class="custom-dropdown"
+    size="small"
+    trigger="manual"
+    :x="contextmenu.x"
+    :y="contextmenu.y"
+    :options="contextmenu.options"
+    :show="contextmenu.show"
+    @clickoutside="contextmenu.show = false"
+    @select="handleGraphMenuSelect"
+  />
+</template>
+
+<style scoped lang="less"></style>

+ 60 - 0
langchat-ui/src/views/flow/layout/Layout.vue

@@ -0,0 +1,60 @@
+<script setup lang="ts">
+  import { onMounted, ref, computed } from 'vue';
+  import GraphLayout from '@/views/flow/layout/GraphLayout.vue';
+  import PinLayout from '@/views/flow/layout/PinLayout.vue';
+  import { useProjectSettingStore } from '@/store/modules/projectSetting';
+  import VueSplitter from '@rmp135/vue-splitter';
+  import { useVueFlow } from '@vue-flow/core';
+  import { getById } from '@/api/aigc/flow';
+  import { isNullOrWhitespace } from '@/utils/is';
+  import { useRouter } from 'vue-router';
+  import { useFlowStore } from '@/views/flow/store';
+
+  // 预先初始化一个Flow instance,后续组件使用useVueFlow()函数将共享这个实例
+  useVueFlow();
+
+  const loading = ref(true);
+  const flowStore = useFlowStore();
+  const router = useRouter();
+  const projectStore = useProjectSettingStore();
+
+  onMounted(async () => {
+    const data = await getById(String(router.currentRoute.value.params.id));
+    flowStore.data = data;
+    if (isNullOrWhitespace(data.flow)) {
+      await router.push({ name: 'flow_initialize', params: { id: data.id } });
+    } else {
+      projectStore.setMenuCollapse();
+    }
+    loading.value = false;
+  });
+
+  const percent = ref(80);
+  const limitedPercent = computed({
+    get() {
+      return percent.value;
+    },
+    set(val) {
+      percent.value = Math.max(50, Math.min(98, val));
+    },
+  });
+</script>
+
+<template>
+  <div class="w-full" style="height: calc(100vh - 80px)">
+    <n-spin :show="loading" description="正在加载Flow流程">
+      <vue-splitter initial-percent="80" v-model:percent="limitedPercent" v-if="flowStore.data">
+        <template #left-pane>
+          <GraphLayout class="h-full" />
+        </template>
+        <template #right-pane>
+          <PinLayout class="h-full" />
+        </template>
+      </vue-splitter>
+    </n-spin>
+  </div>
+</template>
+
+<style lang="less">
+  @import '@/styles/flow';
+</style>

+ 102 - 0
langchat-ui/src/views/flow/layout/PinLayout.vue

@@ -0,0 +1,102 @@
+<script setup lang="ts">
+  import VueSplitter from '@rmp135/vue-splitter';
+  import { computed, onMounted, ref } from 'vue';
+  import { ArrowUndoOutline, CloudDoneOutline } from '@vicons/ionicons5';
+  import { useRouter } from 'vue-router';
+  import { useFlowStore } from '@/views/flow/store';
+  import { renderIcon } from '@/utils';
+  import CustomExec from '@/views/flow/custom/CustomExec.vue';
+  import { useVueFlow } from '@vue-flow/core';
+  import { getDatas } from '@/views/flow/store/get';
+  import { update as updateFlow } from '@/api/aigc/flow';
+
+  const { onNodeClick, onPaneClick, toObject, getConnectedEdges } = useVueFlow();
+  const flowStore = useFlowStore();
+  const router = useRouter();
+
+  onNodeClick((e) => {
+    console.log('点击node');
+    flowStore.initNode(e.node);
+
+    const edges = getConnectedEdges(e.node.id);
+    if (e.node.selected) {
+      edges.forEach((e) => {
+        e.selected = true;
+        e.zIndex = 1;
+      });
+    } else {
+      edges.forEach((e) => {
+        e.selected = false;
+        e.zIndex = 0;
+      });
+    }
+  });
+  onPaneClick((e) => {
+    console.log('点击graph');
+    flowStore.cleanNode();
+  });
+
+  async function handleSubmit() {
+    console.log('save', getDatas(toObject()));
+    await updateFlow({
+      id: String(router.currentRoute.value.params.id),
+      flow: JSON.stringify(getDatas(toObject())),
+    });
+    window['$message'].success('保存成功');
+  }
+
+  const percent = ref(70);
+  const limitedPercent = computed({
+    get() {
+      return percent.value;
+    },
+    set(val) {
+      percent.value = Math.max(10, Math.min(80, val));
+    },
+  });
+  function handlePaneClick(per: number) {
+    limitedPercent.value = per;
+  }
+</script>
+
+<template>
+  <vue-splitter
+    initial-percent="70"
+    is-horizontal
+    v-model:percent="percent"
+    style="background: white"
+  >
+    <template #left-pane>
+      <div class="h-full rounded-xl overflow-y-hidden card-shadow" @click="handlePaneClick(70)">
+        <div class="border-b border-1 flex justify-between items-center p-2">
+          <n-button
+            type="success"
+            size="small"
+            secondary
+            :render-icon="renderIcon(CloudDoneOutline)"
+            @click="handleSubmit"
+          >
+            保存流程
+          </n-button>
+          <n-button
+            @click="router.back()"
+            type="error"
+            size="small"
+            secondary
+            :render-icon="renderIcon(ArrowUndoOutline)"
+          >
+            返回
+          </n-button>
+        </div>
+        <div class="p-4 overflow-y-auto h-full pb-20">
+          <component :is="flowStore.pinComponent" />
+        </div>
+      </div>
+    </template>
+    <template #right-pane>
+      <CustomExec @click="handlePaneClick(30)" />
+    </template>
+  </vue-splitter>
+</template>
+
+<style lang="less"></style>

+ 66 - 0
langchat-ui/src/views/flow/layout/components/NodeCard.vue

@@ -0,0 +1,66 @@
+<script setup lang="ts">
+  import { Pin, renderNodeIcon } from '@/views/flow/store/get';
+  import Draggable from 'vuedraggable';
+
+  interface Props {
+    list: Array<any>;
+  }
+  const props = defineProps<Props>();
+
+  function onDragStart(e: any, v: Pin) {
+    if (e.dataTransfer) {
+      e.dataTransfer.setData('isNode', true);
+      e.dataTransfer.setData('data', JSON.stringify(v));
+    }
+  }
+</script>
+
+<template>
+  <n-collapse class="panel" :default-expanded-names="[0, 1, 2, 3, 4, 5, 6, 7]">
+    <n-collapse-item
+      v-for="(item, index) in props.list"
+      :key="index"
+      :title="item.key"
+      :name="index"
+    >
+      <Draggable
+        animation="300"
+        :list="item.value"
+        :group="{ name: 'plugins', pull: 'clone', put: false }"
+        itemKey="label"
+        :sort="false"
+        :clone="(e) => e"
+        class="flex flex-col gap-2 w-full"
+      >
+        <template #item="{ element }">
+          <n-card
+            @dragstart="onDragStart($event, element)"
+            :draggable="true"
+            hoverable
+            size="small"
+            class="rounded-md cursor-pointer"
+            header-class="pb-0"
+            content-class="pb-2"
+          >
+            <template #header>
+              <div class="flex justify-start items-center">
+                <n-icon>
+                  <component :is="renderNodeIcon(element.type)" />
+                </n-icon>
+                <span class="text-[14px] ml-1">{{ element.type }}</span>
+              </div>
+            </template>
+            <template #header-extra>
+              <n-button text type="primary">
+                <SvgIcon class="text-lg" icon="ic:baseline-plus" />
+              </n-button>
+            </template>
+            <span class="text-gray-400 text-xs">{{ element.des }}</span>
+          </n-card>
+        </template>
+      </Draggable>
+    </n-collapse-item>
+  </n-collapse>
+</template>
+
+<style scoped lang="less"></style>

+ 63 - 0
langchat-ui/src/views/flow/layout/components/PluginCard.vue

@@ -0,0 +1,63 @@
+<script setup lang="ts">
+  import { Pin, renderNodeIcon } from '@/views/flow/store/get';
+  import Draggable from 'vuedraggable';
+
+  interface Props {
+    list: Array<any>;
+  }
+  const props = defineProps<Props>();
+
+  function onDragStart(e: any, v: Pin) {
+    if (e.dataTransfer) {
+      e.dataTransfer.setData('isNode', false);
+    }
+  }
+</script>
+
+<template>
+  <n-collapse class="panel" :default-expanded-names="[0, 1, 2, 3, 4, 5, 6, 7]">
+    <n-collapse-item
+      v-for="(item, index) in props.list"
+      :key="index"
+      :title="item.key"
+      :name="index"
+    >
+      <Draggable
+        animation="300"
+        :list="item.value"
+        :group="{ name: 'plugins', pull: 'clone', put: false }"
+        itemKey="label"
+        :sort="false"
+        :clone="(e) => e"
+        class="flex flex-col gap-2 w-full"
+      >
+        <template #item="{ element }">
+          <n-card
+            @dragstart="onDragStart($event, element)"
+            :draggable="true"
+            hoverable
+            size="small"
+            class="rounded-md cursor-pointer"
+            header-class="pb-1"
+            content-class="pb-2"
+          >
+            <template #header>
+              <n-icon>
+                <component :is="renderNodeIcon(element.type)" />
+              </n-icon>
+              {{ element.type }}
+            </template>
+            <template #header-extra>
+              <n-button text type="primary">
+                <SvgIcon class="text-lg" icon="ic:baseline-plus" />
+              </n-button>
+            </template>
+            <span class="text-gray-400 text-xs">{{ element.des }}</span>
+          </n-card>
+        </template>
+      </Draggable>
+    </n-collapse-item>
+  </n-collapse>
+</template>
+
+<style scoped lang="less"></style>

+ 14 - 0
langchat-ui/src/views/flow/node/AssistNode.vue

@@ -0,0 +1,14 @@
+<script setup lang="ts">
+  import { NodeLayout } from '@/views/flow/node';
+  import { useNode } from '@vue-flow/core';
+
+  const { node } = useNode();
+</script>
+
+<template>
+  <NodeLayout :node="node">
+    <template #content> 1111 </template>
+  </NodeLayout>
+</template>
+
+<style scoped lang="less"></style>

+ 89 - 0
langchat-ui/src/views/flow/node/LLMNode.vue

@@ -0,0 +1,89 @@
+<script setup lang="ts">
+  import { NodeLayout } from '@/views/flow/node';
+  import { useNode } from '@vue-flow/core';
+  import { onMounted, ref, toRaw } from 'vue';
+
+  const { node } = useNode();
+  const options = [
+    {
+      label: 'gpt-4',
+      value: 'gpt-4',
+    },
+    {
+      label: 'gpt-3.5',
+      value: 'gpt-3.5',
+    },
+  ];
+  const form = ref<{
+    model: string;
+    temperate: number;
+  }>({
+    model: '',
+    temperate: 0,
+  });
+
+  onMounted(() => {
+    const { model, temperate } = toRaw(node.data);
+    form.value.model = model;
+    form.value.temperate = temperate;
+  });
+</script>
+
+<template>
+  <NodeLayout :node="node">
+    <template #content>
+      <div class="w-full h-full flex flex-col gap-2 mt-1 mb-2">
+        <div class="bg-[#f7f7f7] p-2 flex flex-row justify-between items-center rounded-md">
+          <div class="flex gap-1 w-full items-center">
+            <span class="text-xs text-gray-400">模型</span>
+            <n-select
+              :options="options"
+              v-model:value="form.model"
+              size="tiny"
+              class="w-[78%] min-select"
+            />
+          </div>
+          <div class="flex gap-1 w-full items-center justify-end">
+            <span class="text-xs text-gray-400">温度</span>
+            <n-input-number
+              size="tiny"
+              v-model:value="form.temperate"
+              :max="1"
+              :min="0"
+              :step="0.1"
+              class="w-[78%]"
+            />
+          </div>
+        </div>
+        <div class="bg-[#f7f7f7] p-2 rounded-md">
+          <n-collapse class="min-collapse">
+            <n-collapse-item name="1">
+              <template #header>
+                <div class="text-xs flex gap-0.5">
+                  <span>Prompt</span>
+                  <n-popover trigger="hover">
+                    <template #trigger>
+                      <SvgIcon icon="tabler:exclamation-circle" class="text-gray-400" />
+                    </template>
+                    Prompt
+                  </n-popover>
+                </div>
+              </template>
+              <template #header-extra>
+                <n-button text size="tiny">
+                  <n-icon class="mr-1">
+                    <SvgIcon icon="iconamoon:screen-full" />
+                  </n-icon>
+                  Full Screen
+                </n-button>
+              </template>
+              <n-input type="textarea" class="text-xs" />
+            </n-collapse-item>
+          </n-collapse>
+        </div>
+      </div>
+    </template>
+  </NodeLayout>
+</template>
+
+<style scoped lang="less"></style>

+ 186 - 0
langchat-ui/src/views/flow/node/NodeLayout.vue

@@ -0,0 +1,186 @@
+<script setup lang="ts">
+  import { Handle, Position, useVueFlow } from '@vue-flow/core';
+  import Draggable from 'vuedraggable';
+  import { AddOutline, CopyOutline } from '@vicons/ionicons5';
+  import { ref } from 'vue';
+  import { useFlowStore } from '@/views/flow/store';
+  import { renderIcon, renderPropsIcon } from '@/utils';
+  import { type GraphNode } from '@vue-flow/core';
+  import { Pin, renderNodeDes, renderNodeIcon } from '@/views/flow/store/get';
+
+  const plugins = ref([]);
+  interface Props {
+    node: GraphNode;
+  }
+  const props = defineProps<Props>();
+  const { node } = props;
+  plugins.value = node.data.plugins ?? [];
+
+  const { removeNodes } = useVueFlow();
+  const flowStore = useFlowStore();
+
+  function onClickAdd() {
+    node.selected = true;
+    flowStore.setShowCard();
+  }
+
+  function onAddEle(e: any) {
+    const item: any = plugins.value[e.newIndex];
+    if (item.isNode) {
+      // 对于节点,不需要作为plugin添加
+      plugins.value.splice(e.newIndex, 1);
+    } else {
+      // 校验不准许出现相同plugin
+      const filter = plugins.value.filter((i) => i.type == item.type);
+      if (filter.length > 1) {
+        window['$message'].warning('Plugin is existed');
+        plugins.value.splice(e.newIndex, 1);
+        return;
+      }
+
+      // @ts-ignore
+      plugins.value[e.newIndex] = {
+        type: item.type,
+        label: item.label,
+      };
+    }
+    node.data.plugins = plugins.value;
+    console.log('添加', plugins.value);
+  }
+
+  function onValid(e, e2): boolean {
+    return true;
+  }
+
+  function onPluginClick(plugin: Pin) {
+    node.selected = true;
+    flowStore.initPlugin(plugin);
+  }
+
+  const menuOptions = [
+    {
+      label: 'Delete',
+      key: 'delete',
+    },
+  ];
+  function onClickMenu(key: string) {
+    if (key === 'delete') {
+      console.log('删除', flowStore.nodeId);
+      removeNodes(flowStore.nodeId);
+    }
+  }
+</script>
+
+<template>
+  <n-el
+    tag="div"
+    class="w-[340px] rounded-md shadow custom-node p-2.5"
+    style="background: var(--card-color)"
+    :class="node.selected ? 'node-selected' : ''"
+  >
+    <div class="flex justify-between items-center h-full">
+      <div class="flex items-center">
+        <n-icon class="mr-1">
+          <component :is="renderNodeIcon(node.type)" />
+        </n-icon>
+        <n-input
+          v-model:value="node.label"
+          size="small"
+          class="hover-input font-bold text-lg w-[80%]"
+          placeholder=""
+        />
+      </div>
+      <n-dropdown trigger="click" :options="menuOptions" @select="onClickMenu">
+        <n-button text type="primary" class="z-20">
+          <n-icon class="ml-1 text-[17px]">
+            <svg
+              width="10"
+              height="18"
+              viewBox="1 0 4 13"
+              fill="none"
+              xmlns="http://www.w3.org/2000/svg"
+            >
+              <path
+                d="M1 0.5H5M1 5.5H5M1 10.5H5"
+                stroke="currentColor"
+                stroke-linecap="round"
+                stroke-linejoin="round"
+              />
+            </svg>
+          </n-icon>
+        </n-button>
+      </n-dropdown>
+    </div>
+    <div class="text-xs text-gray-400 outline-2 pt-1 pb-1">{{ renderNodeDes(node.type) }}</div>
+
+    <slot name="content"></slot>
+
+    <div class="">
+      <Draggable
+        animation="300"
+        itemKey="label"
+        :list="plugins"
+        class="nodrag"
+        group="plugins"
+        @add="onAddEle"
+      >
+        <template #item="{ element }">
+          <n-button
+            :class="
+              flowStore.pin !== null && element.type == flowStore.pin.type ? 'hover-button' : ''
+            "
+            size="small"
+            icon-placement="left"
+            block
+            class="mb-1 justify-start nodrag text-[12px]"
+            :render-icon="renderNodeIcon(element.type)"
+            @click.stop="onPluginClick(element)"
+          >
+            {{ element.label ?? element.type }}
+          </n-button>
+        </template>
+      </Draggable>
+
+      <n-button
+        v-if="!plugins.length"
+        size="small"
+        block
+        dashed
+        :render-icon="renderPropsIcon(CopyOutline, { size: '14px' })"
+        class="text-xs text-gray-400"
+      >
+        Drag Drop This
+      </n-button>
+
+      <n-button
+        class="nodrag mt-2 mb-2 justify-start"
+        size="small"
+        block
+        dashed
+        @click="onClickAdd"
+        :render-icon="renderIcon(AddOutline)"
+      >
+        Add Card
+      </n-button>
+    </div>
+
+    <Handle
+      id="2"
+      type="target"
+      :position="Position.Left"
+      class="!top-[23px]"
+      :class="node.selected ? '!bg-[#2d8cf0]' : ''"
+    />
+
+    <Handle
+      id="1"
+      type="source"
+      :position="Position.Right"
+      :is-valid-connection="onValid"
+      class="!top-auto !bottom-[21px] bg-[#36ad6a]"
+      :class="node.selected ? '!bg-[#2d8cf0]' : ''"
+    />
+  </n-el>
+</template>
+
+<style scoped lang="less"></style>

+ 39 - 0
langchat-ui/src/views/flow/node/common/End.vue

@@ -0,0 +1,39 @@
+<script setup lang="ts">
+  import { Handle, Position, useNode } from '@vue-flow/core';
+  import { useFlowStore } from '@/views/flow/store';
+
+  const { node } = useNode();
+  const flowStore = useFlowStore();
+</script>
+
+<template>
+  <div
+    class="w-20 h-full bg-gray-200 rounded-md custom-node"
+    :class="node.selected ? 'node-selected' : ''"
+  >
+    <div class="flex justify-left items-center h-full p-2 pr-2.5">
+      <n-icon>
+        <svg
+          width="10"
+          height="18"
+          viewBox="1 0 4 13"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+          style="color: #d4d4d8; display: inline-block"
+        >
+          <path
+            d="M1 0.5H5M1 5.5H5M1 10.5H5"
+            stroke="currentColor"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+          />
+        </svg>
+      </n-icon>
+      <span class="font-bold pl-1">End</span>
+    </div>
+
+    <Handle type="target" :position="Position.Left" :class="node.selected ? '!bg-[#2d8cf0]' : ''" />
+  </div>
+</template>
+
+<style scoped lang="less"></style>

+ 43 - 0
langchat-ui/src/views/flow/node/common/Start.vue

@@ -0,0 +1,43 @@
+<script setup lang="ts">
+  import { Handle, Position, useNode } from '@vue-flow/core';
+  import { useFlowStore } from '@/views/flow/store';
+
+  const { node } = useNode();
+  const flowStore = useFlowStore();
+</script>
+
+<template>
+  <div
+    class="w-20 h-full !bg-[#dff0e4] !text-[#18a058] rounded-md custom-node"
+    :class="node.selected ? 'node-selected' : ''"
+  >
+    <div class="flex justify-left items-center h-full p-2 pr-2.5">
+      <n-icon>
+        <svg
+          width="10"
+          height="18"
+          viewBox="1 0 4 13"
+          fill="none"
+          xmlns="http://www.w3.org/2000/svg"
+          style="color: #d4d4d8; display: inline-block"
+        >
+          <path
+            d="M1 0.5H5M1 5.5H5M1 10.5H5"
+            stroke="currentColor"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+          />
+        </svg>
+      </n-icon>
+      <span class="font-bold">Start</span>
+    </div>
+
+    <Handle
+      type="source"
+      :position="Position.Right"
+      :class="node.selected ? '!bg-[#2d8cf0]' : ''"
+    />
+  </div>
+</template>
+
+<style scoped lang="less"></style>

+ 7 - 0
langchat-ui/src/views/flow/node/index.ts

@@ -0,0 +1,7 @@
+import NodeLayout from './NodeLayout.vue';
+import Start from './common/Start.vue';
+import End from './common/End.vue';
+import AssistNode from './AssistNode.vue';
+import LLMNode from './LLMNode.vue';
+
+export { NodeLayout, AssistNode, Start, End, LLMNode };

+ 34 - 0
langchat-ui/src/views/flow/pin/BlankPin.vue

@@ -0,0 +1,34 @@
+<script setup lang="ts">
+  import { useFlowStore } from '@/views/flow/store';
+  import { onMounted, ref } from 'vue';
+  import { Flow } from '@/api/models/flow';
+
+  const flowStore = useFlowStore();
+  const form = ref<Flow>({});
+
+  onMounted(() => {
+    if (!flowStore.data) {
+      return;
+    }
+    const { name, des } = flowStore.data;
+    form.value.name = name;
+    form.value.des = des;
+  });
+</script>
+
+<template>
+  <div class="mb-2">
+    <div class="pin-title">
+      <n-input class="hover-input text-lg" v-model:value="form.name" />
+    </div>
+    <n-input
+      type="textarea"
+      size="small"
+      class="hover-input text-xs text-gray-300"
+      placeholder="Node description..."
+      v-model:value="form.des"
+    />
+  </div>
+</template>
+
+<style scoped lang="less"></style>

+ 9 - 0
langchat-ui/src/views/flow/pin/index.ts

@@ -0,0 +1,9 @@
+import BlankPin from './BlankPin.vue';
+import LLMPin from './node/LLMPin.vue';
+import AssistPin from './node/AssistPin.vue';
+import StartPin from './node/StartPin.vue';
+import EndPin from './node/EndPin.vue';
+import KnowledgeDoc from './plugin/KnowledgeDoc.vue';
+import KnowledgeWeb from './plugin/KnowledgeWeb.vue';
+
+export { BlankPin, LLMPin, AssistPin, StartPin, EndPin, KnowledgeDoc, KnowledgeWeb };

+ 36 - 0
langchat-ui/src/views/flow/pin/node/AssistPin.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+  import { useFlowStore } from '@/views/flow/store';
+
+  const flowStore = useFlowStore();
+</script>
+
+<template>
+  <div class="mb-2">
+    <div class="pin-title">
+      <n-input class="hover-input text-lg" />
+    </div>
+    <n-input
+      type="textarea"
+      size="small"
+      class="hover-input text-xs text-gray-300"
+      placeholder="Node description..."
+    />
+  </div>
+  <n-form ref="formRef" class="custom-form">
+    <n-space vertical :size="16">
+      <n-form-item path="age">
+        <template #label>
+          <n-popover placement="bottom" class="custom-popover">
+            给AI传入的文本内容
+            <template #trigger>
+              <div class="tips-line">输入(Prompt)</div>
+            </template>
+          </n-popover>
+        </template>
+        <n-input disabled />
+      </n-form-item>
+    </n-space>
+  </n-form>
+</template>
+
+<style scoped lang="less"></style>

+ 7 - 0
langchat-ui/src/views/flow/pin/node/EndPin.vue

@@ -0,0 +1,7 @@
+<script setup lang="ts"></script>
+
+<template>
+  <div>End...</div>
+</template>
+
+<style scoped lang="less"></style>

+ 36 - 0
langchat-ui/src/views/flow/pin/node/LLMPin.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+  import { useFlowStore } from '@/views/flow/store';
+
+  const flowStore = useFlowStore();
+</script>
+
+<template>
+  <div class="mb-2">
+    <div class="pin-title">
+      <n-input class="hover-input text-lg" />
+    </div>
+    <n-input
+      type="textarea"
+      size="small"
+      class="hover-input text-xs text-gray-300"
+      placeholder="Node description..."
+    />
+  </div>
+  <n-form ref="formRef" class="custom-form">
+    <n-space vertical :size="16">
+      <n-form-item path="age">
+        <template #label>
+          <n-popover placement="bottom" class="custom-popover">
+            给AI传入的文本内容
+            <template #trigger>
+              <div class="tips-line">输入(Prompt)</div>
+            </template>
+          </n-popover>
+        </template>
+        <n-input disabled />
+      </n-form-item>
+    </n-space>
+  </n-form>
+</template>
+
+<style scoped lang="less"></style>

+ 7 - 0
langchat-ui/src/views/flow/pin/node/StartPin.vue

@@ -0,0 +1,7 @@
+<script setup lang="ts"></script>
+
+<template>
+  <div>Start</div>
+</template>
+
+<style scoped lang="less"></style>

+ 113 - 0
langchat-ui/src/views/flow/pin/plugin/KnowledgeDoc.vue

@@ -0,0 +1,113 @@
+<script setup lang="ts">
+  import { ref } from 'vue';
+  import { basicModal, useModal } from '@/components/Modal';
+  import { SparklesOutline, DocumentTextOutline } from '@vicons/ionicons5';
+
+  const docList = ref(['1', '2']);
+
+  const [
+    modalRegister,
+    { openModal: openModal, closeModal: closeModal, setSubLoading: setSubLoading },
+  ] = useModal({
+    title: '新增/编辑',
+    showSubBtn: false,
+  });
+
+  function onDocClick() {
+    openModal();
+  }
+</script>
+
+<template>
+  <n-form ref="formRef">
+    <n-form-item path="age" class="custom-form-item">
+      <template #label>
+        <n-popover placement="bottom" class="custom-popover">
+          从历史数据中获取
+          <template #trigger>
+            <div class="tips-line pin-label">从数据库中选择</div>
+          </template>
+        </n-popover>
+      </template>
+      <n-select v-model="docList" size="small" />
+    </n-form-item>
+
+    <n-form-item path="age" class="custom-form-item">
+      <template #label>
+        <n-popover placement="bottom" class="custom-popover">
+          仅支持XX文档
+          <template #trigger>
+            <div class="tips-line pin-label">知识库文档名称</div>
+          </template>
+        </n-popover>
+      </template>
+      <n-input size="small" />
+    </n-form-item>
+  </n-form>
+  <div class="">
+    <n-upload
+      multiple
+      directory-dnd
+      action="https://www.mocky.io/v2/5e4bafc63100007100d8b70f"
+      :max="5"
+      class="text-[#bdbdbd]"
+    >
+      <n-upload-dragger>
+        <div class="text-[12px]">
+          点击上传或拖拽文件到这里
+          <br />
+          仅支持(.pdf .html .text .doc .docx)文件
+        </div>
+      </n-upload-dragger>
+    </n-upload>
+  </div>
+
+  <n-space vertical v-if="docList.length">
+    <n-thing v-for="item in docList" :key="item" class="custom-list" @click="onDocClick">
+      <template #avatar>
+        <n-icon size="18">
+          <DocumentTextOutline />
+        </n-icon>
+      </template>
+      <template #header>
+        <div class="text-[12px]">{{ item }}</div>
+      </template>
+      <template #description> <div class="text-[10px]">描述</div> </template>
+    </n-thing>
+  </n-space>
+
+  <basicModal @register="modalRegister" style="width: 30%"> 1111 </basicModal>
+</template>
+
+<style scoped lang="less">
+  ::v-deep(.n-upload-dragger) {
+    background: transparent !important;
+    padding: 15px !important;
+    &:hover {
+      color: #2d8cf0 !important;
+    }
+  }
+  .custom-list {
+    border: 1px solid rgb(224, 224, 230);
+    border-radius: 5px;
+    padding: 4px;
+    cursor: pointer;
+    &:hover {
+      color: #2d8cf0 !important;
+      ::v-deep(.n-thing-header__title) {
+        color: #2d8cf0 !important;
+      }
+      ::v-deep(.n-thing-main__description) {
+        color: #2d8cf0 !important;
+      }
+    }
+    ::v-deep(.n-thing-header__title) {
+    }
+  }
+
+  .backdrop {
+    background: rgba(255, 255, 255, 0.04);
+    backdrop-filter: blur(4px);
+    -webkit-backdrop-filter: blur(4px);
+  }
+</style>

+ 21 - 0
langchat-ui/src/views/flow/pin/plugin/KnowledgeWeb.vue

@@ -0,0 +1,21 @@
+<script setup lang="ts"></script>
+
+<template>
+  <n-form ref="formRef" class="custom-form">
+    <n-space vertical :size="16">
+      <n-form-item path="age">
+        <template #label>
+          <n-popover placement="bottom" class="custom-popover">
+            文档文件的URL地址,请确保该地址可下载文件
+            <template #trigger>
+              <div class="tips-line">HTTP URL</div>
+            </template>
+          </n-popover>
+        </template>
+        <n-input size="small" />
+      </n-form-item>
+    </n-space>
+  </n-form>
+</template>
+
+<style scoped lang="less"></style>

+ 207 - 0
langchat-ui/src/views/flow/store/get.ts

@@ -0,0 +1,207 @@
+import { Component, markRaw } from 'vue';
+import { SparklesOutline, DocumentTextOutline } from '@vicons/ionicons5';
+import { LLMPin, AssistPin, EndPin, StartPin, KnowledgeWeb, KnowledgeDoc } from '@/views/flow/pin';
+import { AssistNode, End, LLMNode, Start } from '@/views/flow/node';
+import { renderPropsIcon } from '@/utils';
+import type { FlowExportObject } from '@vue-flow/core/dist/types/flow';
+
+export enum TypeEnum {
+  Start = 'Start',
+  End = 'End',
+  Assist = 'Assist',
+  Http = 'Http',
+  LLM = 'LLM',
+}
+
+export enum PluginEnum {
+  Input = 'Input',
+  ExecuteCode = 'ExecuteCode',
+  Knowledge_Web = 'From Web',
+  Knowledge_Doc = 'From Doc',
+}
+
+enum ColEnum {
+  Base = 'Base',
+  Node = 'Node',
+  Ai = 'Ai',
+  SendMessage = 'Send Message',
+  Knowledge = 'Knowledge List',
+}
+
+// 节点类型定义,注意非节点不要定义此变量
+export const nodeTypes = {
+  [TypeEnum.Start]: markRaw(Start),
+  [TypeEnum.End]: markRaw(End),
+  [TypeEnum.Assist]: markRaw(AssistNode),
+  [TypeEnum.LLM]: markRaw(LLMNode),
+};
+
+export interface Pin {
+  type?: TypeEnum | PluginEnum;
+  label?: string;
+  des?: string;
+  component?: Component;
+  col?: ColEnum;
+  isNode?: boolean;
+  data?: any;
+}
+
+const nodePins: Pin[] = [
+  {
+    type: TypeEnum.LLM,
+    component: LLMPin,
+    col: ColEnum.Node,
+    isNode: true,
+    des: '利用LLM进行文本消息问答',
+    data: {
+      model: 'gpt-4',
+      temperate: 0.7,
+    },
+  },
+  {
+    type: TypeEnum.Assist,
+    component: AssistPin,
+    col: ColEnum.Node,
+    isNode: true,
+    des: '利用LLM进行文本消息问答',
+  },
+  {
+    type: TypeEnum.Http,
+    component: StartPin,
+    col: ColEnum.Node,
+    isNode: true,
+    des: '发送HTTP请求',
+  },
+
+  { type: TypeEnum.End, component: EndPin, col: ColEnum.Base, isNode: true },
+  { type: TypeEnum.Start, component: StartPin, col: ColEnum.Base, isNode: true },
+];
+
+const pluginPins: Pin[] = [
+  {
+    type: PluginEnum.Input,
+    label: 'Input Node',
+    component: StartPin,
+    col: ColEnum.SendMessage,
+    isNode: false,
+  },
+
+  {
+    type: PluginEnum.ExecuteCode,
+    label: 'ExecuteCode Node',
+    component: StartPin,
+    col: ColEnum.Ai,
+    isNode: false,
+  },
+  {
+    type: PluginEnum.Knowledge_Web,
+    label: 'Knowledge From Web',
+    component: KnowledgeWeb,
+    col: ColEnum.Knowledge,
+    isNode: false,
+  },
+  {
+    type: PluginEnum.Knowledge_Doc,
+    label: 'Knowledge From Doc',
+    component: KnowledgeDoc,
+    col: ColEnum.Knowledge,
+    isNode: false,
+  },
+];
+
+export function getPin(type: string | undefined, isNode: boolean): Pin | undefined {
+  if (type === undefined) {
+    return;
+  }
+  const pins = isNode ? nodePins : pluginPins;
+  const list = pins.filter((i) => i.type?.toLowerCase() === type.toLowerCase());
+  return list.length > 0 ? list[0] : undefined;
+}
+
+export const collapses = (
+  isNode: boolean
+): {
+  key: string;
+  value: Pin[];
+}[] => {
+  const pins = isNode ? nodePins : pluginPins;
+  const transformedArray = pins.reduce((result, pin) => {
+    const { col } = pin;
+    if (col == undefined || col == ColEnum.Base) {
+      return result;
+    }
+    if (col in result) {
+      result[col].push(pin);
+    } else {
+      result[col] = [pin];
+    }
+    return result;
+  }, {});
+  // @ts-ignore
+  return Object.entries(transformedArray).map(([key, value]) => ({
+    key,
+    value,
+  }));
+};
+
+export function getDatas(obj: FlowExportObject): any[] {
+  const data: any[] = [];
+  obj.edges.forEach((i) => {
+    data.push({
+      id: i.id,
+      source: i.source,
+      target: i.target,
+      type: i.type,
+      animated: i.animated,
+      data: i.data,
+      label: i.label,
+    });
+  });
+  obj.nodes.forEach((i) => {
+    data.push({
+      id: i.id,
+      type: i.type,
+      position: i.position,
+      data: i.data,
+      label: i.label,
+    });
+  });
+  return data;
+}
+
+const icons = [
+  {
+    type: TypeEnum.LLM,
+    icon: renderPropsIcon(SparklesOutline, { color: '#8a2be2', size: '15px' }),
+  },
+  {
+    type: TypeEnum.Assist,
+    icon: renderPropsIcon(SparklesOutline, { color: '#8a2be2', size: '15px' }),
+  },
+  {
+    type: PluginEnum.Input,
+    icon: renderPropsIcon(SparklesOutline, { color: '#8a2be2', size: '15px' }),
+  },
+  {
+    type: PluginEnum.ExecuteCode,
+    icon: renderPropsIcon(SparklesOutline, { color: '#8a2be2', size: '15px' }),
+  },
+  {
+    type: PluginEnum.Knowledge_Web,
+    icon: renderPropsIcon(DocumentTextOutline, { size: '15px' }),
+  },
+  {
+    type: PluginEnum.Knowledge_Doc,
+    icon: renderPropsIcon(DocumentTextOutline, { size: '15px' }),
+  },
+];
+
+export function renderNodeIcon(type: string) {
+  const list = icons.filter((i) => i.type == type);
+  return list.length > 0 ? list[0].icon : undefined;
+}
+
+export function renderNodeDes(type: string) {
+  const list = nodePins.filter((i) => i.type == type);
+  return list.length > 0 ? list[0].des : undefined;
+}

+ 62 - 0
langchat-ui/src/views/flow/store/index.ts

@@ -0,0 +1,62 @@
+import { GraphNode } from '@vue-flow/core';
+import { defineStore } from 'pinia';
+import { getPin, Pin } from '@/views/flow/store/get';
+import { Component, shallowRef, toRaw } from 'vue';
+import { BlankPin } from '@/views/flow/pin';
+import { Flow } from '@/api/models/flow';
+
+export interface FlowState {
+  data: Flow | undefined; // 当前编辑的流程数据
+  nodeId: string; // 当前激活的NodeId,通过useVueFlow().findNode获取实例对象
+  pin: Pin | null; // 当前激活节点的pin component
+  pinComponent: Component;
+  showCard: boolean; // 是否展示Node Card面板
+}
+
+export const useFlowStore = defineStore({
+  id: 'flow-store',
+  state: (): FlowState => ({
+    data: undefined,
+    nodeId: '',
+    pin: null,
+    pinComponent: shallowRef(BlankPin),
+    showCard: false,
+  }),
+
+  actions: {
+    cleanNode() {
+      this.nodeId = '';
+      this.pin = null;
+      this.pinComponent = BlankPin;
+    },
+    setShowCard() {
+      this.showCard = !this.showCard;
+    },
+
+    initNode(node: GraphNode) {
+      this.nodeId = node.id;
+      this.pin = {};
+      const pin = getPin(node.type, true);
+      if (pin !== undefined && pin.component !== undefined) {
+        this.pin = pin;
+        this.pin.label = String(node.label);
+        this.pinComponent = pin.component;
+      }
+      if (node.data !== undefined && node.data.des !== undefined) {
+        this.pin.des = toRaw(node.data.des);
+      }
+    },
+
+    initPlugin(plugin: Pin) {
+      const pin = getPin(plugin.type, false);
+      if (pin == undefined || pin.component == undefined) {
+        return;
+      }
+      this.pin = pin;
+      this.pinComponent = pin.component;
+      if (plugin.des == undefined) {
+        this.pin.des = '';
+      }
+    },
+  },
+});

+ 3 - 1
langchat-ui/src/views/upms/menu/edit.vue

@@ -36,7 +36,7 @@
     if (id != null) {
       setFieldsValue(await getById(id));
     } else {
-      let vars = {
+      let vars: any = {
         isDisabled: false,
         type: 'menu',
         isKeepalive: false,
@@ -48,6 +48,8 @@
       };
       if (parentId !== undefined) {
         vars.parentId = parentId;
+      } else {
+        vars.component = 'LAYOUT';
       }
       setFieldsValue(vars);
     }

+ 6 - 0
pom.xml

@@ -37,6 +37,7 @@
         <module>langchat-server</module>
         <module>langchat-upms</module>
         <module>langchat-auth</module>
+        <module>langchat-flow</module>
     </modules>
 
     <dependencyManagement>
@@ -72,6 +73,11 @@
                 <artifactId>langchat-auth</artifactId>
                 <version>${revision}</version>
             </dependency>
+            <dependency>
+                <groupId>cn.tycoding</groupId>
+                <artifactId>langchat-flow</artifactId>
+                <version>${revision}</version>
+            </dependency>
 
             <dependency>
                 <groupId>org.yaml</groupId>