mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 18:03:16 +08:00
Merge branch 'feat/support-agent-sandbox' into sandboxed-agent-rebase
This commit is contained in:
@@ -20,7 +20,6 @@ class BaseMemory(ABC):
|
||||
@abstractmethod
|
||||
def get_history_prompt_messages(
|
||||
self,
|
||||
*,
|
||||
max_token_limit: int = 2000,
|
||||
message_limit: int | None = None,
|
||||
) -> Sequence[PromptMessage]:
|
||||
|
||||
@@ -144,7 +144,6 @@ class NodeTokenBufferMemory(BaseMemory):
|
||||
|
||||
def get_history_prompt_messages(
|
||||
self,
|
||||
*,
|
||||
max_token_limit: int = 2000,
|
||||
message_limit: int | None = None,
|
||||
) -> Sequence[PromptMessage]:
|
||||
|
||||
@@ -116,7 +116,6 @@ class TokenBufferMemory(BaseMemory):
|
||||
|
||||
def get_history_prompt_messages(
|
||||
self,
|
||||
*,
|
||||
max_token_limit: int = 2000,
|
||||
message_limit: int | None = None,
|
||||
) -> Sequence[PromptMessage]:
|
||||
|
||||
@@ -118,37 +118,26 @@ class PluginModelBackwardsInvocation(BaseBackwardsInvocation):
|
||||
user=user_id,
|
||||
)
|
||||
|
||||
if isinstance(response, Generator):
|
||||
if response.usage:
|
||||
deduct_llm_quota(tenant_id=tenant.id, model_instance=model_instance, usage=response.usage)
|
||||
|
||||
def handle() -> Generator[LLMResultChunkWithStructuredOutput, None, None]:
|
||||
for chunk in response:
|
||||
if chunk.delta.usage:
|
||||
deduct_llm_quota(tenant_id=tenant.id, model_instance=model_instance, usage=chunk.delta.usage)
|
||||
chunk.prompt_messages = []
|
||||
yield chunk
|
||||
def handle_non_streaming(
|
||||
response: LLMResultWithStructuredOutput,
|
||||
) -> Generator[LLMResultChunkWithStructuredOutput, None, None]:
|
||||
yield LLMResultChunkWithStructuredOutput(
|
||||
model=response.model,
|
||||
prompt_messages=[],
|
||||
system_fingerprint=response.system_fingerprint,
|
||||
structured_output=response.structured_output,
|
||||
delta=LLMResultChunkDelta(
|
||||
index=0,
|
||||
message=response.message,
|
||||
usage=response.usage,
|
||||
finish_reason="",
|
||||
),
|
||||
)
|
||||
|
||||
return handle()
|
||||
else:
|
||||
if response.usage:
|
||||
deduct_llm_quota(tenant_id=tenant.id, model_instance=model_instance, usage=response.usage)
|
||||
|
||||
def handle_non_streaming(
|
||||
response: LLMResultWithStructuredOutput,
|
||||
) -> Generator[LLMResultChunkWithStructuredOutput, None, None]:
|
||||
yield LLMResultChunkWithStructuredOutput(
|
||||
model=response.model,
|
||||
prompt_messages=[],
|
||||
system_fingerprint=response.system_fingerprint,
|
||||
structured_output=response.structured_output,
|
||||
delta=LLMResultChunkDelta(
|
||||
index=0,
|
||||
message=response.message,
|
||||
usage=response.usage,
|
||||
finish_reason="",
|
||||
),
|
||||
)
|
||||
|
||||
return handle_non_streaming(response)
|
||||
return handle_non_streaming(response)
|
||||
|
||||
@classmethod
|
||||
def invoke_text_embedding(cls, user_id: str, tenant: Tenant, payload: RequestInvokeTextEmbedding):
|
||||
|
||||
@@ -175,11 +175,11 @@ class SandboxBuilder:
|
||||
|
||||
if sandbox.is_cancelled():
|
||||
return
|
||||
# Storage mount is part of readiness. If restore/mount fails,
|
||||
# the sandbox must surface initialization failure instead of
|
||||
# becoming "ready" with missing files.
|
||||
if not sandbox.mount():
|
||||
raise RuntimeError("Sandbox storage mount failed")
|
||||
# Attempt to restore prior workspace state. mount() returns
|
||||
# False when no archive exists yet (first run for this
|
||||
# sandbox_id), which is a normal case — not an error.
|
||||
# Actual failures (download/extract) surface as exceptions.
|
||||
sandbox.mount()
|
||||
sandbox.mark_ready()
|
||||
except Exception as exc:
|
||||
try:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from core.virtual_environment.__base.exec import PipelineExecutionError
|
||||
from core.virtual_environment.__base.helpers import pipeline
|
||||
from core.virtual_environment.__base.virtual_environment import VirtualEnvironment
|
||||
from extensions.storage.base_storage import BaseStorage
|
||||
@@ -47,19 +46,15 @@ class ArchiveSandboxStorage(SandboxStorage):
|
||||
download_url = self._storage.get_download_url(self._storage_key, _ARCHIVE_TIMEOUT)
|
||||
archive = "archive.tar.gz"
|
||||
|
||||
try:
|
||||
(
|
||||
pipeline(sandbox)
|
||||
.add(["curl", "-fsSL", download_url, "-o", archive], error_message="Failed to download archive")
|
||||
.add(
|
||||
["sh", "-c", 'tar -xzf "$1" 2>/dev/null; exit $?', "sh", archive], error_message="Failed to extract"
|
||||
)
|
||||
.add(["rm", archive], error_message="Failed to cleanup")
|
||||
.execute(timeout=_ARCHIVE_TIMEOUT, raise_on_error=True)
|
||||
(
|
||||
pipeline(sandbox)
|
||||
.add(["curl", "-fsSL", download_url, "-o", archive], error_message="Failed to download archive")
|
||||
.add(
|
||||
["sh", "-c", 'tar -xzf "$1" 2>/dev/null; exit $?', "sh", archive], error_message="Failed to extract"
|
||||
)
|
||||
except PipelineExecutionError:
|
||||
logger.exception("Failed to mount archive for sandbox %s", self._sandbox_id)
|
||||
return False
|
||||
.add(["rm", archive], error_message="Failed to cleanup")
|
||||
.execute(timeout=_ARCHIVE_TIMEOUT, raise_on_error=True)
|
||||
)
|
||||
|
||||
logger.info("Mounted archive for sandbox %s", self._sandbox_id)
|
||||
return True
|
||||
|
||||
@@ -148,8 +148,7 @@ class DockerDemuxer:
|
||||
to periodically check for errors and closed state instead of blocking forever.
|
||||
"""
|
||||
if self._error:
|
||||
error = cast(BaseException, self._error)
|
||||
raise TransportEOFError(f"Demuxer error: {error}") from error
|
||||
raise TransportEOFError(f"Demuxer error: {self._error}") from self._error
|
||||
|
||||
while True:
|
||||
try:
|
||||
@@ -584,7 +583,7 @@ class DockerDaemonEnvironment(VirtualEnvironment):
|
||||
stderr=True,
|
||||
tty=False,
|
||||
workdir=working_dir,
|
||||
environment=environments,
|
||||
environment=dict(environments) if environments else None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -135,11 +135,11 @@ class CommandNode(Node[CommandNodeData]):
|
||||
*,
|
||||
graph_config: Mapping[str, Any],
|
||||
node_id: str,
|
||||
node_data: Mapping[str, Any],
|
||||
node_data: CommandNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
_ = graph_config
|
||||
|
||||
typed_node_data = CommandNodeData.model_validate(node_data)
|
||||
typed_node_data = node_data
|
||||
|
||||
selectors: list[VariableSelector] = []
|
||||
selectors += list(variable_template_parser.extract_selectors_from_template(typed_node_data.command))
|
||||
|
||||
@@ -157,10 +157,10 @@ class FileUploadNode(Node[FileUploadNodeData]):
|
||||
*,
|
||||
graph_config: Mapping[str, Any],
|
||||
node_id: str,
|
||||
node_data: Mapping[str, Any],
|
||||
node_data: FileUploadNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
_ = graph_config
|
||||
typed_node_data = FileUploadNodeData.model_validate(node_data)
|
||||
typed_node_data = node_data
|
||||
return {node_id + ".files": typed_node_data.variable_selector}
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -280,11 +280,9 @@ class Graph:
|
||||
if not node_configs:
|
||||
raise ValueError("Graph must have at least one node")
|
||||
|
||||
node_configs = [
|
||||
node_config
|
||||
for node_config in node_configs
|
||||
if node_config.get("data", {}).get("type", "") != "group"
|
||||
]
|
||||
# Filter out UI-only node types:
|
||||
# - custom-note: top-level type (node_config.type == "custom-note")
|
||||
node_configs = [node_config for node_config in node_configs if node_config.get("type", "") != "custom-note"]
|
||||
|
||||
# Parse node configurations
|
||||
node_configs_map = cls._parse_node_configs(node_configs)
|
||||
|
||||
@@ -4,7 +4,6 @@ import json
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from dify_graph.nodes.agent.entities import AgentNodeData, AgentOldVersionModelFeatures, ParamsAutoGenerated
|
||||
from packaging.version import Version
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
@@ -26,6 +25,16 @@ from core.tools.entities.tool_entities import (
|
||||
)
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.tools.utils.message_transformer import ToolFileMessageTransformer
|
||||
from core.workflow.nodes.agent.entities import AgentNodeData, AgentOldVersionModelFeatures, ParamsAutoGenerated
|
||||
from core.workflow.nodes.agent.exceptions import (
|
||||
AgentInputTypeError,
|
||||
AgentInvocationError,
|
||||
AgentMessageTransformError,
|
||||
AgentNodeError,
|
||||
AgentVariableNotFoundError,
|
||||
AgentVariableTypeError,
|
||||
ToolFileNotFoundError,
|
||||
)
|
||||
from dify_graph.enums import (
|
||||
NodeType,
|
||||
SystemVariableKey,
|
||||
@@ -60,16 +69,6 @@ from models import ToolFile
|
||||
from models.model import Conversation
|
||||
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
|
||||
|
||||
from .exc import (
|
||||
AgentInputTypeError,
|
||||
AgentInvocationError,
|
||||
AgentMessageTransformError,
|
||||
AgentNodeError,
|
||||
AgentVariableNotFoundError,
|
||||
AgentVariableTypeError,
|
||||
ToolFileNotFoundError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.agent.strategy.plugin import PluginAgentStrategy
|
||||
from core.plugin.entities.request import InvokeCredentials
|
||||
@@ -387,10 +386,9 @@ class AgentNode(Node[AgentNodeData]):
|
||||
*,
|
||||
graph_config: Mapping[str, Any],
|
||||
node_id: str,
|
||||
node_data: Mapping[str, Any],
|
||||
node_data: AgentNodeData,
|
||||
) -> Mapping[str, Sequence[str]]:
|
||||
# Create typed NodeData from dict
|
||||
typed_node_data = AgentNodeData.model_validate(node_data)
|
||||
typed_node_data = node_data
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
for parameter_name in typed_node_data.agent_parameters:
|
||||
|
||||
@@ -321,6 +321,18 @@ class Node(Generic[NodeDataT]):
|
||||
|
||||
return cast(DifyRunContextProtocol, raw_ctx)
|
||||
|
||||
@property
|
||||
def tenant_id(self) -> str:
|
||||
return self.require_dify_context().tenant_id
|
||||
|
||||
@property
|
||||
def app_id(self) -> str:
|
||||
return self.require_dify_context().app_id
|
||||
|
||||
@property
|
||||
def user_id(self) -> str:
|
||||
return self.require_dify_context().user_id
|
||||
|
||||
@property
|
||||
def execution_id(self) -> str:
|
||||
return self._node_execution_id
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -165,12 +165,12 @@ class QuestionClassifierNode(Node[QuestionClassifierNodeData]):
|
||||
try:
|
||||
# handle invoke result
|
||||
generator = LLMNode.invoke_llm(
|
||||
node_data_model=node_data.model,
|
||||
model_instance=model_instance,
|
||||
prompt_messages=prompt_messages,
|
||||
stop=stop,
|
||||
user_id=self.require_dify_context().user_id,
|
||||
structured_output_enabled=False,
|
||||
structured_output=None,
|
||||
structured_output_schema=None,
|
||||
file_saver=self._llm_file_saver,
|
||||
file_outputs=self._file_outputs,
|
||||
node_id=self._node_id,
|
||||
|
||||
@@ -10,6 +10,7 @@ from core.tools.utils.message_transformer import ToolFileMessageTransformer
|
||||
from dify_graph.entities.graph_config import NodeConfigDict
|
||||
from dify_graph.enums import (
|
||||
BuiltinNodeTypes,
|
||||
NodeType,
|
||||
SystemVariableKey,
|
||||
WorkflowNodeExecutionMetadataKey,
|
||||
WorkflowNodeExecutionStatus,
|
||||
@@ -578,7 +579,7 @@ class ToolNode(Node[ToolNodeData]):
|
||||
:param parent_node_id: the parent node id to find nested nodes for
|
||||
:return: mapping of variable key to variable selector
|
||||
"""
|
||||
from dify_graph.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
|
||||
from core.workflow.node_factory import NODE_TYPE_CLASSES_MAPPING
|
||||
|
||||
result: dict[str, Sequence[str]] = {}
|
||||
nodes = graph_config.get("nodes", [])
|
||||
|
||||
@@ -29,7 +29,6 @@ def init_app(app: DifyApp):
|
||||
reset_password,
|
||||
restore_workflow_runs,
|
||||
setup_datasource_oauth_client,
|
||||
setup_sandbox_system_config,
|
||||
setup_system_tool_oauth_client,
|
||||
setup_system_trigger_oauth_client,
|
||||
transform_datasource_credentials,
|
||||
@@ -56,7 +55,6 @@ def init_app(app: DifyApp):
|
||||
clear_orphaned_file_records,
|
||||
remove_orphaned_files_on_storage,
|
||||
file_usage,
|
||||
setup_sandbox_system_config,
|
||||
setup_system_tool_oauth_client,
|
||||
setup_system_trigger_oauth_client,
|
||||
cleanup_orphaned_draft_variables,
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
"gmpy2",
|
||||
"sendgrid",
|
||||
"sendgrid.helpers.mail",
|
||||
"holo_search_sdk.types"
|
||||
"holo_search_sdk.types",
|
||||
"daytona",
|
||||
"e2b",
|
||||
"e2b.exceptions"
|
||||
],
|
||||
"reportUnknownMemberType": "hint",
|
||||
"reportUnknownParameterType": "hint",
|
||||
|
||||
@@ -204,7 +204,7 @@ class SandboxProviderService:
|
||||
)
|
||||
|
||||
# fallback to system default config
|
||||
system_configed: SandboxProviderSystemConfig | None = session.query(SandboxProviderSystemConfig).first()
|
||||
system_configed = session.query(SandboxProviderSystemConfig).first()
|
||||
if system_configed:
|
||||
return SandboxProviderEntity(
|
||||
id=system_configed.id,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { Folder as FolderLine } from '@/app/components/base/icons/src/vender/line/files'
|
||||
import {
|
||||
Agent,
|
||||
Answer,
|
||||
@@ -70,7 +69,6 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
|
||||
[BlockEnum.TemplateTransform]: TemplatingTransform,
|
||||
[BlockEnum.VariableAssigner]: VariableX,
|
||||
[BlockEnum.VariableAggregator]: VariableX,
|
||||
[BlockEnum.Group]: FolderLine,
|
||||
[BlockEnum.Assigner]: Assigner,
|
||||
[BlockEnum.Tool]: VariableX,
|
||||
[BlockEnum.IterationStart]: VariableX,
|
||||
@@ -128,7 +126,6 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
|
||||
[BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Tool]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Group]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useNodesInteractions,
|
||||
} from './hooks'
|
||||
import { useHooksStore } from './hooks-store'
|
||||
import { BlockEnum, NodeRunningStatus } from './types'
|
||||
import { NodeRunningStatus } from './types'
|
||||
import { getEdgeColor } from './utils'
|
||||
|
||||
const CustomEdge = ({
|
||||
@@ -139,7 +139,7 @@ const CustomEdge = ({
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1),
|
||||
strokeDasharray: (data._isTemp && !data._isSubGraphTemp && data.sourceType !== BlockEnum.Group && data.targetType !== BlockEnum.Group) ? '8 8' : undefined,
|
||||
strokeDasharray: (data._isTemp && !data._isSubGraphTemp) ? '8 8' : undefined,
|
||||
}}
|
||||
/>
|
||||
{allowGraphActions && (
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export const CUSTOM_GROUP_NODE = 'custom-group'
|
||||
export const CUSTOM_GROUP_INPUT_NODE = 'custom-group-input'
|
||||
export const CUSTOM_GROUP_EXIT_PORT_NODE = 'custom-group-exit-port'
|
||||
|
||||
export const GROUP_CHILDREN_Z_INDEX = 1002
|
||||
|
||||
export const UI_ONLY_GROUP_NODE_TYPES = new Set([
|
||||
CUSTOM_GROUP_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
])
|
||||
@@ -1,54 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { CustomGroupExitPortNodeData } from './types'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CustomGroupExitPortNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupExitPortNodeData
|
||||
}
|
||||
|
||||
const CustomGroupExitPortNode: FC<CustomGroupExitPortNodeProps> = ({ id: _id, data }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'h-8 w-8 rounded-full',
|
||||
'bg-util-colors-green-green-500 shadow-md',
|
||||
data.selected && 'ring-2 ring-primary-400',
|
||||
)}
|
||||
>
|
||||
{/* Target handle - receives internal connections from leaf nodes */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Source handle - connects to external nodes */}
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className="h-4 w-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupExitPortNode)
|
||||
@@ -1,55 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { CustomGroupInputNodeData } from './types'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CustomGroupInputNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupInputNodeData
|
||||
}
|
||||
|
||||
const CustomGroupInputNode: FC<CustomGroupInputNodeProps> = ({ id: _id, data }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
'h-8 w-8 rounded-full',
|
||||
'bg-util-colors-blue-blue-500 shadow-md',
|
||||
data.selected && 'ring-2 ring-primary-400',
|
||||
)}
|
||||
>
|
||||
{/* Target handle - receives external connections */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Source handle - connects to entry nodes */}
|
||||
<Handle
|
||||
id="source"
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-2 !w-2 !border-0 !bg-white"
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<svg
|
||||
className="h-4 w-4 text-white"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupInputNode)
|
||||
@@ -1,94 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import type { CustomGroupNodeData } from './types'
|
||||
import { memo } from 'react'
|
||||
import { Handle, Position } from 'reactflow'
|
||||
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type CustomGroupNodeProps = {
|
||||
id: string
|
||||
data: CustomGroupNodeData
|
||||
}
|
||||
|
||||
const CustomGroupNode: FC<CustomGroupNodeProps> = ({ data }) => {
|
||||
const { group } = data
|
||||
const exitPorts = group.exitPorts ?? []
|
||||
const connectedSourceHandleIds = data._connectedSourceHandleIds ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-workflow-block-parma-bg/50 group relative rounded-2xl border-2 border-dashed border-components-panel-border',
|
||||
data.selected && 'border-primary-400',
|
||||
)}
|
||||
style={{
|
||||
width: data.width || 280,
|
||||
height: data.height || 200,
|
||||
}}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="absolute -top-7 left-0 flex items-center gap-1 px-2">
|
||||
<span className="text-xs font-medium text-text-tertiary">
|
||||
{group.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Target handle for incoming connections */}
|
||||
<Handle
|
||||
id="target"
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className={cn(
|
||||
'!h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
|
||||
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
|
||||
'transition-all hover:scale-125',
|
||||
)}
|
||||
style={{ top: '50%' }}
|
||||
/>
|
||||
|
||||
<div className="px-3 pt-3">
|
||||
{exitPorts.map((port, index) => {
|
||||
const connected = connectedSourceHandleIds.includes(port.portNodeId)
|
||||
|
||||
return (
|
||||
<div key={port.portNodeId} className="relative flex h-6 items-center px-1">
|
||||
<div className="w-full text-right text-xs font-semibold text-text-secondary">
|
||||
{port.name}
|
||||
</div>
|
||||
|
||||
<Handle
|
||||
id={port.portNodeId}
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className={cn(
|
||||
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
|
||||
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
|
||||
'transition-all hover:scale-125',
|
||||
!connected && 'after:opacity-0',
|
||||
'!-right-[21px] !top-1/2 !-translate-y-1/2',
|
||||
)}
|
||||
isConnectable
|
||||
/>
|
||||
|
||||
{/* Visual "+" indicator (styling aligned with existing branch handles) */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute z-10 hidden h-4 w-4 items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface',
|
||||
'-right-[21px] top-1/2 -translate-y-1/2',
|
||||
'group-hover:flex',
|
||||
data.selected && '!flex',
|
||||
)}
|
||||
>
|
||||
<Plus02 className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomGroupNode)
|
||||
@@ -1,19 +0,0 @@
|
||||
export {
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_NODE,
|
||||
GROUP_CHILDREN_Z_INDEX,
|
||||
UI_ONLY_GROUP_NODE_TYPES,
|
||||
} from './constants'
|
||||
|
||||
export { default as CustomGroupExitPortNode } from './custom-group-exit-port-node'
|
||||
|
||||
export { default as CustomGroupInputNode } from './custom-group-input-node'
|
||||
export { default as CustomGroupNode } from './custom-group-node'
|
||||
export type {
|
||||
CustomGroupExitPortNodeData,
|
||||
CustomGroupInputNodeData,
|
||||
CustomGroupNodeData,
|
||||
ExitPortInfo,
|
||||
GroupMember,
|
||||
} from './types'
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { BlockEnum } from '../types'
|
||||
|
||||
/**
|
||||
* Exit port info stored in Group node
|
||||
*/
|
||||
export type ExitPortInfo = {
|
||||
portNodeId: string
|
||||
leafNodeId: string
|
||||
sourceHandle: string
|
||||
name: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Group node data structure
|
||||
* node.type = 'custom-group'
|
||||
* node.data.type = '' (empty string to bypass backend NodeType validation)
|
||||
*/
|
||||
export type CustomGroupNodeData = {
|
||||
type: '' // Empty string bypasses backend NodeType validation
|
||||
title: string
|
||||
desc?: string
|
||||
_connectedSourceHandleIds?: string[]
|
||||
_connectedTargetHandleIds?: string[]
|
||||
group: {
|
||||
groupId: string
|
||||
title: string
|
||||
memberNodeIds: string[]
|
||||
entryNodeIds: string[]
|
||||
inputNodeId: string
|
||||
exitPorts: ExitPortInfo[]
|
||||
collapsed: boolean
|
||||
}
|
||||
width?: number
|
||||
height?: number
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Group Input node data structure
|
||||
* node.type = 'custom-group-input'
|
||||
* node.data.type = ''
|
||||
*/
|
||||
export type CustomGroupInputNodeData = {
|
||||
type: ''
|
||||
title: string
|
||||
desc?: string
|
||||
groupInput: {
|
||||
groupId: string
|
||||
title: string
|
||||
}
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit Port node data structure
|
||||
* node.type = 'custom-group-exit-port'
|
||||
* node.data.type = ''
|
||||
*/
|
||||
export type CustomGroupExitPortNodeData = {
|
||||
type: ''
|
||||
title: string
|
||||
desc?: string
|
||||
exitPort: {
|
||||
groupId: string
|
||||
leafNodeId: string
|
||||
sourceHandle: string
|
||||
name: string
|
||||
}
|
||||
selected?: boolean
|
||||
_isTempNode?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Member node info for display
|
||||
*/
|
||||
export type GroupMember = {
|
||||
id: string
|
||||
type: BlockEnum
|
||||
label?: string
|
||||
}
|
||||
@@ -8,11 +8,6 @@ import type {
|
||||
} from '../types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
@@ -151,45 +146,6 @@ export const useEdgesInteractions = () => {
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
|
||||
const edgesToDelete: Set<string> = new Set([currentEdge.id])
|
||||
|
||||
if (currentEdge.data?._isTemp) {
|
||||
const groupNode = nodes.find(n =>
|
||||
n.data.type === BlockEnum.Group
|
||||
&& (n.id === currentEdge.source || n.id === currentEdge.target),
|
||||
)
|
||||
|
||||
if (groupNode) {
|
||||
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
|
||||
|
||||
if (currentEdge.target === groupNode.id) {
|
||||
edges.forEach((edge) => {
|
||||
if (edge.source === currentEdge.source
|
||||
&& memberIds.has(edge.target)
|
||||
&& edge.sourceHandle === currentEdge.sourceHandle) {
|
||||
edgesToDelete.add(edge.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (currentEdge.source === groupNode.id) {
|
||||
const sourceHandle = currentEdge.sourceHandle || ''
|
||||
const lastDashIndex = sourceHandle.lastIndexOf('-')
|
||||
if (lastDashIndex > 0) {
|
||||
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
|
||||
const originalHandle = sourceHandle.substring(lastDashIndex + 1)
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (edge.source === leafNodeId
|
||||
&& edge.target === currentEdge.target
|
||||
&& (edge.sourceHandle || 'source') === originalHandle) {
|
||||
edgesToDelete.add(edge.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
{ type: 'remove', edge: currentEdge },
|
||||
@@ -209,7 +165,7 @@ export const useEdgesInteractions = () => {
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
for (let i = draft.length - 1; i >= 0; i--) {
|
||||
if (edgesToDelete.has(draft[i].id))
|
||||
if (draft[i].id === currentEdge.id)
|
||||
draft.splice(i, 1)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { PredecessorHandle } from '../utils'
|
||||
import { useMemo } from 'react'
|
||||
import { useStore as useReactFlowStore } from 'reactflow'
|
||||
import { shallow } from 'zustand/shallow'
|
||||
import { BlockEnum } from '../types'
|
||||
import { getCommonPredecessorHandles } from '../utils'
|
||||
|
||||
export type MakeGroupAvailability = {
|
||||
canMakeGroup: boolean
|
||||
branchEntryNodeIds: string[]
|
||||
commonPredecessorHandle?: PredecessorHandle
|
||||
}
|
||||
|
||||
type MinimalEdge = {
|
||||
id: string
|
||||
source: string
|
||||
sourceHandle: string
|
||||
target: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to check if the selected nodes can be grouped.
|
||||
* Can be called both from React hooks and imperatively.
|
||||
*/
|
||||
export const checkMakeGroupAvailability = (
|
||||
selectedNodeIds: string[],
|
||||
edges: MinimalEdge[],
|
||||
hasGroupNode = false,
|
||||
): MakeGroupAvailability => {
|
||||
if (selectedNodeIds.length <= 1 || hasGroupNode) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds: [],
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const selectedNodeIdSet = new Set(selectedNodeIds)
|
||||
const inboundFromOutsideTargets = new Set<string>()
|
||||
const incomingEdgeCounts = new Map<string, number>()
|
||||
const incomingFromSelectedTargets = new Set<string>()
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// Only consider edges whose target is inside the selected subgraph.
|
||||
if (!selectedNodeIdSet.has(edge.target))
|
||||
return
|
||||
|
||||
incomingEdgeCounts.set(edge.target, (incomingEdgeCounts.get(edge.target) ?? 0) + 1)
|
||||
|
||||
if (selectedNodeIdSet.has(edge.source))
|
||||
incomingFromSelectedTargets.add(edge.target)
|
||||
else
|
||||
inboundFromOutsideTargets.add(edge.target)
|
||||
})
|
||||
|
||||
// Branch head (entry) definition:
|
||||
// - has at least one incoming edge
|
||||
// - and all its incoming edges come from outside the selected subgraph
|
||||
const branchEntryNodeIds = selectedNodeIds.filter((nodeId) => {
|
||||
const incomingEdgeCount = incomingEdgeCounts.get(nodeId) ?? 0
|
||||
if (incomingEdgeCount === 0)
|
||||
return false
|
||||
|
||||
return !incomingFromSelectedTargets.has(nodeId)
|
||||
})
|
||||
|
||||
// No branch head means we cannot tell how many branches are represented by this selection.
|
||||
if (branchEntryNodeIds.length === 0) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Guardrail: disallow side entrances into the selected subgraph.
|
||||
// If an outside node connects to a non-entry node inside the selection, the grouping boundary is ambiguous.
|
||||
const branchEntryNodeIdSet = new Set(branchEntryNodeIds)
|
||||
const hasInboundToNonEntryNode = Array.from(inboundFromOutsideTargets).some(nodeId => !branchEntryNodeIdSet.has(nodeId))
|
||||
|
||||
if (hasInboundToNonEntryNode) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
// Compare the branch heads by their common predecessor "handler" (source node + sourceHandle).
|
||||
// This is required for multi-handle nodes like If-Else / Classifier where different branches use different handles.
|
||||
const commonPredecessorHandles = getCommonPredecessorHandles(
|
||||
branchEntryNodeIds,
|
||||
// Only look at edges coming from outside the selected subgraph when determining the "pre" handler.
|
||||
edges.filter(edge => !selectedNodeIdSet.has(edge.source)),
|
||||
)
|
||||
|
||||
if (commonPredecessorHandles.length !== 1) {
|
||||
return {
|
||||
canMakeGroup: false,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
canMakeGroup: true,
|
||||
branchEntryNodeIds,
|
||||
commonPredecessorHandle: commonPredecessorHandles[0],
|
||||
}
|
||||
}
|
||||
|
||||
export const useMakeGroupAvailability = (selectedNodeIds: string[]): MakeGroupAvailability => {
|
||||
const edgeKeys = useReactFlowStore((state) => {
|
||||
const delimiter = '\u0000'
|
||||
const keys = state.edges.map(edge => `${edge.source}${delimiter}${edge.sourceHandle || 'source'}${delimiter}${edge.target}`)
|
||||
keys.sort()
|
||||
return keys
|
||||
}, shallow)
|
||||
|
||||
const hasGroupNode = useReactFlowStore((state) => {
|
||||
return state.getNodes().some(node => node.selected && node.data.type === BlockEnum.Group)
|
||||
})
|
||||
|
||||
return useMemo(() => {
|
||||
const delimiter = '\u0000'
|
||||
const edges = edgeKeys.map((key) => {
|
||||
const [source, handleId, target] = key.split(delimiter)
|
||||
return {
|
||||
id: key,
|
||||
source,
|
||||
sourceHandle: handleId || 'source',
|
||||
target,
|
||||
}
|
||||
})
|
||||
|
||||
return checkMakeGroupAvailability(selectedNodeIds, edges, hasGroupNode)
|
||||
}, [edgeKeys, selectedNodeIds, hasGroupNode])
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ResizeParamsWithDirection,
|
||||
} from 'reactflow'
|
||||
import type { PluginDefaultValue } from '../block-selector/types'
|
||||
import type { GroupHandler, GroupMember, GroupNodeData } from '../nodes/group/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
@@ -59,7 +58,6 @@ import { useAutoGenerateWebhookUrl } from './use-auto-generate-webhook-url'
|
||||
import { useCollaborativeWorkflow } from './use-collaborative-workflow'
|
||||
import { useHelpline } from './use-helpline'
|
||||
import useInspectVarsCrud from './use-inspect-vars-crud'
|
||||
import { checkMakeGroupAvailability } from './use-make-group'
|
||||
import { useNodesMetaData } from './use-nodes-meta-data'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import {
|
||||
@@ -82,151 +80,6 @@ const ENTRY_NODE_WRAPPER_OFFSET = {
|
||||
y: 21, // Adjusted based on visual testing feedback
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Parse group handler id to get original node id and sourceHandle
|
||||
* Handler id format: `${nodeId}-${sourceHandle}`
|
||||
*/
|
||||
function parseGroupHandlerId(handlerId: string): { originalNodeId: string, originalSourceHandle: string } {
|
||||
const lastDashIndex = handlerId.lastIndexOf('-')
|
||||
return {
|
||||
originalNodeId: handlerId.substring(0, lastDashIndex),
|
||||
originalSourceHandle: handlerId.substring(lastDashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a pair of edges for group node connections:
|
||||
* - realEdge: hidden edge from original node to target (persisted to backend)
|
||||
* - uiEdge: visible temp edge from group to target (UI-only, not persisted)
|
||||
*/
|
||||
function createGroupEdgePair(params: {
|
||||
groupNodeId: string
|
||||
handlerId: string
|
||||
targetNodeId: string
|
||||
targetHandle: string
|
||||
nodes: Node[]
|
||||
baseEdgeData?: Partial<Edge['data']>
|
||||
zIndex?: number
|
||||
}): { realEdge: Edge, uiEdge: Edge } | null {
|
||||
const { groupNodeId, handlerId, targetNodeId, targetHandle, nodes, baseEdgeData = {}, zIndex = 0 } = params
|
||||
|
||||
const groupNode = nodes.find(node => node.id === groupNodeId)
|
||||
const groupData = groupNode?.data as GroupNodeData | undefined
|
||||
const handler = groupData?.handlers?.find(h => h.id === handlerId)
|
||||
|
||||
let originalNodeId: string
|
||||
let originalSourceHandle: string
|
||||
|
||||
if (handler?.nodeId && handler?.sourceHandle) {
|
||||
originalNodeId = handler.nodeId
|
||||
originalSourceHandle = handler.sourceHandle
|
||||
}
|
||||
else {
|
||||
const parsed = parseGroupHandlerId(handlerId)
|
||||
originalNodeId = parsed.originalNodeId
|
||||
originalSourceHandle = parsed.originalSourceHandle
|
||||
}
|
||||
|
||||
const originalNode = nodes.find(node => node.id === originalNodeId)
|
||||
const targetNode = nodes.find(node => node.id === targetNodeId)
|
||||
|
||||
if (!originalNode || !targetNode)
|
||||
return null
|
||||
|
||||
// Create the real edge (from original node to target) - hidden because original node is in group
|
||||
const realEdge: Edge = {
|
||||
id: `${originalNodeId}-${originalSourceHandle}-${targetNodeId}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: originalNodeId,
|
||||
sourceHandle: originalSourceHandle,
|
||||
target: targetNodeId,
|
||||
targetHandle,
|
||||
hidden: true,
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: originalNode.data.type,
|
||||
targetType: targetNode.data.type,
|
||||
_hiddenInGroupId: groupNodeId,
|
||||
},
|
||||
zIndex,
|
||||
}
|
||||
|
||||
// Create the UI edge (from group to target) - temporary, not persisted to backend
|
||||
const uiEdge: Edge = {
|
||||
id: `${groupNodeId}-${handlerId}-${targetNodeId}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: groupNodeId,
|
||||
sourceHandle: handlerId,
|
||||
target: targetNodeId,
|
||||
targetHandle,
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: BlockEnum.Group,
|
||||
targetType: targetNode.data.type,
|
||||
_isTemp: true,
|
||||
},
|
||||
zIndex,
|
||||
}
|
||||
|
||||
return { realEdge, uiEdge }
|
||||
}
|
||||
|
||||
function createGroupInboundEdges(params: {
|
||||
sourceNodeId: string
|
||||
sourceHandle: string
|
||||
groupNodeId: string
|
||||
groupData: GroupNodeData
|
||||
nodes: Node[]
|
||||
baseEdgeData?: Partial<Edge['data']>
|
||||
zIndex?: number
|
||||
}): { realEdges: Edge[], uiEdge: Edge } | null {
|
||||
const { sourceNodeId, sourceHandle, groupNodeId, groupData, nodes, baseEdgeData = {}, zIndex = 0 } = params
|
||||
|
||||
const sourceNode = nodes.find(node => node.id === sourceNodeId)
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
if (!sourceNode || headNodeIds.length === 0)
|
||||
return null
|
||||
|
||||
const realEdges: Edge[] = headNodeIds.map((headNodeId) => {
|
||||
const headNode = nodes.find(node => node.id === headNodeId)
|
||||
return {
|
||||
id: `${sourceNodeId}-${sourceHandle}-${headNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: sourceNodeId,
|
||||
sourceHandle,
|
||||
target: headNodeId,
|
||||
targetHandle: 'target',
|
||||
hidden: true,
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: sourceNode.data.type,
|
||||
targetType: headNode?.data.type,
|
||||
_hiddenInGroupId: groupNodeId,
|
||||
},
|
||||
zIndex,
|
||||
} as Edge
|
||||
})
|
||||
|
||||
const uiEdge: Edge = {
|
||||
id: `${sourceNodeId}-${sourceHandle}-${groupNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: sourceNodeId,
|
||||
sourceHandle,
|
||||
target: groupNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
...baseEdgeData,
|
||||
sourceType: sourceNode.data.type,
|
||||
targetType: BlockEnum.Group,
|
||||
_isTemp: true,
|
||||
},
|
||||
zIndex,
|
||||
}
|
||||
|
||||
return { realEdges, uiEdge }
|
||||
}
|
||||
|
||||
type NodesMetaDataMap = Record<BlockEnum, { metaData?: { isUndeletable?: boolean } }>
|
||||
|
||||
const buildNestedDeleteSet = (
|
||||
@@ -712,146 +565,6 @@ export const useNodesInteractions = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if source is a group node - need special handling
|
||||
const isSourceGroup = sourceNode?.data.type === BlockEnum.Group
|
||||
|
||||
if (isSourceGroup && sourceHandle && target && targetHandle) {
|
||||
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(sourceHandle)
|
||||
|
||||
// Check if real edge already exists
|
||||
if (edges.find(edge =>
|
||||
edge.source === originalNodeId
|
||||
&& edge.sourceHandle === originalSourceHandle
|
||||
&& edge.target === target
|
||||
&& edge.targetHandle === targetHandle,
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentNode = nodes.find(node => node.id === targetNode?.parentId)
|
||||
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
|
||||
const edgePair = createGroupEdgePair({
|
||||
groupNodeId: source!,
|
||||
handlerId: sourceHandle,
|
||||
targetNodeId: target,
|
||||
targetHandle,
|
||||
nodes,
|
||||
baseEdgeData: {
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? targetNode?.parentId : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? targetNode?.parentId : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (!edgePair)
|
||||
return
|
||||
|
||||
const { realEdge, uiEdge } = edgePair
|
||||
|
||||
// Update connected handle ids for the original node
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[{ type: 'add', edge: realEdge }],
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.push(realEdge)
|
||||
draft.push(uiEdge)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
|
||||
nodeId: targetNode?.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const isTargetGroup = targetNode?.data.type === BlockEnum.Group
|
||||
|
||||
if (isTargetGroup && source && sourceHandle) {
|
||||
const groupData = targetNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
if (edges.find(edge =>
|
||||
edge.source === source
|
||||
&& edge.sourceHandle === sourceHandle
|
||||
&& edge.target === target
|
||||
&& edge.targetHandle === targetHandle,
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentNode = nodes.find(node => node.id === sourceNode?.parentId)
|
||||
const isInIteration = parentNode && parentNode.data.type === BlockEnum.Iteration
|
||||
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
|
||||
|
||||
const inboundResult = createGroupInboundEdges({
|
||||
sourceNodeId: source,
|
||||
sourceHandle,
|
||||
groupNodeId: target!,
|
||||
groupData,
|
||||
nodes,
|
||||
baseEdgeData: {
|
||||
isInIteration,
|
||||
iteration_id: isInIteration ? sourceNode?.parentId : undefined,
|
||||
isInLoop,
|
||||
loop_id: isInLoop ? sourceNode?.parentId : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (!inboundResult)
|
||||
return
|
||||
|
||||
const { realEdges, uiEdge } = inboundResult
|
||||
|
||||
const edgeChanges = realEdges.map(edge => ({ type: 'add' as const, edge }))
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(edgeChanges, nodes)
|
||||
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
realEdges.forEach((edge) => {
|
||||
draft.push(edge)
|
||||
})
|
||||
draft.push(uiEdge)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeConnect, {
|
||||
nodeId: headNodeIds[0],
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
edges.find(
|
||||
edge =>
|
||||
@@ -1311,34 +1024,8 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if prevNode is a group node - need special handling
|
||||
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
|
||||
let newEdge: Edge | null = null
|
||||
let newUiEdge: Edge | null = null
|
||||
|
||||
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
|
||||
const edgePair = createGroupEdgePair({
|
||||
groupNodeId: prevNodeId,
|
||||
handlerId: prevNodeSourceHandle,
|
||||
targetNodeId: newNode.id,
|
||||
targetHandle,
|
||||
nodes: [...nodes, newNode],
|
||||
baseEdgeData: {
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
iteration_id: isInIteration ? prevNode.parentId : undefined,
|
||||
loop_id: isInLoop ? prevNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (edgePair) {
|
||||
newEdge = edgePair.realEdge
|
||||
newUiEdge = edgePair.uiEdge
|
||||
}
|
||||
}
|
||||
else if (nodeType !== BlockEnum.DataSource) {
|
||||
// Normal case: prevNode is not a group
|
||||
if (nodeType !== BlockEnum.DataSource) {
|
||||
newEdge = {
|
||||
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
@@ -1363,7 +1050,7 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const edgesToAdd = [newEdge, newUiEdge].filter(Boolean).map(edge => ({ type: 'add' as const, edge: edge! }))
|
||||
const edgesToAdd = newEdge ? [{ type: 'add' as const, edge: newEdge }] : []
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
edgesToAdd,
|
||||
@@ -1435,8 +1122,6 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
if (newEdge)
|
||||
draft.push(newEdge)
|
||||
if (newUiEdge)
|
||||
draft.push(newUiEdge)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
@@ -1633,113 +1318,41 @@ export const useNodesInteractions = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if prevNode is a group node - need special handling
|
||||
const isPrevNodeGroup = prevNode.data.type === BlockEnum.Group
|
||||
let newPrevEdge: Edge | null = null
|
||||
let newPrevUiEdge: Edge | null = null
|
||||
const edgesToRemove: string[] = []
|
||||
|
||||
if (isPrevNodeGroup && prevNodeSourceHandle && nodeType !== BlockEnum.DataSource) {
|
||||
const { originalNodeId, originalSourceHandle } = parseGroupHandlerId(prevNodeSourceHandle)
|
||||
const currentEdge = edges.find(
|
||||
edge => edge.source === prevNodeId && edge.target === nextNodeId,
|
||||
)
|
||||
if (currentEdge)
|
||||
edgesToRemove.push(currentEdge.id)
|
||||
|
||||
// Find edges to remove: both hidden real edge and UI temp edge from group to nextNode
|
||||
const hiddenEdge = edges.find(
|
||||
edge => edge.source === originalNodeId
|
||||
&& edge.sourceHandle === originalSourceHandle
|
||||
&& edge.target === nextNodeId,
|
||||
)
|
||||
const uiTempEdge = edges.find(
|
||||
edge => edge.source === prevNodeId
|
||||
&& edge.sourceHandle === prevNodeSourceHandle
|
||||
&& edge.target === nextNodeId,
|
||||
)
|
||||
if (hiddenEdge)
|
||||
edgesToRemove.push(hiddenEdge.id)
|
||||
if (uiTempEdge)
|
||||
edgesToRemove.push(uiTempEdge.id)
|
||||
|
||||
const edgePair = createGroupEdgePair({
|
||||
groupNodeId: prevNodeId,
|
||||
handlerId: prevNodeSourceHandle,
|
||||
targetNodeId: newNode.id,
|
||||
if (nodeType !== BlockEnum.DataSource) {
|
||||
newPrevEdge = {
|
||||
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: prevNodeId,
|
||||
sourceHandle: prevNodeSourceHandle,
|
||||
target: newNode.id,
|
||||
targetHandle,
|
||||
nodes: [...nodes, newNode],
|
||||
baseEdgeData: {
|
||||
data: {
|
||||
sourceType: prevNode.data.type,
|
||||
targetType: newNode.data.type,
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
iteration_id: isInIteration ? prevNode.parentId : undefined,
|
||||
loop_id: isInLoop ? prevNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (edgePair) {
|
||||
newPrevEdge = edgePair.realEdge
|
||||
newPrevUiEdge = edgePair.uiEdge
|
||||
}
|
||||
}
|
||||
else {
|
||||
const isNextNodeGroupForRemoval = nextNode.data.type === BlockEnum.Group
|
||||
|
||||
if (isNextNodeGroupForRemoval) {
|
||||
const groupData = nextNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
headNodeIds.forEach((headNodeId) => {
|
||||
const realEdge = edges.find(
|
||||
edge => edge.source === prevNodeId
|
||||
&& edge.sourceHandle === prevNodeSourceHandle
|
||||
&& edge.target === headNodeId,
|
||||
)
|
||||
if (realEdge)
|
||||
edgesToRemove.push(realEdge.id)
|
||||
})
|
||||
|
||||
const uiEdge = edges.find(
|
||||
edge => edge.source === prevNodeId
|
||||
&& edge.sourceHandle === prevNodeSourceHandle
|
||||
&& edge.target === nextNodeId,
|
||||
)
|
||||
if (uiEdge)
|
||||
edgesToRemove.push(uiEdge.id)
|
||||
}
|
||||
else {
|
||||
const currentEdge = edges.find(
|
||||
edge => edge.source === prevNodeId && edge.target === nextNodeId,
|
||||
)
|
||||
if (currentEdge)
|
||||
edgesToRemove.push(currentEdge.id)
|
||||
}
|
||||
|
||||
if (nodeType !== BlockEnum.DataSource) {
|
||||
newPrevEdge = {
|
||||
id: `${prevNodeId}-${prevNodeSourceHandle}-${newNode.id}-${targetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: prevNodeId,
|
||||
sourceHandle: prevNodeSourceHandle,
|
||||
target: newNode.id,
|
||||
targetHandle,
|
||||
data: {
|
||||
sourceType: prevNode.data.type,
|
||||
targetType: newNode.data.type,
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
iteration_id: isInIteration ? prevNode.parentId : undefined,
|
||||
loop_id: isInLoop ? prevNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: prevNode.parentId
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
zIndex: prevNode.parentId
|
||||
? isInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
|
||||
let newNextEdge: Edge | null = null
|
||||
let newNextUiEdge: Edge | null = null
|
||||
const newNextRealEdges: Edge[] = []
|
||||
|
||||
const nextNodeParentNode
|
||||
= nodes.find(node => node.id === nextNode.parentId) || null
|
||||
@@ -1750,104 +1363,41 @@ export const useNodesInteractions = () => {
|
||||
= !!nextNodeParentNode
|
||||
&& nextNodeParentNode.data.type === BlockEnum.Loop
|
||||
|
||||
const isNextNodeGroup = nextNode.data.type === BlockEnum.Group
|
||||
|
||||
if (
|
||||
nodeType !== BlockEnum.IfElse
|
||||
&& nodeType !== BlockEnum.QuestionClassifier
|
||||
&& nodeType !== BlockEnum.HumanInput
|
||||
&& nodeType !== BlockEnum.LoopEnd
|
||||
) {
|
||||
if (isNextNodeGroup) {
|
||||
const groupData = nextNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
|
||||
headNodeIds.forEach((headNodeId) => {
|
||||
const headNode = nodes.find(node => node.id === headNodeId)
|
||||
newNextRealEdges.push({
|
||||
id: `${newNode.id}-${sourceHandle}-${headNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: headNodeId,
|
||||
targetHandle: 'target',
|
||||
hidden: true,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: headNode?.data.type,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_hiddenInGroupId: nextNodeId,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
} as Edge)
|
||||
})
|
||||
|
||||
newNextUiEdge = {
|
||||
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-target`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: nextNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: BlockEnum.Group,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration ? nextNode.parentId : undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_isTemp: true,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
else {
|
||||
newNextEdge = {
|
||||
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: nextNodeId,
|
||||
targetHandle: nextNodeTargetHandle,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: nextNode.data.type,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration
|
||||
? nextNode.parentId
|
||||
: undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
newNextEdge = {
|
||||
id: `${newNode.id}-${sourceHandle}-${nextNodeId}-${nextNodeTargetHandle}`,
|
||||
type: CUSTOM_EDGE,
|
||||
source: newNode.id,
|
||||
sourceHandle,
|
||||
target: nextNodeId,
|
||||
targetHandle: nextNodeTargetHandle,
|
||||
data: {
|
||||
sourceType: newNode.data.type,
|
||||
targetType: nextNode.data.type,
|
||||
isInIteration: isNextNodeInIteration,
|
||||
isInLoop: isNextNodeInLoop,
|
||||
iteration_id: isNextNodeInIteration
|
||||
? nextNode.parentId
|
||||
: undefined,
|
||||
loop_id: isNextNodeInLoop ? nextNode.parentId : undefined,
|
||||
_connectedNodeIsSelected: true,
|
||||
},
|
||||
zIndex: nextNode.parentId
|
||||
? isNextNodeInIteration
|
||||
? ITERATION_CHILDREN_Z_INDEX
|
||||
: LOOP_CHILDREN_Z_INDEX
|
||||
: 0,
|
||||
}
|
||||
}
|
||||
const edgeChanges = [
|
||||
...edgesToRemove.map(id => ({ type: 'remove' as const, edge: edges.find(e => e.id === id)! })).filter(c => c.edge),
|
||||
...(newPrevEdge ? [{ type: 'add' as const, edge: newPrevEdge }] : []),
|
||||
...(newPrevUiEdge ? [{ type: 'add' as const, edge: newPrevUiEdge }] : []),
|
||||
...(newNextEdge ? [{ type: 'add' as const, edge: newNextEdge }] : []),
|
||||
...newNextRealEdges.map(edge => ({ type: 'add' as const, edge })),
|
||||
...(newNextUiEdge ? [{ type: 'add' as const, edge: newNextUiEdge }] : []),
|
||||
]
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap
|
||||
= getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
@@ -1928,15 +1478,8 @@ export const useNodesInteractions = () => {
|
||||
})
|
||||
if (newPrevEdge)
|
||||
draft.push(newPrevEdge)
|
||||
if (newPrevUiEdge)
|
||||
draft.push(newPrevUiEdge)
|
||||
if (newNextEdge)
|
||||
draft.push(newNextEdge)
|
||||
newNextRealEdges.forEach((edge) => {
|
||||
draft.push(edge)
|
||||
})
|
||||
if (newNextUiEdge)
|
||||
draft.push(newNextUiEdge)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}
|
||||
@@ -2861,290 +2404,6 @@ export const useNodesInteractions = () => {
|
||||
return nodes.some(node => node.data._isBundled)
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const getCanMakeGroup = useCallback(() => {
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled)
|
||||
|
||||
if (bundledNodes.length <= 1)
|
||||
return false
|
||||
|
||||
const bundledNodeIds = bundledNodes.map(node => node.id)
|
||||
const minimalEdges = edges.map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle || 'source',
|
||||
target: edge.target,
|
||||
}))
|
||||
const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group)
|
||||
|
||||
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
|
||||
return canMakeGroup
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleMakeGroup = useCallback(() => {
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled)
|
||||
|
||||
if (bundledNodes.length <= 1)
|
||||
return
|
||||
|
||||
const bundledNodeIds = bundledNodes.map(node => node.id)
|
||||
const minimalEdges = edges.map(edge => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
sourceHandle: edge.sourceHandle || 'source',
|
||||
target: edge.target,
|
||||
}))
|
||||
const hasGroupNode = bundledNodes.some(node => node.data.type === BlockEnum.Group)
|
||||
|
||||
const { canMakeGroup } = checkMakeGroupAvailability(bundledNodeIds, minimalEdges, hasGroupNode)
|
||||
if (!canMakeGroup)
|
||||
return
|
||||
|
||||
const bundledNodeIdSet = new Set(bundledNodeIds)
|
||||
const bundledNodeIdIsLeaf = new Set<string>()
|
||||
const inboundEdges = edges.filter(edge => !bundledNodeIdSet.has(edge.source) && bundledNodeIdSet.has(edge.target))
|
||||
const outboundEdges = edges.filter(edge => bundledNodeIdSet.has(edge.source) && !bundledNodeIdSet.has(edge.target))
|
||||
|
||||
// leaf node: no outbound edges to other nodes in the selection
|
||||
const handlers: GroupHandler[] = []
|
||||
const leafNodeIdSet = new Set<string>()
|
||||
|
||||
bundledNodes.forEach((node: Node) => {
|
||||
const targetBranches = node.data._targetBranches || [{ id: 'source', name: node.data.title }]
|
||||
targetBranches.forEach((branch) => {
|
||||
// A branch should be a handler if it's either:
|
||||
// 1. Connected to a node OUTSIDE the group
|
||||
// 2. NOT connected to any node INSIDE the group
|
||||
const isConnectedInside = edges.some(edge =>
|
||||
edge.source === node.id
|
||||
&& (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source'))
|
||||
&& bundledNodeIdSet.has(edge.target),
|
||||
)
|
||||
const isConnectedOutside = edges.some(edge =>
|
||||
edge.source === node.id
|
||||
&& (edge.sourceHandle === branch.id || (!edge.sourceHandle && branch.id === 'source'))
|
||||
&& !bundledNodeIdSet.has(edge.target),
|
||||
)
|
||||
|
||||
if (isConnectedOutside || !isConnectedInside) {
|
||||
const handlerId = `${node.id}-${branch.id}`
|
||||
handlers.push({
|
||||
id: handlerId,
|
||||
label: branch.name || node.data.title || node.id,
|
||||
nodeId: node.id,
|
||||
sourceHandle: branch.id,
|
||||
})
|
||||
leafNodeIdSet.add(node.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const leafNodeIds = Array.from(leafNodeIdSet)
|
||||
leafNodeIds.forEach(id => bundledNodeIdIsLeaf.add(id))
|
||||
|
||||
const members: GroupMember[] = bundledNodes.map((node) => {
|
||||
return {
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
label: node.data.title,
|
||||
}
|
||||
})
|
||||
|
||||
// head nodes: nodes that receive input from outside the group
|
||||
const headNodeIds = [...new Set(inboundEdges.map(edge => edge.target))]
|
||||
|
||||
// put the group node at the top-left corner of the selection, slightly offset
|
||||
const { x: minX, y: minY } = getTopLeftNodePosition(bundledNodes)
|
||||
|
||||
const groupNodeData: GroupNodeData = {
|
||||
title: t('operator.makeGroup', { ns: 'workflow' }),
|
||||
desc: '',
|
||||
type: BlockEnum.Group,
|
||||
members,
|
||||
handlers,
|
||||
headNodeIds,
|
||||
leafNodeIds,
|
||||
selected: true,
|
||||
_targetBranches: handlers.map(handler => ({
|
||||
id: handler.id,
|
||||
name: handler.label || handler.id,
|
||||
})),
|
||||
}
|
||||
|
||||
const { newNode: groupNode } = generateNewNode({
|
||||
data: groupNodeData,
|
||||
position: {
|
||||
x: minX - 20,
|
||||
y: minY - 20,
|
||||
},
|
||||
})
|
||||
|
||||
const nodeTypeMap = new Map(nodes.map(node => [node.id, node.data.type]))
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (bundledNodeIdSet.has(node.id)) {
|
||||
node.data._isBundled = false
|
||||
node.selected = false
|
||||
node.hidden = true
|
||||
node.data._hiddenInGroupId = groupNode.id
|
||||
}
|
||||
else {
|
||||
node.data._isBundled = false
|
||||
}
|
||||
})
|
||||
draft.push(groupNode)
|
||||
})
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
if (bundledNodeIdSet.has(edge.source) || bundledNodeIdSet.has(edge.target)) {
|
||||
edge.hidden = true
|
||||
edge.data = {
|
||||
...edge.data,
|
||||
_hiddenInGroupId: groupNode.id,
|
||||
_isBundled: false,
|
||||
}
|
||||
}
|
||||
else if (edge.data?._isBundled) {
|
||||
edge.data._isBundled = false
|
||||
}
|
||||
})
|
||||
|
||||
// re-add the external inbound edges to the group node as UI-only edges (not persisted to backend)
|
||||
inboundEdges.forEach((edge) => {
|
||||
draft.push({
|
||||
id: `${edge.id}__to-${groupNode.id}`,
|
||||
type: edge.type || CUSTOM_EDGE,
|
||||
source: edge.source,
|
||||
target: groupNode.id,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
...edge.data,
|
||||
sourceType: nodeTypeMap.get(edge.source)!,
|
||||
targetType: BlockEnum.Group,
|
||||
_hiddenInGroupId: undefined,
|
||||
_isBundled: false,
|
||||
_isTemp: true, // UI-only edge, not persisted to backend
|
||||
},
|
||||
zIndex: edge.zIndex,
|
||||
})
|
||||
})
|
||||
|
||||
// outbound edges of the group node as UI-only edges (not persisted to backend)
|
||||
outboundEdges.forEach((edge) => {
|
||||
if (!bundledNodeIdIsLeaf.has(edge.source))
|
||||
return
|
||||
|
||||
// Use the same handler id format: nodeId-sourceHandle
|
||||
const originalSourceHandle = edge.sourceHandle || 'source'
|
||||
const handlerId = `${edge.source}-${originalSourceHandle}`
|
||||
|
||||
draft.push({
|
||||
id: `${groupNode.id}-${edge.target}-${edge.targetHandle || 'target'}-${handlerId}`,
|
||||
type: edge.type || CUSTOM_EDGE,
|
||||
source: groupNode.id,
|
||||
target: edge.target,
|
||||
sourceHandle: handlerId,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: {
|
||||
...edge.data,
|
||||
sourceType: BlockEnum.Group,
|
||||
targetType: nodeTypeMap.get(edge.target)!,
|
||||
_hiddenInGroupId: undefined,
|
||||
_isBundled: false,
|
||||
_isTemp: true,
|
||||
},
|
||||
zIndex: edge.zIndex,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
workflowStore.setState({
|
||||
selectionMenu: undefined,
|
||||
})
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, {
|
||||
nodeId: groupNode.id,
|
||||
})
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, collaborativeWorkflow, t, workflowStore])
|
||||
|
||||
// check if the current selection can be ungrouped (single selected Group node)
|
||||
const getCanUngroup = useCallback(() => {
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const selectedNodes = nodes.filter(node => node.selected)
|
||||
|
||||
if (selectedNodes.length !== 1)
|
||||
return false
|
||||
|
||||
return selectedNodes[0].data.type === BlockEnum.Group
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
// get the selected group node id for ungroup operation
|
||||
const getSelectedGroupId = useCallback(() => {
|
||||
const { nodes } = collaborativeWorkflow.getState()
|
||||
const selectedNodes = nodes.filter(node => node.selected)
|
||||
|
||||
if (selectedNodes.length === 1 && selectedNodes[0].data.type === BlockEnum.Group)
|
||||
return selectedNodes[0].id
|
||||
|
||||
return undefined
|
||||
}, [collaborativeWorkflow])
|
||||
|
||||
const handleUngroup = useCallback((groupId: string) => {
|
||||
const { nodes, setNodes, edges, setEdges } = collaborativeWorkflow.getState()
|
||||
const groupNode = nodes.find(n => n.id === groupId)
|
||||
|
||||
if (!groupNode || groupNode.data.type !== BlockEnum.Group)
|
||||
return
|
||||
|
||||
const memberIds = new Set((groupNode.data.members || []).map((m: { id: string }) => m.id))
|
||||
|
||||
// restore hidden member nodes
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (memberIds.has(node.id)) {
|
||||
node.hidden = false
|
||||
delete node.data._hiddenInGroupId
|
||||
}
|
||||
})
|
||||
// remove group node
|
||||
const groupIndex = draft.findIndex(n => n.id === groupId)
|
||||
if (groupIndex !== -1)
|
||||
draft.splice(groupIndex, 1)
|
||||
})
|
||||
|
||||
// restore hidden edges and remove temp edges in single pass O(E)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const indicesToRemove: number[] = []
|
||||
|
||||
for (let i = 0; i < draft.length; i++) {
|
||||
const edge = draft[i]
|
||||
// restore hidden edges that involve member nodes
|
||||
if (edge.hidden && (memberIds.has(edge.source) || memberIds.has(edge.target)))
|
||||
edge.hidden = false
|
||||
// collect temp edges connected to group for removal
|
||||
if (edge.data?._isTemp && (edge.source === groupId || edge.target === groupId))
|
||||
indicesToRemove.push(i)
|
||||
}
|
||||
|
||||
// remove collected indices in reverse order to avoid index shift
|
||||
for (let i = indicesToRemove.length - 1; i >= 0; i--)
|
||||
draft.splice(indicesToRemove[i], 1)
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeDelete, {
|
||||
nodeId: groupId,
|
||||
})
|
||||
}, [handleSyncWorkflowDraft, saveStateToHistory, collaborativeWorkflow])
|
||||
|
||||
return {
|
||||
handleNodeDragStart,
|
||||
handleNodeDrag,
|
||||
@@ -3165,8 +2424,6 @@ export const useNodesInteractions = () => {
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleMakeGroup,
|
||||
handleUngroup,
|
||||
handleNodeResize,
|
||||
handleNodeDisconnect,
|
||||
handleHistoryBack,
|
||||
@@ -3174,8 +2431,5 @@ export const useNodesInteractions = () => {
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
hasBundledNodes,
|
||||
getCanMakeGroup,
|
||||
getCanUngroup,
|
||||
getSelectedGroupId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import GroupDefault from '@/app/components/workflow/nodes/group/default'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
@@ -27,7 +25,6 @@ export const useNodesMetaData = () => {
|
||||
}
|
||||
|
||||
export const useNodeMetaData = (node: Node) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
@@ -37,9 +34,6 @@ export const useNodeMetaData = (node: Node) => {
|
||||
const { data } = node
|
||||
const nodeMetaData = availableNodesMetaData.nodesMap?.[data.type]
|
||||
const author = useMemo(() => {
|
||||
if (data.type === BlockEnum.Group)
|
||||
return GroupDefault.metaData.author
|
||||
|
||||
if (data.type === BlockEnum.DataSource)
|
||||
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.author
|
||||
|
||||
@@ -54,9 +48,6 @@ export const useNodeMetaData = (node: Node) => {
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList])
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (data.type === BlockEnum.Group)
|
||||
return t('blocksAbout.group', { ns: 'workflow' })
|
||||
|
||||
if (data.type === BlockEnum.DataSource)
|
||||
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language]
|
||||
if (data.type === BlockEnum.Tool) {
|
||||
@@ -67,7 +58,7 @@ export const useNodeMetaData = (node: Node) => {
|
||||
return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
}
|
||||
return nodeMetaData?.metaData.description
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language, t])
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language])
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -29,11 +29,6 @@ export const useShortcuts = (enabled = true): void => {
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
hasBundledNodes,
|
||||
getCanMakeGroup,
|
||||
handleMakeGroup,
|
||||
getCanUngroup,
|
||||
getSelectedGroupId,
|
||||
handleUngroup,
|
||||
} = useNodesInteractions()
|
||||
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
@@ -113,26 +108,6 @@ export const useShortcuts = (enabled = true): void => {
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.g`, (e) => {
|
||||
// Only intercept when the selection can be grouped
|
||||
if (shouldHandleShortcut(e) && getCanMakeGroup()) {
|
||||
e.preventDefault()
|
||||
// Close selection context menu if open
|
||||
workflowStore.setState({ selectionMenu: undefined })
|
||||
handleMakeGroup()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.g`, (e) => {
|
||||
// Only intercept when the selection can be ungrouped
|
||||
if (shouldHandleShortcut(e) && getCanUngroup()) {
|
||||
e.preventDefault()
|
||||
const groupId = getSelectedGroupId()
|
||||
if (groupId)
|
||||
handleUngroup(groupId)
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type {
|
||||
Connection,
|
||||
} from 'reactflow'
|
||||
import type { GroupNodeData } from '../nodes/group/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LoopNodeType } from '../nodes/loop/types'
|
||||
import type {
|
||||
BlockEnum,
|
||||
Edge,
|
||||
Node,
|
||||
ValueSelector,
|
||||
@@ -32,8 +32,7 @@ import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
|
||||
import { BlockEnum, WorkflowRunningStatus } from '../types'
|
||||
import { WorkflowRunningStatus } from '../types'
|
||||
import {
|
||||
getWorkflowEntryNode,
|
||||
isWorkflowEntryNode,
|
||||
@@ -346,7 +345,7 @@ export const useWorkflow = () => {
|
||||
return startNodes
|
||||
}, [nodesMap, getRootNodesById])
|
||||
|
||||
const isValidConnection = useCallback(({ source, sourceHandle, target }: Connection) => {
|
||||
const isValidConnection = useCallback(({ source, target }: Connection) => {
|
||||
const { nodes, edges } = collaborativeWorkflow.getState()
|
||||
const sourceNode: Node = nodes.find(node => node.id === source)!
|
||||
const targetNode: Node = nodes.find(node => node.id === target)!
|
||||
@@ -357,42 +356,15 @@ export const useWorkflow = () => {
|
||||
if (sourceNode.parentId !== targetNode.parentId)
|
||||
return false
|
||||
|
||||
// For Group nodes, use the leaf node's type for validation
|
||||
// sourceHandle format: "${leafNodeId}-${originalSourceHandle}"
|
||||
let actualSourceType = sourceNode.data.type
|
||||
if (sourceNode.data.type === BlockEnum.Group && sourceHandle) {
|
||||
const lastDashIndex = sourceHandle.lastIndexOf('-')
|
||||
if (lastDashIndex > 0) {
|
||||
const leafNodeId = sourceHandle.substring(0, lastDashIndex)
|
||||
const leafNode = nodes.find(node => node.id === leafNodeId)
|
||||
if (leafNode)
|
||||
actualSourceType = leafNode.data.type
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const sourceNodeAvailableNextNodes = getAvailableBlocks(actualSourceType, !!sourceNode.parentId).availableNextBlocks
|
||||
const sourceNodeAvailableNextNodes = getAvailableBlocks(sourceNode.data.type, !!sourceNode.parentId).availableNextBlocks
|
||||
const targetNodeAvailablePrevNodes = getAvailableBlocks(targetNode.data.type, !!targetNode.parentId).availablePrevBlocks
|
||||
|
||||
if (targetNode.data.type === BlockEnum.Group) {
|
||||
const groupData = targetNode.data as GroupNodeData
|
||||
const headNodeIds = groupData.headNodeIds || []
|
||||
if (headNodeIds.length > 0) {
|
||||
const headNode = nodes.find(node => node.id === headNodeIds[0])
|
||||
if (headNode) {
|
||||
const headNodeAvailablePrevNodes = getAvailableBlocks(headNode.data.type, !!targetNode.parentId).availablePrevBlocks
|
||||
if (!headNodeAvailablePrevNodes.includes(actualSourceType))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
|
||||
return false
|
||||
if (!sourceNodeAvailableNextNodes.includes(targetNode.data.type))
|
||||
return false
|
||||
|
||||
if (!targetNodeAvailablePrevNodes.includes(actualSourceType))
|
||||
return false
|
||||
}
|
||||
if (!targetNodeAvailablePrevNodes.includes(sourceNode.data.type))
|
||||
return false
|
||||
}
|
||||
|
||||
const hasCycle = (node: Node, visited = new Set()) => {
|
||||
|
||||
@@ -66,14 +66,6 @@ import {
|
||||
} from './constants'
|
||||
import CustomConnectionLine from './custom-connection-line'
|
||||
import CustomEdge from './custom-edge'
|
||||
import {
|
||||
CUSTOM_GROUP_EXIT_PORT_NODE,
|
||||
CUSTOM_GROUP_INPUT_NODE,
|
||||
CUSTOM_GROUP_NODE,
|
||||
CustomGroupExitPortNode,
|
||||
CustomGroupInputNode,
|
||||
CustomGroupNode,
|
||||
} from './custom-group-node'
|
||||
import DatasetsDetailProvider from './datasets-detail-store/provider'
|
||||
import EdgeContextmenu from './edge-contextmenu'
|
||||
import HelpLine from './help-line'
|
||||
@@ -140,9 +132,6 @@ const nodeTypes = {
|
||||
[CUSTOM_ITERATION_START_NODE]: CustomIterationStartNode,
|
||||
[CUSTOM_LOOP_START_NODE]: CustomLoopStartNode,
|
||||
[CUSTOM_DATA_SOURCE_EMPTY_NODE]: CustomDataSourceEmptyNode,
|
||||
[CUSTOM_GROUP_NODE]: CustomGroupNode,
|
||||
[CUSTOM_GROUP_INPUT_NODE]: CustomGroupInputNode,
|
||||
[CUSTOM_GROUP_EXIT_PORT_NODE]: CustomGroupExitPortNode,
|
||||
}
|
||||
const edgeTypes = {
|
||||
[CUSTOM_EDGE]: CustomEdge,
|
||||
|
||||
@@ -41,14 +41,13 @@ const PanelOperatorPopup = ({
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
handleUngroup,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const edge = edges.find(edge => edge.target === id)
|
||||
const nodeMetaData = useNodeMetaData({ id, data } as Node)
|
||||
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly && data.type !== BlockEnum.Group
|
||||
const showChangeBlock = !nodeMetaData.isTypeFixed && !nodesReadOnly
|
||||
const isChildNode = !!(data.isInIteration || data.isInLoop)
|
||||
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
@@ -62,25 +61,6 @@ const PanelOperatorPopup = ({
|
||||
|
||||
return (
|
||||
<div className="w-[240px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
{
|
||||
!nodesReadOnly && data.type === BlockEnum.Group && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleUngroup(id)
|
||||
}}
|
||||
>
|
||||
{t('panel.ungroup', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'shift', 'g']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular"></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
(showChangeBlock || canRunBySingle(data.type, isChildNode)) && (
|
||||
<>
|
||||
|
||||
@@ -651,7 +651,7 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && data.type !== BlockEnum.Group && (
|
||||
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
|
||||
<div className="flex items-center justify-between pl-4 pr-3">
|
||||
<Tab
|
||||
value={tabType}
|
||||
@@ -660,9 +660,9 @@ const BasePanel: FC<BasePanelProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{data.type !== BlockEnum.Group && <Split />}
|
||||
<Split />
|
||||
</div>
|
||||
{(tabType === TabType.settings || data.type === BlockEnum.Group) && (
|
||||
{tabType === TabType.settings && (
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
<div>
|
||||
{cloneElement(children as any, {
|
||||
|
||||
@@ -67,7 +67,6 @@ const singleRunFormParamsHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.VariableAggregator]: useVariableAggregatorSingleRunFormParams,
|
||||
[BlockEnum.Assigner]: useVariableAssignerSingleRunFormParams,
|
||||
[BlockEnum.KnowledgeBase]: useKnowledgeBaseSingleRunFormParams,
|
||||
[BlockEnum.Group]: undefined,
|
||||
[BlockEnum.VariableAssigner]: undefined,
|
||||
[BlockEnum.End]: undefined,
|
||||
[BlockEnum.Answer]: undefined,
|
||||
@@ -119,7 +118,6 @@ const getDataForCheckMoreHooks: Record<BlockEnum, any> = {
|
||||
[BlockEnum.DataSource]: undefined,
|
||||
[BlockEnum.DataSourceEmpty]: undefined,
|
||||
[BlockEnum.KnowledgeBase]: undefined,
|
||||
[BlockEnum.Group]: undefined,
|
||||
[BlockEnum.TriggerWebhook]: undefined,
|
||||
[BlockEnum.TriggerSchedule]: undefined,
|
||||
[BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore,
|
||||
|
||||
@@ -299,7 +299,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.Group && data.type !== BlockEnum.HumanInput && !data._isCandidate && (
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && data.type !== BlockEnum.HumanInput && !data._isCandidate && (
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
|
||||
@@ -18,8 +18,6 @@ import EndNode from './end/node'
|
||||
import EndPanel from './end/panel'
|
||||
import FileUploadNode from './file-upload/node'
|
||||
import FileUploadPanel from './file-upload/panel'
|
||||
import GroupNode from './group/node'
|
||||
import GroupPanel from './group/panel'
|
||||
import HttpNode from './http/node'
|
||||
import HttpPanel from './http/panel'
|
||||
import HumanInputNode from './human-input/node'
|
||||
@@ -86,7 +84,6 @@ export const NodeComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginNode,
|
||||
[BlockEnum.Command]: CommandNode,
|
||||
[BlockEnum.FileUpload]: FileUploadNode,
|
||||
[BlockEnum.Group]: GroupNode,
|
||||
}
|
||||
|
||||
export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
@@ -118,5 +115,4 @@ export const PanelComponentMap: Record<string, ComponentType<any>> = {
|
||||
[BlockEnum.TriggerPlugin]: TriggerPluginPanel,
|
||||
[BlockEnum.Command]: CommandPanel,
|
||||
[BlockEnum.FileUpload]: FileUploadPanel,
|
||||
[BlockEnum.Group]: GroupPanel,
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { NodeDefault } from '../../types'
|
||||
import type { GroupNodeData } from './types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { genNodeMetaData } from '@/app/components/workflow/utils'
|
||||
|
||||
const metaData = genNodeMetaData({
|
||||
sort: 100,
|
||||
type: BlockEnum.Group,
|
||||
})
|
||||
|
||||
const nodeDefault: NodeDefault<GroupNodeData> = {
|
||||
metaData,
|
||||
defaultValue: {
|
||||
members: [],
|
||||
handlers: [],
|
||||
headNodeIds: [],
|
||||
leafNodeIds: [],
|
||||
},
|
||||
checkValid() {
|
||||
return {
|
||||
isValid: true,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export default nodeDefault
|
||||
@@ -1,94 +0,0 @@
|
||||
import type { GroupHandler, GroupMember, GroupNodeData } from './types'
|
||||
import type { BlockEnum, NodeProps } from '@/app/components/workflow/types'
|
||||
import { RiArrowRightSLine } from '@remixicon/react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { NodeSourceHandle } from '../_base/components/node-handle'
|
||||
|
||||
const MAX_MEMBER_ICONS = 12
|
||||
|
||||
const GroupNode = (props: NodeProps<GroupNodeData>) => {
|
||||
const { data } = props
|
||||
|
||||
// show the explicitly passed members first; otherwise use the _children information to fill the type
|
||||
const members: GroupMember[] = useMemo(() => (
|
||||
data.members?.length
|
||||
? data.members
|
||||
: data._children?.length
|
||||
? data._children.map(child => ({
|
||||
id: child.nodeId,
|
||||
type: child.nodeType as BlockEnum,
|
||||
label: child.nodeType,
|
||||
}))
|
||||
: []
|
||||
), [data._children, data.members])
|
||||
|
||||
const handlers: GroupHandler[] = useMemo(() => (
|
||||
data.handlers?.length
|
||||
? data.handlers
|
||||
: members.length
|
||||
? members.map(member => ({
|
||||
id: `${member.id}-source`,
|
||||
label: member.label || member.id,
|
||||
nodeId: member.id,
|
||||
sourceHandle: 'source',
|
||||
}))
|
||||
: []
|
||||
), [data.handlers, members])
|
||||
|
||||
return (
|
||||
<div className="space-y-2 px-3 pb-3">
|
||||
{members.length > 0 && (
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<div className="flex flex-wrap items-center gap-1 overflow-hidden">
|
||||
{members.slice(0, MAX_MEMBER_ICONS).map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex h-7 items-center rounded-full bg-components-input-bg-normal px-1.5 shadow-xs"
|
||||
>
|
||||
<BlockIcon
|
||||
type={member.type}
|
||||
size="xs"
|
||||
className="!shadow-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{members.length > MAX_MEMBER_ICONS && (
|
||||
<div className="rounded-full bg-components-input-bg-normal px-2 py-1 text-text-tertiary system-xs-medium">
|
||||
+
|
||||
{members.length - MAX_MEMBER_ICONS}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<RiArrowRightSLine className="ml-auto h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
</div>
|
||||
)}
|
||||
{handlers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{handlers.map(handler => (
|
||||
<div
|
||||
key={handler.id}
|
||||
className={cn(
|
||||
'relative',
|
||||
'uppercase system-sm-semibold',
|
||||
'flex h-9 items-center rounded-md bg-components-panel-on-panel-item-bg px-3 text-text-primary shadow-xs',
|
||||
)}
|
||||
>
|
||||
{handler.label || handler.id}
|
||||
<NodeSourceHandle
|
||||
{...props}
|
||||
handleId={handler.id}
|
||||
handleClassName="!top-1/2 !-translate-y-1/2 !-right-[21px]"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
GroupNode.displayName = 'GroupNode'
|
||||
|
||||
export default memo(GroupNode)
|
||||
@@ -1,9 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
|
||||
const GroupPanel = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
GroupPanel.displayName = 'GroupPanel'
|
||||
|
||||
export default memo(GroupPanel)
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { BlockEnum, CommonNodeType } from '../../types'
|
||||
|
||||
export type GroupMember = {
|
||||
id: string
|
||||
type: BlockEnum
|
||||
label?: string
|
||||
}
|
||||
|
||||
export type GroupHandler = {
|
||||
id: string
|
||||
label?: string
|
||||
nodeId?: string // leaf node id for multi-branch nodes
|
||||
sourceHandle?: string // original sourceHandle (e.g., case_id for if-else)
|
||||
}
|
||||
|
||||
export type GroupNodeData = CommonNodeType<{
|
||||
members?: GroupMember[]
|
||||
handlers?: GroupHandler[]
|
||||
headNodeIds?: string[] // nodes that receive input from outside the group
|
||||
leafNodeIds?: string[] // nodes that send output to outside the group
|
||||
}>
|
||||
@@ -1,6 +1,13 @@
|
||||
import { memo } from 'react'
|
||||
import { InputNumber } from '@/app/components/base/input-number'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
NumberField,
|
||||
NumberFieldControls,
|
||||
NumberFieldDecrement,
|
||||
NumberFieldGroup,
|
||||
NumberFieldIncrement,
|
||||
NumberFieldInput,
|
||||
} from '@/app/components/base/ui/number-field'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
@@ -20,14 +27,21 @@ const MaxIterations = ({ value = 10, onChange, className, disabled }: MaxIterati
|
||||
triggerClassName="shrink-0 w-4 h-4"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
className={cn('w-14 shrink-0', disabled && 'opacity-50')}
|
||||
<NumberField
|
||||
value={value}
|
||||
onChange={onChange ?? (() => {})}
|
||||
onValueChange={v => (onChange ?? (() => {}))(v ?? 1)}
|
||||
min={1}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
>
|
||||
<NumberFieldGroup className={cn('w-20 shrink-0', disabled && 'opacity-50')}>
|
||||
<NumberFieldInput />
|
||||
<NumberFieldControls>
|
||||
<NumberFieldIncrement />
|
||||
<NumberFieldDecrement />
|
||||
</NumberFieldControls>
|
||||
</NumberFieldGroup>
|
||||
</NumberField>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { shallow } from 'zustand/shallow'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useCollaborativeWorkflow } from '@/app/components/workflow/hooks/use-collaborative-workflow'
|
||||
import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft } from './hooks'
|
||||
import { useMakeGroupAvailability } from './hooks/use-make-group'
|
||||
import { useSelectionInteractions } from './hooks/use-selection-interactions'
|
||||
import { useWorkflowHistory, WorkflowHistoryEvent } from './hooks/use-workflow-history'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
@@ -86,7 +85,6 @@ const SelectionContextmenu = () => {
|
||||
handleNodesCopy,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleMakeGroup,
|
||||
} = useNodesInteractions()
|
||||
const selectionMenu = useStore(s => s.selectionMenu)
|
||||
|
||||
@@ -100,8 +98,6 @@ const SelectionContextmenu = () => {
|
||||
return ids
|
||||
}, shallow)
|
||||
|
||||
const { canMakeGroup } = useMakeGroupAvailability(selectedNodeIds)
|
||||
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
@@ -434,25 +430,6 @@ const SelectionContextmenu = () => {
|
||||
<div ref={menuRef} className="w-[244px] rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl">
|
||||
{!nodesReadOnly && (
|
||||
<>
|
||||
<div className="p-1">
|
||||
<div
|
||||
className={`flex h-8 items-center justify-between rounded-lg px-3 text-sm ${
|
||||
canMakeGroup
|
||||
? 'cursor-pointer text-text-secondary hover:bg-state-base-hover'
|
||||
: 'cursor-not-allowed text-text-disabled'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!canMakeGroup)
|
||||
return
|
||||
handleMakeGroup()
|
||||
handleSelectionContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('operator.makeGroup', { ns: 'workflow' })}
|
||||
<ShortcutsName keys={['ctrl', 'g']} className={!canMakeGroup ? 'opacity-50' : ''} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px bg-divider-regular" />
|
||||
<div className="p-1">
|
||||
<div
|
||||
className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover"
|
||||
|
||||
@@ -37,7 +37,6 @@ export enum BlockEnum {
|
||||
Code = 'code',
|
||||
TemplateTransform = 'template-transform',
|
||||
HttpRequest = 'http-request',
|
||||
Group = 'group',
|
||||
VariableAssigner = 'variable-assigner',
|
||||
VariableAggregator = 'variable-aggregator',
|
||||
Tool = 'tool',
|
||||
@@ -92,7 +91,6 @@ export type CommonNodeType<T = {}> = {
|
||||
_isEntering?: boolean
|
||||
_showAddVariablePopup?: boolean
|
||||
_holdAddVariablePopup?: boolean
|
||||
_hiddenInGroupId?: string
|
||||
_iterationLength?: number
|
||||
_iterationIndex?: number
|
||||
_waitingRun?: boolean
|
||||
@@ -128,7 +126,6 @@ export type CommonEdgeType = {
|
||||
_connectedNodeIsHovering?: boolean
|
||||
_connectedNodeIsSelected?: boolean
|
||||
_isBundled?: boolean
|
||||
_hiddenInGroupId?: string
|
||||
_sourceRunningStatus?: NodeRunningStatus
|
||||
_targetRunningStatus?: NodeRunningStatus
|
||||
_waitingRun?: boolean
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { CustomGroupNodeData } from '../custom-group-node'
|
||||
import type { GroupNodeData } from '../nodes/group/types'
|
||||
import type { IfElseNodeType } from '../nodes/if-else/types'
|
||||
import type { IterationNodeType } from '../nodes/iteration/types'
|
||||
import type { LLMNodeType } from '../nodes/llm/types'
|
||||
@@ -20,7 +18,6 @@ import {
|
||||
NODE_WIDTH_X_OFFSET,
|
||||
START_INITIAL_POSITION,
|
||||
} from '../constants'
|
||||
import { CUSTOM_GROUP_NODE, GROUP_CHILDREN_Z_INDEX } from '../custom-group-node'
|
||||
import { branchNameCorrect } from '../nodes/if-else/utils'
|
||||
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
|
||||
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
|
||||
@@ -93,16 +90,10 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
node => node.data.type === BlockEnum.Iteration,
|
||||
)
|
||||
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
|
||||
const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE)
|
||||
const hasBusinessGroupNode = nodes.some(
|
||||
node => node.data.type === BlockEnum.Group,
|
||||
)
|
||||
|
||||
if (
|
||||
!hasIterationNode
|
||||
&& !hasLoopNode
|
||||
&& !hasGroupNode
|
||||
&& !hasBusinessGroupNode
|
||||
) {
|
||||
return {
|
||||
nodes,
|
||||
@@ -231,137 +222,9 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Derive Group internal edges (input → entries, leaves → exits)
|
||||
const groupInternalEdges: Edge[] = []
|
||||
const groupNodes = nodes.filter(node => node.type === CUSTOM_GROUP_NODE)
|
||||
|
||||
for (const groupNode of groupNodes) {
|
||||
const groupData = groupNode.data as unknown as CustomGroupNodeData
|
||||
const { group } = groupData
|
||||
|
||||
if (!group)
|
||||
continue
|
||||
|
||||
const { inputNodeId, entryNodeIds, exitPorts } = group
|
||||
|
||||
// Derive edges: input → each entry node
|
||||
for (const entryId of entryNodeIds) {
|
||||
const entryNode = nodesMap[entryId]
|
||||
if (entryNode) {
|
||||
groupInternalEdges.push({
|
||||
id: `group-internal-${inputNodeId}-source-${entryId}-target`,
|
||||
type: 'custom',
|
||||
source: inputNodeId,
|
||||
sourceHandle: 'source',
|
||||
target: entryId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: '' as any, // Group input has empty type
|
||||
targetType: entryNode.data.type,
|
||||
_isGroupInternal: true,
|
||||
_groupId: groupNode.id,
|
||||
},
|
||||
zIndex: GROUP_CHILDREN_Z_INDEX,
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive edges: each leaf node → exit port
|
||||
for (const exitPort of exitPorts) {
|
||||
const leafNode = nodesMap[exitPort.leafNodeId]
|
||||
if (leafNode) {
|
||||
groupInternalEdges.push({
|
||||
id: `group-internal-${exitPort.leafNodeId}-${exitPort.sourceHandle}-${exitPort.portNodeId}-target`,
|
||||
type: 'custom',
|
||||
source: exitPort.leafNodeId,
|
||||
sourceHandle: exitPort.sourceHandle,
|
||||
target: exitPort.portNodeId,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: leafNode.data.type,
|
||||
targetType: '' as string, // Exit port has empty type
|
||||
_isGroupInternal: true,
|
||||
_groupId: groupNode.id,
|
||||
},
|
||||
zIndex: GROUP_CHILDREN_Z_INDEX,
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild isTemp edges for business Group nodes (BlockEnum.Group)
|
||||
// These edges connect the group node to external nodes for visual display
|
||||
const groupTempEdges: Edge[] = []
|
||||
const inboundEdgeIds = new Set<string>()
|
||||
|
||||
nodes.forEach((groupNode) => {
|
||||
if (groupNode.data.type !== BlockEnum.Group)
|
||||
return
|
||||
|
||||
const groupData = groupNode.data as GroupNodeData
|
||||
const {
|
||||
members = [],
|
||||
headNodeIds = [],
|
||||
leafNodeIds = [],
|
||||
handlers = [],
|
||||
} = groupData
|
||||
const memberSet = new Set(members.map(m => m.id))
|
||||
const headSet = new Set(headNodeIds)
|
||||
const leafSet = new Set(leafNodeIds)
|
||||
|
||||
edges.forEach((edge) => {
|
||||
// Inbound edge: source outside group, target is a head node
|
||||
// Use Set to dedupe since multiple head nodes may share same external source
|
||||
if (!memberSet.has(edge.source) && headSet.has(edge.target)) {
|
||||
const sourceHandle = edge.sourceHandle || 'source'
|
||||
const edgeId = `${edge.source}-${sourceHandle}-${groupNode.id}-target`
|
||||
if (!inboundEdgeIds.has(edgeId)) {
|
||||
inboundEdgeIds.add(edgeId)
|
||||
groupTempEdges.push({
|
||||
id: edgeId,
|
||||
type: 'custom',
|
||||
source: edge.source,
|
||||
sourceHandle,
|
||||
target: groupNode.id,
|
||||
targetHandle: 'target',
|
||||
data: {
|
||||
sourceType: edge.data?.sourceType,
|
||||
targetType: BlockEnum.Group,
|
||||
_isTemp: true,
|
||||
},
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
|
||||
// Outbound edge: source is a leaf node, target outside group
|
||||
if (leafSet.has(edge.source) && !memberSet.has(edge.target)) {
|
||||
const edgeSourceHandle = edge.sourceHandle || 'source'
|
||||
const handler = handlers.find(
|
||||
h =>
|
||||
h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle,
|
||||
)
|
||||
if (handler) {
|
||||
groupTempEdges.push({
|
||||
id: `${groupNode.id}-${handler.id}-${edge.target}-${edge.targetHandle}`,
|
||||
type: 'custom',
|
||||
source: groupNode.id,
|
||||
sourceHandle: handler.id,
|
||||
target: edge.target!,
|
||||
targetHandle: edge.targetHandle,
|
||||
data: {
|
||||
sourceType: BlockEnum.Group,
|
||||
targetType: edge.data?.targetType,
|
||||
_isTemp: true,
|
||||
},
|
||||
} as Edge)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
nodes: [...nodes, ...newIterationStartNodes, ...newLoopStartNodes],
|
||||
edges: [...edges, ...newEdges, ...groupInternalEdges, ...groupTempEdges],
|
||||
edges: [...edges, ...newEdges],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,16 +312,6 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.Group) {
|
||||
const groupData = node.data as GroupNodeData
|
||||
if (groupData.handlers?.length) {
|
||||
node.data._targetBranches = groupData.handlers.map(handler => ({
|
||||
id: handler.id,
|
||||
name: handler.label || handler.id,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
if (node.data.type === BlockEnum.Iteration) {
|
||||
const iterationNodeData = node.data as IterationNodeType
|
||||
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
|
||||
|
||||
@@ -1154,6 +1154,8 @@
|
||||
"versionHistory.editVersionInfo": "Edit version info",
|
||||
"versionHistory.filter.all": "All",
|
||||
"versionHistory.filter.empty": "No matching version history found",
|
||||
"viewPicker.graph": "Workflow",
|
||||
"viewPicker.skill": "Skill",
|
||||
"versionHistory.filter.onlyShowNamedVersions": "Only show named versions",
|
||||
"versionHistory.filter.onlyYours": "Only yours",
|
||||
"versionHistory.filter.reset": "Reset Filter",
|
||||
|
||||
@@ -1154,6 +1154,8 @@
|
||||
"versionHistory.editVersionInfo": "编辑信息",
|
||||
"versionHistory.filter.all": "全部",
|
||||
"versionHistory.filter.empty": "没有匹配的版本",
|
||||
"viewPicker.graph": "工作流",
|
||||
"viewPicker.skill": "技能",
|
||||
"versionHistory.filter.onlyShowNamedVersions": "只显示已命名版本",
|
||||
"versionHistory.filter.onlyYours": "仅你的",
|
||||
"versionHistory.filter.reset": "重置",
|
||||
|
||||
Reference in New Issue
Block a user