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