diff --git a/api/core/memory/base.py b/api/core/memory/base.py index 5a5fa7d145..acb506711c 100644 --- a/api/core/memory/base.py +++ b/api/core/memory/base.py @@ -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]: diff --git a/api/core/memory/node_token_buffer_memory.py b/api/core/memory/node_token_buffer_memory.py index 9dea875e39..7f504469dd 100644 --- a/api/core/memory/node_token_buffer_memory.py +++ b/api/core/memory/node_token_buffer_memory.py @@ -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]: diff --git a/api/core/memory/token_buffer_memory.py b/api/core/memory/token_buffer_memory.py index a021d4d8da..675568d730 100644 --- a/api/core/memory/token_buffer_memory.py +++ b/api/core/memory/token_buffer_memory.py @@ -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]: diff --git a/api/core/plugin/backwards_invocation/model.py b/api/core/plugin/backwards_invocation/model.py index cc3e1d9422..fafbbb715c 100644 --- a/api/core/plugin/backwards_invocation/model.py +++ b/api/core/plugin/backwards_invocation/model.py @@ -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): diff --git a/api/core/sandbox/builder.py b/api/core/sandbox/builder.py index 6ec2bd38aa..efb7e88c9b 100644 --- a/api/core/sandbox/builder.py +++ b/api/core/sandbox/builder.py @@ -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: diff --git a/api/core/sandbox/storage/archive_storage.py b/api/core/sandbox/storage/archive_storage.py index bdc8e2021b..2f6c436cad 100644 --- a/api/core/sandbox/storage/archive_storage.py +++ b/api/core/sandbox/storage/archive_storage.py @@ -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 diff --git a/api/core/virtual_environment/providers/docker_daemon_sandbox.py b/api/core/virtual_environment/providers/docker_daemon_sandbox.py index c6d2d5ca39..3e87e2c453 100644 --- a/api/core/virtual_environment/providers/docker_daemon_sandbox.py +++ b/api/core/virtual_environment/providers/docker_daemon_sandbox.py @@ -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, ), ) diff --git a/api/core/workflow/nodes/command/node.py b/api/core/workflow/nodes/command/node.py index b3f154d69e..5fc22e43c7 100644 --- a/api/core/workflow/nodes/command/node.py +++ b/api/core/workflow/nodes/command/node.py @@ -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)) diff --git a/api/core/workflow/nodes/file_upload/node.py b/api/core/workflow/nodes/file_upload/node.py index f8d482c36a..6e78a34221 100644 --- a/api/core/workflow/nodes/file_upload/node.py +++ b/api/core/workflow/nodes/file_upload/node.py @@ -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 diff --git a/api/dify_graph/graph/graph.py b/api/dify_graph/graph/graph.py index 17c8ec7843..b16ebe0391 100644 --- a/api/dify_graph/graph/graph.py +++ b/api/dify_graph/graph/graph.py @@ -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) diff --git a/api/dify_graph/nodes/agent/agent_node.py b/api/dify_graph/nodes/agent/agent_node.py index 1c059ae696..bf5d780006 100644 --- a/api/dify_graph/nodes/agent/agent_node.py +++ b/api/dify_graph/nodes/agent/agent_node.py @@ -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: diff --git a/api/dify_graph/nodes/base/node.py b/api/dify_graph/nodes/base/node.py index 8126b40a94..e859019224 100644 --- a/api/dify_graph/nodes/base/node.py +++ b/api/dify_graph/nodes/base/node.py @@ -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 diff --git a/api/dify_graph/nodes/llm/node.py b/api/dify_graph/nodes/llm/node.py index 1f84535a26..89622981f9 100644 --- a/api/dify_graph/nodes/llm/node.py +++ b/api/dify_graph/nodes/llm/node.py @@ -17,6 +17,7 @@ from sqlalchemy import select from core.agent.entities import AgentEntity, AgentLog, AgentResult, AgentToolEntity, ExecutionContext from core.agent.patterns import StrategyFactory +from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity from core.helper.code_executor import CodeExecutor, CodeLanguage from core.llm_generator.output_parser.errors import OutputParserError from core.llm_generator.output_parser.file_ref import ( @@ -28,9 +29,10 @@ from core.llm_generator.output_parser.structured_output import ( invoke_llm_with_structured_output, ) from core.memory.base import BaseMemory -from core.model_manager import ModelInstance +from core.model_manager import ModelInstance, ModelManager from core.prompt.entities.advanced_prompt_entities import CompletionModelPromptTemplate, MemoryConfig from core.prompt.utils.prompt_message_util import PromptMessageUtil +from core.rag.entities.citation_metadata import RetrievalSourceMetadata from core.sandbox import Sandbox from core.sandbox.bash.session import MAX_OUTPUT_FILE_SIZE, MAX_OUTPUT_FILES, SandboxBashSession from core.sandbox.entities.config import AppAssets @@ -38,6 +40,7 @@ from core.skill.assembler import SkillDocumentAssembler from core.skill.constants import SkillAttrs from core.skill.entities.skill_bundle import SkillBundle from core.skill.entities.skill_document import SkillDocument +from core.skill.entities.skill_metadata import SkillMetadata from core.skill.entities.tool_dependencies import ToolDependencies, ToolDependency from core.tools.__base.tool import Tool from core.tools.signature import sign_tool_file, sign_upload_file @@ -54,15 +57,12 @@ from dify_graph.enums import ( WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus, ) -from dify_graph.file import File, FileTransferMethod, FileType +from dify_graph.file import File, FileTransferMethod, FileType, file_manager from dify_graph.model_runtime.entities import ( - AssistantPromptMessage, ImagePromptMessageContent, PromptMessage, - PromptMessageRole, - SystemPromptMessage, + PromptMessageContentType, TextPromptMessageContent, - UserPromptMessage, ) from dify_graph.model_runtime.entities.llm_entities import ( LLMResult, @@ -72,8 +72,18 @@ from dify_graph.model_runtime.entities.llm_entities import ( LLMStructuredOutput, LLMUsage, ) -from dify_graph.model_runtime.entities.message_entities import PromptMessageContentUnionTypes -from dify_graph.model_runtime.memory import PromptMessageMemory +from dify_graph.model_runtime.entities.message_entities import ( + AssistantPromptMessage, + PromptMessageContentUnionTypes, + PromptMessageRole, + SystemPromptMessage, + UserPromptMessage, +) +from dify_graph.model_runtime.entities.model_entities import ( + ModelFeature, + ModelPropertyKey, + ModelType, +) from dify_graph.model_runtime.utils.encoders import jsonable_encoder from dify_graph.node_events import ( AgentLogEvent, @@ -91,7 +101,6 @@ from dify_graph.node_events.node import ChunkType, ThoughtEndChunkEvent, Thought from dify_graph.nodes.base.entities import VariableSelector from dify_graph.nodes.base.node import Node from dify_graph.nodes.base.variable_template_parser import VariableTemplateParser -from dify_graph.nodes.llm.protocols import CredentialsProvider, ModelFactory, TemplateRenderer from dify_graph.nodes.protocols import HttpClientProtocol from dify_graph.runtime import VariablePool from dify_graph.variables import ( @@ -116,6 +125,7 @@ from .entities import ( LLMNodeCompletionModelPromptTemplate, LLMNodeData, LLMTraceSegment, + ModelConfig, ModelTraceSegment, PromptMessageContext, StreamBuffers, @@ -129,6 +139,10 @@ from .exc import ( InvalidContextStructureError, InvalidVariableTypeError, LLMNodeError, + MemoryRolePrefixRequiredError, + ModelNotExistError, + NoPromptFoundError, + TemplateTypeNotSupportError, VariableNotFoundError, ) from .file_saver import FileSaverImpl, LLMFileSaver @@ -146,16 +160,7 @@ class LLMNode(Node[LLMNodeData]): # Compiled regex for extracting blocks (with compatibility for attributes) _THINK_PATTERN = re.compile(r"]*>(.*?)", re.IGNORECASE | re.DOTALL) - # Instance attributes specific to LLMNode. - # Output variable for file - _file_outputs: list[File] - _llm_file_saver: LLMFileSaver - _credentials_provider: CredentialsProvider - _model_factory: ModelFactory - _model_instance: ModelInstance - _memory: PromptMessageMemory | None - _template_renderer: TemplateRenderer def __init__( self, @@ -164,12 +169,12 @@ class LLMNode(Node[LLMNodeData]): graph_init_params: GraphInitParams, graph_runtime_state: GraphRuntimeState, *, - credentials_provider: CredentialsProvider, - model_factory: ModelFactory, - model_instance: ModelInstance, http_client: HttpClientProtocol, - template_renderer: TemplateRenderer, - memory: PromptMessageMemory | None = None, + credentials_provider: object | None = None, + model_factory: object | None = None, + model_instance: object | None = None, + template_renderer: object | None = None, + memory: object | None = None, llm_file_saver: LLMFileSaver | None = None, ): super().__init__( @@ -178,14 +183,7 @@ class LLMNode(Node[LLMNodeData]): graph_init_params=graph_init_params, graph_runtime_state=graph_runtime_state, ) - # LLM file outputs, used for MultiModal outputs. - self._file_outputs = [] - - self._credentials_provider = credentials_provider - self._model_factory = model_factory - self._model_instance = model_instance - self._memory = memory - self._template_renderer = template_renderer + self._file_outputs: list[File] = [] if llm_file_saver is None: dify_ctx = self.require_dify_context() @@ -252,11 +250,12 @@ class LLMNode(Node[LLMNodeData]): node_inputs["#context_files#"] = [file.model_dump() for file in context_files] # fetch model config - model_instance = self._model_instance - model_name = model_instance.model_name - model_provider = model_instance.provider - model_stop = model_instance.stop + model_instance, model_config = LLMNode._fetch_model_config( + node_data_model=self.node_data.model, + tenant_id=self.tenant_id, + ) + # fetch memory memory = llm_utils.fetch_memory( variable_pool=variable_pool, app_id=self.app_id, @@ -274,6 +273,7 @@ class LLMNode(Node[LLMNodeData]): ): query = query_variable.text + # Get prompt messages prompt_messages: Sequence[PromptMessage] stop: Sequence[str] | None if isinstance(prompt_template, list) and context_refs: @@ -285,7 +285,7 @@ class LLMNode(Node[LLMNodeData]): files=files, context=context, memory=memory, - model_instance=model_instance, + model_config=model_config, context_files=context_files, ) else: @@ -294,8 +294,7 @@ class LLMNode(Node[LLMNodeData]): sys_files=files, context=context, memory=memory, - model_instance=model_instance, - stop=model_stop, + model_config=model_config, prompt_template=cast( Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, self.node_data.prompt_template, @@ -305,25 +304,11 @@ class LLMNode(Node[LLMNodeData]): vision_detail=self.node_data.vision.configs.detail, variable_pool=variable_pool, jinja2_variables=self.node_data.prompt_config.jinja2_variables, + tenant_id=self.tenant_id, context_files=context_files, sandbox=self.graph_runtime_state.sandbox, ) - # handle invoke result - generator = LLMNode.invoke_llm( - model_instance=model_instance, - prompt_messages=prompt_messages, - stop=stop, - user_id=self.require_dify_context().user_id, - structured_output_enabled=self.node_data.structured_output_enabled, - structured_output=self.node_data.structured_output, - file_saver=self._llm_file_saver, - file_outputs=self._file_outputs, - node_id=self._node_id, - node_type=self.node_type, - reasoning_format=self.node_data.reasoning_format, - ) - # Variables for outputs generation_data: LLMGenerationData | None = None structured_output: LLMStructuredOutput | None = None @@ -436,14 +421,14 @@ class LLMNode(Node[LLMNodeData]): # Unified process_data building process_data = { - "model_mode": self.node_data.model.mode, + "model_mode": model_config.mode, "prompts": PromptMessageUtil.prompt_messages_to_prompt_for_saving( - model_mode=self.node_data.model.mode, prompt_messages=prompt_messages + model_mode=model_config.mode, prompt_messages=prompt_messages ), "usage": jsonable_encoder(usage), "finish_reason": finish_reason, - "model_provider": model_provider, - "model_name": model_name, + "model_provider": model_config.provider, + "model_name": model_config.model, } if self.tool_call_enabled and self._node_data.tools: process_data["tools"] = [ @@ -558,21 +543,6 @@ class LLMNode(Node[LLMNodeData]): The ``generation`` field always carries the full structured representation (content, reasoning, tool_calls, sequence) regardless of runtime mode. - - Args: - is_sandbox: Whether the current runtime is sandbox mode. - clean_text: Processed text for outputs["text"]; may keep tags for "tagged" format. - reasoning_content: Native model reasoning from the API response. - generation_reasoning_content: Reasoning for the generation field, extracted from - tags via _split_reasoning (always tag-free). Falls back to reasoning_content - if empty (no tags found). - generation_clean_content: Clean text for the generation field (always tag-free). - Differs from clean_text only when reasoning_format is "tagged". - usage: LLM usage statistics. - finish_reason: Finish reason from LLM. - prompt_messages: Prompt messages sent to the LLM. - generation_data: Multi-turn generation data from tool/sandbox invocation, or None. - structured_output: Structured output if enabled. """ # Common outputs shared by both runtimes outputs: dict[str, Any] = { @@ -588,21 +558,17 @@ class LLMNode(Node[LLMNodeData]): # Build generation field if generation_data: - # Agent/sandbox runtime: generation_data captures multi-turn interactions generation = { "content": generation_data.text, - "reasoning_content": generation_data.reasoning_contents, # [thought1, thought2, ...] + "reasoning_content": generation_data.reasoning_contents, "tool_calls": [self._serialize_tool_call(item) for item in generation_data.tool_calls], "sequence": generation_data.sequence, } files_to_output = list(generation_data.files) - # Merge auto-collected/structured-output files from self._file_outputs if self._file_outputs: existing_ids = {f.id for f in files_to_output} files_to_output.extend(f for f in self._file_outputs if f.id not in existing_ids) else: - # Classical runtime: use pre-computed generation-specific text pair, - # falling back to native model reasoning if no tags were found. generation_reasoning = generation_reasoning_content or reasoning_content generation_content = generation_clean_content or clean_text sequence: list[dict[str, Any]] = [] @@ -630,6 +596,7 @@ class LLMNode(Node[LLMNodeData]): @staticmethod def invoke_llm( *, + node_data_model: ModelConfig, model_instance: ModelInstance, prompt_messages: Sequence[PromptMessage], stop: Sequence[str] | None = None, @@ -642,10 +609,11 @@ class LLMNode(Node[LLMNodeData]): node_type: NodeType, reasoning_format: Literal["separated", "tagged"] = "tagged", ) -> Generator[NodeEventBase | LLMStructuredOutput, None, None]: - model_parameters = model_instance.parameters - invoke_model_parameters = dict(model_parameters) - - model_schema = llm_utils.fetch_model_schema(model_instance=model_instance) + model_schema = model_instance.model_type_instance.get_model_schema( + node_data_model.name, model_instance.credentials + ) + if not model_schema: + raise ValueError(f"Model schema not found for {node_data_model.name}") invoke_result: LLMResult | Generator[LLMResultChunk | LLMStructuredOutput, None, None] if structured_output_schema: @@ -657,7 +625,7 @@ class LLMNode(Node[LLMNodeData]): model_instance=model_instance, prompt_messages=prompt_messages, json_schema=structured_output_schema, - model_parameters=invoke_model_parameters, + model_parameters=node_data_model.completion_params, stop=list(stop or []), user=user_id, allow_file_path=allow_file_path, @@ -667,7 +635,7 @@ class LLMNode(Node[LLMNodeData]): invoke_result = model_instance.invoke_llm( prompt_messages=list(prompt_messages), - model_parameters=invoke_model_parameters, + model_parameters=node_data_model.completion_params, stop=list(stop or []), stream=True, user=user_id, @@ -725,12 +693,10 @@ class LLMNode(Node[LLMNodeData]): first_token_time = None has_content = False - collected_structured_output = None # Collect structured_output from streaming chunks - # Consume the invoke result and handle generator exception + collected_structured_output = None try: for result in invoke_result: if isinstance(result, LLMResultChunkWithStructuredOutput): - # Collect structured_output from the chunk if result.structured_output is not None: collected_structured_output = dict(result.structured_output) yield result @@ -741,20 +707,17 @@ class LLMNode(Node[LLMNodeData]): file_saver=file_saver, file_outputs=file_outputs, ): - # Detect first token for TTFT calculation if text_part and not has_content: first_token_time = time.perf_counter() has_content = True full_text_buffer.write(text_part) - # Text output: always forward raw chunk (keep tags intact) yield StreamChunkEvent( selector=[node_id, "text"], chunk=text_part, is_final=False, ) - # Generation output: split out thoughts, forward only non-thought content chunks for kind, segment in think_parser.process(text_part): if not segment: if kind not in {"thought_start", "thought_end"}: @@ -786,12 +749,9 @@ class LLMNode(Node[LLMNodeData]): is_final=False, ) - # Update the whole metadata if not model and result.model: model = result.model if len(prompt_messages) == 0: - # TODO(QuantumGhost): it seems that this update has no visable effect. - # What's the purpose of the line below? prompt_messages = list(result.prompt_messages) if usage.prompt_tokens == 0 and result.delta.usage: usage = result.delta.usage @@ -829,15 +789,12 @@ class LLMNode(Node[LLMNodeData]): is_final=False, ) - # Extract reasoning content from tags in the main text full_text = full_text_buffer.getvalue() if reasoning_format == "tagged": - # Keep tags in text for backward compatibility clean_text = full_text reasoning_content = "".join(reasoning_chunks) else: - # Extract clean text and reasoning from tags clean_text, reasoning_content = LLMNode._split_reasoning(full_text, reasoning_format) if reasoning_chunks and not reasoning_content: reasoning_content = "".join(reasoning_chunks) @@ -853,13 +810,10 @@ class LLMNode(Node[LLMNodeData]): usage.time_to_generate = round(llm_streaming_time_to_generate, 3) yield ModelInvokeCompletedEvent( - # Use clean_text for separated mode, full_text for tagged mode text=clean_text if reasoning_format == "separated" else full_text, usage=usage, finish_reason=finish_reason, - # Reasoning content for workflow variables and downstream nodes reasoning_content=reasoning_content, - # Pass structured output if collected from streaming chunks structured_output=collected_structured_output, ) @@ -872,35 +826,14 @@ class LLMNode(Node[LLMNodeData]): def _split_reasoning( cls, text: str, reasoning_format: Literal["separated", "tagged"] = "tagged" ) -> tuple[str, str]: - """ - Split reasoning content from text based on reasoning_format strategy. - - Args: - text: Full text that may contain blocks - reasoning_format: Strategy for handling reasoning content - - "separated": Remove tags and return clean text + reasoning_content field - - "tagged": Keep tags in text, return empty reasoning_content - - Returns: - tuple of (clean_text, reasoning_content) - """ - if reasoning_format == "tagged": return text, "" - # Find all ... blocks (case-insensitive) matches = cls._THINK_PATTERN.findall(text) - - # Extract reasoning content from all blocks reasoning_content = "\n".join(match.strip() for match in matches) if matches else "" - - # Remove all ... blocks from original text clean_text = cls._THINK_PATTERN.sub("", text) - - # Clean up extra whitespace clean_text = re.sub(r"\n\s*\n", "\n\n", clean_text).strip() - # Separated mode: always return clean text and reasoning_content return clean_text, reasoning_content or "" def _transform_chat_messages( @@ -921,15 +854,6 @@ class LLMNode(Node[LLMNodeData]): def _parse_prompt_template( self, ) -> tuple[list[LLMNodeChatModelMessage], list[PromptMessageContext], list[tuple[int, str]]]: - """ - Parse prompt_template to separate static messages and context references. - - Returns: - Tuple of (static_messages, context_refs, template_order) - - static_messages: list of LLMNodeChatModelMessage - - context_refs: list of PromptMessageContext - - template_order: list of (index, type) tuples preserving original order - """ prompt_template = self.node_data.prompt_template static_messages: list[LLMNodeChatModelMessage] = [] context_refs: list[PromptMessageContext] = [] @@ -943,7 +867,6 @@ class LLMNode(Node[LLMNodeData]): else: static_messages.append(item) template_order.append((idx, "static")) - # Transform static messages for jinja2 if static_messages: self.node_data.prompt_template = self._transform_chat_messages(static_messages) @@ -962,34 +885,24 @@ class LLMNode(Node[LLMNodeData]): model_config: ModelConfigWithCredentialsEntity, context_files: list[File], ) -> tuple[list[PromptMessage], Sequence[str] | None]: - """ - Build prompt messages by combining static messages and context references in DSL order. - - Returns: - Tuple of (prompt_messages, stop_sequences) - """ variable_pool = self.graph_runtime_state.variable_pool - # Process messages in DSL order: iterate once and handle each type directly combined_messages: list[PromptMessage] = [] context_idx = 0 static_idx = 0 for _, type_ in template_order: if type_ == "context": - # Handle context reference ctx_ref = context_refs[context_idx] ctx_var = variable_pool.get(ctx_ref.value_selector) if ctx_var is None: raise VariableNotFoundError(f"Variable {'.'.join(ctx_ref.value_selector)} not found") if not isinstance(ctx_var, ArrayPromptMessageSegment): raise InvalidVariableTypeError(f"Variable {'.'.join(ctx_ref.value_selector)} is not array[message]") - # Restore multimodal content (base64/url) that was truncated when saving context restored_messages = llm_utils.restore_multimodal_content_in_messages(ctx_var.value) combined_messages.extend(restored_messages) context_idx += 1 else: - # Handle static message static_msg = static_messages[static_idx] processed_msgs = LLMNode.handle_list_messages( messages=[static_msg], @@ -1002,7 +915,6 @@ class LLMNode(Node[LLMNodeData]): combined_messages.extend(processed_msgs) static_idx += 1 - # Append memory messages memory_messages = _handle_memory_chat_mode( memory=memory, memory_config=self.node_data.memory, @@ -1010,7 +922,6 @@ class LLMNode(Node[LLMNodeData]): ) combined_messages.extend(memory_messages) - # Append current query if provided if query: query_message = LLMNodeChatModelMessage( text=query, @@ -1026,7 +937,6 @@ class LLMNode(Node[LLMNodeData]): ) combined_messages.extend(query_msgs) - # Handle files (sys_files and context_files) combined_messages = self._append_files_to_messages( messages=combined_messages, sys_files=files, @@ -1034,7 +944,6 @@ class LLMNode(Node[LLMNodeData]): model_config=model_config, ) - # Filter empty messages and get stop sequences combined_messages = self._filter_messages(combined_messages, model_config) stop = self._get_stop_sequences(model_config) @@ -1048,11 +957,9 @@ class LLMNode(Node[LLMNodeData]): context_files: list[File], model_config: ModelConfigWithCredentialsEntity, ) -> list[PromptMessage]: - """Append sys_files and context_files to messages.""" vision_enabled = self.node_data.vision.enabled vision_detail = self.node_data.vision.configs.detail - # Handle sys_files (will be deprecated later) if vision_enabled and sys_files: file_prompts = [ file_manager.to_prompt_message_content(file, image_detail_config=vision_detail) for file in sys_files @@ -1062,7 +969,6 @@ class LLMNode(Node[LLMNodeData]): else: messages.append(UserPromptMessage(content=file_prompts)) - # Handle context_files if vision_enabled and context_files: file_prompts = [ file_manager.to_prompt_message_content(file, image_detail_config=vision_detail) @@ -1078,21 +984,18 @@ class LLMNode(Node[LLMNodeData]): def _filter_messages( self, messages: list[PromptMessage], model_config: ModelConfigWithCredentialsEntity ) -> list[PromptMessage]: - """Filter empty messages and unsupported content types.""" filtered_messages: list[PromptMessage] = [] for message in messages: if isinstance(message.content, list): filtered_content: list[PromptMessageContentUnionTypes] = [] for content_item in message.content: - # Skip non-text content if features are not defined if not model_config.model_schema.features: if content_item.type != PromptMessageContentType.TEXT: continue filtered_content.append(content_item) continue - # Skip content if corresponding feature is not supported feature_map = { PromptMessageContentType.IMAGE: ModelFeature.VISION, PromptMessageContentType.DOCUMENT: ModelFeature.DOCUMENT, @@ -1104,7 +1007,6 @@ class LLMNode(Node[LLMNodeData]): continue filtered_content.append(content_item) - # Simplify single text content if len(filtered_content) == 1 and filtered_content[0].type == PromptMessageContentType.TEXT: message.content = filtered_content[0].data else: @@ -1122,7 +1024,6 @@ class LLMNode(Node[LLMNodeData]): return filtered_messages def _get_stop_sequences(self, model_config: ModelConfigWithCredentialsEntity) -> Sequence[str] | None: - """Get stop sequences from model config.""" return model_config.stop def _fetch_jinja_inputs(self, node_data: LLMNodeData) -> dict[str, str]: @@ -1138,14 +1039,8 @@ class LLMNode(Node[LLMNodeData]): raise VariableNotFoundError(f"Variable {variable_selector.variable} not found") def parse_dict(input_dict: Mapping[str, Any]) -> str: - """ - Parse dict into string - """ - # check if it's a context structure if "metadata" in input_dict and "_source" in input_dict["metadata"] and "content" in input_dict: return str(input_dict["content"]) - - # else, parse the dict try: return json.dumps(input_dict, ensure_ascii=False) except Exception: @@ -1220,7 +1115,7 @@ class LLMNode(Node[LLMNodeData]): ) elif isinstance(context_value_variable, ArraySegment): context_str = "" - original_retriever_resource: list[dict[str, Any]] = [] + original_retriever_resource: list[RetrievalSourceMetadata] = [] context_files: list[File] = [] for item in context_value_variable.value: if isinstance(item, str): @@ -1236,14 +1131,11 @@ class LLMNode(Node[LLMNodeData]): retriever_resource = self._convert_to_original_retriever_resource(item) if retriever_resource: original_retriever_resource.append(retriever_resource) - segment_id = retriever_resource.get("segment_id") - if not segment_id: - continue attachments_with_bindings = db.session.execute( select(SegmentAttachmentBinding, UploadFile) .join(UploadFile, UploadFile.id == SegmentAttachmentBinding.attachment_id) .where( - SegmentAttachmentBinding.segment_id == segment_id, + SegmentAttachmentBinding.segment_id == retriever_resource.segment_id, ) ).all() if attachments_with_bindings: @@ -1253,7 +1145,7 @@ class LLMNode(Node[LLMNodeData]): filename=upload_file.name, extension="." + upload_file.extension, mime_type=upload_file.mime_type, - tenant_id=self.require_dify_context().tenant_id, + tenant_id=self.tenant_id, type=FileType.IMAGE, transfer_method=FileTransferMethod.LOCAL_FILE, remote_url=upload_file.source_url, @@ -1264,12 +1156,12 @@ class LLMNode(Node[LLMNodeData]): ) context_files.append(attachment_info) yield RunRetrieverResourceEvent( - retriever_resources=original_retriever_resource, + retriever_resources=[r.model_dump() for r in original_retriever_resource], context=context_str.strip(), context_files=context_files, ) - def _convert_to_original_retriever_resource(self, context_dict: dict) -> dict[str, Any] | None: + def _convert_to_original_retriever_resource(self, context_dict: dict) -> RetrievalSourceMetadata | None: if ( "metadata" in context_dict and "_source" in context_dict["metadata"] @@ -1277,29 +1169,50 @@ class LLMNode(Node[LLMNodeData]): ): metadata = context_dict.get("metadata", {}) - return { - "position": metadata.get("position"), - "dataset_id": metadata.get("dataset_id"), - "dataset_name": metadata.get("dataset_name"), - "document_id": metadata.get("document_id"), - "document_name": metadata.get("document_name"), - "data_source_type": metadata.get("data_source_type"), - "segment_id": metadata.get("segment_id"), - "retriever_from": metadata.get("retriever_from"), - "score": metadata.get("score"), - "hit_count": metadata.get("segment_hit_count"), - "word_count": metadata.get("segment_word_count"), - "segment_position": metadata.get("segment_position"), - "index_node_hash": metadata.get("segment_index_node_hash"), - "content": context_dict.get("content"), - "page": metadata.get("page"), - "doc_metadata": metadata.get("doc_metadata"), - "files": context_dict.get("files"), - "summary": context_dict.get("summary"), - } + source = RetrievalSourceMetadata( + position=metadata.get("position"), + dataset_id=metadata.get("dataset_id"), + dataset_name=metadata.get("dataset_name"), + document_id=metadata.get("document_id"), + document_name=metadata.get("document_name"), + data_source_type=metadata.get("data_source_type"), + segment_id=metadata.get("segment_id"), + retriever_from=metadata.get("retriever_from"), + score=metadata.get("score"), + hit_count=metadata.get("segment_hit_count"), + word_count=metadata.get("segment_word_count"), + segment_position=metadata.get("segment_position"), + index_node_hash=metadata.get("segment_index_node_hash"), + content=context_dict.get("content"), + page=metadata.get("page"), + doc_metadata=metadata.get("doc_metadata"), + files=context_dict.get("files"), + summary=context_dict.get("summary"), + ) + + return source return None + @staticmethod + def _fetch_model_config( + *, + node_data_model: ModelConfig, + tenant_id: str, + ) -> tuple[ModelInstance, ModelConfigWithCredentialsEntity]: + model, model_config_with_cred = llm_utils.fetch_model_config( + tenant_id=tenant_id, node_data_model=node_data_model + ) + completion_params = model_config_with_cred.parameters + + model_schema = model.model_type_instance.get_model_schema(node_data_model.name, model.credentials) + if not model_schema: + raise ModelNotExistError(f"Model {node_data_model.name} not exist.") + + model_config_with_cred.parameters = completion_params + node_data_model.completion_params = completion_params + return model, model_config_with_cred + @staticmethod def fetch_prompt_messages( *, @@ -1307,19 +1220,18 @@ class LLMNode(Node[LLMNodeData]): sys_files: Sequence[File], context: str | None = None, memory: BaseMemory | None = None, - model_instance: ModelInstance, + model_config: ModelConfigWithCredentialsEntity, prompt_template: Sequence[LLMNodeChatModelMessage] | LLMNodeCompletionModelPromptTemplate, - stop: Sequence[str] | None = None, memory_config: MemoryConfig | None = None, vision_enabled: bool = False, vision_detail: ImagePromptMessageContent.DETAIL, variable_pool: VariablePool, jinja2_variables: Sequence[VariableSelector], + tenant_id: str, context_files: list[File] | None = None, sandbox: Sandbox | None = None, ) -> tuple[Sequence[PromptMessage], Sequence[str] | None]: prompt_messages: list[PromptMessage] = [] - model_schema = llm_utils.fetch_model_schema(model_instance=model_instance) if isinstance(prompt_template, list): prompt_messages.extend( @@ -1336,7 +1248,7 @@ class LLMNode(Node[LLMNodeData]): memory_messages = _handle_memory_chat_mode( memory=memory, memory_config=memory_config, - model_instance=model_instance, + model_config=model_config, ) prompt_messages.extend(memory_messages) @@ -1369,7 +1281,7 @@ class LLMNode(Node[LLMNodeData]): memory_text = _handle_memory_completion_mode( memory=memory, memory_config=memory_config, - model_instance=model_instance, + model_config=model_config, ) prompt_content = prompt_messages[0].content prompt_content_type = type(prompt_content) @@ -1438,7 +1350,7 @@ class LLMNode(Node[LLMNodeData]): if isinstance(prompt_message.content, list): prompt_message_content: list[PromptMessageContentUnionTypes] = [] for content_item in prompt_message.content: - if not model_schema.features: + if not model_config.model_schema.features: if content_item.type != PromptMessageContentType.TEXT: continue prompt_message_content.append(content_item) @@ -1447,19 +1359,19 @@ class LLMNode(Node[LLMNodeData]): if ( ( content_item.type == PromptMessageContentType.IMAGE - and ModelFeature.VISION not in model_schema.features + and ModelFeature.VISION not in model_config.model_schema.features ) or ( content_item.type == PromptMessageContentType.DOCUMENT - and ModelFeature.DOCUMENT not in model_schema.features + and ModelFeature.DOCUMENT not in model_config.model_schema.features ) or ( content_item.type == PromptMessageContentType.VIDEO - and ModelFeature.VIDEO not in model_schema.features + and ModelFeature.VIDEO not in model_config.model_schema.features ) or ( content_item.type == PromptMessageContentType.AUDIO - and ModelFeature.AUDIO not in model_schema.features + and ModelFeature.AUDIO not in model_config.model_schema.features ) ): continue @@ -1478,7 +1390,19 @@ class LLMNode(Node[LLMNodeData]): "Please ensure a prompt is properly configured before proceeding." ) - return filtered_prompt_messages, stop + model = ModelManager().get_model_instance( + tenant_id=tenant_id, + model_type=ModelType.LLM, + provider=model_config.provider, + model=model_config.model, + ) + model_schema = model.model_type_instance.get_model_schema( + model=model_config.model, + credentials=model.credentials, + ) + if not model_schema: + raise ModelNotExistError(f"Model {model_config.model} not exist.") + return filtered_prompt_messages, model_config.stop @classmethod def _extract_variable_selector_to_variable_mapping( @@ -1488,15 +1412,14 @@ class LLMNode(Node[LLMNodeData]): node_id: str, node_data: LLMNodeData, ) -> Mapping[str, Sequence[str]]: - # graph_config is not used in this node type - _ = graph_config # Explicitly mark as unused - prompt_template = node_data.prompt_template + _ = graph_config + typed_node_data = node_data + + prompt_template = typed_node_data.prompt_template variable_selectors = [] prompt_context_selectors: list[Sequence[str]] = [] if isinstance(prompt_template, list): for item in prompt_template: - # Check PromptMessageContext first (same order as _parse_prompt_template) - # This extracts value_selector which is used by variable_pool.get(ctx_ref.value_selector) if isinstance(item, PromptMessageContext): if len(item.value_selector) >= 2: prompt_context_selectors.append(item.value_selector) @@ -1518,7 +1441,7 @@ class LLMNode(Node[LLMNodeData]): variable_key = f"#{'.'.join(context_selector)}#" variable_mapping[variable_key] = list(context_selector) - memory = node_data.memory + memory = typed_node_data.memory if memory and memory.query_prompt_template: query_variable_selectors = VariableTemplateParser( template=memory.query_prompt_template @@ -1526,16 +1449,16 @@ class LLMNode(Node[LLMNodeData]): for variable_selector in query_variable_selectors: variable_mapping[variable_selector.variable] = variable_selector.value_selector - if node_data.context.enabled: - variable_mapping["#context#"] = node_data.context.variable_selector + if typed_node_data.context.enabled: + variable_mapping["#context#"] = typed_node_data.context.variable_selector - if node_data.vision.enabled: - variable_mapping["#files#"] = node_data.vision.configs.variable_selector + if typed_node_data.vision.enabled: + variable_mapping["#files#"] = typed_node_data.vision.configs.variable_selector - if node_data.memory: + if typed_node_data.memory: variable_mapping["#sys.query#"] = ["sys", SystemVariableKey.QUERY] - if node_data.prompt_config: + if typed_node_data.prompt_config: enable_jinja = False if isinstance(prompt_template, list): @@ -1547,7 +1470,7 @@ class LLMNode(Node[LLMNodeData]): enable_jinja = True if enable_jinja: - for variable_selector in node_data.prompt_config.jinja2_variables or []: + for variable_selector in typed_node_data.prompt_config.jinja2_variables or []: variable_mapping[variable_selector.variable] = variable_selector.value_selector variable_mapping = {node_id + "." + key: value for key, value in variable_mapping.items()} @@ -1606,7 +1529,9 @@ class LLMNode(Node[LLMNodeData]): if bundle is not None: skill_entry = SkillDocumentAssembler(bundle).assemble_document( document=SkillDocument( - skill_id="anonymous", content=result_text, metadata=message.metadata or {} + skill_id="anonymous", + content=result_text, + metadata=SkillMetadata.model_validate(message.metadata or {}), ), base_path=AppAssets.PATH, ) @@ -1645,7 +1570,9 @@ class LLMNode(Node[LLMNodeData]): if plain_text and bundle is not None: skill_entry = SkillDocumentAssembler(bundle).assemble_document( document=SkillDocument( - skill_id="anonymous", content=plain_text, metadata=message.metadata or {} + skill_id="anonymous", + content=plain_text, + metadata=SkillMetadata.model_validate(message.metadata or {}), ), base_path=AppAssets.PATH, ) @@ -1680,25 +1607,19 @@ class LLMNode(Node[LLMNodeData]): ): buffer.write(text_part) - # Extract reasoning content from tags in the main text full_text = buffer.getvalue() if reasoning_format == "tagged": - # Keep tags in text for backward compatibility clean_text = full_text reasoning_content = "" else: - # Extract clean text and reasoning from tags clean_text, reasoning_content = LLMNode._split_reasoning(full_text, reasoning_format) event = ModelInvokeCompletedEvent( - # Use clean_text for separated mode, full_text for tagged mode text=clean_text if reasoning_format == "separated" else full_text, usage=invoke_result.usage, finish_reason=None, - # Reasoning content for workflow variables and downstream nodes reasoning_content=reasoning_content, - # Pass structured output if enabled structured_output=getattr(invoke_result, "structured_output", None), ) if request_latency is not None: @@ -1711,15 +1632,6 @@ class LLMNode(Node[LLMNodeData]): content: ImagePromptMessageContent, file_saver: LLMFileSaver, ) -> File: - """_save_multimodal_output saves multi-modal contents generated by LLM plugins. - - There are two kinds of multimodal outputs: - - - Inlined data encoded in base64, which would be saved to storage directly. - - Remote files referenced by an url, which would be downloaded and then saved to storage. - - Currently, only image files are supported. - """ if content.url != "": saved_file = file_saver.save_remote_url(content.url, FileType.IMAGE) else: @@ -1810,12 +1722,6 @@ class LLMNode(Node[LLMNodeData]): *, structured_output: Mapping[str, Any], ) -> dict[str, Any]: - """ - Fetch the structured output schema from the node data. - - Returns: - dict[str, Any]: The structured output schema - """ if not structured_output: raise LLMNodeError("Please provide a valid structured output schema") structured_output_schema = json.dumps(structured_output.get("schema", {}), ensure_ascii=False) @@ -1837,17 +1743,6 @@ class LLMNode(Node[LLMNodeData]): file_saver: LLMFileSaver, file_outputs: list[File], ) -> Generator[str, None, None]: - """Convert intermediate prompt messages into strings and yield them to the caller. - - If the messages contain non-textual content (e.g., multimedia like images or videos), - it will be saved separately, and the corresponding Markdown representation will - be yielded to the caller. - """ - - # NOTE(QuantumGhost): This function should yield results to the caller immediately - # whenever new content or partial content is available. Avoid any intermediate buffering - # of results. Additionally, do not yield empty strings; instead, yield from an empty list - # if necessary. if contents is None: yield from [] return @@ -1889,26 +1784,16 @@ class LLMNode(Node[LLMNodeData]): NodeEventBase, None, tuple[ - str, # clean_text: processed text for outputs["text"] - str, # reasoning_content: native model reasoning - str, # generation_reasoning_content: reasoning for generation field (from tags) - str, # generation_clean_content: clean text for generation field (always tag-free) + str, + str, + str, + str, LLMUsage, str | None, LLMStructuredOutput | None, LLMGenerationData | None, ], ]: - """Stream events and capture generator return value in one place. - - Uses generator delegation so _run stays concise while still emitting events. - - Returns two pairs of text fields because outputs["text"] and generation["content"] - may differ when reasoning_format is "tagged": - - clean_text / reasoning_content: for top-level outputs (may keep tags) - - generation_clean_content / generation_reasoning_content: for the generation field - (always tag-free, extracted via _split_reasoning with "separated" mode) - """ clean_text = "" reasoning_content = "" generation_reasoning_content = "" @@ -1928,7 +1813,6 @@ class LLMNode(Node[LLMNodeData]): break if completed: - # After completion we still drain to reach StopIteration.value continue match event: @@ -1950,7 +1834,6 @@ class LLMNode(Node[LLMNodeData]): generation_clean_content = clean_text if self.node_data.reasoning_format == "tagged": - # Keep tagged text for output; also extract reasoning for generation field generation_clean_content, generation_reasoning_content = LLMNode._split_reasoning( clean_text, reasoning_format="separated" ) @@ -1964,9 +1847,7 @@ class LLMNode(Node[LLMNodeData]): LLMStructuredOutput(structured_output=structured_raw) if structured_raw else None ) - from core.app.llm.quota import deduct_llm_quota - - deduct_llm_quota(tenant_id=self.tenant_id, model_instance=model_instance, usage=usage) + llm_utils.deduct_llm_quota(tenant_id=self.tenant_id, model_instance=model_instance, usage=usage) completed = True case LLMStructuredOutput(): @@ -1995,8 +1876,6 @@ class LLMNode(Node[LLMNodeData]): return {tool.tool_id(): tool for tool in tools} def _extract_tool_dependencies(self) -> ToolDependencies | None: - """Extract tool artifact from prompt template.""" - sandbox = self.graph_runtime_state.sandbox if not sandbox: raise LLMNodeError("Sandbox not found") @@ -2006,7 +1885,11 @@ class LLMNode(Node[LLMNodeData]): for prompt in self.node_data.prompt_template: if isinstance(prompt, LLMNodeChatModelMessage): skill_entry = SkillDocumentAssembler(bundle).assemble_document( - document=SkillDocument(skill_id="anonymous", content=prompt.text, metadata=prompt.metadata or {}), + document=SkillDocument( + skill_id="anonymous", + content=prompt.text, + metadata=SkillMetadata.model_validate(prompt.metadata or {}), + ), base_path=AppAssets.PATH, ) tool_deps_list.append(skill_entry.tools) @@ -2031,20 +1914,10 @@ class LLMNode(Node[LLMNodeData]): node_inputs: dict[str, Any], process_data: dict[str, Any], ) -> Generator[NodeEventBase, None, LLMGenerationData]: - """Invoke LLM with tools support (from Agent V2). - - Returns LLMGenerationData with text, reasoning_contents, tool_calls, usage, finish_reason, files - """ - # Get model features to determine strategy model_features = self._get_model_features(model_instance) - - # Prepare tool instances tool_instances = self._prepare_tool_instances(variable_pool) - - # Prepare prompt files (files that come from prompt variables, not vision files) prompt_files = self._extract_prompt_files(variable_pool) - # Use factory to create appropriate strategy strategy = StrategyFactory.create_strategy( model_features=model_features, model_instance=model_instance, @@ -2054,7 +1927,6 @@ class LLMNode(Node[LLMNodeData]): context=ExecutionContext(user_id=self.user_id, app_id=self.app_id, tenant_id=self.tenant_id), ) - # Run strategy outputs = strategy.run( prompt_messages=list(prompt_messages), model_parameters=self._node_data.model.completion_params, @@ -2076,7 +1948,6 @@ class LLMNode(Node[LLMNodeData]): ) -> Generator[NodeEventBase, None, LLMGenerationData]: result: LLMGenerationData | None = None - # FIXME(Mairuis): Async processing for bash session. with SandboxBashSession(sandbox=sandbox, node_id=self.id, tools=tool_dependencies) as session: prompt_files = self._extract_prompt_files(variable_pool) model_features = self._get_model_features(model_instance) @@ -2100,7 +1971,6 @@ class LLMNode(Node[LLMNodeData]): result = yield from self._process_tool_outputs(outputs) - # Auto-collect sandbox output/ files, deduplicate by id collected_files = session.collect_output_files() if collected_files: existing_ids = {f.id for f in self._file_outputs} @@ -2112,7 +1982,6 @@ class LLMNode(Node[LLMNodeData]): return result def _get_model_features(self, model_instance: ModelInstance) -> list[ModelFeature]: - """Get model schema to determine features.""" try: model_type_instance = model_instance.model_type_instance model_schema = model_type_instance.get_model_schema( @@ -2125,17 +1994,14 @@ class LLMNode(Node[LLMNodeData]): return [] def _prepare_tool_instances(self, variable_pool: VariablePool) -> list[Tool]: - """Prepare tool instances from configuration.""" tool_instances = [] if self._node_data.tools: for tool in self._node_data.tools: try: - # Process settings to extract the correct structure processed_settings = {} for key, value in tool.settings.items(): if isinstance(value, dict) and "value" in value and isinstance(value["value"], dict): - # Extract the nested value if it has the ToolInput structure if "type" in value["value"] and "value" in value["value"]: processed_settings[key] = value["value"] else: @@ -2143,10 +2009,8 @@ class LLMNode(Node[LLMNodeData]): else: processed_settings[key] = value - # Merge parameters with processed settings (similar to Agent Node logic) merged_parameters = {**tool.parameters, **processed_settings} - # Create AgentToolEntity from ToolMetadata agent_tool = AgentToolEntity( provider_id=tool.provider_name, provider_type=tool.type, @@ -2156,7 +2020,6 @@ class LLMNode(Node[LLMNodeData]): credential_id=tool.credential_id, ) - # Get tool runtime from ToolManager tool_runtime = ToolManager.get_agent_tool_runtime( tenant_id=self.tenant_id, app_id=self.app_id, @@ -2165,7 +2028,6 @@ class LLMNode(Node[LLMNodeData]): variable_pool=variable_pool, ) - # Apply custom description from extra field if available if tool.extra.get("description") and tool_runtime.entity.description: tool_runtime.entity.description.llm = ( tool.extra.get("description") or tool_runtime.entity.description.llm @@ -2179,12 +2041,10 @@ class LLMNode(Node[LLMNodeData]): return tool_instances def _extract_prompt_files(self, variable_pool: VariablePool) -> list[File]: - """Extract files from prompt template variables.""" - from dify_graph.variables import ArrayFileVariable, FileVariable + from dify_graph.variables.variables import ArrayFileVariable, FileVariable files: list[File] = [] - # Extract variables from prompt template if isinstance(self._node_data.prompt_template, list): for message in self._node_data.prompt_template: if message.text: @@ -2202,10 +2062,7 @@ class LLMNode(Node[LLMNodeData]): @staticmethod def _serialize_tool_call(tool_call: ToolCallResult) -> dict[str, Any]: - """Convert ToolCallResult into JSON-friendly dict.""" - def _file_to_ref(file: File) -> str | None: - # Align with streamed tool result events which carry file IDs return file.id or file.related_id files = [] @@ -2225,7 +2082,6 @@ class LLMNode(Node[LLMNodeData]): } def _generate_model_provider_icon_url(self, provider: str, dark: bool = False) -> str | None: - """Generate icon URL for model provider.""" from yarl import URL from configs import dify_config @@ -2247,8 +2103,6 @@ class LLMNode(Node[LLMNodeData]): return None def _emit_model_start(self, trace_state: TraceState) -> Generator[NodeEventBase, None, None]: - """Yield a MODEL_START event with model identity info at the beginning of a model turn. - Idempotent: only emits once per turn (guarded by trace_state.model_start_emitted).""" if trace_state.model_start_emitted: return trace_state.model_start_emitted = True @@ -2272,8 +2126,6 @@ class LLMNode(Node[LLMNodeData]): trace_state: TraceState, error: str | None = None, ) -> Generator[NodeEventBase, None, None]: - """Flush pending thought/content buffers into a single model trace segment - and yield a MODEL_END chunk event with usage/duration metrics.""" if not buffers.pending_thought and not buffers.pending_content and not buffers.pending_tool_calls: return @@ -2348,7 +2200,6 @@ class LLMNode(Node[LLMNodeData]): else: agent_context.agent_logs.append(agent_log_event) - # Handle THOUGHT log completion - capture usage for model segment if output.log_type == AgentLog.LogType.THOUGHT and output.status == AgentLog.LogStatus.SUCCESS: llm_usage = output.metadata.get(AgentLog.LogMetadata.LLM_USAGE) if output.metadata else None if llm_usage: @@ -2393,7 +2244,6 @@ class LLMNode(Node[LLMNodeData]): if tool_call_id and tool_call_id not in trace_state.tool_call_index_map: trace_state.tool_call_index_map[tool_call_id] = len(trace_state.tool_call_index_map) - # Flush model segment before tool result processing yield from self._flush_model_segment(buffers, trace_state) if output.status == AgentLog.LogStatus.ERROR: @@ -2434,7 +2284,6 @@ class LLMNode(Node[LLMNodeData]): if tool_call_id: trace_state.tool_trace_map[tool_call_id] = tool_call_segment - # Start new model segment tracking trace_state.model_segment_start_time = time.perf_counter() yield ToolResultChunkEvent( @@ -2562,12 +2411,9 @@ class LLMNode(Node[LLMNodeData]): if buffers.current_turn_reasoning: buffers.reasoning_per_turn.append("".join(buffers.current_turn_reasoning)) - # For final flush, use aggregate.usage if pending_usage is not set - # (e.g., for simple LLM calls without tool invocations) if trace_state.pending_usage is None: trace_state.pending_usage = aggregate.usage - # Flush final model segment yield from self._flush_model_segment(buffers, trace_state) def _close_streams(self) -> Generator[NodeEventBase, None, None]: @@ -2688,7 +2534,6 @@ class LLMNode(Node[LLMNodeData]): self, outputs: Generator[LLMResultChunk | AgentLog, None, AgentResult], ) -> Generator[NodeEventBase, None, LLMGenerationData]: - """Process strategy outputs and convert to node events.""" state = ToolOutputState() try: @@ -2715,7 +2560,6 @@ class LLMNode(Node[LLMNodeData]): return self._build_generation_data(state.trace, state.agent, state.aggregate, state.stream) def _accumulate_usage(self, total_usage: LLMUsage, delta_usage: LLMUsage) -> None: - """Accumulate LLM usage statistics.""" total_usage.prompt_tokens += delta_usage.prompt_tokens total_usage.completion_tokens += delta_usage.completion_tokens total_usage.total_tokens += delta_usage.total_tokens @@ -2723,10 +2567,6 @@ class LLMNode(Node[LLMNodeData]): total_usage.completion_price += delta_usage.completion_price total_usage.total_price += delta_usage.total_price - @property - def model_instance(self) -> ModelInstance: - return self._model_instance - def _combine_message_content_with_role( *, contents: str | list[PromptMessageContentUnionTypes] | None = None, role: PromptMessageRole @@ -2765,26 +2605,26 @@ def _render_jinja2_message( def _calculate_rest_token( - *, - prompt_messages: list[PromptMessage], - model_instance: ModelInstance, + *, prompt_messages: list[PromptMessage], model_config: ModelConfigWithCredentialsEntity ) -> int: rest_tokens = 2000 - runtime_model_schema = llm_utils.fetch_model_schema(model_instance=model_instance) - runtime_model_parameters = model_instance.parameters - model_context_tokens = runtime_model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE) + model_context_tokens = model_config.model_schema.model_properties.get(ModelPropertyKey.CONTEXT_SIZE) if model_context_tokens: + model_instance = ModelInstance( + provider_model_bundle=model_config.provider_model_bundle, model=model_config.model + ) + curr_message_tokens = model_instance.get_llm_num_tokens(prompt_messages) max_tokens = 0 - for parameter_rule in runtime_model_schema.parameter_rules: + for parameter_rule in model_config.model_schema.parameter_rules: if parameter_rule.name == "max_tokens" or ( parameter_rule.use_template and parameter_rule.use_template == "max_tokens" ): max_tokens = ( - runtime_model_parameters.get(parameter_rule.name) - or runtime_model_parameters.get(str(parameter_rule.use_template)) + model_config.parameters.get(parameter_rule.name) + or model_config.parameters.get(str(parameter_rule.use_template)) or 0 ) @@ -2798,14 +2638,11 @@ def _handle_memory_chat_mode( *, memory: BaseMemory | None, memory_config: MemoryConfig | None, - model_instance: ModelInstance, + model_config: ModelConfigWithCredentialsEntity, ) -> Sequence[PromptMessage]: memory_messages: Sequence[PromptMessage] = [] if memory and memory_config: - rest_tokens = _calculate_rest_token( - prompt_messages=[], - model_instance=model_instance, - ) + rest_tokens = _calculate_rest_token(prompt_messages=[], model_config=model_config) memory_messages = memory.get_history_prompt_messages( max_token_limit=rest_tokens, message_limit=memory_config.window.size if memory_config.window.enabled else None, @@ -2817,18 +2654,14 @@ def _handle_memory_completion_mode( *, memory: BaseMemory | None, memory_config: MemoryConfig | None, - model_instance: ModelInstance, + model_config: ModelConfigWithCredentialsEntity, ) -> str: memory_text = "" if memory and memory_config: - rest_tokens = _calculate_rest_token( - prompt_messages=[], - model_instance=model_instance, - ) + rest_tokens = _calculate_rest_token(prompt_messages=[], model_config=model_config) if not memory_config.role_prefix: raise MemoryRolePrefixRequiredError("Memory role prefix is required for completion model.") - memory_text = llm_utils.fetch_memory_text( - memory=memory, + memory_text = memory.get_history_prompt_text( max_token_limit=rest_tokens, message_limit=memory_config.window.size if memory_config.window.enabled else None, human_prefix=memory_config.role_prefix.user, diff --git a/api/dify_graph/nodes/question_classifier/question_classifier_node.py b/api/dify_graph/nodes/question_classifier/question_classifier_node.py index 1b815a872e..1a9e6a4ca1 100644 --- a/api/dify_graph/nodes/question_classifier/question_classifier_node.py +++ b/api/dify_graph/nodes/question_classifier/question_classifier_node.py @@ -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, diff --git a/api/dify_graph/nodes/tool/tool_node.py b/api/dify_graph/nodes/tool/tool_node.py index 8e583194cb..a93533b960 100644 --- a/api/dify_graph/nodes/tool/tool_node.py +++ b/api/dify_graph/nodes/tool/tool_node.py @@ -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", []) diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index e3aba840de..fe95cc5816 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -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, diff --git a/api/pyrightconfig.json b/api/pyrightconfig.json index 48271aab61..ada3b1939d 100644 --- a/api/pyrightconfig.json +++ b/api/pyrightconfig.json @@ -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", diff --git a/api/services/sandbox/sandbox_provider_service.py b/api/services/sandbox/sandbox_provider_service.py index 387f0a8f14..f18a3f40ae 100644 --- a/api/services/sandbox/sandbox_provider_service.py +++ b/api/services/sandbox/sandbox_provider_service.py @@ -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, diff --git a/web/app/components/workflow/block-icon.tsx b/web/app/components/workflow/block-icon.tsx index 5e1a3a648b..679e90e12d 100644 --- a/web/app/components/workflow/block-icon.tsx +++ b/web/app/components/workflow/block-icon.tsx @@ -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.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', diff --git a/web/app/components/workflow/custom-edge.tsx b/web/app/components/workflow/custom-edge.tsx index f727d1fa0d..8cb3b121c2 100644 --- a/web/app/components/workflow/custom-edge.tsx +++ b/web/app/components/workflow/custom-edge.tsx @@ -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 && ( diff --git a/web/app/components/workflow/custom-group-node/constants.ts b/web/app/components/workflow/custom-group-node/constants.ts deleted file mode 100644 index 5b65aaa80b..0000000000 --- a/web/app/components/workflow/custom-group-node/constants.ts +++ /dev/null @@ -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, -]) diff --git a/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx deleted file mode 100644 index 969cf69935..0000000000 --- a/web/app/components/workflow/custom-group-node/custom-group-exit-port-node.tsx +++ /dev/null @@ -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 = ({ id: _id, data }) => { - return ( -
- {/* Target handle - receives internal connections from leaf nodes */} - - - {/* Source handle - connects to external nodes */} - - - {/* Icon */} - - - -
- ) -} - -export default memo(CustomGroupExitPortNode) diff --git a/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx deleted file mode 100644 index 3476b3d154..0000000000 --- a/web/app/components/workflow/custom-group-node/custom-group-input-node.tsx +++ /dev/null @@ -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 = ({ id: _id, data }) => { - return ( -
- {/* Target handle - receives external connections */} - - - {/* Source handle - connects to entry nodes */} - - - {/* Icon */} - - - - -
- ) -} - -export default memo(CustomGroupInputNode) diff --git a/web/app/components/workflow/custom-group-node/custom-group-node.tsx b/web/app/components/workflow/custom-group-node/custom-group-node.tsx deleted file mode 100644 index c51418a5de..0000000000 --- a/web/app/components/workflow/custom-group-node/custom-group-node.tsx +++ /dev/null @@ -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 = ({ data }) => { - const { group } = data - const exitPorts = group.exitPorts ?? [] - const connectedSourceHandleIds = data._connectedSourceHandleIds ?? [] - - return ( -
- {/* Group Header */} -
- - {group.title} - -
- - {/* Target handle for incoming connections */} - - -
- {exitPorts.map((port, index) => { - const connected = connectedSourceHandleIds.includes(port.portNodeId) - - return ( -
-
- {port.name} -
- - - - {/* Visual "+" indicator (styling aligned with existing branch handles) */} - -
- ) - })} -
-
- ) -} - -export default memo(CustomGroupNode) diff --git a/web/app/components/workflow/custom-group-node/index.ts b/web/app/components/workflow/custom-group-node/index.ts deleted file mode 100644 index af8fa042e8..0000000000 --- a/web/app/components/workflow/custom-group-node/index.ts +++ /dev/null @@ -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' diff --git a/web/app/components/workflow/custom-group-node/types.ts b/web/app/components/workflow/custom-group-node/types.ts deleted file mode 100644 index baf7b2362a..0000000000 --- a/web/app/components/workflow/custom-group-node/types.ts +++ /dev/null @@ -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 -} diff --git a/web/app/components/workflow/hooks/use-edges-interactions.ts b/web/app/components/workflow/hooks/use-edges-interactions.ts index aa342c1f17..f83bfc021f 100644 --- a/web/app/components/workflow/hooks/use-edges-interactions.ts +++ b/web/app/components/workflow/hooks/use-edges-interactions.ts @@ -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 = 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) } }) diff --git a/web/app/components/workflow/hooks/use-make-group.ts b/web/app/components/workflow/hooks/use-make-group.ts deleted file mode 100644 index 321f0e393a..0000000000 --- a/web/app/components/workflow/hooks/use-make-group.ts +++ /dev/null @@ -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() - const incomingEdgeCounts = new Map() - const incomingFromSelectedTargets = new Set() - - 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]) -} diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index 7f0639e647..fe6e422705 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -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 - 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 - 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 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() - 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() - - 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, } } diff --git a/web/app/components/workflow/hooks/use-nodes-meta-data.ts b/web/app/components/workflow/hooks/use-nodes-meta-data.ts index 36c071f4d4..2ea2fd9e9f 100644 --- a/web/app/components/workflow/hooks/use-nodes-meta-data.ts +++ b/web/app/components/workflow/hooks/use-nodes-meta-data.ts @@ -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 { diff --git a/web/app/components/workflow/hooks/use-shortcuts.ts b/web/app/components/workflow/hooks/use-shortcuts.ts index 7f91d3ab1e..1de892d8e3 100644 --- a/web/app/components/workflow/hooks/use-shortcuts.ts +++ b/web/app/components/workflow/hooks/use-shortcuts.ts @@ -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() diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index d175db232d..5e4bf09296 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -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()) => { diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index ea0c29aaf0..d8e5aa4caa 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -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, diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx index c095f7fcb3..b460aa651c 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -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 (
- { - !nodesReadOnly && data.type === BlockEnum.Group && ( - <> -
-
{ - onClosePopup() - handleUngroup(id) - }} - > - {t('panel.ungroup', { ns: 'workflow' })} - -
-
-
- - ) - } { (showChangeBlock || canRunBySingle(data.type, isChildNode)) && ( <> diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx index 43b954027f..d0f5c43e24 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx @@ -651,7 +651,7 @@ const BasePanel: FC = ({ ) } { - !needsToolAuth && !currentDataSource && !currentTriggerPlugin && data.type !== BlockEnum.Group && ( + !needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
= ({
) } - {data.type !== BlockEnum.Group && } +
- {(tabType === TabType.settings || data.type === BlockEnum.Group) && ( + {tabType === TabType.settings && (
{cloneElement(children as any, { diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts index f168f797cb..80f3683fcc 100644 --- a/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts +++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/last-run/use-last-run.ts @@ -67,7 +67,6 @@ const singleRunFormParamsHooks: Record = { [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.DataSource]: undefined, [BlockEnum.DataSourceEmpty]: undefined, [BlockEnum.KnowledgeBase]: undefined, - [BlockEnum.Group]: undefined, [BlockEnum.TriggerWebhook]: undefined, [BlockEnum.TriggerSchedule]: undefined, [BlockEnum.TriggerPlugin]: useTriggerPluginGetDataForCheckMore, diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 89c97be06c..5b655e0796 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -299,7 +299,7 @@ const BaseNode: FC = ({ ) } { - 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 && ( > = { [BlockEnum.TriggerPlugin]: TriggerPluginNode, [BlockEnum.Command]: CommandNode, [BlockEnum.FileUpload]: FileUploadNode, - [BlockEnum.Group]: GroupNode, } export const PanelComponentMap: Record> = { @@ -118,5 +115,4 @@ export const PanelComponentMap: Record> = { [BlockEnum.TriggerPlugin]: TriggerPluginPanel, [BlockEnum.Command]: CommandPanel, [BlockEnum.FileUpload]: FileUploadPanel, - [BlockEnum.Group]: GroupPanel, } diff --git a/web/app/components/workflow/nodes/group/default.ts b/web/app/components/workflow/nodes/group/default.ts deleted file mode 100644 index b46d3544b6..0000000000 --- a/web/app/components/workflow/nodes/group/default.ts +++ /dev/null @@ -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 = { - metaData, - defaultValue: { - members: [], - handlers: [], - headNodeIds: [], - leafNodeIds: [], - }, - checkValid() { - return { - isValid: true, - } - }, -} - -export default nodeDefault diff --git a/web/app/components/workflow/nodes/group/node.tsx b/web/app/components/workflow/nodes/group/node.tsx deleted file mode 100644 index a42515bc10..0000000000 --- a/web/app/components/workflow/nodes/group/node.tsx +++ /dev/null @@ -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) => { - 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 ( -
- {members.length > 0 && ( -
-
- {members.slice(0, MAX_MEMBER_ICONS).map(member => ( -
- -
- ))} - {members.length > MAX_MEMBER_ICONS && ( -
- + - {members.length - MAX_MEMBER_ICONS} -
- )} -
- -
- )} - {handlers.length > 0 && ( -
- {handlers.map(handler => ( -
- {handler.label || handler.id} - -
- ))} -
- )} -
- ) -} - -GroupNode.displayName = 'GroupNode' - -export default memo(GroupNode) diff --git a/web/app/components/workflow/nodes/group/panel.tsx b/web/app/components/workflow/nodes/group/panel.tsx deleted file mode 100644 index a36d074e9d..0000000000 --- a/web/app/components/workflow/nodes/group/panel.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { memo } from 'react' - -const GroupPanel = () => { - return null -} - -GroupPanel.displayName = 'GroupPanel' - -export default memo(GroupPanel) diff --git a/web/app/components/workflow/nodes/group/types.ts b/web/app/components/workflow/nodes/group/types.ts deleted file mode 100644 index 5f16b0e981..0000000000 --- a/web/app/components/workflow/nodes/group/types.ts +++ /dev/null @@ -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 -}> diff --git a/web/app/components/workflow/nodes/llm/components/tools/max-iterations.tsx b/web/app/components/workflow/nodes/llm/components/tools/max-iterations.tsx index 65ae3246a5..2354f5fcf8 100644 --- a/web/app/components/workflow/nodes/llm/components/tools/max-iterations.tsx +++ b/web/app/components/workflow/nodes/llm/components/tools/max-iterations.tsx @@ -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" />
- {})} + onValueChange={v => (onChange ?? (() => {}))(v ?? 1)} min={1} step={1} disabled={disabled} - /> + > + + + + + + + +
) } diff --git a/web/app/components/workflow/selection-contextmenu.tsx b/web/app/components/workflow/selection-contextmenu.tsx index 849fcffb25..5b0c68fe5d 100644 --- a/web/app/components/workflow/selection-contextmenu.tsx +++ b/web/app/components/workflow/selection-contextmenu.tsx @@ -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 = () => {
{!nodesReadOnly && ( <> -
-
{ - if (!canMakeGroup) - return - handleMakeGroup() - handleSelectionContextmenuCancel() - }} - > - {t('operator.makeGroup', { ns: 'workflow' })} - -
-
-
= { _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 diff --git a/web/app/components/workflow/utils/workflow-init.ts b/web/app/components/workflow/utils/workflow-init.ts index b6c29402d1..4143b031f5 100644 --- a/web/app/components/workflow/utils/workflow-init.ts +++ b/web/app/components/workflow/utils/workflow-init.ts @@ -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() - - 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] || [] diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index e5049069d6..e39d21d6ae 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -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", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 37ffa13b42..4d3b42c9fe 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -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": "重置",