diff --git a/ui/AGENTS.md b/ui/AGENTS.md index 6916390d786..531c490e454 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -17,6 +17,8 @@ Before making changes, determine which areas the task touches and read the corre organization, and API type ownership. - Views: `src/views/VIEW_README.md` is the source of truth for page responsibilities and feature-local code organization. +- Workflow canvas: `src/workflow-canvas/README.md` is the source of truth for LogicFlow canvas + responsibilities, configuration boundaries, node maintenance, and View integration. Follow this maintenance flow: @@ -66,12 +68,15 @@ ui/ │ │ └── CONSTANT_README.md # Shared constant placement and naming rules │ ├── layout/ # Shared application shells, headers, sidebars, and layout types │ ├── locales/ # Reserved for internationalization messages and locale setup -│ ├── mk-workflow/ # LogicFlow canvas and workflow domain components -│ │ ├── components/ # Reusable workflow canvas UI -│ │ ├── core/ # Shared canvas behavior and node infrastructure +│ ├── workflow-canvas/ # LogicFlow canvas and workflow domain components +│ │ ├── component/ # Reusable workflow canvas UI +│ │ ├── config/ # Maintainable node data, menus, mappings, and constants +│ │ ├── core/ # Stable canvas behavior and shared node infrastructure │ │ ├── icons/ # Workflow node icons │ │ ├── nodes/ # Workflow node definitions and components │ │ ├── plugins/ # Canvas-local plugins +│ │ ├── README.md # Canvas boundaries and maintenance rules +│ │ ├── types.ts # Workflow canvas protocol types │ │ └── index.vue # MkWorkflow canvas entry │ ├── router/ # Vue Router routes and router instance │ │ └── ROUTE_README.md # Routing conventions and rules @@ -105,8 +110,9 @@ Structural responsibilities: - Put cross-page business components in `components/business/`, name them after their business responsibility without the `Mk` prefix, and keep page-specific components in their owning View. - Put reusable application chrome in `layout/`, not in individual route views. -- Keep LogicFlow initialization, node registration, and canvas behavior inside `mk-workflow/`. - Full-screen Workflow Views own the page header and page-level actions around `MkWorkflow`. +- Keep LogicFlow initialization, node registration, and canvas behavior inside `workflow-canvas/`. + Every page rendering this canvas belongs in `views/workflow/`; those Views own the page header, + route and page-level actions around `MkWorkflow`. - Put server communication in `api/`, isolate Admin and Chat request systems, and group business APIs by domain and resource according to `src/api/API_README.md`. - Put constants shared by most pages or multiple business modules in `constants/`, and split files by a specific domain. Keep constants used by only one page or feature with their owning code. diff --git a/ui/package.json b/ui/package.json index 2921014ee41..76a3fffe2dd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -25,9 +25,11 @@ "dingtalk-jsapi": "^3.2.9", "element-plus": "^2.14.2", "jsencrypt": "^3.5.4", + "nanoid": "^6.0.1", "nprogress": "^0.2.0", "pinia": "^3.0.4", "vue": "^3.5.38", + "vue-demi": "^0.14.10", "vue-router": "^5.1.0" }, "devDependencies": { diff --git a/ui/src/utils/common.ts b/ui/src/utils/common.ts new file mode 100644 index 00000000000..a4b1147ed73 --- /dev/null +++ b/ui/src/utils/common.ts @@ -0,0 +1,7 @@ +import { nanoid } from 'nanoid' +/* + 随机id +*/ +export function randomId() { + return nanoid() +} diff --git a/ui/src/views/VIEW_README.md b/ui/src/views/VIEW_README.md index 64e917a728e..44c900b59e8 100644 --- a/ui/src/views/VIEW_README.md +++ b/ui/src/views/VIEW_README.md @@ -23,6 +23,9 @@ `helpers.ts` 等含义模糊的名称。 - 只有代码确实跨多个功能复用时,才按对应专项规则上移到 `src/components`、`src/constants`、 `src/utils`、`src/stores` 或 `src/api`。 +- 所有使用 `src/workflow-canvas/` 渲染画布的路由页面统一放在 `views/workflow/`。这些页面负责 + 路由参数、页面头部、保存或发布等页面动作以及后续接口编排;LogicFlow 初始化、节点注册和 + 画布内部行为遵循 `src/workflow-canvas/README.md`,不移入 View。 - 页面目录存在多个层级时,目录名应表达业务层级,例如 `system/identity/groups/UserGroupListView.vue`,不要创建无业务含义的分组目录。 - 页面脚本中的状态、计算属性和处理方法按照同一业务流程集中放置,并使用简短的业务备注划分 diff --git a/ui/src/views/agent/template.ts b/ui/src/views/agent/template.ts new file mode 100644 index 00000000000..0af0bb9a065 --- /dev/null +++ b/ui/src/views/agent/template.ts @@ -0,0 +1,1140 @@ +import type LogicFlow from '@logicflow/core' +import { defaultNodes } from '@/workflow-canvas/config/node-mapping' + +export const applicationTemplate = { + blank: { + edges: [], + nodes: defaultNodes, + }, + assistant: { + nodes: [ + { + id: 'base-node', + type: 'base-node', + x: 120, + y: 260.30849999999987, + properties: { + config: {}, + height: 734.766, + showNode: true, + stepName: '基本信息', + node_data: { + desc: '模板', + name: '知识库问答助手', + prologue: + '您好,我是 XXX 小助手,您可以向我提出 XXX 使用问题。\n- XXX 主要功能有什么?\n- XXX 如何收费?\n- 需要转人工服务', + tts_type: 'BROWSER', + }, + input_field_list: [], + user_input_config: { + title: '用户输入', + }, + api_input_field_list: [], + user_input_field_list: [], + }, + }, + { + id: 'start-node', + type: 'start-node', + x: 120, + y: 929.6914999999999, + properties: { + config: { + fields: [ + { + label: '用户问题', + value: 'question', + }, + ], + globalFields: [ + { + label: '当前时间', + value: 'time', + }, + { + label: '历史聊天记录', + value: 'history_context', + }, + { + label: '对话 ID', + value: 'chat_id', + }, + ], + }, + fields: [ + { + label: '用户问题', + value: 'question', + }, + ], + height: 364, + showNode: true, + stepName: '开始', + globalFields: [ + { + label: '当前时间', + value: 'time', + }, + ], + }, + }, + { + id: 'fd0324fc-f5e4-4fa6-a2d9-cb251b467605', + type: 'search-knowledge-node', + x: 710, + y: 929.6914999999999, + properties: { + config: { + fields: [ + { + label: '检索结果的分段列表', + value: 'paragraph_list', + }, + { + label: '满足直接回答的分段列表', + value: 'is_hit_handling_method_list', + }, + { + label: '检索结果', + value: 'data', + }, + { + label: '满足直接回答的分段内容', + value: 'directly_return', + }, + ], + }, + height: 794, + showNode: true, + stepName: '知识库检索', + condition: 'AND', + node_data: { + knowledge_id_list: [], + knowledge_setting: { + top_n: 3, + similarity: 0.6, + search_mode: 'embedding', + max_paragraph_char_number: 5000, + }, + question_reference_address: ['start-node', 'question'], + all_knowledge_id_list: [], + knowledge_list: [], + }, + }, + }, + { + id: '420a6e4f-44ff-4847-bb81-0923630846b5', + type: 'condition-node', + x: 1300, + y: 929.6914999999999, + properties: { + width: 600, + config: { + fields: [ + { + label: '分支名称', + value: 'branch_name', + }, + ], + }, + height: 544.148, + showNode: true, + stepName: '判断器', + condition: 'AND', + node_data: { + branch: [ + { + id: '7887', + type: 'IF', + condition: 'and', + conditions: [ + { + field: ['fd0324fc-f5e4-4fa6-a2d9-cb251b467605', 'is_hit_handling_method_list'], + value: 1, + compare: 'is_not_null', + }, + ], + }, + { + id: '6847', + type: 'ELSE IF 1', + condition: 'and', + conditions: [ + { + field: ['fd0324fc-f5e4-4fa6-a2d9-cb251b467605', 'paragraph_list'], + value: 1, + compare: 'is_not_null', + }, + ], + }, + { + id: '2794', + type: 'ELSE', + condition: 'and', + conditions: [], + }, + ], + }, + branch_condition_list: [ + { + index: 0, + height: 121.383, + id: '7887', + }, + { + index: 1, + height: 121.383, + id: '6847', + }, + { + index: 2, + height: 44, + id: '2794', + }, + ], + }, + }, + { + id: '36a440a9-5b00-4d82-b13a-8e7819112918', + type: 'reply-node', + x: 1890, + y: 120, + properties: { + config: { + fields: [ + { + label: '内容', + value: 'answer', + }, + ], + }, + height: 386, + showNode: true, + stepName: '指定回复', + condition: 'AND', + node_data: { + fields: ['fd0324fc-f5e4-4fa6-a2d9-cb251b467605', 'directly_return'], + content: '', + is_result: true, + reply_type: 'referencing', + }, + }, + }, + { + id: 'f7c3b4a2-cb80-4e47-b050-7fef0315daaf', + type: 'ai-chat-node', + x: 1890, + y: 929.6914999999999, + properties: { + config: { + fields: [ + { + label: 'AI 回答内容', + value: 'answer', + }, + { + label: '思考过程', + value: 'reasoning_content', + }, + ], + }, + height: 993.383, + showNode: true, + stepName: 'AI 对话', + condition: 'AND', + node_data: { + prompt: '已知信息:\n{{知识库检索.data}}\n问题:\n{{开始.question}}', + system: '', + model_id: '', + is_result: true, + max_tokens: null, + temperature: null, + dialogue_type: 'WORKFLOW', + model_setting: { + reasoning_content_end: '', + reasoning_content_start: '', + reasoning_content_enable: false, + }, + dialogue_number: 1, + }, + }, + }, + { + id: '04dd6c1e-95f9-4757-bb3e-134d503fce54', + type: 'reply-node', + x: 1890, + y: 1798.383, + properties: { + config: { + fields: [ + { + label: '内容', + value: 'answer', + }, + ], + }, + height: 504, + showNode: true, + stepName: '指定回复1', + condition: 'AND', + node_data: { + fields: [], + content: '抱歉,没有在知识库查询到相关内容,请提供更详细的信息。', + is_result: true, + reply_type: 'content', + }, + }, + }, + ], + edges: [ + { + id: '73f8992c-65ef-409a-a151-378d0927f2aa', + type: 'app-edge', + sourceNodeId: 'start-node', + targetNodeId: 'fd0324fc-f5e4-4fa6-a2d9-cb251b467605', + startPoint: { + x: 280, + y: 929.6914999999999, + }, + endPoint: { + x: 550, + y: 929.6914999999999, + }, + properties: {}, + pointsList: [ + { + x: 280, + y: 929.6914999999999, + }, + { + x: 390, + y: 929.6914999999999, + }, + { + x: 440, + y: 929.6914999999999, + }, + { + x: 550, + y: 929.6914999999999, + }, + ], + sourceAnchorId: 'start-node_right', + targetAnchorId: 'fd0324fc-f5e4-4fa6-a2d9-cb251b467605_left', + }, + { + id: '6a8d23d9-5179-424e-80c2-f08d37cdb8d4', + type: 'app-edge', + sourceNodeId: 'fd0324fc-f5e4-4fa6-a2d9-cb251b467605', + targetNodeId: '420a6e4f-44ff-4847-bb81-0923630846b5', + startPoint: { + x: 870, + y: 929.6914999999999, + }, + endPoint: { + x: 1010, + y: 929.6914999999999, + }, + properties: {}, + pointsList: [ + { + x: 870, + y: 929.6914999999999, + }, + { + x: 980, + y: 929.6914999999999, + }, + { + x: 900, + y: 929.6914999999999, + }, + { + x: 1010, + y: 929.6914999999999, + }, + ], + sourceAnchorId: 'fd0324fc-f5e4-4fa6-a2d9-cb251b467605_right', + targetAnchorId: '420a6e4f-44ff-4847-bb81-0923630846b5_left', + }, + { + id: '56006748-d9fe-491b-a14b-04fd568cac08', + type: 'app-edge', + sourceNodeId: '420a6e4f-44ff-4847-bb81-0923630846b5', + targetNodeId: '36a440a9-5b00-4d82-b13a-8e7819112918', + startPoint: { + x: 1590, + y: 793.3089999999999, + }, + endPoint: { + x: 1730, + y: 120, + }, + properties: {}, + pointsList: [ + { + x: 1590, + y: 793.3089999999999, + }, + { + x: 1700, + y: 793.3089999999999, + }, + { + x: 1620, + y: 120, + }, + { + x: 1730, + y: 120, + }, + ], + sourceAnchorId: '420a6e4f-44ff-4847-bb81-0923630846b5_7887_right', + targetAnchorId: '36a440a9-5b00-4d82-b13a-8e7819112918_left', + }, + { + id: '9bc8721b-07aa-4730-9347-910ed64e26b9', + type: 'app-edge', + sourceNodeId: '420a6e4f-44ff-4847-bb81-0923630846b5', + targetNodeId: 'f7c3b4a2-cb80-4e47-b050-7fef0315daaf', + startPoint: { + x: 1590, + y: 922.6919999999999, + }, + endPoint: { + x: 1730, + y: 929.6914999999999, + }, + properties: {}, + pointsList: [ + { + x: 1590, + y: 922.6919999999999, + }, + { + x: 1700, + y: 922.6919999999999, + }, + { + x: 1620, + y: 929.6914999999999, + }, + { + x: 1730, + y: 929.6914999999999, + }, + ], + sourceAnchorId: '420a6e4f-44ff-4847-bb81-0923630846b5_6847_right', + targetAnchorId: 'f7c3b4a2-cb80-4e47-b050-7fef0315daaf_left', + }, + { + id: 'c276a5b6-ec29-4ab9-b911-a0a929ff193f', + type: 'app-edge', + sourceNodeId: '420a6e4f-44ff-4847-bb81-0923630846b5', + targetNodeId: '04dd6c1e-95f9-4757-bb3e-134d503fce54', + startPoint: { + x: 1590, + y: 1013.3834999999998, + }, + endPoint: { + x: 1730, + y: 1798.383, + }, + properties: {}, + pointsList: [ + { + x: 1590, + y: 1013.3834999999998, + }, + { + x: 1700, + y: 1013.3834999999998, + }, + { + x: 1620, + y: 1798.383, + }, + { + x: 1730, + y: 1798.383, + }, + ], + sourceAnchorId: '420a6e4f-44ff-4847-bb81-0923630846b5_2794_right', + targetAnchorId: '04dd6c1e-95f9-4757-bb3e-134d503fce54_left', + }, + ], + }, +} satisfies Record + +export const knowledgeTemplate = { + default: { + edges: [ + { + id: '846dd161-450e-4d2f-8119-78557d88421c', + type: 'app-edge', + endPoint: { + x: 550, + y: 720, + }, + pointsList: [ + { + x: 280, + y: 720, + }, + { + x: 390, + y: 720, + }, + { + x: 440, + y: 720, + }, + { + x: 550, + y: 720, + }, + ], + properties: {}, + startPoint: { + x: 280, + y: 720, + }, + sourceNodeId: '768aed24-8139-4689-870f-2065ef05473c', + targetNodeId: '1bed736e-711f-4afd-8454-2c8502444af7', + sourceAnchorId: '768aed24-8139-4689-870f-2065ef05473c_right', + targetAnchorId: '1bed736e-711f-4afd-8454-2c8502444af7_left', + }, + { + id: '79cf563e-0b4d-4d41-ad6f-4ee0c6042af6', + type: 'app-edge', + endPoint: { + x: 1010, + y: 720, + }, + pointsList: [ + { + x: 870, + y: 720, + }, + { + x: 980, + y: 720, + }, + { + x: 900, + y: 720, + }, + { + x: 1010, + y: 720, + }, + ], + properties: {}, + startPoint: { + x: 870, + y: 720, + }, + sourceNodeId: '1bed736e-711f-4afd-8454-2c8502444af7', + targetNodeId: '9018d6b6-be6e-420b-9a0d-7226fd789398', + sourceAnchorId: '1bed736e-711f-4afd-8454-2c8502444af7_right', + targetAnchorId: '9018d6b6-be6e-420b-9a0d-7226fd789398_left', + }, + { + id: '38111bbe-f2ff-428e-acf8-c2f49e45fb08', + type: 'app-edge', + endPoint: { + x: 550, + y: 1460, + }, + pointsList: [ + { + x: 280, + y: 1460, + }, + { + x: 390, + y: 1460, + }, + { + x: 440, + y: 1460, + }, + { + x: 550, + y: 1460, + }, + ], + properties: {}, + startPoint: { + x: 280, + y: 1460, + }, + sourceNodeId: 'affa7bad-1898-4bdb-967b-9e12a72492c6', + targetNodeId: 'd81adcf1-bfd4-4a1c-b62c-e9ae0eb9488d', + sourceAnchorId: 'affa7bad-1898-4bdb-967b-9e12a72492c6_right', + targetAnchorId: 'd81adcf1-bfd4-4a1c-b62c-e9ae0eb9488d_left', + }, + { + id: '632ed493-fd99-461b-9510-f4e3d02120d0', + type: 'app-edge', + endPoint: { + x: 1010, + y: 1460, + }, + pointsList: [ + { + x: 870, + y: 1460, + }, + { + x: 980, + y: 1460, + }, + { + x: 900, + y: 1460, + }, + { + x: 1010, + y: 1460, + }, + ], + properties: {}, + startPoint: { + x: 870, + y: 1460, + }, + sourceNodeId: 'd81adcf1-bfd4-4a1c-b62c-e9ae0eb9488d', + targetNodeId: 'a1d0fa5d-4779-4364-8b54-7eff69bd1ec4', + sourceAnchorId: 'd81adcf1-bfd4-4a1c-b62c-e9ae0eb9488d_right', + targetAnchorId: 'a1d0fa5d-4779-4364-8b54-7eff69bd1ec4_left', + }, + { + id: '5888e8cc-75fc-4df7-a627-1dc6feedea17', + type: 'app-edge', + endPoint: { + x: 1790, + y: 1440, + }, + pointsList: [ + { + x: 1490, + y: 720, + }, + { + x: 1600, + y: 720, + }, + { + x: 1680, + y: 1440, + }, + { + x: 1790, + y: 1440, + }, + ], + properties: {}, + startPoint: { + x: 1490, + y: 720, + }, + sourceNodeId: '9018d6b6-be6e-420b-9a0d-7226fd789398', + targetNodeId: 'ade860cd-db62-4538-9943-c8f42c1b927e', + sourceAnchorId: '9018d6b6-be6e-420b-9a0d-7226fd789398_right', + targetAnchorId: 'ade860cd-db62-4538-9943-c8f42c1b927e_left', + }, + { + id: '3f294d6b-6f4f-4a5c-9d49-f118033eec70', + type: 'app-edge', + endPoint: { + x: 1790, + y: 1440, + }, + pointsList: [ + { + x: 1490, + y: 1460, + }, + { + x: 1600, + y: 1460, + }, + { + x: 1680, + y: 1440, + }, + { + x: 1790, + y: 1440, + }, + ], + properties: {}, + startPoint: { + x: 1490, + y: 1460, + }, + sourceNodeId: 'a1d0fa5d-4779-4364-8b54-7eff69bd1ec4', + targetNodeId: 'ade860cd-db62-4538-9943-c8f42c1b927e', + sourceAnchorId: 'a1d0fa5d-4779-4364-8b54-7eff69bd1ec4_right', + targetAnchorId: 'ade860cd-db62-4538-9943-c8f42c1b927e_left', + }, + { + id: 'd04c1570-6572-4505-bf84-f29d81c30e57', + type: 'app-edge', + endPoint: { + x: 1790, + y: 1440, + }, + pointsList: [ + { + x: 1490, + y: 2190, + }, + { + x: 1600, + y: 2190, + }, + { + x: 1680, + y: 1440, + }, + { + x: 1790, + y: 1440, + }, + ], + properties: {}, + startPoint: { + x: 1490, + y: 2190, + }, + sourceNodeId: 'dddebd93-ea1a-4880-8a52-ea8112f7e769', + targetNodeId: 'ade860cd-db62-4538-9943-c8f42c1b927e', + sourceAnchorId: 'dddebd93-ea1a-4880-8a52-ea8112f7e769_right', + targetAnchorId: 'ade860cd-db62-4538-9943-c8f42c1b927e_left', + }, + { + id: '536d7d47-9ad8-4144-a6df-35e3e2398327', + type: 'app-edge', + endPoint: { + x: 2430, + y: 1430, + }, + pointsList: [ + { + x: 2110, + y: 1440, + }, + { + x: 2220, + y: 1440, + }, + { + x: 2320, + y: 1430, + }, + { + x: 2430, + y: 1430, + }, + ], + properties: {}, + startPoint: { + x: 2110, + y: 1440, + }, + sourceNodeId: 'ade860cd-db62-4538-9943-c8f42c1b927e', + targetNodeId: '1c5abed5-e181-41f7-96f5-b5175cc37f3c', + sourceAnchorId: 'ade860cd-db62-4538-9943-c8f42c1b927e_right', + targetAnchorId: '1c5abed5-e181-41f7-96f5-b5175cc37f3c_left', + }, + { + id: '8b5bb2b5-5baa-4e54-b848-fa87231d9585', + type: 'app-edge', + endPoint: { + x: 1010, + y: 2190, + }, + pointsList: [ + { + x: 870, + y: 2190, + }, + { + x: 980, + y: 2190, + }, + { + x: 900, + y: 2190, + }, + { + x: 1010, + y: 2190, + }, + ], + properties: {}, + startPoint: { + x: 870, + y: 2190, + }, + sourceNodeId: '08503db8-f2e3-4eb3-96f7-957f30a6da6e', + targetNodeId: 'dddebd93-ea1a-4880-8a52-ea8112f7e769', + sourceAnchorId: '08503db8-f2e3-4eb3-96f7-957f30a6da6e_right', + targetAnchorId: 'dddebd93-ea1a-4880-8a52-ea8112f7e769_left', + }, + ], + nodes: [ + { + x: 120, + y: 115.05849999999998, + id: 'knowledge-base-node', + type: 'knowledge-base-node', + properties: { + config: { + fields: [], + globalFields: [], + }, + height: 394.383, + showNode: true, + stepName: '\u57fa\u672c\u4fe1\u606f', + node_data: { + desc: '', + name: '', + prologue: + '\u60a8\u597d\uff0c\u6211\u662f XXX \u5c0f\u52a9\u624b\uff0c\u60a8\u53ef\u4ee5\u5411\u6211\u63d0\u51fa XXX \u4f7f\u7528\u95ee\u9898\u3002\n- XXX \u4e3b\u8981\u529f\u80fd\u6709\u4ec0\u4e48\uff1f\n- XXX \u5982\u4f55\u6536\u8d39\uff1f\n- \u9700\u8981\u8f6c\u4eba\u5de5\u670d\u52a1', + tts_type: 'BROWSER', + }, + input_field_list: [], + user_input_config: { + title: '\u6587\u6863\u5904\u7406\u8bbe\u7f6e', + }, + user_input_field_list: [], + }, + }, + { + x: 120, + y: 720, + id: '768aed24-8139-4689-870f-2065ef05473c', + type: 'data-source-local-node', + properties: { + kind: 'data-source', + config: { + fields: [ + { + label: '\u6587\u4ef6\u5217\u8868', + value: 'file_list', + }, + ], + }, + height: 566, + showNode: true, + stepName: '\u6587\u672c\u6587\u4ef6', + node_data: { + file_type_list: ['TXT', 'DOCX', 'PDF', 'HTML', 'XLS', 'XLSX', 'CSV'], + file_size_limit: 100, + file_count_limit: 50, + }, + input_field_list: [], + user_input_config: {}, + user_input_field_list: [], + }, + }, + { + x: 710, + y: 720, + id: '1bed736e-711f-4afd-8454-2c8502444af7', + type: 'document-extract-node', + properties: { + config: { + fields: [ + { + label: '\u6587\u6863\u5185\u5bb9', + value: 'content', + }, + { + label: '\u6587\u6863\u5217\u8868', + value: 'document_list', + }, + ], + }, + height: 394, + showNode: true, + stepName: '\u6587\u6863\u5185\u5bb9\u63d0\u53d6', + condition: 'OR', + node_data: { + document_list: ['768aed24-8139-4689-870f-2065ef05473c', 'file_list'], + }, + }, + }, + { + x: 1250, + y: 2190, + id: 'dddebd93-ea1a-4880-8a52-ea8112f7e769', + type: 'document-split-node', + properties: { + width: 500, + config: { + fields: [ + { + label: '\u5206\u6bb5\u5217\u8868', + value: 'paragraph_list', + }, + ], + }, + height: 652, + showNode: true, + stepName: 'Web\u667a\u80fd\u5206\u6bb5', + condition: 'AND', + node_data: { + limit: 4096, + patterns: [], + chunk_size: 256, + limit_type: 'custom', + with_filter: false, + document_list: ['08503db8-f2e3-4eb3-96f7-957f30a6da6e', 'document_list'], + patterns_type: 'custom', + split_strategy: 'auto', + chunk_size_type: 'custom', + limit_reference: [], + with_filter_type: 'custom', + patterns_reference: [], + chunk_size_reference: [], + with_filter_reference: [], + document_name_relate_problem: false, + paragraph_title_relate_problem: true, + document_name_relate_problem_type: 'custom', + paragraph_title_relate_problem_type: 'custom', + document_name_relate_problem_reference: [], + paragraph_title_relate_problem_reference: [], + }, + }, + }, + { + x: 2590, + y: 1430, + id: '1c5abed5-e181-41f7-96f5-b5175cc37f3c', + type: 'knowledge-write-node', + properties: { + config: { + fields: [], + }, + height: 278, + showNode: true, + stepName: '\u77e5\u8bc6\u5e93\u5199\u5165', + condition: 'AND', + node_data: { + document_list: ['ade860cd-db62-4538-9943-c8f42c1b927e', 'Segmented_List'], + }, + }, + }, + { + x: 1250, + y: 720, + id: '9018d6b6-be6e-420b-9a0d-7226fd789398', + type: 'document-split-node', + properties: { + width: 500, + config: { + fields: [ + { + label: '\u5206\u6bb5\u5217\u8868', + value: 'paragraph_list', + }, + ], + }, + height: 652, + showNode: true, + stepName: '\u667a\u80fd\u5206\u6bb5', + condition: 'AND', + node_data: { + limit: 4096, + patterns: [], + chunk_size: 256, + limit_type: 'custom', + with_filter: false, + document_list: ['1bed736e-711f-4afd-8454-2c8502444af7', 'document_list'], + patterns_type: 'custom', + split_strategy: 'auto', + chunk_size_type: 'custom', + limit_reference: [], + with_filter_type: 'custom', + patterns_reference: [], + chunk_size_reference: [], + with_filter_reference: [], + document_name_relate_problem: true, + paragraph_title_relate_problem: true, + document_name_relate_problem_type: 'custom', + paragraph_title_relate_problem_type: 'custom', + document_name_relate_problem_reference: [], + paragraph_title_relate_problem_reference: [], + }, + }, + }, + { + x: 120, + y: 1460, + id: 'affa7bad-1898-4bdb-967b-9e12a72492c6', + type: 'data-source-local-node', + properties: { + kind: 'data-source', + config: { + fields: [ + { + label: '\u6587\u4ef6\u5217\u8868', + value: 'file_list', + }, + ], + }, + height: 536, + showNode: true, + stepName: 'QA\u95ee\u7b54\u5bf9', + node_data: { + file_type_list: ['XLS', 'XLSX', 'CSV', 'ZIP'], + file_size_limit: 100, + file_count_limit: 50, + }, + input_field_list: [], + user_input_config: {}, + user_input_field_list: [], + }, + }, + { + x: 710, + y: 1460, + id: 'd81adcf1-bfd4-4a1c-b62c-e9ae0eb9488d', + type: 'document-extract-node', + properties: { + config: { + fields: [ + { + label: '\u6587\u6863\u5185\u5bb9', + value: 'content', + }, + { + label: '\u6587\u6863\u5217\u8868', + value: 'document_list', + }, + ], + }, + height: 394, + showNode: true, + stepName: '\u6587\u6863\u5185\u5bb9\u63d0\u53d61', + condition: 'AND', + node_data: { + document_list: ['affa7bad-1898-4bdb-967b-9e12a72492c6', 'file_list'], + }, + }, + }, + { + x: 1250, + y: 1460, + id: 'a1d0fa5d-4779-4364-8b54-7eff69bd1ec4', + type: 'document-split-node', + properties: { + width: 500, + config: { + fields: [ + { + label: '\u5206\u6bb5\u5217\u8868', + value: 'paragraph_list', + }, + ], + }, + height: 580, + showNode: true, + stepName: 'QA\u95ee\u7b54\u5bf9\u5206\u6bb5', + condition: 'AND', + node_data: { + limit: 4096, + patterns: [], + chunk_size: 256, + limit_type: 'custom', + with_filter: false, + document_list: ['d81adcf1-bfd4-4a1c-b62c-e9ae0eb9488d', 'document_list'], + patterns_type: 'custom', + split_strategy: 'qa', + chunk_size_type: 'custom', + limit_reference: [], + with_filter_type: 'custom', + patterns_reference: [], + chunk_size_reference: [], + with_filter_reference: [], + document_name_relate_problem: true, + paragraph_title_relate_problem: false, + document_name_relate_problem_type: 'custom', + paragraph_title_relate_problem_type: 'custom', + document_name_relate_problem_reference: [], + paragraph_title_relate_problem_reference: [], + }, + }, + }, + { + x: 1950, + y: 1440, + id: 'ade860cd-db62-4538-9943-c8f42c1b927e', + type: 'variable-aggregation-node', + properties: { + config: { + fields: [ + { + label: '\u6587\u6863\u5206\u6bb5\u5217\u8868', + value: 'Segmented_List', + }, + ], + }, + height: 570.7660000000001, + showNode: true, + stepName: '\u805a\u5408\u6587\u6863\u5206\u6bb5\u5217\u8868', + condition: 'OR', + node_data: { + strategy: 'first_non_null', + is_result: true, + group_list: [ + { + id: 'kU2zR8yRzNIt3RXKHmJtL', + field: 'Segmented_List', + label: '\u6587\u6863\u5206\u6bb5\u5217\u8868', + variable_list: [ + { + v_id: 'N59Lo_VyRKuYASHaKN6g0', + variable: ['9018d6b6-be6e-420b-9a0d-7226fd789398', 'paragraph_list'], + }, + { + v_id: 'IRQkWPB7THGXlkSBCWmkY', + variable: ['a1d0fa5d-4779-4364-8b54-7eff69bd1ec4', 'paragraph_list'], + }, + { + v_id: 'jGrIINd-6O6UEzNmZvFap', + variable: ['dddebd93-ea1a-4880-8a52-ea8112f7e769', 'paragraph_list'], + }, + ], + }, + ], + }, + }, + }, + { + x: 710, + y: 2190, + id: '08503db8-f2e3-4eb3-96f7-957f30a6da6e', + type: 'data-source-web-node', + properties: { + kind: 'data-source', + config: { + fields: [ + { + label: '\u6587\u6863\u5217\u8868', + value: 'document_list', + }, + ], + }, + height: 292, + showNode: true, + stepName: 'Web\u7ad9\u70b9', + }, + }, + ], + }, +} satisfies Record diff --git a/ui/src/views/workflow/AgentWorkflowView.vue b/ui/src/views/workflow/AgentWorkflowView.vue index 336fde826c7..892fadef9e9 100644 --- a/ui/src/views/workflow/AgentWorkflowView.vue +++ b/ui/src/views/workflow/AgentWorkflowView.vue @@ -1,7 +1,7 @@ + + diff --git a/ui/src/workflow-canvas/component/NodeMenu.vue b/ui/src/workflow-canvas/component/NodeMenu.vue new file mode 100644 index 00000000000..ff637bece5f --- /dev/null +++ b/ui/src/workflow-canvas/component/NodeMenu.vue @@ -0,0 +1,107 @@ + + + diff --git a/ui/src/workflow-canvas/component/NodeSearch.vue b/ui/src/workflow-canvas/component/NodeSearch.vue new file mode 100644 index 00000000000..0dcb74141e1 --- /dev/null +++ b/ui/src/workflow-canvas/component/NodeSearch.vue @@ -0,0 +1,93 @@ + + + diff --git a/ui/src/workflow-canvas/config/constants.ts b/ui/src/workflow-canvas/config/constants.ts new file mode 100644 index 00000000000..f6b25bc1e29 --- /dev/null +++ b/ui/src/workflow-canvas/config/constants.ts @@ -0,0 +1,35 @@ +export const compareList = [ + { value: 'is_null', label: '为空' }, + { value: 'is_not_null', label: '不为空' }, + { value: 'contain', label: '包含' }, + { value: 'not_contain', label: '不包含' }, + { value: 'eq', label: '等于' }, + { value: 'not_eq', label: '不等于' }, + { value: 'ge', label: '大于等于' }, + { value: 'gt', label: '大于' }, + { value: 'le', label: '小于等于' }, + { value: 'lt', label: '小于' }, + { value: 'len_eq', label: '长度等于' }, + { value: 'len_ge', label: '长度大于等于' }, + { value: 'len_gt', label: '长度大于' }, + { value: 'len_le', label: '长度小于等于' }, + { value: 'len_lt', label: '长度小于' }, + { value: 'is_true', label: '为真' }, + { value: 'is_not_true', label: '不为真' }, + { value: 'start_with', label: 'startWith' }, + { value: 'end_with', label: 'endWith' }, + { value: 'regex', label: '正则匹配' }, + { value: 'wildcard', label: '通配符匹配' }, +] + +export const fileTooltip = JSON.stringify( + [ + { + name: 'File Name', + url: './oss/file/019d8ac3-e2c6-7ff2-8956-c9c98f0e11f4', + file_id: '019d8ac3-e2c6-7ff2-8956-c9c98f0e11f4', + }, + ], + null, + 2, +) diff --git a/ui/src/workflow-canvas/config/locale.ts b/ui/src/workflow-canvas/config/locale.ts new file mode 100644 index 00000000000..cfe9a01738c --- /dev/null +++ b/ui/src/workflow-canvas/config/locale.ts @@ -0,0 +1,367 @@ +/** + * 工作流国际化扩展预留。 + * 当前节点及菜单直接使用中文文案,后续接入 i18n 时在此集中处理。 + */ +// function defineLocaleGetter(target: any, prop: string, key: string, fallback?: string) { +// let overrideValue: string | undefined +// Object.defineProperty(target, prop, { +// configurable: true, +// enumerable: true, +// get: () => overrideValue || t(key, fallback as any), +// set: (value) => { +// overrideValue = value || undefined +// }, +// }) +// } + +// function bindLocale(target: any, path: string, key: string, fallback?: string) { +// const parts = path.split('.') +// const prop = parts.pop() as string +// const owner = parts.reduce((obj, part) => obj?.[part], target) +// if (owner) { +// defineLocaleGetter(owner, prop, key, fallback) +// } +// } + +// function bindFieldLabels(fields: Array | undefined, keys: string[]) { +// const refresh = () => { +// fields?.forEach((field, index) => { +// if (keys[index]) { +// field.label = t(keys[index]) +// } +// }) +// } +// refresh() +// watch(i18n.global.locale, refresh) +// } + +// function bindNodeLocale(node: any, textKey: string, labelKey: string, stepNameKey = labelKey) { +// defineLocaleGetter(node, 'text', textKey) +// defineLocaleGetter(node, 'label', labelKey) +// bindLocale(node, 'properties.stepName', stepNameKey) +// } + +// bindLocale(startNode, 'properties.stepName', 'workflow.nodes.startNode.label') +// bindFieldLabels(startNode.properties.config.fields, ['workflow.nodes.startNode.question']) +// bindFieldLabels(startNode.properties.config.globalFields, [ +// 'workflow.nodes.startNode.currentTime', +// 'views.application.form.historyRecord.label', +// 'aiChat.chatId', +// ]) +// bindFieldLabels(startNode.properties.fields, ['workflow.nodes.startNode.question']) +// bindFieldLabels(startNode.properties.globalFields, ['workflow.nodes.startNode.currentTime']) +// ;[baseNode, knowledgeBaseNode].forEach((node) => { +// bindLocale(node, 'properties.stepName', 'common.info') +// bindLocale(node, 'properties.node_data.prologue', 'views.application.form.defaultPrologue') +// bindLocale(node, 'properties.user_input_config.title', 'aiChat.userInput') +// }) +// bindLocale(toolBaseNode, 'properties.stepName', 'common.info') +// bindLocale(toolBaseNode, 'properties.user_input_config.title', 'aiChat.userInput') +// bindLocale(toolStartNode, 'properties.stepName', 'workflow.nodes.startNode.label') +// bindLocale(toolStartNode, 'properties.user_input_config.title', 'aiChat.userInput') +// const nodeLocaleBindings: Array<[any, string, string]> = [ +// [ +// dataSourceLocalNode, +// 'workflow.nodes.dataSourceLocalNode.text', +// 'workflow.nodes.dataSourceLocalNode.label', +// ], +// [ +// dataSourceWebNode, +// 'workflow.nodes.dataSourceWebNode.text', +// 'workflow.nodes.dataSourceWebNode.label', +// ], +// [ +// knowledgeWriteNode, +// 'workflow.nodes.knowledgeWriteNode.text', +// 'workflow.nodes.knowledgeWriteNode.label', +// ], +// [aiChatNode, 'workflow.nodes.aiChatNode.text', 'workflow.nodes.aiChatNode.label'], +// [ +// searchKnowledgeNode, +// 'workflow.nodes.searchKnowledgeNode.text', +// 'workflow.nodes.searchKnowledgeNode.label', +// ], +// [ +// searchDocumentNode, +// 'workflow.nodes.searchDocumentNode.text', +// 'workflow.nodes.searchDocumentNode.label', +// ], +// [questionNode, 'workflow.nodes.questionNode.text', 'workflow.nodes.questionNode.label'], +// [ +// variableSplittingNode, +// 'workflow.nodes.variableSplittingNode.text', +// 'workflow.nodes.variableSplittingNode.label', +// ], +// [ +// parameterExtractionNode, +// 'workflow.nodes.parameterExtractionNode.text', +// 'workflow.nodes.parameterExtractionNode.label', +// ], +// [conditionNode, 'workflow.nodes.conditionNode.text', 'workflow.nodes.conditionNode.label'], +// [replyNode, 'workflow.nodes.replyNode.text', 'workflow.nodes.replyNode.label'], +// [rerankerNode, 'workflow.nodes.rerankerNode.text', 'workflow.nodes.rerankerNode.label'], +// [formNode, 'workflow.nodes.formNode.text', 'workflow.nodes.formNode.label'], +// [ +// documentExtractNode, +// 'workflow.nodes.documentExtractNode.text', +// 'workflow.nodes.documentExtractNode.label', +// ], +// [ +// documentSplitNode, +// 'workflow.nodes.documentSplitNode.text', +// 'workflow.nodes.documentSplitNode.label', +// ], +// [ +// imageUnderstandNode, +// 'workflow.nodes.imageUnderstandNode.text', +// 'workflow.nodes.imageUnderstandNode.label', +// ], +// [ +// videoUnderstandNode, +// 'workflow.nodes.videoUnderstandNode.text', +// 'workflow.nodes.videoUnderstandNode.label', +// ], +// [ +// variableAggregationNode, +// 'workflow.nodes.variableAggregationNode.text', +// 'workflow.nodes.variableAggregationNode.label', +// ], +// [ +// variableAssignNode, +// 'workflow.nodes.variableAssignNode.text', +// 'workflow.nodes.variableAssignNode.label', +// ], +// [mcpNode, 'workflow.nodes.mcpNode.text', 'workflow.nodes.mcpNode.label'], +// [ +// imageGenerateNode, +// 'workflow.nodes.imageGenerateNode.text', +// 'workflow.nodes.imageGenerateNode.label', +// ], +// [ +// speechToTextNode, +// 'workflow.nodes.speechToTextNode.text', +// 'workflow.nodes.speechToTextNode.label', +// ], +// [ +// textToSpeechNode, +// 'workflow.nodes.textToSpeechNode.text', +// 'workflow.nodes.textToSpeechNode.label', +// ], +// [toolNode, 'workflow.nodes.toolNode.text', 'workflow.nodes.toolNode.label'], +// [intentNode, 'workflow.nodes.intentNode.text', 'workflow.nodes.intentNode.label'], +// [loopNode, 'workflow.nodes.loopNode.text', 'workflow.nodes.loopNode.label'], +// [ +// imageToVideoNode, +// 'workflow.nodes.imageToVideoGenerate.text', +// 'workflow.nodes.imageToVideoGenerate.label', +// ], +// [loopBodyNode, 'workflow.nodes.loopBodyNode.text', 'workflow.nodes.loopBodyNode.label'], +// [ +// loopContinueNode, +// 'workflow.nodes.loopContinueNode.text', +// 'workflow.nodes.loopContinueNode.label', +// ], +// [ +// textToVideoNode, +// 'workflow.nodes.textToVideoGenerate.text', +// 'workflow.nodes.textToVideoGenerate.label', +// ], +// [loopBreakNode, 'workflow.nodes.loopBreakNode.text', 'workflow.nodes.loopBreakNode.label'], +// [toolLibNode, 'workflow.nodes.toolNode.text', 'workflow.nodes.toolNode.label'], +// [applicationNode, 'workflow.nodes.applicationNode.label', 'workflow.nodes.applicationNode.label'], +// ] +// nodeLocaleBindings.forEach(([node, textKey, labelKey]) => bindNodeLocale(node, textKey, labelKey)) + +// defineLocaleGetter( +// toolWorkflowLibNode, +// 'text', +// 'workflow.nodes.toolWorlflowNode.text', +// '工作流工具', +// ) +// defineLocaleGetter( +// toolWorkflowLibNode, +// 'label', +// 'workflow.nodes.toolWorlflowNode.label', +// '工作流工具', +// ) +// bindLocale( +// toolWorkflowLibNode, +// 'properties.stepName', +// 'workflow.nodes.toolWorlflowNode.label', +// '工作流工具', +// ) + +// bindLocale(loopStartNode, 'properties.stepName', 'workflow.nodes.loopStartNode.label') +// bindFieldLabels(loopStartNode.properties.config.fields, [ +// 'workflow.nodes.loopStartNode.loopIndex', +// 'workflow.nodes.loopStartNode.loopItem', +// ]) +// bindLocale( +// loopNode, +// 'properties.workflow.nodes.0.properties.stepName', +// 'workflow.nodes.startNode.label', +// ) +// { +// let overrideValue: string | undefined +// Object.defineProperty(formNode.properties.node_data, 'form_content_format', { +// configurable: true, +// enumerable: true, +// get: () => +// overrideValue ?? +// `${t('workflow.nodes.formNode.form_content_format1')} +// {{form}} +// ${t('workflow.nodes.formNode.form_content_format2')}`, +// set: (value) => { +// overrideValue = value +// }, +// }) +// } + +// ;[ +// [dataSourceLocalNode.properties.config.fields, ['workflow.nodes.dataSourceLocalNode.fileList']], +// [dataSourceWebNode.properties.config.fields, ['workflow.nodes.dataSourceWebNode.field_label']], +// [ +// aiChatNode.properties.config.fields, +// [ +// 'workflow.nodes.aiChatNode.answer', +// 'workflow.nodes.aiChatNode.think', +// 'workflow.nodes.aiChatNode.historyMessage', +// ], +// ], +// [ +// searchKnowledgeNode.properties.config.fields, +// [ +// 'workflow.nodes.searchKnowledgeNode.paragraph_list', +// 'workflow.nodes.searchKnowledgeNode.is_hit_handling_method_list', +// 'workflow.nodes.searchKnowledgeNode.result', +// 'workflow.nodes.searchKnowledgeNode.directly_return', +// ], +// ], +// [ +// searchDocumentNode.properties.config.fields, +// [ +// 'workflow.nodes.searchDocumentNode.knowledgeList', +// 'workflow.nodes.searchDocumentNode.documentList', +// ], +// ], +// [questionNode.properties.config.fields, ['workflow.nodes.questionNode.result']], +// [variableSplittingNode.properties.config.fields, ['common.result']], +// [parameterExtractionNode.properties.config.fields, ['common.result']], +// [conditionNode.properties.config.fields, ['workflow.nodes.conditionNode.branch_name']], +// [replyNode.properties.config.fields, ['common.content']], +// [ +// rerankerNode.properties.config.fields, +// [ +// 'workflow.nodes.rerankerNode.result_list', +// 'workflow.nodes.rerankerNode.result', +// 'workflow.nodes.searchKnowledgeNode.is_hit_handling_method_list', +// ], +// ], +// [formNode.properties.config.fields, ['workflow.nodes.formNode.form_data']], +// [ +// documentExtractNode.properties.config.fields, +// ['workflow.nodes.documentExtractNode.content', 'workflow.nodes.dataSourceWebNode.field_label'], +// ], +// [documentSplitNode.properties.config.fields, ['workflow.nodes.documentSplitNode.paragraphList']], +// [imageUnderstandNode.properties.config.fields, ['workflow.nodes.imageUnderstandNode.answer']], +// [videoUnderstandNode.properties.config.fields, ['workflow.nodes.videoUnderstandNode.answer']], +// [mcpNode.properties.config.fields, ['common.result']], +// [ +// imageGenerateNode.properties.config.fields, +// ['workflow.nodes.imageGenerateNode.answer', 'common.fileUpload.image'], +// ], +// [speechToTextNode.properties.config.fields, ['common.result']], +// [textToSpeechNode.properties.config.fields, ['common.result']], +// [toolNode.properties.config.fields, ['common.result']], +// [intentNode.properties.config.fields, ['common.classify', 'common.reason']], +// [imageToVideoNode.properties.config.fields, ['common.fileUpload.video']], +// [textToVideoNode.properties.config.fields, ['common.fileUpload.video']], +// [toolLibNode.properties.config.fields, ['common.result']], +// [applicationNode.properties.config.fields, ['common.result']], +// ].forEach(([fields, keys]) => bindFieldLabels(fields as Array, keys as string[])) +// ;[ +// [ +// knowledgeMenuNodes, +// [ +// 'views.tool.dataSource.title', +// 'views.knowledge.title', +// 'workflow.nodes.classify.aiCapability', +// 'workflow.nodes.classify.businessLogic', +// 'workflow.nodes.classify.dataProcessing', +// 'common.other', +// ], +// ], +// [ +// knowledgeLoopMenuNodes, +// [ +// 'views.tool.dataSource.title', +// 'views.knowledge.title', +// 'workflow.nodes.classify.aiCapability', +// 'workflow.nodes.classify.businessLogic', +// 'workflow.nodes.classify.dataProcessing', +// 'common.other', +// ], +// ], +// [ +// toolLoopMenuNodes, +// [ +// 'views.tool.dataSource.title', +// 'views.knowledge.title', +// 'workflow.nodes.classify.aiCapability', +// 'workflow.nodes.classify.businessLogic', +// 'workflow.nodes.classify.dataProcessing', +// 'common.other', +// ], +// ], +// [ +// menuNodes, +// [ +// 'workflow.nodes.classify.aiCapability', +// 'views.knowledge.title', +// 'workflow.nodes.classify.businessLogic', +// 'workflow.nodes.classify.dataProcessing', +// 'common.other', +// ], +// ], +// [ +// applicationLoopMenuNodes, +// [ +// 'workflow.nodes.classify.aiCapability', +// 'views.knowledge.title', +// 'workflow.nodes.classify.businessLogic', +// 'workflow.nodes.classify.dataProcessing', +// 'common.other', +// ], +// ], +// [ +// toolMenuNodes, +// [ +// 'workflow.nodes.classify.aiCapability', +// 'views.knowledge.title', +// 'workflow.nodes.classify.businessLogic', +// 'workflow.nodes.classify.dataProcessing', +// 'common.other', +// ], +// ], +// ].forEach(([nodes, keys]) => bindFieldLabels(nodes as Array, keys as string[])) +// ;[ +// 'workflow.compare.is_null', +// 'workflow.compare.is_not_null', +// 'workflow.compare.contain', +// 'workflow.compare.not_contain', +// 'workflow.compare.eq', +// 'workflow.compare.not_eq', +// 'workflow.compare.ge', +// 'workflow.compare.gt', +// 'workflow.compare.le', +// 'workflow.compare.lt', +// 'workflow.compare.len_eq', +// 'workflow.compare.len_ge', +// 'workflow.compare.len_gt', +// 'workflow.compare.len_le', +// 'workflow.compare.len_lt', +// 'workflow.compare.is_true', +// 'workflow.compare.is_not_true', +// ].forEach((key, index) => defineLocaleGetter(compareList[index], 'label', key)) +// defineLocaleGetter(compareList[19], 'label', 'workflow.compare.regex') +// defineLocaleGetter(compareList[20], 'label', 'workflow.compare.wildcard') diff --git a/ui/src/workflow-canvas/config/menu.ts b/ui/src/workflow-canvas/config/menu.ts new file mode 100644 index 00000000000..aefdfcea5b1 --- /dev/null +++ b/ui/src/workflow-canvas/config/menu.ts @@ -0,0 +1,302 @@ +import { WorkflowMode, WorkflowNodeType } from '@/workflow-canvas/types' +import * as NodeData from './node-data' +interface WorkflowMenuNode { + type: WorkflowNodeType +} +interface WorkflowMenuGroup { + label: string + list: WorkflowMenuNode[] +} +export const knowledgeMenuNodes = [ + { + label: '数据源', + list: [NodeData.dataSourceLocalNode, NodeData.dataSourceWebNode], + }, + { + label: '知识库', + list: [NodeData.documentSplitNode, NodeData.knowledgeWriteNode, NodeData.documentExtractNode], + }, + { + label: 'AI 能力', + list: [ + NodeData.aiChatNode, + NodeData.intentNode, + NodeData.textToSpeechNode, + NodeData.speechToTextNode, + NodeData.imageGenerateNode, + NodeData.imageUnderstandNode, + NodeData.textToVideoNode, + NodeData.imageToVideoNode, + NodeData.videoUnderstandNode, + NodeData.questionNode, + ], + }, + { + label: '业务逻辑', + list: [NodeData.conditionNode, NodeData.replyNode, NodeData.loopNode], + }, + { + label: '数据处理', + list: [ + NodeData.variableAssignNode, + NodeData.variableAggregationNode, + NodeData.variableSplittingNode, + NodeData.parameterExtractionNode, + ], + }, + { + label: '其他', + list: [NodeData.mcpNode, NodeData.toolNode], + }, +] +export const menuNodes = [ + { + label: 'AI 能力', + list: [ + NodeData.aiChatNode, + NodeData.intentNode, + NodeData.textToSpeechNode, + NodeData.speechToTextNode, + NodeData.imageGenerateNode, + NodeData.imageUnderstandNode, + NodeData.textToVideoNode, + NodeData.imageToVideoNode, + NodeData.videoUnderstandNode, + NodeData.questionNode, + ], + }, + { + label: '知识库', + list: [ + NodeData.searchKnowledgeNode, + NodeData.searchDocumentNode, + NodeData.rerankerNode, + NodeData.documentExtractNode, + ], + }, + { + label: '业务逻辑', + list: [NodeData.conditionNode, NodeData.formNode, NodeData.replyNode, NodeData.loopNode], + }, + { + label: '数据处理', + list: [ + NodeData.variableAssignNode, + NodeData.variableAggregationNode, + NodeData.variableSplittingNode, + NodeData.parameterExtractionNode, + ], + }, + { + label: '其他', + list: [NodeData.mcpNode, NodeData.toolNode], + }, +] +export const applicationLoopMenuNodes = [ + { + label: 'AI 能力', + list: [ + NodeData.aiChatNode, + NodeData.intentNode, + NodeData.textToSpeechNode, + NodeData.speechToTextNode, + NodeData.imageGenerateNode, + NodeData.imageUnderstandNode, + NodeData.textToVideoNode, + NodeData.imageToVideoNode, + NodeData.videoUnderstandNode, + NodeData.questionNode, + ], + }, + { + label: '知识库', + list: [ + NodeData.searchKnowledgeNode, + NodeData.searchDocumentNode, + NodeData.rerankerNode, + NodeData.documentExtractNode, + ], + }, + { + label: '业务逻辑', + list: [ + NodeData.conditionNode, + NodeData.formNode, + NodeData.replyNode, + NodeData.loopContinueNode, + NodeData.loopBreakNode, + ], + }, + { + label: '数据处理', + list: [ + NodeData.variableAssignNode, + NodeData.variableAggregationNode, + NodeData.variableSplittingNode, + NodeData.parameterExtractionNode, + ], + }, + { + label: '其他', + list: [NodeData.mcpNode, NodeData.toolNode], + }, +] +export const knowledgeLoopMenuNodes = [ + { + label: '数据源', + list: [NodeData.dataSourceLocalNode, NodeData.dataSourceWebNode], + }, + { + label: '知识库', + list: [NodeData.documentSplitNode, NodeData.knowledgeWriteNode, NodeData.documentExtractNode], + }, + { + label: 'AI 能力', + list: [ + NodeData.aiChatNode, + NodeData.intentNode, + NodeData.textToSpeechNode, + NodeData.speechToTextNode, + NodeData.imageGenerateNode, + NodeData.imageUnderstandNode, + NodeData.textToVideoNode, + NodeData.imageToVideoNode, + NodeData.videoUnderstandNode, + NodeData.questionNode, + ], + }, + { + label: '业务逻辑', + list: [ + NodeData.conditionNode, + NodeData.replyNode, + NodeData.loopContinueNode, + NodeData.loopBreakNode, + ], + }, + { + label: '数据处理', + list: [ + NodeData.variableAssignNode, + NodeData.variableAggregationNode, + NodeData.variableSplittingNode, + NodeData.parameterExtractionNode, + ], + }, + { + label: '其他', + list: [NodeData.mcpNode, NodeData.toolNode], + }, +] +export const toolLoopMenuNodes = [ + { + label: '数据源', + list: [NodeData.dataSourceLocalNode, NodeData.dataSourceWebNode], + }, + { + label: '知识库', + list: [NodeData.documentSplitNode, NodeData.knowledgeWriteNode, NodeData.documentExtractNode], + }, + { + label: 'AI 能力', + list: [ + NodeData.aiChatNode, + NodeData.intentNode, + NodeData.textToSpeechNode, + NodeData.speechToTextNode, + NodeData.imageGenerateNode, + NodeData.imageUnderstandNode, + NodeData.textToVideoNode, + NodeData.imageToVideoNode, + NodeData.videoUnderstandNode, + NodeData.questionNode, + ], + }, + { + label: '业务逻辑', + list: [ + NodeData.conditionNode, + NodeData.formNode, + NodeData.replyNode, + NodeData.loopContinueNode, + NodeData.loopBreakNode, + ], + }, + { + label: '数据处理', + list: [ + NodeData.variableAssignNode, + NodeData.variableAggregationNode, + NodeData.variableSplittingNode, + NodeData.parameterExtractionNode, + ], + }, + { + label: '其他', + list: [NodeData.mcpNode, NodeData.toolNode], + }, +] +const toolMenuNodes = [ + { + label: 'AI 能力', + list: [ + NodeData.aiChatNode, + NodeData.intentNode, + NodeData.textToSpeechNode, + NodeData.speechToTextNode, + NodeData.imageGenerateNode, + NodeData.imageUnderstandNode, + NodeData.textToVideoNode, + NodeData.imageToVideoNode, + NodeData.videoUnderstandNode, + NodeData.questionNode, + ], + }, + { + label: '知识库', + list: [ + NodeData.searchKnowledgeNode, + NodeData.searchDocumentNode, + NodeData.rerankerNode, + NodeData.documentExtractNode, + NodeData.documentSplitNode, + ], + }, + { + label: '业务逻辑', + list: [NodeData.conditionNode, NodeData.formNode, NodeData.replyNode, NodeData.loopNode], + }, + { + label: '数据处理', + list: [ + NodeData.variableAssignNode, + NodeData.variableAggregationNode, + NodeData.variableSplittingNode, + NodeData.parameterExtractionNode, + ], + }, + { + label: '其他', + list: [NodeData.mcpNode, NodeData.toolNode], + }, +] +export const getMenuNodes = (workflowMode: WorkflowMode): WorkflowMenuGroup[] | undefined => { + if (workflowMode == WorkflowMode.Application) { + return menuNodes + } + if (workflowMode == WorkflowMode.ApplicationLoop) { + return applicationLoopMenuNodes + } + if (workflowMode == WorkflowMode.Knowledge) { + return knowledgeMenuNodes + } + if (workflowMode == WorkflowMode.KnowledgeLoop) { + return knowledgeLoopMenuNodes + } + if (workflowMode == WorkflowMode.Tool) { + return toolMenuNodes + } + if (workflowMode == WorkflowMode.ToolLoop) { + return toolLoopMenuNodes + } +} diff --git a/ui/src/workflow-canvas/config/node-data.ts b/ui/src/workflow-canvas/config/node-data.ts new file mode 100644 index 00000000000..a68c052bc8f --- /dev/null +++ b/ui/src/workflow-canvas/config/node-data.ts @@ -0,0 +1,825 @@ +import { WorkflowKind, WorkflowNodeType } from '@/workflow-canvas/types' + +export const startNode = { + id: WorkflowNodeType.Start, + type: WorkflowNodeType.Start, + x: 480, + y: 3340, + properties: { + height: 364, + stepName: '开始', + config: { + fields: [ + { + label: '用户问题', + value: 'question', + }, + ], + globalFields: [ + { label: '当前时间', value: 'time' }, + { + label: '历史聊天记录', + value: 'history_context', + }, + { + label: '对话 ID', + value: 'aiChat_id', + }, + ], + }, + fields: [{ label: '用户问题', value: 'question' }], + globalFields: [{ label: '当前时间', value: 'time' }], + showNode: true, + }, +} + +export const baseNode = { + id: WorkflowNodeType.Base, + type: WorkflowNodeType.Base, + x: 360, + y: 2761.3875, + text: '', + properties: { + height: 728.375, + stepName: '基本信息', + input_field_list: [], + node_data: { + name: '', + desc: '', + prologue: + '您好,我是 XXX 小助手,您可以向我提出 XXX 使用问题。\n- XXX 主要功能有什么?\n- XXX 如何收费?\n- 需要转人工服务', + tts_type: 'BROWSER', + }, + config: {}, + showNode: true, + user_input_config: { title: '用户输入' }, + user_input_field_list: [], + }, +} + +export const knowledgeBaseNode = { + id: WorkflowNodeType.KnowledgeBase, + type: WorkflowNodeType.KnowledgeBase, + x: 360, + y: 2761.3875, + text: '', + properties: { + height: 728.375, + stepName: '基本信息', + input_field_list: [], + node_data: { + name: '', + desc: '', + prologue: + '您好,我是 XXX 小助手,您可以向我提出 XXX 使用问题。\n- XXX 主要功能有什么?\n- XXX 如何收费?\n- 需要转人工服务', + tts_type: 'BROWSER', + }, + config: {}, + showNode: true, + user_input_config: { title: '用户输入' }, + user_input_field_list: [], + }, +} + +export const toolBaseNode = { + id: WorkflowNodeType.ToolBaseNode, + type: WorkflowNodeType.ToolBaseNode, + x: 360, + y: 2761.3875, + text: '', + properties: { + width: 500, + height: 728.375, + stepName: '基本信息', + input_field_list: [], + node_data: {}, + config: {}, + showNode: true, + user_input_config: { title: '用户输入' }, + user_input_field_list: [], + }, +} + +export const toolStartNode = { + id: WorkflowNodeType.ToolStartNode, + type: WorkflowNodeType.ToolStartNode, + x: 280, + y: 3301, + text: '', + properties: { + height: 728.375, + stepName: '开始', + input_field_list: [], + node_data: {}, + config: {}, + showNode: true, + user_input_config: { title: '用户输入' }, + user_input_field_list: [], + }, +} + +export const dataSourceLocalNode = { + type: WorkflowNodeType.DataSourceLocalNode, + x: 360, + y: 2761.3875, + text: '上传本地文档,输出文档列表(不解析内容,需配合 “文档内容提取” 节点解析)', + label: '本地文件', + properties: { + kind: WorkflowKind.DataSource, + height: 728.375, + stepName: '本地文件', + input_field_list: [], + config: { + fields: [ + { + label: '文件列表', + value: 'file_list', + }, + ], + }, + showNode: true, + user_input_config: {}, + user_input_field_list: [], + }, +} + +export const dataSourceWebNode = { + id: WorkflowNodeType.DataSourceWebNode, + type: WorkflowNodeType.DataSourceWebNode, + x: 360, + y: 2761.3875, + text: '输入根地址自动抓取 Web 数据(单链接对应单文档),输出含内容的文档列表', + label: 'Web 站点', + properties: { + kind: WorkflowKind.DataSource, + height: 180, + stepName: 'Web 站点', + config: { + fields: [ + { + label: '文档列表', + value: 'document_list', + }, + ], + }, + }, +} + +export const knowledgeWriteNode = { + type: WorkflowNodeType.KnowledgeWriteNode, + text: '将输入的分段列表写入当前知识库,并完成向量化处理', + label: '知识库写入', + height: 100, + properties: { + stepName: '知识库写入', + config: { + fields: [], + }, + }, +} + +/** + * ai对话节点配置数据 + */ +export const aiChatNode = { + type: WorkflowNodeType.AiChat, + text: '与 AI 大模型进行对话', + label: 'AI 对话', + height: 340, + properties: { + stepName: 'AI 对话', + config: { + fields: [ + { + label: 'AI 回答内容', + value: 'answer', + }, + { + label: '思考过程', + value: 'reasoning_content', + }, + { + label: '历史聊天记录', + value: 'history_message', + }, + ], + }, + }, +} + +/** + * 知识库检索配置数据 + */ +export const searchKnowledgeNode = { + type: WorkflowNodeType.SearchKnowledge, + text: '关联知识库,查找与问题相关的分段', + label: '知识库检索', + height: 355, + properties: { + stepName: '知识库检索', + config: { + fields: [ + { + label: '检索结果的分段列表', + value: 'paragraph_list', + }, + { + label: '满足直接回答的分段列表', + value: 'is_hit_handling_method_list', + }, + { + label: '检索结果', + value: 'data', + }, + { + label: '满足直接回答的分段内容', + value: 'directly_return', + }, + ], + }, + }, +} + +/** + * 文档标签检索 + */ +export const searchDocumentNode = { + type: WorkflowNodeType.SearchDocument, + text: '从设定的检索范围中,根据文档标签检索出满足条件的文档', + label: '文档标签检索', + height: 355, + properties: { + width: 600, + stepName: '文档标签检索', + config: { + fields: [ + { + label: '知识库列表', + value: 'knowledge_list', + }, + { + label: '文档列表', + value: 'document_list', + }, + ], + }, + }, +} + +export const questionNode = { + type: WorkflowNodeType.Question, + text: '根据历史聊天记录优化完善当前问题,更利于匹配知识库分段', + label: '问题优化', + height: 345, + properties: { + stepName: '问题优化', + config: { + fields: [ + { + label: '问题优化结果', + value: 'answer', + }, + ], + }, + }, +} + +export const variableSplittingNode = { + type: WorkflowNodeType.VariableSplittingNode, + text: '通过配置JSON Path 表达式,对输入的 JSON 格式变量进行解析和拆分', + label: '变量拆分', + height: 345, + properties: { + stepName: '变量拆分', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +export const parameterExtractionNode = { + type: WorkflowNodeType.ParameterExtractionNode, + text: '利用 AI 模型提取结构化参数', + label: '参数提取', + height: 345, + properties: { + width: 430, + stepName: '参数提取', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +export const conditionNode = { + type: WorkflowNodeType.Condition, + text: '根据不同条件执行不同的节点', + label: '判断器', + height: 175, + properties: { + width: 600, + stepName: '判断器', + config: { + fields: [ + { + label: '分支名称', + value: 'branch_name', + }, + ], + }, + }, +} + +export const replyNode = { + type: WorkflowNodeType.Reply, + text: '指定回复内容,引用变量会转换为字符串进行输出', + label: '指定回复', + height: 210, + properties: { + stepName: '指定回复', + config: { + fields: [ + { + label: '内容', + value: 'answer', + }, + ], + }, + }, +} + +export const rerankerNode = { + type: WorkflowNodeType.RerankerNode, + text: '使用重排模型对多个知识库的检索结果进行二次召回', + label: '多路召回', + height: 252, + properties: { + stepName: '多路召回', + config: { + fields: [ + { + label: '重排结果列表', + value: 'result_list', + }, + { + label: '重排结果', + value: 'result', + }, + { + label: '满足直接回答的分段列表', + value: 'is_hit_handling_method_list', + }, + ], + }, + }, +} + +export const formNode = { + type: WorkflowNodeType.FormNode, + text: '在问答过程中用于收集用户信息,可以根据收集到表单数据执行后续流程', + label: '表单收集', + height: 252, + properties: { + width: 600, + stepName: '表单收集', + node_data: { + is_result: true, + form_field_list: [], + form_content_format: `${'你好,请先填写下面表单内容:'} +{{form}} +${'填写后请点击【提交】按钮进行提交。'}`, + }, + config: { + fields: [ + { + label: '表单全部内容', + value: 'form_data', + }, + ], + }, + }, +} + +export const documentExtractNode = { + type: WorkflowNodeType.DocumentExtractNode, + text: '解析输入文档,输出结构化文档内容', + label: '文档内容提取', + height: 252, + properties: { + stepName: '文档内容提取', + config: { + fields: [ + { + label: '文档内容', + value: 'content', + }, + { + label: '文档列表', + value: 'document_list', + }, + ], + }, + }, +} + +export const documentSplitNode = { + type: WorkflowNodeType.DocumentSplitNode, + text: '按分段策略拆分输入文档内容,输出分段文本列表', + label: '文档分段', + height: 252, + properties: { + width: 500, + stepName: '文档分段', + config: { + fields: [ + { + label: '分段列表', + value: 'paragraph_list', + }, + ], + }, + }, +} + +export const imageUnderstandNode = { + type: WorkflowNodeType.ImageUnderstandNode, + text: '识别出图片中的对象、场景等信息回答用户问题', + label: '图片理解', + height: 252, + properties: { + stepName: '图片理解', + config: { + fields: [ + { + label: 'AI 回答内容', + value: 'answer', + }, + ], + }, + }, +} + +export const videoUnderstandNode = { + type: WorkflowNodeType.VideoUnderstandNode, + text: '识别出视频中的对象、场景等信息回答用户问题', + label: '视频理解', + height: 252, + properties: { + stepName: '视频理解', + config: { + fields: [ + { + label: 'AI 回答内容', + value: 'answer', + }, + ], + }, + }, +} + +export const variableAggregationNode = { + type: WorkflowNodeType.VariableAggregationNode, + text: '按聚合策略聚合每组的变量', + label: '变量聚合', + height: 252, + properties: { + stepName: '变量聚合', + config: { + fields: [], + }, + }, +} + +export const variableAssignNode = { + type: WorkflowNodeType.VariableAssignNode, + text: '更新全局变量的值', + label: '变量赋值', + height: 252, + properties: { + stepName: '变量赋值', + config: {}, + }, +} + +export const mcpNode = { + type: WorkflowNodeType.McpNode, + text: '通过 SSE/Streamable HTTP 方式执行MCP服务中的工具', + label: 'MCP 调用', + height: 252, + properties: { + stepName: 'MCP 调用', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +export const imageGenerateNode = { + type: WorkflowNodeType.ImageGenerateNode, + text: '根据提供的文本内容生成图片', + label: '图片生成', + height: 252, + properties: { + stepName: '图片生成', + config: { + fields: [ + { + label: 'AI 回答内容', + value: 'answer', + }, + { + label: '图片', + value: 'image', + }, + ], + }, + }, +} + +export const speechToTextNode = { + type: WorkflowNodeType.SpeechToTextNode, + text: '将音频通过语音识别模型转换为文本', + label: '语音转文本', + height: 252, + properties: { + stepName: '语音转文本', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +export const textToSpeechNode = { + type: WorkflowNodeType.TextToSpeechNode, + text: '将文本通过语音合成模型转换为音频', + label: '文本转语音', + height: 252, + properties: { + stepName: '文本转语音', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +/** + * 自定义工具配置数据 + */ +export const toolNode = { + type: WorkflowNodeType.ToolLibCustom, + text: '通过执行自定义脚本,实现数据处理', + label: '自定义工具', + height: 260, + properties: { + stepName: '自定义工具', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +export const intentNode = { + type: WorkflowNodeType.IntentNode, + text: '将用户问题与用户预设的意图分类进行匹配', + label: '意图识别', + height: 260, + properties: { + stepName: '意图识别', + config: { + fields: [ + { + label: '分类', + value: 'category', + }, + { + label: '理由', + value: 'reason', + }, + ], + }, + }, +} + +export const loopStartNode = { + id: WorkflowNodeType.LoopStartNode, + type: WorkflowNodeType.LoopStartNode, + x: 480, + y: 3340, + properties: { + height: 364, + stepName: '循环开始', + config: { + fields: [ + { + label: '下标', + value: 'index', + }, + { + label: '循环元素', + value: 'item', + }, + ], + globalFields: [], + }, + showNode: true, + }, +} + +export const loopNode = { + type: WorkflowNodeType.LoopNode, + visible: false, + text: '通过设置循环次数和逻辑,重复执行一系列任务', + label: '循环节点', + height: 252, + properties: { + stepName: '循环节点', + workflow: { + edges: [], + nodes: [ + { + x: 480, + y: 3340, + id: 'loop-start-node', + type: 'loop-start-node', + properties: { + config: { + fields: [], + globalFields: [], + }, + fields: [], + height: 361.333, + showNode: true, + stepName: '开始', + globalFields: [], + }, + }, + ], + }, + config: { + fields: [], + }, + }, +} + +export const imageToVideoNode = { + type: WorkflowNodeType.ImageToVideoGenerateNode, + text: '根据提供的图片生成视频', + label: '图生视频', + height: 252, + properties: { + stepName: '图生视频', + config: { + fields: [ + { + label: '视频', + value: 'video', + }, + ], + }, + }, +} + +export const loopBodyNode = { + type: WorkflowNodeType.LoopBodyNode, + text: '循环体', + label: '循环体', + height: 1080, + properties: { + width: 1920, + stepName: '循环体', + config: { + fields: [], + }, + }, +} + +export const loopContinueNode = { + type: WorkflowNodeType.LoopContinueNode, + text: '用于终止当前循环,执行下次循环', + label: 'Continue', + height: 100, + properties: { + width: 600, + stepName: 'Continue', + config: { + fields: [], + }, + }, +} + +export const textToVideoNode = { + type: WorkflowNodeType.TextToVideoGenerateNode, + text: '根据提供的文本内容生成视频', + label: '文生视频', + height: 252, + properties: { + stepName: '文生视频', + config: { + fields: [ + { + label: '视频', + value: 'video', + }, + ], + }, + }, +} + +export const loopBreakNode = { + type: WorkflowNodeType.LoopBreakNode, + text: '终止当前循环,跳出循环体', + label: 'Break', + height: 100, + properties: { + width: 600, + stepName: 'Break', + config: { + fields: [], + }, + }, +} + +/** + * 工具配置数据 + */ +export const toolLibNode = { + type: WorkflowNodeType.ToolLib, + text: '通过执行自定义脚本,实现数据处理', + label: '自定义工具', + height: 170, + properties: { + stepName: '自定义工具', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} + +/** + * 工作流工具配置数据 + */ +export const toolWorkflowLibNode = { + type: WorkflowNodeType.ToolWorkflowLib, + text: '工作流工具', + label: '工作流工具', + height: 170, + properties: { + stepName: '工作流工具', + config: { + fields: [], + }, + }, +} + +export const applicationNode = { + type: WorkflowNodeType.Application, + text: '智能体节点', + label: '智能体节点', + height: 260, + properties: { + stepName: '智能体节点', + config: { + fields: [ + { + label: '结果', + value: 'result', + }, + ], + }, + }, +} diff --git a/ui/src/workflow-canvas/config/node-mapping.ts b/ui/src/workflow-canvas/config/node-mapping.ts new file mode 100644 index 00000000000..ea3f069b145 --- /dev/null +++ b/ui/src/workflow-canvas/config/node-mapping.ts @@ -0,0 +1,109 @@ +import { TOOL_TYPE, type ToolType } from '@/api/types/workspace-tool' +import { WorkflowMode, WorkflowNodeType } from '@/workflow-canvas/types' +import * as NodeData from './node-data' +interface WorkflowModelNode { + properties?: { + node_data?: { + tool_type?: ToolType + } + } + type: WorkflowNodeType +} +type WorkflowNodeMatcher = (node: unknown) => boolean +export const workflowModelDict: Record = { + [WorkflowMode.Application]: (value) => { + const node = value as WorkflowModelNode + return ( + [ + WorkflowNodeType.Application, + WorkflowNodeType.ToolWorkflowLib, + WorkflowNodeType.ToolLib, + ].includes(node.type) && node?.properties?.node_data?.tool_type !== TOOL_TYPE.DATA_SOURCE + ) + }, + [WorkflowMode.ApplicationLoop]: (value) => { + const node = value as WorkflowModelNode + return ( + [ + WorkflowNodeType.Application, + WorkflowNodeType.ToolWorkflowLib, + WorkflowNodeType.ToolLib, + ].includes(node.type) && node?.properties?.node_data?.tool_type !== TOOL_TYPE.DATA_SOURCE + ) + }, + [WorkflowMode.Knowledge]: (value) => { + const node = value as WorkflowModelNode + return [WorkflowNodeType.ToolWorkflowLib, WorkflowNodeType.ToolLib].includes(node.type) + }, + [WorkflowMode.KnowledgeLoop]: (value) => { + const node = value as WorkflowModelNode + return ( + [WorkflowNodeType.ToolWorkflowLib, WorkflowNodeType.ToolLib].includes(node.type) && + node?.properties?.node_data?.tool_type !== TOOL_TYPE.DATA_SOURCE + ) + }, + [WorkflowMode.Tool]: (value) => { + const node = value as WorkflowModelNode + return ( + [WorkflowNodeType.ToolWorkflowLib, WorkflowNodeType.ToolLib].includes(node.type) && + node?.properties?.node_data?.tool_type !== TOOL_TYPE.DATA_SOURCE + ) + }, + [WorkflowMode.ToolLoop]: (value) => { + const node = value as WorkflowModelNode + return ( + [WorkflowNodeType.ToolWorkflowLib, WorkflowNodeType.ToolLib].includes(node.type) && + node?.properties?.node_data?.tool_type !== TOOL_TYPE.DATA_SOURCE + ) + }, +} +export const nodeDict: Partial> = { + [WorkflowNodeType.AiChat]: NodeData.aiChatNode, + [WorkflowNodeType.SearchKnowledge]: NodeData.searchKnowledgeNode, + [WorkflowNodeType.SearchDocument]: NodeData.searchDocumentNode, + [WorkflowNodeType.Question]: NodeData.questionNode, + [WorkflowNodeType.Condition]: NodeData.conditionNode, + [WorkflowNodeType.Base]: NodeData.baseNode, + [WorkflowNodeType.Start]: NodeData.startNode, + [WorkflowNodeType.Reply]: NodeData.replyNode, + [WorkflowNodeType.ToolLib]: NodeData.toolNode, + [WorkflowNodeType.ToolWorkflowLib]: NodeData.toolWorkflowLibNode, + [WorkflowNodeType.ToolLibCustom]: NodeData.toolNode, + [WorkflowNodeType.RerankerNode]: NodeData.rerankerNode, + [WorkflowNodeType.FormNode]: NodeData.formNode, + [WorkflowNodeType.Application]: NodeData.applicationNode, + [WorkflowNodeType.DocumentExtractNode]: NodeData.documentExtractNode, + [WorkflowNodeType.DocumentSplitNode]: NodeData.documentSplitNode, + [WorkflowNodeType.ImageUnderstandNode]: NodeData.imageUnderstandNode, + [WorkflowNodeType.TextToSpeechNode]: NodeData.textToSpeechNode, + [WorkflowNodeType.SpeechToTextNode]: NodeData.speechToTextNode, + [WorkflowNodeType.ImageGenerateNode]: NodeData.imageGenerateNode, + [WorkflowNodeType.VariableAssignNode]: NodeData.variableAssignNode, + [WorkflowNodeType.McpNode]: NodeData.mcpNode, + [WorkflowNodeType.TextToVideoGenerateNode]: NodeData.textToVideoNode, + [WorkflowNodeType.ImageToVideoGenerateNode]: NodeData.imageToVideoNode, + [WorkflowNodeType.IntentNode]: NodeData.intentNode, + [WorkflowNodeType.LoopNode]: NodeData.loopNode, + [WorkflowNodeType.LoopBodyNode]: NodeData.loopBodyNode, + [WorkflowNodeType.LoopStartNode]: NodeData.loopStartNode, + [WorkflowNodeType.LoopBreakNode]: NodeData.loopBodyNode, + [WorkflowNodeType.LoopContinueNode]: NodeData.loopContinueNode, + [WorkflowNodeType.VariableSplittingNode]: NodeData.variableSplittingNode, + [WorkflowNodeType.VideoUnderstandNode]: NodeData.videoUnderstandNode, + [WorkflowNodeType.ParameterExtractionNode]: NodeData.parameterExtractionNode, + [WorkflowNodeType.VariableAggregationNode]: NodeData.variableAggregationNode, + [WorkflowNodeType.KnowledgeBase]: NodeData.knowledgeBaseNode, + [WorkflowNodeType.DataSourceLocalNode]: NodeData.dataSourceLocalNode, + [WorkflowNodeType.DataSourceWebNode]: NodeData.dataSourceWebNode, + [WorkflowNodeType.KnowledgeWriteNode]: NodeData.knowledgeWriteNode, + [WorkflowNodeType.ToolBaseNode]: NodeData.toolBaseNode, + [WorkflowNodeType.ToolStartNode]: NodeData.toolStartNode, +} +export const BasicComponentsNode: Partial< + Record +> = { + [WorkflowNodeType.AiChat]: NodeData.aiChatNode, + [WorkflowNodeType.Reply]: NodeData.replyNode, +} + +export const defaultNodes = [NodeData.baseNode, NodeData.startNode] diff --git a/ui/src/workflow-canvas/core/CustomLine.vue b/ui/src/workflow-canvas/core/CustomLine.vue new file mode 100644 index 00000000000..951cbac867c --- /dev/null +++ b/ui/src/workflow-canvas/core/CustomLine.vue @@ -0,0 +1,38 @@ + + + diff --git a/ui/src/workflow-canvas/core/NodeCascader.vue b/ui/src/workflow-canvas/core/NodeCascader.vue new file mode 100644 index 00000000000..1e5d2ee893c --- /dev/null +++ b/ui/src/workflow-canvas/core/NodeCascader.vue @@ -0,0 +1,114 @@ + + + diff --git a/ui/src/workflow-canvas/core/NodeContainer.vue b/ui/src/workflow-canvas/core/NodeContainer.vue new file mode 100644 index 00000000000..10af8f683e6 --- /dev/null +++ b/ui/src/workflow-canvas/core/NodeContainer.vue @@ -0,0 +1,615 @@ + + + diff --git a/ui/src/workflow-canvas/core/edge.ts b/ui/src/workflow-canvas/core/edge.ts new file mode 100644 index 00000000000..c3612202622 --- /dev/null +++ b/ui/src/workflow-canvas/core/edge.ts @@ -0,0 +1,249 @@ +import { + BezierEdge, + BezierEdgeModel, + h, + type BaseEdgeModel, + type GraphModel, +} from '@logicflow/core' +import type { App } from 'vue' +import { createApp, h as vh } from 'vue' +import { isActive, connect, disconnect } from './teleport' +import CustomLine from './CustomLine.vue' + +function isMouseInElement(element: Element, event: PointerEvent) { + const rect = element.getBoundingClientRect() + return ( + event.clientX >= rect.left && + event.clientX <= rect.right && + event.clientY >= rect.top && + event.clientY <= rect.bottom + ) +} +const DEFAULT_WIDTH = 32 +const DEFAULT_HEIGHT = 32 +class CustomEdge2 extends BezierEdge { + isMounted = false + customLineApp?: App + root?: HTMLDivElement + + constructor() { + super() + this.handleMouseUp = (event?: PointerEvent) => { + if (!event || !(event.target instanceof Element)) return + + this.props.graphModel.clearSelectElements() + this.props.model.isSelected = true + const element = + event.target.parentElement?.parentElement?.querySelector('.lf-custom-edge-wrapper') + if (element && isMouseInElement(element, event)) { + this.props.model.graphModel.deleteEdgeById(this.props.model.id) + } + } + } + /** + * 渲染vue组件 + * @param root + */ + protected renderVueComponent(root: HTMLDivElement) { + this.unmountVueComponent() + this.root = root + const { graphModel } = this.props + if (root) { + if (isActive()) { + connect( + this.targetId(), + CustomLine, + root, + this.props.model, + graphModel, + (node: BaseEdgeModel, graph: GraphModel) => { + return { model: node, graph } + }, + ) + } else { + this.customLineApp = createApp({ + render: () => vh(CustomLine, { model: this.props.model }), + }) + this.customLineApp?.mount(root) + } + } + } + protected targetId() { + return `${this.props.graphModel.flowId}:${this.props.model.id}` + } + /** + * 组件即将卸载勾子 + */ + componentWillUnmount() { + if (super.componentWillUnmount) { + super.componentWillUnmount() + } + if (isActive()) { + disconnect(this.targetId()) + } + this.unmountVueComponent() + } + /** + * 卸载vue + * @returns + */ + protected unmountVueComponent() { + if (this.customLineApp) { + this.customLineApp.unmount() + this.customLineApp = undefined + } + if (this.root) { + this.root.innerHTML = '' + } + return this.root + } + + getEdge() { + const { model } = this.props + const id = model.id + const { customWidth = DEFAULT_WIDTH, customHeight = DEFAULT_HEIGHT } = model.getProperties() + const { startPoint, endPoint, path, isAnimation, arrowConfig } = model + const animationStyle = model.getEdgeAnimationStyle() + const { + strokeDasharray, + stroke, + strokeDashoffset, + animationName, + animationDuration, + animationIterationCount, + animationTimingFunction, + animationDirection, + } = animationStyle + const positionData = { + x: (startPoint.x + endPoint.x - customWidth) / 2, + y: (startPoint.y + endPoint.y - customHeight) / 2, + width: customWidth, + height: customHeight, + } + const style = model.getEdgeStyle() + const wrapperStyle = { + width: customWidth, + height: customHeight, + } + + setTimeout(() => { + const s = document.getElementById(id) + if (s instanceof HTMLDivElement && !this.isMounted) { + this.isMounted = true + this.renderVueComponent(s) + } + }, 0) + + delete style.stroke + + return h('g', {}, [ + h('style', { type: 'text/css' }, '.lf-edge{stroke:#afafaf}.lf-edge:hover{stroke: #3370FF;}'), + h('path', { + d: path, + ...style, + ...arrowConfig, + ...(isAnimation + ? { + strokeDasharray, + stroke, + style: { + strokeDashoffset, + animationName, + animationDuration, + animationIterationCount, + animationTimingFunction, + animationDirection, + }, + } + : {}), + }), + h( + 'foreignObject', + { + ...positionData, + y: positionData.y + 5, + x: positionData.x + 5, + style: {}, + }, + [ + h('div', { + id, + style: { ...wrapperStyle }, + className: 'lf-custom-edge-wrapper', + }), + ], + ), + ]) + } +} + +class CustomEdgeModel2 extends BezierEdgeModel { + getArrowStyle() { + const arrowStyle = super.getArrowStyle() + arrowStyle.offset = 1 + arrowStyle.verticalLength = 0 + return arrowStyle + } + + getEdgeStyle() { + const style = super.getEdgeStyle() + // svg属性 + style.strokeWidth = 2 + style.stroke = '#BBBFC4' + style.offset = 0 + return style + } + /** + * 重写此方法,使保存数据是能带上锚点数据。 + */ + getData() { + const data = super.getData() + if (data) { + data.sourceAnchorId = this.sourceAnchorId + data.targetAnchorId = this.targetAnchorId + } + return data + } + /** + * 给边自定义方案,使其支持基于锚点的位置更新边的路径 + */ + updatePathByAnchor() { + const sourceNodeModel = this.graphModel.getNodeModelById(this.sourceNodeId) + const sourceAnchor = sourceNodeModel + ?.getDefaultAnchor() + .find((anchor) => anchor.id === this.sourceAnchorId) + + const targetNodeModel = this.graphModel.getNodeModelById(this.targetNodeId) + const targetAnchor = targetNodeModel + ?.getDefaultAnchor() + .find((anchor) => anchor.id === this.targetAnchorId) + if (sourceAnchor && targetAnchor) { + const startPoint = { + x: sourceAnchor.x, + y: sourceAnchor.y, + } + this.updateStartPoint(startPoint) + const endPoint = { + x: targetAnchor.x, + y: targetAnchor.y, + } + + this.updateEndPoint(endPoint) + } + + // 这里需要将原有的pointsList设置为空,才能触发bezier的自动计算control点。 + this.pointsList = [] + this.initPoints() + } + setAttributes(): void { + super.setAttributes() + this.isHitable = true + this.zIndex = 0 + } +} + +export default { + type: 'app-edge', + view: CustomEdge2, + model: CustomEdgeModel2, +} diff --git a/ui/src/workflow-canvas/core/loop-edge.ts b/ui/src/workflow-canvas/core/loop-edge.ts new file mode 100644 index 00000000000..5fd801dcf89 --- /dev/null +++ b/ui/src/workflow-canvas/core/loop-edge.ts @@ -0,0 +1,73 @@ +import { BezierEdge, BezierEdgeModel } from '@logicflow/core' + +class CustomEdgeModel2 extends BezierEdgeModel { + getArrowStyle() { + const arrowStyle = super.getArrowStyle() + arrowStyle.offset = 0 + arrowStyle.verticalLength = 0 + return arrowStyle + } + + getEdgeStyle() { + const style = super.getEdgeStyle() + // svg属性 + style.strokeWidth = 2 + style.stroke = '#BBBFC4' + style.offset = 0 + return style + } + /** + * 重写此方法,使保存数据是能带上锚点数据。 + */ + getData() { + const data = super.getData() + if (data) { + data.sourceAnchorId = this.sourceAnchorId + data.targetAnchorId = this.targetAnchorId + } + return data + } + /** + * 给边自定义方案,使其支持基于锚点的位置更新边的路径 + */ + updatePathByAnchor() { + // TODO + const sourceNodeModel = this.graphModel.getNodeModelById(this.sourceNodeId) + const sourceAnchor = sourceNodeModel + ?.getDefaultAnchor() + .find((anchor) => anchor.id === this.sourceAnchorId) + + const targetNodeModel = this.graphModel.getNodeModelById(this.targetNodeId) + const targetAnchor = targetNodeModel + ?.getDefaultAnchor() + .find((anchor) => anchor.id === this.targetAnchorId) + if (sourceAnchor && targetAnchor) { + const startPoint = { + x: sourceAnchor.x, + y: sourceAnchor.y - 10, + } + this.updateStartPoint(startPoint) + const endPoint = { + x: targetAnchor.x, + y: targetAnchor.y + 3, + } + + this.updateEndPoint(endPoint) + } + + // 这里需要将原有的pointsList设置为空,才能触发bezier的自动计算control点。 + this.pointsList = [] + this.initPoints() + } + setAttributes(): void { + super.setAttributes() + this.isHitable = true + this.zIndex = 0 + } +} + +export default { + type: 'loop-edge', + view: BezierEdge, + model: CustomEdgeModel2, +} diff --git a/ui/src/workflow-canvas/core/shortcut.ts b/ui/src/workflow-canvas/core/shortcut.ts new file mode 100644 index 00000000000..077aa5968a2 --- /dev/null +++ b/ui/src/workflow-canvas/core/shortcut.ts @@ -0,0 +1,371 @@ +import { cloneDeep } from 'lodash' +import type LogicFlow from '@logicflow/core' +import { type GraphModel } from '@logicflow/core' +import { MsgError, MsgConfirm } from '@/utils/message' +import { WorkflowMode, WorkflowNodeType } from '@/workflow-canvas/types' +import { copyText } from '@/utils/clipboard' +import { randomId } from '@/utils/common' +import { workflowModelDict } from '@/workflow-canvas/config/node-mapping' +import { getMenuNodes } from '@/workflow-canvas/config/menu' +let activeCanvasId: string | null = null +type Point = { x: number; y: number } +type WorkflowGraphModel = GraphModel & { + get_provide: ( + node: LogicFlow.NodeData | null, + graph: GraphModel | null, + ) => { workflowMode: WorkflowMode } +} + +const lastMouse = { + x: 0, + y: 0, + hasValue: false, +} +let selected: LogicFlow.GraphData | null = null +const bindMousePosition = (lf: LogicFlow) => { + const updateMouse = (e: MouseEvent) => { + lastMouse.x = e.clientX + lastMouse.y = e.clientY + lastMouse.hasValue = true + } + + // 推荐直接监听容器,这样鼠标在节点上移动也能拿到 + lf.container.addEventListener('mousemove', updateMouse) + + return () => { + lf.container.removeEventListener('mousemove', updateMouse) + } +} +const bindCanvasActive = (lf: LogicFlow) => { + const container = lf.container + if (!container) return + + // 让容器可聚焦 + container.tabIndex = 0 + + const activate = () => { + activeCanvasId = lf.graphModel.flowId ?? null + container.focus() + } + + container.addEventListener('mousedown', activate) + container.addEventListener('focus', activate) + + return () => { + container.removeEventListener('mousedown', activate) + container.removeEventListener('focus', activate) + } +} +function translationNodeData(nodeData: LogicFlow.NodeData, distance: number) { + nodeData.x += distance + nodeData.y += distance + if (nodeData.text) { + nodeData.text.x += distance + nodeData.text.y += distance + } + return nodeData +} + +function translationEdgeData(edgeData: LogicFlow.EdgeData, distance: number) { + if (edgeData.startPoint) { + edgeData.startPoint.x += distance + edgeData.startPoint.y += distance + } + if (edgeData.endPoint) { + edgeData.endPoint.x += distance + edgeData.endPoint.y += distance + } + if (edgeData.pointsList && edgeData.pointsList.length > 0) { + edgeData.pointsList.forEach((point) => { + point.x += distance + point.y += distance + }) + } + if (edgeData.text) { + edgeData.text.x += distance + edgeData.text.y += distance + } + return edgeData +} + +const TRANSLATION_DISTANCE = 40 +let CHILDREN_TRANSLATION_DISTANCE = 40 + +export function initDefaultShortcut(lf: LogicFlow, graph: GraphModel) { + bindMousePosition(lf) + bindCanvasActive(lf) + const { keyboard } = lf + const { + options: { keyboard: keyboardOptions }, + } = keyboard + const copy_node = () => { + CHILDREN_TRANSLATION_DISTANCE = TRANSLATION_DISTANCE + if (!keyboardOptions?.enabled) return true + if (graph.textEditElement) return true + const { guards } = lf.options + const elements = graph.getSelectElements(false) + const enabledClone = guards && guards.beforeClone ? guards.beforeClone(elements) : true + if (!enabledClone || (elements.nodes.length === 0 && elements.edges.length === 0)) { + selected = null + return true + } + const base_nodes = elements.nodes.filter( + (node) => node.type === WorkflowNodeType.Start || node.type === WorkflowNodeType.Base, + ) + if (base_nodes.length > 0) { + MsgError(base_nodes[0]?.properties?.stepName + '不能被复制') + return + } + selected = cloneDeep(elements) + selected.nodes.forEach((node) => translationNodeData(node, TRANSLATION_DISTANCE)) + selected.edges.forEach((edge) => translationEdgeData(edge, TRANSLATION_DISTANCE)) + copyText(JSON.stringify(selected)) + return false + } + // 3. 求节点包围盒 + const getBounds = (nodes: LogicFlow.NodeData[]) => { + if (!nodes.length) { + return { minX: 0, maxX: 0, minY: 0, maxY: 0 } + } + + const firstNode = nodes[0]! + let minX = firstNode.x + let maxX = firstNode.x + let minY = firstNode.y + let maxY = firstNode.y + + for (const node of nodes) { + if (node.x < minX) minX = node.x + if (node.x > maxX) maxX = node.x + if (node.y < minY) minY = node.y + if (node.y > maxY) maxY = node.y + } + + return { minX, maxX, minY, maxY } + } + + // 4. 整体平移 + const moveData = (data: LogicFlow.GraphData, dx: number, dy: number) => { + for (const node of data.nodes ?? []) { + node.x += dx + node.y += dy + } + + for (const edge of data.edges ?? []) { + if (edge.startPoint) { + edge.startPoint.x += dx + edge.startPoint.y += dy + } + if (edge.endPoint) { + edge.endPoint.x += dx + edge.endPoint.y += dy + } + if (edge.text && typeof edge.text.x === 'number' && typeof edge.text.y === 'number') { + edge.text.x += dx + edge.text.y += dy + } + if (Array.isArray(edge.pointsList)) { + edge.pointsList = edge.pointsList.map((p: Point) => ({ + ...p, + x: p.x + dx, + y: p.y + dy, + })) + } + } + } + const resetData = (data: LogicFlow.GraphData) => { + const idMap = new Map() + + const getOrCreateId = (oldId: string) => { + let newId = idMap.get(oldId) + if (!newId) { + newId = randomId() + idMap.set(oldId, newId) + } + return newId + } + + for (const node of data.nodes) { + node.id = getOrCreateId(node.id) + } + + for (const edge of data.edges) { + const oldEdgeId = edge.id + const oldSourceNodeId = edge.sourceNodeId + const oldTargetNodeId = edge.targetNodeId + + edge.id = getOrCreateId(oldEdgeId) + edge.sourceNodeId = getOrCreateId(oldSourceNodeId) + edge.targetNodeId = getOrCreateId(oldTargetNodeId) + + if (typeof edge.sourceAnchorId === 'string') { + edge.sourceAnchorId = edge.sourceAnchorId.replace(oldSourceNodeId, edge.sourceNodeId) + } + + if (typeof edge.targetAnchorId === 'string') { + edge.targetAnchorId = edge.targetAnchorId.replace(oldTargetNodeId, edge.targetNodeId) + } + } + + return data + } + + const paste_node = async (e: ClipboardEvent) => { + if (lf.graphModel.flowId !== activeCanvasId) { + return true + } + if (!keyboardOptions?.enabled) return true + if (graph.textEditElement) return true + const text = e.clipboardData?.getData('text/plain') || '' + const data = parseAndValidate(text) + selected = resetData(data) + const workflowMode = (lf.graphModel as WorkflowGraphModel).get_provide(null, null).workflowMode + const menus = getMenuNodes(workflowMode) + const menuNodeTypes = new Set(menus?.flatMap((menu) => menu.list).map((node) => node.type)) + + if (selected && (selected.nodes || selected.edges)) { + if (!lastMouse.hasValue) { + moveData(data, 40, 40) + } else { + // LogicFlow 文档里 getPointByClient 会把页面坐标转成画布坐标 + const point = lf.graphModel.getPointByClient({ + x: lastMouse.x, + y: lastMouse.y, + }) + const mouseCanvasX = point.canvasOverlayPosition.x + const mouseCanvasY = point.canvasOverlayPosition.y + + const { minX, maxX, minY, maxY } = getBounds(selected.nodes) + const centerX = (minX + maxX) / 2 + const centerY = (minY + maxY) / 2 + moveData(data, mouseCanvasX - centerX, mouseCanvasY - centerY) + } + + selected.nodes = selected.nodes.filter( + (node) => + menuNodeTypes.has(node.type as WorkflowNodeType) || workflowModelDict[workflowMode](node), + ) + lf.clearSelectElements() + const addElements = lf.addElements(selected, CHILDREN_TRANSLATION_DISTANCE) + if (!addElements) return true + addElements.nodes.forEach((node) => lf.selectElementById(node.id, true)) + addElements.edges.forEach((edge) => lf.selectElementById(edge.id, true)) + selected.nodes.forEach((node) => translationNodeData(node, TRANSLATION_DISTANCE)) + selected.edges.forEach((edge) => translationEdgeData(edge, TRANSLATION_DISTANCE)) + CHILDREN_TRANSLATION_DISTANCE = CHILDREN_TRANSLATION_DISTANCE + TRANSLATION_DISTANCE + } + selected = null + return false + } + + const parseAndValidate = (text: string) => { + let data: unknown + try { + data = JSON.parse(text) + } catch { + throw new Error('数据不是合法的 JSON') + } + + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('数据必须是对象') + } + + if (!('nodes' in data)) { + throw new Error('数据缺少 nodes 字段') + } + + if (!('edges' in data)) { + throw new Error('数据缺少 edges 字段') + } + + if (!Array.isArray(data.nodes)) { + throw new Error('nodes 必须是数组') + } + + for (let i = 0; i < data.nodes.length; i++) { + const node = data.nodes[i] + + if (!node || typeof node !== 'object' || Array.isArray(node)) { + throw new Error(`nodes[${i}] 必须是对象`) + } + + if (!('id' in node) || node.id === undefined || node.id === null || node.id === '') { + throw new Error(`nodes[${i}] 缺少 id`) + } + } + + return data as LogicFlow.GraphData + } + document.addEventListener('paste', paste_node) + + const delete_node = () => { + const elements = graph.getSelectElements(true) + lf.clearSelectElements() + if (elements.nodes.length == 0 && elements.edges.length == 0) { + return + } + if (elements.edges.length > 0 && elements.nodes.length == 0) { + elements.edges + .filter((edge) => !['loop-edge'].includes(edge.type || '')) + .forEach((edge) => lf.deleteEdge(edge.id)) + return + } + const protectedNodeTypes: string[] = [ + WorkflowNodeType.Start, + WorkflowNodeType.ToolStartNode, + WorkflowNodeType.ToolBaseNode, + WorkflowNodeType.Base, + WorkflowNodeType.LoopBodyNode, + WorkflowNodeType.LoopStartNode, + WorkflowNodeType.KnowledgeBase, + ] + const nodes = elements.nodes.filter((node) => protectedNodeTypes.includes(node.type)) + if (nodes.length > 0) { + MsgError(`${nodes[0]?.properties?.stepName}节点不允许删除`) + return + } + MsgConfirm('提示', '确定删除选中的节点和连线吗?', { + confirmButtonText: '确定', + confirmButtonClass: 'danger', + }).then(() => { + if (!keyboardOptions?.enabled) return true + if (graph.textEditElement) return true + + elements.edges.forEach((edge) => lf.deleteEdge(edge.id)) + elements.nodes.forEach((node) => { + if (node.type === WorkflowNodeType.LoopNode) { + const next = lf.getNodeOutgoingNode(node.id) + next.forEach((nextNode) => { + if (String(nextNode.type) === WorkflowNodeType.LoopBodyNode) { + lf.deleteNode(nextNode.id) + } + }) + } + lf.deleteNode(node.id) + }) + }) + + return false + } + graph.eventCenter.on('copy_node', copy_node) + // 复制 + keyboard.on(['cmd + c', 'ctrl + c'], copy_node) + // 粘贴 + keyboard.on(['cmd + v', 'ctrl + v'], () => {}) + // undo + keyboard.on(['cmd + z', 'ctrl + z'], () => { + // if (!keyboardOptions?.enabled) return true + // if (graph.textEditElement) return true + // lf.undo() + // return false + }) + // redo + keyboard.on(['cmd + y', 'ctrl + y'], () => { + if (!keyboardOptions?.enabled) return true + if (graph.textEditElement) return true + lf.redo() + return false + }) + // delete + keyboard.on(['backspace'], delete_node) +} diff --git a/ui/src/workflow-canvas/core/teleport.ts b/ui/src/workflow-canvas/core/teleport.ts new file mode 100644 index 00000000000..5d58b5e7c5b --- /dev/null +++ b/ui/src/workflow-canvas/core/teleport.ts @@ -0,0 +1,105 @@ +import type { BaseEdgeModel, BaseNodeModel, GraphModel } from '@logicflow/core' +import type { Component } from 'vue' +import { defineComponent, h, reactive, isVue3, Teleport, markRaw, Fragment } from 'vue-demi' + +type TeleportNodeModel = BaseNodeModel | BaseEdgeModel +type TeleportProps = Record +type TeleportProvide = Record +type PropsFactory = ( + node: NodeModel, + graph: GraphModel, +) => TeleportProps +type ProvideFactory = ( + node: NodeModel, + graph: GraphModel, +) => TeleportProvide + +let active = false +const teleportItems = reactive>({}) + +export function connect( + id: string, + component: Component, + container: HTMLDivElement, + node: NodeModel, + graph: GraphModel, + get_props?: PropsFactory, + get_provide?: ProvideFactory, +) { + if (!get_props) { + get_props = (node: BaseNodeModel | BaseEdgeModel, graph: GraphModel) => { + return { nodeModel: node, graph } + } + } + if (!get_provide) { + get_provide = (node: BaseNodeModel | BaseEdgeModel, graph: GraphModel) => ({ + getNode: () => node, + getGraph: () => graph, + }) + } + if (active) { + teleportItems[id] = markRaw( + defineComponent({ + render: () => h(Teleport, { to: container }, [h(component, get_props(node, graph))]), + provide: () => get_provide(node, graph), + }), + ) + } +} + +export function disconnect(id: string) { + if (active) delete teleportItems[id] +} + +export function disconnectByFlow(flowId: string) { + Object.keys(teleportItems).forEach((id) => { + if (id.startsWith(flowId)) delete teleportItems[id] + }) +} + +export function disconnectAll() { + Object.keys(teleportItems).forEach((id) => delete teleportItems[id]) +} + +export function isActive() { + return active +} + +export function getTeleport(): Component { + if (!isVue3) { + throw new Error('teleport is only available in Vue3') + } + active = true + + return defineComponent({ + props: { + flowId: { + type: String, + required: true, + }, + }, + setup(props) { + return () => { + const children: Component[] = [] + Object.keys(teleportItems).forEach((id) => { + // https://github.com/didi/LogicFlow/issues/1768 + // 多个不同的VueNodeView都会connect注册到items中,因此items存储了可能有多个flowId流程图的数据 + // 当使用多个LogicFlow时,会创建多个flowId + 同时使用KeepAlive + // 每一次items改变,会触发不同flowId持有的setup()执行,由于每次setup()执行就是遍历items,因此存在多次重复渲染元素的问题 + // 即items[0]会在Page1的setup()执行,items[0]也会在Page2的setup()执行,从而生成两个items[0] + + // 比对当前界面显示的flowId,只更新items[当前页面flowId:nodeId]的数据 + // 比如items[0]属于Page1的数据,那么Page2无论active=true/false,都无法执行items[0] + + const component = teleportItems[id] + if (component) children.push(component) + }) + return h( + Fragment, + {}, + children.map((item) => h(item)), + ) + } + }, + }) +} diff --git a/ui/src/workflow-canvas/core/utils.ts b/ui/src/workflow-canvas/core/utils.ts new file mode 100644 index 00000000000..0db997ce7c5 --- /dev/null +++ b/ui/src/workflow-canvas/core/utils.ts @@ -0,0 +1,24 @@ +export function isWorkFlow(type: string | undefined) { + return type === 'WORK_FLOW' +} + +export function handleNodeWheel(event: WheelEvent) { + if (event.ctrlKey) { + event.preventDefault() + return true + } else { + event.stopPropagation() + return true + } +} + +export function isLastNode(nodeModel: BaseNodeModel) { + const incoming = nodeModel.graphModel.getNodeIncomingNode(nodeModel.id) + const outcomming = nodeModel.graphModel.getNodeOutgoingNode(nodeModel.id) + if (incoming.length > 0 && outcomming.length === 0) { + return true + } else { + return false + } +} +import type { BaseNodeModel } from '@logicflow/core' diff --git a/ui/src/workflow-canvas/core/validate.ts b/ui/src/workflow-canvas/core/validate.ts new file mode 100644 index 00000000000..aff6a783ec0 --- /dev/null +++ b/ui/src/workflow-canvas/core/validate.ts @@ -0,0 +1,430 @@ +import type LogicFlow from '@logicflow/core' +import { WorkflowNodeType, WorkflowMode, WorkflowKind } from '@/workflow-canvas/types' + +interface WorkflowBranch { + id: string + type: string +} + +interface WorkflowValidationNodeProperties extends LogicFlow.PropertiesType { + kind?: WorkflowKind + node_data?: { + branch?: WorkflowBranch[] + loop_body?: WorkflowGraphData + } + status?: number + stepName?: string +} + +type WorkflowValidationNode = LogicFlow.NodeData & { + properties: WorkflowValidationNodeProperties + type: WorkflowNodeType +} + +type WorkflowValidationEdge = LogicFlow.EdgeData + +interface WorkflowGraphData { + edges: WorkflowValidationEdge[] + nodes: WorkflowValidationNode[] +} + +const end_nodes: string[] = [ + WorkflowNodeType.AiChat, + WorkflowNodeType.Reply, + WorkflowNodeType.ToolLib, + WorkflowNodeType.ToolLibCustom, + WorkflowNodeType.ImageUnderstandNode, + WorkflowNodeType.Application, + WorkflowNodeType.SpeechToTextNode, + WorkflowNodeType.TextToSpeechNode, + WorkflowNodeType.ImageGenerateNode, + WorkflowNodeType.ImageToVideoGenerateNode, + WorkflowNodeType.TextToVideoGenerateNode, + WorkflowNodeType.LoopBodyNode, + WorkflowNodeType.LoopNode, + WorkflowNodeType.LoopBreakNode, + WorkflowNodeType.VideoUnderstandNode, + WorkflowNodeType.VariableAssignNode, + WorkflowNodeType.KnowledgeWriteNode, + WorkflowNodeType.ToolWorkflowLib, +] + +const loop_end_nodes: string[] = [ + WorkflowNodeType.AiChat, + WorkflowNodeType.Reply, + WorkflowNodeType.ToolLib, + WorkflowNodeType.ToolLibCustom, + WorkflowNodeType.ImageUnderstandNode, + WorkflowNodeType.VideoUnderstandNode, + WorkflowNodeType.Application, + WorkflowNodeType.SpeechToTextNode, + WorkflowNodeType.TextToSpeechNode, + WorkflowNodeType.ImageGenerateNode, + WorkflowNodeType.ImageToVideoGenerateNode, + WorkflowNodeType.TextToVideoGenerateNode, + WorkflowNodeType.LoopBodyNode, + WorkflowNodeType.LoopNode, + WorkflowNodeType.LoopBreakNode, + WorkflowNodeType.VariableAssignNode, + WorkflowNodeType.ToolWorkflowLib, +] +const end_nodes_dict: Record = { + [WorkflowMode.Application]: end_nodes, + [WorkflowMode.Knowledge]: [WorkflowNodeType.KnowledgeWriteNode], + [WorkflowMode.ApplicationLoop]: loop_end_nodes, + [WorkflowMode.KnowledgeLoop]: [...loop_end_nodes, WorkflowNodeType.KnowledgeWriteNode], + [WorkflowMode.Tool]: end_nodes, + [WorkflowMode.ToolLoop]: loop_end_nodes, +} + +export class WorkFlowInstance { + nodes: WorkflowValidationNode[] + edges: WorkflowValidationEdge[] + workFlowNodes: WorkflowValidationNode[] + workflowModel: WorkflowMode + + constructor(workflow: WorkflowGraphData, workflowModel?: WorkflowMode) { + this.nodes = workflow.nodes + this.edges = workflow.edges + this.workFlowNodes = [] + this.workflowModel = workflowModel ? workflowModel : WorkflowMode.Application + } + + /** + * 校验开始节点 + */ + is_valid_start_node() { + const start_node_list = this.nodes.filter( + (item) => item.id === WorkflowNodeType.Start || item.id === WorkflowNodeType.LoopStartNode, + ) + if (start_node_list.length == 0) { + throw '开始节点必填' + } else if (start_node_list.length > 1) { + throw '开始节点只能有一个' + } + } + + /** + * 校验基本信息节点 + */ + is_valid_base_node() { + const start_node_list = this.nodes.filter((item) => item.id === WorkflowNodeType.Base) + if (start_node_list.length == 0) { + throw '基本信息节点必填' + } else if (start_node_list.length > 1) { + throw '基本信息节点只能有一个' + } + } + + /** + * 校验节点 + */ + is_valid() { + this.is_valid_start_node() + this.is_valid_base_node() + this.is_valid_work_flow() + this.is_valid_nodes() + } + + is_loop_valid() { + this.is_valid_start_node() + this.is_valid_work_flow() + this.is_valid_nodes() + } + + /** + * 获取开始节点 + * @returns + */ + get_start_node() { + const start_node_list = this.nodes.filter( + (item) => + item.id === WorkflowNodeType.Start || + item.id === WorkflowNodeType.LoopStartNode || + item.id === WorkflowNodeType.ToolStartNode, + ) + return start_node_list[0] + } + + /** + * 获取基本节点 + * @returns 基本节点 + */ + get_base_node() { + const base_node_list = this.nodes.filter((item) => item.id === WorkflowNodeType.Base) + return base_node_list[0] + } + + exist_break_node() { + return this.nodes.some((item) => item.type === WorkflowNodeType.LoopBreakNode) + } + + /** + * 校验工作流 + * @param up_node 上一个节点 + */ + _is_valid_work_flow(up_node?: WorkflowValidationNode) { + if (!up_node) { + up_node = this.get_start_node()! + } + this.workFlowNodes.push(up_node) + this.is_valid_node(up_node) + const next_nodes = this.get_next_nodes(up_node) + for (const next_node of next_nodes) { + this._is_valid_work_flow(next_node) + } + } + + is_valid_work_flow() { + this.workFlowNodes = [] + this._is_valid_work_flow() + const notInWorkFlowNodes = this.nodes + .filter( + (node) => + node.id !== WorkflowNodeType.Start && + node.id !== WorkflowNodeType.Base && + node.type !== WorkflowNodeType.ToolBaseNode && + node.type !== WorkflowNodeType.ToolStartNode, + ) + .filter((node) => !this.workFlowNodes.includes(node)) + if (notInWorkFlowNodes.length > 0) { + throw `未在流程中的节点:${notInWorkFlowNodes.map((node) => node.properties.stepName).join(',')}` + } + this.workFlowNodes = [] + } + + /** + * 获取流程下一个节点列表 + * @param node 节点 + * @returns 节点列表 + */ + get_next_nodes(node: WorkflowValidationNode) { + const edge_list = this.edges.filter((edge) => edge.sourceNodeId == node.id) + const node_list = edge_list + .map((edge) => this.nodes.filter((node) => node.id == edge.targetNodeId)) + .reduce((x, y) => [...x, ...y], []) + const end = end_nodes_dict[this.workflowModel] + if (node_list.length == 0 && !end.includes(node.type)) { + throw '不存在的下一个节点' + } + return node_list + } + + is_valid_nodes() { + for (const node of this.nodes) { + if ( + node.type !== WorkflowNodeType.Base && + node.type !== WorkflowNodeType.Start && + node.type !== WorkflowNodeType.LoopStartNode && + node.type !== WorkflowNodeType.ToolBaseNode && + node.type !== WorkflowNodeType.ToolStartNode + ) { + if (!this.edges.some((edge) => edge.targetNodeId === node.id)) { + throw `未在流程中的节点:${node.properties.stepName}` + } + } + } + } + + /** + * 校验节点 + * @param node 节点 + */ + is_valid_node(node: WorkflowValidationNode) { + if (node.properties.status && node.properties.status === 500) { + throw `${node.properties.stepName} 节点不可用` + } + if (node.type === WorkflowNodeType.Condition) { + const branch_list = node.properties.node_data!.branch! + for (const branch of branch_list) { + const source_anchor_id = `${node.id}_${branch.id}_right` + const edge_list = this.edges.filter((edge) => edge.sourceAnchorId == source_anchor_id) + if (edge_list.length == 0) { + throw `${node.properties.stepName} 节点的${branch.type}分支需要连接` + } + } + } else { + const edge_list = this.edges.filter((edge) => edge.sourceNodeId == node.id) + const end = end_nodes_dict[this.workflowModel] + if (edge_list.length == 0 && !end.includes(node.type)) { + throw `${node.properties.stepName} 节点不能当做结束节点` + } + } + if (node.properties.status && node.properties.status !== 200) { + throw `${node.properties.stepName} 节点不可用` + } + } +} +export class ToolWorkFlowInstance extends WorkFlowInstance { + is_valid_start_node() { + const start_node_list = this.nodes.filter( + (item) => item.type === WorkflowNodeType.ToolStartNode, + ) + + if (start_node_list.length == 0) { + throw '开始节点必填' + } + } + /** + * 校验基本信息节点 + */ + is_valid_base_node() { + const base_node_list = this.nodes.filter((item) => item.id === WorkflowNodeType.ToolBaseNode) + if (base_node_list.length == 0) { + throw '基本信息节点必填' + } else if (base_node_list.length > 1) { + throw '基本信息节点只能有一个' + } + } + get_start_nodes() { + return this.nodes.filter((item) => item.type === WorkflowNodeType.ToolStartNode) + } + get_base_node() { + const base_node_list = this.nodes.filter((item) => item.id === WorkflowNodeType.ToolBaseNode) + return base_node_list[0] + } +} +export class KnowledgeWorkFlowInstance extends WorkFlowInstance { + is_valid_start_node() { + const start_node_list = + this.workflowModel == WorkflowMode.Knowledge + ? this.nodes.filter((item) => item.properties.kind === WorkflowKind.DataSource) + : this.nodes.filter((item) => item.type === WorkflowNodeType.LoopStartNode) + + if (start_node_list.length == 0) { + throw '开始节点必填' + } + } + /** + * 校验基本信息节点 + */ + is_valid_base_node() { + const base_node_list = this.nodes.filter((item) => item.id === WorkflowNodeType.KnowledgeBase) + if (base_node_list.length == 0) { + throw '基本信息节点必填' + } else if (base_node_list.length > 1) { + throw '基本信息节点只能有一个' + } + } + + is_valid_work_flow() { + this.workFlowNodes = [] + const start_node_list = this.get_start_nodes() + start_node_list.forEach((n) => { + this._is_valid_work_flow(n) + }) + + const notInWorkFlowNodes = this.nodes + .filter( + (node) => + node.id !== WorkflowNodeType.KnowledgeBase && + node.type !== WorkflowNodeType.LoopStartNode && + node.properties.kind !== WorkflowKind.DataSource, + ) + .filter((node) => !this.workFlowNodes.includes(node)) + if (notInWorkFlowNodes.length > 0) { + throw `未在流程中的节点:${notInWorkFlowNodes.map((node) => node.properties.stepName).join(',')}` + } + this.workFlowNodes = [] + } + + is_valid_nodes() { + for (const node of this.nodes) { + if ( + node.type !== WorkflowNodeType.KnowledgeBase && + node.type !== WorkflowNodeType.LoopStartNode && + node.properties.kind !== WorkflowKind.DataSource + ) { + if (!this.edges.some((edge) => edge.targetNodeId === node.id)) { + throw `未在流程中的节点:${node.properties.stepName}` + } + } + } + } + get_start_nodes() { + if (this.workflowModel == WorkflowMode.Knowledge) { + return this.nodes.filter((item) => item.properties.kind === WorkflowKind.DataSource) + } else { + return this.nodes.filter((item) => item.type === WorkflowNodeType.LoopStartNode) + } + } + get_end_nodes() { + const start_node_list = this.get_start_nodes() + return start_node_list.flatMap((n) => { + return this._get_end_nodes(n, []) + }) + } + _get_end_nodes(startNode: WorkflowValidationNode, value: WorkflowValidationNode[]) { + const next = this.get_next_nodes(startNode) + if (next.length == 0) { + value.push(startNode) + } else { + next.forEach((n) => { + this._get_end_nodes(n, value) + }) + } + return value + } + + /** + * 获取流程下一个节点列表 + * @param node 节点 + * @returns 节点列表 + */ + get_next_nodes(node: WorkflowValidationNode) { + const edge_list = this.edges.filter((edge) => edge.sourceNodeId == node.id) + const node_list = edge_list + .map((edge) => this.nodes.filter((node) => node.id == edge.targetNodeId)) + .reduce((x, y) => [...x, ...y], []) + + return node_list + } + + /** + * 校验节点 + * @param node 节点 + */ + is_valid_node(node: WorkflowValidationNode) { + if (node.properties.status && node.properties.status === 500) { + throw `${node.properties.stepName} 节点不可用` + } + if (node.type === WorkflowNodeType.Condition) { + const branch_list = node.properties.node_data!.branch! + for (const branch of branch_list) { + const source_anchor_id = `${node.id}_${branch.id}_right` + const edge_list = this.edges.filter((edge) => edge.sourceAnchorId == source_anchor_id) + if (edge_list.length == 0) { + throw `${node.properties.stepName} 节点的${branch.type}分支需要连接` + } + } + } else { + const edge_list = this.edges.filter((edge) => edge.sourceNodeId == node.id) + const end = end_nodes_dict[this.workflowModel] + if (this.workflowModel == WorkflowMode.KnowledgeLoop) { + if (edge_list.length == 0 && !end.includes(node.type)) { + throw `${node.properties.stepName} 节点不能当做结束节点` + } + return + } + if (edge_list.length == 0 && !end.includes(node.type)) { + if (node.type == WorkflowNodeType.LoopNode) { + const loopBody = node.properties.node_data?.loop_body + if (loopBody) { + const end_nodes = new KnowledgeWorkFlowInstance( + loopBody, + WorkflowMode.KnowledgeLoop, + ).get_end_nodes() + if (!end_nodes.every((n) => end.includes(n.type))) { + throw `${node.properties.stepName} 节点不能当做结束节点` + } + } + } else { + throw `${node.properties.stepName} 节点不能当做结束节点` + } + } + } + if (node.properties.status && node.properties.status !== 200) { + throw `${node.properties.stepName} 节点不可用` + } + } +} diff --git a/ui/src/workflow-canvas/core/workflow-node.ts b/ui/src/workflow-canvas/core/workflow-node.ts new file mode 100644 index 00000000000..917cd0ac076 --- /dev/null +++ b/ui/src/workflow-canvas/core/workflow-node.ts @@ -0,0 +1,233 @@ +import type { Component } from 'vue' +import { createApp, h as createVNode } from 'vue' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/es/locale/lang/zh-cn' +import { + h as createLogicFlowElement, + HtmlNode, + HtmlNodeModel, + type GraphModel, + type IHtmlNodeProperties, + type Model, +} from '@logicflow/core' +import { BasicComponentsNode } from '@/workflow-canvas/config/node-mapping' +import { WorkflowNodeType, type WorkflowNodeField } from '@/workflow-canvas/types' + +type NodeViewProps = ConstructorParameters[0] +type NodeFieldGroup = Record + +type WorkflowNodeConfig = { + chatFields?: WorkflowNodeField[] + fields?: WorkflowNodeField[] + globalFields?: WorkflowNodeField[] +} + +interface WorkflowNodeProperties extends IHtmlNodeProperties { + api_input_field_list?: unknown[] + chat_input_field_list?: unknown[] + config?: WorkflowNodeConfig + node_data?: unknown + showNode?: boolean + status?: number + stepName?: string + user_input_field_list?: unknown[] +} + +export class WorkflowNodeView extends HtmlNode { + private nodeApp?: ReturnType + private readonly vueComponent: Component + + constructor(props: NodeViewProps, vueComponent: Component) { + super(props) + this.vueComponent = vueComponent + + const nodeModel = props.model as unknown as WorkflowNodeModel + const definition = BasicComponentsNode[nodeModel.type as WorkflowNodeType] + if (definition && !nodeModel.properties.config) { + nodeModel.properties.config = structuredClone(definition.properties.config ?? {}) + } + nodeModel.properties.stepName = this.getUniqueNodeName( + props.graphModel, + String(nodeModel.properties.stepName ?? definition?.label ?? nodeModel.type), + nodeModel.id, + ) + } + + private getUniqueNodeName(graphModel: GraphModel, baseName: string, nodeId: string) { + const names = new Set( + graphModel.nodes + .filter((node) => node.id !== nodeId) + .map((node) => String(node.properties.stepName ?? '')), + ) + let index = 0 + let nodeName = baseName + while (names.has(nodeName.trim())) nodeName = `${baseName}${++index}` + return nodeName + } + + getAnchorShape(anchorData?: Model.AnchorConfig) { + if (!anchorData) return null + const { x, y, type } = anchorData + const connected = this.props.graphModel.edges.some((edge) => + type === 'left' + ? edge.targetAnchorId === anchorData.id + : edge.sourceAnchorId === anchorData.id, + ) + + return createLogicFlowElement( + 'foreignObject', + { ...anchorData, x: x - 10, y: y - 10, width: 20, height: 20 }, + [ + createLogicFlowElement('div', { + className: 'h-5 w-5 cursor-pointer rounded-full border-2 border-primary bg-white', + style: connected ? { background: 'var(--mk-primary)' } : {}, + onClick: () => { + if (type === 'right') { + const nodeModel = this.props.model as unknown as WorkflowNodeModel + nodeModel.openNodeMenu?.(anchorData) + } + }, + }), + ], + ) + } + + setHtml(rootElement: SVGForeignObjectElement) { + if (this.nodeApp) return + const mountElement = document.createElement('div') + rootElement.replaceChildren(mountElement) + const nodeModel = this.props.model + + this.nodeApp = createApp({ + render: () => createVNode(this.vueComponent, { nodeModel }), + }) + this.nodeApp.use(ElementPlus, { locale: zhCn }) + this.nodeApp.mount(mountElement) + } + + componentWillUnmount() { + super.componentWillUnmount() + this.nodeApp?.unmount() + this.nodeApp = undefined + } +} + +export class WorkflowNodeModel extends HtmlNodeModel { + private upNodeFieldDict?: NodeFieldGroup + + openNodeMenu?: (anchorData: Model.AnchorConfig) => void + validate?: () => Promise + + setAttributes() { + this.width = Number( + this.properties.width ?? (String(this.type) === WorkflowNodeType.Base ? 600 : 340), + ) + this.height = Number(this.properties.height ?? 300) + this.text.editable = false + } + + setHeight(contentHeight: number) { + const targetHeight = Math.max(contentHeight + 32, 120) + if (targetHeight === this.height) return + const offset = (targetHeight - this.height) / 2 + this.height = targetHeight + this.properties.height = targetHeight + this.move(0, offset) + } + + getNodeFieldList(): WorkflowNodeField[] { + const config = this.properties.config ?? {} + const fields = [...(config.fields ?? [])] + const fieldGroups: WorkflowNodeField[] = [] + + if (String(this.type) === WorkflowNodeType.Start) { + fieldGroups.push({ + value: 'global', + label: '全局变量', + type: 'global', + children: config.globalFields ?? [], + }) + fieldGroups.push({ + value: 'chat', + label: '会话变量', + type: 'chat', + children: config.chatFields ?? [], + }) + } + + fieldGroups.push({ + value: this.id, + label: String(this.properties.stepName ?? this.type), + type: String(this.type), + children: fields, + }) + return fieldGroups + } + + getUpNodeFieldDict(containSelf: boolean, useCache: boolean): NodeFieldGroup { + if (!this.upNodeFieldDict || !useCache) { + this.upNodeFieldDict = this.graphModel + .getNodeIncomingNode(this.id) + .filter((node) => node.id !== WorkflowNodeType.Start) + .reduce((fieldGroups, node) => { + const upstreamModel = node as unknown as WorkflowNodeModel + return { + ...fieldGroups, + ...upstreamModel.getUpNodeFieldDict(true, useCache), + } + }, {}) + } + + if (!containSelf) return this.upNodeFieldDict + return { + ...this.upNodeFieldDict, + [this.id]: this.getNodeFieldList(), + } + } + + getUpNodeFieldList(containSelf: boolean, useCache: boolean): WorkflowNodeField[] { + const upstreamFields = Object.values(this.getUpNodeFieldDict(containSelf, useCache)).flat() + const startNodeModel = this.graphModel.getNodeModelById(WorkflowNodeType.Start) as unknown as + | WorkflowNodeModel + | undefined + return [...(startNodeModel?.getNodeFieldList() ?? []), ...upstreamFields] + } + + clearNextNodeField(containSelf: boolean) { + this.graphModel.getNodeOutgoingNode(this.id).forEach((node) => { + const downstreamModel = node as unknown as WorkflowNodeModel + downstreamModel.clearNextNodeField(true) + }) + if (containSelf) this.upNodeFieldDict = undefined + } + + getDefaultAnchor(): Model.AnchorConfig[] { + if (String(this.type) === WorkflowNodeType.Base) return [] + + const anchors: Model.AnchorConfig[] = [] + if (String(this.type) !== WorkflowNodeType.Start) { + anchors.push({ + x: this.x - this.width / 2, + y: this.y, + id: `${this.id}_left`, + type: 'left', + edgeAddable: false, + }) + } + anchors.push({ + x: this.x + this.width / 2, + y: this.y, + id: `${this.id}_right`, + type: 'right', + }) + return anchors + } + + getNodeStyle() { + return { ...super.getNodeStyle(), stroke: 'transparent' } + } + + getOutlineStyle() { + return { ...super.getOutlineStyle(), stroke: 'transparent' } + } +} diff --git a/ui/src/workflow-canvas/icons/ai-chat-node-icon.vue b/ui/src/workflow-canvas/icons/ai-chat-node-icon.vue new file mode 100644 index 00000000000..7a220814144 --- /dev/null +++ b/ui/src/workflow-canvas/icons/ai-chat-node-icon.vue @@ -0,0 +1,6 @@ + + diff --git a/ui/src/workflow-canvas/icons/base-node-icon.vue b/ui/src/workflow-canvas/icons/base-node-icon.vue new file mode 100644 index 00000000000..fc526666e04 --- /dev/null +++ b/ui/src/workflow-canvas/icons/base-node-icon.vue @@ -0,0 +1,6 @@ + + diff --git a/ui/src/workflow-canvas/icons/reply-node-icon.vue b/ui/src/workflow-canvas/icons/reply-node-icon.vue new file mode 100644 index 00000000000..c30ebc85929 --- /dev/null +++ b/ui/src/workflow-canvas/icons/reply-node-icon.vue @@ -0,0 +1,6 @@ + + diff --git a/ui/src/workflow-canvas/icons/start-node-icon.vue b/ui/src/workflow-canvas/icons/start-node-icon.vue new file mode 100644 index 00000000000..3f0eee973ed --- /dev/null +++ b/ui/src/workflow-canvas/icons/start-node-icon.vue @@ -0,0 +1,6 @@ + + diff --git a/ui/src/workflow-canvas/icons/utils.ts b/ui/src/workflow-canvas/icons/utils.ts new file mode 100644 index 00000000000..e1644c4d4a4 --- /dev/null +++ b/ui/src/workflow-canvas/icons/utils.ts @@ -0,0 +1,7 @@ +import type { Component } from 'vue' + +const icons = import.meta.glob<{ default: Component }>('./**.vue', { eager: true }) +export function iconComponent(name: string) { + const url = `./${name}.vue` + return icons[url]?.default || null +} diff --git a/ui/src/workflow-canvas/index.vue b/ui/src/workflow-canvas/index.vue new file mode 100644 index 00000000000..7a57932e4bc --- /dev/null +++ b/ui/src/workflow-canvas/index.vue @@ -0,0 +1,205 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts b/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts new file mode 100644 index 00000000000..40df5b7437c --- /dev/null +++ b/ui/src/workflow-canvas/nodes/ai-chat-node/index.ts @@ -0,0 +1,15 @@ +import AiChatNode from './index.vue' +import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' +import { WorkflowNodeType } from '@/workflow-canvas/types.ts' + +class AiChatNodeView extends WorkflowNodeView { + constructor(props: ConstructorParameters[0]) { + super(props, AiChatNode) + } +} + +export default { + type: WorkflowNodeType.AiChat, + model: WorkflowNodeModel, + view: AiChatNodeView, +} diff --git a/ui/src/workflow-canvas/nodes/ai-chat-node/index.vue b/ui/src/workflow-canvas/nodes/ai-chat-node/index.vue new file mode 100644 index 00000000000..2939692eb17 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/ai-chat-node/index.vue @@ -0,0 +1,119 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/base-node/index.ts b/ui/src/workflow-canvas/nodes/base-node/index.ts new file mode 100644 index 00000000000..3f3ea6d0c53 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/index.ts @@ -0,0 +1,22 @@ +import BaseNode from './index.vue' +import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' +import { WorkflowNodeType } from '@/workflow-canvas/types.ts' + +class BaseNodeView extends WorkflowNodeView { + constructor(props: ConstructorParameters[0]) { + super(props, BaseNode) + } +} + +class BaseNodeModel extends WorkflowNodeModel { + setAttributes() { + super.setAttributes() + this.width = 600 + } +} + +export default { + type: WorkflowNodeType.Base, + model: BaseNodeModel, + view: BaseNodeView, +} diff --git a/ui/src/workflow-canvas/nodes/base-node/index.vue b/ui/src/workflow-canvas/nodes/base-node/index.vue new file mode 100644 index 00000000000..eb5200d6031 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/base-node/index.vue @@ -0,0 +1,74 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/reply-node/index.ts b/ui/src/workflow-canvas/nodes/reply-node/index.ts new file mode 100644 index 00000000000..4bc4a8db427 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/reply-node/index.ts @@ -0,0 +1,15 @@ +import ReplyNode from './index.vue' +import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' +import { WorkflowNodeType } from '@/workflow-canvas/types.ts' + +class ReplyNodeView extends WorkflowNodeView { + constructor(props: ConstructorParameters[0]) { + super(props, ReplyNode) + } +} + +export default { + type: WorkflowNodeType.Reply, + model: WorkflowNodeModel, + view: ReplyNodeView, +} diff --git a/ui/src/workflow-canvas/nodes/reply-node/index.vue b/ui/src/workflow-canvas/nodes/reply-node/index.vue new file mode 100644 index 00000000000..0dce3782123 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/reply-node/index.vue @@ -0,0 +1,84 @@ + + + diff --git a/ui/src/workflow-canvas/nodes/start-node/index.ts b/ui/src/workflow-canvas/nodes/start-node/index.ts new file mode 100644 index 00000000000..2e359284336 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/start-node/index.ts @@ -0,0 +1,15 @@ +import StartNode from './index.vue' +import { WorkflowNodeModel, WorkflowNodeView } from '@/workflow-canvas/core/workflow-node' +import { WorkflowNodeType } from '@/workflow-canvas/types.ts' + +class StartNodeView extends WorkflowNodeView { + constructor(props: ConstructorParameters[0]) { + super(props, StartNode) + } +} + +export default { + type: WorkflowNodeType.Start, + model: WorkflowNodeModel, + view: StartNodeView, +} diff --git a/ui/src/workflow-canvas/nodes/start-node/index.vue b/ui/src/workflow-canvas/nodes/start-node/index.vue new file mode 100644 index 00000000000..429988c8eb1 --- /dev/null +++ b/ui/src/workflow-canvas/nodes/start-node/index.vue @@ -0,0 +1,110 @@ + + + diff --git a/ui/src/workflow-canvas/plugins/dagre.ts b/ui/src/workflow-canvas/plugins/dagre.ts new file mode 100644 index 00000000000..52a2ec4750f --- /dev/null +++ b/ui/src/workflow-canvas/plugins/dagre.ts @@ -0,0 +1,86 @@ +import type LogicFlow from '@logicflow/core' +import type { BaseEdgeModel, BaseNodeModel } from '@logicflow/core' +import { DagreLayout, type DagreLayoutOptions } from '@antv/layout' + +type DagreNodeData = { + id: string + model: BaseNodeModel + size: { + height: number + width: number + } +} + +type DagreEdgeData = { + id: string + model: BaseEdgeModel + source: string + target: string +} + +export default class Dagre { + static pluginName = 'dagre' + + lf?: LogicFlow + + render(lf: LogicFlow) { + this.lf = lf + } + + /** + * option: { + * rankdir: "TB", // layout 方向, 可选 TB, BT, LR, RL + * align: undefined, // 节点对齐方式,可选 UL, UR, DL, DR + * nodeSize: undefined, // 节点大小 + * nodesepFunc: undefined, // 节点水平间距(px) + * ranksepFunc: undefined, // 每一层节点之间间距 + * nodesep: 40, // 节点水平间距(px) 注意:如果有grid,需要保证nodesep为grid的偶数倍 + * ranksep: 40, // 每一层节点之间间距 注意:如果有grid,需要保证ranksep为grid的偶数倍 + * controlPoints: false, // 是否保留布局连线的控制点 + * radial: false, // 是否基于 dagre 进行辐射布局 + * focusNode: null, // radial 为 true 时生效,关注的节点 + * }; + */ + + async layout(options: Partial = {}) { + if (!this.lf) return + const { edges, gridSize, nodes } = this.lf.graphModel + const defaultSpacing = gridSize > 20 ? gridSize * 2 : 40 + const layoutOptions: Partial = { + type: 'dagre', + rankdir: 'LR', + align: 'DR', + nodesep: defaultSpacing, + ranksep: defaultSpacing, + begin: [120, 120], + nodeSize: (node) => { + const { model } = node as DagreNodeData + return [model.width, model.height] + }, + ...options, + } + + const layoutInstance = new DagreLayout(layoutOptions) + await layoutInstance.execute({ + nodes: nodes.map((node) => ({ + id: node.id, + size: { + width: node.width, + height: node.height, + }, + model: node, + })), + edges: edges.map((edge) => ({ + id: edge.id, + source: edge.sourceNodeId, + target: edge.targetNodeId, + model: edge, + })), + }) + layoutInstance.forEachNode((node) => { + const { model } = node._original as DagreNodeData + model.moveTo(node.x, node.y) + }) + this.lf.fitView() + } +} diff --git a/ui/src/workflow-canvas/types.ts b/ui/src/workflow-canvas/types.ts new file mode 100644 index 00000000000..54db159984e --- /dev/null +++ b/ui/src/workflow-canvas/types.ts @@ -0,0 +1,68 @@ +/** LogicFlow 节点类型,作为工作流图数据中的稳定协议值。 */ +export enum WorkflowNodeType { + Base = 'base-node', + KnowledgeBase = 'knowledge-base-node', + Start = 'start-node', + AiChat = 'ai-chat-node', + SearchKnowledge = 'search-knowledge-node', + SearchDocument = 'search-document-node', + Question = 'question-node', + Condition = 'condition-node', + Reply = 'reply-node', + ToolLib = 'tool-lib-node', + ToolWorkflowLib = 'tool-workflow-lib-node', + ToolLibCustom = 'tool-node', + RerankerNode = 'reranker-node', + Application = 'application-node', + DocumentExtractNode = 'document-extract-node', + DocumentSplitNode = 'document-split-node', + ImageUnderstandNode = 'image-understand-node', + VariableAssignNode = 'variable-assign-node', + FormNode = 'form-node', + TextToSpeechNode = 'text-to-speech-node', + SpeechToTextNode = 'speech-to-text-node', + ImageGenerateNode = 'image-generate-node', + McpNode = 'mcp-node', + IntentNode = 'intent-node', + TextToVideoGenerateNode = 'text-to-video-node', + ImageToVideoGenerateNode = 'image-to-video-node', + LoopNode = 'loop-node', + LoopBodyNode = 'loop-body-node', + LoopStartNode = 'loop-start-node', + LoopContinueNode = 'loop-continue-node', + LoopBreakNode = 'loop-break-node', + VariableSplittingNode = 'variable-splitting-node', + VariableAggregationNode = 'variable-aggregation-node', + VideoUnderstandNode = 'video-understand-node', + ParameterExtractionNode = 'parameter-extraction-node', + DataSourceLocalNode = 'data-source-local-node', + DataSourceWebNode = 'data-source-web-node', + KnowledgeWriteNode = 'knowledge-write-node', + ToolStartNode = 'tool-start-node', + ToolBaseNode = 'tool-base-node', +} + +export enum WorkflowKind { + DataSource = 'data-source', +} +export enum WorkflowMode { + // 应用工作流 + Application = 'application', + // 应用工作流循环 + ApplicationLoop = 'application-loop', + // 知识库工作流 + Knowledge = 'knowledge', + // 工具 + Tool = 'tool', + // 工具循环体 + ToolLoop = 'tool-loop', + // 知识库工作流循环体 + KnowledgeLoop = 'knowledge-loop', +} + +export interface WorkflowNodeField { + children?: WorkflowNodeField[] + label: string + type?: string + value: string +}