diff --git a/.github/workflows/sync-to-gitee.yml b/.github/workflows/sync-to-gitee.yml index 71ddf22a..f3be5dbc 100644 --- a/.github/workflows/sync-to-gitee.yml +++ b/.github/workflows/sync-to-gitee.yml @@ -3,12 +3,9 @@ name: Sync to Gitee on: push: branches: - - main # Production - - develop # Integration - - 'release/*' # Release preparation - - 'hotfix/*' # Urgent fixes + - '**' # All branchs tags: - - '*' # All version tags (v1.0.0, etc.) + - '**' # All version tags (v1.0.0, etc.) jobs: sync: diff --git a/api/app/controllers/service/app_api_controller.py b/api/app/controllers/service/app_api_controller.py index 93e88dc5..c2755bdc 100644 --- a/api/app/controllers/service/app_api_controller.py +++ b/api/app/controllers/service/app_api_controller.py @@ -296,7 +296,7 @@ async def chat( } ) - # 多 Agent 非流式返回 + # workflow 非流式返回 result = await app_chat_service.workflow_chat( message=payload.message, diff --git a/api/app/controllers/workspace_controller.py b/api/app/controllers/workspace_controller.py index 47068288..abe43593 100644 --- a/api/app/controllers/workspace_controller.py +++ b/api/app/controllers/workspace_controller.py @@ -221,7 +221,7 @@ def update_workspace_members( @router.delete("/members/{member_id}", response_model=ApiResponse) @cur_workspace_access_guard() -def delete_workspace_member( +async def delete_workspace_member( member_id: uuid.UUID, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), @@ -230,7 +230,7 @@ def delete_workspace_member( workspace_id = current_user.current_workspace_id api_logger.info(f"用户 {current_user.username} 请求删除工作空间 {workspace_id} 的成员 {member_id}") - workspace_service.delete_workspace_member( + await workspace_service.delete_workspace_member( db=db, workspace_id=workspace_id, member_id=member_id, diff --git a/api/app/core/config.py b/api/app/core/config.py index 64c5520e..56a07f3f 100644 --- a/api/app/core/config.py +++ b/api/app/core/config.py @@ -241,6 +241,8 @@ class Settings: SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) SMTP_USER: str = os.getenv("SMTP_USER", "") SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") + + SANDBOX_URL: str = os.getenv("SANDBOX_URL", "") REFLECTION_INTERVAL_SECONDS: float = float(os.getenv("REFLECTION_INTERVAL_SECONDS", "300")) HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600")) diff --git a/api/app/core/models/base.py b/api/app/core/models/base.py index 86ac5fe0..6847a880 100644 --- a/api/app/core/models/base.py +++ b/api/app/core/models/base.py @@ -216,7 +216,7 @@ class RedBearModelFactory: # 深度思考模式:Claude 3.7 Sonnet 等支持思考的模型 # 通过 additional_model_request_fields 传递 thinking 块,关闭时不传(Bedrock 无 disabled 选项) if config.deep_thinking: - budget = config.thinking_budget_tokens or 10000 + budget = config.thinking_budget_tokens or 1024 params["additional_model_request_fields"] = { "thinking": {"type": "enabled", "budget_tokens": budget} } diff --git a/api/app/core/workflow/nodes/code/node.py b/api/app/core/workflow/nodes/code/node.py index 69c660fe..d715be7d 100644 --- a/api/app/core/workflow/nodes/code/node.py +++ b/api/app/core/workflow/nodes/code/node.py @@ -14,6 +14,7 @@ from app.core.workflow.engine.variable_pool import VariablePool from app.core.workflow.nodes import BaseNode from app.core.workflow.nodes.code.config import CodeNodeConfig from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE +from app.core.config import settings logger = logging.getLogger(__name__) @@ -131,7 +132,7 @@ class CodeNode(BaseNode): async with httpx.AsyncClient(timeout=60) as client: response = await client.post( - "http://sandbox:8194/v1/sandbox/run", + f"{settings.SANDBOX_URL}:8194/v1/sandbox/run", headers={ "x-api-key": 'redbear-sandbox' }, diff --git a/api/app/core/workflow/nodes/document_extractor/node.py b/api/app/core/workflow/nodes/document_extractor/node.py index ea1070f4..5fefbc94 100644 --- a/api/app/core/workflow/nodes/document_extractor/node.py +++ b/api/app/core/workflow/nodes/document_extractor/node.py @@ -182,7 +182,7 @@ class DocExtractorNode(BaseNode): mime_type=f"image/{ext}", is_file=True, ).model_dump()) - text = text + f"\n{placeholder}: {url}" + text = text + f"\n{placeholder}: " except Exception as e: logger.error(f"Node {self.node_id}: failed to save image {placeholder}: {e}") diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 89603322..7facf381 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -250,7 +250,7 @@ class ModelParameters(BaseModel): n: int = Field(default=1, ge=1, le=10, description="生成的回复数量") stop: Optional[List[str]] = Field(default=None, description="停止序列") deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)") - thinking_budget_tokens: Optional[int] = Field(default=None, ge=1024, le=131072, description="深度思考 token 预算(仅部分模型支持)") + thinking_budget_tokens: Optional[int] = Field(default=None, ge=1, le=131072, description="深度思考 token 预算(仅部分模型支持)") json_output: bool = Field(default=False, description="是否强制 JSON 格式输出(需模型支持 json_output 能力)") diff --git a/api/app/services/app_chat_service.py b/api/app/services/app_chat_service.py index 12f54c03..cc2b02f1 100644 --- a/api/app/services/app_chat_service.py +++ b/api/app/services/app_chat_service.py @@ -161,7 +161,10 @@ class AppChatService: f.type == FileType.DOCUMENT for f in files ): system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) # 创建 LangChain Agent @@ -448,7 +451,10 @@ class AppChatService: ): from langchain.agents import create_agent system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) # 创建 LangChain Agent diff --git a/api/app/services/draft_run_service.py b/api/app/services/draft_run_service.py index 2566a50f..16d856ca 100644 --- a/api/app/services/draft_run_service.py +++ b/api/app/services/draft_run_service.py @@ -650,7 +650,10 @@ class AgentRunService: ) if has_doc_with_images: system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) agent = LangChainAgent( @@ -924,7 +927,10 @@ class AgentRunService: ) if has_doc_with_images: system_prompt += ( - "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: ," + "请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" + "重要:图片 URL 中包含 UUID(如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)," + "必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。" ) # 创建 LangChain Agent diff --git a/api/app/services/multimodal_service.py b/api/app/services/multimodal_service.py index c362158c..dd021357 100644 --- a/api/app/services/multimodal_service.py +++ b/api/app/services/multimodal_service.py @@ -400,7 +400,7 @@ class MultimodalService: # 在文本内容中追加图片位置标记 if result and result[-1].get("type") in ("text", "document"): key = "text" if "text" in result[-1] else list(result[-1].keys())[-1] - result[-1][key] = result[-1].get(key, "") + f"\n[图片 {placeholder}]: {img_url}" + result[-1][key] = result[-1].get(key, "") + f"\n[图片 {placeholder}]: " # 将图片以视觉格式追加到消息内容中 img_file = FileInput( type=FileType.IMAGE, diff --git a/api/app/services/workflow_service.py b/api/app/services/workflow_service.py index b35656d9..27327e99 100644 --- a/api/app/services/workflow_service.py +++ b/api/app/services/workflow_service.py @@ -554,13 +554,16 @@ class WorkflowService: } } case "workflow_end": + data = { + "elapsed_time": payload.get("elapsed_time"), + "message_length": len(payload.get("output", "")), + "error": payload.get("error", "") + } + if "citations" in payload and payload["citations"]: + data["citations"] = payload["citations"] return { "event": "end", - "data": { - "elapsed_time": payload.get("elapsed_time"), - "message_length": len(payload.get("output", "")), - "error": payload.get("error", "") - } + "data": data } case "node_start" | "node_end" | "node_error" | "cycle_item": return None diff --git a/api/app/services/workspace_service.py b/api/app/services/workspace_service.py index 4034eb6d..db641638 100644 --- a/api/app/services/workspace_service.py +++ b/api/app/services/workspace_service.py @@ -20,6 +20,7 @@ from app.models.workspace_model import ( ) from app.repositories import workspace_repository from app.repositories.workspace_invite_repository import WorkspaceInviteRepository +from app.services.session_service import SessionService from app.schemas.workspace_schema import ( InviteAcceptRequest, InviteValidateResponse, @@ -58,7 +59,7 @@ def switch_workspace( raise BusinessException(f"切换工作空间失败: {str(e)}", BizCode.INTERNAL_ERROR) -def delete_workspace_member( +async def delete_workspace_member( db: Session, workspace_id: uuid.UUID, member_id: uuid.UUID, @@ -76,10 +77,29 @@ def delete_workspace_member( BizCode.WORKSPACE_NOT_FOUND) try: + deleted_user = workspace_member.user workspace_member.is_active = False - workspace_member.user.current_workspace_id = None + deleted_user.current_workspace_id = None + + # 若被删除成员不是超级管理员且没有其他可用工作空间,则禁用该用户 + if not deleted_user.is_superuser: + remaining = ( + db.query(WorkspaceMember) + .filter( + WorkspaceMember.user_id == deleted_user.id, + WorkspaceMember.workspace_id != workspace_id, + WorkspaceMember.is_active.is_(True), + ) + .count() + ) + if remaining == 0: + deleted_user.is_active = False + db.commit() business_logger.info(f"用户 {user.username} 成功删除工作空间 {workspace_id} 的成员 {member_id}") + + # 使被删除成员的所有 token 立即失效 + await SessionService.invalidate_all_user_tokens(str(workspace_member.user_id)) except Exception as e: db.rollback() business_logger.error(f"删除工作空间成员失败 - 工作空间: {workspace_id}, 成员: {member_id}, 错误: {str(e)}") diff --git a/web/src/components/Chat/ChatContent.tsx b/web/src/components/Chat/ChatContent.tsx index a785ea49..509004b0 100644 --- a/web/src/components/Chat/ChatContent.tsx +++ b/web/src/components/Chat/ChatContent.tsx @@ -8,12 +8,11 @@ import { type FC, useRef, useEffect, useState } from 'react' import clsx from 'clsx' import Markdown from '@/components/Markdown' import type { ChatContentProps } from './types' -import { Spin, Image, Flex, Button } from 'antd' +import { Spin, Flex, Button } from 'antd' import { SoundOutlined } from '@ant-design/icons' import { useTranslation } from 'react-i18next' -import AudioPlayer from './AudioPlayer' -import VideoPlayer from './VideoPlayer' +import MessageFiles from './MessageFiles' const getFileUrl = (file: any) => { return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) @@ -149,72 +148,7 @@ const ChatContent: FC = ({ {labelFormat(item)} } - {item?.meta_data?.files && item.meta_data?.files.length > 0 && - {item.meta_data?.files?.map((file) => { - if (file.type.includes('image')) { - return ( -
- {file.name} -
- ) - } - if (file.type.includes('video')) { - return ( -
- {/*
- ) - } - if (file.type.includes('audio')) { - return ( -
- -
- ) - } - - const documentType = (file.file_type || file.type)?.split('/') - return ( - handleDownload(file)} - > -
-
-
{file.name}
-
{documentType?.[documentType.length - 1]} · {file.size}
-
-
- ) - })} -
} + {/* Message bubble */}
+ file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) + +const DOC_ICONS: [string[], string][] = [ + [['pdf'], "rb:bg-[url('@/assets/images/file/pdf.svg')]"], + [['excel', 'spreadsheetml.sheet', 'xls', 'xlsx'], "rb:bg-[url('@/assets/images/file/excel.svg')]"], + [['csv'], "rb:bg-[url('@/assets/images/file/csv.svg')]"], + [['html'], "rb:bg-[url('@/assets/images/file/html.svg')]"], + [['json'], "rb:bg-[url('@/assets/images/file/json.svg')]"], + [['ppt'], "rb:bg-[url('@/assets/images/file/ppt.svg')]"], + [['markdown'], "rb:bg-[url('@/assets/images/file/md.svg')]"], + [['text'], "rb:bg-[url('@/assets/images/file/txt.svg')]"], + [['doc', 'docx', 'word', 'wordprocessingml.document'], "rb:bg-[url('@/assets/images/file/word.svg')]"], +] + +const getDocIcon = (parts: string[]) => { + const match = DOC_ICONS.find(([keys]) => keys.some(k => parts.includes(k))) + return match ? match[1] : "rb:bg-[url('@/assets/images/file/txt.svg')]" +} + +interface MessageFilesProps { + files: any[] + contentClassNames?: string | Record + onDownload: (file: any) => void +} + +const MessageFiles = ({ files, contentClassNames, onDownload }: MessageFilesProps) => { + if (!files?.length) return null + return ( + + {files.map((file) => { + const key = file.url || file.uid + if (file.type.includes('image')) { + return ( +
+ {file.name} +
+ ) + } + if (file.type.includes('video')) { + return ( +
+ +
+ ) + } + if (file.type.includes('audio')) { + return ( +
+ +
+ ) + } + const documentType = (file.file_type || file.type)?.split('/') ?? [] + return ( + onDownload(file)} + > +
+
+
{file.name}
+
+ {documentType?.[documentType.length - 1]} · {file.size} +
+
+ + ) + })} + + ) +} + +export default MessageFiles diff --git a/web/src/components/OverflowTags/index.tsx b/web/src/components/OverflowTags/index.tsx index 9ad9cd92..82fdb2c9 100644 --- a/web/src/components/OverflowTags/index.tsx +++ b/web/src/components/OverflowTags/index.tsx @@ -3,14 +3,14 @@ import { Popover, type PopoverProps } from 'antd' import Tag, { type TagProps } from '@/components/Tag' interface OverflowTagsProps { - items: ReactNode[]; + items?: ReactNode[]; gap?: number; numTagColor?: TagProps['color']; numTag?: (num?: number) => ReactNode; popoverProps?: PopoverProps | false; } -const OverflowTags = ({ items, gap = 8, numTagColor = 'default', numTag, popoverProps }: OverflowTagsProps) => { +const OverflowTags = ({ items = [], gap = 8, numTagColor = 'default', numTag, popoverProps }: OverflowTagsProps) => { const containerRef = useRef(null) const measureRef = useRef(null) const [visibleCount, setVisibleCount] = useState(items.length) @@ -20,7 +20,7 @@ const OverflowTags = ({ items, gap = 8, numTagColor = 'default', numTag, popover if (!measure || containerWidth === 0) return const children = Array.from(measure.children) as HTMLElement[] - if (!children.length) return + if (!children.length) { setVisibleCount(0); return } // last child is the sample +N tag const extraTagWidth = (children[children.length - 1] as HTMLElement).offsetWidth diff --git a/web/src/components/SiderMenu/index.tsx b/web/src/components/SiderMenu/index.tsx index e1d7e596..c0698389 100644 --- a/web/src/components/SiderMenu/index.tsx +++ b/web/src/components/SiderMenu/index.tsx @@ -399,7 +399,7 @@ const Menu: FC<{ className="rb:overflow-y-auto rb:flex-1!" /> {/* Return to space button for superusers */} - {user?.is_superuser && source === 'space' && + {source === 'space' &&
{collapsed ? null : t('common.switchSpace')}
- -
- {collapsed ? null : t('common.returnToSpace')} -
+ {user?.is_superuser && + +
+ {collapsed ? null : t('common.returnToSpace')} +
+ }
} {source === 'manage' && subscription && !collapsed && diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2a7534c4..3a03fbc6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1538,6 +1538,7 @@ export const en = { json_output: 'Support JSON formatted output', thinking_budget_tokens: 'thinking budget tokens', thinking_budget_tokens_max_error: "Cannot exceed the max tokens limit ({{max}})", + thinking_budget_tokens_min_error: "Cannot be less than {{min}}", logSearchPlaceholder: 'Search log content', }, userMemory: { diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 6989cf3f..c7b24eb4 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -868,6 +868,7 @@ export const zh = { json_output: '支持JSON格式化输出', thinking_budget_tokens: '深度思考预算Token数', thinking_budget_tokens_max_error: "不能超过 最大令牌数 ({{max}})", + thinking_budget_tokens_min_error: "不能小于 {{min}}", logSearchPlaceholder: '搜索日志内容', }, table: { diff --git a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx index bda18571..d63e5b17 100644 --- a/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx +++ b/web/src/views/ApplicationConfig/components/ModelConfigModal.tsx @@ -49,6 +49,8 @@ const configFields = [ { key: 'n', max: 10, min: 1, step: 1, defaultValue: 1 }, ] +const minThinkingBudgetTokens = 128; +const defaultThinkingBudgetTokens = 1000; const ModelConfigModal = forwardRef(({ refresh, data, @@ -108,7 +110,7 @@ const ModelConfigModal = forwardRef( const newValues: ModelConfig = { capability: (option as Model).capability, deep_thinking: false, - thinking_budget_tokens: undefined, + thinking_budget_tokens: defaultThinkingBudgetTokens, json_output: false, } if (source === 'chat') { @@ -128,6 +130,12 @@ const ModelConfigModal = forwardRef( form.setFieldsValue({ ...rest }) }, [data?.default_model_config_id]) + useEffect(() => { + if (values?.deep_thinking && !values?.thinking_budget_tokens) { + form.setFieldValue('thinking_budget_tokens', defaultThinkingBudgetTokens) + } + }, [values?.deep_thinking]) + const handleReset = () => { if (!id) return resetAppModelConfig(id).then((res) => { @@ -178,15 +186,20 @@ const ModelConfigModal = forwardRef( name="thinking_budget_tokens" label={t('application.thinking_budget_tokens')} hidden={!['model', 'chat'].includes(source) || !(values?.deep_thinking || values?.capability?.includes('thinking'))} - extra={<>{t('application.range')}: [{0}, {t(`application.max_tokens`)}: {values?.max_tokens}]} + extra={<>{t('application.range')}: [{minThinkingBudgetTokens}, {t(`application.max_tokens`)}: {values?.max_tokens}]} rules={[ { required: values?.deep_thinking, message: t('common.pleaseEnter') }, { validator: (_, value) => { const maxTokens = values?.max_tokens const deep_thinking = values?.deep_thinking; - if (deep_thinking && value !== undefined && maxTokens !== undefined && value > maxTokens) { - return Promise.reject(t('application.thinking_budget_tokens_max_error', { max: maxTokens })) + if (deep_thinking && value !== undefined) { + if (value < minThinkingBudgetTokens) { + return Promise.reject(t('application.thinking_budget_tokens_min_error', { min: minThinkingBudgetTokens })) + } + if (maxTokens !== undefined && value > maxTokens) { + return Promise.reject(t('application.thinking_budget_tokens_max_error', { max: maxTokens })) + } } return Promise.resolve() } @@ -195,7 +208,7 @@ const ModelConfigModal = forwardRef( > {
{item.scene_description}
-
+
{type}), {`+${item.type_num - 3}`}]} + items={item.entity_type ? [...item.entity_type.map((type, i) => {type}), {`+${item.type_num - 3}`}] : []} numTag={(num?: number) => {`+${item.type_num - 3 + (num ? num - 1 : 0)}`}} />
diff --git a/web/src/views/ToolManagement/components/CustomToolModal.tsx b/web/src/views/ToolManagement/components/CustomToolModal.tsx index cbcddb7c..08e18679 100644 --- a/web/src/views/ToolManagement/components/CustomToolModal.tsx +++ b/web/src/views/ToolManagement/components/CustomToolModal.tsx @@ -101,6 +101,7 @@ const CustomToolModal = forwardRef(({ }); }; const formatSchema = (value: string) => { + if (!value || value.trim() === '') return setParseSchemaData({} as ParseSchemaData) parseSchema({ schema_content: value }) .then(res => { diff --git a/web/src/views/Workflow/components/CanvasToolbar.tsx b/web/src/views/Workflow/components/CanvasToolbar.tsx index 1bbb51f2..8225b65f 100644 --- a/web/src/views/Workflow/components/CanvasToolbar.tsx +++ b/web/src/views/Workflow/components/CanvasToolbar.tsx @@ -57,7 +57,6 @@ const CanvasToolbar: FC = ({ } }} labelRender={(props) => { - console.log('props', props) return `${props.value}%` }} className="rb:w-20 rb:h-4!" diff --git a/web/src/views/Workflow/components/Chat/Chat.tsx b/web/src/views/Workflow/components/Chat/Chat.tsx index 863825ba..025c2e0b 100644 --- a/web/src/views/Workflow/components/Chat/Chat.tsx +++ b/web/src/views/Workflow/components/Chat/Chat.tsx @@ -66,8 +66,6 @@ const Chat = forwardRef([]) const [message, setMessage] = useState(undefined) - console.log('abortRef', abortRef, chatList) - /** * Opens the chat drawer and loads workflow variables from the start node */ diff --git a/web/src/views/Workflow/components/Nodes/AddNode.tsx b/web/src/views/Workflow/components/Nodes/AddNode.tsx index 3bdb96c0..1c8eeee6 100644 --- a/web/src/views/Workflow/components/Nodes/AddNode.tsx +++ b/web/src/views/Workflow/components/Nodes/AddNode.tsx @@ -18,6 +18,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { // Handle node selection from popover and create new node replacing the add-node placeholder const handleNodeSelect = (selectedNodeType: any) => { + graph.startBatch('add-node'); const parentBBox = node.getBBox(); const cycleId = data.cycle; const horizontalSpacing = 0; @@ -43,7 +44,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { if (cycleId) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (parentNode) { - parentNode.addChild(newNode); + parentNode.addChild(newNode, { silent: true }); } } @@ -76,55 +77,40 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => { } }); - setTimeout(() => { - addedEdges.forEach(e => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.isNode()) src.toFront(); - if (tgt?.isNode()) tgt.toFront(); - }); - }, 50); - // Automatically adjust loop node size const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); if (loopNode) { - const adjustLoopSize = () => { - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - if (childNodes.length > 0) { - const bounds = childNodes.reduce((acc, child) => { - const bbox = child.getBBox(); - return { - minX: Math.min(acc.minX, bbox.x), - minY: Math.min(acc.minY, bbox.y), - maxX: Math.max(acc.maxX, bbox.x + bbox.width), - maxY: Math.max(acc.maxY, bbox.y + bbox.height) - }; - }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); - - const padding = 50; - const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); - const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); - - loopNode.prop('size', { width: newWidth, height: newHeight }); - - // Update right port x position - const ports = loopNode.getPorts(); - ports.forEach(port => { - if (port.group === 'right' && port.args) { - loopNode.portProp(port.id!, 'args/x', newWidth); - } - }); - } - }; - - adjustLoopSize(); - - // Listen to child node movement events const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - childNodes.forEach((childNode: any) => { - childNode.on('change:position', adjustLoopSize); - }); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc, child) => { + const bbox = child.getBBox(); + return { + minX: Math.min(acc.minX, bbox.x), + minY: Math.min(acc.minY, bbox.y), + maxX: Math.max(acc.maxX, bbox.x + bbox.width), + maxY: Math.max(acc.maxY, bbox.y + bbox.height) + }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + const padding = 50; + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + loopNode.prop('size', { width: newWidth, height: newHeight }); + loopNode.getPorts().forEach(port => { + if (port.group === 'right' && port.args) { + loopNode.portProp(port.id!, 'args/x', newWidth); + } + }); + } } + + addedEdges.forEach(e => { + const src = graph.getCellById(e.getSourceCellId()); + const tgt = graph.getCellById(e.getTargetCellId()); + if (src?.isNode()) src.toFront(); + if (tgt?.isNode()) tgt.toFront(); + }); + + graph.stopBatch('add-node'); setOpen(false); }; diff --git a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx index b431ddd4..74a15c78 100644 --- a/web/src/views/Workflow/components/Nodes/ConditionNode.tsx +++ b/web/src/views/Workflow/components/Nodes/ConditionNode.tsx @@ -99,7 +99,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => { {data.type === 'if-else' && {data.config?.cases?.defaultValue.map((item: any, index: number) => ( -
0 ? '' : 'rb:mb-1'}> +
0 ? "space-between" : 'end'} className="rb:mb-1! rb:leading-4"> {item.expressions.length > 0 && CASE{index + 1}} {index === 0 ? 'IF' : `ELIF`} diff --git a/web/src/views/Workflow/components/Nodes/LoopNode.tsx b/web/src/views/Workflow/components/Nodes/LoopNode.tsx index cffb62dd..ac81a667 100644 --- a/web/src/views/Workflow/components/Nodes/LoopNode.tsx +++ b/web/src/views/Workflow/components/Nodes/LoopNode.tsx @@ -1,134 +1,15 @@ -import { useEffect } from 'react'; -import { useTranslation } from 'react-i18next' import clsx from 'clsx'; import type { ReactShapeConfig } from '@antv/x6-react-shape'; import { Flex } from 'antd'; import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next' -import { graphNodeLibrary, edgeAttrs } from '../../constant'; import NodeTools from './NodeTools' -const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { +const LoopNode: ReactShapeConfig['component'] = ({ node }) => { const data = node.getData() || {}; const { t } = useTranslation() - useEffect(() => { - // 使用setTimeout确保在所有节点都添加完成后再创建连线 - const timer = setTimeout(() => { - initNodes() - checkAndAddAddNode() - }, 50) - - return () => clearTimeout(timer) - }, [graph]) - - const checkAndAddAddNode = () => { - if (!graph) return; - - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === data.id); - const cycleStartNodes = childNodes.filter((n: any) => n.getData()?.type === 'cycle-start'); - - // 如果只有一个cycle-start节点且没有其他类型的子节点,则添加add-node - if (cycleStartNodes.length === 1 && childNodes.length === 1) { - const cycleStartNode = cycleStartNodes[0]; - const cycleStartBBox = cycleStartNode.getBBox(); - - const addNode = graph.addNode({ - ...graphNodeLibrary.addStart, - x: cycleStartBBox.x + 84, - y: cycleStartBBox.y + 4, - data: { - type: 'add-node', - label: t('workflow.addNode'), - icon: '+', - parentId: node.id, - cycle: data.id, - }, - }); - - node.addChild(addNode); - - // 连接cycle-start和add-node - const sourcePorts = cycleStartNode.getPorts(); - const targetPorts = addNode.getPorts(); - const sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - const targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left'; - - // 然后创建连线 - graph.addEdge({ - source: { cell: cycleStartNode.id, port: sourcePort }, - target: { cell: addNode.id, port: targetPort }, - ...edgeAttrs, - }); - - cycleStartNode.toFront() - addNode.toFront() - } - } - - const initNodes = () => { - // 检查是否存在cycle为当前节点ID的子节点,若存在则不调用initNodes,避免重复创建 - const existingCycleNodes = graph.getNodes().filter((n: any) => - n.getData()?.cycle === data.id - ); - if (existingCycleNodes.length > 0) return; - // 添加默认子节点 - const parentBBox = node.getBBox(); - const centerX = parentBBox.x + 24; - const centerY = parentBBox.y + 70; - - const cycleStartNodeId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` - const cycleStartNode = graph.addNode({ - ...graphNodeLibrary.cycleStart, - x: centerX, - y: centerY, - id: cycleStartNodeId, - data: { - id: cycleStartNodeId, - type: 'cycle-start', - parentId: node.id, - isDefault: true, // 标记为默认节点,不可删除 - cycle: data.id, - }, - }); - const addNode = graph.addNode({ - ...graphNodeLibrary.addStart, - x: centerX + 84, - y: centerY + 4, - data: { - type: 'add-node', - label: t('workflow.addNode'), - icon: '+', - parentId: node.id, - cycle: data.id, - }, - }); - node.addChild(cycleStartNode) - node.addChild(addNode) - const sourcePorts = cycleStartNode.getPorts() - const targetPorts = addNode.getPorts() - let sourcePort = sourcePorts.find((port: any) => port.group === 'right')?.id || 'right'; - - const edgeConfig = { - source: { - cell: cycleStartNode.id, - port: sourcePort - }, - target: { - cell: addNode.id, - port: targetPorts.find((port: any) => port.group === 'left')?.id || 'left' - }, - ...edgeAttrs - } - graph.addEdge(edgeConfig) - - setTimeout(() => { - - cycleStartNode.toFront() - addNode.toFront() - }, 0) - } - return (
= ({ graph }) => { }; }, []); - // Handle node selection from popover menu and create new node with edge connection const handleNodeSelect = (selectedNodeType: any) => { if (!sourceNode || !graph) return; const sourceNodeData = sourceNode.getData(); const sourceNodeType = sourceNodeData?.type; - - // If it's a cycle-start node, handle the add-node placeholder + const isCycleSubNode = !!sourceNodeData.cycle; + const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; + const newNodeType = selectedNodeType.type; + + // Save add-node placeholder position before disabling history let addNodePosition = null; - const isCycleSubNode = sourceNodeData.cycle if (isCycleSubNode && sourceNodeType === 'cycle-start') { const cycleId = sourceNodeData.cycle; - const addNodes = graph.getNodes().filter((n: any) => + const addNodes = graph.getNodes().filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId ); - - if (addNodes.length > 0) { - const addNode = addNodes[0]; - addNodePosition = addNode.getBBox(); - addNode.remove(); - } + if (addNodes.length > 0) addNodePosition = addNodes[0].getBBox(); } - - // Calculate new node position to avoid overlapping + + // Calculate position const sourceBBox = sourceNode.getBBox(); - const nodeWidth = graphNodeLibrary[selectedNodeType.type]?.width || 120; - const nodeHeight = graphNodeLibrary[selectedNodeType.type]?.height || 88; - const horizontalSpacing = isCycleSubNode ? 48 : 80; - const verticalSpacing = 10; - - // Get source port group information + const nw = graphNodeLibrary[newNodeType]?.width || 120; + const nh = graphNodeLibrary[newNodeType]?.height || 88; + const hSpacing = isCycleSubNode ? 48 : 80; + const vSpacing = 10; const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortGroup = sourcePortInfo?.group || sourcePort; - - // Calculate new node position - let newX, newY; + + let newX: number, newY: number; if (edgeInsertion) { - // Edge insertion: place new node on the same row as target, between source and target const targetBBox = edgeInsertion.targetCell.getBBox(); const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width); - const requiredSpace = nodeWidth + horizontalSpacing * 4; - - // New node x: right after source + spacing - newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; - // Same row as target node - newY = targetBBox.y + (targetBBox.height - nodeHeight) / 2; - - // If not enough space, shift target and all downstream nodes to the right + const requiredSpace = nw + hSpacing * 4; + newX = sourceBBox.x + sourceBBox.width + hSpacing; + newY = targetBBox.y + (targetBBox.height - nh) / 2; if (gap < requiredSpace) { const shiftX = requiredSpace - gap; const visited = new Set(); const shiftDownstream = (cell: any) => { - const cellId = cell.id; - if (visited.has(cellId)) return; - visited.add(cellId); + if (visited.has(cell.id)) return; + visited.add(cell.id); const pos = cell.getPosition(); cell.setPosition(pos.x + shiftX, pos.y); - // Recursively shift nodes connected from right ports graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => { - const tId = e.getTargetCellId(); - if (tId && !visited.has(tId)) { - const tCell = graph.getCellById(tId); - if (tCell?.isNode()) shiftDownstream(tCell); - } + const tCell = graph.getCellById(e.getTargetCellId()); + if (tCell?.isNode()) shiftDownstream(tCell); }); }; shiftDownstream(edgeInsertion.targetCell); @@ -114,208 +96,170 @@ const PortClickHandler: React.FC = ({ graph }) => { } else if (addNodePosition) { newX = addNodePosition.x; newY = addNodePosition.y; + } else if (sourcePortGroup === 'left') { + newX = sourceBBox.x - nw * 2 - hSpacing; + newY = sourceBBox.y; } else { - // Determine node placement direction based on port position - if (sourcePortGroup === 'left') { - // Left port: add node to the left - newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing; - newY = sourceBBox.y; - } else { - // Right port: add node to the right - newX = sourceBBox.x + sourceBBox.width + horizontalSpacing; - newY = sourceBBox.y; - } - - // Check if position overlaps with existing nodes (only consider connected nodes) - const checkOverlap = (x: number, y: number) => { - // Get nodes connected to the source node - const connectedNodes = new Set(); - graph.getConnectedEdges(sourceNode).forEach((edge: any) => { - const sourceId = edge.getSourceCellId(); - const targetId = edge.getTargetCellId(); - if (sourceId !== sourceNode.id) connectedNodes.add(sourceId); - if (targetId !== sourceNode.id) connectedNodes.add(targetId); + newX = sourceBBox.x + sourceBBox.width + hSpacing; + newY = sourceBBox.y; + const connectedNodes = new Set(); + graph.getConnectedEdges(sourceNode).forEach((e: any) => { + [e.getSourceCellId(), e.getTargetCellId()].forEach((cid: string) => { + if (cid !== sourceNode.id) connectedNodes.add(cid); }); - - return graph.getNodes().some((node: any) => { - if (node.id === sourceNode.id) return false; - if (!connectedNodes.has(node.id)) return false; // Only consider connected nodes - const bbox = node.getBBox(); - return !(x + nodeWidth < bbox.x || x > bbox.x + bbox.width || - y + nodeHeight < bbox.y || y > bbox.y + bbox.height); + }); + const checkOverlap = (x: number, y: number) => + graph.getNodes().some((n: any) => { + if (n.id === sourceNode.id || !connectedNodes.has(n.id)) return false; + const b = n.getBBox(); + return !(x + nw < b.x || x > b.x + b.width || y + nh < b.y || y > b.y + b.height); }); - }; - - // If position is occupied, search downward for empty space - while (checkOverlap(newX, newY)) { - newY += nodeHeight + verticalSpacing; - } + while (checkOverlap(newX, newY)) newY += nh + vSpacing; } - - // Create new node - const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + + // Disable history for all graph mutations + graph.disableHistory(); + + // Remove add-node placeholder + if (isCycleSubNode && sourceNodeType === 'cycle-start') { + const cycleId = sourceNodeData.cycle; + graph.getNodes() + .filter((n: any) => n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId) + .forEach((n: any) => n.remove()); + } + + const id = `${newNodeType.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const newNode = graph.addNode({ - ...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), + ...(graphNodeLibrary[newNodeType] || graphNodeLibrary.default), x: newX, y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0), id, data: { id, - type: selectedNodeType.type, + type: newNodeType, icon: selectedNodeType.icon, - name: t(`workflow.${selectedNodeType.type}`), - cycle: sourceNodeData.cycle, // Inherit cycle from source node + name: t(`workflow.${newNodeType}`), + cycle: sourceNodeData.cycle, config: selectedNodeType.config || {} }, }); - // Add new node as child of parent node if (sourceNodeData.cycle) { const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); - if (parentNode) { - parentNode.addChild(newNode); - } + if (parentNode) parentNode.addChild(newNode, { silent: true }); } - // Edge insertion: remove old edge immediately before creating new edges if (edgeInsertion) { const { edge: oldEdge } = edgeInsertion; - if (oldEdge.id && graph.getCellById(oldEdge.id)) { - graph.removeCell(oldEdge.id); - } else { - graph.removeEdge(oldEdge); - } + if (oldEdge.id && graph.getCellById(oldEdge.id)) graph.removeCell(oldEdge.id); + else graph.removeEdge(oldEdge); } - // Create edge connection - setTimeout(() => { - const newPorts = newNode.getPorts(); + const newPorts = newNode.getPorts(); + const addedCells: any[] = [newNode]; - const addedEdges: any[] = []; - if (edgeInsertion) { - // Edge insertion: create source→new and new→target edges - const { targetCell, targetPort: origTargetPort } = edgeInsertion; - const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; - const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; - addedEdges.push(graph.addEdge({ - source: { cell: sourceNode.id, port: sourcePort }, - target: { cell: newNode.id, port: newLeftPort }, - ...edgeAttrs - })); - addedEdges.push(graph.addEdge({ - source: { cell: newNode.id, port: newRightPort }, - target: { cell: targetCell.id, port: origTargetPort }, - ...edgeAttrs - })); - setEdgeInsertion(null); - } else if (sourcePortGroup === 'left') { - // Connect from left port to new node's right side - const targetPort = newPorts.find((port: any) => port.group === 'right')?.id || 'right'; - addedEdges.push(graph.addEdge({ - source: { cell: newNode.id, port: targetPort }, - target: { cell: sourceNode.id, port: sourcePort }, - ...edgeAttrs - })); - } else { - // Connect from right port to new node's left side - const targetPort = newPorts.find((port: any) => port.group === 'left')?.id || 'left'; - addedEdges.push(graph.addEdge({ - source: { cell: sourceNode.id, port: sourcePort }, - target: { cell: newNode.id, port: targetPort }, - ...edgeAttrs - })); - } - - // Adjust loop node size when child node is added via port within loop node - const cycleId = sourceNodeData.cycle; - if (cycleId) { - const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (edgeInsertion) { + const { targetCell, targetPort: origTargetPort } = edgeInsertion; + const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs })); + addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs })); + setEdgeInsertion(null); + } else if (sourcePortGroup === 'left') { + const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; + addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs })); + } else { + const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; + addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs })); + } - if (parentNode) { - const adjustLoopSize = () => { - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - if (childNodes.length > 0) { - const bounds = childNodes.reduce((acc: any, child: any) => { - const bbox = child.getBBox(); - return { - minX: Math.min(acc.minX, bbox.x), - minY: Math.min(acc.minY, bbox.y), - maxX: Math.max(acc.maxX, bbox.x + bbox.width), - maxY: Math.max(acc.maxY, bbox.y + bbox.height) - }; - }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + // If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type + if (isCycleContainer(newNodeType)) { + const parentBBox = newNode.getBBox(); + const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const cycleStartNode = graph.addNode({ + ...graphNodeLibrary.cycleStart, + x: parentBBox.x + 24, + y: parentBBox.y + 70, + id: cycleStartId, + data: { id: cycleStartId, type: 'cycle-start', parentId: id, isDefault: true, cycle: id }, + }); + const addNodePlaceholder = graph.addNode({ + ...graphNodeLibrary.addStart, + x: parentBBox.x + 24 + 84, + y: parentBBox.y + 70 + 4, + data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: id, cycle: id }, + }); + newNode.addChild(cycleStartNode, { silent: true }); + newNode.addChild(addNodePlaceholder, { silent: true }); + const innerEdge = graph.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find((p: any) => p.group === 'right')?.id || 'right' }, + target: { cell: addNodePlaceholder.id, port: addNodePlaceholder.getPorts().find((p: any) => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }); + addedCells.push(cycleStartNode, addNodePlaceholder, innerEdge); + } - const padding = 50; - const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); - const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); - - parentNode.prop('size', { width: newWidth, height: newHeight }); - - // Update right port x position - const ports = parentNode.getPorts(); - ports.forEach((port: any) => { - if (port.group === 'right' && port.args) { - parentNode.portProp(port.id!, 'args/x', newWidth); - } - }); - } - }; - - adjustLoopSize(); - - // Listen to child node movement events - const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); - childNodes.forEach((childNode: any) => { - childNode.on('change:position', adjustLoopSize); + // Adjust parent size if adding inside a cycle container + const cycleId = sourceNodeData.cycle; + if (cycleId) { + const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); + if (parentNode) { + const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); + if (childNodes.length > 0) { + const bounds = childNodes.reduce((acc: any, child: any) => { + const b = child.getBBox(); + return { minX: Math.min(acc.minX, b.x), minY: Math.min(acc.minY, b.y), maxX: Math.max(acc.maxX, b.x + b.width), maxY: Math.max(acc.maxY, b.y + b.height) }; + }, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); + const padding = 50; + const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); + const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); + parentNode.prop('size', { width: newWidth, height: newHeight }); + parentNode.getPorts().forEach((port: any) => { + if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth); }); } } + } - const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; - const newNodeType = selectedNodeType.type; + // toFront + const bringCycleChildrenToFront = (cycleContainerId: string) => { + graph.getEdges().forEach((e: any) => { + const src = graph.getCellById(e.getSourceCellId()); + const tgt = graph.getCellById(e.getTargetCellId()); + if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); + }); + graph.getNodes().forEach((n: any) => { if (n.getData()?.cycle === cycleContainerId) n.toFront(); }); + }; - // Helper: bring all child nodes and their edges of a cycle container to front - const bringCycleChildrenToFront = (cycleContainerId: string) => { - - graph.getEdges().forEach((e: any) => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); - }); - graph.getNodes().forEach((n: any) => { - if (n.getData()?.cycle === cycleContainerId) n.toFront(); - }); - }; + if (isCycleContainer(sourceNodeType)) { + newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(sourceNodeData.id); + if (isCycleContainer(newNodeType)) bringCycleChildrenToFront(id); + } else if (isCycleContainer(newNodeType)) { + newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(id); + } else { + addedCells.forEach(c => { if (c.isNode?.()) c.toFront(); }); + } - if (isCycleContainer(sourceNodeType)) { - console.log('isCycleContainer(sourceNodeType)') - // Case 4: source is a loop/iteration node — bring new node to front, then its children - newNode.toFront(); - sourceNode.toFront(); - bringCycleChildrenToFront(sourceNodeData.id); - } else if (isCycleContainer(newNodeType)) { - console.log('isCycleContainer(newNodeType)') - // Case 3: adding a loop/iteration node from a normal node — bring new node to front, then its children - newNode.toFront(); - sourceNode.toFront() - bringCycleChildrenToFront(id); - } else { - // Case 2: normal node → normal node - addedEdges.forEach(e => { - const src = graph.getCellById(e.getSourceCellId()); - const tgt = graph.getCellById(e.getTargetCellId()); - if (src?.isNode()) src.toFront(); - if (tgt?.isNode()) tgt.toFront(); - }); - } - }, 50); + // Re-enable history and manually push one batch frame for all added cells + graph.enableHistory(); + const history = graph.getPlugin('history') as any; + if (history) { + const batchFrame = addedCells.map((cell: any) => ({ + batch: true, + event: 'cell:added', + data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, + options: {}, + })); + history.undoStack.push(batchFrame); + history.redoStack = []; + graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-node' } }); + } - // Clean up temporary element if (tempElement) { document.body.removeChild(tempElement); setTempElement(null); } - setPopoverVisible(false); }; @@ -391,4 +335,4 @@ const PortClickHandler: React.FC = ({ graph }) => { ); }; -export default PortClickHandler; \ No newline at end of file +export default PortClickHandler; diff --git a/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx b/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx index 6e8bd0c0..73a5c087 100644 --- a/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx +++ b/web/src/views/Workflow/components/Properties/ToolConfig/index.tsx @@ -242,10 +242,11 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({ className={parameter.type === 'boolean' ? 'rb:mb-0!' : ''} > {parameter.type === 'string' && parameter.enum && parameter.enum.length > 0 - ? ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} /> : parameter.type === 'boolean' - ? + ? : = { width: nodeWidth, height: 120, shape: 'notes-node', + }, + output: { + width: nodeWidth, + height: 76, + shape: 'normal-node', + ports: { + groups: { left: defaultPortGroup }, + items: [defaultPortItems[0]], + }, } } diff --git a/web/src/views/Workflow/hooks/useWorkflowGraph.ts b/web/src/views/Workflow/hooks/useWorkflowGraph.ts index a22ee6c0..0fda2935 100644 --- a/web/src/views/Workflow/hooks/useWorkflowGraph.ts +++ b/web/src/views/Workflow/hooks/useWorkflowGraph.ts @@ -2,10 +2,9 @@ * @Author: ZhaoYing * @Date: 2026-02-03 15:17:48 * @Last Modified by: ZhaoYing - * @Last Modified time: 2026-04-24 17:21:09 + * @Last Modified time: 2026-04-28 13:49:11 */ import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6'; -import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type'; import { register } from '@antv/x6-react-shape'; import type { PortMetadata } from '@antv/x6/lib/model/port'; import { App } from 'antd'; @@ -17,7 +16,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'; import { useUser } from '@/store/user'; import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant'; -import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types'; +import type { ChatVariable, HistoryRecord, NodeProperties, WorkflowConfig } from '../types'; import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils'; import { useWorkflowStore } from '@/store/workflow'; @@ -86,6 +85,10 @@ export interface UseWorkflowGraphReturn { /** Get start node output variable list (user-defined + system variables) */ getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>; nodeClick: ({ node }: { node: Node }) => void; + /** All recorded history operations */ + historyRecords: HistoryRecord[]; + /** Clear history records */ + clearHistoryRecords: () => void; } /** @@ -119,14 +122,19 @@ export const useWorkflowGraph = ({ const featuresRef = useRef(undefined) const [canUndo, setCanUndo] = useState(false) const [canRedo, setCanRedo] = useState(false) - + const [historyRecords, setHistoryRecords] = useState([]) + const lastHistoryRef = useRef<{ cellIds: string[]; timestamp: number; type: string } | null>(null) + const undoRef = useRef<() => void>(() => {}) + const redoRef = useRef<() => void>(() => {}) + const syncChildRelationshipsRef = useRef<() => void>(() => {}) + const isSyncingRef = useRef(false) useEffect(() => { if (!graphRef.current) return graphRef.current.getNodes().forEach(node => { const data = node.getData() if (data?.type === 'if-else' || data?.type === 'question-classifier') { console.log('chatVariables', chatVariables) - node.setData({ ...data, chatVariables }, { silent: true }) + node.setData({ ...data, chatVariables }) } }) }, [chatVariables]) @@ -343,7 +351,7 @@ export const useWorkflowGraph = ({ if (parentNode) { const addedChild = graphRef.current?.addNode(childNode) if (addedChild) { - parentNode.addChild(addedChild) + parentNode.addChild(addedChild, { silent: true }) } } } @@ -374,8 +382,6 @@ export const useWorkflowGraph = ({ const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2) const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) - console.log('newWidth', newHeight, newWidth) - parentNode.prop('size', { width: newWidth, height: newHeight }) // Update x position of right group ports @@ -488,8 +494,77 @@ export const useWorkflowGraph = ({ graphRef.current.cleanHistory() } }, 200) + } else { + graphRef.current.enableHistory() + graphRef.current.cleanHistory() } } + + const resizeGroupNodes = (graph: Graph) => { + graph.getNodes().forEach(parentNode => { + const parentType = parentNode.getData()?.type + if (parentType !== 'loop' && parentType !== 'iteration') return + const children = graph.getNodes().filter( + n => n.getData()?.cycle === parentNode.getData()?.id && n.getData()?.type !== 'add-node' + ) + if (!children.length) return + const padding = 24 + const headerHeight = 50 + const childBounds = children.map(c => c.getBBox()) + const minX = Math.min(...childBounds.map(b => b.x)) + const minY = Math.min(...childBounds.map(b => b.y)) + const maxX = Math.max(...childBounds.map(b => b.x + b.width)) + const maxY = Math.max(...childBounds.map(b => b.y + b.height)) + const parentBBox = parentNode.getBBox() + const newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2) + const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) + parentNode.prop('size', { width: newWidth, height: newHeight }) + parentNode.getPorts().forEach(port => { + if (port.group === 'right' && port.args) { + parentNode.portProp(port.id!, 'args/x', newWidth) + } + }) + }) + } + + const syncChildRelationships = () => { + if (!graphRef.current) return + const graph = graphRef.current + graph.disableHistory() + graph.getNodes().forEach(node => { + const cycleId = node.getData()?.cycle + if (!cycleId) return + const parentNode = graph.getCellById(cycleId) as Node | null + if (!parentNode) return + if (!parentNode.getChildren()?.some(c => c.id === node.id)) { + parentNode.addChild(node, { silent: true }) + } + }) + graph.getNodes().forEach(node => { + const children = node.getChildren() + if (!children?.length) return + children.forEach(child => { + if (!child.isNode()) return + const childCycleId = (child as Node).getData?.()?.cycle + if (childCycleId !== node.id && childCycleId !== node.getData?.()?.id) { + node.removeChild(child, { silent: true }) + } + }) + }) + resizeGroupNodes(graph) + graph.getEdges().forEach(edge => { + const src = graph.getCellById(edge.getSourceCellId()) + const tgt = graph.getCellById(edge.getTargetCellId()) + if (src?.getData()?.cycle || tgt?.getData()?.cycle) { + edge.toFront() + } + }) + graph.getNodes().forEach(node => { + if (node.getData()?.cycle) node.toFront() + }) + graph.enableHistory() + } + syncChildRelationshipsRef.current = syncChildRelationships /** * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard) */ @@ -525,18 +600,44 @@ export const useWorkflowGraph = ({ new History({ enabled: false, beforeAddCommand(_event, args: any) { - const event = args?.key ? `cell:change:${args.key}` : _event; - if (event.startsWith('cell:change:') && - event !== 'cell:change:position' && - event !== 'cell:change:source' && - event !== 'cell:change:target') return false; + const key = args?.key + if (key === 'attrs' || key === 'tools') return false }, }), ); - graphRef.current.on('history:change', ({ cmds }: { cmds: Command[] }) => { + const MERGE_INTERVAL = 1000 + graphRef.current.on('history:change', ({ cmds, options }: { cmds: any[]; options: any }) => { setCanUndo(graphRef.current?.canUndo() ?? false) setCanRedo(graphRef.current?.canRedo() ?? false) + console.log('history:change', cmds, options) + const batchName: string | undefined = options?.name + const actionType = batchName === 'undo' ? 'undo' : batchName === 'redo' ? 'redo' : batchName ? 'batch' : 'change' + const cellIds = [...new Set(cmds?.map((cmd: any) => cmd.data?.id).filter(Boolean))] + const now = Date.now() + const last = lastHistoryRef.current + const canMerge = + actionType === 'change' && + last?.type === 'change' && + now - last.timestamp < MERGE_INTERVAL && + cellIds.length > 0 && + cellIds.length === last.cellIds.length && + cellIds.every((id, i) => id === last.cellIds[i]) + if (canMerge) { + lastHistoryRef.current!.timestamp = now + setHistoryRecords(prev => { + const next = [...prev] + next[next.length - 1] = { ...next[next.length - 1], timestamp: now } + return next + }) + } else { + const record: HistoryRecord = { type: actionType, timestamp: now, batchName, cellIds } + lastHistoryRef.current = { cellIds, timestamp: now, type: actionType } + setHistoryRecords(prev => [...prev, record]) + } }) + + graphRef.current.on('history:undo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() }) + graphRef.current.on('history:redo', () => { if (!isSyncingRef.current) syncChildRelationshipsRef.current() }) }; // 显示/隐藏连接桩 // const showPorts = (show: boolean) => { @@ -569,13 +670,13 @@ export const useWorkflowGraph = ({ vo.setData({ ...data, isSelected: false, - }); + }, { silent: true }); } }); node.setData({ ...nodeData, isSelected: true, - }); + }, { silent: true }); clearEdgeSelect() if (nodeData.type !== 'notes') { setSelectedNode(node); @@ -589,7 +690,7 @@ export const useWorkflowGraph = ({ const edgeClick = ({ edge }: { edge: Edge }) => { clearEdgeSelect(); edge.setAttrByPath('line/stroke', edge_selected_color); - edge.setData({ ...edge.getData(), isSelected: true }); + edge.setData({ ...edge.getData(), isSelected: true }, { silent: true }); clearNodeSelect(); }; /** @@ -604,7 +705,7 @@ export const useWorkflowGraph = ({ node.setData({ ...data, isSelected: false, - }); + }, { silent: true }); } }); setSelectedNode(null); @@ -614,7 +715,7 @@ export const useWorkflowGraph = ({ */ const clearEdgeSelect = () => { graphRef.current?.getEdges().forEach(e => { - e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }); + e.setData({ ...e.getData(), isSelected: false, isNodeHover: false }, { silent: true }); e.setAttrByPath('line/stroke', edge_color); e.setAttrByPath('line/strokeWidth', edge_width); }); @@ -753,8 +854,6 @@ export const useWorkflowGraph = ({ // Find corresponding parent node const parentNode = nodes?.find(n => n.id === nodeData.cycle); if (parentNode) { - // Use removeChild method to delete child node - parentNode.removeChild(nodeToDelete); parentNodesToUpdate.push(parentNode); } // Add child node to deletion list @@ -782,42 +881,51 @@ export const useWorkflowGraph = ({ // Delete all collected nodes and edges if (cells.length > 0) { + // Pre-calculate which parents need an add-node restored (before removal changes the graph) + const parentsNeedingAddNode = parentNodesToUpdate + .filter(parentNode => { + const parentShape = parentNode.shape; + if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return false; + const parentData = parentNode.getData(); + const allChildren = graphRef.current!.getNodes().filter(n => n.getData()?.cycle === parentData.id); + const cycleStartNodes = allChildren.filter(n => n.getData()?.type === 'cycle-start'); + // After deletion, only cycle-start will remain + const nonCycleStartToDelete = cells.filter(c => + c.isNode() && + (c as Node).getData()?.cycle === parentData.id && + (c as Node).getData()?.type !== 'cycle-start' + ); + return cycleStartNodes.length === 1 && (allChildren.length - nonCycleStartToDelete.length) === 1; + }) + .map(parentNode => ({ + parentNode, + cycleStartNode: graphRef.current!.getNodes().find( + n => n.getData()?.cycle === parentNode.getData().id && n.getData()?.type === 'cycle-start' + )! + })) + .filter(({ cycleStartNode }) => !!cycleStartNode); + + graphRef.current?.startBatch('delete'); graphRef.current?.removeCells(cells); - // If parent is iteration/loop and only cycle-start remains, add add-node connected to it - parentNodesToUpdate.forEach(parentNode => { - const parentShape = parentNode.shape; - if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return; + parentsNeedingAddNode.forEach(({ parentNode, cycleStartNode }) => { const parentData = parentNode.getData(); - const remainingChildren = graphRef.current!.getNodes().filter( - n => n.getData()?.cycle === parentData.id - ); - const cycleStartNodes = remainingChildren.filter(n => n.getData()?.type === 'cycle-start'); - if (cycleStartNodes.length === 1 && remainingChildren.length === 1) { - const cycleStartNode = cycleStartNodes[0]; - const bbox = cycleStartNode.getBBox(); - const addNode = graphRef.current!.addNode({ - ...graphNodeLibrary.addStart, - x: bbox.x + 84, - y: bbox.y + 4, - data: { - type: 'add-node', - parentId: parentNode.id, - cycle: parentData.id, - label: t('workflow.addNode'), - icon: '+', - }, - }); - parentNode.addChild(addNode); - const sourcePort = cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right'; - const targetPort = addNode.getPorts().find(p => p.group === 'left')?.id || 'left'; - graphRef.current!.addEdge({ - source: { cell: cycleStartNode.id, port: sourcePort }, - target: { cell: addNode.id, port: targetPort }, - ...edgeAttrs, - }); - } + const bbox = cycleStartNode.getBBox(); + const addNode = graphRef.current!.addNode({ + ...graphNodeLibrary.addStart, + x: bbox.x + 84, + y: bbox.y + 4, + data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' }, + }); + parentNode.addChild(addNode, { silent: true }); + graphRef.current!.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, + target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }); }); + + graphRef.current?.stopBatch('delete'); } return false; }; @@ -1036,7 +1144,7 @@ export const useWorkflowGraph = ({ graphRef.current?.getConnectedEdges(node).forEach(edge => { if (!edge.getData()?.isSelected) { edge.setAttrByPath('line/stroke', edge_selected_color); - edge.setData({ ...edge.getData(), isNodeHover: true }); + edge.setData({ ...edge.getData(), isNodeHover: true }, { silent: true }); } }); }); @@ -1044,7 +1152,7 @@ export const useWorkflowGraph = ({ graphRef.current?.getConnectedEdges(node).forEach(edge => { if (!edge.getData()?.isSelected) { edge.setAttrByPath('line/stroke', edge_color); - edge.setData({ ...edge.getData(), isNodeHover: false }); + edge.setData({ ...edge.getData(), isNodeHover: false }, { silent: true }); } }); }); @@ -1126,8 +1234,8 @@ export const useWorkflowGraph = ({ // Delete selected nodes and edges graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); // Undo / Redo - graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; }); - graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; }); + graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { undo(); return false; }); + graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { redo(); return false; }); }; @@ -1193,13 +1301,51 @@ export const useWorkflowGraph = ({ }; if (dragData.type === 'loop' || dragData.type === 'iteration') { - graphRef.current.addNode({ + graph.disableHistory() + const parentNode = graphRef.current.addNode({ ...graphNodeLibrary[dragData.type], x: point.x - 150, y: point.y - 100, id: cleanNodeData.id, data: { ...cleanNodeData, isGroup: true }, - }); + }) + const parentBBox = parentNode.getBBox() + const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + const cycleStartNode = graphRef.current.addNode({ + ...graphNodeLibrary.cycleStart, + x: parentBBox.x + 24, + y: parentBBox.y + 70, + id: cycleStartId, + data: { id: cycleStartId, type: 'cycle-start', parentId: cleanNodeData.id, isDefault: true, cycle: cleanNodeData.id }, + }) + const addNode = graphRef.current.addNode({ + ...graphNodeLibrary.addStart, + x: parentBBox.x + 24 + 84, + y: parentBBox.y + 70 + 4, + data: { type: 'add-node', label: t('workflow.addNode'), icon: '+', parentId: cleanNodeData.id, cycle: cleanNodeData.id }, + }) + parentNode.addChild(cycleStartNode, { silent: true }) + parentNode.addChild(addNode, { silent: true }) + const newEdge = graphRef.current.addEdge({ + source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' }, + target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' }, + ...edgeAttrs, + }) + cycleStartNode.toFront() + addNode.toFront() + graph.enableHistory() + // Manually push a single batch frame covering all 4 cells into undoStack + const history = graph.getPlugin('history') as History + const makeBatchCmd = (cell: any) => ({ + batch: true, + event: 'cell:added', + data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() }, + options: {}, + }) + const batchFrame = [parentNode, cycleStartNode, addNode, newEdge].map(makeBatchCmd) + ;(history as any).undoStack.push(batchFrame) + ;(history as any).redoStack = [] + graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-group' } }) } else if (dragData.type === 'if-else') { // Create condition node graphRef.current.addNode({ @@ -1446,8 +1592,80 @@ export const useWorkflowGraph = ({ return userVars } - const undo = () => graphRef.current?.undo() - const redo = () => graphRef.current?.redo() + const clearHistoryRecords = () => { + setHistoryRecords([]) + lastHistoryRef.current = null + } + + const getStackCellIds = (cmds: any): string[] => { + const arr = Array.isArray(cmds) ? cmds : [cmds] + return [...new Set(arr.map((c: any) => c.data?.id).filter(Boolean))] + } + + const isSkippableFrame = (frame: any): boolean => { + const arr = Array.isArray(frame) ? frame : [frame] + return arr.every((c: any) => ['zIndex', 'attrs', 'tools'].includes(c.data?.key)) + } + + const undo = () => { + const history = graphRef.current?.getPlugin('history') as History | undefined + if (!history || history.getUndoSize() === 0) return + const undoStack = (history as any).undoStack as any[] + isSyncingRef.current = true + while (undoStack.length > 0 && isSkippableFrame(undoStack[undoStack.length - 1])) { + graphRef.current!.undo() + } + if (undoStack.length === 0) { + isSyncingRef.current = false + return + } + const topIds = getStackCellIds(undoStack[undoStack.length - 1]) + graphRef.current!.undo() + while (undoStack.length > 0) { + if (isSkippableFrame(undoStack[undoStack.length - 1])) { + graphRef.current!.undo() + continue + } + const nextIds = getStackCellIds(undoStack[undoStack.length - 1]) + if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) { + graphRef.current!.undo() + } else { + break + } + } + isSyncingRef.current = false + syncChildRelationships() + } + + const redo = () => { + const history = graphRef.current?.getPlugin('history') as History | undefined + if (!history || history.getRedoSize() === 0) return + const redoStack = (history as any).redoStack as any[] + isSyncingRef.current = true + while (redoStack.length > 0 && isSkippableFrame(redoStack[redoStack.length - 1])) { + graphRef.current!.redo() + } + if (redoStack.length === 0) { + isSyncingRef.current = false + return + } + const topIds = getStackCellIds(redoStack[redoStack.length - 1]) + graphRef.current!.redo() + while (redoStack.length > 0) { + if (isSkippableFrame(redoStack[redoStack.length - 1])) { + graphRef.current!.redo() + continue + } + const nextIds = getStackCellIds(redoStack[redoStack.length - 1]) + if (nextIds.length === topIds.length && nextIds.every((id, i) => id === topIds[i])) { + graphRef.current!.redo() + } else { + break + } + } + isSyncingRef.current = false + syncChildRelationships() + } const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => { const { statement = '' } = value?.opening_statement || {} @@ -1488,20 +1706,16 @@ export const useWorkflowGraph = ({ if (!graphRef.current) return; const nodes = graphRef.current.getNodes(); - const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length); - // Reset all node execution status first + // Reset all node execution status on every chatHistory change nodes.forEach(node => { const data = node.getData(); - if (typeof data.executionStatus === 'string') { - node.setData({ ...data, executionStatus: undefined }); - } + node.setData({ ...data, executionStatus: '' }); }); - if (!lastWithSub?.subContent) return; - // Build a nodeId -> status map first - const statusMap: Record = {}; - lastWithSub.subContent.forEach(sub => { + + const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant'); + if (!lastAssistant?.subContent?.length) return; + lastAssistant.subContent.forEach(sub => { if (typeof sub.status === 'string') { - statusMap[sub.node_id] = sub.status; const node = nodes.find(n => n.getData()?.id === sub.node_id); if (node) { node.setData({ ...node.getData(), executionStatus: sub.status }); @@ -1537,5 +1751,7 @@ export const useWorkflowGraph = ({ canRedo, undo, redo, + historyRecords, + clearHistoryRecords, }; }; diff --git a/web/src/views/Workflow/types.ts b/web/src/views/Workflow/types.ts index 1604aac2..16a64632 100644 --- a/web/src/views/Workflow/types.ts +++ b/web/src/views/Workflow/types.ts @@ -113,4 +113,13 @@ export interface ChatVariable { } export interface AddChatVariableRef { handleOpen: (value?: ChatVariable) => void; +} + +export type HistoryActionType = 'add' | 'remove' | 'change' | 'undo' | 'redo' | 'batch' + +export interface HistoryRecord { + type: HistoryActionType; + timestamp: number; + batchName?: string; + cellIds?: string[]; } \ No newline at end of file diff --git a/web/src/views/Workflow/utils.ts b/web/src/views/Workflow/utils.ts index 74dfca2c..a4035517 100644 --- a/web/src/views/Workflow/utils.ts +++ b/web/src/views/Workflow/utils.ts @@ -17,6 +17,7 @@ export const isSubExprSet = (sub: any) => { * Uses the same per-expression height logic as getConditionNodeCasePortY. */ export const calcConditionNodeTotalHeight = (cases: any[]) => { + if (!cases?.length) return conditionNodeHeight; const casesHeight = cases.reduce((acc: number, c: any) => { const exprs = c?.expressions ?? []; const n = exprs.length;