Compare commits

..

45 Commits

Author SHA1 Message Date
Ke Sun
feae2f2e1e Merge pull request #1033 from SuanmoSuanyangTechnology/release/v0.3.2
Release/v0.3.2
2026-04-30 11:12:12 +08:00
Mark
415234d4c8 Merge pull request #1032 from SuanmoSuanyangTechnology/fix/sandbox
feat(core): add configurable SANDBOX_URL for code node sandbox requests
2026-04-29 20:26:55 +08:00
Eternity
e38a60e107 feat(core): add configurable SANDBOX_URL for code node sandbox requests 2026-04-29 20:24:10 +08:00
yingzhao
86eb08c73f Merge pull request #1027 from SuanmoSuanyangTechnology/fix/release0.3.2_zy
fix(web): node executionStatus update remove silent
2026-04-29 12:26:26 +08:00
zhaoying
53f1b0e586 fix(web): node executionStatus update remove silent 2026-04-29 12:24:34 +08:00
yingzhao
49cc47a79a Merge pull request #1026 from SuanmoSuanyangTechnology/fix/release0.3.2_zy
fix(web): ontology tag
2026-04-29 12:17:40 +08:00
zhaoying
1817f52edf fix(web): ontology tag 2026-04-29 11:55:43 +08:00
山程漫悟
40633d72c3 Merge pull request #1024 from SuanmoSuanyangTechnology/fix/Timebomb_032
fix(workspace)
2026-04-28 18:37:50 +08:00
Timebomb2018
6f10296969 fix(workspace): deactivate user when removed from last active workspace 2026-04-28 18:34:06 +08:00
yingzhao
89228825cf Merge pull request #1023 from SuanmoSuanyangTechnology/fix/v0.3.2_zy
fix(web): workflow redo/undo
2026-04-28 17:41:45 +08:00
zhaoying
cab4deb2ff fix(web): workflow redo/undo 2026-04-28 17:37:59 +08:00
Ke Sun
4048a10858 ci: add GitHub Actions workflow to sync all branches and tags to Gitee 2026-04-28 16:44:50 +08:00
yingzhao
d6ef0f4923 Merge pull request #1022 from SuanmoSuanyangTechnology/fix/v0.3.2_zy
fix(web): thinking_budget_tokens add min & default value
2026-04-28 16:18:11 +08:00
zhaoying
75fbe44839 fix(web): add min validator 2026-04-28 16:17:31 +08:00
山程漫悟
06597c567b Merge pull request #1019 from SuanmoSuanyangTechnology/fix/Timebomb_032
fix(workspace)
2026-04-28 16:11:44 +08:00
Timebomb2018
28694fefb0 fix(app): adjust thinking budget tokens default and validation range
The default thinking budget tokens value was changed from 10000 to 1024 in base.py, and the minimum validation constraint was updated from 1024 to 1 in app_schema.py to allow smaller budgets while maintaining backward compatibility.
2026-04-28 16:10:44 +08:00
zhaoying
7a0f08148e fix(web): thinking_budget_tokens add min & default value 2026-04-28 16:10:18 +08:00
Timebomb2018
d3058ce379 fix(workspace): make delete workspace member async and invalidate user tokens 2026-04-28 15:04:13 +08:00
Ke Sun
8d88df391d Merge pull request #1017 from SuanmoSuanyangTechnology/revert-1016-feat/episodic-memory-detail-and-pagination
Revert "refactor(memory): replace raw dict responses with Pydantic schema mod…"
2026-04-27 18:50:43 +08:00
Ke Sun
7621321d1b Revert "refactor(memory): replace raw dict responses with Pydantic schema mod…" 2026-04-27 18:50:26 +08:00
Ke Sun
0e29b0b2a5 Merge pull request #1016 from SuanmoSuanyangTechnology/feat/episodic-memory-detail-and-pagination
refactor(memory): replace raw dict responses with Pydantic schema mod…
2026-04-27 18:43:53 +08:00
lanceyq
2fa4d29548 fix(memory): use explicit None checks and remove unnecessary Optional type
- Replace truthiness checks with 'is not None' for data.message in graph_data and community_graph endpoints to handle empty string correctly
- Remove Optional wrapper from GraphStatistics.edge_types since it already has a default_factory
2026-04-27 18:39:33 +08:00
yingzhao
7bb181c1c7 Merge pull request #1014 from SuanmoSuanyangTechnology/fix/v0.3.2_zy
Fix/v0.3.2 zy
2026-04-27 18:07:10 +08:00
zhaoying
a9c87b03ff Merge branch 'fix/v0.3.2_zy' of github.com:SuanmoSuanyangTechnology/MemoryBear into fix/v0.3.2_zy 2026-04-27 18:05:59 +08:00
zhaoying
720af8d261 fix(web): file icon 2026-04-27 18:04:55 +08:00
山程漫悟
09d32ed446 Merge pull request #1015 from SuanmoSuanyangTechnology/fix/Timebomb_032
fix(multimodal)
2026-04-27 18:01:12 +08:00
lanceyq
9a5ce7f7c6 refactor(memory): replace raw dict responses with Pydantic schema models in user memory controllers
- Add user_memory_schema.py with typed Pydantic models for all user memory
  API responses: MemoryInsightReportData, UserSummaryData, GraphData,
  MemoryTypeStatItem, cache result models, and RelationshipEvolutionData
- Refactor user_memory_controllers.py to construct schema instances and
  return model_dump() instead of raw dicts
- Remove unused imports (datetime, timestamp_to_datetime, EndUserInfoResponse,
  EndUserInfoCreate, EndUser)
2026-04-27 17:57:06 +08:00
Timebomb2018
531d785629 fix(multimodal): support HTML image tags in document extraction and chat responses
- Replace plain image URLs with `<img src="..." data-url="...">` HTML tags in multimodal and document extractor services
- Propagate citations from workflow end events to client responses
- Update system prompts to instruct LLMs to render images using Markdown `![alt](url)` with strict UUID-preserving URL copying
2026-04-27 17:56:58 +08:00
zhaoying
6d80d74f4a Merge branch 'fix/v0.3.2_zy' of github.com:SuanmoSuanyangTechnology/MemoryBear into fix/v0.3.2_zy 2026-04-27 17:55:51 +08:00
Ke Sun
3d9882643e ci: add GitHub Actions workflow to sync all branches and tags to Gitee 2026-04-27 17:48:35 +08:00
zhaoying
b4e4be1133 fix(web): chat file icon 2026-04-27 17:42:56 +08:00
zhaoying
16926d9db5 fix(web): tool node config reset 2026-04-27 17:10:02 +08:00
zhaoying
f369a63c8d fix(web): loop & iteration child node history 2026-04-27 16:31:10 +08:00
zhaoying
1861b0fbc9 Merge branch 'fix/v0.3.2_zy' of github.com:SuanmoSuanyangTechnology/MemoryBear into fix/v0.3.2_zy 2026-04-27 16:07:20 +08:00
zhaoying
750d4ca841 fix(web): custom tool schema api add case
Co-authored-by: Copilot <copilot@github.com>
2026-04-27 16:04:02 +08:00
山程漫悟
ce4a3daec7 Merge pull request #1012 from SuanmoSuanyangTechnology/fix/wxy-032
feat(workflow): augment logging queries and ameliorate error handling
2026-04-27 16:00:49 +08:00
山程漫悟
c12d06bb07 Merge pull request #1013 from SuanmoSuanyangTechnology/fix/Timebomb_032
fix(workflow)
2026-04-27 15:51:18 +08:00
Timebomb2018
98d8d7b261 fix(conversation_schema): refine citations field type to Dict[str, Any] 2026-04-27 15:49:21 +08:00
Timebomb2018
12a08a487d fix(tool_controller): re-raise HTTPException to preserve original status codes 2026-04-27 15:47:34 +08:00
Timebomb2018
f7fa33c0c4 Merge remote-tracking branch 'origin/release/v0.3.2' into fix/Timebomb_032 2026-04-27 15:36:03 +08:00
Timebomb2018
faf8d1a51a fix(workflow): add reasoning content, suggested questions, citations and audio status support
- Introduce `reasoning_content`, `suggested_questions`, `citations`, and `audio_status` fields in conversation and app response schemas
- Conditionally set `audio_status` to `"pending"` only when `audio_url` is present
- Replace `model_dump` override with `@model_serializer(mode="wrap")` for cleaner serialization logic
- Change knowledge base validation failure from `RuntimeError` to warning + `continue` to avoid halting retrieval on invalid KB
2026-04-27 15:35:26 +08:00
zhaoying
8baa466b31 fix(web): loop & iteration history 2026-04-27 15:00:49 +08:00
zhaoying
dd7f9f6cee fix(web): output type node only has left port 2026-04-27 14:08:02 +08:00
zhaoying
d5d81f0c4f fix(web): node execution status reset 2026-04-27 13:47:49 +08:00
zhaoying
610ae27cf9 fix(web): switch space 2026-04-27 10:48:03 +08:00
36 changed files with 708 additions and 571 deletions

View File

@@ -3,12 +3,9 @@ name: Sync to Gitee
on: on:
push: push:
branches: branches:
- main # Production - '**' # All branchs
- develop # Integration
- 'release/*' # Release preparation
- 'hotfix/*' # Urgent fixes
tags: tags:
- '*' # All version tags (v1.0.0, etc.) - '**' # All version tags (v1.0.0, etc.)
jobs: jobs:
sync: sync:

View File

@@ -296,7 +296,7 @@ async def chat(
} }
) )
# 多 Agent 非流式返回 # workflow 非流式返回
result = await app_chat_service.workflow_chat( result = await app_chat_service.workflow_chat(
message=payload.message, message=payload.message,

View File

@@ -173,6 +173,8 @@ async def delete_tool(
return success(msg="工具删除成功") return success(msg="工具删除成功")
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@@ -249,6 +251,8 @@ async def parse_openapi_schema(
if result["success"] is False: if result["success"] is False:
raise HTTPException(status_code=400, detail=result["message"]) raise HTTPException(status_code=400, detail=result["message"])
return success(data=result, msg="Schema解析完成") return success(data=result, msg="Schema解析完成")
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -221,7 +221,7 @@ def update_workspace_members(
@router.delete("/members/{member_id}", response_model=ApiResponse) @router.delete("/members/{member_id}", response_model=ApiResponse)
@cur_workspace_access_guard() @cur_workspace_access_guard()
def delete_workspace_member( async def delete_workspace_member(
member_id: uuid.UUID, member_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
@@ -230,7 +230,7 @@ def delete_workspace_member(
workspace_id = current_user.current_workspace_id workspace_id = current_user.current_workspace_id
api_logger.info(f"用户 {current_user.username} 请求删除工作空间 {workspace_id} 的成员 {member_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, db=db,
workspace_id=workspace_id, workspace_id=workspace_id,
member_id=member_id, member_id=member_id,

View File

@@ -241,6 +241,8 @@ class Settings:
SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587")) SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER: str = os.getenv("SMTP_USER", "") SMTP_USER: str = os.getenv("SMTP_USER", "")
SMTP_PASSWORD: str = os.getenv("SMTP_PASSWORD", "") 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")) REFLECTION_INTERVAL_SECONDS: float = float(os.getenv("REFLECTION_INTERVAL_SECONDS", "300"))
HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600")) HEALTH_CHECK_SECONDS: float = float(os.getenv("HEALTH_CHECK_SECONDS", "600"))

View File

@@ -216,7 +216,7 @@ class RedBearModelFactory:
# 深度思考模式Claude 3.7 Sonnet 等支持思考的模型 # 深度思考模式Claude 3.7 Sonnet 等支持思考的模型
# 通过 additional_model_request_fields 传递 thinking 块关闭时不传Bedrock 无 disabled 选项) # 通过 additional_model_request_fields 传递 thinking 块关闭时不传Bedrock 无 disabled 选项)
if config.deep_thinking: if config.deep_thinking:
budget = config.thinking_budget_tokens or 10000 budget = config.thinking_budget_tokens or 1024
params["additional_model_request_fields"] = { params["additional_model_request_fields"] = {
"thinking": {"type": "enabled", "budget_tokens": budget} "thinking": {"type": "enabled", "budget_tokens": budget}
} }

View File

@@ -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 import BaseNode
from app.core.workflow.nodes.code.config import CodeNodeConfig from app.core.workflow.nodes.code.config import CodeNodeConfig
from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE from app.core.workflow.variable.base_variable import VariableType, DEFAULT_VALUE
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -131,7 +132,7 @@ class CodeNode(BaseNode):
async with httpx.AsyncClient(timeout=60) as client: async with httpx.AsyncClient(timeout=60) as client:
response = await client.post( response = await client.post(
"http://sandbox:8194/v1/sandbox/run", f"{settings.SANDBOX_URL}:8194/v1/sandbox/run",
headers={ headers={
"x-api-key": 'redbear-sandbox' "x-api-key": 'redbear-sandbox'
}, },

View File

@@ -182,7 +182,7 @@ class DocExtractorNode(BaseNode):
mime_type=f"image/{ext}", mime_type=f"image/{ext}",
is_file=True, is_file=True,
).model_dump()) ).model_dump())
text = text + f"\n{placeholder}: {url}" text = text + f"\n{placeholder}: <img src=\"{url}\" data-url=\"{url}\">"
except Exception as e: except Exception as e:
logger.error(f"Node {self.node_id}: failed to save image {placeholder}: {e}") logger.error(f"Node {self.node_id}: failed to save image {placeholder}: {e}")

View File

@@ -334,7 +334,8 @@ class KnowledgeRetrievalNode(BaseNode):
for kb_config in knowledge_bases: for kb_config in knowledge_bases:
db_knowledge = knowledge_repository.get_knowledge_by_id(db=db, knowledge_id=kb_config.kb_id) db_knowledge = knowledge_repository.get_knowledge_by_id(db=db, knowledge_id=kb_config.kb_id)
if not (db_knowledge and db_knowledge.chunk_num > 0 and db_knowledge.status == 1): if not (db_knowledge and db_knowledge.chunk_num > 0 and db_knowledge.status == 1):
raise RuntimeError("The knowledge base does not exist or access is denied.") logger.warning("The knowledge base does not exist or access is denied.")
continue
tasks.append(self.knowledge_retrieval(db, query, db_knowledge, kb_config)) tasks.append(self.knowledge_retrieval(db, query, db_knowledge, kb_config))
if tasks: if tasks:
result = await asyncio.gather(*tasks) result = await asyncio.gather(*tasks)

View File

@@ -3,7 +3,7 @@ import uuid
from typing import Optional, Any, List, Dict, Union from typing import Optional, Any, List, Dict, Union
from enum import Enum, StrEnum from enum import Enum, StrEnum
from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator from pydantic import BaseModel, Field, ConfigDict, field_serializer, field_validator, model_serializer
from app.schemas.workflow_schema import WorkflowConfigCreate from app.schemas.workflow_schema import WorkflowConfigCreate
@@ -250,7 +250,7 @@ class ModelParameters(BaseModel):
n: int = Field(default=1, ge=1, le=10, description="生成的回复数量") n: int = Field(default=1, ge=1, le=10, description="生成的回复数量")
stop: Optional[List[str]] = Field(default=None, description="停止序列") stop: Optional[List[str]] = Field(default=None, description="停止序列")
deep_thinking: bool = Field(default=False, description="是否启用深度思考模式(需模型支持,如 DeepSeek-R1、QwQ 等)") 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 能力)") json_output: bool = Field(default=False, description="是否强制 JSON 格式输出(需模型支持 json_output 能力)")
@@ -661,9 +661,11 @@ class DraftRunResponse(BaseModel):
suggested_questions: List[str] = Field(default_factory=list, description="下一步建议问题") suggested_questions: List[str] = Field(default_factory=list, description="下一步建议问题")
citations: List[Dict[str, Any]] = Field(default_factory=list, description="引用来源") citations: List[Dict[str, Any]] = Field(default_factory=list, description="引用来源")
audio_url: Optional[str] = Field(default=None, description="TTS 语音URL") audio_url: Optional[str] = Field(default=None, description="TTS 语音URL")
audio_status: Optional[str] = Field(default=None, description="TTS 语音状态")
def model_dump(self, **kwargs): @model_serializer(mode="wrap")
data = super().model_dump(**kwargs) def _serialize(self, handler):
data = handler(self)
if not data.get("reasoning_content"): if not data.get("reasoning_content"):
data.pop("reasoning_content", None) data.pop("reasoning_content", None)
return data return data

View File

@@ -2,7 +2,7 @@
import uuid import uuid
import datetime import datetime
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from pydantic import BaseModel, Field, ConfigDict, field_serializer from pydantic import BaseModel, Field, ConfigDict, field_serializer, model_serializer
# 导入 FileInput用于体验运行 # 导入 FileInput用于体验运行
from app.schemas.app_schema import FileInput from app.schemas.app_schema import FileInput
@@ -94,6 +94,18 @@ class ChatResponse(BaseModel):
message_id: str message_id: str
usage: Optional[Dict[str, Any]] = None usage: Optional[Dict[str, Any]] = None
elapsed_time: Optional[float] = None elapsed_time: Optional[float] = None
reasoning_content: Optional[str] = None
suggested_questions: Optional[List[str]] = None
citations: Optional[List[Dict[str, Any]]] = None
audio_url: Optional[str] = None
audio_status: Optional[str] = None
@model_serializer(mode="wrap")
def _serialize(self, handler):
data = handler(self)
if not data.get("reasoning_content"):
data.pop("reasoning_content", None)
return data
# ---------- Conversation Summary Schemas ---------- # ---------- Conversation Summary Schemas ----------

View File

@@ -161,7 +161,10 @@ class AppChatService:
f.type == FileType.DOCUMENT for f in files f.type == FileType.DOCUMENT for f in files
): ):
system_prompt += ( system_prompt += (
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: <img src=\"url\"...>"
"请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。"
"重要:图片 URL 中包含 UUID如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。"
) )
# 创建 LangChain Agent # 创建 LangChain Agent
@@ -317,7 +320,7 @@ class AppChatService:
"suggested_questions": suggested_questions, "suggested_questions": suggested_questions,
"citations": filtered_citations, "citations": filtered_citations,
"audio_url": audio_url, "audio_url": audio_url,
"audio_status": "pending" "audio_status": "pending" if audio_url else None
} }
async def agnet_chat_stream( async def agnet_chat_stream(
@@ -448,7 +451,10 @@ class AppChatService:
): ):
from langchain.agents import create_agent from langchain.agents import create_agent
system_prompt += ( system_prompt += (
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: <img src=\"url\"...>"
"请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。"
"重要:图片 URL 中包含 UUID如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。"
) )
# 创建 LangChain Agent # 创建 LangChain Agent

View File

@@ -650,7 +650,10 @@ class AgentRunService:
) )
if has_doc_with_images: if has_doc_with_images:
system_prompt += ( system_prompt += (
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: <img src=\"url\"...>"
"请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。"
"重要:图片 URL 中包含 UUID如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。"
) )
agent = LangChainAgent( agent = LangChainAgent(
@@ -754,7 +757,7 @@ class AgentRunService:
) if not sub_agent else [], ) if not sub_agent else [],
"citations": filtered_citations, "citations": filtered_citations,
"audio_url": audio_url, "audio_url": audio_url,
"audio_status": "pending" "audio_status": "pending" if audio_url else None
} }
logger.info( logger.info(
@@ -924,7 +927,10 @@ class AgentRunService:
) )
if has_doc_with_images: if has_doc_with_images:
system_prompt += ( system_prompt += (
"\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: http://...,请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。" "\n\n文档文字中包含图片位置标记如 [图片 第2页 第1张]: <img src=\"url\"...>"
"请在回答中用 Markdown 格式 ![图片描述](url) 展示对应图片。"
"重要:图片 URL 中包含 UUID如 /storage/permanent/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
"必须将 src 属性的值原封不动复制到 Markdown 的括号中,不得增删任何字符。"
) )
# 创建 LangChain Agent # 创建 LangChain Agent

View File

@@ -400,7 +400,7 @@ class MultimodalService:
# 在文本内容中追加图片位置标记 # 在文本内容中追加图片位置标记
if result and result[-1].get("type") in ("text", "document"): if result and result[-1].get("type") in ("text", "document"):
key = "text" if "text" in result[-1] else list(result[-1].keys())[-1] 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 src=\"{img_url}\" data-url=\"{img_url}\">"
# 将图片以视觉格式追加到消息内容中 # 将图片以视觉格式追加到消息内容中
img_file = FileInput( img_file = FileInput(
type=FileType.IMAGE, type=FileType.IMAGE,

View File

@@ -554,13 +554,16 @@ class WorkflowService:
} }
} }
case "workflow_end": 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 { return {
"event": "end", "event": "end",
"data": { "data": data
"elapsed_time": payload.get("elapsed_time"),
"message_length": len(payload.get("output", "")),
"error": payload.get("error", "")
}
} }
case "node_start" | "node_end" | "node_error" | "cycle_item": case "node_start" | "node_end" | "node_error" | "cycle_item":
return None return None

View File

@@ -20,6 +20,7 @@ from app.models.workspace_model import (
) )
from app.repositories import workspace_repository from app.repositories import workspace_repository
from app.repositories.workspace_invite_repository import WorkspaceInviteRepository from app.repositories.workspace_invite_repository import WorkspaceInviteRepository
from app.services.session_service import SessionService
from app.schemas.workspace_schema import ( from app.schemas.workspace_schema import (
InviteAcceptRequest, InviteAcceptRequest,
InviteValidateResponse, InviteValidateResponse,
@@ -58,7 +59,7 @@ def switch_workspace(
raise BusinessException(f"切换工作空间失败: {str(e)}", BizCode.INTERNAL_ERROR) raise BusinessException(f"切换工作空间失败: {str(e)}", BizCode.INTERNAL_ERROR)
def delete_workspace_member( async def delete_workspace_member(
db: Session, db: Session,
workspace_id: uuid.UUID, workspace_id: uuid.UUID,
member_id: uuid.UUID, member_id: uuid.UUID,
@@ -76,10 +77,29 @@ def delete_workspace_member(
BizCode.WORKSPACE_NOT_FOUND) BizCode.WORKSPACE_NOT_FOUND)
try: try:
deleted_user = workspace_member.user
workspace_member.is_active = False 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() db.commit()
business_logger.info(f"用户 {user.username} 成功删除工作空间 {workspace_id} 的成员 {member_id}") 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: except Exception as e:
db.rollback() db.rollback()
business_logger.error(f"删除工作空间成员失败 - 工作空间: {workspace_id}, 成员: {member_id}, 错误: {str(e)}") business_logger.error(f"删除工作空间成员失败 - 工作空间: {workspace_id}, 成员: {member_id}, 错误: {str(e)}")

View File

@@ -8,12 +8,11 @@ import { type FC, useRef, useEffect, useState } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import Markdown from '@/components/Markdown' import Markdown from '@/components/Markdown'
import type { ChatContentProps } from './types' 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 { SoundOutlined } from '@ant-design/icons'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import AudioPlayer from './AudioPlayer' import MessageFiles from './MessageFiles'
import VideoPlayer from './VideoPlayer'
const getFileUrl = (file: any) => { const getFileUrl = (file: any) => {
return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined) return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
@@ -149,72 +148,7 @@ const ChatContent: FC<ChatContentProps> = ({
{labelFormat(item)} {labelFormat(item)}
</div> </div>
} }
{item?.meta_data?.files && item.meta_data?.files.length > 0 && <Flex gap={8} vertical align="end" className="rb:mb-2!"> <MessageFiles files={item.meta_data?.files ?? []} contentClassNames={contentClassNames} onDownload={handleDownload} />
{item.meta_data?.files?.map((file) => {
if (file.type.includes('image')) {
return (
<div key={file.url || file.uid} className={`rb:inline-block rb:group rb:relative rb:rounded-lg ${contentClassNames}`}>
<Image src={getFileUrl(file)} alt={file.name} className="rb:w-full rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
</div>
)
}
if (file.type.includes('video')) {
return (
<div key={file.url || file.uid} className="rb:w-50">
{/* <video src={getFileUrl(file)} controls className="rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" /> */}
<VideoPlayer key={file.url || file.uid} src={getFileUrl(file)} />
</div>
)
}
if (file.type.includes('audio')) {
return (
<div key={file.url || file.uid} className="rb:w-50">
<AudioPlayer key={file.url || file.uid} src={getFileUrl(file)} />
</div>
)
}
const documentType = (file.file_type || file.type)?.split('/')
return (
<Flex
key={file.url || file.uid}
align="center"
gap={10}
className="rb:text-left rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb-border rb:py-2! rb:px-2.5! rb:border rb:border-[#F6F6F6]"
onClick={() => handleDownload(file)}
>
<div
className={clsx(
"rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]",
file.type?.includes('pdf')
? "rb:bg-[url('@/assets/images/file/pdf.svg')]"
: (file.type?.includes('excel') || file.type?.includes('spreadsheetml.sheet')) || file.type?.includes('xls') || file.type?.includes('xlsx')
? "rb:bg-[url('@/assets/images/file/excel.svg')]"
: file.type?.includes('csv')
? "rb:bg-[url('@/assets/images/file/csv.svg')]"
: file.type?.includes('html')
? "rb:bg-[url('@/assets/images/file/html.svg')]"
: file.type?.includes('json')
? "rb:bg-[url('@/assets/images/file/json.svg')]"
: file.type?.includes('ppt')
? "rb:bg-[url('@/assets/images/file/ppt.svg')]"
: file.type?.includes('markdown')
? "rb:bg-[url('@/assets/images/file/md.svg')]"
: file.type?.includes('text')
? "rb:bg-[url('@/assets/images/file/txt.svg')]"
: (file.type?.includes('doc') || file.type?.includes('docx') || file.type?.includes('word') || file.type?.includes('wordprocessingml.document'))
? "rb:bg-[url('@/assets/images/file/word.svg')]"
: "rb:bg-[url('@/assets/images/file/txt.svg')]"
)}
></div>
<div className="rb:flex-1 rb:w-32.5">
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{documentType?.[documentType.length - 1]} · {file.size}</div>
</div>
</Flex>
)
})}
</Flex>}
{/* Message bubble */} {/* Message bubble */}
<div className={clsx('rb:text-left rb:leading-5 rb:inline-block rb:wrap-break-word rb:relative', item.role === 'user' ? contentClassNames : '', { <div className={clsx('rb:text-left rb:leading-5 rb:inline-block rb:wrap-break-word rb:relative', item.role === 'user' ? contentClassNames : '', {
// Error message style (content is null and not assistant message) // Error message style (content is null and not assistant message)

View File

@@ -0,0 +1,87 @@
import { Image, Flex } from 'antd'
import clsx from 'clsx'
import AudioPlayer from './AudioPlayer'
import VideoPlayer from './VideoPlayer'
const getFileUrl = (file: any) =>
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<string, boolean>
onDownload: (file: any) => void
}
const MessageFiles = ({ files, contentClassNames, onDownload }: MessageFilesProps) => {
if (!files?.length) return null
return (
<Flex gap={8} vertical align="end" className="rb:mb-2!">
{files.map((file) => {
const key = file.url || file.uid
if (file.type.includes('image')) {
return (
<div key={key} className={clsx('rb:inline-block rb:group rb:relative rb:rounded-lg', contentClassNames)}>
<Image src={getFileUrl(file)} alt={file.name} className="rb:w-full rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
</div>
)
}
if (file.type.includes('video')) {
return (
<div key={key} className="rb:w-50">
<VideoPlayer src={getFileUrl(file)} />
</div>
)
}
if (file.type.includes('audio')) {
return (
<div key={key} className="rb:w-50">
<AudioPlayer src={getFileUrl(file)} />
</div>
)
}
const documentType = (file.file_type || file.type)?.split('/') ?? []
return (
<Flex
key={key}
align="center"
gap={10}
className="rb:text-left rb:w-45 rb:text-[12px] rb:group rb:relative rb:rounded-lg rb-border rb:py-2! rb:px-2.5! rb:border rb:border-[#F6F6F6]"
onClick={() => onDownload(file)}
>
<div
className={clsx(
"rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')]",
getDocIcon(documentType)
)}
/>
<div className="rb:flex-1 rb:w-32.5">
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
{documentType?.[documentType.length - 1]} · {file.size}
</div>
</div>
</Flex>
)
})}
</Flex>
)
}
export default MessageFiles

View File

@@ -3,14 +3,14 @@ import { Popover, type PopoverProps } from 'antd'
import Tag, { type TagProps } from '@/components/Tag' import Tag, { type TagProps } from '@/components/Tag'
interface OverflowTagsProps { interface OverflowTagsProps {
items: ReactNode[]; items?: ReactNode[];
gap?: number; gap?: number;
numTagColor?: TagProps['color']; numTagColor?: TagProps['color'];
numTag?: (num?: number) => ReactNode; numTag?: (num?: number) => ReactNode;
popoverProps?: PopoverProps | false; 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<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const measureRef = useRef<HTMLDivElement>(null) const measureRef = useRef<HTMLDivElement>(null)
const [visibleCount, setVisibleCount] = useState(items.length) const [visibleCount, setVisibleCount] = useState(items.length)
@@ -20,7 +20,7 @@ const OverflowTags = ({ items, gap = 8, numTagColor = 'default', numTag, popover
if (!measure || containerWidth === 0) return if (!measure || containerWidth === 0) return
const children = Array.from(measure.children) as HTMLElement[] 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 // last child is the sample +N tag
const extraTagWidth = (children[children.length - 1] as HTMLElement).offsetWidth const extraTagWidth = (children[children.length - 1] as HTMLElement).offsetWidth

View File

@@ -399,7 +399,7 @@ const Menu: FC<{
className="rb:overflow-y-auto rb:flex-1!" className="rb:overflow-y-auto rb:flex-1!"
/> />
{/* Return to space button for superusers */} {/* Return to space button for superusers */}
{user?.is_superuser && source === 'space' && {source === 'space' &&
<Flex gap={4} vertical className="rb:my-3! rb:mx-3!"> <Flex gap={4} vertical className="rb:my-3! rb:mx-3!">
<Divider className="rb:mb-2.5! rb:mt-0! rb:border-[#DFE4ED]! rb:mx-2! rb:min-w-[calc(100%-20px)]! rb:w-[calc(100%-20px)]!" /> <Divider className="rb:mb-2.5! rb:mt-0! rb:border-[#DFE4ED]! rb:mx-2! rb:min-w-[calc(100%-20px)]! rb:w-[calc(100%-20px)]!" />
<Flex <Flex
@@ -412,16 +412,18 @@ const Menu: FC<{
<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/switch.svg')]"></div> <div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/switch.svg')]"></div>
{collapsed ? null : t('common.switchSpace')} {collapsed ? null : t('common.switchSpace')}
</Flex> </Flex>
<Flex {user?.is_superuser &&
gap={8} <Flex
align="center" gap={8}
justify="start" align="center"
onClick={goToSpace} justify="start"
className="rb:p-2.5! rb:text-[13px] rb:hover:bg-[rgba(223,228,237,0.5)] rb:rounded-lg rb:leading-3.5 rb:font-regular rb:text-center rb:cursor-pointer" onClick={goToSpace}
> className="rb:p-2.5! rb:text-[13px] rb:hover:bg-[rgba(223,228,237,0.5)] rb:rounded-lg rb:leading-3.5 rb:font-regular rb:text-center rb:cursor-pointer"
<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/return.svg')]"></div> >
{collapsed ? null : t('common.returnToSpace')} <div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/menuNew/return.svg')]"></div>
</Flex> {collapsed ? null : t('common.returnToSpace')}
</Flex>
}
</Flex> </Flex>
} }
{source === 'manage' && subscription && !collapsed && {source === 'manage' && subscription && !collapsed &&

View File

@@ -1538,6 +1538,7 @@ export const en = {
json_output: 'Support JSON formatted output', json_output: 'Support JSON formatted output',
thinking_budget_tokens: 'thinking budget tokens', thinking_budget_tokens: 'thinking budget tokens',
thinking_budget_tokens_max_error: "Cannot exceed the max tokens limit ({{max}})", 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', logSearchPlaceholder: 'Search log content',
}, },
userMemory: { userMemory: {

View File

@@ -868,6 +868,7 @@ export const zh = {
json_output: '支持JSON格式化输出', json_output: '支持JSON格式化输出',
thinking_budget_tokens: '深度思考预算Token数', thinking_budget_tokens: '深度思考预算Token数',
thinking_budget_tokens_max_error: "不能超过 最大令牌数 ({{max}})", thinking_budget_tokens_max_error: "不能超过 最大令牌数 ({{max}})",
thinking_budget_tokens_min_error: "不能小于 {{min}}",
logSearchPlaceholder: '搜索日志内容', logSearchPlaceholder: '搜索日志内容',
}, },
table: { table: {

View File

@@ -49,6 +49,8 @@ const configFields = [
{ key: 'n', max: 10, min: 1, step: 1, defaultValue: 1 }, { key: 'n', max: 10, min: 1, step: 1, defaultValue: 1 },
] ]
const minThinkingBudgetTokens = 128;
const defaultThinkingBudgetTokens = 1000;
const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(({ const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(({
refresh, refresh,
data, data,
@@ -108,7 +110,7 @@ const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(
const newValues: ModelConfig = { const newValues: ModelConfig = {
capability: (option as Model).capability, capability: (option as Model).capability,
deep_thinking: false, deep_thinking: false,
thinking_budget_tokens: undefined, thinking_budget_tokens: defaultThinkingBudgetTokens,
json_output: false, json_output: false,
} }
if (source === 'chat') { if (source === 'chat') {
@@ -128,6 +130,12 @@ const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(
form.setFieldsValue({ ...rest }) form.setFieldsValue({ ...rest })
}, [data?.default_model_config_id]) }, [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 = () => { const handleReset = () => {
if (!id) return if (!id) return
resetAppModelConfig(id).then((res) => { resetAppModelConfig(id).then((res) => {
@@ -178,15 +186,20 @@ const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(
name="thinking_budget_tokens" name="thinking_budget_tokens"
label={t('application.thinking_budget_tokens')} label={t('application.thinking_budget_tokens')}
hidden={!['model', 'chat'].includes(source) || !(values?.deep_thinking || values?.capability?.includes('thinking'))} 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={[ rules={[
{ required: values?.deep_thinking, message: t('common.pleaseEnter') }, { required: values?.deep_thinking, message: t('common.pleaseEnter') },
{ {
validator: (_, value) => { validator: (_, value) => {
const maxTokens = values?.max_tokens const maxTokens = values?.max_tokens
const deep_thinking = values?.deep_thinking; const deep_thinking = values?.deep_thinking;
if (deep_thinking && value !== undefined && maxTokens !== undefined && value > maxTokens) { if (deep_thinking && value !== undefined) {
return Promise.reject(t('application.thinking_budget_tokens_max_error', { max: maxTokens })) 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() return Promise.resolve()
} }
@@ -195,7 +208,7 @@ const ModelConfigModal = forwardRef<ModelConfigModalRef, ModelConfigModalProps>(
> >
<RbSlider <RbSlider
step={1} step={1}
min={0} min={minThinkingBudgetTokens}
max={32000} max={32000}
isInput={true} isInput={true}
disabled={!values?.deep_thinking} disabled={!values?.deep_thinking}

View File

@@ -166,10 +166,10 @@ const Ontology: FC = () => {
<div className="rb:h-10 rb:wrap-break-word rb:line-clamp-2 rb:leading-5">{item.scene_description}</div> <div className="rb:h-10 rb:wrap-break-word rb:line-clamp-2 rb:leading-5">{item.scene_description}</div>
</Tooltip> </Tooltip>
<div className="rb:mt-2"> <div className="rb:mt-2 rb:h-5.5">
<OverflowTags <OverflowTags
popoverProps={false} popoverProps={false}
items={[...item.entity_type?.map((type, i) => <Tag key={i} variant="borderless" color="dark">{type}</Tag>), <Tag variant="borderless" color="dark">{`+${item.type_num - 3}`}</Tag>]} items={item.entity_type ? [...item.entity_type.map((type, i) => <Tag key={i} variant="borderless" color="dark">{type}</Tag>), <Tag variant="borderless" color="dark">{`+${item.type_num - 3}`}</Tag>] : []}
numTag={(num?: number) => <Tag variant="borderless" color="dark">{`+${item.type_num - 3 + (num ? num - 1 : 0)}`}</Tag>} numTag={(num?: number) => <Tag variant="borderless" color="dark">{`+${item.type_num - 3 + (num ? num - 1 : 0)}`}</Tag>}
/> />
</div> </div>

View File

@@ -101,6 +101,7 @@ const CustomToolModal = forwardRef<CustomToolModalRef, CustomToolModalProps>(({
}); });
}; };
const formatSchema = (value: string) => { const formatSchema = (value: string) => {
if (!value || value.trim() === '') return
setParseSchemaData({} as ParseSchemaData) setParseSchemaData({} as ParseSchemaData)
parseSchema({ schema_content: value }) parseSchema({ schema_content: value })
.then(res => { .then(res => {

View File

@@ -57,7 +57,6 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
} }
}} }}
labelRender={(props) => { labelRender={(props) => {
console.log('props', props)
return `${props.value}%` return `${props.value}%`
}} }}
className="rb:w-20 rb:h-4!" className="rb:w-20 rb:h-4!"

View File

@@ -66,8 +66,6 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
const [fileList, setFileList] = useState<any[]>([]) const [fileList, setFileList] = useState<any[]>([])
const [message, setMessage] = useState<string | undefined>(undefined) const [message, setMessage] = useState<string | undefined>(undefined)
console.log('abortRef', abortRef, chatList)
/** /**
* Opens the chat drawer and loads workflow variables from the start node * Opens the chat drawer and loads workflow variables from the start node
*/ */

View File

@@ -18,6 +18,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
// Handle node selection from popover and create new node replacing the add-node placeholder // Handle node selection from popover and create new node replacing the add-node placeholder
const handleNodeSelect = (selectedNodeType: any) => { const handleNodeSelect = (selectedNodeType: any) => {
graph.startBatch('add-node');
const parentBBox = node.getBBox(); const parentBBox = node.getBBox();
const cycleId = data.cycle; const cycleId = data.cycle;
const horizontalSpacing = 0; const horizontalSpacing = 0;
@@ -43,7 +44,7 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
if (cycleId) { if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (parentNode) { 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 // Automatically adjust loop node size
const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId); const loopNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
if (loopNode) { 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); const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
childNodes.forEach((childNode: any) => { if (childNodes.length > 0) {
childNode.on('change:position', adjustLoopSize); 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); setOpen(false);
}; };

View File

@@ -99,7 +99,7 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
{data.type === 'if-else' && {data.type === 'if-else' &&
<Flex vertical gap={4} className="rb:mt-3!"> <Flex vertical gap={4} className="rb:mt-3!">
{data.config?.cases?.defaultValue.map((item: any, index: number) => ( {data.config?.cases?.defaultValue.map((item: any, index: number) => (
<div key={index} className={item.expressions.length > 0 ? '' : 'rb:mb-1'}> <div key={index}>
<Flex justify={item.expressions.length > 0 ? "space-between" : 'end'} className="rb:mb-1! rb:leading-4"> <Flex justify={item.expressions.length > 0 ? "space-between" : 'end'} className="rb:mb-1! rb:leading-4">
{item.expressions.length > 0 && <span className="rb:text-[#5B6167] rb:text-[10px] rb:pl-1">CASE{index + 1}</span>} {item.expressions.length > 0 && <span className="rb:text-[#5B6167] rb:text-[10px] rb:pl-1">CASE{index + 1}</span>}
<span className="rb:text-[#212332] rb:font-medium rb:text-[12px]">{index === 0 ? 'IF' : `ELIF`}</span> <span className="rb:text-[#212332] rb:font-medium rb:text-[12px]">{index === 0 ? 'IF' : `ELIF`}</span>

View File

@@ -1,134 +1,15 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'
import clsx from 'clsx'; import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape'; import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { Flex } from 'antd'; import { Flex } from 'antd';
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons'; import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'
import { graphNodeLibrary, edgeAttrs } from '../../constant';
import NodeTools from './NodeTools' import NodeTools from './NodeTools'
const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => { const LoopNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node.getData() || {}; const data = node.getData() || {};
const { t } = useTranslation() 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 ( return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', { <div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]!': data.isSelected && !data.executionStatus, 'rb:border-[#171719]!': data.isSelected && !data.executionStatus,

View File

@@ -43,70 +43,52 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
}; };
}, []); }, []);
// Handle node selection from popover menu and create new node with edge connection
const handleNodeSelect = (selectedNodeType: any) => { const handleNodeSelect = (selectedNodeType: any) => {
if (!sourceNode || !graph) return; if (!sourceNode || !graph) return;
const sourceNodeData = sourceNode.getData(); const sourceNodeData = sourceNode.getData();
const sourceNodeType = sourceNodeData?.type; const sourceNodeType = sourceNodeData?.type;
const isCycleSubNode = !!sourceNodeData.cycle;
// If it's a cycle-start node, handle the add-node placeholder const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration';
const newNodeType = selectedNodeType.type;
// Save add-node placeholder position before disabling history
let addNodePosition = null; let addNodePosition = null;
const isCycleSubNode = sourceNodeData.cycle
if (isCycleSubNode && sourceNodeType === 'cycle-start') { if (isCycleSubNode && sourceNodeType === 'cycle-start') {
const cycleId = sourceNodeData.cycle; 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 n.getData()?.type === 'add-node' && n.getData()?.cycle === cycleId
); );
if (addNodes.length > 0) addNodePosition = addNodes[0].getBBox();
if (addNodes.length > 0) {
const addNode = addNodes[0];
addNodePosition = addNode.getBBox();
addNode.remove();
}
} }
// Calculate new node position to avoid overlapping // Calculate position
const sourceBBox = sourceNode.getBBox(); const sourceBBox = sourceNode.getBBox();
const nodeWidth = graphNodeLibrary[selectedNodeType.type]?.width || 120; const nw = graphNodeLibrary[newNodeType]?.width || 120;
const nodeHeight = graphNodeLibrary[selectedNodeType.type]?.height || 88; const nh = graphNodeLibrary[newNodeType]?.height || 88;
const horizontalSpacing = isCycleSubNode ? 48 : 80; const hSpacing = isCycleSubNode ? 48 : 80;
const verticalSpacing = 10; const vSpacing = 10;
// Get source port group information
const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort); const sourcePortInfo = sourceNode.getPorts().find((p: any) => p.id === sourcePort);
const sourcePortGroup = sourcePortInfo?.group || sourcePort; const sourcePortGroup = sourcePortInfo?.group || sourcePort;
// Calculate new node position let newX: number, newY: number;
let newX, newY;
if (edgeInsertion) { if (edgeInsertion) {
// Edge insertion: place new node on the same row as target, between source and target
const targetBBox = edgeInsertion.targetCell.getBBox(); const targetBBox = edgeInsertion.targetCell.getBBox();
const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width); const gap = targetBBox.x - (sourceBBox.x + sourceBBox.width);
const requiredSpace = nodeWidth + horizontalSpacing * 4; const requiredSpace = nw + hSpacing * 4;
newX = sourceBBox.x + sourceBBox.width + hSpacing;
// New node x: right after source + spacing newY = targetBBox.y + (targetBBox.height - nh) / 2;
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
if (gap < requiredSpace) { if (gap < requiredSpace) {
const shiftX = requiredSpace - gap; const shiftX = requiredSpace - gap;
const visited = new Set<string>(); const visited = new Set<string>();
const shiftDownstream = (cell: any) => { const shiftDownstream = (cell: any) => {
const cellId = cell.id; if (visited.has(cell.id)) return;
if (visited.has(cellId)) return; visited.add(cell.id);
visited.add(cellId);
const pos = cell.getPosition(); const pos = cell.getPosition();
cell.setPosition(pos.x + shiftX, pos.y); cell.setPosition(pos.x + shiftX, pos.y);
// Recursively shift nodes connected from right ports
graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => { graph.getConnectedEdges(cell, { outgoing: true }).forEach((e: any) => {
const tId = e.getTargetCellId(); const tCell = graph.getCellById(e.getTargetCellId());
if (tId && !visited.has(tId)) { if (tCell?.isNode()) shiftDownstream(tCell);
const tCell = graph.getCellById(tId);
if (tCell?.isNode()) shiftDownstream(tCell);
}
}); });
}; };
shiftDownstream(edgeInsertion.targetCell); shiftDownstream(edgeInsertion.targetCell);
@@ -114,208 +96,170 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
} else if (addNodePosition) { } else if (addNodePosition) {
newX = addNodePosition.x; newX = addNodePosition.x;
newY = addNodePosition.y; newY = addNodePosition.y;
} else if (sourcePortGroup === 'left') {
newX = sourceBBox.x - nw * 2 - hSpacing;
newY = sourceBBox.y;
} else { } else {
// Determine node placement direction based on port position newX = sourceBBox.x + sourceBBox.width + hSpacing;
if (sourcePortGroup === 'left') { newY = sourceBBox.y;
// Left port: add node to the left const connectedNodes = new Set<string>();
newX = sourceBBox.x - nodeWidth*2 - horizontalSpacing; graph.getConnectedEdges(sourceNode).forEach((e: any) => {
newY = sourceBBox.y; [e.getSourceCellId(), e.getTargetCellId()].forEach((cid: string) => {
} else { if (cid !== sourceNode.id) connectedNodes.add(cid);
// 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);
}); });
});
return graph.getNodes().some((node: any) => { const checkOverlap = (x: number, y: number) =>
if (node.id === sourceNode.id) return false; graph.getNodes().some((n: any) => {
if (!connectedNodes.has(node.id)) return false; // Only consider connected nodes if (n.id === sourceNode.id || !connectedNodes.has(n.id)) return false;
const bbox = node.getBBox(); const b = n.getBBox();
return !(x + nodeWidth < bbox.x || x > bbox.x + bbox.width || return !(x + nw < b.x || x > b.x + b.width || y + nh < b.y || y > b.y + b.height);
y + nodeHeight < bbox.y || y > bbox.y + bbox.height);
}); });
}; while (checkOverlap(newX, newY)) newY += nh + vSpacing;
// If position is occupied, search downward for empty space
while (checkOverlap(newX, newY)) {
newY += nodeHeight + verticalSpacing;
}
} }
// Create new node // Disable history for all graph mutations
const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` 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({ const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default), ...(graphNodeLibrary[newNodeType] || graphNodeLibrary.default),
x: newX, x: newX,
y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0), y: newY - (isCycleSubNode && sourceNodeType === 'cycle-start' ? 12 : 0),
id, id,
data: { data: {
id, id,
type: selectedNodeType.type, type: newNodeType,
icon: selectedNodeType.icon, icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`), name: t(`workflow.${newNodeType}`),
cycle: sourceNodeData.cycle, // Inherit cycle from source node cycle: sourceNodeData.cycle,
config: selectedNodeType.config || {} config: selectedNodeType.config || {}
}, },
}); });
// Add new node as child of parent node
if (sourceNodeData.cycle) { if (sourceNodeData.cycle) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle); const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle);
if (parentNode) { if (parentNode) parentNode.addChild(newNode, { silent: true });
parentNode.addChild(newNode);
}
} }
// Edge insertion: remove old edge immediately before creating new edges
if (edgeInsertion) { if (edgeInsertion) {
const { edge: oldEdge } = edgeInsertion; const { edge: oldEdge } = edgeInsertion;
if (oldEdge.id && graph.getCellById(oldEdge.id)) { if (oldEdge.id && graph.getCellById(oldEdge.id)) graph.removeCell(oldEdge.id);
graph.removeCell(oldEdge.id); else graph.removeEdge(oldEdge);
} else {
graph.removeEdge(oldEdge);
}
} }
// Create edge connection const newPorts = newNode.getPorts();
setTimeout(() => { const addedCells: any[] = [newNode];
const newPorts = newNode.getPorts();
const addedEdges: any[] = []; if (edgeInsertion) {
if (edgeInsertion) { const { targetCell, targetPort: origTargetPort } = edgeInsertion;
// Edge insertion: create source→new and new→target edges const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
const { targetCell, targetPort: origTargetPort } = edgeInsertion; const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
const newLeftPort = newPorts.find((p: any) => p.group === 'left')?.id || 'left'; addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: newLeftPort }, ...edgeAttrs }));
const newRightPort = newPorts.find((p: any) => p.group === 'right')?.id || 'right'; addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: newRightPort }, target: { cell: targetCell.id, port: origTargetPort }, ...edgeAttrs }));
addedEdges.push(graph.addEdge({ setEdgeInsertion(null);
source: { cell: sourceNode.id, port: sourcePort }, } else if (sourcePortGroup === 'left') {
target: { cell: newNode.id, port: newLeftPort }, const tp = newPorts.find((p: any) => p.group === 'right')?.id || 'right';
...edgeAttrs addedCells.push(graph.addEdge({ source: { cell: newNode.id, port: tp }, target: { cell: sourceNode.id, port: sourcePort }, ...edgeAttrs }));
})); } else {
addedEdges.push(graph.addEdge({ const tp = newPorts.find((p: any) => p.group === 'left')?.id || 'left';
source: { cell: newNode.id, port: newRightPort }, addedCells.push(graph.addEdge({ source: { cell: sourceNode.id, port: sourcePort }, target: { cell: newNode.id, port: tp }, ...edgeAttrs }));
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 (parentNode) { // If adding a loop/iteration node, create cycle-start, add-node and inner edge regardless of source type
const adjustLoopSize = () => { if (isCycleContainer(newNodeType)) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId); const parentBBox = newNode.getBBox();
if (childNodes.length > 0) { const cycleStartId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
const bounds = childNodes.reduce((acc: any, child: any) => { const cycleStartNode = graph.addNode({
const bbox = child.getBBox(); ...graphNodeLibrary.cycleStart,
return { x: parentBBox.x + 24,
minX: Math.min(acc.minX, bbox.x), y: parentBBox.y + 70,
minY: Math.min(acc.minY, bbox.y), id: cycleStartId,
maxX: Math.max(acc.maxX, bbox.x + bbox.width), data: { id: cycleStartId, type: 'cycle-start', parentId: id, isDefault: true, cycle: id },
maxY: Math.max(acc.maxY, bbox.y + bbox.height) });
}; const addNodePlaceholder = graph.addNode({
}, { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }); ...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; // Adjust parent size if adding inside a cycle container
const newWidth = Math.max(nodeWidth, bounds.maxX - bounds.minX + padding * 2); const cycleId = sourceNodeData.cycle;
const newHeight = Math.max(120, bounds.maxY - bounds.minY + padding * 2); if (cycleId) {
const parentNode = graph.getNodes().find((n: any) => n.getData()?.id === cycleId);
parentNode.prop('size', { width: newWidth, height: newHeight }); if (parentNode) {
const childNodes = graph.getNodes().filter((n: any) => n.getData()?.cycle === cycleId);
// Update right port x position if (childNodes.length > 0) {
const ports = parentNode.getPorts(); const bounds = childNodes.reduce((acc: any, child: any) => {
ports.forEach((port: any) => { const b = child.getBBox();
if (port.group === 'right' && port.args) { 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) };
parentNode.portProp(port.id!, 'args/x', newWidth); }, { 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) => {
adjustLoopSize(); if (port.group === 'right' && port.args) parentNode.portProp(port.id!, 'args/x', newWidth);
// 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);
}); });
} }
} }
}
const isCycleContainer = (type: string) => type === 'loop' || type === 'iteration'; // toFront
const newNodeType = selectedNodeType.type; 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 if (isCycleContainer(sourceNodeType)) {
const bringCycleChildrenToFront = (cycleContainerId: string) => { newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(sourceNodeData.id);
if (isCycleContainer(newNodeType)) bringCycleChildrenToFront(id);
graph.getEdges().forEach((e: any) => { } else if (isCycleContainer(newNodeType)) {
const src = graph.getCellById(e.getSourceCellId()); newNode.toFront(); sourceNode.toFront(); bringCycleChildrenToFront(id);
const tgt = graph.getCellById(e.getTargetCellId()); } else {
if (src?.getData()?.cycle === cycleContainerId || tgt?.getData()?.cycle === cycleContainerId) e.toFront(); addedCells.forEach(c => { if (c.isNode?.()) c.toFront(); });
}); }
graph.getNodes().forEach((n: any) => {
if (n.getData()?.cycle === cycleContainerId) n.toFront();
});
};
if (isCycleContainer(sourceNodeType)) { // Re-enable history and manually push one batch frame for all added cells
console.log('isCycleContainer(sourceNodeType)') graph.enableHistory();
// Case 4: source is a loop/iteration node — bring new node to front, then its children const history = graph.getPlugin('history') as any;
newNode.toFront(); if (history) {
sourceNode.toFront(); const batchFrame = addedCells.map((cell: any) => ({
bringCycleChildrenToFront(sourceNodeData.id); batch: true,
} else if (isCycleContainer(newNodeType)) { event: 'cell:added',
console.log('isCycleContainer(newNodeType)') data: { id: cell.id, node: cell.isNode(), edge: cell.isEdge(), props: cell.toJSON() },
// Case 3: adding a loop/iteration node from a normal node — bring new node to front, then its children options: {},
newNode.toFront(); }));
sourceNode.toFront() history.undoStack.push(batchFrame);
bringCycleChildrenToFront(id); history.redoStack = [];
} else { graph.trigger('history:change', { cmds: batchFrame, options: { name: 'add-node' } });
// 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);
// Clean up temporary element
if (tempElement) { if (tempElement) {
document.body.removeChild(tempElement); document.body.removeChild(tempElement);
setTempElement(null); setTempElement(null);
} }
setPopoverVisible(false); setPopoverVisible(false);
}; };
@@ -391,4 +335,4 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
); );
}; };
export default PortClickHandler; export default PortClickHandler;

View File

@@ -242,10 +242,11 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
className={parameter.type === 'boolean' ? 'rb:mb-0!' : ''} className={parameter.type === 'boolean' ? 'rb:mb-0!' : ''}
> >
{parameter.type === 'string' && parameter.enum && parameter.enum.length > 0 {parameter.type === 'string' && parameter.enum && parameter.enum.length > 0
? <Select size="small" options={parameter.enum.map(vo => ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} /> ? <Select key={values.tool_id} size="small" options={parameter.enum.map(vo => ({ value: vo, label: vo }))} placeholder={t('common.pleaseSelect')} />
: parameter.type === 'boolean' : parameter.type === 'boolean'
? <Switch size="small" /> ? <Switch key={values.tool_id} size="small" />
: <Editor : <Editor
key={values.tool_id}
variant="outlined" variant="outlined"
type="input" type="input"
size="small" size="small"

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:06:18 * @Date: 2026-02-03 15:06:18
* @Last Modified by: ZhaoYing * @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-21 18:23:31 * @Last Modified time: 2026-04-27 14:07:14
*/ */
import type { ReactShapeConfig } from '@antv/x6-react-shape'; import type { ReactShapeConfig } from '@antv/x6-react-shape';
import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port'; import type { GroupMetadata, PortMetadata } from '@antv/x6/lib/model/port';
@@ -948,6 +948,15 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
width: nodeWidth, width: nodeWidth,
height: 120, height: 120,
shape: 'notes-node', shape: 'notes-node',
},
output: {
width: nodeWidth,
height: 76,
shape: 'normal-node',
ports: {
groups: { left: defaultPortGroup },
items: [defaultPortItems[0]],
},
} }
} }

View File

@@ -2,10 +2,9 @@
* @Author: ZhaoYing * @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48 * @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing * @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 { 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 { register } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port'; import type { PortMetadata } from '@antv/x6/lib/model/port';
import { App } from 'antd'; import { App } from 'antd';
@@ -17,7 +16,7 @@ import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application';
import { useUser } from '@/store/user'; import { useUser } from '@/store/user';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'; 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 { 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 { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
import { useWorkflowStore } from '@/store/workflow'; import { useWorkflowStore } from '@/store/workflow';
@@ -86,6 +85,10 @@ export interface UseWorkflowGraphReturn {
/** Get start node output variable list (user-defined + system variables) */ /** Get start node output variable list (user-defined + system variables) */
getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>; getStartNodeVariables: () => Array<{ name: string; type: string; readonly?: boolean }>;
nodeClick: ({ node }: { node: Node }) => void; 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<FeaturesConfigForm | undefined>(undefined) const featuresRef = useRef<FeaturesConfigForm | undefined>(undefined)
const [canUndo, setCanUndo] = useState(false) const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false) const [canRedo, setCanRedo] = useState(false)
const [historyRecords, setHistoryRecords] = useState<HistoryRecord[]>([])
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(() => { useEffect(() => {
if (!graphRef.current) return if (!graphRef.current) return
graphRef.current.getNodes().forEach(node => { graphRef.current.getNodes().forEach(node => {
const data = node.getData() const data = node.getData()
if (data?.type === 'if-else' || data?.type === 'question-classifier') { if (data?.type === 'if-else' || data?.type === 'question-classifier') {
console.log('chatVariables', chatVariables) console.log('chatVariables', chatVariables)
node.setData({ ...data, chatVariables }, { silent: true }) node.setData({ ...data, chatVariables })
} }
}) })
}, [chatVariables]) }, [chatVariables])
@@ -343,7 +351,7 @@ export const useWorkflowGraph = ({
if (parentNode) { if (parentNode) {
const addedChild = graphRef.current?.addNode(childNode) const addedChild = graphRef.current?.addNode(childNode)
if (addedChild) { 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 newWidth = Math.max(parentBBox.width, maxX - minX + padding * 2)
const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight) const newHeight = Math.max(parentBBox.height, maxY - minY + padding * 2 + headerHeight)
console.log('newWidth', newHeight, newWidth)
parentNode.prop('size', { width: newWidth, height: newHeight }) parentNode.prop('size', { width: newWidth, height: newHeight })
// Update x position of right group ports // Update x position of right group ports
@@ -488,8 +494,77 @@ export const useWorkflowGraph = ({
graphRef.current.cleanHistory() graphRef.current.cleanHistory()
} }
}, 200) }, 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) * Setup X6 graph plugins (MiniMap, Snapline, Clipboard, Keyboard)
*/ */
@@ -525,18 +600,44 @@ export const useWorkflowGraph = ({
new History({ new History({
enabled: false, enabled: false,
beforeAddCommand(_event, args: any) { beforeAddCommand(_event, args: any) {
const event = args?.key ? `cell:change:${args.key}` : _event; const key = args?.key
if (event.startsWith('cell:change:') && if (key === 'attrs' || key === 'tools') return false
event !== 'cell:change:position' &&
event !== 'cell:change:source' &&
event !== 'cell:change:target') 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) setCanUndo(graphRef.current?.canUndo() ?? false)
setCanRedo(graphRef.current?.canRedo() ?? 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) => { // const showPorts = (show: boolean) => {
@@ -569,13 +670,13 @@ export const useWorkflowGraph = ({
vo.setData({ vo.setData({
...data, ...data,
isSelected: false, isSelected: false,
}); }, { silent: true });
} }
}); });
node.setData({ node.setData({
...nodeData, ...nodeData,
isSelected: true, isSelected: true,
}); }, { silent: true });
clearEdgeSelect() clearEdgeSelect()
if (nodeData.type !== 'notes') { if (nodeData.type !== 'notes') {
setSelectedNode(node); setSelectedNode(node);
@@ -589,7 +690,7 @@ export const useWorkflowGraph = ({
const edgeClick = ({ edge }: { edge: Edge }) => { const edgeClick = ({ edge }: { edge: Edge }) => {
clearEdgeSelect(); clearEdgeSelect();
edge.setAttrByPath('line/stroke', edge_selected_color); edge.setAttrByPath('line/stroke', edge_selected_color);
edge.setData({ ...edge.getData(), isSelected: true }); edge.setData({ ...edge.getData(), isSelected: true }, { silent: true });
clearNodeSelect(); clearNodeSelect();
}; };
/** /**
@@ -604,7 +705,7 @@ export const useWorkflowGraph = ({
node.setData({ node.setData({
...data, ...data,
isSelected: false, isSelected: false,
}); }, { silent: true });
} }
}); });
setSelectedNode(null); setSelectedNode(null);
@@ -614,7 +715,7 @@ export const useWorkflowGraph = ({
*/ */
const clearEdgeSelect = () => { const clearEdgeSelect = () => {
graphRef.current?.getEdges().forEach(e => { 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/stroke', edge_color);
e.setAttrByPath('line/strokeWidth', edge_width); e.setAttrByPath('line/strokeWidth', edge_width);
}); });
@@ -753,8 +854,6 @@ export const useWorkflowGraph = ({
// Find corresponding parent node // Find corresponding parent node
const parentNode = nodes?.find(n => n.id === nodeData.cycle); const parentNode = nodes?.find(n => n.id === nodeData.cycle);
if (parentNode) { if (parentNode) {
// Use removeChild method to delete child node
parentNode.removeChild(nodeToDelete);
parentNodesToUpdate.push(parentNode); parentNodesToUpdate.push(parentNode);
} }
// Add child node to deletion list // Add child node to deletion list
@@ -782,42 +881,51 @@ export const useWorkflowGraph = ({
// Delete all collected nodes and edges // Delete all collected nodes and edges
if (cells.length > 0) { 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); graphRef.current?.removeCells(cells);
// If parent is iteration/loop and only cycle-start remains, add add-node connected to it parentsNeedingAddNode.forEach(({ parentNode, cycleStartNode }) => {
parentNodesToUpdate.forEach(parentNode => {
const parentShape = parentNode.shape;
if (parentShape !== 'loop-node' && parentShape !== 'iteration-node') return;
const parentData = parentNode.getData(); const parentData = parentNode.getData();
const remainingChildren = graphRef.current!.getNodes().filter( const bbox = cycleStartNode.getBBox();
n => n.getData()?.cycle === parentData.id const addNode = graphRef.current!.addNode({
); ...graphNodeLibrary.addStart,
const cycleStartNodes = remainingChildren.filter(n => n.getData()?.type === 'cycle-start'); x: bbox.x + 84,
if (cycleStartNodes.length === 1 && remainingChildren.length === 1) { y: bbox.y + 4,
const cycleStartNode = cycleStartNodes[0]; data: { type: 'add-node', parentId: parentNode.id, cycle: parentData.id, label: t('workflow.addNode'), icon: '+' },
const bbox = cycleStartNode.getBBox(); });
const addNode = graphRef.current!.addNode({ parentNode.addChild(addNode, { silent: true });
...graphNodeLibrary.addStart, graphRef.current!.addEdge({
x: bbox.x + 84, source: { cell: cycleStartNode.id, port: cycleStartNode.getPorts().find(p => p.group === 'right')?.id || 'right' },
y: bbox.y + 4, target: { cell: addNode.id, port: addNode.getPorts().find(p => p.group === 'left')?.id || 'left' },
data: { ...edgeAttrs,
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,
});
}
}); });
graphRef.current?.stopBatch('delete');
} }
return false; return false;
}; };
@@ -1036,7 +1144,7 @@ export const useWorkflowGraph = ({
graphRef.current?.getConnectedEdges(node).forEach(edge => { graphRef.current?.getConnectedEdges(node).forEach(edge => {
if (!edge.getData()?.isSelected) { if (!edge.getData()?.isSelected) {
edge.setAttrByPath('line/stroke', edge_selected_color); 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 => { graphRef.current?.getConnectedEdges(node).forEach(edge => {
if (!edge.getData()?.isSelected) { if (!edge.getData()?.isSelected) {
edge.setAttrByPath('line/stroke', edge_color); 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 // Delete selected nodes and edges
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent); graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent);
// Undo / Redo // Undo / Redo
graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); 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'], () => { graphRef.current?.redo(); 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') { if (dragData.type === 'loop' || dragData.type === 'iteration') {
graphRef.current.addNode({ graph.disableHistory()
const parentNode = graphRef.current.addNode({
...graphNodeLibrary[dragData.type], ...graphNodeLibrary[dragData.type],
x: point.x - 150, x: point.x - 150,
y: point.y - 100, y: point.y - 100,
id: cleanNodeData.id, id: cleanNodeData.id,
data: { ...cleanNodeData, isGroup: true }, 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') { } else if (dragData.type === 'if-else') {
// Create condition node // Create condition node
graphRef.current.addNode({ graphRef.current.addNode({
@@ -1446,8 +1592,80 @@ export const useWorkflowGraph = ({
return userVars return userVars
} }
const undo = () => graphRef.current?.undo() const clearHistoryRecords = () => {
const redo = () => graphRef.current?.redo() 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 handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
const { statement = '' } = value?.opening_statement || {} const { statement = '' } = value?.opening_statement || {}
@@ -1488,20 +1706,16 @@ export const useWorkflowGraph = ({
if (!graphRef.current) return; if (!graphRef.current) return;
const nodes = graphRef.current.getNodes(); const nodes = graphRef.current.getNodes();
const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length); // Reset all node execution status on every chatHistory change
// Reset all node execution status first
nodes.forEach(node => { nodes.forEach(node => {
const data = node.getData(); const data = node.getData();
if (typeof data.executionStatus === 'string') { node.setData({ ...data, executionStatus: '' });
node.setData({ ...data, executionStatus: undefined });
}
}); });
if (!lastWithSub?.subContent) return;
// Build a nodeId -> status map first const lastAssistant = [...chatHistory].reverse().find(item => item.role === 'assistant');
const statusMap: Record<string, string> = {}; if (!lastAssistant?.subContent?.length) return;
lastWithSub.subContent.forEach(sub => { lastAssistant.subContent.forEach(sub => {
if (typeof sub.status === 'string') { if (typeof sub.status === 'string') {
statusMap[sub.node_id] = sub.status;
const node = nodes.find(n => n.getData()?.id === sub.node_id); const node = nodes.find(n => n.getData()?.id === sub.node_id);
if (node) { if (node) {
node.setData({ ...node.getData(), executionStatus: sub.status }); node.setData({ ...node.getData(), executionStatus: sub.status });
@@ -1537,5 +1751,7 @@ export const useWorkflowGraph = ({
canRedo, canRedo,
undo, undo,
redo, redo,
historyRecords,
clearHistoryRecords,
}; };
}; };

View File

@@ -113,4 +113,13 @@ export interface ChatVariable {
} }
export interface AddChatVariableRef { export interface AddChatVariableRef {
handleOpen: (value?: ChatVariable) => void; handleOpen: (value?: ChatVariable) => void;
}
export type HistoryActionType = 'add' | 'remove' | 'change' | 'undo' | 'redo' | 'batch'
export interface HistoryRecord {
type: HistoryActionType;
timestamp: number;
batchName?: string;
cellIds?: string[];
} }

View File

@@ -17,6 +17,7 @@ export const isSubExprSet = (sub: any) => {
* Uses the same per-expression height logic as getConditionNodeCasePortY. * Uses the same per-expression height logic as getConditionNodeCasePortY.
*/ */
export const calcConditionNodeTotalHeight = (cases: any[]) => { export const calcConditionNodeTotalHeight = (cases: any[]) => {
if (!cases?.length) return conditionNodeHeight;
const casesHeight = cases.reduce((acc: number, c: any) => { const casesHeight = cases.reduce((acc: number, c: any) => {
const exprs = c?.expressions ?? []; const exprs = c?.expressions ?? [];
const n = exprs.length; const n = exprs.length;