feat(web): Add Workflow
@@ -10,10 +10,14 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@antv/layout": "^1.2.14-beta.8",
|
||||||
|
"@antv/x6": "^3.0.1",
|
||||||
|
"@antv/x6-react-shape": "^3.0.1",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@lexical/react": "^0.39.0",
|
||||||
"antd": "^5.27.4",
|
"antd": "^5.27.4",
|
||||||
"axios": "^1.12.2",
|
"axios": "^1.12.2",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -23,6 +27,8 @@
|
|||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"echarts-for-react": "^3.0.2",
|
"echarts-for-react": "^3.0.2",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"lexical": "^0.39.0",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -31,7 +37,6 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.0",
|
"react-router-dom": "^6.22.0",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"reactflow": "^11.11.4",
|
|
||||||
"rehype-katex": "^7.0.1",
|
"rehype-katex": "^7.0.1",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
@@ -46,6 +51,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^24.6.0",
|
"@types/node": "^24.6.0",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react": "^18.2.0",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@types/react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { request } from '@/utils/request'
|
import { request } from '@/utils/request'
|
||||||
import type { Application } from '@/views/ApplicationManagement/types'
|
import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
|
||||||
import type { Config } from '@/views/ApplicationConfig/types'
|
import type { Config } from '@/views/ApplicationConfig/types'
|
||||||
import { handleSSE, type SSEMessage } from '@/utils/stream'
|
import { handleSSE, type SSEMessage } from '@/utils/stream'
|
||||||
import type { QueryParams } from '@/views/Conversation/types'
|
import type { QueryParams } from '@/views/Conversation/types'
|
||||||
|
import type { WorkflowConfig } from '@/views/Workflow/types'
|
||||||
|
|
||||||
// 应用列表
|
// 应用列表
|
||||||
export const getApplicationListUrl = '/apps'
|
export const getApplicationListUrl = '/apps'
|
||||||
@@ -13,20 +14,24 @@ export const getApplicationList = (data: Record<string, unknown>) => {
|
|||||||
export const getApplicationConfig = (id: string) => {
|
export const getApplicationConfig = (id: string) => {
|
||||||
return request.get(`/apps/${id}/config`)
|
return request.get(`/apps/${id}/config`)
|
||||||
}
|
}
|
||||||
// 获取集群应配置
|
// 获取集群应用配置
|
||||||
export const getMultiAgentConfig = (id: string) => {
|
export const getMultiAgentConfig = (id: string) => {
|
||||||
return request.get(`/apps/${id}/multi-agent`)
|
return request.get(`/apps/${id}/multi-agent`)
|
||||||
}
|
}
|
||||||
|
// 获取 workflow应用配置
|
||||||
|
export const getWorkflowConfig = (id: string) => {
|
||||||
|
return request.get(`/apps/${id}/workflow`)
|
||||||
|
}
|
||||||
// 应用详情
|
// 应用详情
|
||||||
export const getApplication = (id: string) => {
|
export const getApplication = (id: string) => {
|
||||||
return request.get(`/apps/${id}`)
|
return request.get(`/apps/${id}`)
|
||||||
}
|
}
|
||||||
// 更新应用
|
// 更新应用
|
||||||
export const updateApplication = (id: string, values: Application) => {
|
export const updateApplication = (id: string, values: ApplicationModalData) => {
|
||||||
return request.put(`/apps/${id}`, values)
|
return request.put(`/apps/${id}`, values)
|
||||||
}
|
}
|
||||||
// 创建应用
|
// 创建应用
|
||||||
export const addApplication = (values: Application) => {
|
export const addApplication = (values: ApplicationModalData) => {
|
||||||
return request.post('/apps', values)
|
return request.post('/apps', values)
|
||||||
}
|
}
|
||||||
// 保存Agent配置
|
// 保存Agent配置
|
||||||
@@ -37,6 +42,10 @@ export const saveAgentConfig = (app_id: string, values: Config) => {
|
|||||||
export const saveMultiAgentConfig = (app_id: string, values: Config) => {
|
export const saveMultiAgentConfig = (app_id: string, values: Config) => {
|
||||||
return request.put(`/apps/${app_id}/multi-agent`, values)
|
return request.put(`/apps/${app_id}/multi-agent`, values)
|
||||||
}
|
}
|
||||||
|
// 保存workflow配置
|
||||||
|
export const saveWorkflowConfig = (app_id: string, values: WorkflowConfig) => {
|
||||||
|
return request.put(`/apps/${app_id}/workflow`, values)
|
||||||
|
}
|
||||||
// 模型比对试运行
|
// 模型比对试运行
|
||||||
export const runCompare = (app_id: string, values: Record<string, unknown>, onMessage?: (data: SSEMessage[]) => void) => {
|
export const runCompare = (app_id: string, values: Record<string, unknown>, onMessage?: (data: SSEMessage[]) => void) => {
|
||||||
return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage)
|
return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage)
|
||||||
|
|||||||
BIN
web/src/assets/images/menu/apiKey.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
web/src/assets/images/menu/apiKey_active.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
BIN
web/src/assets/images/menu/tool.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
web/src/assets/images/menu/tool_active.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
BIN
web/src/assets/images/workflow/agent_arbitration.png
Normal file
|
After Width: | Height: | Size: 835 B |
BIN
web/src/assets/images/workflow/agent_collaboration.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
web/src/assets/images/workflow/agent_scheduling.png
Normal file
|
After Width: | Height: | Size: 785 B |
BIN
web/src/assets/images/workflow/aggregator.png
Normal file
|
After Width: | Height: | Size: 668 B |
BIN
web/src/assets/images/workflow/answer.png
Normal file
|
After Width: | Height: | Size: 753 B |
BIN
web/src/assets/images/workflow/arrow.png
Normal file
|
After Width: | Height: | Size: 775 B |
BIN
web/src/assets/images/workflow/classification.png
Normal file
|
After Width: | Height: | Size: 849 B |
BIN
web/src/assets/images/workflow/code_execution.png
Normal file
|
After Width: | Height: | Size: 684 B |
BIN
web/src/assets/images/workflow/condition.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
web/src/assets/images/workflow/empty.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
web/src/assets/images/workflow/end.png
Normal file
|
After Width: | Height: | Size: 792 B |
BIN
web/src/assets/images/workflow/http_request.png
Normal file
|
After Width: | Height: | Size: 745 B |
BIN
web/src/assets/images/workflow/iteration.png
Normal file
|
After Width: | Height: | Size: 612 B |
BIN
web/src/assets/images/workflow/llm.png
Normal file
|
After Width: | Height: | Size: 591 B |
BIN
web/src/assets/images/workflow/loop.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
web/src/assets/images/workflow/memory_enhancement.png
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
web/src/assets/images/workflow/model_selection.png
Normal file
|
After Width: | Height: | Size: 908 B |
BIN
web/src/assets/images/workflow/model_voting.png
Normal file
|
After Width: | Height: | Size: 769 B |
BIN
web/src/assets/images/workflow/output_audit.png
Normal file
|
After Width: | Height: | Size: 624 B |
BIN
web/src/assets/images/workflow/parallel.png
Normal file
|
After Width: | Height: | Size: 979 B |
BIN
web/src/assets/images/workflow/parameter_extraction.png
Normal file
|
After Width: | Height: | Size: 699 B |
BIN
web/src/assets/images/workflow/process_evolution.png
Normal file
|
After Width: | Height: | Size: 516 B |
BIN
web/src/assets/images/workflow/rag.png
Normal file
|
After Width: | Height: | Size: 741 B |
BIN
web/src/assets/images/workflow/reasoning_control.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
web/src/assets/images/workflow/robot-2-line@2x.png
Normal file
|
After Width: | Height: | Size: 471 B |
BIN
web/src/assets/images/workflow/self_optimization.png
Normal file
|
After Width: | Height: | Size: 922 B |
BIN
web/src/assets/images/workflow/self_reflection.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
web/src/assets/images/workflow/sensitive_detection.png
Normal file
|
After Width: | Height: | Size: 803 B |
BIN
web/src/assets/images/workflow/start.png
Normal file
|
After Width: | Height: | Size: 567 B |
BIN
web/src/assets/images/workflow/task_planning.png
Normal file
|
After Width: | Height: | Size: 648 B |
BIN
web/src/assets/images/workflow/template_rendering.png
Normal file
|
After Width: | Height: | Size: 408 B |
BIN
web/src/assets/images/workflow/tools.png
Normal file
|
After Width: | Height: | Size: 869 B |
@@ -2,7 +2,7 @@
|
|||||||
* @Author: ZhaoYing
|
* @Author: ZhaoYing
|
||||||
* @Date: 2025-12-10 16:46:14
|
* @Date: 2025-12-10 16:46:14
|
||||||
* @Last Modified by: ZhaoYing
|
* @Last Modified by: ZhaoYing
|
||||||
* @Last Modified time: 2025-12-10 16:49:13
|
* @Last Modified time: 2025-12-20 15:38:40
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { Flex, Input, Form } from 'antd'
|
import { Flex, Input, Form } from 'antd'
|
||||||
@@ -40,7 +40,7 @@ const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputPr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rb:absolute rb:bottom-3 rb:left-0 rb:right-0">
|
<div className="rb:absolute rb:bottom-3 rb:left-0 rb:right-0">
|
||||||
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-[120px]">
|
<Flex vertical justify="space-between" className="rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:min-h-30">
|
||||||
{/* 消息输入表单 */}
|
{/* 消息输入表单 */}
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
<Form.Item name="message" noStyle>
|
<Form.Item name="message" noStyle>
|
||||||
@@ -66,10 +66,10 @@ const ChatInput = ({ message, onChange, onSend, loading, children }: ChatInputPr
|
|||||||
{children}
|
{children}
|
||||||
{/* 发送按钮 - 根据状态显示不同图标 */}
|
{/* 发送按钮 - 根据状态显示不同图标 */}
|
||||||
{loading
|
{loading
|
||||||
? <img src={LoadingIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" />
|
? <img src={LoadingIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
|
||||||
: !values || !values?.message || values?.message?.trim() === ''
|
: !values || !values?.message || values?.message?.trim() === ''
|
||||||
? <img src={SendDisabledIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" />
|
? <img src={SendDisabledIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" />
|
||||||
: <img src={SendIcon} className="rb:w-[22px] rb:h-[22px] rb:cursor-pointer" onClick={onSend} />
|
: <img src={SendIcon} className="rb:w-5.5 rb:h-5.5 rb:cursor-pointer" onClick={onSend} />
|
||||||
}
|
}
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ import modelActiveIcon from '@/assets/images/menu/model_active.svg';
|
|||||||
import memoryIcon from '@/assets/images/menu/memory.svg';
|
import memoryIcon from '@/assets/images/menu/memory.svg';
|
||||||
import memoryActiveIcon from '@/assets/images/menu/memory_active.svg';
|
import memoryActiveIcon from '@/assets/images/menu/memory_active.svg';
|
||||||
import spaceIcon from '@/assets/images/menu/space.svg';
|
import spaceIcon from '@/assets/images/menu/space.svg';
|
||||||
import spaceActiveIcon from '@/assets/images/menu/space_acitve.svg';
|
import spaceActiveIcon from '@/assets/images/menu/space_active.svg';
|
||||||
import userIcon from '@/assets/images/menu/user.svg';
|
import userIcon from '@/assets/images/menu/user.svg';
|
||||||
import userActiveIcon from '@/assets/images/menu/user_active.svg';
|
import userActiveIcon from '@/assets/images/menu/user_active.svg';
|
||||||
import userMemoryIcon from '@/assets/images/menu/userMemory.svg';
|
import userMemoryIcon from '@/assets/images/menu/userMemory.svg';
|
||||||
import userMemoryActiveIcon from '@/assets/images/menu/userMemory_acitve.svg';
|
import userMemoryActiveIcon from '@/assets/images/menu/userMemory_active.svg';
|
||||||
import applicationIcon from '@/assets/images/menu/application.svg';
|
import applicationIcon from '@/assets/images/menu/application.svg';
|
||||||
import applicationActiveIcon from '@/assets/images/menu/application_active.svg';
|
import applicationActiveIcon from '@/assets/images/menu/application_active.svg';
|
||||||
import knowledgeIcon from '@/assets/images/menu/knowledge.svg';
|
import knowledgeIcon from '@/assets/images/menu/knowledge.svg';
|
||||||
@@ -34,6 +34,10 @@ import memoryConversationIcon from '@/assets/images/menu/memoryConversation.svg'
|
|||||||
import memoryConversationActiveIcon from '@/assets/images/menu/memoryConversation_active.svg';
|
import memoryConversationActiveIcon from '@/assets/images/menu/memoryConversation_active.svg';
|
||||||
import memberIcon from '@/assets/images/menu/member.svg';
|
import memberIcon from '@/assets/images/menu/member.svg';
|
||||||
import memberActiveIcon from '@/assets/images/menu/member_active.svg';
|
import memberActiveIcon from '@/assets/images/menu/member_active.svg';
|
||||||
|
import toolIcon from '@/assets/images/menu/tool.png';
|
||||||
|
import toolActiveIcon from '@/assets/images/menu/tool_active.png';
|
||||||
|
import apiKeyIcon from '@/assets/images/menu/apiKey.png';
|
||||||
|
import apiKeyActiveIcon from '@/assets/images/menu/apiKey_active.png';
|
||||||
|
|
||||||
// 图标路径映射表
|
// 图标路径映射表
|
||||||
const iconPathMap: Record<string, string> = {
|
const iconPathMap: Record<string, string> = {
|
||||||
@@ -57,6 +61,10 @@ const iconPathMap: Record<string, string> = {
|
|||||||
'memoryConversationActive': memoryConversationActiveIcon,
|
'memoryConversationActive': memoryConversationActiveIcon,
|
||||||
'member': memberIcon,
|
'member': memberIcon,
|
||||||
'memberActive': memberActiveIcon,
|
'memberActive': memberActiveIcon,
|
||||||
|
'tool': toolIcon,
|
||||||
|
'toolActive': toolActiveIcon,
|
||||||
|
'apiKey': apiKeyIcon,
|
||||||
|
'apiKeyActive': apiKeyActiveIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|||||||
@@ -174,4 +174,10 @@ body {
|
|||||||
}
|
}
|
||||||
.ant-breadcrumb a:hover {
|
.ant-breadcrumb a:hover {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* X6 节点样式 */
|
||||||
|
.x6-node foreignObject > body {
|
||||||
|
min-height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type FC, useEffect, useState, useRef, type Key } from 'react'
|
import { type FC, useEffect, useState, useRef, forwardRef, useImperativeHandle, type Key } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import Card from './components/Card'
|
import Card from './components/Card'
|
||||||
@@ -11,17 +11,19 @@ import type {
|
|||||||
Config,
|
Config,
|
||||||
SubAgentModalRef,
|
SubAgentModalRef,
|
||||||
ChatData,
|
ChatData,
|
||||||
SubAgentItem
|
SubAgentItem,
|
||||||
|
ClusterRef
|
||||||
} from './types'
|
} from './types'
|
||||||
import Chat from './components/Chat'
|
import Chat from './components/Chat'
|
||||||
import RbCard from '@/components/RbCard/Card'
|
import RbCard from '@/components/RbCard/Card'
|
||||||
import SubAgentModal from './components/SubAgentModal'
|
import SubAgentModal from './components/SubAgentModal'
|
||||||
import Empty from '@/components/Empty'
|
import Empty from '@/components/Empty'
|
||||||
|
import type { Application } from '@/views/ApplicationManagement/types'
|
||||||
|
|
||||||
|
|
||||||
const tagColors = ['processing', 'warning', 'default']
|
const tagColors = ['processing', 'warning', 'default']
|
||||||
const MAX_LENGTH = 5;
|
const MAX_LENGTH = 5;
|
||||||
const Cluster: FC<{application: SubAgentItem}> = ({application}) => {
|
const Cluster = forwardRef<ClusterRef, { application: Application }>(({application}, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
const [form] = Form.useForm()
|
const [form] = Form.useForm()
|
||||||
@@ -113,6 +115,9 @@ const Cluster: FC<{application: SubAgentItem}> = ({application}) => {
|
|||||||
form.setFieldsValue({ master_agent_name: option.children })
|
form.setFieldsValue({ master_agent_name: option.children })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleSave
|
||||||
|
}))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="rb:h-[calc(100vh-64px)]">
|
<Row className="rb:h-[calc(100vh-64px)]">
|
||||||
@@ -210,6 +215,6 @@ const Cluster: FC<{application: SubAgentItem}> = ({application}) => {
|
|||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
export default Cluster
|
export default Cluster
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type FC, useRef } from 'react';
|
import { type FC, useEffect, useRef } from 'react';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
import { Layout, Tabs, Dropdown } from 'antd';
|
import { Layout, Tabs, Dropdown, Button } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import styles from '../index.module.css'
|
import styles from '../index.module.css'
|
||||||
@@ -11,7 +11,7 @@ import exportIcon from '@/assets/images/export_hover.svg'
|
|||||||
import deleteIcon from '@/assets/images/delete_hover.svg'
|
import deleteIcon from '@/assets/images/delete_hover.svg'
|
||||||
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
|
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
|
||||||
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
|
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
|
||||||
import type { CopyModalRef } from '../types'
|
import type { CopyModalRef, WorkflowRef } from '../types'
|
||||||
import { deleteApplication } from '@/api/application'
|
import { deleteApplication } from '@/api/application'
|
||||||
import CopyModal from './CopyModal'
|
import CopyModal from './CopyModal'
|
||||||
|
|
||||||
@@ -29,8 +29,12 @@ interface ConfigHeaderProps {
|
|||||||
activeTab: string;
|
activeTab: string;
|
||||||
handleChangeTab: (key: string) => void;
|
handleChangeTab: (key: string) => void;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
|
workflowRef: React.RefObject<WorkflowRef>
|
||||||
}
|
}
|
||||||
const ConfigHeader: FC<ConfigHeaderProps> = ({ application, activeTab, handleChangeTab, refresh }) => {
|
const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||||
|
application, activeTab, handleChangeTab, refresh,
|
||||||
|
workflowRef
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
@@ -46,7 +50,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({ application, activeTab, handleCha
|
|||||||
const formatMenuItems = () => {
|
const formatMenuItems = () => {
|
||||||
const items = ['edit', 'copy', 'delete'].map(key => ({
|
const items = ['edit', 'copy', 'delete'].map(key => ({
|
||||||
key,
|
key,
|
||||||
icon: <img src={menuIcons[key]} className="rb:w-[16px] rb:h-[16px] rb:mr-[8px]" />,
|
icon: <img src={menuIcons[key]} className="rb:w-4 rb:h-4 rb:mr-2" />,
|
||||||
label: t(`common.${key}`),
|
label: t(`common.${key}`),
|
||||||
}))
|
}))
|
||||||
return {
|
return {
|
||||||
@@ -85,12 +89,23 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({ application, activeTab, handleCha
|
|||||||
const goToApplication = () => {
|
const goToApplication = () => {
|
||||||
navigate('/application', { replace: true })
|
navigate('/application', { replace: true })
|
||||||
}
|
}
|
||||||
|
const save = () => {
|
||||||
|
workflowRef.current?.handleSave()
|
||||||
|
}
|
||||||
|
const run = () => {
|
||||||
|
workflowRef.current?.handleSave(false)
|
||||||
|
.then(() => {
|
||||||
|
workflowRef.current?.handleRun()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const clear = () => {
|
||||||
|
workflowRef?.current?.graphRef?.current?.clearCells()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header className="rb:w-full rb:h-[64px] rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-[32px]">
|
<Header className="rb:w-full rb:h-16 rb:grid rb:grid-cols-3 rb:p-[16px_16px_16px_24px]! rb:border-b rb:border-[#EAECEE] rb:leading-8">
|
||||||
<div className="rb:h-[32px] rb:flex rb:items-center rb:font-medium">
|
<div className="rb:h-8 rb:flex rb:items-center rb:font-medium">
|
||||||
<div className="rb:w-[32px] rb:h-[32px] rb:rounded-[8px] rb:mr-[13px] rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[24px] rb:text-[#ffffff]">
|
<div className="rb:w-8 rb:h-8 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[24px] rb:text-[#ffffff]">
|
||||||
{application?.name[0]}
|
{application?.name[0]}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -101,7 +116,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({ application, activeTab, handleCha
|
|||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="rb:w-[20px] rb:h-[20px] rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||||
></div>
|
></div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,10 +129,19 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({ application, activeTab, handleCha
|
|||||||
className={styles.tabs}
|
className={styles.tabs}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="rb:h-[32px] rb:flex rb:items-center rb:justify-end rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goToApplication}>
|
{application?.type === 'workflow'
|
||||||
<img src={logoutIcon} className="rb:mr-[8px]" />
|
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
|
||||||
|
<Button onClick={clear}>{t('workflow.clear')}</Button>
|
||||||
|
<Button onClick={run}>{t('workflow.run')}</Button>
|
||||||
|
<Button type="primary" onClick={save}>{t('workflow.save')}</Button>
|
||||||
|
{/* <Button type="primary">{t('workflow.export')}</Button> */}
|
||||||
|
<img src={logoutIcon} className="rb:w-4 rb:h-4 rb:cursor-pointer" onClick={goToApplication} />
|
||||||
|
</div>
|
||||||
|
: <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:cursor-pointer" onClick={goToApplication}>
|
||||||
|
<img src={logoutIcon} className="rb:mr-2 rb:w-4 rb:h-4" />
|
||||||
{t('application.returnToApplicationList')}
|
{t('application.returnToApplicationList')}
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</Header>
|
</Header>
|
||||||
<ApplicationModal
|
<ApplicationModal
|
||||||
ref={applicationModalRef}
|
ref={applicationModalRef}
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import ConfigHeader from './components/ConfigHeader'
|
import ConfigHeader from './components/ConfigHeader'
|
||||||
import type { AgentRef } from './types'
|
import type { AgentRef, ClusterRef, WorkflowRef } from './types'
|
||||||
import type { Application } from '@/views/ApplicationManagement/types'
|
import type { Application } from '@/views/ApplicationManagement/types'
|
||||||
import Agent from './Agent'
|
import Agent from './Agent'
|
||||||
import Api from './Api'
|
import Api from './Api'
|
||||||
import ReleasePage from './ReleasePage'
|
import ReleasePage from './ReleasePage'
|
||||||
import Cluster from './Cluster'
|
import Cluster from './Cluster'
|
||||||
import { getApplication } from '@/api/application'
|
import { getApplication } from '@/api/application'
|
||||||
|
import Workflow from '@/views/Workflow';
|
||||||
|
|
||||||
const ApplicationConfig: React.FC = () => {
|
const ApplicationConfig: React.FC = () => {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const agentRef = useRef<AgentRef>(null)
|
const agentRef = useRef<AgentRef>(null)
|
||||||
|
const clusterRef = useRef<ClusterRef>(null)
|
||||||
|
const workflowRef = useRef<WorkflowRef>(null)
|
||||||
const [application, setApplication] = useState<Application | null>(null);
|
const [application, setApplication] = useState<Application | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('arrangement');
|
const [activeTab, setActiveTab] = useState('arrangement');
|
||||||
|
|
||||||
@@ -21,6 +24,16 @@ const ApplicationConfig: React.FC = () => {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
setActiveTab(key)
|
setActiveTab(key)
|
||||||
})
|
})
|
||||||
|
} else if (activeTab === 'arrangement' && application?.type === 'multi_agent' && clusterRef.current) {
|
||||||
|
clusterRef.current.handleSave(false)
|
||||||
|
.then(() => {
|
||||||
|
setActiveTab(key)
|
||||||
|
})
|
||||||
|
} else if (activeTab === 'arrangement' && application?.type === 'workflow' && workflowRef.current) {
|
||||||
|
workflowRef.current.handleSave(false)
|
||||||
|
.then(() => {
|
||||||
|
setActiveTab(key)
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setActiveTab(key)
|
setActiveTab(key)
|
||||||
}
|
}
|
||||||
@@ -47,9 +60,11 @@ const ApplicationConfig: React.FC = () => {
|
|||||||
handleChangeTab={handleChangeTab}
|
handleChangeTab={handleChangeTab}
|
||||||
application={application as Application}
|
application={application as Application}
|
||||||
refresh={getApplicationInfo}
|
refresh={getApplicationInfo}
|
||||||
|
workflowRef={workflowRef}
|
||||||
/>
|
/>
|
||||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
|
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
|
||||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster application={application as Application} />}
|
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} application={application as Application} />}
|
||||||
|
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} />}
|
||||||
{activeTab === 'api' && <Api application={application} />}
|
{activeTab === 'api' && <Api application={application} />}
|
||||||
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { KnowledgeBaseListItem } from '@/views/KnowledgeBase/types'
|
import type { KnowledgeBaseListItem } from '@/views/KnowledgeBase/types'
|
||||||
import type { ChatItem } from '@/components/Chat/types'
|
import type { ChatItem } from '@/components/Chat/types'
|
||||||
|
import type { GraphRef } from '@/views/Workflow/types';
|
||||||
|
import type { ApiKey } from '@/views/ApiKeyManagement/types'
|
||||||
|
|
||||||
export interface ModelConfig {
|
export interface ModelConfig {
|
||||||
label?: string;
|
label?: string;
|
||||||
@@ -116,6 +118,14 @@ export interface ApplicationModalData {
|
|||||||
export interface AgentRef {
|
export interface AgentRef {
|
||||||
handleSave: (flag?: boolean) => Promise<any>;
|
handleSave: (flag?: boolean) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
export interface ClusterRef {
|
||||||
|
handleSave: (flag?: boolean) => Promise<any>;
|
||||||
|
}
|
||||||
|
export interface WorkflowRef {
|
||||||
|
handleSave: (flag?: boolean) => Promise<any>;
|
||||||
|
handleRun: () => void;
|
||||||
|
graphRef: GraphRef
|
||||||
|
}
|
||||||
export interface ApplicationModalRef {
|
export interface ApplicationModalRef {
|
||||||
handleOpen: (application?: Config) => void;
|
handleOpen: (application?: Config) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
203
web/src/views/Workflow/components/CanvasToolbar.tsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import type { FC } from 'react';
|
||||||
|
import { Select, Button } from 'antd';
|
||||||
|
import { Node } from '@antv/x6';
|
||||||
|
import type { GraphRef } from '../types'
|
||||||
|
|
||||||
|
interface CanvasToolbarProps {
|
||||||
|
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||||
|
graphRef: GraphRef;
|
||||||
|
isHandMode: boolean;
|
||||||
|
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
zoomLevel: number;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||||
|
miniMapRef,
|
||||||
|
graphRef,
|
||||||
|
isHandMode,
|
||||||
|
setIsHandMode,
|
||||||
|
zoomLevel,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
}) => {
|
||||||
|
// 整理布局函数
|
||||||
|
const handleLayout = () => {
|
||||||
|
if (!graphRef.current) return;
|
||||||
|
const nodes = graphRef.current.getNodes();
|
||||||
|
const edges = graphRef.current.getEdges();
|
||||||
|
|
||||||
|
// 如果没有连线,使用垂直布局避免节点重叠
|
||||||
|
if (edges.length === 0) {
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const nodeData = node.getData();
|
||||||
|
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
|
||||||
|
const nodeHeight = isSpecialNode ? 220 : 50;
|
||||||
|
const xPosition = 100;
|
||||||
|
const yPosition = index * (nodeHeight + 100) + 100;
|
||||||
|
node.setPosition(xPosition, yPosition);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的树布局算法
|
||||||
|
const nodeMap = new Map<string, Node>();
|
||||||
|
const children = new Map<string, string[]>();
|
||||||
|
const roots: string[] = [];
|
||||||
|
|
||||||
|
// 初始化节点映射
|
||||||
|
nodes.forEach(node => {
|
||||||
|
nodeMap.set(node.id, node);
|
||||||
|
children.set(node.id, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 构建父子关系
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const sourceId = edge.getSourceCellId();
|
||||||
|
const targetId = edge.getTargetCellId();
|
||||||
|
if (sourceId && targetId) {
|
||||||
|
children.get(sourceId)?.push(targetId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 找到根节点
|
||||||
|
const hasParent = new Set<string>();
|
||||||
|
edges.forEach(edge => {
|
||||||
|
const targetId = edge.getTargetCellId();
|
||||||
|
if (targetId) hasParent.add(targetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (!hasParent.has(node.id)) {
|
||||||
|
roots.push(node.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 布局参数
|
||||||
|
const levelWidths: number[] = [];
|
||||||
|
const baseNodeSpacing = 120;
|
||||||
|
let currentY = 100;
|
||||||
|
|
||||||
|
// 计算每层的最大宽度
|
||||||
|
const calculateLevelWidths = (nodeId: string, level: number) => {
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const nodeData = node.getData();
|
||||||
|
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
|
||||||
|
const nodeWidth = isSpecialNode ? 400 : 160;
|
||||||
|
const gap = isSpecialNode ? 150 : 100;
|
||||||
|
|
||||||
|
levelWidths[level] = Math.max(levelWidths[level] || 0, nodeWidth + gap);
|
||||||
|
|
||||||
|
const childIds = children.get(nodeId) || [];
|
||||||
|
childIds.forEach((childId: string) => calculateLevelWidths(childId, level + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
roots.forEach(rootId => calculateLevelWidths(rootId, 0));
|
||||||
|
|
||||||
|
// 递归布局函数
|
||||||
|
const layoutNode = (nodeId: string, level: number, parentY: number): number => {
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (!node) return parentY;
|
||||||
|
|
||||||
|
const nodeData = node.getData();
|
||||||
|
const isSpecialNode = nodeData?.isGroup || nodeData?.type === 'condition';
|
||||||
|
const nodeHeight = isSpecialNode ? 220 : 50;
|
||||||
|
const verticalGap = isSpecialNode ? 80 : 40;
|
||||||
|
const spacing = baseNodeSpacing + nodeHeight + verticalGap;
|
||||||
|
|
||||||
|
const xPosition = levelWidths.slice(0, level).reduce((sum, width) => sum + width, 100);
|
||||||
|
|
||||||
|
const childIds = children.get(nodeId) || [];
|
||||||
|
|
||||||
|
if (childIds.length === 0) {
|
||||||
|
// 叶子节点
|
||||||
|
node.setPosition(xPosition, currentY);
|
||||||
|
currentY += spacing;
|
||||||
|
return currentY - spacing;
|
||||||
|
} else {
|
||||||
|
// 非叶子节点,先布局子节点
|
||||||
|
const childPositions: number[] = [];
|
||||||
|
childIds.forEach((childId: string) => {
|
||||||
|
const childY = layoutNode(childId, level + 1, currentY);
|
||||||
|
childPositions.push(childY);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 父节点居中,确保有足够间隙
|
||||||
|
const minY = Math.min(...childPositions);
|
||||||
|
const maxY = Math.max(...childPositions);
|
||||||
|
const centerY = (minY + maxY) / 2;
|
||||||
|
node.setPosition(xPosition, centerY);
|
||||||
|
return centerY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 布局所有根节点
|
||||||
|
roots.forEach(rootId => {
|
||||||
|
layoutNode(rootId, 0, currentY);
|
||||||
|
currentY += 300; // 不同树之间的间距
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 小地图 */}
|
||||||
|
<div ref={miniMapRef} className="rb:absolute rb:bottom-17 rb:left-5 rb:z-1000"></div>
|
||||||
|
{/* 缩放控制按钮 */}
|
||||||
|
<div className="rb:absolute rb:bottom-5 rb:left-5 rb:flex rb:flex-row rb:gap-2 rb:z-1000">
|
||||||
|
<Button
|
||||||
|
type={isHandMode ? 'primary' : 'default'}
|
||||||
|
onClick={() => {
|
||||||
|
const newHandMode = !isHandMode;
|
||||||
|
setIsHandMode(newHandMode);
|
||||||
|
if (newHandMode) {
|
||||||
|
graphRef.current?.enablePanning();
|
||||||
|
} else {
|
||||||
|
graphRef.current?.disablePanning();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isHandMode ? '✋' : '👆'}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => graphRef.current?.zoom(0.1)}>+</Button>
|
||||||
|
<Select
|
||||||
|
value={Math.round(zoomLevel * 100)}
|
||||||
|
onChange={(value: number | string) => {
|
||||||
|
if (value === 'fit') {
|
||||||
|
graphRef.current?.zoomToFit({ padding: 20 });
|
||||||
|
} else {
|
||||||
|
graphRef.current?.zoomTo((value as number) / 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
labelRender={(props) => {
|
||||||
|
console.log('props', props)
|
||||||
|
return `${props.value}%`
|
||||||
|
}}
|
||||||
|
className="rb:w-20"
|
||||||
|
options={[
|
||||||
|
{ label: '25%', value: 25 },
|
||||||
|
{ label: '50%', value: 50 },
|
||||||
|
{ label: '75%', value: 75 },
|
||||||
|
{ label: '100%', value: 100 },
|
||||||
|
{ label: '125%', value: 125 },
|
||||||
|
{ label: '150%', value: 150 },
|
||||||
|
{ label: '200%', value: 200 },
|
||||||
|
{ label: '自适应', value: 'fit' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => graphRef.current?.zoom(-0.1)}>-</Button>
|
||||||
|
<Button disabled={!canUndo} onClick={onUndo}>撤销</Button>
|
||||||
|
<Button disabled={!canRedo} onClick={onRedo}>重做</Button>
|
||||||
|
<Button onClick={handleLayout}>整理</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CanvasToolbar;
|
||||||
174
web/src/views/Workflow/components/Chat/Chat.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Input, Form } from 'antd'
|
||||||
|
import { Space, Button } from 'antd'
|
||||||
|
|
||||||
|
import ChatIcon from '@/assets/images/application/chat.png'
|
||||||
|
import RbDrawer from '@/components/RbDrawer';
|
||||||
|
import VariableConfigModal from './VariableConfigModal'
|
||||||
|
import { draftRun } from '@/api/application';
|
||||||
|
import Empty from '@/components/Empty'
|
||||||
|
import ChatContent from '@/components/Chat/ChatContent'
|
||||||
|
import type { ChatItem } from '@/components/Chat/types'
|
||||||
|
import ChatSendIcon from '@/assets/images/application/chatSend.svg'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import type { ChatRef, VariableEditModalRef, StartVariableItem, GraphRef } from '../../types'
|
||||||
|
import { type SSEMessage } from '@/utils/stream'
|
||||||
|
|
||||||
|
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [form] = Form.useForm<{ message: string }>()
|
||||||
|
const variableConfigModalRef = useRef<VariableEditModalRef>(null)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||||
|
const [variables, setVariables] = useState<StartVariableItem[]>([])
|
||||||
|
const [streamLoading, setStreamLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
setOpen(true)
|
||||||
|
getVariables()
|
||||||
|
}
|
||||||
|
const getVariables = () => {
|
||||||
|
const nodes = graphRef.current?.getNodes()
|
||||||
|
const list = nodes?.map(node => node.getData()) || []
|
||||||
|
const startNodes = list.filter(vo => vo.type === 'start')
|
||||||
|
if (startNodes.length) {
|
||||||
|
const curVariables = startNodes[0].config.variables?.defaultValue
|
||||||
|
|
||||||
|
const initialValue: Record<string, any> = {}
|
||||||
|
|
||||||
|
curVariables.forEach((vo: StartVariableItem) => {
|
||||||
|
if (vo.default) {
|
||||||
|
initialValue[vo.name] = vo.default
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setVariables(curVariables)
|
||||||
|
form.setFieldsValue(initialValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false)
|
||||||
|
setChatList([])
|
||||||
|
}
|
||||||
|
const handleEditVariables = () => {
|
||||||
|
variableConfigModalRef.current?.handleOpen()
|
||||||
|
}
|
||||||
|
const handleSave = (values: StartVariableItem[]) => {
|
||||||
|
setVariables([...values])
|
||||||
|
}
|
||||||
|
const handleClusterSend = () => {
|
||||||
|
if (loading || !appId) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
const message = form.getFieldValue('message')
|
||||||
|
setChatList(prev => [...prev, {
|
||||||
|
role: 'user',
|
||||||
|
content: message,
|
||||||
|
created_at: Date.now(),
|
||||||
|
}])
|
||||||
|
setChatList(prev => [...prev, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: message,
|
||||||
|
created_at: Date.now(),
|
||||||
|
}])
|
||||||
|
|
||||||
|
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||||
|
setStreamLoading(false)
|
||||||
|
|
||||||
|
data.map(item => {
|
||||||
|
const { chunk } = item.data as { chunk: string; };
|
||||||
|
|
||||||
|
switch(item.event) {
|
||||||
|
case 'message':
|
||||||
|
setChatList(prev => {
|
||||||
|
const lastChat = { ...prev[prev.length - 1] }
|
||||||
|
lastChat.content = lastChat.content + chunk
|
||||||
|
|
||||||
|
return [
|
||||||
|
...prev.slice(0, prev.length - 1),
|
||||||
|
lastChat
|
||||||
|
]
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'workflow_end':
|
||||||
|
setStreamLoading(false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
const params: Record<string, any> = {}
|
||||||
|
if (variables.length > 0) {
|
||||||
|
variables.forEach(vo => {
|
||||||
|
params[vo.name] = vo.value ?? vo.defaultValue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
form.setFieldValue('message', undefined)
|
||||||
|
draftRun(appId, {
|
||||||
|
message: message,
|
||||||
|
variables: params,
|
||||||
|
stream: true
|
||||||
|
}, handleStreamMessage)
|
||||||
|
.finally(() => {
|
||||||
|
setLoading(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbDrawer
|
||||||
|
title={<div className="rb:flex rb:items-center rb:gap-2.5">
|
||||||
|
{t('workflow.run')}
|
||||||
|
{variables.length > 0 && <Space>
|
||||||
|
<Button size="small" onClick={handleEditVariables}>变量</Button>
|
||||||
|
</Space>}
|
||||||
|
</div>}
|
||||||
|
classNames={{
|
||||||
|
body: 'rb:p-0!'
|
||||||
|
}}
|
||||||
|
open={open}
|
||||||
|
onClose={handleClose}
|
||||||
|
>
|
||||||
|
<ChatContent
|
||||||
|
classNames={{
|
||||||
|
'rb:mx-[16px] rb:pt-[24px] rb:h-[calc(100%-76px)]': true,
|
||||||
|
|
||||||
|
}}
|
||||||
|
contentClassNames="rb:max-w-[400px]!'"
|
||||||
|
empty={<Empty url={ChatIcon} title={t('application.chatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
|
||||||
|
data={chatList}
|
||||||
|
streamLoading={streamLoading}
|
||||||
|
labelPosition="bottom"
|
||||||
|
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||||
|
errorDesc={t('application.ReplyException')}
|
||||||
|
/>
|
||||||
|
<div className="rb:flex rb:items-center rb:gap-2.5 rb:p-4">
|
||||||
|
<Form form={form} style={{width: 'calc(100% - 54px)'}}>
|
||||||
|
<Form.Item name="message" className="rb:mb-0!">
|
||||||
|
<Input
|
||||||
|
className="rb:h-11 rb:shadow-[0px_2px_8px_0px_rgba(33,35,50,0.1)]"
|
||||||
|
placeholder={t('application.chatPlaceholder')}
|
||||||
|
onPressEnter={handleClusterSend}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<img src={ChatSendIcon} className={clsx("rb:w-11 rb:h-11 rb:cursor-pointer", {
|
||||||
|
'rb:opacity-50': loading,
|
||||||
|
})} onClick={handleClusterSend} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VariableConfigModal
|
||||||
|
ref={variableConfigModalRef}
|
||||||
|
refresh={handleSave}
|
||||||
|
variables={variables}
|
||||||
|
/>
|
||||||
|
</RbDrawer>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default Chat
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { Form, Input, InputNumber, Checkbox } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { StartVariableItem, VariableEditModalRef } from '../../types'
|
||||||
|
import RbModal from '@/components/RbModal'
|
||||||
|
|
||||||
|
interface VariableEditModalProps {
|
||||||
|
refresh: (values: StartVariableItem[]) => void;
|
||||||
|
variables: StartVariableItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const VariableConfigModal = forwardRef<VariableEditModalRef, VariableEditModalProps>(({
|
||||||
|
refresh,
|
||||||
|
variables
|
||||||
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<{variables: StartVariableItem[]}>();
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setLoading(false)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
|
||||||
|
setVisible(true);
|
||||||
|
};
|
||||||
|
// 封装保存方法,添加提交逻辑
|
||||||
|
const handleSave = () => {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
refresh([
|
||||||
|
...(values?.variables ?? []),
|
||||||
|
])
|
||||||
|
handleClose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={t('workflow.variableConfig')}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
okText={t('common.save')}
|
||||||
|
onOk={handleSave}
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="horizontal"
|
||||||
|
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
|
||||||
|
initialValues={{ variables: variables }}
|
||||||
|
>
|
||||||
|
<Form.List name="variables">
|
||||||
|
{(fields) => (
|
||||||
|
<>
|
||||||
|
{fields.map(({ name }, index) => {
|
||||||
|
const field = variables[index]
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={name}
|
||||||
|
name={[name, 'value']}
|
||||||
|
label={field.type === 'boolean' ? undefined : `${field.name}·${field.description}`}
|
||||||
|
rules={[
|
||||||
|
{ required: field.required, message: field.type === 'boolean' ? t('common.pleaseSelect') : t('common.pleaseEnter') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
field.type === 'string' && <Input placeholder={t('common.pleaseEnter')} />
|
||||||
|
}
|
||||||
|
{
|
||||||
|
field.type === 'number' && <InputNumber placeholder={t('common.pleaseEnter')} style={{ width: '100%' }} />
|
||||||
|
}
|
||||||
|
{
|
||||||
|
field.type === 'boolean' && <Checkbox>{`${field.name}·${field.description}`}</Checkbox>
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</Form>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VariableConfigModal;
|
||||||
48
web/src/views/Workflow/components/NodeLibrary.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { type FC } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Card, Space } from 'antd'
|
||||||
|
|
||||||
|
import { nodeLibrary } from '../constant';
|
||||||
|
|
||||||
|
const NodeLibrary: FC = () => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
console.log('nodeLibrary', nodeLibrary)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rb:w-80 rb:fixed rb:h-screen rb:left-0 rb:py-5 rb:px-5.5 rb:overflow-y-auto">
|
||||||
|
<Space size={12} direction="vertical" className="rb:w-full">
|
||||||
|
{nodeLibrary.map(category => (
|
||||||
|
<Card
|
||||||
|
key={category.category}
|
||||||
|
type="inner"
|
||||||
|
title={t(`workflow.${category.category}`)}
|
||||||
|
classNames={{
|
||||||
|
body: "rb:p-[10px]!",
|
||||||
|
header: "rb:bg-[#F6F8FC]!"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size={8} direction="vertical" className="rb:w-full">
|
||||||
|
{category.nodes.map((node, nodeIndex) => (
|
||||||
|
<div
|
||||||
|
key={nodeIndex}
|
||||||
|
className="rb:bg-white rb:rounded-lg rb:p-2 rb:border rb:border-[#DFE4ED] rb:cursor-pointer rb:flex rb:items-center rb:gap-2 rb:hover:border-[#155EEF] rb:hover:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]"
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
e.dataTransfer.setData('application/reactflow', node.type);
|
||||||
|
e.dataTransfer.setData('application/json', JSON.stringify(node));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src={node.icon} className="rb:w-5 rb:h-5" />
|
||||||
|
<span className="rb:font-medium rb:text-[12px]">{t(`workflow.${node.type}`)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NodeLibrary;
|
||||||
19
web/src/views/Workflow/components/Nodes/AddNode.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
|
||||||
|
const AddNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||||
|
const data = node?.getData() || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-30 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
|
||||||
|
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
|
||||||
|
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
|
||||||
|
})}>
|
||||||
|
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
|
||||||
|
{data.icon} {data.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddNode;
|
||||||
155
web/src/views/Workflow/components/Nodes/ConditionNode.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Button } from 'antd'
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
|
||||||
|
const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||||
|
const data = node?.getData() || {};
|
||||||
|
|
||||||
|
const addPort = (e: React.MouseEvent) => {
|
||||||
|
if (!node || !node.addPort) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const currentPorts = node.getPorts();
|
||||||
|
const totalPorts = currentPorts.length;
|
||||||
|
|
||||||
|
// 如果没有端口,添加第一个端口和ELSE端口
|
||||||
|
if (totalPorts === 0) {
|
||||||
|
// 添加第一个ELIF端口
|
||||||
|
node.addPort({
|
||||||
|
id: 'elif_1',
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: 'ELIF 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 添加ELSE端口
|
||||||
|
node.addPort({
|
||||||
|
id: 'else',
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: 'ELSE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个端口,确保它是ELSE,然后在之前添加ELIF
|
||||||
|
if (totalPorts === 1) {
|
||||||
|
const existingPort = currentPorts[0];
|
||||||
|
|
||||||
|
// 如果现有端口不是ELSE,先移除它
|
||||||
|
if (node.removePort && existingPort.id !== 'else') {
|
||||||
|
node.removePort(existingPort.id as string);
|
||||||
|
|
||||||
|
// 添加ELIF端口
|
||||||
|
node.addPort({
|
||||||
|
id: 'elif_1',
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: 'ELIF 1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加或确保存在ELSE端口
|
||||||
|
if (existingPort.id !== 'else') {
|
||||||
|
node.addPort({
|
||||||
|
id: 'else',
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: 'ELSE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最后一个端口,确保它是ELSE
|
||||||
|
let lastPort = currentPorts[totalPorts - 1];
|
||||||
|
|
||||||
|
// 如果最后一个端口不是ELSE,先移除它
|
||||||
|
if (node.removePort && lastPort.id !== 'else') {
|
||||||
|
node.removePort(lastPort.id as string);
|
||||||
|
|
||||||
|
// 添加ELSE端口作为最后一个
|
||||||
|
node.addPort({
|
||||||
|
id: 'else',
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: 'ELSE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新currentPorts和totalPorts
|
||||||
|
const updatedPorts = node.getPorts();
|
||||||
|
const updatedTotal = updatedPorts.length;
|
||||||
|
lastPort = updatedPorts[updatedTotal - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算新的ELIF端口数量(最后一个是ELSE,不算在内)
|
||||||
|
const elifCount = totalPorts - 1;
|
||||||
|
const newElifCount = elifCount + 1;
|
||||||
|
|
||||||
|
// 如果有removePort方法,先移除最后一个端口(ELSE),添加新的ELIF端口,再添加回ELSE端口
|
||||||
|
if (node.removePort) {
|
||||||
|
// 移除最后一个端口(ELSE)
|
||||||
|
node.removePort(lastPort.id as string);
|
||||||
|
|
||||||
|
// 添加新的ELIF端口在倒数第二个位置
|
||||||
|
node.addPort({
|
||||||
|
id: `elif_${newElifCount}`,
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: `ELIF ${newElifCount}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 添加回ELSE端口
|
||||||
|
node.addPort({
|
||||||
|
id: 'else',
|
||||||
|
group: 'right',
|
||||||
|
attrs: {
|
||||||
|
text: {
|
||||||
|
text: 'ELSE',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// const removeElif = (e: React.MouseEvent) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(`rb:border rb:rounded-[12px] rb:relative rb:min-w-[200px] rb:min-h-[120px] rb:p-2`, {
|
||||||
|
'rb:border-orange-500 rb:border-[3px] rb:bg-orange-50 rb:text-gray-700': data.isSelected,
|
||||||
|
'rb:border-[#d1d5db] rb:bg-[#FFFFFF] rb:text-[#374151]': !data.isSelected
|
||||||
|
})}>
|
||||||
|
|
||||||
|
<Button onClick={addPort}>+ 添加 ELIF</Button>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="rb:absolute rb:-top-3 rb:left-2 rb:bg-blue-500 rb:rounded-2xl rb:px-3 rb:py-1 rb:flex rb:items-center rb:gap-1.5 rb:text-white rb:text-xs rb:font-bold rb:z-10">
|
||||||
|
<div className="rb:w-4 rb:h-4 rb:bg-white rb:rounded rb:flex rb:items-center rb:justify-center rb:text-blue-500 rb:text-[10px]">
|
||||||
|
🔀
|
||||||
|
</div>
|
||||||
|
条件分支
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConditionNode;
|
||||||
19
web/src/views/Workflow/components/Nodes/GroupStartNode.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
|
||||||
|
const GroupStartNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||||
|
const data = node?.getData() || {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-20 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
|
||||||
|
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
|
||||||
|
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
|
||||||
|
})}>
|
||||||
|
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
|
||||||
|
{data.icon} {data.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroupStartNode;
|
||||||
98
web/src/views/Workflow/components/Nodes/IterationNode.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import { SmallDashOutlined } from '@ant-design/icons';
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
import { graphNodeLibrary } from '../../constant';
|
||||||
|
|
||||||
|
interface NodeData {
|
||||||
|
isSelected?: boolean;
|
||||||
|
type?: string;
|
||||||
|
label?: string;
|
||||||
|
icon?: string;
|
||||||
|
parentId?: string;
|
||||||
|
isGroup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IterationNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
||||||
|
const data = node.getData() as NodeData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initNodes()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initNodes = () => {
|
||||||
|
// 添加默认子节点
|
||||||
|
const parentBBox = node.getBBox();
|
||||||
|
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
|
||||||
|
const centerY = parentBBox.y + 50; // 默认节点高度的一半
|
||||||
|
|
||||||
|
const childNode1 = graph.addNode({
|
||||||
|
...graphNodeLibrary.groupStart,
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
data: {
|
||||||
|
type: 'default',
|
||||||
|
label: '开始',
|
||||||
|
// icon: '📌',
|
||||||
|
parentId: node.id,
|
||||||
|
isDefault: true // 标记为默认节点,不可删除
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const childNode2 = graph.addNode({
|
||||||
|
...graphNodeLibrary.addStart,
|
||||||
|
x: centerX + 150,
|
||||||
|
y: centerY,
|
||||||
|
data: {
|
||||||
|
type: 'default',
|
||||||
|
label: '添加节点',
|
||||||
|
icon: '+',
|
||||||
|
parentId: node.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.addChild(childNode1)
|
||||||
|
node.addChild(childNode2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-xl rb:relative rb:min-w-75 rb:min-h-50 rb:p-4', {
|
||||||
|
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
|
||||||
|
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
|
||||||
|
})}>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
|
||||||
|
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
|
||||||
|
🔁
|
||||||
|
</div>
|
||||||
|
迭代
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
menu={{items: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: '删除',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: '复制',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: '删除',
|
||||||
|
}
|
||||||
|
]}}
|
||||||
|
>
|
||||||
|
<SmallDashOutlined
|
||||||
|
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
|
||||||
|
'rb:visible': data.isSelected
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{/* 画布内容区域 */}
|
||||||
|
<div className="rb:mt-6 rb:min-h-37.5 rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IterationNode;
|
||||||
98
web/src/views/Workflow/components/Nodes/LoopNode.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Dropdown } from 'antd';
|
||||||
|
import { SmallDashOutlined } from '@ant-design/icons';
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
import { graphNodeLibrary } from '../../constant';
|
||||||
|
|
||||||
|
interface NodeData {
|
||||||
|
isSelected?: boolean;
|
||||||
|
type?: string;
|
||||||
|
label?: string;
|
||||||
|
icon?: string;
|
||||||
|
parentId?: string;
|
||||||
|
isGroup?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
|
||||||
|
const data = node.getData() as NodeData;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initNodes()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const initNodes = () => {
|
||||||
|
// 添加默认子节点
|
||||||
|
const parentBBox = node.getBBox();
|
||||||
|
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
|
||||||
|
const centerY = parentBBox.y + 50; // 默认节点高度的一半
|
||||||
|
|
||||||
|
const childNode1 = graph.addNode({
|
||||||
|
...graphNodeLibrary.groupStart,
|
||||||
|
x: centerX,
|
||||||
|
y: centerY,
|
||||||
|
data: {
|
||||||
|
type: 'default',
|
||||||
|
label: '开始',
|
||||||
|
// icon: '📌',
|
||||||
|
parentId: node.id,
|
||||||
|
isDefault: true // 标记为默认节点,不可删除
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const childNode2 = graph.addNode({
|
||||||
|
...graphNodeLibrary.addStart,
|
||||||
|
x: centerX + 150,
|
||||||
|
y: centerY,
|
||||||
|
data: {
|
||||||
|
type: 'default',
|
||||||
|
label: '添加节点',
|
||||||
|
icon: '+',
|
||||||
|
parentId: node.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
node.addChild(childNode1)
|
||||||
|
node.addChild(childNode2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-[12px] rb:relative rb:min-w-[300px] rb:min-h-[200px] rb:p-4', {
|
||||||
|
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
|
||||||
|
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
|
||||||
|
})}>
|
||||||
|
{/* 标题区域 */}
|
||||||
|
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
|
||||||
|
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
|
||||||
|
♻️
|
||||||
|
</div>
|
||||||
|
循环
|
||||||
|
</div>
|
||||||
|
<Dropdown
|
||||||
|
menu={{items: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: '删除',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: '复制',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: '删除',
|
||||||
|
}
|
||||||
|
]}}
|
||||||
|
>
|
||||||
|
<SmallDashOutlined
|
||||||
|
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
|
||||||
|
'rb:visible': data.isSelected
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
{/* 画布内容区域 */}
|
||||||
|
<div className="rb:mt-6 rb:min-h-[150px] rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoopNode;
|
||||||
31
web/src/views/Workflow/components/Nodes/NormalNode.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
|
||||||
|
const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||||
|
const data = node?.getData() || {}
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-16 rb:w-60 rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
|
||||||
|
'rb:border-[#155EEF]': data.isSelected,
|
||||||
|
'rb:border-[#DFE4ED]': !data.isSelected
|
||||||
|
})}>
|
||||||
|
<div className="rb:flex rb:items-center rb:justify-between">
|
||||||
|
<div className="rb:flex rb:items-center rb:gap-2 rb:flex-1">
|
||||||
|
<img src={data.icon} className="rb:w-5 rb:h-5" />
|
||||||
|
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
|
||||||
|
onClick={() => {}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:mt-1.5">{t('workflow.clickToConfigure')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NormalNode;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { type FC } from 'react';
|
||||||
|
import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd';
|
||||||
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface TextareaProps {
|
||||||
|
parentName?: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value?: string) => void;
|
||||||
|
}
|
||||||
|
const roleOptions = [
|
||||||
|
// { label: 'SYSTEM', value: 'SYSTEM' },
|
||||||
|
{ label: 'USER', value: 'USER' },
|
||||||
|
{ label: 'ASSISTANT', value: 'ASSISTANT' },
|
||||||
|
]
|
||||||
|
const MessageEditor: FC<TextareaProps> = ({
|
||||||
|
parentName = 'messages',
|
||||||
|
placeholder,
|
||||||
|
}) => {
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
const values = form.getFieldsValue()
|
||||||
|
|
||||||
|
const handleAdd = (add: FormListOperation['add']) => {
|
||||||
|
const list = values[parentName];
|
||||||
|
const lastRole = list[list.length - 1].role
|
||||||
|
|
||||||
|
add({
|
||||||
|
role: lastRole === 'USER' ? 'ASSISTANT' : 'USER',
|
||||||
|
content: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form.List name={parentName}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map(({ key, name, ...restField }) => {
|
||||||
|
const currentRole = values[parentName]?.[key].role || 'USER'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space key={key} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5">
|
||||||
|
<Row>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
{...restField}
|
||||||
|
name={[name, 'role']}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
{currentRole === 'SYSTEM'
|
||||||
|
? <Input disabled />
|
||||||
|
:
|
||||||
|
<Select
|
||||||
|
options={roleOptions}
|
||||||
|
disabled={currentRole === 'SYSTEM'}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
{currentRole !== 'SYSTEM' && <Col span={12}>
|
||||||
|
<div className="rb:h-full rb:flex rb:justify-end rb:items-center">
|
||||||
|
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||||
|
</div>
|
||||||
|
</Col>}
|
||||||
|
</Row>
|
||||||
|
<Form.Item
|
||||||
|
{...restField}
|
||||||
|
name={[name, 'content']}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Input.TextArea placeholder={placeholder} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<Form.Item className="rb:mt-3!">
|
||||||
|
<Button type="dashed" onClick={() => handleAdd(add)} block icon={<PlusOutlined />}>
|
||||||
|
Add field
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MessageEditor;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import { Form, Input, Select, InputNumber, Checkbox, Tag } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import type { StartVariableItem, VariableEditModalRef } from '../../types'
|
||||||
|
import RbModal from '@/components/RbModal'
|
||||||
|
import SortableList from '@/components/SortableList'
|
||||||
|
|
||||||
|
const FormItem = Form.Item;
|
||||||
|
|
||||||
|
interface VariableEditModalProps {
|
||||||
|
refresh: (values: StartVariableItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const types = [
|
||||||
|
'string',
|
||||||
|
'number',
|
||||||
|
'boolean',
|
||||||
|
// 'array',
|
||||||
|
// 'object'
|
||||||
|
]
|
||||||
|
const variableType = {
|
||||||
|
string: 'string',
|
||||||
|
number: 'number',
|
||||||
|
boolean: 'boolean',
|
||||||
|
// array: 'array',
|
||||||
|
// object: 'object',
|
||||||
|
}
|
||||||
|
|
||||||
|
const VariableEditModal = forwardRef<VariableEditModalRef, VariableEditModalProps>(({
|
||||||
|
refresh
|
||||||
|
}, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [form] = Form.useForm<StartVariableItem>();
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [editVo, setEditVo] = useState<StartVariableItem | null>(null)
|
||||||
|
|
||||||
|
const values = Form.useWatch([], form);
|
||||||
|
|
||||||
|
// 封装取消方法,添加关闭弹窗逻辑
|
||||||
|
const handleClose = () => {
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
setLoading(false)
|
||||||
|
setEditVo(null)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = (variable?: StartVariableItem) => {
|
||||||
|
setVisible(true);
|
||||||
|
if (variable) {
|
||||||
|
setEditVo(variable || null)
|
||||||
|
form.setFieldsValue(variable)
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 封装保存方法,添加提交逻辑
|
||||||
|
const handleSave = () => {
|
||||||
|
form.validateFields().then((values) => {
|
||||||
|
refresh({
|
||||||
|
...(editVo || {}),
|
||||||
|
...values,
|
||||||
|
})
|
||||||
|
handleClose()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 暴露给父组件的方法
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleOpen,
|
||||||
|
handleClose
|
||||||
|
}));
|
||||||
|
// 变量类型改变时,更新初始化其他字段值
|
||||||
|
const handleChangeType = (value: StartVariableItem['type']) => {
|
||||||
|
if (value) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
type: value,
|
||||||
|
name: undefined,
|
||||||
|
description: undefined,
|
||||||
|
max_length: undefined,
|
||||||
|
default: undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RbModal
|
||||||
|
title={editVo ? t('workflow.config.start.editVariable') : t('workflow.config.start.addVariable')}
|
||||||
|
open={visible}
|
||||||
|
onCancel={handleClose}
|
||||||
|
okText={t('common.save')}
|
||||||
|
onOk={handleSave}
|
||||||
|
confirmLoading={loading}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
scrollToFirstError={{ behavior: 'instant', block: 'end', focus: true }}
|
||||||
|
>
|
||||||
|
{/* 变量类型 */}
|
||||||
|
<FormItem
|
||||||
|
name="type"
|
||||||
|
label={t('workflow.config.start.variableType')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
options={types.map(key => ({
|
||||||
|
value: key,
|
||||||
|
label: t(`workflow.config.start.${key}`),
|
||||||
|
}))}
|
||||||
|
onChange={handleChangeType}
|
||||||
|
labelRender={(props) => <div className="rb:flex rb:justify-between rb:items-center">{props.label} <Tag color="blue">{variableType[props.value as keyof typeof variableType]}</Tag></div>}
|
||||||
|
optionRender={(props) => <div className="rb:flex rb:justify-between rb:items-center">{props.label} <Tag color="blue">{variableType[props.value as keyof typeof variableType]}</Tag></div>}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
{/* 变量名称 */}
|
||||||
|
<FormItem
|
||||||
|
name="name"
|
||||||
|
label={t('workflow.config.start.variableName')}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: t('common.pleaseEnter') },
|
||||||
|
{ pattern: /^[a-zA-Z_][a-zA-Z0-9_]*$/, message: t('workflow.config.start.invalidVariableName') },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.enter')} />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
{/* 显示名称 */}
|
||||||
|
<FormItem
|
||||||
|
name="description"
|
||||||
|
label={t('workflow.config.start.description')}
|
||||||
|
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||||
|
>
|
||||||
|
<Input placeholder={t('common.enter')} />
|
||||||
|
</FormItem>
|
||||||
|
|
||||||
|
{/* 最大长度 */}
|
||||||
|
{['string'].includes(values?.type) && (
|
||||||
|
<FormItem
|
||||||
|
name="max_length"
|
||||||
|
label={t('workflow.config.start.max_length')}
|
||||||
|
>
|
||||||
|
<InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
{/* 默认值 */}
|
||||||
|
{['string', 'number', 'boolean'].includes(values?.type) && (
|
||||||
|
<FormItem
|
||||||
|
name="default"
|
||||||
|
label={t('workflow.config.start.default')}
|
||||||
|
>
|
||||||
|
{['string'].includes(values.type) && <Input placeholder={t('common.enter')} />}
|
||||||
|
{['number'].includes(values.type) && <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />}
|
||||||
|
{['boolean'].includes(values.type) && <Select placeholder={t('common.pleaseSelect')} options={[{ value: true, label: t('workflow.config.start.defaultChecked') }, { value: false, label: t('workflow.config.start.notDefaultChecked') }]} />}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
{/* 选项 */}
|
||||||
|
{['array'].includes(values?.type) && (
|
||||||
|
<FormItem
|
||||||
|
name="options"
|
||||||
|
label={t('workflow.config.start.options')}
|
||||||
|
>
|
||||||
|
<SortableList />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
{/* 是否必填 */}
|
||||||
|
<FormItem
|
||||||
|
name="required"
|
||||||
|
valuePropName="checked"
|
||||||
|
>
|
||||||
|
<Checkbox>{t('workflow.config.start.required')}</Checkbox>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
</RbModal>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VariableEditModal;
|
||||||
231
web/src/views/Workflow/components/Properties/index.tsx
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import { type FC, useEffect, useState, useRef } from "react";
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Graph, Node } from '@antv/x6';
|
||||||
|
import { Form, Input, Button, Select, InputNumber, Slider, Space, Divider, App } from 'antd'
|
||||||
|
|
||||||
|
import type { NodeConfig, NodeProperties, StartVariableItem, VariableEditModalRef } from '../../types'
|
||||||
|
import Empty from '@/components/Empty';
|
||||||
|
import emptyIcon from '@/assets/images/workflow/empty.png'
|
||||||
|
import CustomSelect from "@/components/CustomSelect";
|
||||||
|
import VariableEditModal from './VariableEditModal';
|
||||||
|
import MessageEditor from './MessageEditor'
|
||||||
|
|
||||||
|
interface PropertiesProps {
|
||||||
|
selectedNode?: Node | null;
|
||||||
|
setSelectedNode: (node: Node | null) => void;
|
||||||
|
graphRef: React.MutableRefObject<Graph | undefined>;
|
||||||
|
blankClick: () => void;
|
||||||
|
deleteEvent: () => void;
|
||||||
|
copyEvent: () => void;
|
||||||
|
parseEvent: () => void;
|
||||||
|
}
|
||||||
|
const Properties: FC<PropertiesProps> = ({
|
||||||
|
selectedNode,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { modal } = App.useApp()
|
||||||
|
const [form] = Form.useForm<NodeConfig>();
|
||||||
|
const [configs, setConfigs] = useState<Record<string,NodeConfig>>({} as Record<string,NodeConfig>)
|
||||||
|
const values = Form.useWatch([], form);
|
||||||
|
const variableModalRef = useRef<VariableEditModalRef>(null)
|
||||||
|
const [editIndex, setEditIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNode && form) {
|
||||||
|
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
||||||
|
const initialValue: Record<string, any> = {}
|
||||||
|
Object.keys(config || {}).forEach(key => {
|
||||||
|
if (config && config[key] && 'defaultValue' in config[key]) {
|
||||||
|
initialValue[key] = config[key].defaultValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
type,
|
||||||
|
id: selectedNode.id,
|
||||||
|
name,
|
||||||
|
...initialValue,
|
||||||
|
})
|
||||||
|
setConfigs(config || {})
|
||||||
|
}
|
||||||
|
}, [selectedNode, form])
|
||||||
|
|
||||||
|
const updateNodeLabel = (newLabel: string) => {
|
||||||
|
if (selectedNode && form) {
|
||||||
|
const nodeData = selectedNode.data as NodeProperties;
|
||||||
|
selectedNode.setAttrByPath('text/text', `${nodeData.icon} ${newLabel}`);
|
||||||
|
selectedNode.setData({ ...selectedNode.data, name: newLabel });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (values && selectedNode) {
|
||||||
|
const { id, ...rest } = values
|
||||||
|
|
||||||
|
Object.keys(values).forEach(key => {
|
||||||
|
if (selectedNode.data.config[key]) {
|
||||||
|
selectedNode.data.config[key].defaultValue = values[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
selectedNode?.setData({ ...selectedNode.data, ...rest })
|
||||||
|
}
|
||||||
|
}, [values, selectedNode])
|
||||||
|
|
||||||
|
const handleAddVariable = () => {
|
||||||
|
variableModalRef.current?.handleOpen()
|
||||||
|
}
|
||||||
|
const handleEditVariable = (index: number, vo: StartVariableItem) => {
|
||||||
|
variableModalRef.current?.handleOpen(vo)
|
||||||
|
setEditIndex(index)
|
||||||
|
}
|
||||||
|
const handleRefreshVariable = (value: StartVariableItem) => {
|
||||||
|
if (!selectedNode) return
|
||||||
|
if (editIndex !== null) {
|
||||||
|
const defaultValue = selectedNode.data.config.variables.defaultValue ?? []
|
||||||
|
defaultValue[editIndex] = value
|
||||||
|
selectedNode.data.config.variables.defaultValue = [...defaultValue]
|
||||||
|
} else {
|
||||||
|
const defaultValue = selectedNode.data.config.variables.defaultValue ?? []
|
||||||
|
selectedNode.data.config.variables.defaultValue = [...defaultValue, value]
|
||||||
|
}
|
||||||
|
selectedNode?.setData({ ...selectedNode.data})
|
||||||
|
|
||||||
|
setConfigs({ ...selectedNode.data.config})
|
||||||
|
}
|
||||||
|
const handleDeleteVariable = (index: number, vo: StartVariableItem) => {
|
||||||
|
if (!selectedNode) return
|
||||||
|
|
||||||
|
modal.confirm({
|
||||||
|
title: t('common.confirmDeleteDesc', { name: vo.name }),
|
||||||
|
okText: t('common.delete'),
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: () => {
|
||||||
|
const defaultValue = selectedNode.data.config.variables.defaultValue ?? []
|
||||||
|
defaultValue.splice(index, 1)
|
||||||
|
selectedNode.data.config.variables.defaultValue = [...defaultValue]
|
||||||
|
|
||||||
|
selectedNode?.setData({ ...selectedNode.data })
|
||||||
|
|
||||||
|
setConfigs({ ...selectedNode.data.config })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3">
|
||||||
|
<div className="rb:font-medium rb:leading-5 rb:mb-3">{t('workflow.nodeProperties')}</div>
|
||||||
|
{!selectedNode
|
||||||
|
? <Empty url={emptyIcon} size={140} className="rb:h-full rb:mx-15" title={t('workflow.empty')} />
|
||||||
|
: <Form form={form} layout="vertical" className="rb:h-[calc(100%-20px)] rb:overflow-y-auto">
|
||||||
|
<Form.Item name="name" label={t('workflow.nodeName')}>
|
||||||
|
<Input
|
||||||
|
placeholder={t('common.pleaseEnter')}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateNodeLabel(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{configs && Object.keys(configs).length > 0 && Object.keys(configs).map((key) => {
|
||||||
|
const config = configs[key] || {}
|
||||||
|
|
||||||
|
if (selectedNode.data.type === 'start' && key === 'variables' && config.type === 'define') {
|
||||||
|
return (
|
||||||
|
<div key={key}>
|
||||||
|
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75">
|
||||||
|
<div className="rb:leading-5">
|
||||||
|
{t(`workflow.config.${selectedNode.data.type}.${key}`)}
|
||||||
|
</div>
|
||||||
|
<Button style={{padding: '0 8px', height: '24px'}} onClick={handleAddVariable}>+{t('application.addVariables')}</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space size={4} direction="vertical" className="rb:w-full">
|
||||||
|
{Array.isArray(config.defaultValue) && config.defaultValue?.map((vo, index) =>
|
||||||
|
<div key={`${vo.name}}-${index}`} className="rb:p-[4px_8px] rb:text-[12px] rb:text-[#5B6167] rb:flex rb:items-center rb:justify-between rb:border rb:border-[#DFE4ED] rb:rounded-md rb:group rb:cursor-pointer">
|
||||||
|
<span>{vo.name}·{vo.description}</span>
|
||||||
|
|
||||||
|
<div className="rb:group-hover:hidden rb:flex rb:items-center rb:gap-1">
|
||||||
|
{vo.required && <span>{t('workflow.config.start.required')}</span>}
|
||||||
|
{vo.type}
|
||||||
|
</div>
|
||||||
|
<Space className="rb:hidden! rb:group-hover:flex!">
|
||||||
|
<div
|
||||||
|
className="rb:w-4.5 rb:h-4.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
|
||||||
|
onClick={() => handleEditVariable(index, vo)}
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
className="rb:w-4.5 rb:h-4.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
|
||||||
|
onClick={() => handleDeleteVariable(index, vo)}
|
||||||
|
></div>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Divider size="small" />
|
||||||
|
{config.sys?.map((vo, index) =>
|
||||||
|
<div key={index} className="rb:p-[4px_8px] rb:text-[12px] rb:text-[#5B6167] rb:flex rb:items-center rb:justify-between rb:border rb:border-[#DFE4ED] rb:rounded-md">
|
||||||
|
<div>
|
||||||
|
<span>sys.{vo.name}</span>
|
||||||
|
</div>
|
||||||
|
{vo.type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.data.type === 'llm' && key === 'messages' && config.type === 'define') {
|
||||||
|
return (
|
||||||
|
<Form.Item key={key} name={key}>
|
||||||
|
<MessageEditor />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'define') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={key}
|
||||||
|
name={key}
|
||||||
|
label={t(`workflow.config.${selectedNode.data.type}.${key}`)}
|
||||||
|
>
|
||||||
|
{config.type === 'input'
|
||||||
|
? <Input placeholder={t('common.pleaseEnter')} />
|
||||||
|
: config.type === 'textarea'
|
||||||
|
? <Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||||
|
: config.type === 'select'
|
||||||
|
? <Select
|
||||||
|
options={config.options}
|
||||||
|
placeholder={t('common.pleaseSelect')}
|
||||||
|
/>
|
||||||
|
: config.type === 'inputNumber'
|
||||||
|
? <InputNumber />
|
||||||
|
: config.type === 'slider'
|
||||||
|
? <Slider min={config.min} max={config.max} step={config.step} />
|
||||||
|
: config.type === 'customSelect'
|
||||||
|
? <CustomSelect
|
||||||
|
url={config.url as string}
|
||||||
|
params={config.params}
|
||||||
|
hasAll={false}
|
||||||
|
valueKey={config.valueKey}
|
||||||
|
labelKey={config.labelKey}
|
||||||
|
/>
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Form>
|
||||||
|
}
|
||||||
|
|
||||||
|
<VariableEditModal
|
||||||
|
ref={variableModalRef}
|
||||||
|
refresh={handleRefreshVariable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Properties;
|
||||||
339
web/src/views/Workflow/constant.ts
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
import LoopNode from './components/Nodes/LoopNode';
|
||||||
|
import IterationNode from './components/Nodes/IterationNode';
|
||||||
|
import NormalNode from './components/Nodes/NormalNode';
|
||||||
|
import ConditionNode from './components/Nodes/ConditionNode';
|
||||||
|
import GroupStartNode from './components/Nodes/GroupStartNode';
|
||||||
|
import AddNode from './components/Nodes/AddNode'
|
||||||
|
import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port';
|
||||||
|
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||||
|
|
||||||
|
// Import workflow icons
|
||||||
|
import startIcon from '@/assets/images/workflow/start.png';
|
||||||
|
import endIcon from '@/assets/images/workflow/end.png';
|
||||||
|
import answerIcon from '@/assets/images/workflow/answer.png';
|
||||||
|
import llmIcon from '@/assets/images/workflow/llm.png';
|
||||||
|
import modelSelectionIcon from '@/assets/images/workflow/model_selection.png';
|
||||||
|
import modelVotingIcon from '@/assets/images/workflow/model_voting.png';
|
||||||
|
import ragIcon from '@/assets/images/workflow/rag.png';
|
||||||
|
import classificationIcon from '@/assets/images/workflow/classification.png';
|
||||||
|
import parameterExtractionIcon from '@/assets/images/workflow/parameter_extraction.png';
|
||||||
|
import taskPlanningIcon from '@/assets/images/workflow/task_planning.png';
|
||||||
|
import reasoningControlIcon from '@/assets/images/workflow/reasoning_control.png';
|
||||||
|
import selfReflectionIcon from '@/assets/images/workflow/self_reflection.png';
|
||||||
|
import memoryEnhancementIcon from '@/assets/images/workflow/memory_enhancement.png';
|
||||||
|
import agentSchedulingIcon from '@/assets/images/workflow/agent_scheduling.png';
|
||||||
|
import agentCollaborationIcon from '@/assets/images/workflow/agent_collaboration.png';
|
||||||
|
import agentArbitrationIcon from '@/assets/images/workflow/agent_arbitration.png';
|
||||||
|
import conditionIcon from '@/assets/images/workflow/condition.png';
|
||||||
|
import iterationIcon from '@/assets/images/workflow/iteration.png';
|
||||||
|
import loopIcon from '@/assets/images/workflow/loop.png';
|
||||||
|
import parallelIcon from '@/assets/images/workflow/parallel.png';
|
||||||
|
import aggregatorIcon from '@/assets/images/workflow/aggregator.png';
|
||||||
|
import httpRequestIcon from '@/assets/images/workflow/http_request.png';
|
||||||
|
import toolsIcon from '@/assets/images/workflow/tools.png';
|
||||||
|
import codeExecutionIcon from '@/assets/images/workflow/code_execution.png';
|
||||||
|
import templateRenderingIcon from '@/assets/images/workflow/template_rendering.png';
|
||||||
|
import sensitiveDetectionIcon from '@/assets/images/workflow/sensitive_detection.png';
|
||||||
|
import outputAuditIcon from '@/assets/images/workflow/output_audit.png';
|
||||||
|
import selfOptimizationIcon from '@/assets/images/workflow/self_optimization.png';
|
||||||
|
import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png';
|
||||||
|
|
||||||
|
import { getModelListUrl } from '@/api/models'
|
||||||
|
import type { NodeLibrary } from './types'
|
||||||
|
|
||||||
|
export const nodeLibrary: NodeLibrary[] = [
|
||||||
|
{
|
||||||
|
category: "coreNode",
|
||||||
|
nodes: [
|
||||||
|
{ type: "start", icon: startIcon,
|
||||||
|
config: {
|
||||||
|
variables: {
|
||||||
|
type: 'define',
|
||||||
|
sys: [
|
||||||
|
{
|
||||||
|
name: "message",
|
||||||
|
type: "string",
|
||||||
|
readonly: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "conversation_id",
|
||||||
|
type: "string",
|
||||||
|
readonly: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "execution_id",
|
||||||
|
type: "string",
|
||||||
|
readonly: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "workspace_id",
|
||||||
|
type: "string",
|
||||||
|
readonly: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "user_id",
|
||||||
|
type: "string",
|
||||||
|
readonly: true
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultValue: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "end", icon: endIcon,
|
||||||
|
config: {
|
||||||
|
output: {
|
||||||
|
type: 'textarea'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// { type: "answer", icon: answerIcon },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "aiAndCognitiveProcessing",
|
||||||
|
nodes: [
|
||||||
|
{ type: "llm", icon: llmIcon,
|
||||||
|
config: {
|
||||||
|
model_id: {
|
||||||
|
type: 'customSelect',
|
||||||
|
url: getModelListUrl,
|
||||||
|
params: { type: 'llm,chat' }, // llm/chat
|
||||||
|
valueKey: 'id',
|
||||||
|
labelKey: 'name',
|
||||||
|
},
|
||||||
|
temperature: {
|
||||||
|
type: 'slider',
|
||||||
|
max: 2,
|
||||||
|
min: 0,
|
||||||
|
step: 0.1,
|
||||||
|
defaultValue: 0.7
|
||||||
|
},
|
||||||
|
max_tokens: {
|
||||||
|
type: 'slider',
|
||||||
|
max: 32000,
|
||||||
|
min: 256,
|
||||||
|
step: 1,
|
||||||
|
defaultValue: 2000
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
type: 'define',
|
||||||
|
defaultValue: [
|
||||||
|
{
|
||||||
|
role: 'SYSTEM',
|
||||||
|
content: undefined,
|
||||||
|
readonly: true
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// { type: "model_selection", icon: modelSelectionIcon },
|
||||||
|
// { type: "model_voting", icon: modelVotingIcon },
|
||||||
|
// { type: "rag", icon: ragIcon },
|
||||||
|
// { type: "classification", icon: classificationIcon },
|
||||||
|
// { type: "parameter_extraction", icon: parameterExtractionIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
{
|
||||||
|
category: "cognitiveUpgrading",
|
||||||
|
nodes: [
|
||||||
|
{ type: "task_planning", icon: taskPlanningIcon },
|
||||||
|
{ type: "reasoning_control", icon: reasoningControlIcon },
|
||||||
|
{ type: "self_reflection", icon: selfReflectionIcon },
|
||||||
|
{ type: "memory_enhancement", icon: memoryEnhancementIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "agentCollaborationNode",
|
||||||
|
nodes: [
|
||||||
|
{ type: "agent_scheduling", icon: agentSchedulingIcon },
|
||||||
|
{ type: "agent_collaboration", icon: agentCollaborationIcon },
|
||||||
|
{ type: "agent_arbitration", icon: agentArbitrationIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "flowControl",
|
||||||
|
nodes: [
|
||||||
|
{ type: "condition", icon: conditionIcon },
|
||||||
|
{ type: "iteration", icon: iterationIcon },
|
||||||
|
{ type: "loop", icon: loopIcon },
|
||||||
|
{ type: "parallel", icon: parallelIcon },
|
||||||
|
{ type: "aggregator", icon: aggregatorIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "externalInteraction",
|
||||||
|
nodes: [
|
||||||
|
{ type: "http_request", icon: httpRequestIcon },
|
||||||
|
{ type: "tools", icon: toolsIcon },
|
||||||
|
{ type: "code_execution", icon: codeExecutionIcon },
|
||||||
|
{ type: "template_rendering", icon: templateRenderingIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "safetyAndCompliance",
|
||||||
|
nodes: [
|
||||||
|
{ type: "sensitive_detection", icon: sensitiveDetectionIcon },
|
||||||
|
{ type: "output_audit", icon: outputAuditIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: "evolutionAndGovernance",
|
||||||
|
nodes: [
|
||||||
|
{ type: "self_optimization", icon: selfOptimizationIcon },
|
||||||
|
{ type: "process_evolution", icon: processEvolutionIcon }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
];
|
||||||
|
|
||||||
|
// 节点注册库
|
||||||
|
export const nodeRegisterLibrary: ReactShapeConfig[] = [
|
||||||
|
{
|
||||||
|
shape: 'loop-node',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
component: LoopNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shape: 'iteration-node',
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
component: IterationNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shape: 'normal-node',
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
component: NormalNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shape: 'condition-node',
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
component: ConditionNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shape: 'group-start-node',
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
component: GroupStartNode,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
shape: 'add-node',
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
component: AddNode,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PortsConfig {
|
||||||
|
groups?: GroupMetadata;
|
||||||
|
items?: PortMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeConfig {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
shape: string;
|
||||||
|
ports?: PortsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const portAttrs = {
|
||||||
|
circle: {
|
||||||
|
r: 4, magnet: true, stroke: '#155EEF', strokeWidth: 2, fill: '#155EEF',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const defaultPortGroups = {
|
||||||
|
// top: { position: 'top', attrs: portAttrs },
|
||||||
|
right: { position: 'right', attrs: portAttrs },
|
||||||
|
// bottom: { position: 'bottom', attrs: portAttrs },
|
||||||
|
left: { position: 'left', attrs: portAttrs },
|
||||||
|
}
|
||||||
|
const defaultPortItems = [
|
||||||
|
// { group: 'top' },
|
||||||
|
{ group: 'right' },
|
||||||
|
// { group: 'bottom' },
|
||||||
|
{ group: 'left' }
|
||||||
|
];
|
||||||
|
export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||||
|
iteration: {
|
||||||
|
width: 240,
|
||||||
|
height: 200,
|
||||||
|
shape: 'iteration-node',
|
||||||
|
ports: {
|
||||||
|
groups: defaultPortGroups,
|
||||||
|
items: defaultPortItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
loop: {
|
||||||
|
width: 240,
|
||||||
|
height: 200,
|
||||||
|
shape: 'loop-node',
|
||||||
|
ports: {
|
||||||
|
groups: defaultPortGroups,
|
||||||
|
items: defaultPortItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
condition: {
|
||||||
|
width: 240,
|
||||||
|
height: 200,
|
||||||
|
shape: 'condition-node',
|
||||||
|
ports: {
|
||||||
|
groups: defaultPortGroups,
|
||||||
|
items: [
|
||||||
|
{ group: 'left' },
|
||||||
|
{ group: 'right', id: 'if_1', attrs: {text: { text: 'IF' }} },
|
||||||
|
{ group: 'right', id: 'else_2', attrs: {text: { text: 'ELSE' }} }
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
start: {
|
||||||
|
width: 240,
|
||||||
|
height: 64,
|
||||||
|
shape: 'normal-node',
|
||||||
|
ports: {
|
||||||
|
groups: {right: { position: 'right', attrs: portAttrs }},
|
||||||
|
items: [{ group: 'right' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
end: {
|
||||||
|
width: 240,
|
||||||
|
height: 64,
|
||||||
|
shape: 'normal-node',
|
||||||
|
ports: {
|
||||||
|
groups: {left: { position: 'left', attrs: portAttrs }},
|
||||||
|
items: [{ group: 'left' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
width: 240,
|
||||||
|
height: 64,
|
||||||
|
shape: 'normal-node',
|
||||||
|
ports: {
|
||||||
|
groups: defaultPortGroups,
|
||||||
|
items: defaultPortItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
groupStart: {
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
shape: 'group-start-node',
|
||||||
|
ports: {
|
||||||
|
groups: {right: { position: 'right', attrs: portAttrs }},
|
||||||
|
items: [{ group: 'right' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
addStart: {
|
||||||
|
width: 80,
|
||||||
|
height: 40,
|
||||||
|
shape: 'add-node',
|
||||||
|
ports: {
|
||||||
|
groups: {left: { position: 'left', attrs: portAttrs }},
|
||||||
|
items: [{ group: 'left' }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
741
web/src/views/Workflow/hooks/useWorkflowGraph.ts
Normal file
@@ -0,0 +1,741 @@
|
|||||||
|
import { useRef, useEffect, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { App } from 'antd'
|
||||||
|
import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '@antv/x6';
|
||||||
|
import { register } from '@antv/x6-react-shape';
|
||||||
|
|
||||||
|
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary } from '../constant';
|
||||||
|
import type { WorkflowConfig, NodeProperties } from '../types';
|
||||||
|
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||||
|
|
||||||
|
export interface UseWorkflowGraphProps {
|
||||||
|
containerRef: React.RefObject<HTMLDivElement>;
|
||||||
|
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseWorkflowGraphReturn {
|
||||||
|
config: WorkflowConfig | null;
|
||||||
|
graphRef: React.MutableRefObject<Graph | undefined>;
|
||||||
|
selectedNode: Node | null;
|
||||||
|
setSelectedNode: React.Dispatch<React.SetStateAction<Node | null>>;
|
||||||
|
zoomLevel: number;
|
||||||
|
setZoomLevel: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
isHandMode: boolean;
|
||||||
|
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
onDrop: (event: React.DragEvent) => void;
|
||||||
|
blankClick: () => void;
|
||||||
|
deleteEvent: () => boolean | void;
|
||||||
|
copyEvent: () => boolean | void;
|
||||||
|
parseEvent: () => boolean | void;
|
||||||
|
handleSave: (flag?: boolean) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const edge_color = '#155EEF';
|
||||||
|
const edge_selected_color = '#4DA8FF'
|
||||||
|
|
||||||
|
export const useWorkflowGraph = ({
|
||||||
|
containerRef,
|
||||||
|
miniMapRef,
|
||||||
|
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const graphRef = useRef<Graph>();
|
||||||
|
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
const historyRef = useRef<{ undoStack: string[], redoStack: string[] }>({ undoStack: [], redoStack: [] });
|
||||||
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
const [isHandMode, setIsHandMode] = useState(false);
|
||||||
|
const [config, setConfig] = useState<WorkflowConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getConfig()
|
||||||
|
}, [id])
|
||||||
|
const getConfig = () => {
|
||||||
|
if (!id) return
|
||||||
|
getWorkflowConfig(id)
|
||||||
|
.then(res => {
|
||||||
|
setConfig(res as WorkflowConfig)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initWorkflow()
|
||||||
|
}, [config, graphRef.current])
|
||||||
|
|
||||||
|
const initWorkflow = () => {
|
||||||
|
if (!config || !graphRef.current) return
|
||||||
|
const { nodes, edges } = config
|
||||||
|
|
||||||
|
if (nodes.length) {
|
||||||
|
const nodeList = nodes.map(node => {
|
||||||
|
const { id, type, name, position, config } = node
|
||||||
|
let nodeLibraryConfig = [...nodeLibrary]
|
||||||
|
.flatMap(category => category.nodes)
|
||||||
|
.find(n => n.type === type)
|
||||||
|
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||||
|
|
||||||
|
if (nodeLibraryConfig?.config) {
|
||||||
|
Object.keys(nodeLibraryConfig.config).forEach(key => {
|
||||||
|
if (nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
|
||||||
|
nodeLibraryConfig.config[key].defaultValue = config[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const nodeConfig = {
|
||||||
|
...(graphNodeLibrary[type] ?? graphNodeLibrary.default),
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
name,
|
||||||
|
data: { ...node, ...nodeLibraryConfig},
|
||||||
|
...position,
|
||||||
|
}
|
||||||
|
return nodeConfig
|
||||||
|
})
|
||||||
|
graphRef.current?.addNodes(nodeList)
|
||||||
|
}
|
||||||
|
if (edges.length) {
|
||||||
|
const edgeList = edges.map(edge => {
|
||||||
|
const { source, target } = edge
|
||||||
|
const sourceCell = graphRef.current?.getCellById(source)
|
||||||
|
const targetCell = graphRef.current?.getCellById(target)
|
||||||
|
|
||||||
|
if (sourceCell && targetCell) {
|
||||||
|
const sourcePorts = (sourceCell as Node).getPorts()
|
||||||
|
const targetPorts = (targetCell as Node).getPorts()
|
||||||
|
|
||||||
|
const edgeConfig = {
|
||||||
|
source: {
|
||||||
|
cell: sourceCell.id,
|
||||||
|
port: sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'
|
||||||
|
},
|
||||||
|
target: {
|
||||||
|
cell: targetCell.id,
|
||||||
|
port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left'
|
||||||
|
},
|
||||||
|
// label,
|
||||||
|
attrs: {
|
||||||
|
line: {
|
||||||
|
stroke: edge_color,
|
||||||
|
strokeWidth: 1,
|
||||||
|
targetMarker: {
|
||||||
|
name: 'block',
|
||||||
|
size: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return edgeConfig
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
graphRef.current.addEdges(edgeList.filter(vo => vo !== null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveState = () => {
|
||||||
|
if (!graphRef.current) return;
|
||||||
|
const state = JSON.stringify(graphRef.current.toJSON());
|
||||||
|
historyRef.current.undoStack.push(state);
|
||||||
|
historyRef.current.redoStack = [];
|
||||||
|
if (historyRef.current.undoStack.length > 50) {
|
||||||
|
historyRef.current.undoStack.shift();
|
||||||
|
}
|
||||||
|
updateHistoryState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateHistoryState = () => {
|
||||||
|
setCanUndo(historyRef.current.undoStack.length > 1);
|
||||||
|
setCanRedo(historyRef.current.redoStack.length > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 撤销
|
||||||
|
const onUndo = () => {
|
||||||
|
if (!graphRef.current || historyRef.current.undoStack.length === 0) return;
|
||||||
|
const { undoStack = [], redoStack = [] } = historyRef.current
|
||||||
|
|
||||||
|
const currentState = JSON.stringify(graphRef.current.toJSON());
|
||||||
|
const prevState = undoStack[undoStack.length - 2];
|
||||||
|
|
||||||
|
historyRef.current.redoStack = [...redoStack, currentState]
|
||||||
|
historyRef.current.undoStack = undoStack.slice(0, undoStack.length - 1)
|
||||||
|
graphRef.current.fromJSON(JSON.parse(prevState));
|
||||||
|
updateHistoryState();
|
||||||
|
};
|
||||||
|
// 重做
|
||||||
|
const onRedo = () => {
|
||||||
|
if (!graphRef.current || historyRef.current.redoStack.length === 0) return;
|
||||||
|
const { undoStack = [], redoStack = [] } = historyRef.current
|
||||||
|
|
||||||
|
const nextState = redoStack[redoStack.length - 1];
|
||||||
|
|
||||||
|
historyRef.current.undoStack = [...undoStack, nextState]
|
||||||
|
historyRef.current.redoStack = redoStack.slice(0, redoStack.length - 1)
|
||||||
|
graphRef.current.fromJSON(JSON.parse(nextState));
|
||||||
|
updateHistoryState();
|
||||||
|
};
|
||||||
|
// 使用插件
|
||||||
|
const setupPlugins = () => {
|
||||||
|
if (!graphRef.current || !miniMapRef.current) return;
|
||||||
|
// 添加小地图
|
||||||
|
graphRef.current.use(
|
||||||
|
new MiniMap({
|
||||||
|
container: miniMapRef.current,
|
||||||
|
width: 100,
|
||||||
|
height: 80,
|
||||||
|
padding: 5,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
graphRef.current.use(
|
||||||
|
new Snapline({
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
graphRef.current.use(
|
||||||
|
new Clipboard({
|
||||||
|
enabled: true,
|
||||||
|
useLocalStorage: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
graphRef.current.use(
|
||||||
|
new Keyboard({
|
||||||
|
enabled: true,
|
||||||
|
global: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// 显示/隐藏连接桩
|
||||||
|
const showPorts = (show: boolean) => {
|
||||||
|
const container = containerRef.current!;
|
||||||
|
const ports = container.querySelectorAll('.x6-port-body') as NodeListOf<SVGElement>;
|
||||||
|
for (let i = 0, len = ports.length; i < len; i += 1) {
|
||||||
|
ports[i].style.visibility = show ? 'visible' : 'hidden';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 节点选择事件
|
||||||
|
const nodeClick = ({ node }: { node: Node }) => {
|
||||||
|
const nodes = graphRef.current?.getNodes();
|
||||||
|
|
||||||
|
nodes?.forEach(vo => {
|
||||||
|
const data = vo.getData();
|
||||||
|
if (data.isSelected) {
|
||||||
|
vo.setData({
|
||||||
|
...data,
|
||||||
|
isSelected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
node.setData({
|
||||||
|
...node.getData(),
|
||||||
|
isSelected: true,
|
||||||
|
});
|
||||||
|
setSelectedNode(node);
|
||||||
|
};
|
||||||
|
// 连线选择事件
|
||||||
|
const edgeClick = ({ edge }: { edge: Edge }) => {
|
||||||
|
edge.setAttrByPath('line/stroke', edge_selected_color);
|
||||||
|
clearNodeSelect();
|
||||||
|
};
|
||||||
|
// 清空选中节点
|
||||||
|
const clearNodeSelect = () => {
|
||||||
|
const nodes = graphRef.current?.getNodes();
|
||||||
|
|
||||||
|
nodes?.forEach(node => {
|
||||||
|
const data = node.getData();
|
||||||
|
if (data.isSelected) {
|
||||||
|
node.setData({
|
||||||
|
...data,
|
||||||
|
isSelected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setSelectedNode(null);
|
||||||
|
};
|
||||||
|
// 清空选中连线
|
||||||
|
const clearEdgeSelect = () => {
|
||||||
|
graphRef.current?.getEdges().forEach(e => {
|
||||||
|
e.setAttrByPath('line/stroke', edge_color);
|
||||||
|
e.setAttrByPath('line/strokeWidth', 1);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
// 画布点击事件,取消选择
|
||||||
|
const blankClick = () => {
|
||||||
|
clearNodeSelect();
|
||||||
|
clearEdgeSelect();
|
||||||
|
graphRef.current?.cleanSelection();
|
||||||
|
};
|
||||||
|
// 画布缩放事件
|
||||||
|
const scaleEvent = ({ sx }: { sx: number }) => {
|
||||||
|
setZoomLevel(sx);
|
||||||
|
};
|
||||||
|
// 节点移动事件
|
||||||
|
const nodeMoved = ({ node }: { node: Node }) => {
|
||||||
|
const parentId = node.getData()?.parentId;
|
||||||
|
if (parentId) {
|
||||||
|
const parentNode = graphRef.current!.getNodes().find(n => n.id === parentId);
|
||||||
|
if (parentNode?.getData()?.isGroup) {
|
||||||
|
// 获取父节点和子节点的边界框
|
||||||
|
const parentBBox = parentNode.getBBox();
|
||||||
|
const childBBox = node.getBBox();
|
||||||
|
|
||||||
|
// 计算父节点的内边距
|
||||||
|
const padding = 24;
|
||||||
|
const headerHeight = 50;
|
||||||
|
|
||||||
|
// 计算子节点允许的最小和最大位置
|
||||||
|
const minX = parentBBox.x + padding;
|
||||||
|
const minY = parentBBox.y + padding + headerHeight;
|
||||||
|
const maxX = parentBBox.x + parentBBox.width - padding - childBBox.width;
|
||||||
|
const maxY = parentBBox.y + parentBBox.height - padding - childBBox.height;
|
||||||
|
|
||||||
|
// 限制子节点在父节点内移动
|
||||||
|
let newX = childBBox.x;
|
||||||
|
let newY = childBBox.y;
|
||||||
|
|
||||||
|
if (newX < minX) newX = minX;
|
||||||
|
if (newY < minY) newY = minY;
|
||||||
|
if (newX > maxX) newX = maxX;
|
||||||
|
if (newY > maxY) newY = maxY;
|
||||||
|
|
||||||
|
// 如果子节点位置被限制,更新其位置
|
||||||
|
if (newX !== childBBox.x || newY !== childBBox.y) {
|
||||||
|
node.setPosition(newX, newY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 复制快捷键事件
|
||||||
|
const copyEvent = () => {
|
||||||
|
if (!graphRef.current) return false;
|
||||||
|
const selectedNodes = graphRef.current.getNodes().filter(node => node.getData()?.isSelected);
|
||||||
|
if (selectedNodes.length) {
|
||||||
|
graphRef.current.copy(selectedNodes);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// 粘贴快捷键事件
|
||||||
|
const parseEvent = () => {
|
||||||
|
if (!graphRef.current?.isClipboardEmpty()) {
|
||||||
|
graphRef.current?.paste({ offset: 32 });
|
||||||
|
blankClick();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// 撤销快捷键事件
|
||||||
|
const undoEvent = () => {
|
||||||
|
if (canUndo) {
|
||||||
|
onUndo();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// 重做快捷键事件
|
||||||
|
const redoEvent = () => {
|
||||||
|
if (canRedo) {
|
||||||
|
onRedo();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// 删除选中的节点和连线事件
|
||||||
|
const deleteEvent = () => {
|
||||||
|
if (!graphRef.current) return;
|
||||||
|
const nodes = graphRef.current?.getNodes();
|
||||||
|
const edges = graphRef.current?.getEdges();
|
||||||
|
const cells: (Node | Edge)[] = [];
|
||||||
|
const nodesToDelete: Node[] = [];
|
||||||
|
const parentNodesToUpdate: Node[] = [];
|
||||||
|
|
||||||
|
// 首先收集所有选中的节点,但排除默认子节点
|
||||||
|
nodes?.forEach(node => {
|
||||||
|
const data = node.getData();
|
||||||
|
// 如果节点是默认子节点,不允许单独删除
|
||||||
|
if (data.isSelected && !data.isDefault) {
|
||||||
|
nodesToDelete.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 收集与选中节点相关的连线
|
||||||
|
edges?.forEach(edge => {
|
||||||
|
const attrs = edge.getAttrs()
|
||||||
|
if (attrs.line.stroke === edge_selected_color) {
|
||||||
|
cells.push(edge)
|
||||||
|
}
|
||||||
|
const sourceId = edge.getSourceCellId();
|
||||||
|
const targetId = edge.getTargetCellId();
|
||||||
|
if (sourceId && targetId) {
|
||||||
|
const sourceNode = nodes?.find(n => n.id === sourceId);
|
||||||
|
const targetNode = nodes?.find(n => n.id === targetId);
|
||||||
|
if (sourceNode?.getData()?.isSelected || targetNode?.getData()?.isSelected) {
|
||||||
|
cells.push(edge);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 对于每个选中的节点
|
||||||
|
if (nodesToDelete.length > 0) {
|
||||||
|
nodesToDelete.forEach(nodeToDelete => {
|
||||||
|
// 检查是否为子节点
|
||||||
|
const nodeData = nodeToDelete.getData();
|
||||||
|
if (nodeData.parentId) {
|
||||||
|
// 找到对应的父节点
|
||||||
|
const parentNode = nodes?.find(n => n.id === nodeData.parentId);
|
||||||
|
if (parentNode) {
|
||||||
|
// 使用removeChild方法删除子节点
|
||||||
|
parentNode.removeChild(nodeToDelete);
|
||||||
|
parentNodesToUpdate.push(parentNode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 检查是否为 LoopNode、IterationNode 或 SubGraphNode
|
||||||
|
else if (nodeToDelete.shape === 'loop-node' || nodeToDelete.shape === 'iteration-node' || nodeToDelete.shape === 'subgraph-node') {
|
||||||
|
// 查找所有 parentId 为当前节点 id 的子节点
|
||||||
|
nodes?.forEach(node => {
|
||||||
|
const data = node.getData();
|
||||||
|
if (data.parentId === nodeToDelete.id) {
|
||||||
|
cells.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 添加父节点到删除列表
|
||||||
|
cells.push(nodeToDelete);
|
||||||
|
}
|
||||||
|
// 普通节点
|
||||||
|
else {
|
||||||
|
cells.push(nodeToDelete);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
blankClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除所有收集的节点和连线
|
||||||
|
if (cells.length > 0) {
|
||||||
|
graphRef.current?.removeCells(cells);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 调整画布大小
|
||||||
|
const handleResize = () => {
|
||||||
|
if (containerRef.current && graphRef.current) {
|
||||||
|
graphRef.current.resize(containerRef.current.offsetWidth, containerRef.current.offsetHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
const init = () => {
|
||||||
|
if (!containerRef.current || !miniMapRef.current) return;
|
||||||
|
|
||||||
|
// 注册React形状
|
||||||
|
nodeRegisterLibrary.forEach((item) => {
|
||||||
|
register(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = containerRef.current;
|
||||||
|
graphRef.current = new Graph({
|
||||||
|
container,
|
||||||
|
background: {
|
||||||
|
color: '#F0F3F8',
|
||||||
|
},
|
||||||
|
// width: container.clientWidth || 800,
|
||||||
|
// height: container.clientHeight || 600,
|
||||||
|
autoResize: true,
|
||||||
|
grid: {
|
||||||
|
visible: true,
|
||||||
|
type: 'dot',
|
||||||
|
size: 10,
|
||||||
|
args: {
|
||||||
|
color: '#939AB1', // 网点颜色
|
||||||
|
thickness: 1, // 网点大小
|
||||||
|
}
|
||||||
|
},
|
||||||
|
panning: false,
|
||||||
|
mousewheel: {
|
||||||
|
enabled: true,
|
||||||
|
modifiers: ['ctrl', 'meta'],
|
||||||
|
},
|
||||||
|
connecting: {
|
||||||
|
// router: 'orth',
|
||||||
|
// router: 'manhattan',
|
||||||
|
connector: {
|
||||||
|
name: 'rounded',
|
||||||
|
args: {
|
||||||
|
radius: 8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
anchor: 'center',
|
||||||
|
connectionPoint: 'anchor',
|
||||||
|
allowBlank: false,
|
||||||
|
allowNode: false,
|
||||||
|
allowEdge: false,
|
||||||
|
highlight: true,
|
||||||
|
snap: {
|
||||||
|
radius: 20,
|
||||||
|
},
|
||||||
|
createEdge() {
|
||||||
|
return graphRef.current?.createEdge({
|
||||||
|
attrs: {
|
||||||
|
line: {
|
||||||
|
stroke: edge_color,
|
||||||
|
strokeWidth: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
zIndex: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
validateConnection({ sourceCell, targetCell, targetMagnet }) {
|
||||||
|
if (!targetMagnet) return false;
|
||||||
|
|
||||||
|
const sourceType = sourceCell?.getData()?.type;
|
||||||
|
const targetType = targetCell?.getData()?.type;
|
||||||
|
|
||||||
|
// 开始节点不能作为连线的终点
|
||||||
|
if (targetType === 'start') return false;
|
||||||
|
|
||||||
|
// 结束节点不能作为连线的起点
|
||||||
|
if (sourceType === 'end') return false;
|
||||||
|
|
||||||
|
// 获取源节点和目标节点的父节点ID
|
||||||
|
const sourceParentId = sourceCell?.getData()?.parentId;
|
||||||
|
const targetParentId = targetCell?.getData()?.parentId;
|
||||||
|
|
||||||
|
// 验证父子节点关系:
|
||||||
|
// 1. 如果两个节点都有父节点ID,必须相同才能连线
|
||||||
|
// 2. 如果一个有父节点ID,另一个没有,不能连线
|
||||||
|
// 3. 如果两个都没有父节点ID,可以正常连线
|
||||||
|
if (sourceParentId && targetParentId) {
|
||||||
|
// 同一父节点下的子节点可以互相连线
|
||||||
|
return sourceParentId === targetParentId;
|
||||||
|
} else if (sourceParentId || targetParentId) {
|
||||||
|
// 一个有父节点,一个没有,不能连线
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
enabled: true,
|
||||||
|
validate (this, { parent }) {
|
||||||
|
const parentData = parent.getData()
|
||||||
|
return parentData.type === 'iteration' || parentData.type === 'loop'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
translating: {
|
||||||
|
restrict(view) {
|
||||||
|
if (!view) return null
|
||||||
|
const cell = view.cell
|
||||||
|
if (cell.isNode()) {
|
||||||
|
const parent = cell.getParent()
|
||||||
|
if (parent) {
|
||||||
|
return parent.getBBox()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 使用插件
|
||||||
|
setupPlugins();
|
||||||
|
// 监听连线mouseleave事件
|
||||||
|
graphRef.current.on('edge:mouseleave', ({ edge }: { edge: Edge }) => {
|
||||||
|
if (edge.getAttrByPath('line/stroke') !== edge_selected_color) {
|
||||||
|
edge.setAttrByPath('line/stroke', edge_color);
|
||||||
|
edge.setAttrByPath('line/strokeWidth', 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 监听节点选择事件
|
||||||
|
graphRef.current.on('node:click', nodeClick);
|
||||||
|
// 监听连线选择事件
|
||||||
|
graphRef.current.on('edge:click', edgeClick);
|
||||||
|
// 监听画布点击事件,取消选择
|
||||||
|
graphRef.current.on('blank:click', blankClick);
|
||||||
|
// 监听缩放事件
|
||||||
|
graphRef.current.on('scale', scaleEvent);
|
||||||
|
// 监听节点移动事件
|
||||||
|
graphRef.current.on('node:moved', nodeMoved);
|
||||||
|
|
||||||
|
// 监听画布变化事件
|
||||||
|
const events = [
|
||||||
|
'node:added',
|
||||||
|
'node:removed',
|
||||||
|
'edge:added',
|
||||||
|
'edge:removed',
|
||||||
|
];
|
||||||
|
events.forEach(event => {
|
||||||
|
graphRef.current!.on(event, () => {
|
||||||
|
console.log('event', event);
|
||||||
|
setTimeout(() => saveState(), 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听撤销键盘事件
|
||||||
|
graphRef.current.bindKey(['ctrl+z', 'cmd+z'], undoEvent);
|
||||||
|
// 监听重做键盘事件
|
||||||
|
graphRef.current.bindKey(['ctrl+shift+z', 'cmd+shift+z', 'ctrl+y', 'cmd+y'], redoEvent);
|
||||||
|
// 监听复制键盘事件
|
||||||
|
graphRef.current.bindKey(['ctrl+c', 'cmd+c'], copyEvent);
|
||||||
|
// 监听粘贴键盘事件
|
||||||
|
graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent);
|
||||||
|
// 删除选中的节点和连线
|
||||||
|
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent);
|
||||||
|
|
||||||
|
// 保存初始状态
|
||||||
|
setTimeout(() => saveState(), 100);
|
||||||
|
// init window hook
|
||||||
|
(window as Window & { __x6_instances__?: Graph[] }).__x6_instances__ = [];
|
||||||
|
(window as Window & { __x6_instances__?: Graph[] }).__x6_instances__?.push(graphRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!containerRef.current || !miniMapRef.current) return;
|
||||||
|
init();
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
graphRef.current?.dispose();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onDrop = (event: React.DragEvent) => {
|
||||||
|
if (!graphRef.current) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const dragData = JSON.parse(event.dataTransfer.getData('application/json'));
|
||||||
|
const graph = graphRef.current;
|
||||||
|
if (!graph) return;
|
||||||
|
|
||||||
|
const point = graphRef.current.clientToLocal(event.clientX, event.clientY);
|
||||||
|
|
||||||
|
// 获取节点库中的原始配置,避免config数据串联
|
||||||
|
let nodeLibraryConfig = [...nodeLibrary]
|
||||||
|
.flatMap(category => category.nodes)
|
||||||
|
.find(n => n.type === dragData.type);
|
||||||
|
nodeLibraryConfig = { config: {}, ...nodeLibraryConfig } as NodeProperties;
|
||||||
|
|
||||||
|
// 创建干净的节点数据,只保留必要的字段
|
||||||
|
const cleanNodeData = {
|
||||||
|
id: `${dragData.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
name: t(`workflow.${dragData.type}`),
|
||||||
|
...nodeLibraryConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dragData.type === 'loop' || dragData.type === 'iteration') {
|
||||||
|
graphRef.current.addNode({
|
||||||
|
...graphNodeLibrary[dragData.type],
|
||||||
|
x: point.x - 150,
|
||||||
|
y: point.y - 100,
|
||||||
|
data: { ...cleanNodeData, isGroup: true },
|
||||||
|
});
|
||||||
|
} else if (dragData.type === 'condition') {
|
||||||
|
// 创建条件节点
|
||||||
|
graphRef.current.addNode({
|
||||||
|
...graphNodeLibrary[dragData.type],
|
||||||
|
x: point.x - 100,
|
||||||
|
y: point.y - 60,
|
||||||
|
data: { ...cleanNodeData, elifCount: 0 },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 检查是否放置在群组内
|
||||||
|
const groups = graphRef.current.getNodes().filter(node => {
|
||||||
|
const shape = node.shape;
|
||||||
|
return shape === 'loop-node' || shape === 'iteration-node' || shape === 'subgraph-node';
|
||||||
|
});
|
||||||
|
let parentGroup = null;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const bbox = group.getBBox();
|
||||||
|
if (point.x >= bbox.x && point.x <= bbox.x + bbox.width &&
|
||||||
|
point.y >= bbox.y && point.y <= bbox.y + bbox.height) {
|
||||||
|
parentGroup = group;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const childNode = graphRef.current.addNode({
|
||||||
|
...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default),
|
||||||
|
x: point.x - 60,
|
||||||
|
y: point.y - 20,
|
||||||
|
data: { ...cleanNodeData, parentId: parentGroup?.id },
|
||||||
|
});
|
||||||
|
parentGroup?.addChild(childNode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 保存workflow配置
|
||||||
|
const handleSave = (flag = true) => {
|
||||||
|
if (!graphRef.current || !config) return Promise.resolve()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const nodes = graphRef.current?.getNodes() || [];
|
||||||
|
const edges = graphRef.current?.getEdges() || []
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
...config,
|
||||||
|
nodes: nodes.map((node: Node) => {
|
||||||
|
const data = node.getData();
|
||||||
|
const position = node.getPosition();
|
||||||
|
const config: Record<string, any> = {}
|
||||||
|
|
||||||
|
if (data.config) {
|
||||||
|
Object.keys(data.config).forEach(key => {
|
||||||
|
if (data.config[key] && 'defaultValue' in data.config[key]) {
|
||||||
|
config[key] = data.config[key].defaultValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.id || node.id,
|
||||||
|
type: data.type,
|
||||||
|
name: data.name,
|
||||||
|
position: {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
},
|
||||||
|
config: config
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
edges: edges.map((edge: Edge) => {
|
||||||
|
return {
|
||||||
|
source: edge.getSourceCellId(),
|
||||||
|
target: edge.getTargetCellId(),
|
||||||
|
// label: edge.getAttrs()?.label?.text,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
saveWorkflowConfig(config.app_id, params as WorkflowConfig)
|
||||||
|
.then(() => {
|
||||||
|
if (flag) {
|
||||||
|
message.success(t('common.saveSuccess'))
|
||||||
|
}
|
||||||
|
resolve(true)
|
||||||
|
}).catch(error => {
|
||||||
|
reject(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
graphRef,
|
||||||
|
selectedNode,
|
||||||
|
setSelectedNode,
|
||||||
|
zoomLevel,
|
||||||
|
setZoomLevel,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
isHandMode,
|
||||||
|
setIsHandMode,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onDrop,
|
||||||
|
blankClick,
|
||||||
|
deleteEvent,
|
||||||
|
copyEvent,
|
||||||
|
parseEvent,
|
||||||
|
handleSave
|
||||||
|
};
|
||||||
|
};
|
||||||
110
web/src/views/Workflow/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { forwardRef, useRef, useImperativeHandle, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import NodeLibrary from './components/NodeLibrary'
|
||||||
|
import Properties from './components/Properties';
|
||||||
|
import CanvasToolbar from './components/CanvasToolbar';
|
||||||
|
import { useWorkflowGraph } from './hooks/useWorkflowGraph';
|
||||||
|
import type { WorkflowRef } from '@/views/ApplicationConfig/types'
|
||||||
|
import Chat from './components/Chat/Chat';
|
||||||
|
import type { ChatRef } from './types'
|
||||||
|
import arrowIcon from '@/assets/images/workflow/arrow.png'
|
||||||
|
|
||||||
|
const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const miniMapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const chatRef = useRef<ChatRef>(null)
|
||||||
|
const [collapsed, setCollapsed] = useState(false)
|
||||||
|
// 使用自定义Hook初始化工作流图
|
||||||
|
const {
|
||||||
|
config,
|
||||||
|
graphRef,
|
||||||
|
selectedNode,
|
||||||
|
setSelectedNode,
|
||||||
|
zoomLevel,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
isHandMode,
|
||||||
|
setIsHandMode,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onDrop,
|
||||||
|
blankClick,
|
||||||
|
deleteEvent,
|
||||||
|
copyEvent,
|
||||||
|
parseEvent,
|
||||||
|
handleSave
|
||||||
|
} = useWorkflowGraph({ containerRef, miniMapRef });
|
||||||
|
|
||||||
|
const onDragOver = (event: React.DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
const handleRun = () => {
|
||||||
|
chatRef.current?.handleOpen()
|
||||||
|
}
|
||||||
|
const handleToggle = () => {
|
||||||
|
setCollapsed(prev => !prev)
|
||||||
|
}
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
handleSave,
|
||||||
|
handleRun,
|
||||||
|
graphRef
|
||||||
|
}))
|
||||||
|
return (
|
||||||
|
<div className="rb:h-[calc(100vh-64px)] rb:relative">
|
||||||
|
{/* 左侧节点面板 */}
|
||||||
|
{!collapsed && <NodeLibrary />}
|
||||||
|
<img
|
||||||
|
src={arrowIcon}
|
||||||
|
className={clsx('rb:cursor-pointer rb:w-5 rb:h-10 rb:absolute rb:top-[50%] rb:z-100', {
|
||||||
|
'rb:left-0 rb:rotate-180': collapsed,
|
||||||
|
'rb:left-80': !collapsed
|
||||||
|
})}
|
||||||
|
onClick={handleToggle}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧画布区域 */}
|
||||||
|
<div
|
||||||
|
className={clsx(`rb:fixed rb:top-16 rb:bottom-0 rb:right-75 rb:flex-1 rb:border-x rb:border-[#DFE4ED] rb:transition-all`, {
|
||||||
|
'rb:left-80': !collapsed,
|
||||||
|
'rb:left-0': collapsed
|
||||||
|
})}
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
>
|
||||||
|
<div ref={containerRef} className="rb:w-full rb:h-full" />
|
||||||
|
{/* 地图工具栏 */}
|
||||||
|
<CanvasToolbar
|
||||||
|
miniMapRef={miniMapRef}
|
||||||
|
graphRef={graphRef}
|
||||||
|
isHandMode={isHandMode}
|
||||||
|
setIsHandMode={setIsHandMode}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canRedo={canRedo}
|
||||||
|
onUndo={onUndo}
|
||||||
|
onRedo={onRedo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧属性面板 */}
|
||||||
|
<Properties
|
||||||
|
selectedNode={selectedNode}
|
||||||
|
setSelectedNode={setSelectedNode}
|
||||||
|
graphRef={graphRef}
|
||||||
|
blankClick={blankClick}
|
||||||
|
deleteEvent={deleteEvent}
|
||||||
|
copyEvent={copyEvent}
|
||||||
|
parseEvent={parseEvent}
|
||||||
|
/>
|
||||||
|
<Chat
|
||||||
|
ref={chatRef}
|
||||||
|
graphRef={graphRef}
|
||||||
|
appId={config?.app_id as string}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default Workflow;
|
||||||
95
web/src/views/Workflow/types.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
|
||||||
|
import { Graph } from '@antv/x6';
|
||||||
|
export interface NodeConfig {
|
||||||
|
type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define';
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
|
||||||
|
max?: number;
|
||||||
|
min?: number;
|
||||||
|
step?: number;
|
||||||
|
|
||||||
|
url?: string;
|
||||||
|
params?: { [key: string]: unknown; }
|
||||||
|
valueKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
|
||||||
|
defaultValue?: any | StartVariableItem[];
|
||||||
|
|
||||||
|
sys?: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
readonly: boolean;
|
||||||
|
}>
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeProperties {
|
||||||
|
type: string;
|
||||||
|
icon: string;
|
||||||
|
config?: Record<string, NodeConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeLibrary {
|
||||||
|
category: string;
|
||||||
|
nodes: NodeProperties[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface NodeItem {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
position: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
config: {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export interface EdgesItem {
|
||||||
|
source: string;
|
||||||
|
target: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
export interface WorkflowConfig {
|
||||||
|
id: string;
|
||||||
|
app_id: string;
|
||||||
|
nodes: NodeItem[],
|
||||||
|
edges: EdgesItem[],
|
||||||
|
variables: Array<{
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
default: string;
|
||||||
|
}>,
|
||||||
|
execution_config: {
|
||||||
|
max_execution_time: number;
|
||||||
|
max_iterations: number;
|
||||||
|
}
|
||||||
|
triggers: any[];
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariableEditModalRef {
|
||||||
|
handleOpen: (values?: StartVariableItem) => void;
|
||||||
|
}
|
||||||
|
export interface StartVariableItem {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
max_length?: number;
|
||||||
|
default?: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
defaultValue?: any;
|
||||||
|
value?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRef {
|
||||||
|
handleOpen: () => void;
|
||||||
|
}
|
||||||
|
export type GraphRef = React.MutableRefObject<Graph | undefined>
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"]
|
||||||
@@ -28,6 +29,6 @@
|
|||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedSideEffectImports": true
|
"noUncheckedSideEffectImports": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src", "src/views/Workflow/components/Properties/.tsx"],
|
||||||
"exclude": ["**/*copy*"]
|
"exclude": ["**/*copy*"]
|
||||||
}
|
}
|
||||||
|
|||||||